#!/usr/bin/env luajit package.path = "./?.lua;./?/init.lua;" .. package.path local GameVersion = require("src.core.GameVersion") GameVersion.set("firered") local Flags = require("src.core.game3.scripting.flags") local Ctx = require("src.core.game3.scripting.ctx") local Vm = require("src.core.game3.scripting.vm") local Ops = require("src.core.game3.scripting.ops_a") local Adapters = require("src.core.game3.scripting.adapters") local Audio = require("src.core.game3.audio") local Runtime = require("src.core.game3.runtime") local Player = require("src.core.game3.player") local Task = require("src.core.game3.task") local Warp = require("src.core.game3.warp") local LayoutNative = require("src.core.game3.layout_native") local NativePack = require("src.import.gba.native_pack") local Extract = require("src.import.gba.extract_island1") 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 VAR_TEMP_1 = 0x4001 local VAR_0x8004 = 0x8004 -- pokefirered/include/constants/field_tasks.h:4 local STEP_CB_ICE = (Ctx.STEP_CB and Ctx.STEP_CB.ICE) or 4 local STEP_CB_ASH = (Ctx.STEP_CB and Ctx.STEP_CB.ASH) or 1 local function resetStepCb() if Ctx.resetStepCallback then Ctx.resetStepCallback() end end local function stepCb(mapId) if not Ctx.stepCallback then return nil end return Ctx.stepCallback(mapId) end local function ops(name, ...) if type(Ops[name]) ~= "function" then return nil end return Ops[name](...) end local function newVm(scripts, store) store = store or Flags.newStore() local vm = Vm.new({ store = store, scripts = scripts, adapters = Adapters.host(nil, nil, nil) }) return vm, store end local function run(scripts, key, store) local vm, st = newVm(scripts, store) vm:start(key) return vm, st end print("[test] 1. playmoncry dispatches species and mode to the cry mixer") -- pokefirered/src/scrcmd.c:2088 Audio.stopCry() Audio._cryParams = nil run({ t = { { op = "playmoncry", [1] = 143, [2] = 2 }, { op = "end" } }, }, "t") eq(Audio._cryParams and Audio._cryParams.mode, 2, "playmoncry SNORLAX, CRY_MODE_ENCOUNTER reaches the mixer as mode 2") Audio.stopCry() Audio._cryParams = nil run({ t = { { op = "playmoncry", [1] = 33, [2] = 0 }, { op = "end" } }, }, "t") eq(Audio._cryParams and Audio._cryParams.mode, 0, "playmoncry NIDORAN_M, CRY_MODE_NORMAL reaches the mixer as mode 0") -- pokefirered/src/scrcmd.c:2090 Audio.stopCry() Audio._cryParams = nil local vm, store = newVm({ t = { { op = "setvar", [1] = VAR_TEMP_1, [2] = 25 }, { op = "playmoncry", [1] = VAR_TEMP_1, [2] = 0 }, { op = "end" }, }, }) vm:start("t") check(Audio._cryParams ~= nil, "playmoncry VarGets a variable species operand without erroring") print("[test] 2. waitmoncry blocks the script until the cry finishes") -- pokefirered/src/scrcmd.c:2097 Audio.stopCry() vm, store = newVm({ t = { { op = "playmoncry", [1] = 143, [2] = 2 }, { op = "waitmoncry" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 7 }, { op = "end" }, }, }) vm:start("t") check(vm:isRunning(), "the vm is still running at waitmoncry") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 0, "the op after waitmoncry has not run yet") local heldFrames = 0 for _ = 1, 600 do if not vm:isRunning() then break end heldFrames = heldFrames + 1 Audio.tickCry(1 / 60) vm:tick() end check(heldFrames > 1, "waitmoncry held the script for " .. heldFrames .. " frames") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 7, "the script resumed once the cry finished") Audio.stopCry() vm, store = run({ t = { { op = "waitmoncry" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 7 }, { op = "end" }, }, }, "t") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 7, "waitmoncry with no cry playing does not block") -- pokefirered/src/sound.c:502 Audio.stopCry() vm, store = newVm({ t = { { op = "playmoncry", [1] = 143, [2] = 2 }, { op = "waitmoncry" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 9 }, { op = "end" }, }, }) vm:start("t") for _ = 1, 600 do if not vm:isRunning() then break end vm:tick() end eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 9, "waitmoncry still completes when the caller never ticks the audio clock") print("[test] 3. setstepcallback registry") -- pokefirered/src/field_tasks.c:96 resetStepCb() local prevSession = Runtime.session Runtime.session = { map = "FR_FOUR_ISLAND_ICEFALL_CAVE_1F" } run({ t = { { op = "setstepcallback", [1] = STEP_CB_ICE }, { op = "end" } } }, "t") eq(stepCb("FR_FOUR_ISLAND_ICEFALL_CAVE_1F"), "ice", "STEP_CB_ICE registers the ice callback on Icefall Cave 1F") eq(stepCb("FR_PALLET_TOWN"), nil, "the callback does not leak to another map") -- pokefirered/src/overworld.c:2105 resetStepCb() eq(stepCb("FR_FOUR_ISLAND_ICEFALL_CAVE_1F"), nil, "a map load resets the callback to STEP_CB_DUMMY") resetStepCb() run({ t = { { op = "setstepcallback", [1] = STEP_CB_ASH }, { op = "end" } } }, "t") eq(stepCb(), nil, "an unregistered callback id falls back to STEP_CB_DUMMY") resetStepCb() print("[test] 4. setmaplayoutindex swaps the live layout") -- pokefirered/src/overworld.c:978 local W, H = 6, 4 local baseCells, altCells = {}, {} for i = 1, W * H do baseCells[i] = { mid = 0x10, coll = 0x00, elev = 3 } altCells[i] = { mid = 0x10, coll = 0x00, elev = 3 } end altCells[1] = { mid = 0x2A, coll = 0x01, elev = 4 } altCells[W * H] = { mid = 0x2B, coll = 0x01, elev = 4 } local layout = LayoutNative.fromDecoded({ width = W, height = H, borderWidth = 1, borderHeight = 1, borderMids = { 0 }, cells = baseCells, }, "FR_SEAFOAM_ISLANDS_B4F", "cave") local scratch = os.getenv("TMPDIR") or "/tmp" scratch = scratch:gsub("/$", "") .. "/game3_ops_scene_" .. tostring(os.time()) os.execute("mkdir -p '" .. scratch .. "/layouts'") local blob = NativePack.encodeMidLayout({ width = W, height = H, borderWidth = 1, borderHeight = 1, borderMids = { 0 }, cells = altCells, }) local fh = io.open(scratch .. "/layouts/alt_279.mid", "wb") fh:write(blob) fh:close() local prevRoot = Extract.NATIVE_ROOT Extract.NATIVE_ROOT = scratch local prevGame = Runtime._game Runtime._game = { data = { maps = { FR_SEAFOAM_ISLANDS_B4F = { midLayout = layout, pair = "cave", width = W, height = H, } } } } Runtime.session = { map = "FR_SEAFOAM_ISLANDS_B4F" } eq(layout:midAt(0, 0), 0x10, "the current layout starts on the flowing-current metatile") run({ t = { { op = "setmaplayoutindex", [1] = 279 }, { op = "end" } } }, "t") eq(layout:midAt(0, 0), 0x2A, "setmaplayoutindex swapped the first cell's metatile") eq(layout:collAt(0, 0), 0x01, "the swapped cell carries the alternate layout's collision") eq(layout:elevAt(0, 0), 4, "the swapped cell carries the alternate layout's elevation") eq(layout:midAt(W - 1, H - 1), 0x2B, "setmaplayoutindex swapped the last cell too") eq(layout:midAt(1, 0), 0x10, "cells the alternate layout leaves alone are untouched") local ok, swapped = ops("setMapLayout", 279) check(ok and swapped == 0, "re-applying the same layout swaps nothing") check(ops("setMapLayout", 9999) == false, "an unbaked layout id is a no-op, not an error") layout:clearOverrides() eq(layout:midAt(0, 0), 0x10, "clearing the overrides restores the original layout") os.execute("rm -rf '" .. scratch .. "'") Extract.NATIVE_ROOT = prevRoot print("[test] 5. warphole destination resolution") -- pokefirered/src/scrcmd.c:761 eq(ops("warpHoleDest", 1, 112), "FR_FOUR_ISLAND_ICEFALL_CAVE_B1F", "warphole MAP_FOUR_ISLAND_ICEFALL_CAVE_B1F resolves to the port map id") -- pokefirered/include/constants/maps.h:11 eq(ops("warpHoleDest", 0xFF, 0xFF), nil, "MAP_UNDEFINED has no fixed hole warp on this cart") Runtime.session = { map = "FR_FOUR_ISLAND_ICEFALL_CAVE_1F" } Runtime._game = prevGame Warp._busy = false Task.clear() Player.reset(8, 14, "down") vm, store = newVm({ t = { { op = "warphole", [1] = 0xFF, [2] = 0xFF }, { op = "waitstate" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 5 }, { op = "end" }, }, }) vm:start("t") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 5, "an unresolvable warphole falls through instead of hanging") check(vm.ctx.warpPending ~= true, "no warp is marked pending when the destination is unknown") Task.clear() Warp._busy = false Player.reset(8, 14, "down") vm, store = newVm({ t = { { op = "warphole", [1] = 1, [2] = 112 }, { op = "waitstate" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 5 }, { op = "end" }, }, }) vm:start("t") check(Warp.isBusy() == true, "warphole started the fall sequence") check(vm.ctx.warpPending == true, "warphole marked the warp pending") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 0, "waitstate is blocking on the fall") for _ = 1, 30 do Task.update(1 / 60) vm:tick() end eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 0, "waitstate keeps blocking while the fall runs") check(vm.ctx.warpPending == true, "the pending flag is still set while Warp is busy") check(vm:isRunning(), "the script is still parked on waitstate after 30 frames of falling") vm:halt() Task.clear() Warp._busy = false print("[test] 5b. the pending flag clears and waitstate releases when the fall ends") -- pokefirered/data/scripts/hole.inc:19 local realWarp = package.loaded["src.core.game3.warp"] local fallFrames = 0 package.loaded["src.core.game3.warp"] = { startFall = function() fallFrames = 12 return true end, isBusy = function() return fallFrames > 0 end, } Task.clear() Player.reset(8, 14, "down") vm, store = newVm({ t = { { op = "warphole", [1] = 1, [2] = 112 }, { op = "waitstate" }, { op = "setvar", [1] = VAR_TEMP_1, [2] = 5 }, { op = "end" }, }, }) vm:start("t") check(vm.ctx.warpPending == true, "warphole marked the warp pending") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 0, "waitstate is parked while the fall runs") for _ = 1, 40 do if fallFrames > 0 then fallFrames = fallFrames - 1 end Task.update(1 / 60) vm:tick() end check(vm.ctx.warpPending == false, "the pending flag cleared when Warp stopped being busy") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 5, "waitstate released and the script ran on") check(not vm:isRunning(), "the script finished instead of hanging on waitstate") package.loaded["src.core.game3.warp"] = realWarp Task.clear() print("[test] 6. braille width is pret's fixed 16px advance") -- pokefirered/src/braille_text.c:209 local everything = { { t = "text", s = "EVERYTHING" }, { t = "eos" } } eq(ops("brailleWidth", everything), 160, "a ten glyph braille line measures 160") eq(ops("brailleWidth", { { t = "text", s = "ABC" }, { t = "eos" } }), 48, "ABC measures 48") -- pokefirered/src/text.c:1051 eq(ops("brailleWidth", { { t = "text", s = "ABC" }, { t = "nl" }, { t = "text", s = "WXYZ" }, { t = "eos" }, }), 64, "a two line braille string measures its widest line") eq(ops("brailleWidth", { { t = "text", s = "É?é=" }, { t = "eos" } }), 64, "multi byte glyphs count once each") eq(ops("brailleWidth", nil), 0, "a missing braille string measures 0") print("[test] 7. braillemessage and getbraillestringwidth fall back with no renderer") -- pokefirered/src/scrcmd.c:1570 local prevBraille = package.loaded["src.ui.game3.braille"] package.loaded["src.ui.game3.braille"] = {} local opened = {} local adapters = Adapters.host(nil, nil, nil) adapters.openMessageStay = function(body) opened[#opened + 1] = body end adapters.openMessageAsync = nil adapters.openMessage = nil store = Flags.newStore() vm = Vm.new({ store = store, scripts = { t = { { op = "braillemessage", [1] = "braille1", ptr = "braille1" }, { op = "getbraillestringwidth", [1] = "braille1" }, { op = "copyvar", [1] = VAR_TEMP_1, [2] = VAR_0x8004 }, { op = "end" }, }, }, text = { braille1 = { { t = "text", s = "EVERYTHING" }, { t = "eos" } } }, adapters = adapters, }) vm:start("t") eq(#opened, 1, "braillemessage fell back to the ordinary message box") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 160, "getbraillestringwidth wrote the width into VAR_0x8004") package.loaded["src.ui.game3.braille"] = prevBraille print("[test] 8. braillemessage hands the string to the renderer when it exists") local shown = {} package.loaded["src.ui.game3.braille"] = { show = function(text, opts) shown[#shown + 1] = { text = text, opts = opts } end, width = function(text) return #text * 16 end, } opened = {} store = Flags.newStore() vm = Vm.new({ store = store, scripts = { t = { { op = "braillemessage", [1] = "braille1", ptr = "braille1" }, { op = "getbraillestringwidth", [1] = "braille1" }, { op = "copyvar", [1] = VAR_TEMP_1, [2] = VAR_0x8004 }, { op = "end" }, }, }, text = { braille1 = { { t = "text", s = "CUT" }, { t = "eos" } } }, adapters = adapters, }) vm:start("t") eq(#opened, 0, "the ordinary message box is not used when the renderer is present") eq(shown[1] and shown[1].text, "CUT", "Braille.show received the braille string") eq(shown[1] and shown[1].opts and shown[1].opts.width, 48, "Braille.show received the pret width") eq(Flags.getVar(store, vm.ctx, VAR_TEMP_1), 48, "Braille.width fed VAR_0x8004") package.loaded["src.ui.game3.braille"] = prevBraille Runtime.session = prevSession Runtime._game = prevGame if failed > 0 then print("[test] FAILED " .. failed) os.exit(1) end print("[test] all passed")