From e6e3459dbe08ff3cea352d3737632ab84bfc6cc0 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 4 Sep 2026 10:45:02 -0400 Subject: [PATCH] yellow: Bill's house Pikachu timing, collision and spawn cell, CLOSES #2165 --- src/script/Commands.lua | 19 +- src/world/PikachuFollower.lua | 68 ++++- tests/drivers/bills_pikachu_refix2_2165.lua | 279 ++++++++++++++++++++ tests/parity_bills_pc.lua | 37 ++- tests/parity_yellow_bills_pikachu.lua | 99 +++++++ 5 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 tests/drivers/bills_pikachu_refix2_2165.lua diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 1da34c8d..c46bfb21 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -620,6 +620,23 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end +-- wait_sound: WaitForSoundToFinish (home/delay.asm:15), the drain that +-- follows every PlaySound in the cell-separator chain +-- (engine/events/hidden_events/bills_house_pc.asm:22) +local WAIT_SOUND_CEILING = 600 + +function Commands.wait_sound(ctx) + local Sound = require("src.core.Sound") + if not Sound.sfxBusy() then return end + local runner = ctx.runner + local left = WAIT_SOUND_CEILING + runner.waitingCheck = function() + left = left - 1 + return left <= 0 or not Sound.sfxBusy() + end + runner:yield() +end + -- text_sound : the jingle the ROM parks at the END of a string as -- a trailing text command (sound_get_item_1, sound_get_key_item -> -- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same @@ -1476,7 +1493,7 @@ end for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "open_mart", "trade", "push_screen", "record_hall_of_fame", "old_man_demo", "static_battle", "rival_battle", "give_item", - "give_pokemon", "wait", + "give_pokemon", "wait", "wait_sound", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", "emote", "fade", "pan_camera", "play_once", "pikachu_make_way", "ss_anne_departs" }) do diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 8f9dd65e..4c776d7b 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -420,15 +420,69 @@ function PikachuFollower.rebase(ow, dx, dy) if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end end +local DIRS = { "up", "down", "left", "right" } + +-- wPikachuCollisionCounter: 8 on a direction change (home/overworld.asm:189), +-- cleared with no d-pad held (:130) and once a step commits (:242) +local function tickCollisionCounter(game, ow, npc) + local input = game.input + local p = ow.player + local dir + if input then + for _, d in ipairs(DIRS) do + if input:isDown(d) then dir = d break end + end + end + if not dir then + ow.pikachuCollisionCounter = 0 + if ow.pikachuMovingDir then + ow.pikachuLastStopDir = ow.pikachuMovingDir + ow.pikachuMovingDir = nil + end + ow.pikachuTurnArmed = true + return + end + if ow.pikachuTurnArmed and dir ~= ow.pikachuLastStopDir then + ow.pikachuCollisionCounter = 8 + ow.pikachuTurnArmed = false + ow.pikachuMovingDir = dir + return + end + ow.pikachuMovingDir = dir + if p.moving then + ow.pikachuCollisionCounter = 0 + return + end + local n = ow.pikachuCollisionCounter or 0 + if n <= 0 then return end + local tx, ty = Collision.target(p.cellX, p.cellY, dir) + if p.facing == dir and npc.cellX == tx and npc.cellY == ty then + ow.pikachuCollisionCounter = n - 1 + end +end + +-- CollisionCheckOnLand's Pikachu branch -- home/overworld.asm:1234-1252 +local function updatePassable(game, ow, npc) + if PikachuFollower.isFollowingDisabled(ow) then + npc.passable = false + ow.pikachuCollisionCounter = 0 + return + end + tickCollisionCounter(game, ow, npc) + if game.input and game.input:isDown("b") then + npc.passable = true + return + end + npc.passable = (ow.pikachuCollisionCounter or 0) <= 0 +end + -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) if ow.pikaHop then return end -- the counter hop owns the follower (#417) local npc = findFollower(ow) -- home/overworld.asm:1238-1240 - if npc then - npc.passable = not PikachuFollower.isFollowingDisabled(ow) - end + if npc then updatePassable(game, ow, npc) end if PikachuFollower.isFollowingDisabled(ow) then return end if not npc then if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end @@ -895,7 +949,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow) local npc = findFollower(ow) if not npc then return end ow.pikachuBillsScene = true - movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function() + local steps = { { "right", 3 }, { "up", 1 } } + -- engine/pikachu/pikachu_follow.asm:59 + if npc.cellX == ow.player.cellX and npc.cellY == ow.player.cellY + and Collision.canMove(ow.map, ow.entities, npc, "right") then + table.insert(steps, 1, { "right", 1 }) + end + movePikachu(ow, npc, steps, function() -- BillsHouse_CheckPikachuEmotion SCRIPT0 -- scripts/BillsHouse_2.asm:88 billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE", 23) end) diff --git a/tests/drivers/bills_pikachu_refix2_2165.lua b/tests/drivers/bills_pikachu_refix2_2165.lua new file mode 100644 index 00000000..d0d85ad6 --- /dev/null +++ b/tests/drivers/bills_pikachu_refix2_2165.lua @@ -0,0 +1,279 @@ +-- #2165 refix 2: home/overworld.asm:1234-1252 (the B bypass and the +-- wPikachuCollisionCounter soft bump), engine/pikachu/pikachu_follow.asm:59 +-- (the companion never starts the confused walk on the player's cell) and +-- engine/events/hidden_events/bills_house_pc.asm:14-40 (268 delay frames plus +-- five WaitForSoundToFinish drains). Self-terminating: it quits LOVE with 0 +-- on all-PASS and 1 otherwise. No POKEPORT_SPEED -- it scales the logic +-- clock only and desyncs the cries against the walk. +-- +-- SHOT_DIR=/tmp/shots2165b POKEPORT_SHOT_DIR=/tmp/shots2165b \ +-- POKEPORT_DRIVER=tests/drivers/bills_pikachu_refix2_2165.lua \ +-- POKEPORT_IDENTITY=yellow-sep04 POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots2165b" + local failures = 0 + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failures = failures + 1 end + return ok + end + + local function finish() + U.log(failures == 0 and "DONE all checks passed" + or ("DONE " .. failures .. " check(s) failed")) + U.log("shots in", SHOT_DIR) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + finish() + end + + local MAP = "BILLS_HOUSE" + + local function resetSave() + game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) } + game.save.player.name = "bryan" + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + game.save.flags.EVENT_MET_BILL = nil + game.save.flags.EVENT_MET_BILL_2 = nil + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + game.save.pikachuMapScriptActive = nil + end + + local ow + local function follower() + for _, n in ipairs(ow and ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local function objectAt(name) + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local function press(dir, frames) + for _ = 1, frames do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + end + + local function release(...) + for _, b in ipairs({ ... }) do game.input.state[b] = false end + U.wait(1) + end + + -- walk the player exactly one cell: hold until the cell index changes, + -- release, then let the in-flight step land + local function step(dir) + local p0 = ow.player + local sx, sy = p0.cellX, p0.cellY + for _ = 1, 60 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + if p0.cellX ~= sx or p0.cellY ~= sy then break end + end + game.input.state[dir] = false + for _ = 1, 40 do + if not p0.moving then break end + coroutine.yield() + end + U.wait(2) + end + + -- ===================================================================== + -- A. the soft bump and the B bypass, both of which only exist while + -- following is ENABLED -- home/overworld.asm:1240-1252 + -- ===================================================================== + resetSave() + game.save.flags.EVENT_MET_BILL_2 = true -- BillsHouseScript0 stays asleep + U.teleport(game, MAP, 3, 6, "up") + U.wait(30) + ow = game.overworld + if not check("Bill's House loaded", ow and ow.map and ow.map.id == MAP) then + finish() + end + check("the confused beat did not run with EVENT_MET_BILL_2 set", + ow.pikachuBillsScene ~= true) + + -- take a step so the companion trails onto the cell behind the player + step("up") + U.wait(20) + local npc = follower() + if not check("the follower spawned and trailed", npc ~= nil) then finish() end + check("a following companion is walk-through when nothing is pressed", + npc.passable == true and (ow.pikachuCollisionCounter or 0) == 0) + + local p = ow.player + local backDir = (npc.cellY > p.cellY and "down") + or (npc.cellY < p.cellY and "up") + or (npc.cellX > p.cellX and "right") or "left" + U.log(string.format("A player=(%d,%d)%s pika=(%d,%d) push=%s", + p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY, backDir)) + + local startX, startY = p.cellX, p.cellY + local seeded, blockedTicks, movedAt = 0, 0, nil + for i = 1, 40 do + table.insert(game.input.pressQueue, backDir) + game.input.state[backDir] = true + coroutine.yield() + seeded = math.max(seeded, ow.pikachuCollisionCounter or 0) + if p.cellX ~= startX or p.cellY ~= startY or p.moving then + movedAt = i + break + end + if p.facing == backDir and npc.passable == false then + blockedTicks = blockedTicks + 1 + end + end + release(backDir) + U.log("A seeded=" .. seeded .. " blockedTicks=" .. blockedTicks + .. " movedAt=" .. tostring(movedAt)) + check("turning toward the companion seeds the 8-count bump", seeded == 8) + check("the bump holds the player for several frames, then yields", + blockedTicks >= 4 and movedAt ~= nil) + + U.wait(20) + npc = follower() + p = ow.player + backDir = (npc.cellY > p.cellY and "down") or (npc.cellY < p.cellY and "up") + or (npc.cellX > p.cellX and "right") or "left" + startX, startY = p.cellX, p.cellY + local bWalkedThrough = false + game.input.state.b = true + for _ = 1, 40 do + table.insert(game.input.pressQueue, "b") + table.insert(game.input.pressQueue, backDir) + game.input.state[backDir] = true + coroutine.yield() + if p.cellX ~= startX or p.cellY ~= startY or p.moving then + bWalkedThrough = true + break + end + end + release(backDir, "b") + check("holding B walks straight through the companion", bWalkedThrough) + + -- ===================================================================== + -- B. the confused walk lands directly below Bill + -- (scripts/BillsHouse_2.asm:125 from the emerged cell) + -- ===================================================================== + resetSave() + U.teleport(game, MAP, 2, 7, "up") + U.wait(20) + ow = game.overworld + npc = follower() + if not check("the follower spawned for the confused beat", npc ~= nil) then + finish() + end + check("the confused beat started", ow.pikachuBillsScene == true) + check("the scripted walk uses the 16-frame Pikachu step", + npc.stepFrames == 16) + + for _ = 1, 600 do + if ow.emote and ow.emote.pikaPic then break end + U.wait(1) + end + for _ = 1, 900 do + if not ow.emote then break end + U.wait(1) + end + U.wait(10) + + local bill = objectAt("BILLSHOUSE_BILL_POKEMON") + npc = follower() + U.log(string.format("B pika=(%d,%d) bill=(%s,%s)", + npc.cellX, npc.cellY, + bill and bill.cellX or "?", bill and bill.cellY or "?")) + check("the confused walk ends directly below Bill (6,6)", + npc.cellX == 6 and npc.cellY == 6) + check("which is Bill's own column, one row south", + bill ~= nil and npc.cellX == bill.cellX and npc.cellY == bill.cellY + 1) + U.shot(game, SHOT_DIR .. "/01_confused_walk_ends_below_bill.png") + + -- ===================================================================== + -- C. the parked companion is solid -- home/overworld.asm:1238-1240 + -- ===================================================================== + p = ow.player + for _ = 1, 4 do step("right") end + U.wait(10) + U.log(string.format("C player=(%d,%d)%s pika=(%d,%d) passable=%s", + p.cellX, p.cellY, p.facing, npc.cellX, npc.cellY, + tostring(npc.passable))) + check("the player reached the cell south of the companion", + p.cellX == 6 and p.cellY == 7) + check("and following is still disabled, so the companion is solid", + npc.passable == false) + startX, startY = p.cellX, p.cellY + press("up", 60) + release("up") + check("pushing north into the parked companion never moves the player", + p.cellX == startX and p.cellY == startY) + U.shot(game, SHOT_DIR .. "/02_solid_companion_blocks_player.png") + + -- ===================================================================== + -- D. the cell separator runs the cartridge's 268 delay frames plus five + -- WaitForSoundToFinish drains -- bills_house_pc.asm:14-40, :51-63 + -- ===================================================================== + game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = nil + U.teleport(game, MAP, 1, 5, "up") + U.wait(20) + ow = game.overworld + U.tap(game, "a") + U.wait(10) + for _ = 1, 200 do -- type + prompt-button the initiated text away + U.tap(game, "a") + U.wait(2) + if ow.runner and ow.runner:isRunning() then break end + if #(ow.pendingScripts or {}) > 0 then break end + end + + local chainFrames, shot = 0, false + for _ = 1, 1200 do + if game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then break end + chainFrames = chainFrames + 1 + U.wait(1) + if not shot and chainFrames == 150 then + shot = true + U.shot(game, SHOT_DIR .. "/03_cell_separator_running.png") + end + end + -- 268 asm delay frames less the handful the text-box drain eats before + -- this loop starts; the pre-fix chain was 222 and measures ~218 here + U.log("D chainFrames=" .. chainFrames .. " (asm budget 268 + five SFX)") + check("the separator chain runs the cartridge's ~268 delay frames", + chainFrames >= 255) + check("and it sets EVENT_USED_CELL_SEPARATOR_ON_BILL only at the end", + game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL == true) + + for _ = 1, 600 do + if objectAt("BILLSHOUSE_BILL1") then break end + U.wait(1) + end + U.wait(30) + check("Bill steps out of the machine when the chain ends", + objectAt("BILLSHOUSE_BILL1") ~= nil) + U.shot(game, SHOT_DIR .. "/04_bill_out_of_the_machine.png") + + finish() +end diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua index b6986fe1..1f80994f 100644 --- a/tests/parity_bills_pc.lua +++ b/tests/parity_bills_pc.lua @@ -85,8 +85,9 @@ check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub), local realMenu = package.loaded["src.ui.Menu"] package.loaded["src.ui.Menu"] = menuStub +local queued local fakeSelf = setmetatable({ - queueScript = function() end, + queueScript = function(_, script, extra) queued = { script, extra } end, billsHouseBillExits = function() end, }, { __index = OW }) @@ -127,11 +128,45 @@ package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end, } +queued = nil runPC() check(musicStopped, "cell separator stops map music") check(tostring(lastPush().text):find("Cell", 1, true) or tostring(lastPush().text):find("TELEPORTER", 1, true), "separator path prints initiated text") + +-- bills_house_pc.asm:14-40 plus BillsHouseInitiatedText's text_asm at :51-63: +-- 268 delay frames, and every PlaySound is followed by WaitForSoundToFinish +-- before the next delay starts. +local wantSeparator = { + { "wait", 16 }, + { "play_sound", "Switch" }, { "wait_sound" }, + { "wait", 60 }, + { "wait", 32 }, + { "play_sound", "Tink" }, { "wait_sound" }, + { "wait", 80 }, + { "play_sound", "Shrink" }, { "wait_sound" }, + { "wait", 48 }, + { "play_sound", "Tink" }, { "wait_sound" }, + { "wait", 32 }, + { "play_sound", "Get_Item1" }, { "wait_sound" }, +} +check(queued ~= nil, "the separator queues its cutscene script") +local gotRows, delayFrames = {}, 0 +for _, row in ipairs(queued and queued[1] or {}) do + gotRows[#gotRows + 1] = table.concat({ tostring(row[1]), tostring(row[2]) }, " ") + if row[1] == "wait" then delayFrames = delayFrames + row[2] end +end +local wantRows = {} +for _, row in ipairs(wantSeparator) do + wantRows[#wantRows + 1] = table.concat({ tostring(row[1]), tostring(row[2]) }, " ") +end +eq(table.concat(gotRows, " | "), table.concat(wantRows, " | "), + "the separator chain matches bills_house_pc.asm delay for delay") +eq(delayFrames, 268, "268 delay frames, DelayFrames call for DelayFrames call") +check(not fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, + "SetEvent waits for the chain, not the text box") +queued[2].onDone() check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, "separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL") diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index f3694915..99258718 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -248,5 +248,104 @@ for _, scene in ipairs({ "pikachuBillsScene", "pikachuFanClubScene", check(npc.passable, scene .. " ending lets the player walk through again") end +-- The other two states of CollisionCheckOnLand's Pikachu branch +-- (home/overworld.asm:1234-1252): B held walks straight through, and +-- wPikachuCollisionCounter is a soft 8-count bump seeded on a direction +-- change (:189) and cleared with no d-pad held (:130) or once a step +-- commits (:242). +local held = {} +yellowGame.input = { isDown = function(_, b) return held[b] == true end } +local cp = ow.player +cp.cellX, cp.cellY, cp.facing, cp.moving = 3, 7, "up", false +npc.cellX, npc.cellY = 3, 6 + +local function tick() + ow.npcs, ow.entities = { npc }, { npc } + PikachuFollower.update(yellowGame, ow) +end + +held = {} +tick() +check(npc.passable and ow.pikachuCollisionCounter == 0, + "no d-pad held clears the counter and the companion is walk-through") + +held = { up = true } +tick() +check(ow.pikachuCollisionCounter == 8 and not npc.passable, + "turning to a new direction seeds the 8-count bump") + +local blocked = 0 +for _ = 1, 8 do + tick() + if not npc.passable then blocked = blocked + 1 end +end +check(blocked == 7, + "the bump blocks seven pushes and then yields, like dec [hl] / jr nz") +check(npc.passable and ow.pikachuCollisionCounter == 0, + "the drained counter leaves the companion passable") + +held = {} +tick() +held = { up = true } +tick() +check(ow.pikachuCollisionCounter == 0, + "re-pressing the same direction after a release does not re-seed") + +held = {} +tick() +held = { left = true } +tick() +check(ow.pikachuCollisionCounter == 8, "a new direction seeds again") +held = { left = true, b = true } +tick() +check(npc.passable, "holding B walks straight through the bump") + +held = { left = true } +cp.moving = true +tick() +check(ow.pikachuCollisionCounter == 0 and npc.passable, + "a committed step clears the counter") +cp.moving = false +held = {} +tick() +yellowGame.input = nil + +-- CalculatePikachuPlacementCoords never leaves the companion on the +-- player's own cell (engine/pikachu/pikachu_follow.asm:59), so +-- BillsHousePikachuConfused's three STEP_RIGHTs start one cell east of the +-- door and land it directly below Bill. +local emergeMoves = {} +local emergeOw = { + map = { + id = "BILLS_HOUSE", + def = { tileset = "INTERIOR" }, + inBounds = function() return true end, + isWalkableCell = function() return true end, + isWaterCell = function() return false end, + cellTile = function() return 0 end, + }, + npcs = {}, entities = {}, + player = { cellX = 2, cellY = 7, facing = "up" }, + scriptMove = function(_, entity, dir, tiles, onDone) + emergeMoves[#emergeMoves + 1] = { dir = dir, tiles = tiles, onDone = onDone } + end, +} +local emergeNpc = { + pikachuFollower = true, cellX = 2, cellY = 7, px = 32, py = 112, + facing = "up", passable = true, +} +emergeOw.npcs[1], emergeOw.entities[1] = emergeNpc, emergeNpc +yellowGame.save.flags = {} +yellowGame.save.pikachuMapScriptActive = nil +PikachuFollower.onBillsHouseEnter(yellowGame, emergeOw) +check(#emergeMoves == 1 and emergeMoves[1].dir == "right" + and emergeMoves[1].tiles == 1, + "a companion still stacked on the player steps off before the walk") +emergeNpc.cellX = 3 +emergeMoves[1].onDone() +check(#emergeMoves == 2 and emergeMoves[2].dir == "right" + and emergeMoves[2].tiles == 3, + "then runs PikachuMovement_Confused's three STEP_RIGHTs") + GameVersion.set("red") S.finish()