Files
gen1recomp/tests/drivers/gold_ledge_hop_bug1713_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

273 lines
10 KiB
Lua

-- #1713: a ledge hop advanced only every other logic frame, so the whole map
-- scrolled at 30Hz in 2px lurches and the sprite bobbed against its own arc.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_ledge_hop_bug1713_test.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-ledge \
-- perl -e 'alarm 240; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- jump_step is STEP_WALK (pokegold/engine/overworld/movement.asm:595-597), so
-- StepFunction_PlayerJump runs the `db 0, 2, 8, 2` StepVectors row as two
-- 8-frame beats (engine/overworld/map_objects.asm:365-381, :1163-1200) and
-- UpdateJumpPosition walks its 16-entry arc one entry a frame (:1796-1817).
-- We render at twice that resolution, so the hop owes 1px and half an arc
-- entry on every one of its 32 frames.
--
-- No POKEPORT_SPEED here on purpose: the whole subject is per-frame motion,
-- and fast-forward scales only the logic clock.
local U = require("tests.drivers.util")
local Player = require("src.world.gen2.Player")
local Permissions = require("src.world.gen2.Permissions")
-- ROUTE_30's ledge run at y=10 (data/generated/maps.lua, TILESET_JOHTO
-- collision $a3 COLL_HOP_DOWN): open ground two cells above it, a wall in
-- between, floor on the landing. Re-derived by scanning if a re-import moves.
local MAP = "ROUTE_30"
local LEDGE = { x = 4, y = 10 }
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-ledge"
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()
for _, line in ipairs(lines) do U.log(line) end
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
claim(Player.STEP_FRAMES == 16, "a walk is 16 logic frames per cell")
claim(type(world.tryLedgeJump) == "function",
"World:tryLedgeJump is wired for the refused step to fall into")
-- ---- find a ledge with room to run up to it ------------------------------
world:setMap(MAP, LEDGE.x, LEDGE.y - 2, "down")
U.wait(10)
world.noWildEncounters = true
local map = world.map
local function hopsDown(x, y)
if not map:inBounds(x, y) then return false end
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
return facings ~= nil and facings.down == true
end
local function walkable(x, y)
return map:inBounds(x, y) and map:isWalkable(x, y)
end
-- A usable ledge: two free cells to walk down through, a refused cell in the
-- middle (that refusal is what becomes the jump) and a free landing.
local function usable(x, y)
return hopsDown(x, y) and not walkable(x, y + 1) and walkable(x, y + 2)
and walkable(x, y - 1) and walkable(x, y - 2)
end
local target
if usable(LEDGE.x, LEDGE.y) then target = { x = LEDGE.x, y = LEDGE.y } end
if not target then
local def = world.maps[MAP]
for y = 0, (def.height or 0) * 2 - 1 do
for x = 0, (def.width or 0) * 2 - 1 do
if not target and usable(x, y) then target = { x = x, y = y } end
end
end
end
claim(target ~= nil,
("%s still has a hop-down ledge with a run-up to it"):format(MAP))
if not target then
U.log("nothing to hop off; stopping rather than parking you at a wall")
stop()
end
if target.x ~= LEDGE.x or target.y ~= LEDGE.y then
U.log(("note: using the ledge at (%d,%d), not the (%d,%d) in the header")
:format(target.x, target.y, LEDGE.x, LEDGE.y))
end
local START = { x = target.x, y = target.y - 2 }
local function press(dir)
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
end
-- Back to the top of the run-up. The settle first is load bearing: a
-- setMap on top of a hop that is still in the air carries the leftover jump
-- into the new position and the next run measures nonsense.
local function reset()
game.input.state.down = false
for _ = 1, 120 do
if not (world.player and world.player.moving) then break end
coroutine.yield()
end
world:setMap(MAP, START.x, START.y, "down")
world.noWildEncounters = true
U.wait(8)
end
-- ---- the run-up and the hop, measured ------------------------------------
--
-- Nobody can count pixels per frame by eye, and this is the whole bug: the
-- per-frame travel of the camera-following position, plus the arc riding on
-- top of it. Every sample is "pixels travelled == frames elapsed", never a
-- difference between two driver frames -- the fixed-step loop can run two
-- logic steps for one yield, and a delta would read that as a lurch.
reset()
local p = world.player
local hopRows, takeoff = {}, 0
local walkSeen, walkWrong = 0, 0
for _ = 1, 240 do
press("down")
if p.jumping then
if #hopRows == 0 then takeoff = p.cellY * 16 end
hopRows[#hopRows + 1] = { n = p.progress, py = p.py,
off = p.spriteYOffset or 0 }
elseif #hopRows > 0 then
break
elseif p.moving and (p.progress or 0) > 0 then
walkSeen = walkSeen + 1
if p.py - p.cellY * 16 ~= p.progress then walkWrong = walkWrong + 1 end
end
end
game.input.state.down = false
local wrong, lurch, last = 0, 0, 0
for _, r in ipairs(hopRows) do
if r.py - takeoff ~= r.n then wrong = wrong + 1 end
if r.n > last then last = r.n end
end
-- The old quantum, named directly: an even number of pixels on an odd frame.
for _, r in ipairs(hopRows) do
if (r.py - takeoff) % 2 == 0 and r.n % 2 == 1 then lurch = lurch + 1 end
end
claim(#hopRows > 0, "walking down into the ledge started a hop")
claim(last >= 30,
("the hop ran its full 32 frames (the last one sampled was %d)")
:format(last))
claim(p.cellY == target.y + 2,
("it landed two cells down at y=%d (the player is at y=%d)")
:format(target.y + 2, p.cellY))
claim(p.py - takeoff == 32,
("and 32 pixels down, which is two cells (it moved %d)")
:format(p.py - takeoff))
claim(wrong == 0,
("on every one of the %d frames sampled the player had moved a pixel per"
.. " frame elapsed (%d had not)"):format(#hopRows, wrong))
claim(lurch == 0,
("so no frame of it stood still waiting to lurch two (%d did)")
:format(lurch))
claim(walkSeen > 0 and walkWrong == 0,
("the two ordinary steps before it move a pixel a frame too, on all %d"
.. " frames sampled (%d wrong)"):format(walkSeen, walkWrong))
-- The composed screen position: the camera-following py plus the sprite
-- offset. The armed frame is skipped because the first frame after it is
-- the take-off, and rising there is the jump starting.
local air, back = {}, 0
for _, r in ipairs(hopRows) do
if r.n >= 1 then air[#air + 1] = r.py + r.off end
end
for i = 2, #air do
if air[i] < air[i - 1] then back = back + 1 end
end
claim(back == 0,
("after the take-off the sprite never reversed against its own arc"
.. " (%d frames did)"):format(back))
-- ---- one frame per run, so the strip really is consecutive ---------------
--
-- A capture costs the driver an unknown number of frames, so shooting four
-- frames in one hop would skip some. Four identical run-ups, each shot on a
-- different frame, gives a strip that can be flipped through.
-- One run-up, ending either on the frame asked for or on the landing. The
-- fixed-step loop can occasionally run two logic steps for one driver yield,
-- so the shot is taken at the first frame AT OR PAST the one asked for, and
-- the frame it actually landed on is reported rather than assumed.
local function hopRun(frame, path)
reset()
local airborne = false
for _ = 1, 240 do
press("down")
local pl = world.player
if pl.jumping then
airborne = true
if frame and pl.progress >= frame then
local at, py = pl.progress, pl.py
game.input.state.down = false
U.shot(game, path)
return at, py
end
elseif airborne then
break
end
end
game.input.state.down = false
end
reset()
U.shot(game, out .. "/01-standing.png")
local strip = {}
for i, frame in ipairs({ 12, 13, 14, 15 }) do
local at, py = hopRun(frame, ("%s/02-strip-%d.png"):format(out, i))
strip[#strip + 1] = { at = at or -1, py = py or -1 }
end
hopRun(4, out .. "/03-rising.png")
hopRun(16, out .. "/04-peak.png")
hopRun(28, out .. "/05-falling.png")
hopRun()
U.wait(20)
U.shot(game, out .. "/06-landed.png")
local consecutive = true
local shown = {}
for i, s in ipairs(strip) do
shown[i] = ("%d"):format(s.at)
if i > 1 and (s.at - strip[i - 1].at ~= 1
or s.py - strip[i - 1].py ~= 1) then
consecutive = false
end
end
claim(consecutive,
("the strip really is four consecutive frames, %s -- if this is FAIL the"
.. " images are not comparable"):format(table.concat(shown, ", ")))
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 is FAIL, so do not spend time watching the replay")
end
-- ---- the replay, live ----------------------------------------------------
U.log("shots are in " .. out .. "; 02-strip-* are consecutive frames of the")
U.log("same hop, so the ground should shift one pixel between each, and")
U.log("03 / 04 / 05 are the rise, the top of the arc and the drop.")
U.log("the replay walks down into the ledge three times, then hands over.")
U.wait(120)
for _ = 1, 3 do
hopRun()
U.wait(60)
end
U.log("two ordinary steps down, then the hop: the map should scroll at the")
U.log("same speed through all three, and the sprite should rise, hang and")
U.log("fall once. a stutter, a 2px lurch, or the sprite bobbing back up")
U.log("during the rise is the old behaviour.")
U.log("the near miss to watch for: a smooth hop that clears only one cell,")
U.log("or one that takes twice as long as it should -- that is the two-cell")
U.log("span applied without the doubled duration.")
U.log("gen 1 is the reference; a Red ledge hop already looked like this.")
while true do coroutine.yield() end
end