-- Parity test, Workstream C. -- Self-contained: run via `luajit tests/parity_C.lua`; also dofile'd by -- tests/run_tests.lua's aggregator. -- -- Oracle: pokered/engine/events/elevator.asm (DisplayElevatorFloorMenu), -- pokered/engine/overworld/elevator.asm (ShakeElevator), -- pokered/scripts/SilphCoElevator.asm / CeladonMartElevator.asm / -- RocketHideoutElevator.asm (floor tables), pokered/data/items/names.asm -- (short FLOOR_* tokens). 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 C") local check, eq = S.check, S.eq -- empty a table in place (rebinding the local wouldn't reach closures -- that already captured the original table, e.g. ow.startWarpTo below) local function clear(t) for i = #t, 1, -1 do t[i] = nil end end local mapScripts = require("data.scripts.init") local ListMenu = require("src.ui.ListMenu") local Sound = require("src.core.Sound") local Map = require("src.world.Map") local Warp = require("src.world.Warp") -- synchronous takeWarp: after the ride the car's exit warps are rewritten -- and the player walks out onto one under their own control, so the test -- takes that warp itself instead of expecting a scripted walk-out. -- the Rocket Hideout keyGate path pushes a TextBox, which needs the font -- loaded (like tests/run_tests.lua does before any TextBox use) local Font = require("src.render.Font") Font.load(Data) -- spy on Sound.play so we can see the arrival-SFX beat without real audio local sfxCalls local origSoundPlay = Sound.play Sound.play = function(data, name) sfxCalls[#sfxCalls + 1] = name return origSoundPlay(data, name) end -- fake StateStack: just enough for ListMenu:close()/onCancel's game.stack use local function newStack() local items = {} local stack = {} function stack:push(item) items[#items + 1] = item end function stack:pop() items[#items] = nil end function stack:top() return items[#items] end return stack, items end -- snapshot / restore car warps so tests that seed or ride do not leak -- across cases (Silph's ROM default is UNUSED_MAP_ED) local function snapshotWarps(mapId) local snap = {} for i, w in ipairs(Data.maps[mapId].warps) do snap[i] = { destMap = w.destMap, destWarp = w.destWarp } end return snap end local function restoreWarps(mapId, snap) for i, w in ipairs(Data.maps[mapId].warps) do w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp end end -- the car's panel bg_event (data/maps/objects/CeladonMartElevator.asm -- etc.): the TEXT_ constant whose talk script opens the floor menu local PANEL_TEXT = { SILPH_CO_ELEVATOR = "TEXT_SILPHCOELEVATOR_ELEVATOR", CELADON_MART_ELEVATOR = "TEXT_CELADONMARTELEVATOR", ROCKET_HIDEOUT_ELEVATOR = "TEXT_ROCKETHIDEOUTELEVATOR", } -- drives one elevator map's onEnter and then its panel talk script, and -- returns the pushed state (either a ListMenu or, for the keyGated Rocket -- Hideout without the key, a TextBox). fromMapId is the floor the player -- entered from (OverworldState:setMap passes it), used to seed a -- cancel-safe exit. local function openElevator(mapId, inventory, fromMapId) local script = mapScripts.get(mapId) check(script ~= nil, mapId .. " script registered") check(script and script.onEnter ~= nil, mapId .. " has onEnter") local stack, items = newStack() local warpCalls = {} local ow = {} -- the real elevator car map (built without the tile renderer -- Map.new -- is pure data), plus the player standing on the exit tile they warped -- in onto (the true arrival cell) local carDef = Data.maps[mapId] ow.map = Map.new(carDef, Data.tilesets[carDef.tileset]) local firstWarp = carDef.warps[1] ow.player = { cellX = firstWarp.x, cellY = firstWarp.y, facing = "up" } function ow:startWarpTo(map, x, y, facing) warpCalls[#warpCalls + 1] = { map = map, x = x, y = y, facing = facing } end -- resolve the (rewritten) warp entry like OverworldState:takeWarp does function ow:takeWarp(warpDef) local destMap, x, y = Warp.destination(Data, warpDef, self.lastOutdoor) warpCalls[#warpCalls + 1] = { map = destMap, x = x, y = y, facing = self.player.facing } end local game = { data = Data, save = { inventory = inventory or {}, player = { name = "RED", rival = "BLUE" } }, stack = stack, } sfxCalls = {} script.onEnter(game, ow, fromMapId) -- #395: entry only seeds the car's exit warps; the floor menu belongs -- to the panel bg_event, so nothing may be pushed yet eq(#items, 0, mapId .. " onEnter opens no menu") local panel = script.talk and script.talk[PANEL_TEXT[mapId]] check(panel ~= nil, mapId .. " panel bg_event has a talk script") if panel then panel(game, ow, nil, function() end) end return items[#items], warpCalls, stack, ow end -- step the ElevatorShake state (pokered ShakeElevator) frame by frame -- until it pops itself; returns how many frames the ride took plus the -- observed scroll-offset trace (first nonzero frame/value, both signs -- seen). Headless the SFX_SAFARI_ZONE_PA source never sounds, so the -- .musicLoop wait resolves on the frame after the 100 cycles. local function rideOut(stack, shake, ow) local steps, firstFrame, firstOffset = 0, nil, nil local sawUp, sawDown = false, false while stack:top() == shake and steps < 400 do shake:update(1 / 60) steps = steps + 1 local o = ow.bgShakeY or 0 if o ~= 0 and not firstFrame then firstFrame, firstOffset = steps, o end if o > 0 then sawUp = true elseif o < 0 then sawDown = true end end return steps, firstFrame, firstOffset, sawUp and sawDown end local function countSfx(name) local n = 0 for _, s in ipairs(sfxCalls) do if s == name then n = n + 1 end end return n end -- =================================================================== -- Silph Co elevator: 11 floors, the double-digit sort/label regression -- =================================================================== do local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR") -- ROM default (UNUSED_MAP_ED) must still be what we start from so the -- cancel-then-exit regression below is real eq(silphSnap[1].destMap, "UNUSED_MAP_ED", "Silph Co ROM car warps still default to UNUSED_MAP_ED") local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F") check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu") if menu then eq(#menu.items, 11, "Silph Co elevator lists all 11 floors") local wantOrder = { "1F", "2F", "3F", "4F", "5F", "6F", "7F", "8F", "9F", "10F", "11F" } for i, want in ipairs(wantOrder) do local item = menu.items[i] eq(item and item.label, want, "Silph Co floor " .. i .. " label/order") end -- labels are short floor tokens, never the full source map id check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"), "Silph Co floor label is the short token, not the full map id") -- onEnter seeds the car's exit to the floor we came from so a B-cancel -- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out) eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F", "Silph onEnter seeds exit warps to the entry floor") check(Data.maps[ow.map.def.warps[1].destMap] ~= nil, "seeded Silph exit map exists in Data.maps") -- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B -- -- no warp at all. clear(warpCalls) menu.onCancel() eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)") -- #123: after B-cancel, walking out of the car must resolve without -- asserting on the ROM placeholder UNUSED_MAP_ED clear(warpCalls) local okExit, errExit = pcall(function() ow:takeWarp(ow.map.def.warps[1]) end) check(okExit, "cancel then exit does not crash: " .. tostring(errExit)) eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp") if warpCalls[1] then eq(warpCalls[1].map, "SILPH_CO_5F", "cancel then exit returns to the floor the player entered from") end -- Choose a mid-list floor (5F): pokered never warps on the spot -- -- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the -- map script runs ShakeElevator: a 12-frame lead-in (the script's -- Delay3 + ShakeElevator's own 9 frames of Delay3s), then 100 -- two-frame cycles of hSCY bouncing -1/+1 with SFX_COLLISION each -- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp. clear(warpCalls) sfxCalls = {} local chosen = menu.items[5] -- "5F" menu.onChoose(chosen, menu) eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)") local shake = stack:top() check(shake ~= nil and getmetatable(shake) ~= ListMenu and shake.update ~= nil, "choosing a floor pushes the ElevatorShake state") local steps, firstFrame, firstOffset, bothWays = rideOut(stack, shake, ow) eq(steps, 12 + 200 + 1, "Silph ride: 12 lead-in frames + 100 2-frame cycles + PA poll") eq(firstFrame, 13, "first scroll write lands right after the 12-frame lead-in") eq(firstOffset, -1, "first hSCY offset is -1 (ld e, $1 then xor $fe)") check(bothWays, "the scroll oscillates both ways (-1/+1)") eq(ow.bgShakeY, 0, "hSCY restored to rest after the ride") eq(countSfx("Collision"), 100, "SFX_COLLISION plays once per shake cycle (ld b, 100)") eq(countSfx("Safari_Zone_PA"), 1, "SFX_SAFARI_ZONE_PA plays once") eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "SFX_SAFARI_ZONE_PA caps the ride") -- .UpdateWarp rewrites the car's exit warp entries to the chosen -- floor, then the player walks out onto that warp (no jump cut) eq(ow.map.def.warps[1].destMap, chosen.value.map, "the car's exit warp is rewritten to the chosen floor's map") eq(#warpCalls, 0, "the ride never warps: the player walks out of the car themselves") ow:takeWarp(ow.map.def.warps[1]) eq(#warpCalls, 1, "walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "walk-out lands on the chosen floor's map") eq(warpCalls[1].x, chosen.value.x, "walk-out lands on the chosen floor's x") eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y") end end restoreWarps("SILPH_CO_ELEVATOR", silphSnap) end -- =================================================================== -- Celadon Mart elevator: 5 floors, single-digit control (should already -- have passed lexicographically -- non-regression check) -- =================================================================== do local menu, warpCalls, stack, ow = openElevator("CELADON_MART_ELEVATOR") check(menu ~= nil and getmetatable(menu) == ListMenu, "CELADON_MART_ELEVATOR opens a ListMenu") if menu then eq(#menu.items, 5, "Celadon Mart elevator lists all 5 floors") local wantOrder = { "1F", "2F", "3F", "4F", "5F" } for i, want in ipairs(wantOrder) do eq(menu.items[i] and menu.items[i].label, want, "Celadon Mart floor " .. i .. " label/order") end clear(warpCalls) menu.onCancel() eq(#warpCalls, 0, "Celadon Mart cancel does not warp") -- CeladonMartElevatorShakeScript farjps straight into ShakeElevator: -- no extra Delay3, so the lead-in is only ShakeElevator's own 9 frames clear(warpCalls) sfxCalls = {} local chosen = menu.items[3] -- "3F" menu.onChoose(chosen, menu) eq(#warpCalls, 0, "Celadon Mart choose does not warp on the spot") local shake = stack:top() local steps, firstFrame = rideOut(stack, shake, ow) eq(steps, 9 + 200 + 1, "Celadon ride: 9 lead-in frames (farjp, no extra Delay3) + shake + PA poll") eq(firstFrame, 10, "Celadon first scroll write follows the 9-frame lead-in") eq(countSfx("Collision"), 100, "Celadon Mart shake thuds 100 times") eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Celadon Mart ride ends on the PA chime") eq(ow.map.def.warps[1].destMap, chosen.value.map, "Celadon Mart car exit warp rewritten to the chosen floor") eq(#warpCalls, 0, "Celadon Mart ride never warps on its own") ow:takeWarp(ow.map.def.warps[1]) eq(#warpCalls, 1, "Celadon Mart walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "Celadon Mart walk-out lands on the chosen floor map") eq(warpCalls[1].x, chosen.value.x, "Celadon Mart walk-out lands on the chosen floor x") eq(warpCalls[1].y, chosen.value.y, "Celadon Mart walk-out lands on the chosen floor y") end end end -- =================================================================== -- Rocket Hideout elevator: B1F/B2F/B4F ordering (numeric-not-lexical on -- the digit only), plus the LIFT_KEY gate. -- =================================================================== do local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR") -- ROM car defaults to B1F; entering from another floor without the key -- must still seed a walk-out back to that floor (#90 / #105). eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F", "Rocket Hideout ROM car warps still default to B1F") -- without the key: text-only, no floor menu, but exit is seeded local gated, _, _, gatedOw = openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F") check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something") check(gated ~= nil and getmetatable(gated) ~= ListMenu, "Rocket Hideout without LIFT_KEY does not open the floor menu") eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F", "Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor") restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap) -- with the key: full B1F/B2F/B4F menu, same as the other elevators local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 }) check(menu ~= nil and getmetatable(menu) == ListMenu, "Rocket Hideout with LIFT_KEY opens a ListMenu") if menu then eq(#menu.items, 3, "Rocket Hideout elevator lists all 3 floors") local wantOrder = { "B1F", "B2F", "B4F" } for i, want in ipairs(wantOrder) do eq(menu.items[i] and menu.items[i].label, want, "Rocket Hideout floor " .. i .. " label/order") end clear(warpCalls) menu.onCancel() eq(#warpCalls, 0, "Rocket Hideout cancel does not warp") -- RocketHideoutElevatorShakeScript is `call Delay3 / farcall -- ShakeElevator` like Silph's: 12 lead-in frames clear(warpCalls) sfxCalls = {} local chosen = menu.items[2] -- "B2F" menu.onChoose(chosen, menu) eq(#warpCalls, 0, "Rocket Hideout choose does not warp on the spot") local shake = stack:top() local steps = rideOut(stack, shake, ow) eq(steps, 12 + 200 + 1, "Rocket Hideout ride: 12 lead-in frames + shake + PA poll") eq(countSfx("Collision"), 100, "Rocket Hideout shake thuds 100 times") eq(sfxCalls[#sfxCalls], "Safari_Zone_PA", "Rocket Hideout ride ends on the PA chime") eq(ow.map.def.warps[1].destMap, chosen.value.map, "Rocket Hideout car exit warp rewritten to the chosen floor") eq(#warpCalls, 0, "Rocket Hideout ride never warps on its own") ow:takeWarp(ow.map.def.warps[1]) eq(#warpCalls, 1, "Rocket Hideout walking out takes the rewritten warp") if warpCalls[1] then eq(warpCalls[1].map, chosen.value.map, "Rocket Hideout walk-out lands on the chosen floor map") eq(warpCalls[1].x, chosen.value.x, "Rocket Hideout walk-out lands on the chosen floor x") eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y") end end restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap) end Sound.play = origSoundPlay S.finish()