#!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path package.loaded["src.core.game3.rom_text"] = { plain = function(key) return key end, box = function(key) return key end, ascii = function(key) return key end, has = function() return true end, key = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, at = function(n, i, j) return j and (n .. "[" .. i .. "][" .. j .. "]") or (n .. "[" .. i .. "]") end, count = function() return 0 end, list = function() return {} end, lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, } local failed = 0 local function check(cond, msg) if cond then print("[ok] " .. msg) else failed = failed + 1 print("[FAIL] " .. msg) end end local function eq(a, b, msg) check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) end local store = { flags = {}, vars = {} } local session = { store = store, map = "FR_SILPH_CO_ELEVATOR", party = {} } package.loaded["src.core.game3.runtime"] = { getSession = function() return session end, isActive = function() return true end, } local Std = require("src.core.game3.scripting.stdscripts") local Natives = require("src.core.game3.scripting.natives") local Elevator = require("src.core.game3.scripting.natives_elevator") local ListMenu = require("src.core.game3.scripting.natives_listmenu") local Cutscene = require("src.core.game3.scripting.natives_cutscene") local Flags = require("src.core.game3.scripting.flags") local Stack = require("src.ui.game3.stack") local Menu = ListMenu.Menu local SCR_MENU_CANCEL = 0x7F local function newCtx() return { specialVars = {} } end local function getVar(ctx, id) return Flags.getVar(store, ctx, id) end local function result(ctx) return getVar(ctx, 0x800D) end print("[test] 1. ids match the 0-based def_special index of pokefirered/data/specials.inc") local EXPECTED = { GetElevatorFloor = 0xD8, AnimateElevator = 0x111, SpawnCameraObject = 0x113, RemoveCameraObject = 0x114, DrawElevatorCurrentFloorWindow = 0x132, ListMenu = 0x158, ReturnToListMenu = 0x159, CloseElevatorCurrentFloorWindow = 0x160, AnimateTeleporterHousing = 0x1B5, AnimateTeleporterCable = 0x1B7, InitElevatorFloorSelectMenuPos = 0x1B8, } for name, id in pairs(EXPECTED) do eq(Std.SPECIAL[name], id, "Std.SPECIAL." .. name) check(Natives.ALLOW["special:" .. id] ~= nil, name .. " is bound in Natives.ALLOW") end local names = {} for _, n in ipairs(Natives.MODULE_NAMES) do names[n] = true end check(names["natives_elevator"], "discovery found natives_elevator with no natives.lua edit") check(names["natives_listmenu"], "discovery found natives_listmenu") check(names["natives_cutscene"], "discovery found natives_cutscene") print("[test] 2. no registered handler logs as unknown") Natives.resetLog() local logs = {} local quiet = { log = function(m) logs[#logs + 1] = m end } for _, mod in ipairs({ Elevator, ListMenu, Cutscene }) do for id in pairs(mod.HANDLERS) do local _, _, known = Natives.special(newCtx(), id, quiet) check(known, string.format("special 0x%X dispatches to a handler", id)) end end Menu.close() eq(#logs, 0, "nothing reached the unknown-special log") print("[test] 3. GetElevatorFloor writes VAR_ELEVATOR_FLOOR from the dynamic warp") local FLOORS = { FR_SILPH_CO_1F = 4, FR_SILPH_CO_5F = 8, FR_SILPH_CO_11F = 14, FR_ROCKET_HIDEOUT_B1F = 3, FR_ROCKET_HIDEOUT_B2F = 2, FR_ROCKET_HIDEOUT_B4F = 0, FR_CELADON_CITY_DEPARTMENT_STORE_5F = 8, FR_TRAINER_TOWER_LOBBY = 3, FR_TRAINER_TOWER_ROOF = 15, } for mapId, floor in pairs(FLOORS) do session.dynamicWarp = { map = mapId } local ctx = newCtx() Elevator.HANDLERS[Std.SPECIAL.GetElevatorFloor](ctx, {}) eq(getVar(ctx, 0x403A), floor, "VAR_ELEVATOR_FLOOR after GetElevatorFloor on " .. mapId) end session.dynamicWarp = nil local ctx3 = newCtx() Elevator.HANDLERS[Std.SPECIAL.GetElevatorFloor](ctx3, {}) eq(getVar(ctx3, 0x403A), 4, "an unset dynamic warp leaves the cart default 1F") session.dynamicWarp = { map = "FR_SILPH_CO_5F" } Elevator.HANDLERS[Std.SPECIAL.GetElevatorFloor](newCtx(), {}) eq(Flags.getVar(store, nil, 0x403A), 8, "VAR_ELEVATOR_FLOOR round trips through the store") print("[test] 4. InitElevatorFloorSelectMenuPos returns the cursor for the current floor") local POS = { FR_SILPH_CO_11F = { 0, 0 }, FR_SILPH_CO_7F = { 0, 4 }, FR_SILPH_CO_5F = { 2, 4 }, FR_SILPH_CO_1F = { 5, 5 }, FR_ROCKET_HIDEOUT_B4F = { 0, 2 }, FR_CELADON_CITY_DEPARTMENT_STORE_1F = { 0, 4 }, } for mapId, want in pairs(POS) do session.dynamicWarp = { map = mapId } local ctx = newCtx() local yield, value = Elevator.HANDLERS[Std.SPECIAL.InitElevatorFloorSelectMenuPos](ctx, {}) eq(yield, false, "InitElevatorFloorSelectMenuPos does not yield on " .. mapId) eq(value, want[2], "cursor returned for " .. mapId) eq(ListMenu.elevatorScroll, want[1], "scroll staged for " .. mapId) eq(ListMenu.elevatorCursorPos, want[2], "cursor staged for " .. mapId) end print("[test] 5. the elevator floor window drives the adapter seams") local shown, closed = nil, 0 local adapters5 = { elevatorWindow = function(label) shown = label end, elevatorWindowClose = function() closed = closed + 1 end, } local ctx5 = newCtx() Flags.setVar(store, ctx5, 0x8005, 8) Elevator.HANDLERS[Std.SPECIAL.DrawElevatorCurrentFloorWindow](ctx5, adapters5) eq(shown, "sFloorNamePointers[8]", "floor 8 draws the 5F window") Flags.setVar(store, ctx5, 0x8005, 0) Elevator.HANDLERS[Std.SPECIAL.DrawElevatorCurrentFloorWindow](ctx5, adapters5) eq(shown, "sFloorNamePointers[0]", "floor 0 draws the B4F window") Flags.setVar(store, ctx5, 0x8005, 15) Elevator.HANDLERS[Std.SPECIAL.DrawElevatorCurrentFloorWindow](ctx5, adapters5) eq(shown, "sFloorNamePointers[15]", "floor 15 draws the ROOFTOP window") Elevator.HANDLERS[Std.SPECIAL.CloseElevatorCurrentFloorWindow](ctx5, adapters5) eq(closed, 1, "CloseElevatorCurrentFloorWindow closes it") local okSeamless = pcall(function() Elevator.HANDLERS[Std.SPECIAL.DrawElevatorCurrentFloorWindow](ctx5, {}) Elevator.HANDLERS[Std.SPECIAL.CloseElevatorCurrentFloorWindow](ctx5, {}) end) check(okSeamless, "both window specials are safe with no host seam") print("[test] 6. AnimateElevator holds the script for the cart's frame count") local Field = require("src.core.game3.field") local realSetMetatile = Field.setMetatile local writes = {} Field.setMetatile = function(x, y, mid, impassable) writes[#writes + 1] = { x = x, y = y, mid = mid, impassable = impassable } end local FieldView = package.loaded["src.core.game3.field_view"] if not FieldView then FieldView = { _nativeDirty = false } package.loaded["src.core.game3.field_view"] = FieldView end local ctx6 = newCtx() Flags.setVar(store, ctx6, 0x8005, 4) Flags.setVar(store, ctx6, 0x8006, 8) eq(Elevator.HANDLERS[Std.SPECIAL.AnimateElevator](ctx6, {}), false, "AnimateElevator returns to waitstate") local task = ctx6.stateWait check(type(task) == "function", "AnimateElevator armed a state task") local ticks = 0 -- pokefirered/src/field_specials.c:812 sElevatorAnimationDuration for _ = 1, 400 do ticks = ticks + 1 if task() then break end end eq(ticks, 114, "four floors takes 3 frames per shake step, 38 steps") eq(#writes, 135, "the window view wrote nine metatiles on each of fifteen steps") eq(writes[1] and writes[1].x, 1, "window view starts at map x 1") eq(writes[1] and writes[1].y, 0, "window view starts at map y 0") eq(writes[1] and writes[1].impassable, true, "MAPGRID_COLLISION_MASK on the window metatiles") eq(writes[1] and writes[1].mid, 0x2E9, "going up writes Top1 on the first step") eq(FieldView.cameraPanY, 0, "the camera pan is released when the elevator stops") local ctx6b = newCtx() Flags.setVar(store, ctx6b, 0x8005, 8) Flags.setVar(store, ctx6b, 0x8006, 8) Elevator.HANDLERS[Std.SPECIAL.AnimateElevator](ctx6b, {}) local task6b, ticks6b = ctx6b.stateWait, 0 for _ = 1, 100 do ticks6b = ticks6b + 1 if task6b() then break end end eq(ticks6b, 24, "a zero-floor move still runs sElevatorAnimationDuration[0]") writes = {} local Task = require("src.core.game3.task") Task.clear() local ctx6c = newCtx() Flags.setVar(store, ctx6c, 0x8005, 14) Flags.setVar(store, ctx6c, 0x8006, 4) Elevator.HANDLERS[Std.SPECIAL.AnimateElevator](ctx6c, {}) local task6c, ticks6c = ctx6c.stateWait, 0 for _ = 1, 400 do ticks6c = ticks6c + 1 if task6c() then break end end eq(writes[1] and writes[1].mid, 0x2EA, "going down writes Top2 on the first step") eq(ticks6c, 171, "ten floors clamps to eight, 57 shake steps of 3 frames") eq(#writes, 24 * 9, "the shake ended with the window view unfinished") -- pokefirered/src/field_specials.c:1119 AnimateElevatorWindowView is its own task eq(Task.count(), 1, "the unfinished window view kept running as a task") for _ = 1, 40 do Task.update(1 / 60) end eq(#writes, 27 * 9, "it ran on to sElevatorWindowAnimDuration[8] steps after the script resumed") eq(Task.count(), 0, "and then finished") Field.setMetatile = realSetMetatile print("[test] 7. ListMenu picks a real Silph floor") local ctx7 = newCtx() Flags.setVar(store, ctx7, 0x8004, ListMenu.LISTMENU_SILPHCO_FLOORS) session.dynamicWarp = { map = "FR_SILPH_CO_1F" } Elevator.HANDLERS[Std.SPECIAL.InitElevatorFloorSelectMenuPos](ctx7, {}) eq(ListMenu.HANDLERS[Std.SPECIAL.ListMenu](ctx7, {}), false, "ListMenu returns to waitstate") check(Menu.isOpen(), "the list menu is open") check(Stack.has("script_list_menu"), "the list menu owns the modal stack") local wait7 = ctx7.stateWait check(type(wait7) == "function" and wait7() == false, "the script waits while the list is open") eq(Menu.scroll, 5, "the Silph list opens scrolled to the bottom on 1F") eq(Menu.row, 6, "the cursor sits on 1F") eq(Menu.selection(), 10, "1F is index 10") Menu.move(-1) Menu.move(-1) Menu.move(-1) Menu.move(-1) eq(Menu.selection(), 6, "four presses up reach 5F") Menu.confirm() eq(result(ctx7), 6, "VAR_RESULT is the 5F case of the switch") check(wait7() == true, "the script resumes once a floor is picked") check(not Menu.isOpen(), "the list menu closed") check(not Stack.has("script_list_menu"), "the modal stack is empty again") print("[test] 8. scrolling stops at both ends and B cancels") local ctx8 = newCtx() Flags.setVar(store, ctx8, 0x8004, ListMenu.LISTMENU_SILPHCO_FLOORS) ListMenu.elevatorScroll, ListMenu.elevatorCursorPos = 0, 0 ListMenu.HANDLERS[Std.SPECIAL.ListMenu](ctx8, {}) eq(Menu.selection(), 0, "11F is the first entry") Menu.move(-1) eq(Menu.selection(), 0, "up at the top does not wrap") for _ = 1, 20 do Menu.move(1) end eq(Menu.selection(), 11, "down stops on EXIT, the twelfth entry") eq(Menu.scroll, 5, "the viewport scrolled to the bottom") eq(Menu.row, 7, "seven rows are visible at once") Menu.cancel() eq(result(ctx8), SCR_MENU_CANCEL, "B sets VAR_RESULT to 0x7F") check(ctx8.stateWait() == true, "cancel resumes the script") print("[test] 9. the badge list keeps its place across ReturnToListMenu") local ctx9 = newCtx() Flags.setVar(store, ctx9, 0x8004, ListMenu.LISTMENU_BADGES) ListMenu.HANDLERS[Std.SPECIAL.ListMenu](ctx9, {}) Menu.move(1) Menu.move(1) eq(Menu.selection(), 2, "THUNDERBADGE is index 2") Menu.confirm() eq(result(ctx9), 2, "VAR_RESULT is the chosen badge") check(not Menu.isOpen(), "the window is not left on screen while the badge text runs") check(ListMenu._suspended ~= nil, "a keep-open list stays suspended") local ctx9b = newCtx() ListMenu.HANDLERS[Std.SPECIAL.ReturnToListMenu](ctx9b, {}) check(Menu.isOpen(), "ReturnToListMenu reopens the badge list") eq(Menu.selection(), 2, "it reopens on the badge that was described") Menu.move(1) Menu.move(1) Menu.move(1) Menu.move(1) Menu.move(1) Menu.move(1) eq(Menu.selection(), 8, "EXIT is the ninth badge-list entry") Menu.confirm() eq(result(ctx9b), 8, "EXIT reports its own index") check(ListMenu._suspended == nil, "picking EXIT ends the keep-open list") local ctx9c = newCtx() eq(ListMenu.HANDLERS[Std.SPECIAL.ReturnToListMenu](ctx9c, {}), false, "ReturnToListMenu with nothing suspended is a no-op") check(not Menu.isOpen(), "and opens no window") print("[test] 10. an unknown list id cancels instead of hanging the script") local ctx10 = newCtx() Flags.setVar(store, ctx10, 0x8004, 99) ListMenu.HANDLERS[Std.SPECIAL.ListMenu](ctx10, {}) eq(result(ctx10), SCR_MENU_CANCEL, "VAR_RESULT is 0x7F") check(ctx10.stateWait() == true, "waitstate is satisfied immediately") check(not Menu.isOpen(), "no window was opened") print("[test] 11. camera object specials drive game3/camera_object.lua when it exists") local saved = package.loaded["src.core.game3.camera_object"] local calls = {} package.loaded["src.core.game3.camera_object"] = { spawn = function() calls[#calls + 1] = "spawn" end, remove = function() calls[#calls + 1] = "remove" end, } Cutscene.HANDLERS[Std.SPECIAL.SpawnCameraObject](newCtx(), {}) Cutscene.HANDLERS[Std.SPECIAL.RemoveCameraObject](newCtx(), {}) eq(table.concat(calls, ","), "spawn,remove", "SpawnCameraObject / RemoveCameraObject call it") package.loaded["src.core.game3.camera_object"] = {} local okNoApi = pcall(function() Cutscene.HANDLERS[Std.SPECIAL.SpawnCameraObject](newCtx(), {}) Cutscene.HANDLERS[Std.SPECIAL.RemoveCameraObject](newCtx(), {}) end) check(okNoApi, "the camera seam is safe while game3/camera_object.lua has no spawn/remove") package.loaded["src.core.game3.camera_object"] = saved for _, name in ipairs({ "AnimateTeleporterHousing", "AnimateTeleporterCable" }) do local ctx = newCtx() eq(Cutscene.HANDLERS[Std.SPECIAL[name]](ctx, {}), false, name .. " does not yield") eq(result(ctx), 0, name .. " leaves VAR_RESULT at 0") end local Task = require("src.core.game3.task") local realSpawn, realSet = Task.spawn, Field.setMetatile for _, name in ipairs({ "AnimateTeleporterHousing", "AnimateTeleporterCable" }) do local fn Task.spawn = function(f) fn = f end local tw = {} Field.setMetatile = function(x, y, mid, impassable) tw[#tw + 1] = impassable end Cutscene.HANDLERS[Std.SPECIAL[name]](newCtx(), {}) for _ = 1, 1000 do if not fn or fn() then break end end local allSolid = #tw > 0 for _, v in ipairs(tw) do if v ~= true then allSolid = false end end check(allSolid, name .. " writes every metatile with MAPGRID_COLLISION_MASK") end Task.spawn, Field.setMetatile = realSpawn, realSet if failed > 0 then print("[test] FAILED " .. failed) os.exit(1) end print("[test] all passed")