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

424 lines
18 KiB
Lua

-- The `callasm` / `memcall` / `memcallasm` / `memjump` registry:
-- src/script/gen2/CallAsm.lua, which resolves the bank:addr operand against
-- pokegold-symbols/pokegold.sym and runs the hand-ported routine.
--
-- Two things are worth asserting here and nothing else is. The first is the
-- wScriptVar contract: only the eleven routines whose asm ends in
-- `ld [wScriptVar], a` may answer with a number, because every other site is
-- followed by an `iffalse` the cart decides some other way. The second is
-- WHICH rows in this cache resolve, pinned name by name: a row that starts
-- answering is either a real site the extractor has finally reached or an
-- address collision, and the two are told apart by hand against
-- pokegold.sym, not by whatever the cache happens to hold.
--
-- ROM-free: `luajit tests/gen2_callasm_test.lua`. The cache section at the
-- bottom SKIPs when no Gold cache is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 callasm")
local check, eq = S.check, S.eq
local CallAsm = require("src.script.gen2.CallAsm")
-- ---- the site table -------------------------------------------------------
do
local sites = 0
for _ in pairs(CallAsm.SITES) do sites = sites + 1 end
eq(sites, 47, "every callasm / memcall / memcallasm / memjump target in the cart")
local unmapped = {}
for key, name in pairs(CallAsm.SITES) do
if not CallAsm.ALL[name] then unmapped[#unmapped + 1] = key .. " " .. name end
end
eq(#unmapped, 0, "every site has a port or a stub: " .. table.concat(unmapped, ", "))
-- Disjoint by construction: CallAsm.lua raises if a name lands in both, so
-- this is the assertion that the raise has not been defeated by a rename.
for name in pairs(CallAsm.HANDLERS) do
check(CallAsm.STUBS[name] == nil, name .. " is ported, not stubbed")
end
for name in pairs(CallAsm.STUBS) do
check(CallAsm.STUB_REASONS[name] ~= nil, name .. " says why it is a stub")
end
-- pokegold-symbols/pokegold.sym, spot-checked across banks so a table that
-- was rebuilt against the wrong ROM revision fails here rather than silently
-- calling the neighbouring routine.
eq(CallAsm.SITES["11:4055"], "CheckFruitTree", "11:4055 is CheckFruitTree")
eq(CallAsm.SITES["04:68e0"], "HalveMoney", "04:68e0 is HalveMoney")
eq(CallAsm.SITES["03:4d7b"], "TryStrengthOW", "03:4d7b is TryStrengthOW")
eq(CallAsm.SITES["2e:6378"], "TreeMonEncounter", "2e:6378 is TreeMonEncounter")
eq(CallAsm.SITES["14:4786"], "CheckCanUseSquirtbottle",
"14:4786 is _Squirtbottle.CheckCanUseSquirtbottle")
-- The three WRAM operands are NOT sites: the pointer at the address is
-- written at run time, so there is nothing static to resolve.
eq(CallAsm.SITES["00:cfd8"], nil, "wQueuedScriptBank is not a routine")
eq(CallAsm.MEM_OPERANDS["00:cfd8"], "wQueuedScriptBank",
"it is a memjump / memcallasm operand instead")
end
-- ---- the key format -------------------------------------------------------
do
eq(CallAsm.key(0x11, 0x4055), "11:4055", "two hex digits and four, lower case")
eq(CallAsm.key(0x04, 0x0800), "04:0800", "both halves are zero padded")
eq(CallAsm.key(0, 0), "00:0000", "bank 0 at 0")
end
-- ---- the wScriptVar contract ----------------------------------------------
--
-- The eleven routines whose asm writes wScriptVar, taken one at a time out of
-- the decomp. Everything else must answer nil however it is called, because
-- Script_callasm itself never touches wScriptVar and inventing a byte picks a
-- branch at random.
local WRITES_SCRIPT_VAR = {
CheckFruitTree = true, -- engine/events/fruit_trees.asm
SweetScentEncounter = true, -- engine/events/sweet_scent.asm
CheckCanUseSquirtbottle = true, -- engine/events/squirtbottle.asm
CheckWhitedOut = true, -- engine/events/poisonstep.asm
TryStrengthOW = true, -- engine/events/overworld.asm
CheckContinueWaterfall = true, -- engine/events/overworld.asm
AskCutScript_CheckMap = true, -- engine/events/overworld.asm
HasRockSmash = true, -- engine/events/overworld.asm
Fishing_CheckFacingUp = true, -- engine/events/overworld.asm
TreeMonEncounter = true, -- engine/events/treemons.asm
TryReceiveItem = true, -- engine/events/misc_scripts.asm
}
do
local writers = 0
for _ in pairs(WRITES_SCRIPT_VAR) do writers = writers + 1 end
eq(writers, 11, "eleven of the fifty-seven sites write wScriptVar")
-- An empty ctx: no map, no party, no save. Every routine still has to come
-- back with the right SHAPE of answer, which is the whole contract. A
-- STUBBED writer is the one case that answers nil anyway: a routine that
-- never ran must not pick the branch that follows it.
for name in pairs(CallAsm.ALL) do
local value = CallAsm.run({}, name)
if WRITES_SCRIPT_VAR[name] and CallAsm.HANDLERS[name] then
eq(type(value), "number", name .. " answers a wScriptVar byte")
check(type(value) == "number" and value >= 0 and value < 256,
name .. " answers one byte")
else
eq(value, nil, name .. " leaves wScriptVar alone")
end
end
-- TryReceiveItem is the one wScriptVar writer that is a STUB, and it is the
-- reason the stub table carries a nil value rather than a 0: the "no room"
-- arm must not be picked by a routine that never ran.
eq(CallAsm.run({}, "TryReceiveItem"), nil,
"the stubbed writer still answers nil, not a guessed branch")
end
-- ---- dispatch --------------------------------------------------------------
do
local halved = { game = { save = { player = { money = 5001 } } } }
-- 04:68e0 is HalveMoney, reached with no label at all -- which is how every
-- callasm in this cache arrives, because nothing resolves symbols at import.
eq(CallAsm.dispatch(halved, nil, 0x04, 0x68e0), nil, "HalveMoney writes no wScriptVar")
eq(halved.game.save.player.money, 2500,
"the 24-bit srl/rra/rra is floor division")
-- An address nobody has named answers nil rather than falling through to a
-- neighbour, and a label the table does not know is ignored in favour of the
-- address.
eq(CallAsm.dispatch({}, nil, 0x45, 0x9752), nil, "an unknown site is nil")
eq(CallAsm.nameFor("NotARoutine", 0x11, 0x4055), "CheckFruitTree",
"an unknown label falls back to the address")
eq(CallAsm.nameFor("HalveMoney", 0x11, 0x4055), "HalveMoney",
"a known label wins over the address")
-- A handler that throws must not take the script down with it: the cart's
-- own callasm cannot fail, and the branch after it still needs the
-- "left alone" answer rather than an aborted coroutine.
local exploding = setmetatable({}, { __index = function()
return function() error("boom") end
end })
eq(CallAsm.run(exploding, "SelectMenu"), nil, "a throwing handler answers nil")
end
-- ---- the routines, one at a time ------------------------------------------
-- engine/events/fruit_trees.asm. The flag means "already picked" and
-- FruitTreeScript reads it with `iffalse .fruit`, so a picked tree is the 1.
do
local picked = {}
local ctx = {
curFruitTree = 7,
fruitTreeItem = function(_self, tree) return 100 + tree end,
fruitTreeReset = function() picked.reset = true end,
fruitTreePicked = function(_self, tree) return tree == 7 end,
fruitTreePick = function(_self, tree) picked.tree = tree end,
}
CallAsm.run(ctx, "GetCurTreeFruit")
eq(ctx.curFruit, 107, "GetCurTreeFruit leaves the item in wCurFruit")
CallAsm.run(ctx, "TryResetFruitTrees")
check(picked.reset, "TryResetFruitTrees runs the daily refill")
eq(CallAsm.run(ctx, "CheckFruitTree"), 1, "a picked tree answers 1")
ctx.curFruitTree = 8
eq(CallAsm.run(ctx, "CheckFruitTree"), 0, "an unpicked one answers 0")
CallAsm.run(ctx, "PickedFruitTree")
eq(picked.tree, 8, "PickedFruitTree sets the flag and writes no wScriptVar")
end
-- engine/events/overworld.asm GetPartyNickname: wCurPartyMon's nickname into
-- the string buffers, which is what {STRBUF} reads back.
do
local named
local ctx = {
curPartyMon = { nickname = "SPARKY" },
setNickname = function(_self, mon) named = mon end,
}
CallAsm.run(ctx, "GetPartyNickname")
eq(named.nickname, "SPARKY", "the mon CheckPartyMove picked is named")
-- With nothing in wCurPartyMon the cart still reads slot 0.
local first = { game = { save = { party = { { nickname = "TOTO" } } } },
setNickname = function(_self, mon) named = mon end }
CallAsm.run(first, "GetPartyNickname")
eq(named.nickname, "TOTO", "an unset wCurPartyMon is slot 0")
end
-- engine/events/overworld.asm HasRockSmash is INVERTED: 1 means the party does
-- NOT know it, which is why AskRockSmashScript reads it with `ifequal 1, .no`.
do
local without = { partyMoveUser = function() return nil end }
local with = { partyMoveUser = function() return { nickname = "ONIX" } end }
eq(CallAsm.run(without, "HasRockSmash"), 1, "no ROCK SMASH in the party is 1")
eq(CallAsm.run(with, "HasRockSmash"), 0, "a mon that knows it is 0")
end
-- engine/events/overworld.asm Fishing_CheckFacingUp: `and $c / cp OW_UP`.
do
eq(CallAsm.run({ player = { facing = "up" } }, "Fishing_CheckFacingUp"), 1,
"facing up is the only 1")
eq(CallAsm.run({ player = { facing = "left" } }, "Fishing_CheckFacingUp"), 0,
"any other facing is 0")
end
-- engine/events/overworld.asm PutTheRodAway: wPlayerAction back to normal,
-- which in this port is the fishing state going away.
do
local ctx = { fishing = { phase = "done" } }
CallAsm.run(ctx, "PutTheRodAway")
eq(ctx.fishing, nil, "the rod pose is dropped")
end
-- engine/events/overworld.asm SetStrengthFlag: BIKEFLAGS_STRENGTH_ACTIVE, the
-- species, and a tail call into GetPartyNickname.
do
local named
local ctx = {
curPartyMon = { nickname = "GEODUDE", species = "GEODUDE" },
setNickname = function(_self, mon) named = mon end,
}
eq(CallAsm.run(ctx, "SetStrengthFlag"), nil, "SetStrengthFlag writes no wScriptVar")
check(ctx.strengthActive, "STRENGTH is switched on")
eq(ctx.strengthSpecies, "GEODUDE", "and wStrengthSpecies is the mon's")
eq(named.nickname, "GEODUDE", "the nickname is copied in the same routine")
end
-- engine/events/squirtbottle.asm .CheckCanUseSquirtbottle: Route 36, and the
-- object faced has to carry SPRITEMOVEDATA_SUDOWOODO ($17).
do
local function ctxWith(mapId, movement)
return {
map = { id = mapId },
player = { cellX = 5, cellY = 5, facing = "up" },
npcAt = function(_self, x, y)
if x == 5 and y == 4 and movement then
return { def = { movement = movement } }
end
return nil
end,
}
end
eq(CallAsm.run(ctxWith("ROUTE_36", 0x17), "CheckCanUseSquirtbottle"), 1,
"Route 36 plus a Sudowoodo is the 1")
eq(CallAsm.run(ctxWith("ROUTE_36", 0x19), "CheckCanUseSquirtbottle"), 0,
"a STRENGTH boulder on the same tile is not")
eq(CallAsm.run(ctxWith("ROUTE_36", nil), "CheckCanUseSquirtbottle"), 0,
"GetFacingObject's own carry is the same 0")
eq(CallAsm.run(ctxWith("ROUTE_35", 0x17), "CheckCanUseSquirtbottle"), 0,
"and the map check comes first")
end
-- engine/events/hidden_item.asm SetMemEvent: the flag whose NUMBER is in
-- wHiddenItemEvent. wEventFlags is keyed by number, not by name.
do
local set = {}
local ctx = {
hiddenItemEvent = 173,
events = { set = function(_self, flag, value) set[flag] = value end },
}
eq(CallAsm.run(ctx, "SetMemEvent"), nil, "SetMemEvent writes no wScriptVar")
eq(set[173], true, "the hidden item's flag is set by number")
end
-- engine/events/poisonstep.asm .CheckWhitedOut ends on
-- CheckPlayerPartyForFitMon, whose answer is 1 when something can still fight;
-- the `iffalse .whiteout` after it is reading "no fit mon".
do
local fit = { game = { save = { party = { { hp = 4, maxHp = 20 } } } } }
local out = { game = { save = { party = { { hp = 0, maxHp = 20 } } } } }
eq(CallAsm.run(fit, "CheckWhitedOut"), 1, "a mon that can still fight is 1")
eq(CallAsm.run(out, "CheckWhitedOut"), 0, "a whited-out party is 0")
end
-- engine/events/whiteout.asm HalveMoney: the wallet ALONE. Mom's savings are
-- a separate three bytes the routine never reaches.
do
local ctx = { game = { save = {
player = { money = 9999 },
mom = { savedMoney = 4000 },
} } }
CallAsm.run(ctx, "HalveMoney")
eq(ctx.game.save.player.money, 4999, "9999 halves to 4999, remainder dropped")
eq(ctx.game.save.mom.savedMoney, 4000, "Mom's savings are untouched")
end
-- engine/events/whiteout.asm GetWhiteoutSpawn: IsSpawnPoint over the
-- `blackoutmod` pair, SPAWN_HOME when it is not one.
do
local spawns = {
SPAWN_HOME = { map = "PLAYERS_HOUSE_1F" },
SPAWN_OLIVINE = { map = "OLIVINE_POKECENTER_1F" },
}
local known = { landmarks = { spawns = spawns },
game = { save = { blackoutMap = "OLIVINE_POKECENTER_1F" } } }
CallAsm.run(known, "GetWhiteoutSpawn")
eq(known.defaultSpawnpoint, "SPAWN_OLIVINE", "a real spawn point is kept")
local adrift = { landmarks = { spawns = spawns },
game = { save = { blackoutMap = "FAST_SHIP_1F" } } }
CallAsm.run(adrift, "GetWhiteoutSpawn")
eq(adrift.defaultSpawnpoint, "SPAWN_HOME",
"somewhere that is not a spawn point falls back to SPAWN_HOME")
end
-- engine/items/itemfinder.asm .ItemfinderSound: `ld c, 4` around a pair of
-- WaitPlaySFX calls, so eight sounds, alternating.
do
local played = {}
local ctx = { playSfxNamed = function(_self, name) played[#played + 1] = name end }
CallAsm.run(ctx, "ItemfinderSound")
eq(#played, 8, "four loops of two sounds")
eq(played[1], "Sfx_SecondPartOfItemfinder", "the ping comes first")
eq(played[2], "Sfx_Transaction", "then the transaction blip")
eq(played[8], "Sfx_Transaction", "and the pair repeats to the end")
end
-- engine/overworld/events.asm HatchEggScript is one command: `callasm
-- OverworldHatchEgg / end`. World:countStep runs it through the registry.
do
local hatched = false
local ctx = { hatchEggs = function() hatched = true end }
eq(CallAsm.run(ctx, "OverworldHatchEgg"), nil, "the hatch writes no wScriptVar")
check(hatched, "and it runs HatchEggs")
end
-- ---- the World seam --------------------------------------------------------
do
local World = require("src.world.gen2.World")
local ctx = { game = { save = { player = { money = 100 } } } }
-- World:callAsm is what src/script/gen2/Vm.lua's callasm branch calls, and
-- it dispatches on the address because `label` is always nil today.
eq(World.callAsm(ctx, nil, 0x04, 0x68e0), nil, "the seam answers nil for HalveMoney")
eq(ctx.game.save.player.money, 50, "and the routine ran")
eq(World.callAsm(ctx, nil, 0x45, 0x9752), nil, "a garbage address is a no-op")
eq(ctx.game.save.player.money, 50, "and changes nothing")
end
-- ---- the cache -------------------------------------------------------------
--
-- WHICH rows carry one of the four opcodes and resolve, pinned exactly.
--
-- The row count is deliberately not pinned. It was thirty-eight before the
-- extractor stopped walking three-byte `hiddenitem` bg_event operands as
-- bytecode, and every one of those thirty-eight sat inside a key made out of
-- one (45:697a is IlexForestHiddenEther, 47:40a5 is
-- WhirlIslandB1FHiddenRareCandy), so their bank/address pairs were noise; a
-- cache built after that fix has none at all.
--
-- The RESOLUTIONS are pinned, because a row that starts resolving is either a
-- real site the extractor has finally reached or an address collision, and
-- both want reading before the registry answers them. These five appeared
-- when `farscall` / `farsjump` / `farwritetext` stopped reading their `dba`
-- operand backwards: the swap sent 40:4154 and 40:4158 at 4d:4e03 / 4f:6003
-- instead of AskStrengthScript / AskRockSmashScript, so the STRENGTH and ROCK
-- SMASH arms of engine/events/overworld.asm were unreachable and their
-- `callasm` rows were never walked. All five are exact pokegold.sym matches,
-- not collisions:
--
-- 03:4d30 Script_UsedStrength -> 03:4d15 SetStrengthFlag
-- 03:4d4e AskStrengthScript -> 03:4d7b TryStrengthOW
-- 03:4f35 RockSmashScript -> 03:474b GetPartyNickname
-- 03:4f35 RockSmashScript -> 2e:63a1 RockMonEncounter
-- 03:4f60 AskRockSmashScript -> 03:4f7f HasRockSmash
local EXPECTED_RESOLVED = {
"03:4d30 03:4d15 -> SetStrengthFlag",
"03:4d4e 03:4d7b -> TryStrengthOW",
"03:4f35 03:474b -> GetPartyNickname",
"03:4f35 2e:63a1 -> RockMonEncounter",
"03:4f60 03:4f7f -> HasRockSmash",
}
local ASM_OPS = {
callasm = true, memcall = true, memcallasm = true, memjump = true,
}
local cacheDir = os.getenv("GOLD_CACHE")
if not cacheDir then
cacheDir = (os.getenv("HOME") or "") ..
"/Library/Application Support/LOVE/gold-dev/gold"
end
local scriptsFile = loadfile(cacheDir .. "/data/generated/scripts.lua")
if not scriptsFile then
check(true, "no Gold cache (SKIP)")
S.finish()
return
end
local scripts = scriptsFile()
local rows, resolved = 0, {}
for key, list in pairs(scripts) do
if type(list) == "table" then
for _, cmd in ipairs(list) do
if type(cmd) == "table" and ASM_OPS[cmd.op] then
rows = rows + 1
local args = cmd.args or {}
-- callasm is bank, lo, hi; the other three are lo, hi out of WRAM.
local bank, addr
if cmd.op == "callasm" then
bank = args[1] or 0
addr = (args[2] or 0) + (args[3] or 0) * 0x100
else
bank = 0
addr = (args[1] or 0) + (args[2] or 0) * 0x100
end
local name = CallAsm.nameFor(cmd.label, bank, addr)
if name then
resolved[#resolved + 1] =
("%s %s -> %s"):format(key, CallAsm.key(bank, addr), name)
end
end
end
end
end
check(rows >= 0,
("%d rows in this cache carry callasm / memcall / memcallasm / memjump")
:format(rows))
-- `pairs` over the script table has no order, so sort before comparing.
table.sort(resolved)
eq(table.concat(resolved, ", "), table.concat(EXPECTED_RESOLVED, ", "),
"and exactly the five field-move sites resolve")
for _, line in ipairs(resolved) do
local name = line:match("-> (%S+)$")
check(CallAsm.HANDLERS[name] ~= nil or CallAsm.STUBS[name] ~= nil,
name .. " is ported or stubbed, so the site answers something deliberate")
end
S.finish()