mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
265 lines
11 KiB
Lua
265 lines
11 KiB
Lua
-- Driver: the Pewter JIGGLYPUFF sings AND dances (#249).
|
|
-- scripts/PewterPokecenter.asm PewterPokecenterJigglypuffText: stop the music,
|
|
-- DelayFrames 32, MUSIC_JIGGLYPUFF_SONG, then a clockwise quarter turn every 24
|
|
-- frames until the song ends, 48 more frames, PlayDefaultMusic. TextScriptEnd
|
|
-- is the only thing that closes the box. Not under POKEPORT_SPEED.
|
|
-- POKEPORT_DRIVER=tests/drivers/jigglypuff_bug249_test.lua love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
local mapScripts = require("data.scripts.init")
|
|
local TextBox = require("src.render.TextBox")
|
|
local Music = require("src.core.Music")
|
|
|
|
local MAP = "PEWTER_POKECENTER"
|
|
local NPC = "PEWTERPOKECENTER_JIGGLYPUFF"
|
|
local TEXT = "TEXT_PEWTERPOKECENTER_JIGGLYPUFF"
|
|
local SONG, MAP_SONG = "Music_JigglypuffSong", "Music_Pokecenter"
|
|
-- scripts/PewterPokecenter.asm .FacingDirections, in order
|
|
local RING = { "down", "left", "up", "right" }
|
|
local SILENCE, STEP, TAIL = 32, 24, 48 -- the three DelayFrames counts
|
|
|
|
local function check(label, ok)
|
|
U.log(ok and "PASS" or "FAIL", label)
|
|
return ok
|
|
end
|
|
|
|
local function nextInRing(dir)
|
|
for i, d in ipairs(RING) do
|
|
if d == dir then return RING[i % #RING + 1] end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- ---- preconditions the eye cannot check --------------------------------
|
|
-- A missing handler, text entry or song def all look the same on screen: a
|
|
-- JIGGLYPUFF that just stands there.
|
|
local handler = mapScripts.talkScript(MAP, TEXT)
|
|
check(MAP .. "/" .. TEXT .. " has a talk handler",
|
|
type(handler) == "function")
|
|
|
|
local line = game.data.text and game.data.text._PewterPokecenterJigglypuffText
|
|
check("_PewterPokecenterJigglypuffText resolves to a string",
|
|
type(line) == "string" and line ~= "")
|
|
if type(line) == "string" then
|
|
U.log("the box should read:", (line:gsub("\n", " / ")))
|
|
end
|
|
|
|
-- Music.playOnce returns false on a missing def, and the dance then skips
|
|
-- straight to its 48-frame tail with no spin at all.
|
|
local songs = game.data.audio and game.data.audio.songs
|
|
check("audio.songs." .. SONG .. " resolves (PlayMusic MUSIC_JIGGLYPUFF_SONG)",
|
|
songs ~= nil and songs[SONG] ~= nil)
|
|
check("audio.songs." .. MAP_SONG .. " resolves (PlayDefaultMusic comes back to it)",
|
|
songs ~= nil and songs[MAP_SONG] ~= nil)
|
|
local mapSong = game.data.audio and game.data.audio.mapSongs
|
|
and game.data.audio.mapSongs[MAP]
|
|
check("the Center's map theme is " .. MAP_SONG, mapSong == MAP_SONG)
|
|
|
|
-- SPRITE_FAIRY has to be a walker or three of the four facings have no
|
|
-- frames to draw and the "turn" is invisible even when it happens
|
|
local fairy = game.data.sprites and game.data.sprites.SPRITE_FAIRY
|
|
check("SPRITE_FAIRY renders all four facings",
|
|
fairy ~= nil and fairy.walker == true and (fairy.frames or 0) >= 4)
|
|
|
|
local opts = game.save.options or {}
|
|
U.log("audio device present:", love.audio ~= nil,
|
|
" MUSIC VOL (0-7):", tostring(opts.musicVol),
|
|
" SFX VOL (0-7):", tostring(opts.sfxVol))
|
|
if not love.audio or opts.musicVol == 0 then
|
|
U.log("WARNING: music output is off, so the cut to silence and the song",
|
|
"itself will not be audible; raise MUSIC VOL in OPTION first")
|
|
end
|
|
|
|
-- ---- park the player against the JIGGLYPUFF -----------------------------
|
|
-- data/maps/objects/PewterPokecenter.asm: object_event 1, 3, SPRITE_FAIRY.
|
|
-- It sits against the west wall, so approach from the floor to its east.
|
|
local STAND = { x = 2, y = 3, facing = "left" }
|
|
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
|
U.wait(10)
|
|
|
|
local function puffIn(ow)
|
|
for _, n in ipairs(ow.npcs or {}) do
|
|
if n.def and n.def.name == NPC then return n end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- re-reads game.overworld every call: the fallback below teleports again,
|
|
-- which rebuilds the state and its npc list
|
|
local function facingThePuff()
|
|
local ow = game.overworld
|
|
local puff = ow and puffIn(ow)
|
|
if not puff then return false end
|
|
local fx, fy = ow.player:facingCell()
|
|
return ow:npcAtCell(fx, fy) == puff
|
|
end
|
|
|
|
local ow = game.overworld
|
|
local puff = ow and puffIn(ow)
|
|
check("JIGGLYPUFF object loaded on " .. MAP, puff ~= nil)
|
|
|
|
if puff and not facingThePuff() then
|
|
-- Approach cell is blocked (map edit, or a mod moved the object): take any
|
|
-- free neighbour instead. {dx, dy, facing} is the offset from the fairy
|
|
-- plus the direction that looks back at it, so +1 on x means facing left.
|
|
local sides = {
|
|
{ 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" },
|
|
}
|
|
for _, s in ipairs(sides) do
|
|
local cx, cy = puff.cellX + s[1], puff.cellY + s[2]
|
|
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
|
U.log(("approach cell (%d, %d) is blocked, standing on")
|
|
:format(STAND.x, STAND.y), cx, cy, "facing", s[3])
|
|
U.teleport(game, MAP, cx, cy, s[3])
|
|
U.wait(10)
|
|
break
|
|
end
|
|
end
|
|
puff = game.overworld and puffIn(game.overworld)
|
|
end
|
|
check("player is standing against the JIGGLYPUFF", facingThePuff())
|
|
|
|
-- ---- one frame, sampling everything at once ----------------------------
|
|
local function boxOnTop()
|
|
local top = game.stack:top()
|
|
if getmetatable(top) == TextBox then return top end
|
|
return nil
|
|
end
|
|
|
|
local function sample(pressA)
|
|
if pressA then U.tap(game, "a") else U.wait(1) end
|
|
local box = boxOnTop()
|
|
return {
|
|
box = box ~= nil,
|
|
typed = box ~= nil and box.done == true,
|
|
tick = box ~= nil and box.auto ~= nil and type(box.auto.tick) == "function",
|
|
facing = puff and puff.facing or nil,
|
|
song = Music.oneShotPlaying(),
|
|
}
|
|
end
|
|
|
|
-- ---- 1. talk to it and watch the whole dance, mashing A throughout -----
|
|
U.log("--- run 1: the full dance, with A held down on it ---------------")
|
|
local before = puff and puff.facing
|
|
U.tap(game, "a")
|
|
U.wait(2)
|
|
local box = boxOnTop()
|
|
check("pressing A opened a text box", box ~= nil)
|
|
if box then
|
|
U.log("the JIGGLYPUFF turned to face the player:",
|
|
tostring(before), "->", tostring(puff.facing))
|
|
check("the box carries an auto.tick hook (the per-frame dance driver)",
|
|
box.auto ~= nil and type(box.auto.tick) == "function")
|
|
check("the box is an auto box, so no blinking arrow and no A dismissal",
|
|
box.auto ~= nil)
|
|
end
|
|
|
|
-- Mash A every 3rd frame for the whole run; in the original the box cannot
|
|
-- be dismissed at all, so this must change nothing.
|
|
local log, turns = {}, {}
|
|
local typedAt, songAt, songEnd, closedAt = nil, nil, nil, nil
|
|
local last = puff and puff.facing
|
|
for i = 1, 3000 do
|
|
log[i] = sample(i % 3 == 0)
|
|
if not typedAt and log[i].typed then typedAt = i end
|
|
if not songAt and log[i].song then songAt = i end
|
|
if songAt and not songEnd and not log[i].song then songEnd = i end
|
|
if log[i].facing and log[i].facing ~= last then
|
|
turns[#turns + 1] = { at = i, from = last, to = log[i].facing }
|
|
last = log[i].facing
|
|
end
|
|
if not log[i].box then closedAt = i break end
|
|
end
|
|
|
|
U.log(("frames: text finished typing at %s, song started at %s, song ended " ..
|
|
"at %s, box closed at %s")
|
|
:format(tostring(typedAt), tostring(songAt), tostring(songEnd),
|
|
tostring(closedAt)))
|
|
for n, t in ipairs(turns) do
|
|
U.log((" turn %d on frame %d: %s -> %s"):format(n, t.at, tostring(t.from),
|
|
tostring(t.to)))
|
|
end
|
|
|
|
if check("the song played", songAt ~= nil) then
|
|
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic
|
|
local gap = songAt - (typedAt or 1)
|
|
U.log(("silence between the text finishing and the song starting: %d frames " ..
|
|
"(pokered waits %d)"):format(gap, SILENCE))
|
|
check(("that silence is about %d frames"):format(SILENCE),
|
|
gap >= SILENCE - 4 and gap <= SILENCE + 8)
|
|
end
|
|
|
|
check("the JIGGLYPUFF turned at least four times", #turns >= 4)
|
|
local ringOk, spacingOk = true, true
|
|
for n, t in ipairs(turns) do
|
|
if t.to ~= nextInRing(t.from) then
|
|
ringOk = false
|
|
U.log((" turn %d is not a clockwise quarter turn: %s -> %s (expected %s)")
|
|
:format(n, tostring(t.from), tostring(t.to),
|
|
tostring(nextInRing(t.from))))
|
|
end
|
|
if n > 1 then
|
|
local d = t.at - turns[n - 1].at
|
|
if d < STEP - 6 or d > STEP + 6 then
|
|
spacingOk = false
|
|
U.log((" turn %d came %d frames after the last one (expected %d)")
|
|
:format(n, d, STEP))
|
|
end
|
|
end
|
|
end
|
|
check("every turn is one clockwise quarter turn (DOWN->LEFT->UP->RIGHT)",
|
|
ringOk and #turns >= 4)
|
|
check(("the turns are %d frames apart"):format(STEP),
|
|
spacingOk and #turns >= 2)
|
|
|
|
-- not just "it eventually closed": an A-dismissable box closes too, only far
|
|
-- too early, and that is the bug
|
|
check("mashing A never closed the box early",
|
|
(closedAt or #log) >= SILENCE + 4 * STEP)
|
|
if check("the box closed itself with no button press", closedAt ~= nil) then
|
|
check("it stayed up for the whole song, A mashing and all",
|
|
songEnd ~= nil and closedAt > songEnd)
|
|
if songEnd then
|
|
local tail = closedAt - songEnd
|
|
U.log(("the box lingered %d frames after the song (pokered waits %d, " ..
|
|
"plus the box's own pop delay)"):format(tail, TAIL))
|
|
check(("that tail is about %d frames"):format(TAIL),
|
|
tail >= TAIL - 6 and tail <= TAIL + 20)
|
|
end
|
|
end
|
|
|
|
-- ---- 2. do it again, screenshot each quarter turn, hand off mid-song ---
|
|
U.log("--- run 2: screenshots, then the pad is yours -------------------")
|
|
U.wait(30)
|
|
puff = game.overworld and puffIn(game.overworld)
|
|
check("still standing against the JIGGLYPUFF", facingThePuff())
|
|
U.tap(game, "a")
|
|
U.wait(2)
|
|
U.shot(game, DIR .. "/bug249_0_box.png")
|
|
|
|
local shots, seen = 0, puff and puff.facing
|
|
for _ = 1, 600 do
|
|
U.wait(1)
|
|
if not boxOnTop() then break end
|
|
if puff and puff.facing ~= seen then
|
|
seen = puff.facing
|
|
shots = shots + 1
|
|
-- U.shot costs a frame or two, which is why run 1 did the timing
|
|
local path = ("%s/bug249_%d_%s.png"):format(DIR, shots, seen)
|
|
if U.shot(game, path) then U.log("captured", path) end
|
|
if shots >= 4 then break end
|
|
end
|
|
end
|
|
check("captured four quarter turns", shots >= 4)
|
|
|
|
U.log("The JIGGLYPUFF has been talked to and is mid-song. Mash A: the box has no")
|
|
U.log("arrow and must not close. The fairy should turn a quarter turn clockwise")
|
|
U.log("(down, left, up, right) every 24 frames until the song ends, then close")
|
|
U.log("itself (#249). The Center theme returns just before it does; expected.")
|
|
|
|
while true do
|
|
coroutine.yield()
|
|
end
|
|
end
|