mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
317 lines
11 KiB
Lua
317 lines
11 KiB
Lua
-- Yellow follower cadence (#424): the ledge hop deferred a player step, the
|
|
-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the
|
|
-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/
|
|
-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43,
|
|
-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED:
|
|
-- it scales the logic clock alone and every number judged here is a frame count.
|
|
-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local PikachuFollower = require("src.world.PikachuFollower")
|
|
|
|
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
|
|
local results = {}
|
|
local function check(label, ok)
|
|
results[#results + 1] = { label = label, ok = ok and true or false }
|
|
return ok
|
|
end
|
|
local function report()
|
|
for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end
|
|
end
|
|
local function idleForever()
|
|
while true do coroutine.yield() end
|
|
end
|
|
|
|
if not GameVersion.isYellow() then
|
|
check("running the Yellow cache (POKEPORT_VERSION=yellow)", false)
|
|
report()
|
|
U.log("Red and Blue have no follower, no idle rolls and no pikapic box.")
|
|
idleForever()
|
|
end
|
|
|
|
-- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift
|
|
-- happened and a healthy Pikachu leads the party. Without both there is no
|
|
-- follower and every count below would read as the bug.
|
|
game.save.flags = game.save.flags or {}
|
|
game.save.flags.EVENT_GOT_STARTER = true
|
|
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
|
game.save.onBike = false
|
|
|
|
local frames = 0
|
|
local function step(n)
|
|
for _ = 1, n do
|
|
coroutine.yield()
|
|
frames = frames + 1
|
|
end
|
|
end
|
|
|
|
-- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the
|
|
-- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell
|
|
-- to hop from, re-derived below out of the cached blocks against
|
|
-- data.field.ledges so a map edit moves the test instead of breaking it.
|
|
local MAP = "ROUTE_1"
|
|
local WANT = { x = 5, y = 4 }
|
|
|
|
local function southLedge(map, cx, cy)
|
|
if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end
|
|
-- the landing plus one more open cell below it: the queued hop only drains
|
|
-- on the player's next step, so the ledge needs room for that step
|
|
if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1)
|
|
and map:isWalkableCell(cx, cy + 2)
|
|
and map:isWalkableCell(cx, cy + 3)) then
|
|
return false
|
|
end
|
|
local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1)
|
|
for _, l in ipairs(game.data.field.ledges or {}) do
|
|
if (l.tileset or "OVERWORLD") == map.def.tileset
|
|
and l.facing == "down" and l.input == "down"
|
|
and l.standingTile == standing and l.ledgeTile == front then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
U.teleport(game, MAP, WANT.x, WANT.y - 1, "down")
|
|
U.wait(20)
|
|
|
|
local ow = game.overworld
|
|
check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
|
if not ow then
|
|
report()
|
|
idleForever()
|
|
end
|
|
|
|
local hx, hy = WANT.x, WANT.y
|
|
if not southLedge(ow.map, hx, hy) then
|
|
for cy = 1, ow.map.heightCells - 4 do
|
|
for cx = 0, ow.map.widthCells - 1 do
|
|
if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end
|
|
end
|
|
if hx ~= WANT.x or hy ~= WANT.y then break end
|
|
end
|
|
U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y),
|
|
hx, hy)
|
|
U.teleport(game, MAP, hx, hy - 1, "down")
|
|
U.wait(20)
|
|
ow = game.overworld
|
|
end
|
|
check(("a south ledge to hop at (%d, %d)"):format(hx, hy),
|
|
southLedge(ow.map, hx, hy))
|
|
|
|
local function follower()
|
|
for _, n in ipairs(ow.npcs or {}) do
|
|
if n.pikachuFollower then return n end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local npc = follower()
|
|
if not check("the follower spawned", npc ~= nil) then
|
|
report()
|
|
idleForever()
|
|
end
|
|
|
|
-- ---- the standing idle clock ----
|
|
-- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and
|
|
-- a standing OverworldLoop burns two DelayFrames per pass, so one counter
|
|
-- unit is two of this port's fixed steps: the glance lands on frame 64.
|
|
local function glanceLeft()
|
|
local idle = npc.idle
|
|
if idle and idle.kind == "wait" then return idle.frames end
|
|
return nil
|
|
end
|
|
|
|
for _ = 1, 90 do
|
|
if glanceLeft() then break end
|
|
step(1)
|
|
end
|
|
if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then
|
|
report()
|
|
idleForever()
|
|
end
|
|
|
|
local last, lastAt = glanceLeft(), frames
|
|
-- the first change lands a partial unit after the sampling started, so it
|
|
-- only sets the baseline: every gap measured after it is a whole unit
|
|
local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil
|
|
for _ = 1, 300 do
|
|
step(1)
|
|
local now = glanceLeft()
|
|
if now == nil then break end
|
|
if now ~= last then
|
|
seen = seen + 1
|
|
local gap = frames - lastAt
|
|
if seen > 1 then
|
|
if gap < minGap then minGap = gap end
|
|
if gap > maxGap then maxGap = gap end
|
|
end
|
|
if now > last then
|
|
if resetAt then period = period or frames - resetAt end
|
|
resetAt = frames
|
|
end
|
|
last, lastAt = now, frames
|
|
end
|
|
end
|
|
check("the idle counter loses one unit every two frames, not every frame",
|
|
minGap == 2 and maxGap == 2)
|
|
check("a full glance takes 64 frames ($20 units), not 32", period == 64)
|
|
U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period",
|
|
tostring(period))
|
|
|
|
-- ---- the ledge hop, one player step late ----
|
|
-- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop
|
|
-- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop
|
|
-- a command with nothing queued behind it, so the hop waits for the player's
|
|
-- next step and Pikachu sits two cells back until then.
|
|
local sawHop, hopLen = false, nil
|
|
local function sample()
|
|
local f = follower()
|
|
if f and f.hopStep then
|
|
sawHop = true
|
|
hopLen = hopLen or f.stepFrames
|
|
end
|
|
end
|
|
|
|
for _ = 1, 200 do
|
|
table.insert(game.input.pressQueue, "down")
|
|
game.input.state["down"] = true
|
|
step(1)
|
|
sample()
|
|
if (ow.player.hopFrames or 0) > 0 then break end
|
|
end
|
|
game.input.state["down"] = false
|
|
check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0)
|
|
|
|
for _ = 1, 150 do
|
|
step(1)
|
|
sample()
|
|
end
|
|
npc = follower()
|
|
check("the follower waits on the cell the player took off from",
|
|
npc ~= nil and npc.cellX == hx and npc.cellY == hy)
|
|
check("it does not hop while the player stands still", not sawHop)
|
|
if npc then
|
|
U.log("player at", ow.player.cellX, ow.player.cellY,
|
|
"| follower waiting at", npc.cellX, npc.cellY)
|
|
end
|
|
|
|
-- one more player step drains the buffered hop
|
|
for _ = 1, 120 do
|
|
table.insert(game.input.pressQueue, "down")
|
|
game.input.state["down"] = true
|
|
step(1)
|
|
sample()
|
|
if ow.player.cellY > hy + 2 then break end
|
|
end
|
|
game.input.state["down"] = false
|
|
for _ = 1, 120 do
|
|
step(1)
|
|
sample()
|
|
end
|
|
|
|
check("the deferred hop then ran", sawHop)
|
|
-- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks
|
|
-- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a
|
|
-- Fast step even though its goal is two cells off
|
|
local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16
|
|
check("the hop takes a full step's frames, not a halved Fast step",
|
|
hopLen == walkLen)
|
|
U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen)
|
|
npc = follower()
|
|
check("the follower cleared the ledge row",
|
|
npc ~= nil and npc.cellY > hy + 1
|
|
and ow.map:isWalkableCell(npc.cellX, npc.cellY))
|
|
|
|
-- ---- the pikapic beat ----
|
|
-- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart
|
|
-- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second
|
|
-- full-body pose up for 24 of every 40 ticks).
|
|
game.save.pikachuHappiness = 255
|
|
game.save.pikachuMood = 200
|
|
|
|
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
|
{ "left", -1, 0 }, { "right", 1, 0 } }
|
|
local function facingFollower()
|
|
local fx, fy = ow.player:facingCell()
|
|
return follower() ~= nil and ow:npcAtCell(fx, fy) == follower()
|
|
end
|
|
if not facingFollower() then
|
|
for _, d in ipairs(DIRS) do
|
|
if npc and npc.cellX == ow.player.cellX + d[2]
|
|
and npc.cellY == ow.player.cellY + d[3] then
|
|
U.tap(game, d[1])
|
|
U.wait(6)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
check("player is facing the follower", facingFollower())
|
|
|
|
-- a muted run sounds like a beat that never started, so say so up front
|
|
local sfxVol = game.save.options and game.save.options.sfxVol
|
|
U.log("save.options.sfxVol:", tostring(sfxVol))
|
|
if (sfxVol or 0) == 0 then
|
|
U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not")
|
|
U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.")
|
|
end
|
|
|
|
U.tap(game, "a")
|
|
U.wait(1)
|
|
local emote = ow.emote
|
|
if not check("the A press raised the framed pikapic", emote ~= nil) then
|
|
report()
|
|
idleForever()
|
|
end
|
|
check("the beat runs the script's 50 ticks at three frames each",
|
|
emote.pikaTotal == 150)
|
|
check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50)
|
|
check("the pikapic marks itself skippable", emote.skippable == true)
|
|
check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table")
|
|
|
|
local lifted, grounded, liftShot = 0, 0, nil
|
|
for _ = 1, 60 do
|
|
if not ow.emote then break end
|
|
local lift = PikachuFollower.picLift(ow.emote)
|
|
if lift > 0 then
|
|
lifted = lifted + 1
|
|
if not liftShot then
|
|
liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png"
|
|
if U.shot(game, liftShot) then U.log("captured", liftShot)
|
|
else liftShot = nil end
|
|
end
|
|
else
|
|
grounded = grounded + 1
|
|
end
|
|
step(1)
|
|
end
|
|
check("the pic sits on the box floor for part of the beat", grounded > 0)
|
|
check("and rises off it for another part", lifted > 0)
|
|
U.log("of the first 60 frames the pic was up for", lifted, "and down for",
|
|
grounded)
|
|
|
|
local leftWhenCut = ow.emote and ow.emote.frames or 0
|
|
U.tap(game, "a")
|
|
U.wait(2)
|
|
check("A cuts the beat short (PikaPicAnimTimerAndJoypad)",
|
|
leftWhenCut > 0 and ow.emote == nil)
|
|
U.log("cut with", leftWhenCut, "frames still on the clock")
|
|
|
|
report()
|
|
|
|
-- hand the pad back one cell above the same ledge so Down replays the hop
|
|
U.teleport(game, MAP, hx, hy - 1, "down")
|
|
U.wait(20)
|
|
|
|
U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,")
|
|
U.log("then clear both cells in one motion on your next step, at walking")
|
|
U.log("speed. Let go and count the first glance: about a second, not half.")
|
|
U.log("Face it, press A: the framed pic hops inside the box for the whole")
|
|
U.log("beat, and A or B cuts the beat off early.")
|
|
|
|
idleForever()
|
|
end
|