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
329 lines
13 KiB
Lua
329 lines
13 KiB
Lua
-- #1748: overworld Pokemon objects (SPRITEMOVEDATA_POKEMON, $16) never bounce.
|
|
--
|
|
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
|
-- POKEPORT_DRIVER=tests/drivers/gold_ow_bounce_bug1748_test.lua \
|
|
-- POKEPORT_SHOT_DIR=/tmp/gold-bounce \
|
|
-- perl -e 'alarm 240; exec @ARGV' \
|
|
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
|
--
|
|
-- The $16 row is SPRITEMOVEFN_BOUNCE + OBJECT_ACTION_BOUNCE
|
|
-- (data/sprites/map_objects.asm:181-187). SetFacingBounce steps
|
|
-- OBJECT_STEP_FRAME once a frame and reads bit 3, swapping FACING_STEP_DOWN_0
|
|
-- for FACING_STEP_UP_0 -- eight frames on each (map_object_action.asm:184-202).
|
|
-- Its frozen column, SetFacingFreezeBounce, pins FACING_STEP_DOWN_0.
|
|
--
|
|
-- No POKEPORT_SPEED here on purpose: an eight-frame dwell is the whole thing
|
|
-- being watched, and fast-forward scales only the logic clock.
|
|
local U = require("tests.drivers.util")
|
|
|
|
local NPC = require("src.world.gen2.Npc")
|
|
|
|
-- ../pokegold/maps/PokemonFanClub.asm:311-316 -- the bouncing SPRITE_ODDISH at
|
|
-- (7,3) shares the room with a SPRITE_FAIRY doll at (2,4) on
|
|
-- SPRITEMOVEDATA_STANDING_DOWN, which is the negative control standing next to
|
|
-- it. ../pokegold/maps/PewterPokecenter1F.asm is the reporter's own room.
|
|
local ROOM = { map = "POKEMON_FAN_CLUB", x = 7, y = 3 }
|
|
local PEWTER = { map = "PEWTER_POKECENTER_1F", x = 1, y = 3 }
|
|
|
|
local SPRITEMOVEDATA_POKEMON = 0x16
|
|
local DELTA = {
|
|
up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 },
|
|
}
|
|
local FACE_FROM = { up = "down", down = "up", left = "right", right = "left" }
|
|
|
|
return function(game)
|
|
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bounce"
|
|
local fails, lines = 0, {}
|
|
|
|
local function claim(ok, text)
|
|
if not ok then fails = fails + 1 end
|
|
lines[#lines + 1] = (ok and "PASS " or "FAIL ") .. text
|
|
return ok
|
|
end
|
|
|
|
local function stop(why)
|
|
for _, line in ipairs(lines) do U.log(line) end
|
|
U.log(why)
|
|
while true do coroutine.yield() end
|
|
end
|
|
|
|
U.wait(45)
|
|
local world = game.world
|
|
if not (world and world.map) then
|
|
U.log("FAIL the gold world never booted, nothing to look at")
|
|
while true do coroutine.yield() end
|
|
end
|
|
|
|
-- ---- the things a human's eyes cannot check ------------------------------
|
|
--
|
|
-- Every one of these fails as silence: a missing movement byte, a sheet with
|
|
-- no second frame and a doll that quietly took the animation all look exactly
|
|
-- like "the object sits there", which is also what the bug looked like.
|
|
claim(NPC.MOVE.POKEMON == SPRITEMOVEDATA_POKEMON,
|
|
"SPRITEMOVEDATA_POKEMON is modelled as $16")
|
|
claim(type(NPC.bounceFrame) == "function",
|
|
"NPC:bounceFrame exists for the draw call to spend")
|
|
|
|
local function findObject(mapId, x, y)
|
|
local def = world.maps[mapId]
|
|
if not def then return nil end
|
|
local exact, any
|
|
for _, obj in ipairs(def.objects or {}) do
|
|
if obj.movement == SPRITEMOVEDATA_POKEMON then
|
|
any = any or obj
|
|
if obj.x == x and obj.y == y then exact = obj end
|
|
end
|
|
end
|
|
return exact or any
|
|
end
|
|
|
|
local monDef = findObject(ROOM.map, ROOM.x, ROOM.y)
|
|
claim(monDef ~= nil,
|
|
("%s still carries a $16 object"):format(ROOM.map))
|
|
if not monDef then
|
|
stop("no bouncing object on the map; stopping rather than faking the moment")
|
|
end
|
|
if monDef.x ~= ROOM.x or monDef.y ~= ROOM.y then
|
|
U.log(("note: using the mon at (%d,%d), not the (%d,%d) in the header")
|
|
:format(monDef.x, monDef.y, ROOM.x, ROOM.y))
|
|
end
|
|
|
|
local sheet = type(monDef.sprite) == "string"
|
|
and world.sprites and world.sprites[monDef.sprite]
|
|
claim(sheet ~= nil,
|
|
("its sprite %s resolves to a sheet"):format(tostring(monDef.sprite)))
|
|
claim(sheet and (sheet.frames or 1) >= 2,
|
|
("and that sheet is two frames deep (frames = %s) -- one frame is the half"
|
|
.. " of #1748 a re-import fixes"):format(sheet and tostring(sheet.frames)))
|
|
claim(world.scripts and world.scripts[monDef.scriptKey] ~= nil,
|
|
("its script %s is in the cache, so the A press below opens a box")
|
|
:format(tostring(monDef.scriptKey)))
|
|
|
|
-- ---- walk in and stand in front of it ------------------------------------
|
|
world:setMap(ROOM.map, monDef.x, monDef.y + 1, "up")
|
|
U.wait(20)
|
|
local map = world.map
|
|
|
|
-- Stand on the mon's neighbour and face back at it; any free neighbour will
|
|
-- do, so a re-import that shuffles the room degrades instead of parking the
|
|
-- player at a wall.
|
|
local spot
|
|
for _, dir in ipairs({ "down", "left", "right", "up" }) do
|
|
local d = DELTA[dir]
|
|
local x, y = monDef.x + d[1], monDef.y + d[2]
|
|
if not spot and map:isWalkable(x, y) then
|
|
spot = { x = x, y = y, facing = FACE_FROM[dir] }
|
|
end
|
|
end
|
|
claim(spot ~= nil, "there is a free cell beside it to stand on")
|
|
if not spot then
|
|
stop("nowhere to stand; stopping rather than parking the player at a wall")
|
|
end
|
|
world:setMap(ROOM.map, spot.x, spot.y, spot.facing)
|
|
U.wait(20)
|
|
|
|
local mon, dolls = nil, {}
|
|
for _, npc in ipairs(world.npcs or {}) do
|
|
if npc.def == monDef then mon = npc
|
|
elseif npc.def and npc.def.movement ~= SPRITEMOVEDATA_POKEMON then
|
|
dolls[#dolls + 1] = npc
|
|
end
|
|
end
|
|
claim(mon ~= nil, "the object spawned as an NPC")
|
|
if not mon then stop("the mon never spawned; nothing to watch") end
|
|
claim(mon.bouncing == true, "and it spawned bouncing")
|
|
|
|
local stillBouncers = 0
|
|
for _, npc in ipairs(dolls) do
|
|
if npc.bouncing then stillBouncers = stillBouncers + 1 end
|
|
end
|
|
claim(stillBouncers == 0,
|
|
("none of the %d other objects in the room bounce -- the Clefairy doll"
|
|
.. " beside it must not move"):format(#dolls))
|
|
|
|
-- ---- the cycle, measured on the logic clock ------------------------------
|
|
-- One sample per frame, and the run lengths are what SetFacingBounce's bit 3
|
|
-- decides. Measured rather than eyeballed because four flips a second is
|
|
-- exactly the rate a human reads as "about right" when it is wrong.
|
|
local samples = {}
|
|
for i = 1, 96 do
|
|
samples[i] = mon:bounceFrame()
|
|
coroutine.yield()
|
|
end
|
|
local runs, values = {}, {}
|
|
for i = 1, #samples do
|
|
if i > 1 and samples[i] == samples[i - 1] then
|
|
runs[#runs] = runs[#runs] + 1
|
|
else
|
|
runs[#runs + 1] = 1
|
|
values[#values + 1] = samples[i]
|
|
end
|
|
end
|
|
claim(#runs >= 5, ("the pose changed %d times in 96 frames"):format(#runs - 1))
|
|
local alternates, dwell, uneven = true, nil, nil
|
|
for i = 2, #runs - 1 do
|
|
dwell = dwell or runs[i]
|
|
if runs[i] ~= dwell then uneven = uneven or runs[i] end
|
|
if values[i] == values[i - 1] then alternates = false end
|
|
end
|
|
claim(alternates and values[1] ~= nil,
|
|
"it alternates between two poses and only two")
|
|
claim(uneven == nil, ("every whole dwell is the same length"
|
|
.. (uneven and (" -- saw %d and %d"):format(dwell or 0, uneven) or "")))
|
|
claim(dwell == 8, ("and it is the eight frames bit 3 buys (measured %s)")
|
|
:format(tostring(dwell)))
|
|
|
|
-- ---- what the renderer was actually handed -------------------------------
|
|
-- A bounce the draw call drops looks exactly like no bounce, and a doll that
|
|
-- picked the override up is the other way to pass this by accident.
|
|
local seen, watched = {}, { { key = "mon", npc = mon } }
|
|
for i, npc in ipairs(dolls) do
|
|
watched[#watched + 1] = { key = "doll" .. i, npc = npc }
|
|
end
|
|
for _, row in ipairs(watched) do
|
|
local sprite = row.npc.sprite
|
|
row.sprite, row.real = sprite, sprite.draw
|
|
seen[row.key] = {}
|
|
sprite.draw = function(s, ...)
|
|
local args = { ... }
|
|
local log = seen[row.key]
|
|
-- "none" rather than a hole, so a sprite that was never drawn at all is
|
|
-- not mistaken for one that was drawn with no override.
|
|
log[#log + 1] = args[10] == nil and "none" or args[10]
|
|
return row.real(s, ...)
|
|
end
|
|
end
|
|
U.wait(40)
|
|
for _, row in ipairs(watched) do row.sprite.draw = row.real end
|
|
|
|
local monPoses = {}
|
|
for _, v in ipairs(seen.mon) do monPoses[v] = true end
|
|
claim(#seen.mon > 0, "the mon was drawn")
|
|
claim(monPoses[0] and monPoses[1],
|
|
"and both icon frames reached SpriteRenderer over 40 frames")
|
|
local dollDraws, dollOverrides = 0, 0
|
|
for i = 1, #dolls do
|
|
for _, v in ipairs(seen["doll" .. i] or {}) do
|
|
dollDraws = dollDraws + 1
|
|
if v ~= "none" then dollOverrides = dollOverrides + 1 end
|
|
end
|
|
end
|
|
claim(dollDraws > 0, ("the other %d objects were drawn too"):format(#dolls))
|
|
claim(dollOverrides == 0, "and not one of them was handed a frame override")
|
|
|
|
-- ---- the pair of shots ---------------------------------------------------
|
|
-- The counter is held while each capture lands so the file is definitely one
|
|
-- pose rather than whichever the spin drifted onto; the cycle above is what
|
|
-- proves it runs on its own.
|
|
local function holdShot(path, step)
|
|
os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null')
|
|
game.capturePath = path
|
|
for _ = 1, 120 do
|
|
mon.bounceStep = step
|
|
if not game.capturePath then break end
|
|
coroutine.yield()
|
|
end
|
|
mon.bounceStep = step
|
|
coroutine.yield()
|
|
local f = io.open(path, "rb")
|
|
if not f then return nil end
|
|
local bytes = f:read("*a")
|
|
f:close()
|
|
return bytes
|
|
end
|
|
|
|
local down = holdShot(out .. "/01-pose-down.png", 0)
|
|
local up = holdShot(out .. "/02-pose-up.png", 8)
|
|
claim(down ~= nil and up ~= nil, "both pose shots reached disk")
|
|
claim(down and up and down ~= up,
|
|
"and the two files differ, so the screen really changes between poses")
|
|
|
|
-- ---- talking to it holds the first pose ----------------------------------
|
|
-- OBJECT_ACTION_BOUNCE's frozen column is SetFacingFreezeBounce, which writes
|
|
-- FACING_STEP_DOWN_0 and never touches the counter.
|
|
mon.bounceStep = 12
|
|
local phaseBefore = mon.bounceStep
|
|
U.tap(game, "a")
|
|
U.wait(30)
|
|
claim(world.talkNpc == mon or mon.frozen,
|
|
"the A press reached the mon and froze it")
|
|
local heldWrong = nil
|
|
for _ = 1, 60 do
|
|
if mon:bounceFrame() ~= 0 then heldWrong = mon:bounceFrame() end
|
|
coroutine.yield()
|
|
end
|
|
claim(heldWrong == nil,
|
|
"and it holds its first pose for the whole conversation")
|
|
claim(mon.bounceStep == phaseBefore,
|
|
("with the step counter left at %d, so the bounce resumes on the phase it"
|
|
.. " froze at (it is %s)"):format(phaseBefore, tostring(mon.bounceStep)))
|
|
U.shot(game, out .. "/03-frozen-talking.png")
|
|
|
|
U.tap(game, "a")
|
|
U.wait(40)
|
|
U.tap(game, "a")
|
|
U.wait(40)
|
|
|
|
-- ---- the reporter's own room ---------------------------------------------
|
|
local pewterDef = findObject(PEWTER.map, PEWTER.x, PEWTER.y)
|
|
if not pewterDef then
|
|
U.log("note: no $16 object on " .. PEWTER.map .. ", skipping its shots")
|
|
else
|
|
world:setMap(PEWTER.map, pewterDef.x, pewterDef.y + 2, "up")
|
|
U.wait(30)
|
|
local jiggly
|
|
for _, npc in ipairs(world.npcs or {}) do
|
|
if npc.def == pewterDef then jiggly = npc end
|
|
end
|
|
claim(jiggly ~= nil and jiggly.bouncing == true,
|
|
("the %s at (%d,%d) bounces too"):format(PEWTER.map,
|
|
pewterDef.x, pewterDef.y))
|
|
if jiggly then
|
|
local a, b
|
|
local function pin(path, step)
|
|
os.execute('mkdir -p "' .. path:match("^(.*)/[^/]+$") .. '" 2>/dev/null')
|
|
game.capturePath = path
|
|
for _ = 1, 120 do
|
|
jiggly.bounceStep = step
|
|
if not game.capturePath then break end
|
|
coroutine.yield()
|
|
end
|
|
coroutine.yield()
|
|
local f = io.open(path, "rb")
|
|
if not f then return nil end
|
|
local bytes = f:read("*a")
|
|
f:close()
|
|
return bytes
|
|
end
|
|
a = pin(out .. "/04-pewter-down.png", 0)
|
|
b = pin(out .. "/05-pewter-up.png", 8)
|
|
claim(a ~= nil and b ~= nil and a ~= b,
|
|
"and its two pose shots differ as well")
|
|
end
|
|
end
|
|
|
|
for _, line in ipairs(lines) do U.log(line) end
|
|
U.log(("%d checks, %d failed"):format(#lines, fails))
|
|
if fails > 0 then
|
|
U.log("something above says FAIL, so do not spend time watching the room.")
|
|
end
|
|
|
|
-- ---- what to look at -----------------------------------------------------
|
|
U.log("shots are in " .. out .. ". 01 and 02 are the two poses of the same")
|
|
U.log("mon; they should look like the two halves of its party menu icon,")
|
|
U.log("the second one sitting a pixel or two differently. 03 is the same mon")
|
|
U.log("mid-conversation, which must match 01.")
|
|
U.log("")
|
|
U.log("the run ends back in the Fan Club with the Oddish on screen at (7,3).")
|
|
U.log("it should visibly flip between its two icon poses about four times a")
|
|
U.log("second, without moving off its tile or turning to face anything.")
|
|
U.log("the Clefairy doll at (2,4) and the people in the room must stay dead")
|
|
U.log("still: a doll that bobs means the bounce got keyed off the sprite")
|
|
U.log("instead of the movement byte. a mon that keeps bobbing while its text")
|
|
U.log("box is open is the other near miss -- it must hold one pose there and")
|
|
U.log("pick the cycle back up where it left off when the box closes.")
|
|
|
|
world:setMap(ROOM.map, spot.x, spot.y, spot.facing)
|
|
while true do coroutine.yield() end
|
|
end
|