From 43957922260143126967aadb021cbb007e04ef34 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 14 Aug 2026 11:41:54 -0400 Subject: [PATCH] CLOSES #1283 --- data/scripts/story2.lua | 10 ++- data/scripts/story5.lua | 7 +- tests/drivers/escort_lockstep_test.lua | 40 ++++++++++++ tests/parity_escort_lockstep.lua | 88 ++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 tests/drivers/escort_lockstep_test.lua create mode 100644 tests/parity_escort_lockstep.lua diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index aa8fb23d..b414b804 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -178,7 +178,8 @@ M.PALLET_TOWN = { end end - local function enterLab() + local function enterLab(oak) + if oak then oak.stepFrames = nil end Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK") Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2") ow.doorWarp = true @@ -187,12 +188,17 @@ M.PALLET_TOWN = { end local function walkToLab(oak) + -- lockstep half runs Oak on the player's own frames per cell + -- engine/overworld/movement.asm:737 (DoScriptedNPCMovement) local i = 0 + if oak then + oak.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames + end local function tick() i = i + 1 local playerStep = escort.playerSteps[i] if not playerStep then - enterLab() + enterLab(oak) return end if oak and escort.oakSteps[i] then diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index aaba5178..2c580f41 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -446,7 +446,7 @@ local function pewterGymEscort(game, ow) end local function afterWalk() - if guy then guy.facing = "left" end + if guy then guy.stepFrames, guy.facing = nil, "left" end Music.playMap(game.data, "PEWTER_CITY") push(game, t._PewterCityYoungsterGoTakeOnBrockText or "Go take on BROCK\nat the GYM first!", walkHome) @@ -469,6 +469,11 @@ local function pewterGymEscort(game, ow) end local function beginWalk() + -- the escort runs the youngster on the player's own frames per cell + -- engine/overworld/movement.asm:737 (DoScriptedNPCMovement) + if guy then + guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames + end Music.play(game.data, "Music_MuseumGuy") if guy and head > 0 then local h = 0 diff --git a/tests/drivers/escort_lockstep_test.lua b/tests/drivers/escort_lockstep_test.lua new file mode 100644 index 00000000..f52a803c --- /dev/null +++ b/tests/drivers/escort_lockstep_test.lua @@ -0,0 +1,40 @@ +-- Driver: Oak's Pallet Town escort. Logs the player/Oak separation the +-- whole way down and shoots the walk mid-street, so the formation is +-- checkable by eye as well as by number (the escort holds one cell, +-- ~16px; a desynced Oak drifts off by a cell per two steps). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 3, "up") + local ow = game.overworld + local function oak() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "PALLETTOWN_OAK" then return n end + end + end + U.hold(game, "up", 40) + local worst, samples = 0, 0 + for _ = 1, 1200 do + if game.stack:top() ~= ow then U.tap(game, "a") end + local o = oak() + if o and #ow.scriptMoves > 0 and ow.map.id == "PALLET_TOWN" then + local d = math.abs(o.px - ow.player.px) + math.abs(o.py - ow.player.py) + if samples > 0 or d <= 20 then + samples = samples + 1 + if d > worst then worst = d end + if samples == 60 then U.shot(game, DIR .. "/escort_midwalk.png") end + if samples % 24 == 1 then + U.log(("t=%d player=(%d,%d) oak=(%d,%d) dist=%dpx") + :format(samples, ow.player.cellX, ow.player.cellY, + o.cellX, o.cellY, d)) + end + end + end + if ow.map.id == "OAKS_LAB" then break end + U.wait(2) + end + U.log(("ESCORT worst separation: %dpx over %d samples"):format(worst, samples)) + U.log("map:", ow.map.id, "flag:", + tostring(game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB)) + love.event.quit() +end diff --git a/tests/parity_escort_lockstep.lua b/tests/parity_escort_lockstep.lua new file mode 100644 index 00000000..4fc567df --- /dev/null +++ b/tests/parity_escort_lockstep.lua @@ -0,0 +1,88 @@ +-- Parity test: escort lockstep cadence. An NPC walking the player to a +-- destination moves under DoScriptedNPCMovement (engine/overworld/ +-- movement.asm:737), whose wScriptedNPCWalkCounter is 8 ticks of 2px -- +-- the player's own frames per cell, not MoveSprite's doubled NPC walk. +-- Self-contained: run via `luajit tests/parity_escort_lockstep.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +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 escort lockstep") +local check, eq = S.check, S.eq + +local SaveData = require("src.core.SaveData") +local Game = require("src.core.Game") +local StateStack = require("src.core.StateStack") +local OverworldState = require("src.world.OverworldController") + +local prev = { data = Game.data, save = Game.save, stack = Game.stack, + input = Game.input, renderer = Game.renderer, + overworld = Game.overworld } +Game.data = Data +Game.save = SaveData.newGame(Data) +Game.save.player.name = "RED" +StateStack:init() +Game.stack = StateStack +Game.input = { + isDown = function() return false end, + wasPressed = function() return false end, + step = function() end, state = {}, pressQueue = {}, +} +Game.renderer = { + beginWorldPass = function() end, endWorldPass = function() end, + beginUIPass = function() end, endUIPass = function() end, + worldViewSize = function() return 160, 144 end, + setSGBZones = function() end, +} +StateStack:push(OverworldState, "PALLET_TOWN", 10, 5, "down") +local ow = OverworldState +Game.overworld = ow + +-- A PALLET_TOWN object parked one cell ahead of the player, then walked +-- in the paired-step pattern the escorts use: the NPC's step is queued +-- without a callback and the player's carries the chain. +local function runEscort(sync, steps) + local npc = ow.npcs[1] + local p = ow.player + p.cellX, p.cellY, p.px, p.py = 10, 5, 160, 80 + p.moving, p.progress, p.targetX, p.targetY = false, 0, nil, nil + npc.cellX, npc.cellY, npc.px, npc.py = 10, 4, 160, 64 + npc.moving, npc.progress, npc.targetX, npc.targetY = false, 0, nil, nil + ow.scriptMoves = {} + npc.stepFrames = sync and (p.stepFramesCur or p.stepFrames) or nil + local worstDrift, done, i = 0, false, 0 + local function tick() + i = i + 1 + if i > steps then done = true; return end + ow:scriptMove(npc, "down", 1) + ow:scriptMove(p, "down", 1, tick) + end + tick() + -- NPCs update before updateScriptMoves and the player after, so a + -- paired step leaves the NPC one frame (1px) behind for its whole cell + for _ = 1, 60 * steps do + ow:update(1) + local drift = math.abs((p.py - npc.py) - 16) + if drift > worstDrift then worstDrift = drift end + if done then break end + end + npc.stepFrames = nil + return { done = done, drift = worstDrift, + npcY = npc.cellY, playerY = p.cellY } +end + +local synced = runEscort(true, 6) +check(synced.done, "synced escort finishes") +check(synced.drift <= 2, + ("synced NPC holds formation (worst drift %dpx)"):format(synced.drift)) +eq(synced.playerY - synced.npcY, 1, "synced NPC stays one cell ahead") + +-- The default NPC walk is MoveSprite's, half the player's rate: without +-- the sync the escort desyncs, which is the bug this guards. +local plain = runEscort(false, 6) +check(plain.drift >= 16, + ("unsynced NPC lags a cell or more (worst drift %dpx)"):format(plain.drift)) + +for k, v in pairs(prev) do Game[k] = v end +S.finish()