From c2334f2f91536e4a0808666fe7684bd0e894b9fb Mon Sep 17 00:00:00 2001 From: johnjohto Date: Tue, 28 Jul 2026 08:07:17 -0400 Subject: [PATCH] Gate overworld buttons on the completed step (#286) OverworldLoop (home/overworld.asm) jumps straight to .moveAhead while wWalkCounter is nonzero: JoypadOverworld -- the START check, the A check, and direction initiation -- only ever runs while the player stands on a tile, and a button pressed mid-step is simply never seen. The port ran handleInput() every frame regardless, so a mid-step A/START pushed its TextBox/StartMenu right there and froze Red between tiles, mid-animation (the Nurse Joy run-up in the report). Gate handleInput on player.moving like the original. The port-invented wall-bonk SFX cooldown is hoisted above the gate so it keeps ticking on held-direction frames mid-step exactly as before. Adds tests/parity_midstep_buttons.lua: mid-step and last-frame A/START presses are swallowed and the step completes, and both buttons work again once the player stands on the tile. --- src/world/OverworldController.lua | 19 ++++- tests/parity_midstep_buttons.lua | 115 ++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/parity_midstep_buttons.lua diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1fe9b3ef..afeffecc 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -938,6 +938,24 @@ end function OverworldState:handleInput() local input = Game.input + -- the wall-bonk SFX cooldown ticks with any held direction, step or not + -- (it is a port invention, not part of JoypadOverworld, so the + -- wWalkCounter gate below must not freeze it mid-step) + if self:dirHeld() then + self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1) + end + + -- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on + -- wWalkCounter == 0 ("if the player sprite has not yet completed the + -- walking animation" it jumps straight to .moveAhead): A, START and + -- direction initiation are only ever looked at while the player stands + -- on a tile, and a button pressed mid-step is simply never seen. + -- Without this gate a mid-step A/START pushed its TextBox/StartMenu + -- right there and froze Red between tiles, mid-animation (#286). Held + -- directions need no buffering -- isDown below picks them up on the + -- landing frame. + if self.player.moving then return end + if input:wasPressed("a") then self:interact() return @@ -974,7 +992,6 @@ function OverworldState:handleInput() self.bumpCooldown = 16 end end - self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1) return result end end diff --git a/tests/parity_midstep_buttons.lua b/tests/parity_midstep_buttons.lua new file mode 100644 index 00000000..d5fb2833 --- /dev/null +++ b/tests/parity_midstep_buttons.lua @@ -0,0 +1,115 @@ +-- Parity test: A/START are never handled mid-step (#286). +-- Self-contained: run via `luajit tests/parity_midstep_buttons.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +-- +-- Oracle: home/overworld.asm OverworldLoop reads wWalkCounter and, when it +-- is nonzero ("the player sprite has not yet completed the walking +-- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with +-- it the START check, the A check, and every direction initiation, only +-- ever runs while the player stands on a tile. A button pressed mid-step +-- is simply never seen. +-- +-- The port ran handleInput() every frame regardless of player.moving, so a +-- mid-step A/START press pushed its TextBox/StartMenu right there and +-- froze Red between tiles, mid-animation (#286: running up to Nurse Joy +-- and mashing A stops him half off the tile). +-- +-- The invariant: while a step is in progress, A and START change nothing +-- (no TextBox, no StartMenu, the step completes); once the player stands +-- on the tile again, both work. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity midstep buttons") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack +StateStack:init() + +-- PALLET_TOWN (6,9) facing down: open grass, several free tiles south +Game.save = SaveData.newGame() +Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down") +local ow = Game.stack:top() + +local function step(pressedBtn) + -- the real driver: Game:step promotes pressQueue edges via Input:step() + -- (which also expires them) before stack:update + if pressedBtn then table.insert(Input.pressQueue, pressedBtn) end + Input:step() + ow:update(1 / 60) +end + +-- start a step south (held direction, like hJoyHeld) +Input.state.down = true +step() +Input.state.down = false +check(ow.player.moving, "held direction starts a step") +local startY = ow.player.cellY + +-- spy on interact(): a mid-step A press must not even reach it +local interactCalls = 0 +local baseInteract = ow.interact +ow.interact = function(self, ...) + interactCalls = interactCalls + 1 + return baseInteract(self, ...) +end + +-- mid-step A press: nothing may happen (the original never sees it) +step("a") +eq(interactCalls, 0, "mid-step A never reaches interact()") +check(Game.stack:top() == ow, "mid-step A pushes no TextBox") +check(ow.player.moving, "mid-step A does not interrupt the step") + +-- mid-step START press: no start menu either +step("start") +check(Game.stack:top() == ow, "mid-step START opens no menu") +check(ow.player.moving, "mid-step START does not interrupt the step") + +-- run the step out: the player lands on the next tile, unfrozen +local guard = 0 +while ow.player.moving and guard < 60 do step(); guard = guard + 1 end +eq(ow.player.cellY, startY + 1, "the step completes onto the next tile") + +-- the issue's actual repro ("press A quickly/early" running up to Nurse +-- Joy): start another step and press A on its FINAL mid-step frame +Input.state.down = true +step() +Input.state.down = false +check(ow.player.moving, "second step starts") +guard = 0 +while ow.player.moving and guard < 60 do + guard = guard + 1 + if guard == (ow.player.stepFramesCur or 16) - 1 then + step("a") -- the last frame before landing + else + step() + end +end +check(not ow.player.moving, "the second step completes") +eq(interactCalls, 0, "a last-frame A press is still swallowed (no buffering)") +check(Game.stack:top() == ow, "last-frame A pushes no TextBox") + +-- standing on the tile again, START and A work as always +step("start") +check(Game.stack:top() ~= ow, "START opens the start menu on a tile") +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down") +ow = Game.stack:top() +interactCalls = 0 -- OW is a singleton: the spy survives the re-push +step("a") +eq(interactCalls, 1, "A on a tile runs interact() (the gate is movement-only)") + +S.finish()