-- Ledge hop with the Yellow follower: one shadow under the jumper (#408) -- and Pikachu clearing the ledge in one motion instead of stopping on it -- (#409). The hop cell is searched out of the loaded map's own blocks -- against data.field.ledges (pokeyellow data/tilesets/ledge_tiles.asm), so -- a map edit moves the test instead of breaking it. Never add -- POKEPORT_SPEED here: it scales only the logic clock while audio keeps its -- own real-time accumulator, which desyncs the exact 32 frames being judged. -- POKEPORT_DRIVER=tests/drivers/pikachu_ledge_bug408_bug409_test.lua POKEPORT_IDENTITY=bug408 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . return function(game) local U = dofile("tests/drivers/util.lua") local GameVersion = require("src.core.GameVersion") local Pokemon = require("src.pokemon.Pokemon") local results = {} local function check(label, ok) results[#results + 1] = { label = label, ok = ok and true or false } return ok end local function report() for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end end if not GameVersion.isYellow() then check("running under POKEPORT_VERSION=yellow", false) report() U.log("Nothing else in this driver applies to RED/BLUE: there is no") U.log("follower to hop, and the four-quadrant shadow is correct there.") while true do coroutine.yield() end end -- ShouldPikachuSpawn's two preconditions (engine/pikachu/ -- pikachu_follow.asm): the lab gift happened and a healthy Pikachu is in -- the party. Without both, ow.npcs never gets a follower and every -- follower check below would read as the bug. game.save.flags = game.save.flags or {} game.save.flags.EVENT_GOT_STARTER = true game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } game.save.onBike = false -- ROUTE_1 (10x18 blocks) is the first outdoor map with a plain -- south-facing ledge run; (5, 4) is the standing cell of one, read out of -- the cached blocks rather than typed from a map screenshot. scanHop -- re-derives it below and this only picks the starting point. local MAP = "ROUTE_1" local WANT = { x = 5, y = 4 } -- data.field.ledges rows for the loaded tileset, in the shape -- OverworldState:checkLedgeHop matches (facing == input == the pressed -- direction, standing tile under the player, ledge tile in front). local function ledgeRows(map, dir) local rows = {} for _, l in ipairs(game.data.field.ledges or {}) do if (l.tileset or "OVERWORLD") == map.def.tileset and l.facing == dir and l.input == dir then rows[#rows + 1] = l end end return rows end local function ledgeTileSet(map) local set = {} for _, l in ipairs(game.data.field.ledges or {}) do if (l.tileset or "OVERWORLD") == map.def.tileset then set[l.ledgeTile] = true end end return set end -- a cell the player can hop south from, with two walkable cells above it -- to walk in from and a walkable landing two cells below local function hopCellOk(map, cx, cy) if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then return false end if not map:isWalkableCell(cx, cy) then return false end if not map:isWalkableCell(cx, cy + 2) then return false end if not (map:isWalkableCell(cx, cy - 1) and map:isWalkableCell(cx, cy - 2)) then return false end local standing = map:cellTile(cx, cy) local front = map:cellTile(cx, cy + 1) for _, l in ipairs(ledgeRows(map, "down")) do if l.standingTile == standing and l.ledgeTile == front then return true end end return false end local function scanHop(map) for cy = 2, map.heightCells - 3 do for cx = 0, map.widthCells - 1 do if hopCellOk(map, cx, cy) then return cx, cy end end end return nil end U.teleport(game, MAP, WANT.x, WANT.y - 2, "down") U.wait(20) local ow = game.overworld check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) if not ow then report() while true do coroutine.yield() end end local hx, hy = WANT.x, WANT.y if not hopCellOk(ow.map, hx, hy) then local sx, sy = scanHop(ow.map) if sx then U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y), sx, sy) hx, hy = sx, sy U.teleport(game, MAP, hx, hy - 2, "down") U.wait(20) ow = game.overworld end end check(("a south ledge to hop at (%d, %d)"):format(hx, hy), hopCellOk(ow.map, hx, hy)) local LEDGES = ledgeTileSet(ow.map) local function follower() for _, n in ipairs(ow.npcs or {}) do if n.pikachuFollower then return n end end return nil end check("the follower spawned", follower() ~= nil) -- sampled every frame from here on: the two things that only exist -- mid-motion. A follower at rest on a ledge tile is #409 exactly -- it -- walked onto the ledge and stopped there instead of clearing it. local maxHop, sawHopStep, restedOnLedge = 0, false, false local function sample() local p = ow.player if p.hopFrames and p.hopFrames > maxHop then maxHop = p.hopFrames end local npc = follower() if not npc then return end if npc.hopStep then sawHopStep = true end if not npc.moving and LEDGES[ow.map:cellTile(npc.cellX, npc.cellY)] then restedOnLedge = true end end local function step(n) for _ = 1, n do coroutine.yield() sample() end end -- walk south into the ledge, one frame of held Down at a time, and let go -- the moment the hop commits so the landing cannot roll straight into a -- second hop local walked = 0 for _ = 1, 200 do table.insert(game.input.pressQueue, "down") game.input.state["down"] = true coroutine.yield() sample() walked = walked + 1 if ow.player.hopFrames and ow.player.hopFrames > 0 then break end end game.input.state["down"] = false check("the player's ledge hop actually fired", maxHop > 0) -- mid-arc: count the shadow quads one Player:draw puts down. Player:draw -- issues its shadow copies back to back before the sprite, so the longest -- run of consecutive love.graphics.draw calls carrying shadowImg is that -- per-frame count. Yellow's LedgeHoppingShadowOAM is two entries (dbsprite -- 9,11 and 10,11 OAM_XFLIP, engine/overworld/ledges.asm) mirrored into one -- 16x8 ellipse; RED's four-quadrant block is what #408 was leaking in. local shadowDraws local img = ow.player.shadowImg check("the hop shadow tile loaded", img ~= nil) if img then local real = love.graphics.draw local run, best = 0, 0 love.graphics.draw = function(a, ...) if a == img then run = run + 1 if run > best then best = run end else run = 0 end return real(a, ...) end step(4) love.graphics.draw = real shadowDraws = best check("exactly one mirrored pair of shadow quads per frame (2 draws)", shadowDraws == 2) end local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local midShot = SHOT_DIR .. "/bug408_hop_midair.png" if ow.player.hopFrames and ow.player.hopFrames > 0 then check("mid-arc screenshot reached disk", U.shot(game, midShot) and true) U.log("captured", midShot, "at hopFrames", ow.player.hopFrames) else check("mid-arc screenshot reached disk", false) U.log("the arc was already over before the capture; no mid-air frame") end -- let the follower finish crossing (its hop is one normal step's frames -- with a doubled step vector, pikachu_follow.asm Func_fca0a) step(120) local npc = follower() check("the follower is still on the map", npc ~= nil) if npc then local tile = ow.map:cellTile(npc.cellX, npc.cellY) check("the follower came to rest on a walkable cell", ow.map:isWalkableCell(npc.cellX, npc.cellY)) check("that cell is not a ledge tile", not LEDGES[tile]) check("the follower ended below the ledge row", npc.cellY > hy + 1) check("it took the doubled hop step, not two walks", sawHopStep) U.log(("follower rests at (%d, %d), tile %s; player at (%d, %d)") :format(npc.cellX, npc.cellY, tostring(tile), ow.player.cellX, ow.player.cellY)) end check("the follower never stood still on a ledge tile", not restedOnLedge) local afterShot = SHOT_DIR .. "/bug409_after_follow.png" check("landing screenshot reached disk", U.shot(game, afterShot) and true) U.log("captured", afterShot) U.log("walked", walked, "frames into the ledge at", hx, hy) report() -- hand the pad back one cell short of the same ledge, so Down alone -- re-runs the hop as many times as the reader wants U.teleport(game, MAP, hx, hy - 1, "down") U.wait(20) U.log("Hold Down to hop the ledge again from here.") U.log("During the arc there should be one flat ellipse on the ground under") U.log("the player, not a taller blob with a seam across its middle, and") U.log("Pikachu should clear both cells in one motion. The near miss to") U.log("watch for is Pikachu pausing a beat on the ledge tile itself.") while true do coroutine.yield() end end