-- 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()