Files
gen1recomp/tests/drivers/gold_extractor_pass.lua
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

322 lines
13 KiB
Lua

-- The four things the extractor pass unblocked, driven in the real game.
--
-- Every one of them was a pointer the importer emitted raw, so the engine had
-- an address and nothing behind it. This driver walks the player to each and
-- asserts the feature actually runs, rather than shooting a screenshot for
-- someone to squint at:
--
-- 1. a scripted static menu -- Goldenrod Dept Store 6F's vending machine
-- (`loadmenu` / `verticalmenu`). Every one of the seventeen sites took
-- the cancel arm before the MenuHeader pointer was followed.
-- 2. the elevator -- Goldenrod Dept Store's, whose floor list
-- lives in its own script bank. The ride is a `warp_event` with
-- destination warp -1 reading what Elevator_GoToFloor left behind.
-- 3. a special phone call -- SPECIALCALL_ROBBED, whose script is in ROM
-- bank $41. Nothing on any map points into that bank; the seed is
-- PhoneContacts itself.
-- 4. an in-game trade -- NPC_TRADE_MIKE, off data/events/npc_trades.asm.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_extractor_pass.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-extractor"
return function(game)
local w = game.world
local fails = 0
local function wait(n) for _ = 1, n do coroutine.yield() end end
local function ok(cond, msg)
if cond then print("[extract] ok " .. msg)
else fails = fails + 1 print("[extract] FAIL " .. msg) end
return cond
end
-- A tap is a press AND a release. Writing pressQueue directly injects a
-- press with no source map behind it (src/core/Input.lua Input:step), and
-- nothing else will ever clear it, so a tap that skipped the release would
-- leave that button HELD for the rest of the run -- which put DOWN under the
-- player's thumb on arrival in the elevator and walked them straight back out
-- through its COLL_WARP_CARPET_DOWN door.
local function tap(btn)
table.insert(game.input.pressQueue, btn)
coroutine.yield()
coroutine.yield()
game.input.state[btn] = false
end
-- DoPlayerMovement's .CheckWarp (engine/overworld/player_movement.asm): an
-- edge warp is only taken while its own direction is actually on the d-pad,
-- so walking out of a lift needs a real hold rather than a tap.
local function hold(btn, frames)
for _ = 1, (frames or 1) do
table.insert(game.input.pressQueue, btn)
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
end
-- Gold runs on the engine's own src/core/StateStack.lua, the same stack
-- Gen 1 uses (src/core/Game2.lua:makeStack), so ask it for the top rather
-- than indexing a field.
local function top()
return game.stack and game.stack:top()
end
-- Run a script by key and step the world until it parks on something.
local function runUntilIdle(limit)
for _ = 1, (limit or 400) do
if not w:busy() then return true end
coroutine.yield()
end
return false
end
os.execute('mkdir -p "' .. SHOT_DIR .. '" 2>/dev/null')
wait(45)
-- ---------------------------------------------------------------- 1. menu
--
-- CeladonDeptStore6F / GoldenrodDeptStore6F's vending machine is
-- `opentext / writetext / special PlaceMoneyTopRight / loadmenu / verticalmenu`,
-- and the `ifequal 1..3` ladder after it is what buys a drink. With no
-- header there was nothing to open and the script fell to the cancel arm.
--
-- The pair is run as an INLINE command list (Vm:start takes one) rather than
-- by starting the whole vending-machine script: everything in front of the
-- menu is text boxes and a money panel, and none of that is what this is
-- about. The two commands are the cache's own, lifted out of a real site.
local menuCmds
for key, cmds in pairs(w.scripts) do
if type(cmds) == "table" and key ~= "movements" and not menuCmds then
for i, cmd in ipairs(cmds) do
if cmd.op == "loadmenu" and cmd.menu and cmd.menu.items
and cmds[i + 1] and cmds[i + 1].op == "verticalmenu" then
menuCmds = { cmd, cmds[i + 1], { op = "end" } }
break
end
end
end
end
if ok(menuCmds ~= nil, "the cache has a loadmenu site with a real header") then
w:setMap("GOLDENROD_DEPT_STORE_6F", 5, 5, "up")
wait(20)
ok(w.vm:start(menuCmds), "the VM took the loadmenu / verticalmenu pair")
local opened = false
for _ = 1, 60 do
local state = top()
if state and state.screenId == "Gen2ScriptMenu" then opened = true break end
coroutine.yield()
end
ok(opened, "the vending machine opened its menu")
local menu = top()
if opened then
ok(#menu.items >= 2,
("with %d items off the extracted header"):format(#menu.items))
game.capturePath = SHOT_DIR .. "/menu.png"
wait(2)
-- Walk to the last row and pick it: CANCEL, the arm the script used to
-- take by default. Picking it deliberately proves the cursor moves and
-- the answer is the 1-based index rather than a stuck 0.
local want = #menu.items
for _ = 1, want do tap("down") end
ok(menu.row == want,
("the cursor reached row %d (got %d)"):format(want, menu.row))
tap("a")
wait(4)
ok(top() ~= menu, "and choosing closed it")
ok(w.vm.scriptVar == want,
("wScriptVar is the 1-based choice %d (got %s)")
:format(want, tostring(w.vm.scriptVar)))
end
runUntilIdle(200)
end
-- ------------------------------------------------------------ 2. elevator
--
-- Elevator writes wBackupWarpNumber / wBackupMapGroup / wBackupMapNumber and
-- rides nowhere; the elevator's own door -- a warp_event whose destination
-- warp is -1 -- is what carries the player out onto the chosen floor.
-- Pick GOLDENROD'S list by the floor it names, not by whichever `elevator`
-- pairs() reaches first: the three lists are per-building, and running
-- Celadon's from Goldenrod's lift is the .FindCurrentFloor miss that quits
-- with no menu at all.
local FLOOR = "GOLDENROD_DEPT_STORE_1F"
local elevatorCmds
for key, cmds in pairs(w.scripts) do
if type(cmds) == "table" and key ~= "movements" and not elevatorCmds then
for _, cmd in ipairs(cmds) do
for _, floor in ipairs((cmd.op == "elevator" and cmd.floors) or {}) do
if floor.destMap == FLOOR then
elevatorCmds = { cmd, { op = "end" } }
break
end
end
if elevatorCmds then break end
end
end
end
if ok(elevatorCmds ~= nil, "the cache has an elevator with a floor list") then
-- Arrive the way a player does, so wBackupMapNumber is the floor they got
-- in on -- .FindCurrentFloor answers `scf` and skips the whole thing
-- otherwise.
w:setMap(FLOOR, 4, 2, "up")
wait(10)
local door
for _, warp in ipairs(w.map.def.warps or {}) do
if warp.destMap == "GOLDENROD_DEPT_STORE_ELEVATOR" then door = warp end
end
if ok(door ~= nil, "1F has a door into the elevator") then
w:takeWarp(door)
runUntilIdle(300)
wait(20)
ok(w.map.id == "GOLDENROD_DEPT_STORE_ELEVATOR",
("the player is in the elevator (got %s)"):format(w.map.id))
ok(w.backupMapId == FLOOR,
("and came in from 1F (got %s)"):format(tostring(w.backupMapId)))
-- The door is the thing under test: a `warp_event` whose destination
-- warp is -1 names no floor of its own.
local door_ = nil
for _, warp in ipairs(w.map.def.warps or {}) do
if warp.destWarp == 0xff then door_ = warp end
end
ok(door_ ~= nil, "the elevator's own door is a -1 warp")
ok(door_ and w:resolveWarp(door_) == FLOOR,
"which resolves to the floor we came in on until a ride is picked")
ok(w.vm:start(elevatorCmds), "the VM took the elevator command")
local opened = false
for _ = 1, 60 do
local state = top()
if state and state.screenId == "Gen2ElevatorMenu" then opened = true break end
coroutine.yield()
end
ok(opened, "the elevator opened its floor list")
local lift = top()
if opened then
ok(lift.origin ~= nil, "with the floor it came in on marked")
game.capturePath = SHOT_DIR .. "/elevator.png"
wait(2)
-- Ride to the top floor of the list, which is never the one we are on.
for _ = 1, #lift.floors do tap("down") end
local target = lift.floors[lift.index]
tap("a")
wait(4)
ok(w.backupWarp ~= nil and w.backupWarp.map == target.destMap,
("Elevator_GoToFloor stored %s"):format(tostring(target.destMap)))
-- The SAME door now resolves somewhere else, which is the whole of
-- what the ride is: nothing about the map changed.
ok(door_ and w:resolveWarp(door_) == target.destMap,
("and the door now resolves to %s"):format(
tostring(target.destMap)))
-- Elevator_GoToFloor rides nowhere: the player still has to walk out
-- through the door, which is the edge warp the -1 destination is on.
hold("down", 40)
runUntilIdle(300)
wait(20)
ok(w.map.id == target.destMap,
("walking out opens on %s (got %s)"):format(target.destMap,
w.map.id))
end
end
end
-- --------------------------------------------------------- 3. phone call
--
-- SPECIALCALL_ROBBED is Elm's "your POKeMON was stolen" beat. Its script is
-- ElmPhoneCallerScript at 41:41e1, reached only because the extractor seeds
-- its queue from PhoneContacts.
local Phone = require("src.core.gen2.Phone")
local key = Phone.SCRIPT_KEYS.ElmPhoneCallerScript
ok(w.scripts[key] ~= nil,
("ElmPhoneCallerScript (%s) is in scripts.lua"):format(tostring(key)))
do
-- The condition is SpecialCallOnlyWhenOutside, so stand in a town.
w:setMap("NEW_BARK_TOWN", 5, 8, "down")
wait(20)
Phone.queueSpecialCall(game.save, Phone.SPECIALCALL.SPECIALCALL_ROBBED)
local call = Phone.checkSpecialCall(game.save, {
map = w.map.def, maps = w.maps, daytime = w.daytime,
environment = w.map.def and w.map.def.environment,
})
if ok(call ~= nil, "CheckSpecialPhoneCall produced a call outdoors") then
ok(call.scriptKey == key,
("aimed at the caller script (%s)"):format(tostring(call.scriptKey)))
local before = w.unrunnableCalls or 0
local ran = w:receivePhoneCall(call)
ok(ran, "and the world RAN it rather than dropping it")
ok((w.unrunnableCalls or 0) == before,
"so nothing was counted as unrunnable")
game.capturePath = SHOT_DIR .. "/phonecall.png"
wait(2)
for _ = 1, 300 do
if not w:busy() then break end
tap("a")
end
-- The script's own first act is `specialphonecall SPECIALCALL_NONE`.
ok(not Phone.hasSpecialCall(game.save),
"and the script cleared the queue on its way out")
end
end
-- --------------------------------------------------------------- 4. trade
--
-- NPC_TRADE_MIKE: hand over a DROWZEE, get MACHOP nicknamed MUSCLE.
local NpcTrade = require("src.core.gen2.NpcTrade")
local row = NpcTrade.row(w.eventTables, 0)
if ok(row ~= nil, "the cache carries the six in-game trades") then
local Mon = require("src.battle.gen2.Mon")
game.save.party = { Mon.new(game.data, row.give, 20) }
game.save.tradeFlags = {}
ok(game.save.party[1] ~= nil,
("a level 20 %s in the party"):format(tostring(row.give)))
w:openNpcTrade(0, function() end)
local trade = top()
if ok(trade and trade.screenId == "Gen2TradeMenu", "the trade opened") then
game.capturePath = SHOT_DIR .. "/trade.png"
wait(2)
-- Page to the yes/no, answer YES, then pick the only party member.
for _ = 1, 30 do
if trade.confirm and trade.confirm.page >= #trade.confirm.pages then
break
end
tap("a")
end
tap("a") -- YES
wait(4)
local party = top()
if ok(party and party.screenId == "Gen2PartyMenu",
"and it opened the party list") then
tap("a")
wait(4)
for _ = 1, 60 do
if not top() or top() == trade then break end
tap("a")
end
for _ = 1, 60 do
if trade.closed then break end
tap("a")
end
end
local got = game.save.party[1]
ok(got and got.species == row.get,
("the party now holds %s (got %s)"):format(tostring(row.get),
tostring(got and got.species)))
ok(got and got.nickname == row.nickname,
("nicknamed %s (got %s)"):format(tostring(row.nickname),
tostring(got and got.nickname)))
ok(got and got.otName == row.otName,
("with OT %s"):format(tostring(row.otName)))
ok(got and got.level == 20, "at the level of the mon handed over")
ok(NpcTrade.done(game.save, 0), "and the trade's flag is set")
end
end
if fails > 0 then
error(("gold extractor pass: %d assertion(s) failed"):format(fails), 0)
end
print("[driver] PASS gold extractor pass in " .. SHOT_DIR)
end