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

489 lines
21 KiB
Lua

-- Map callbacks: RunMapCallback (home/map.asm) and the four places a map load
-- runs one. `luajit tests/gen2_map_callbacks_test.lua`; also dofile'd by
-- tests/run_tests.lua. Fixture-driven, with a final section that runs every
-- callback in a real Gold cache and SKIPs when there is none.
--
-- Three separate things are under test and they fail in different ways:
--
-- Vm:runCallback a NESTED script run. RunMapCallback does not check
-- wScriptRunning, so this must work with a script parked --
-- which is the normal case, because every warp a script
-- takes is a map load with that script still on the stack.
-- the ORDER HandleNewMap, then LoadBlockData, then LoadMapObjects.
-- A TILES callback that runs after the canvas is baked
-- paints nothing; an OBJECTS callback that runs after the
-- people are built shows yesterday's NPC.
-- the CACHE FLUSH a baked canvas is keyed by map and daytime and knows
-- nothing about the blocks it was baked from, so a callback
-- that rewrites blocks on every load needs the stale bakes
-- dropped or it freezes on its first answer.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 map callbacks")
local check, eq = S.check, S.eq
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
local World = require("src.world.gen2.World")
-- ---------------------------------------------------------------------------
-- Vm:runCallback -- ExecuteCallbackScript
-- ---------------------------------------------------------------------------
local function vmWith(scripts, hooks)
return Vm.new(scripts, {}, Events.new(), hooks or {})
end
-- The plain case: a body that ends on `endcallback` runs to completion and the
-- VM is idle again afterwards.
do
local flags = {}
local vm = vmWith({
cb = { { op = "setevent", event = 7 }, { op = "endcallback" } },
}, { onFlagsChanged = function() flags[#flags + 1] = true end })
check(vm:runCallback("cb"), "a callback body runs and reports it ran")
check(vm.events:get(7), "and its setevent landed")
check(not vm:running(), "with the VM idle again on the far side")
check(not vm:runCallback(nil), "no script key is not a run")
check(not vm:runCallback("nope"), "and neither is a key with no body")
end
-- Script_endcallback is ExitScriptSubroutine then StopScript: it ends the
-- CALLBACK, not the map load. A body with commands behind it stops there.
do
local vm = vmWith({
cb = {
{ op = "setevent", event = 1 },
{ op = "endcallback" },
{ op = "setevent", event = 2 },
},
})
vm:runCallback("cb")
check(vm.events:get(1), "commands before endcallback run")
check(not vm.events:get(2), "and nothing behind it does")
end
-- The case that made this a separate entry point from Vm:start. A script is
-- parked on a text box; the map load underneath it runs a callback; the parked
-- script must still be parked, on the SAME request, and must resume where it
-- was. Vm:start would have refused outright (`if self.busy then return
-- false`), which is what "nothing runs them yet" looked like from the inside.
do
local resumeText
local vm = vmWith({
parent = {
{ op = "writetext", text = "A" },
{ op = "setevent", event = 20 },
{ op = "end" },
},
cb = { { op = "setevent", event = 21 }, { op = "endcallback" } },
}, {
showText = function(_, onDone) resumeText = onDone end,
})
vm:start("parent")
check(vm:running(), "the parent script is up")
check(vm.pending and vm.pending.kind == "text", "and parked on its text box")
local parked = vm.pending
check(vm:runCallback("cb"), "a callback runs with a script already parked")
check(vm.events:get(21), "the callback's own command landed")
check(not vm.events:get(20),
"and the parent did NOT run on past the box it is waiting on")
check(vm:running(), "the parent is still up")
eq(vm.pending, parked, "parked on the very same request")
resumeText()
check(vm.events:get(20), "and resuming it picks up where it left off")
check(not vm:running(), "then ends normally")
end
-- A callback cannot block: ScriptEvents runs inside the map load, with no frame
-- to come back on. Nothing reachable from an extracted callback yields, so
-- this is a guard rather than a behaviour -- but if one ever does, the parent's
-- parked frame must survive it untouched.
do
local vm = vmWith({
parent = { { op = "writetext", text = "A" }, { op = "end" } },
bad = { { op = "writetext", text = "B" }, { op = "endcallback" } },
}, { showText = function() end })
vm:start("parent")
local parked = vm.pending
check(not vm:runCallback("bad"), "a blocking callback reports failure")
eq(vm.pending, parked, "and leaves the parent's request alone")
check(vm:running(), "with the parent still running")
eq(vm.blockedCallbacks["bad"], "text", "the ledger names what it blocked on")
end
-- ---------------------------------------------------------------------------
-- World:mapCallbackScript / World:runMapCallback
-- ---------------------------------------------------------------------------
local function callbackWorld(callbacks, scripts)
local world = World.new({ data = {}, save = { party = {}, inventory = {} } })
world.maps = {
TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 2, height = 2,
blocks = { 1, 2, 3, 4 }, objects = {}, warps = {},
callbacks = callbacks },
}
world.map = { id = "TEST_MAP", def = world.maps.TEST_MAP,
width = 2, height = 2 }
world.map.blocks = world.maps.TEST_MAP.blocks
world.scripts = scripts or {}
-- Only the hooks these checks reach; World:load builds the real table.
world.vm = Vm.new(world.scripts, {}, world.events, {
changeBlock = function(bx, by, block)
return world:changeBlock(bx, by, block)
end,
appear = function(id) world:appearObject(id) end,
disappear = function(id) world:disappearObject(id) end,
})
return world
end
do
local world = callbackWorld({
{ callback = "MAPCALLBACK_TILES", scriptKey = "first" },
{ callback = "MAPCALLBACK_TILES", scriptKey = "second" },
{ callback = "MAPCALLBACK_NEWMAP", scriptKey = "new" },
}, {
first = { { op = "setevent", event = 1 }, { op = "endcallback" } },
second = { { op = "setevent", event = 2 }, { op = "endcallback" } },
new = { { op = "setevent", event = 3 }, { op = "endcallback" } },
})
eq(world:mapCallbackScript("MAPCALLBACK_TILES"), "first",
".FindCallback takes the FIRST row of a type")
eq(world:mapCallbackScript("MAPCALLBACK_NEWMAP"), "new",
"and matches on the type, not on the order")
check(world:mapCallbackScript("MAPCALLBACK_SPRITES") == nil,
"a type this map has no row for is nil, not the next row along")
check(world:runMapCallback("MAPCALLBACK_TILES"), "and it runs")
check(world.events:get(1) and not world.events:get(2),
"only the first row of the type")
check(not world:runMapCallback("MAPCALLBACK_OBJECTS"),
"a map with no callback of that type is a no-op, not an error")
end
-- A map with no callback list at all -- 284 of Gold's 368 maps -- must not
-- cost anything or throw.
do
local world = callbackWorld(nil, {})
check(world:mapCallbackScript("MAPCALLBACK_NEWMAP") == nil,
"a map with no callbacks answers nil")
check(not world:runMapCallback("MAPCALLBACK_NEWMAP"), "and runs nothing")
end
-- ---------------------------------------------------------------------------
-- The order inside a map load
-- ---------------------------------------------------------------------------
-- MapSetupScript_Warp reads HandleNewMap, LoadBlockData, LoadMapObjects, in
-- that order, and each of the three carries one callback. setMap is a single
-- call here, so the order has to be asserted out loud.
do
local world = callbackWorld({
{ callback = "MAPCALLBACK_OBJECTS", scriptKey = "objects" },
{ callback = "MAPCALLBACK_TILES", scriptKey = "tiles" },
{ callback = "MAPCALLBACK_NEWMAP", scriptKey = "newmap" },
}, {
newmap = { { op = "setevent", event = 1 }, { op = "endcallback" } },
tiles = { { op = "changeblock", args = { 0, 0, 9 } },
{ op = "endcallback" } },
objects = { { op = "setevent", event = 2 }, { op = "endcallback" } },
})
local order = {}
local newMapCell
world.tilesets = { TEST = {} }
world.maps.TEST_MAP.tileset = "TEST"
-- Stub out everything setMap does with love: the ORDER is the subject, and a
-- headless suite has no canvas to bake into.
local Map = require("src.world.gen2.Map")
world.imageFor = function() order[#order + 1] = "bake" return true end
world.rebuildNeighbors = function() end
world.rebuildPeople = function() order[#order + 1] = "people" end
world.applyPalettes = function() end
world.noteFlypoint = function() end
local realRun = World.runMapCallback
world.runMapCallback = function(self, kind)
order[#order + 1] = kind
if kind == "MAPCALLBACK_NEWMAP" then
newMapCell = self.player and (self.player.cellX .. "," .. self.player.cellY)
end
return realRun(self, kind)
end
world.map = Map.new(world.maps.TEST_MAP, {})
check(world:setMap("TEST_MAP", 3, 3, "down"), "the map loads")
eq(table.concat(order, " "),
"MAPCALLBACK_NEWMAP MAPCALLBACK_TILES bake MAPCALLBACK_OBJECTS people",
"HandleNewMap, then LoadBlockData, then LoadMapObjects")
check(world.events:get(1) and world.events:get(2),
"and both bodies really ran")
eq(world.maps.TEST_MAP.blocks[1], 9,
"the TILES callback's changeblock is in the buffer BEFORE the bake")
-- data/maps/setup_scripts.asm:99-106: GetWarpDestCoords before HandleNewMap.
eq(newMapCell, "3,3",
"the NEWMAP callback reads the DESTINATION coords, not the map it left")
end
-- ---------------------------------------------------------------------------
-- Route16AlwaysOnBikeCallback
-- ---------------------------------------------------------------------------
-- maps/Route16.asm:7-17. The callback is two coordinate tests and nothing
-- else: YCOORD < 5 or XCOORD > 13 takes .CanWalk, everything else `setflag
-- ENGINE_ALWAYS_ON_BIKE`. It carries no `checkitem` -- the BICYCLE refusal is
-- the gatehouse's own coord event (maps/Route16Gate.asm:16-29, :69-70) -- so
-- both arms below run on a save with an empty PACK.
do
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Map = require("src.world.gen2.Map")
local function route16At(cx, cy)
local world = callbackWorld({
{ callback = "MAPCALLBACK_NEWMAP", scriptKey = "bike" },
}, {
-- The extracted body, opcode for opcode: VAR_YCOORD $13, VAR_XCOORD $12.
bike = {
{ op = "readvar", var = 0x13 },
{ op = "ifless", value = 5, script = "walk" },
{ op = "readvar", var = 0x12 },
{ op = "ifgreater", value = 13, script = "walk" },
{ op = "setflag", flag = Bike.ENGINE_ALWAYS_ON_BIKE },
{ op = "endcallback" },
},
walk = {
{ op = "clearflag", flag = Bike.ENGINE_ALWAYS_ON_BIKE },
{ op = "endcallback" },
},
})
world.maps.TEST_MAP.environment = "ROUTE"
world.maps.TEST_MAP.width, world.maps.TEST_MAP.height = 10, 10
world.tilesets = { TEST = {} }
world.maps.TEST_MAP.tileset = "TEST"
world.imageFor = function() return true end
world.rebuildNeighbors = function() end
world.rebuildPeople = function() end
world.applyPalettes = function() end
world.vm = Vm.new(world.scripts, {}, world.events, {
readVar = function(id) return world:readVar(id) end,
setEngineFlag = function(flag, value) world:setEngineFlag(flag, value) end,
})
world.map = Map.new(world.maps.TEST_MAP, {})
check(world:setMap("TEST_MAP", cx, cy, "down"), "the map loads")
return world
end
-- warp_event 9, 6 (maps/Route16.asm:36): the Cycling Road side of the gate.
local road = route16At(9, 6)
check(road:alwaysOnBike(),
"arriving west of the gate sets ENGINE_ALWAYS_ON_BIKE")
-- .CheckForcedBiking (engine/overworld/map_setup.asm:112-120) reads the flag
-- and nothing else: no BICYCLE is consulted, on the cart or here.
eq(road.playerState, FieldMoves.PLAYER_BIKE, "and the load mounts the bike")
-- warp_event 14, 6 (maps/Route16.asm:34): the gatehouse doorway, one cell
-- past the XCOORD test. Read off the map being LEFT this is the road arm,
-- which is the whole reason the coords are written before HandleNewMap.
local gate = route16At(14, 6)
check(not gate:alwaysOnBike(), "arriving at the gate doorway takes .CanWalk")
eq(gate.playerState, FieldMoves.PLAYER_NORMAL, "and nothing mounts")
-- The YCOORD arm, north of the fork: `ifless 5, .CanWalk` on its own.
local north = route16At(9, 4)
check(not north:alwaysOnBike(), "and so does anything north of YCOORD 5")
end
-- ---------------------------------------------------------------------------
-- The baked canvas has to follow the blocks
-- ---------------------------------------------------------------------------
do
local world = callbackWorld(nil, {})
world.mapImages = {
["TEST_MAP|DAY|gbc|1"] = "a", ["TEST_MAP|NITE|gbc|1"] = "b",
["OTHER_MAP|DAY|gbc|1"] = "c",
}
world:dropMapImages("TEST_MAP")
check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, "both bakes of the map")
check(world.mapImages["TEST_MAP|NITE|gbc|1"] == nil, "go, daytime and all")
eq(world.mapImages["OTHER_MAP|DAY|gbc|1"], "c",
"and the neighbours' keep theirs -- only the edited map's are stale")
-- restoreBlocks is LoadMapAttributes' refill. Putting the blocks back
-- without dropping the bake left a CUT tree cut for the session, and would
-- have frozen every MAPCALLBACK_TILES map on whichever answer it baked first.
world.blockEdits = { TEST_MAP = { [1] = 5 } }
world.maps.TEST_MAP.blocks[1] = 99
world.mapImages["TEST_MAP|DAY|gbc|1"] = "stale"
check(world:restoreBlocks(), "restoreBlocks reports it restored something")
eq(world.maps.TEST_MAP.blocks[1], 5, "the original block is back")
check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil,
"and the bake taken off the edited blocks went with it")
end
-- ---------------------------------------------------------------------------
-- readvar VAR_WEEKDAY
-- ---------------------------------------------------------------------------
-- GetWeekday reads wCurDay, SUNDAY 0 .. SATURDAY 6 -- the same numbering
-- os.date("%w") uses. 39 of the 40 readvar sites reachable from a callback are
-- this one, and every one of them was taking the SUNDAY arm.
do
local world = callbackWorld(nil, {})
world.clockDay = 2
eq(world:weekday(), 2, "clockDay pins the day")
world.clockDay = 9
eq(world:weekday(), 2, "and wraps into the week")
world.clockDay = nil
local today = tonumber(os.date("%w")) or 0
eq(world:weekday(), today, "unpinned, it is the host clock's day")
check(world:weekday() >= 0 and world:weekday() <= 6, "in SUNDAY..SATURDAY")
end
-- ---------------------------------------------------------------------------
-- Every callback in a real cache
-- ---------------------------------------------------------------------------
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local mapsPath = cache .. "/data/generated/maps.lua"
local mf = io.open(mapsPath, "r")
if not mf then
check(true, "gold cache absent : fixture checks only (SKIP cache facts)")
S.finish()
return
end
mf:close()
local maps = assert(loadfile(mapsPath))()
local scripts = assert(loadfile(cache .. "/data/generated/scripts.lua"))()
local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))()
local byType, rows = {}, {}
for id, def in pairs(maps) do
for _, cb in ipairs(def.callbacks or {}) do
byType[cb.callback] = (byType[cb.callback] or 0) + 1
rows[#rows + 1] = { map = id, kind = cb.callback, key = cb.scriptKey }
end
end
check(#rows > 0, "the cache carries map callbacks at all")
-- The census, so a re-import that loses a whole type fails here rather than by
-- a door quietly staying open. MAPCALLBACK_SPRITES has no user in Gold.
eq(byType.MAPCALLBACK_NEWMAP, 39, "39 MAPCALLBACK_NEWMAP")
eq(byType.MAPCALLBACK_OBJECTS, 24, "24 MAPCALLBACK_OBJECTS")
eq(byType.MAPCALLBACK_TILES, 19, "19 MAPCALLBACK_TILES")
eq(byType.MAPCALLBACK_CMDQUEUE, 2, "2 MAPCALLBACK_CMDQUEUE")
check(byType.MAPCALLBACK_SPRITES == nil,
"and no MAPCALLBACK_SPRITES, which no Gold map declares")
local missing = 0
for _, row in ipairs(rows) do
if not (row.key and scripts[row.key]) then missing = missing + 1 end
end
eq(missing, 0, "every callback row names a body the cache carries")
-- Run all of them. A callback is a script, and the one thing that must hold
-- for all 84 is that each completes inside the map load: no yield, no unknown
-- opcode, no bad byte. The hooks are counters rather than a World, so this
-- checks the BODIES rather than the wiring -- the wiring is the order check
-- above and tests/drivers/gold_map_callbacks.lua.
do
local blocks, appears, disappears = 0, 0, 0
local vm = Vm.new(scripts, {}, Events.new(), {
specialOrder = constants.specialOrder,
specials = {},
-- VAR_WEEKDAY, so the day-of-week arms are exercised rather than all
-- taking SUNDAY the way an unanswered readvar makes them.
readVar = function(id) return id == 0x0b and 2 or 0 end,
changeBlock = function() blocks = blocks + 1 return true end,
appear = function() appears = appears + 1 end,
disappear = function() disappears = disappears + 1 end,
})
local ran = 0
for _, row in ipairs(rows) do
if vm:runCallback(row.key) then ran = ran + 1 end
end
eq(ran, #rows, "all " .. #rows .. " callback bodies run to completion")
local blocked = {}
for key in pairs(vm.blockedCallbacks) do blocked[#blocked + 1] = key end
eq(#blocked, 0,
"none of them blocks: " .. table.concat(blocked, ", "))
local unknown = {}
for op in pairs(vm.unknownOps) do unknown[#unknown + 1] = op end
table.sort(unknown)
eq(#unknown, 0,
"and none reaches an unimplemented opcode: " .. table.concat(unknown, ", "))
eq(#vm.badBytes, 0, "with no extractor `unknown` row inside one")
check(blocks > 0, "the run really repainted blocks (" .. blocks .. ")")
check(appears > 0 and disappears > 0,
("and moved objects (%d appear, %d disappear)"):format(appears, disappears))
end
-- The named ones, so the census above cannot pass on the wrong rows.
do
local function kindOf(mapId, kind)
for _, cb in ipairs((maps[mapId] or {}).callbacks or {}) do
if cb.callback == kind then return scripts[cb.scriptKey] end
end
return nil
end
local function ops(body)
local out = {}
for _, c in ipairs(body or {}) do out[#out + 1] = c.op end
return table.concat(out, ",")
end
-- NewBarkTownFlypointCallback: `setflag ENGINE_FLYPOINT_NEW_BARK` is the
-- cart's own way of banking a fly point.
check(ops(kindOf("NEW_BARK_TOWN", "MAPCALLBACK_NEWMAP")):find("setflag"),
"New Bark's NEWMAP callback is the flypoint flag")
-- Route31CheckMomCallCallback queues Mom's worried call, which is a whole
-- phone conversation nothing could reach while the callbacks were dead.
check(ops(kindOf("ROUTE_31", "MAPCALLBACK_NEWMAP"))
:find("checkevent") ~= nil, "Route 31's NEWMAP callback checks the egg quest")
-- BrunosRoomDoorsCallback: the entrance sealing behind you and the exit
-- opening after the battle. Both `iffalse`s jump FORWARD over their own
-- changeblock into the next test, so both sit in the body itself rather than
-- behind a branch -- which is why the whole callback is five commands.
local bruno = kindOf("BRUNOS_ROOM", "MAPCALLBACK_TILES")
local brunoBlocks = 0
for _, c in ipairs(bruno or {}) do
if c.op == "changeblock" then brunoBlocks = brunoBlocks + 1 end
end
eq(brunoBlocks, 2, "Bruno's room walls itself in with two changeblocks")
check(ops(bruno):find("checkevent"),
"and it opens on EVENT_BRUNOS_ROOM_ENTRANCE_CLOSED")
-- GoldenrodUndergroundCheckDayOfWeekCallback: seven arms off VAR_WEEKDAY.
check(ops(kindOf("GOLDENROD_UNDERGROUND", "MAPCALLBACK_OBJECTS"))
:find("readvar"), "the Goldenrod underground reads the weekday")
-- Route16AlwaysOnBikeCallback (maps/Route16.asm:7-17), read off the cache:
-- VAR_YCOORD $13 then VAR_XCOORD $12, and no `checkitem` anywhere in it.
local r16 = kindOf("ROUTE_16", "MAPCALLBACK_NEWMAP")
eq(ops(r16), "readvar,ifless,readvar,ifgreater,setflag,endcallback",
"Route 16's NEWMAP callback is the coordinate test")
local vars = {}
for _, c in ipairs(r16 or {}) do
if c.op == "readvar" then vars[#vars + 1] = c.var end
end
eq(table.concat(vars, ","), "19,18", "VAR_YCOORD first, then VAR_XCOORD")
-- Route17AlwaysOnBikeCallback (maps/Route17.asm:13-16): the sibling, with no
-- coordinate test at all -- ALWAYS_ON_BIKE and DOWNHILL, unconditionally.
eq(ops(kindOf("ROUTE_17", "MAPCALLBACK_NEWMAP")),
"setflag,setflag,endcallback", "Route 17 forces the bike and the downhill")
-- maps/Route16Gate.asm:69-70: the BICYCLE gate is the gatehouse's two coord
-- events, which is what keeps a bikeless player off the road the callback
-- above mounts unconditionally.
local gateEvents = (maps.ROUTE_16_GATE or {}).coordEvents or {}
eq(#gateEvents, 2, "the Route 16 gatehouse carries its two coord events")
local checks = 0
for _, ev in ipairs(gateEvents) do
if ops(scripts[ev.scriptKey]):find("checkitem") then checks = checks + 1 end
end
eq(checks, 2, "and both are the checkitem BICYCLE refusal")
end
S.finish()