mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 00:40:59 +02:00
322 lines
13 KiB
Lua
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
|