Files
gen1recomp/tests/drivers/wrap_anim_bug1653_test.lua
T
bryanthaboi 1905261c5b Spider Car Unleashed
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
2026-08-24 07:52:05 -04:00

405 lines
16 KiB
Lua

-- Driver: Wrap's animation length and the blank tile-load beat that opens every
-- subanimation row (#1653). PlayAnimation calls LoadMoveAnimationTiles once per
-- row (engine/battle/animations.asm:252) and CopyVideoData blocks c/8 + 1 frames
-- (home/copy2.asm:62), so WrapAnim's three rows (data/moves/animations.asm:401)
-- cost 3 x 10 blank frames on top of the 30 the port already played.
-- No POKEPORT_SPEED: it scales only the logic clock while audio runs real-time,
-- which desyncs the sfx-against-pulse ordering this driver exists to judge.
-- POKEPORT_DRIVER=tests/drivers/wrap_anim_bug1653_test.lua POKEPORT_VERSION=red POKEPORT_TOUCH=0 POKEPORT_IDENTITY=bug1653 SHOT_DIR=/tmp/wrap love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local AnimPlayer = require("src.battle.AnimPlayer")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- ---- what the eye and ear cannot check ---------------------------------
U.log("#1653 Wrap animation length: machine checks")
local ba = game.data.battle_anims
check("battle_anims is in the cache", ba ~= nil)
local anims = (ba or {}).moveAnims or {}
check("WRAP is in the move table", game.data.moves.WRAP ~= nil)
check("WRAP has an animation", anims.WRAP ~= nil)
-- anim_tileset 79 / 79 / 64 (engine/battle/animations.asm:383): 79 tiles is
-- nine 8-tile chunks plus the tail frame, so tileset 0 blocks 10 frames
local sheet0 = (ba or {}).tilesheets and ba.tilesheets[0]
check(("tileset 0 declares %s tiles (anim_tileset 79)")
:format(tostring(sheet0 and sheet0.tiles)),
sheet0 ~= nil and sheet0.tiles == 79)
-- WrapAnim is three identical rows: SUBANIM_0_BIND on tileset 0, delay 4
local seq = (anims.WRAP or {}).seq or {}
check(("WRAP is %d subanimation rows (want 3)"):format(#seq), #seq == 3)
local rowsOk = #seq == 3
for _, row in ipairs(seq) do
rowsOk = rowsOk and row.effect == nil and row.tileset == 0
and row.delay == 4 and row.sound == "WRAP"
end
check("every WRAP row is a tileset-0 subanimation with sound WRAP", rowsOk)
-- The move id in the row resolves to a real sfx program through moves.lua
local sfxName = (game.data.moves.WRAP or {}).anim
and game.data.moves.WRAP.anim.sound
check("WRAP's row sound resolves to a program (" .. tostring(sfxName) .. ")",
sfxName ~= nil and ((game.data.audio or {}).sfx or {})[sfxName] ~= nil)
-- ---- the compiled timeline ---------------------------------------------
local function compile(move)
if not (ba and anims[move]) then return nil end
local p = AnimPlayer.new(ba)
local ok = pcall(p.start, p, move, false)
if not ok then return nil end
return p
end
local function total(p)
local n = 0
for _, s in ipairs(p.steps) do n = n + s.dur end
return n
end
local function shapeOf(p)
local out = {}
for _, s in ipairs(p.steps) do
out[#out + 1] = ("%d/%d"):format(s.dur, #s.sprites)
end
return table.concat(out, " ")
end
-- which step is on screen at `elapsed`, mirroring AnimPlayer:update
local function stepAt(p, elapsed)
local acc = 0
for _, s in ipairs(p.steps) do
acc = acc + s.dur
if elapsed < acc then return s end
end
return nil
end
local wrap = compile("WRAP")
check("WRAP compiles into a timeline", wrap ~= nil)
if wrap then
U.log(" WRAP steps: " .. shapeOf(wrap))
check(("WRAP is %d frames long (want 60, half-length bug gives 30)")
:format(total(wrap)), total(wrap) == 60)
check(("WRAP compiles to %d steps (want 9: three rows of load + 2 blocks)")
:format(#wrap.steps), #wrap.steps == 9)
local shapeOk = #wrap.steps == 9
for i = 1, 9 do
local s = wrap.steps[i]
if not s then shapeOk = false break end
if i % 3 == 1 then
shapeOk = shapeOk and s.dur == 10 and #s.sprites == 0
else
shapeOk = shapeOk and s.dur == 5 and #s.sprites == 8
end
end
check("steps 1, 4 and 7 are 10 blank frames and the other six are 5 frames "
.. "of 8 sprites", shapeOk)
-- Near-miss guard. Paying the 10 frames AFTER a row's blocks also totals
-- 60, but it opens on sprites and ends on a blank tail, and every sound
-- lands 10 frames early.
local first, last = wrap.steps[1], wrap.steps[#wrap.steps]
check("the blank beat OPENS the animation (step 1 is blank, 10 frames)",
first ~= nil and #first.sprites == 0 and first.dur == 10)
check("...and does not close it (the last step draws sprites)",
last ~= nil and #last.sprites > 0)
local sounds = {}
for _, ev in ipairs(wrap.events) do
if ev.sound then sounds[#sounds + 1] = ev end
end
check(("WRAP queues %d row sounds (want 3)"):format(#sounds), #sounds == 3)
local want = { 10, 30, 50 }
for i = 1, 3 do
local ev = sounds[i]
check(("sound %d fires at frame %s (want %d, after that row's tile load)")
:format(i, ev and tostring(ev.frame) or "nil", want[i]),
ev ~= nil and ev.frame == want[i] and ev.sound == "WRAP")
if ev then
local st = stepAt(wrap, ev.frame)
check(("sound %d lands on a frame with sprites on screen, not on the "
.. "blank beat"):format(i),
st ~= nil and #st.sprites > 0)
end
end
end
-- ---- the same load, elsewhere: this was never Wrap-specific ------------
-- POUND is one tileset-0 row, so it gains the same 10 frames.
local pound = compile("POUND")
if pound then
local s = pound.steps[1]
check(("POUND also opens on a 10-frame blank load (%s)")
:format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"),
s ~= nil and s.dur == 10 and #s.sprites == 0)
end
-- Tileset 2 declares 64 tiles, which is eight chunks plus the tail: 9 frames.
local ball = compile("TRADE_BALL_DROP_ANIM")
if ball then
local s = ball.steps[1]
check(("a tileset-2 animation opens on 9 blank frames instead of 10 (%s)")
:format(s and ("%d/%d"):format(s.dur, #s.sprites) or "nil"),
s ~= nil and s.dur == 9 and #s.sprites == 0)
end
-- Control: TACKLE's animation is two special-effect rows and no subanimation,
-- so PlayAnimation never reaches LoadMoveAnimationTiles and nothing changes.
local tackle = compile("TACKLE")
if tackle then
check(("TACKLE, which has no subanimation row, is still %d frames "
.. "(the load is not charged blindly)"):format(total(tackle)),
total(tackle) == 6)
end
-- ---- options that would fake a failure ---------------------------------
local opts = game.save.options or {}
check("battle animations are on in OPTION", opts.animations ~= false)
if opts.animations == false then
U.log(" ANIMATION is off, which skips the whole queued row: turn it on")
U.log(" in OPTION before judging anything below")
end
local vol = opts.sfxVol
if vol == 0 then
U.log(" sfxVol is 0: all three WRAP sfx will be SILENT, which sounds")
U.log(" exactly like the bug. Raise SFX in OPTION before listening")
else
U.log(" sfxVol " .. tostring(vol) .. ", so the three squeezes should be audible")
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
if fail > 0 then
U.log("something above says FAIL, so do not spend time watching the screen")
end
if not game.data.moves.WRAP then
U.log("no WRAP in the move table, so there is nothing to put on screen")
while true do coroutine.yield() end
end
-- ---- put a Wrap on screen ----------------------------------------------
-- data/generated/maps.lua ROUTE_1 is 10x18 blocks of open route, and pokered
-- data/maps/objects/Route1.asm parks its two youngsters at (5, 24) and
-- (15, 13), so the north end is empty. (5, 5) sits in the fence line in the
-- current cache, so the neighbour fallback below is the one that runs; the
-- battle is pushed straight in either way.
local MAP, SX, SY = "ROUTE_1", 5, 5
local ekans = Pokemon.new(game.data, "EKANS", 30)
ekans.moves = { { id = "WRAP", pp = game.data.moves.WRAP.pp } }
game.save.party = { ekans }
game.save.player.name = "RED"
U.teleport(game, MAP, SX, SY, "down")
U.wait(12)
local ow = game.overworld
if ow and not ow.map:isWalkableCell(SX, SY) then
-- a map edit blocked the cell: take the nearest free neighbour instead
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
local cx, cy = SX + d[1], SY + d[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d, %d) is blocked, standing on %d, %d"):format(SX, SY, cx, cy))
U.teleport(game, MAP, cx, cy, "down")
U.wait(12)
ow = game.overworld
break
end
end
end
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
local battle = BattleState.newWild(game, "PIDGEY", 20)
battle.onFinish = function() end
ow:pushBattle(battle)
for _ = 1, 400 do
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
U.wait(1)
end
check("the battle reached the screen", game.stack:top() == battle)
-- a foe that survives the wrap chain, so the animation can repeat
battle.enemy.mon.stats.hp = 500
battle.enemy.mon.hp = 500
battle.enemy.shownHP = 500
-- a wild PIDGEY that knows WHIRLWIND ends the fight the moment it picks it
for i = #battle.enemy.mon.moves, 1, -1 do
local id = battle.enemy.mon.moves[i].id
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
table.remove(battle.enemy.mon.moves, i)
end
end
-- ---- watch the live playback -------------------------------------------
-- every frame the player is playing, record whether sprites are on screen,
-- so the blank load beats show up as runs in the trace
local runs, cur = {}, nil
if battle.animPlayer then
local realStart = battle.animPlayer.start
battle.animPlayer.start = function(self, moveId, isPlayer, o)
local r = realStart(self, moveId, isPlayer, o)
cur = { move = moveId, marks = {} }
runs[#runs + 1] = cur
return r
end
end
-- U.shot spins frames of its own, which would punch a hole in the trace, so
-- the measuring turn runs with `shooting` off and a later turn takes the shot
local shooting, shot = false, false
local function sample()
if not (battle.animPlaying and cur and battle.animPlayer) then return end
local ap = battle.animPlayer
local st = ap.steps[ap.stepIndex]
if not st then return end
local lit = #st.sprites > 0
cur.marks[#cur.marks + 1] = lit and 1 or 0
if shooting and not shot and lit and cur.move == "WRAP" then
shot = true
U.shot(game, DIR .. "/bug1653_wrap_squeeze.png")
end
end
local function runLengths(marks)
local out, run, val = {}, 0, nil
for _, m in ipairs(marks) do
if m ~= val then
if val ~= nil then
out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run)
end
val, run = m, 0
end
run = run + 1
end
if val ~= nil then
out[#out + 1] = ("%s %d"):format(val == 1 and "sprites" or "blank", run)
end
return table.concat(out, ", ")
end
-- step n frames, sampling each one, pressing A only while a box is up: an A
-- on the FIGHT menu opens the move list and fires a move behind our back
local function pump(n, mash, stop)
for i = 1, n do
if mash and i % mash == 0 and battle.phase == "messages" then
table.insert(game.input.pressQueue, "a")
end
U.wait(1)
game.input.state.a = false
sample()
if stop and stop() then return end
end
end
local function toMenu()
pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
return battle.phase == "menu"
end
-- FIGHT is menuIndex 1 of the 2x2 grid, and WRAP is the only move on the list
local function useWrap()
for _ = 1, 80 do
if battle.phase == "moveSelect" then break end
if battle.phase == "menu" then
if battle.menuIndex ~= 1 then
U.tap(game, battle.menuIndex > 2 and "up" or "left")
else
U.tap(game, "a")
end
else
U.tap(game, "a")
end
for _ = 1, 3 do U.wait(1) sample() end
end
if battle.phase ~= "moveSelect" then return false end
for _ = 1, 20 do
if battle.phase ~= "moveSelect" then return true end
U.tap(game, "a")
for _ = 1, 3 do U.wait(1) sample() end
end
return false
end
-- the foe animates too, so pick the player's WRAP row out of the list
local function wrapRun()
for i = #runs, 1, -1 do
if runs[i].move == "WRAP" and #runs[i].marks > 0 then return runs[i] end
end
return nil
end
check("the battle reached its FIGHT menu", toMenu())
local sent = false
for _ = 1, 6 do
-- WRAP is 85% accurate and the foe leans on SAND-ATTACK, so hand the
-- accuracy back each try rather than reading a whiff as a regression
battle.player.stages.accuracy = 0
sent = useWrap()
if not sent then break end
pump(900, 8, function()
return wrapRun() ~= nil and not battle.animPlaying
end)
if wrapRun() then break end
U.log(" WRAP did not connect that turn; using it again")
toMenu()
end
check("chose WRAP from the move menu", sent)
local played = wrapRun()
check("WRAP's animation actually played on screen", played ~= nil)
if played then
U.log((" live playback: %d frames, %s")
:format(#played.marks, runLengths(played.marks)))
-- the sampler runs once per fixed step and can slip a frame against the
-- battle's own update, so the compiled timeline above is the exact
-- authority; this only has to rule out the 30-frame version
check(("live playback is around 60 frames, not 30 (measured %d)")
:format(#played.marks), #played.marks >= 50)
end
-- WRAP traps the foe, so the next turn replays it: measurement is done, so
-- now let the sampler stop and catch a squeeze mid-coil for the record
shooting = true
for _ = 1, 4 do
if shot then break end
if battle.phase == "menu" then
battle.player.stages.accuracy = 0
if not useWrap() then break end
end
pump(600, 8, function() return shot end)
end
if not shot then U.log(" no lit WRAP frame to capture") end
U.shot(game, DIR .. "/bug1653_wrap_after.png")
-- ---- hand off ----------------------------------------------------------
U.log("the pad is yours in a battle where the EKANS knows only WRAP (#1653),")
U.log("so pick FIGHT then WRAP and watch. the coils should squeeze the PIDGEY")
U.log("three separate times, each squeeze about a sixth of a second, with a")
U.log("clear blank beat of the same length before each one -- roughly a second")
U.log("end to end. the old bug ran the three squeezes together as one half-")
U.log("second smear with no gaps.")
U.log("listen as well as look: each of the three WRAP sfx should land as the")
U.log("coils appear. an sfx that fires while the screen is still blank, with")
U.log("the pause moved to the end of the animation, is the near miss that")
U.log("still measures 60 frames.")
U.log("wrap traps the foe, so the animation repeats over the next few turns")
U.log("and you can watch it more than once. any other move works the same")
U.log("way: every animation in the game now opens on that blank beat.")
U.log("screenshots: " .. DIR .. "/bug1653_*.png")
while true do
coroutine.yield()
end
end