yellow: Bill's house Pikachu timing, collision and spawn cell, CLOSES #2165

This commit is contained in:
bryanthaboi
2026-09-04 10:45:02 -04:00
parent e9cf07ef47
commit e6e3459dbe
5 changed files with 496 additions and 6 deletions
+18 -1
View File
@@ -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 <soundId>: 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
+64 -4
View File
@@ -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)
+279
View File
@@ -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
+36 -1
View File
@@ -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")
+99
View File
@@ -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()