mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
1905261c5b
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
405 lines
16 KiB
Lua
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
|