From 2c356f28eab53472829310e2221111da8c371c8d Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 22 Sep 2026 03:28:39 +0100 Subject: [PATCH] fix(game3): save, item, map, script and importer bugs found in review The rest of the Gen 3 review fixes, each with a gated suite in tests/engine/. ROM semantics were checked against pret/pokefirered. Scripts - givemon carried the wrong operand layout, found earlier; four more layout desyncs came out of pret asm/macros/event.inc: comparestat is {byte,word}, and setptr / loadbytefromptr / setptrbyte each carry a leading byte plus a word. A wrong size mis-decodes every instruction after the bad one, so Versions.CACHE_VERSION moves to 113 and existing caches re-import. - handlers for previously handler-less verbs: comparestat, bufferitemnameplural, setmonmove, setmonmetlocation, the modern fateful-encounter pair, the script-locals family (copylocal, setptr, loadbytefromptr, setptrbyte, copybyte, compare_local_to_* and compare_ptr_to_*), the RAM-script family (setvaddress, vgoto, vcall, vgoto_if, vcall_if, vmessage, vbuffermessage, vbufferstring, endram, returnram) and the same-map forms of the *at verbs. - setdooropen / setdoorclosed read their coordinates through VarGet. Battles - Knock Off and Thief / Trick persist the item change instead of only touching the in-battle copy. - knocked-off party slots are tracked in a bitmask, so a slot reused later does not inherit the flag. Field and UI - Player.reset restores facing and clears the surf flags. - a definition-less Map.load no longer leaves collision unbound. - an unresolved region-map section no longer reports PALLET TOWN. - the naming screen splits input from the timer, so update(dt) stops indexing a number. - the hall of fame commits through the engine save path and serializes its fields. Persistence - gameStats, the link-battle records and the trainer card are serialized. - the PC deposit refuses at the 999 cap instead of destroying the overflow. Robustness - Data.load runs cached modules sandboxed. - the file browser quotes shell arguments. - .meta dimensions are bounds-checked and mids.idx validates its header. --- src/core/Data.lua | 10 +- src/core/game3/battle/effects/secondary.lua | 20 +- src/core/game3/battle/effects/special.lua | 10 +- src/core/game3/battle/init.lua | 3 +- src/core/game3/battle/state.lua | 29 ++ src/core/game3/dataset.lua | 9 +- src/core/game3/map.lua | 9 +- src/core/game3/ow_sprites.lua | 9 + src/core/game3/player.lua | 11 + src/core/game3/runtime.lua | 3 +- src/core/game3/save_schema_firered.lua | 29 ++ src/core/game3/scripting/opcodes.lua | 16 +- src/core/game3/scripting/ops_a.lua | 296 +++++++++++++++++- src/core/game3/storage.lua | 8 +- src/import/gba/native_pack.lua | 15 + src/import/gba/versions.lua | 7 +- src/ui/game3/hall_of_fame.lua | 20 +- src/ui/game3/map_name_popup.lua | 5 +- src/ui/game3/naming.lua | 36 ++- src/ui/game3/new_game_scene.lua | 3 +- src/ui/kit/FileBrowser.lua | 15 +- .../game3_cache_module_sandbox_test.lua | 61 ++++ .../engine/game3_file_browser_quote_test.lua | 50 +++ .../game3_gamestats_persistence_test.lua | 45 +++ .../game3_givemon_opcode_layout_test.lua | 58 ++++ tests/engine/game3_hall_of_fame_save_test.lua | 85 +++++ tests/engine/game3_item_steal_trick_test.lua | 103 ++++++ tests/engine/game3_knock_off_item_test.lua | 75 +++++ tests/engine/game3_knocked_off_flag_test.lua | 99 ++++++ .../game3_link_records_persistence_test.lua | 49 +++ tests/engine/game3_map_def_less_bind_test.lua | 75 +++++ .../game3_map_section_unresolved_test.lua | 57 ++++ tests/engine/game3_mids_idx_bounds_test.lua | 52 +++ .../game3_naming_update_contract_test.lua | 65 ++++ tests/engine/game3_ow_meta_bounds_test.lua | 69 ++++ tests/engine/game3_pc_item_capacity_test.lua | 67 ++++ tests/engine/game3_player_reset_test.lua | 51 +++ .../engine/game3_script_verbs_subset_test.lua | 259 +++++++++++++++ 38 files changed, 1847 insertions(+), 36 deletions(-) create mode 100644 tests/engine/game3_cache_module_sandbox_test.lua create mode 100644 tests/engine/game3_file_browser_quote_test.lua create mode 100644 tests/engine/game3_gamestats_persistence_test.lua create mode 100644 tests/engine/game3_givemon_opcode_layout_test.lua create mode 100644 tests/engine/game3_hall_of_fame_save_test.lua create mode 100644 tests/engine/game3_item_steal_trick_test.lua create mode 100644 tests/engine/game3_knock_off_item_test.lua create mode 100644 tests/engine/game3_knocked_off_flag_test.lua create mode 100644 tests/engine/game3_link_records_persistence_test.lua create mode 100644 tests/engine/game3_map_def_less_bind_test.lua create mode 100644 tests/engine/game3_map_section_unresolved_test.lua create mode 100644 tests/engine/game3_mids_idx_bounds_test.lua create mode 100644 tests/engine/game3_naming_update_contract_test.lua create mode 100644 tests/engine/game3_ow_meta_bounds_test.lua create mode 100644 tests/engine/game3_pc_item_capacity_test.lua create mode 100644 tests/engine/game3_player_reset_test.lua create mode 100644 tests/engine/game3_script_verbs_subset_test.lua diff --git a/src/core/Data.lua b/src/core/Data.lua index eca32b99..4575d883 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -264,7 +264,11 @@ local function loadModule(dir, name) local path = "data/generated/" .. name .. ".lua" local bytes = CacheFs.readActive(path) if type(bytes) == "string" then - local chunk = loadstring(bytes, "@" .. GameVersion.cachePrefix() .. path) + -- Sandbox the generated module the way every other cache loader in the + -- engine does (dataset/doors/field/...). Without an environment the chunk + -- ran with the real os/io/loadfile in scope, so a file dropped into the + -- user-writable cache would execute at boot. + local chunk = load(bytes, "@" .. GameVersion.cachePrefix() .. path, "t", {}) if chunk then local ok, res = pcall(chunk) if ok then return true, res end @@ -275,6 +279,10 @@ local function loadModule(dir, name) return false, nil end +-- Test seam: the generated-module loader, so a suite can pin the sandbox that +-- keeps cache files from reaching os/io/loadfile at boot. +Data._loadModule = loadModule + function Data:load() local dir = os.getenv("POKEPORT_DATA_DIR") local gen = require("src.core.GameVersion").generation() diff --git a/src/core/game3/battle/effects/secondary.lua b/src/core/game3/battle/effects/secondary.lua index 761df681..61e81e74 100644 --- a/src/core/game3/battle/effects/secondary.lua +++ b/src/core/game3/battle/effects/secondary.lua @@ -264,6 +264,13 @@ local function persist_item(b, item) end Secondary.persistItem = persist_item +-- Battle-scoped state (the adapter carries it as `_st`). Resolved lazily so +-- this module stays loadable without the battle engine. +local function battle_state() + return package.loaded["src.core.game3.battle.state"] + or require("src.core.game3.battle.state") +end + function Secondary.set(M, eff, primary, certain, affectsUser) local ad = M.adapter local user, target = M.user, M.target @@ -357,7 +364,8 @@ function Secondary.set(M, eff, primary, certain, affectsUser) return true elseif eff == "STEAL_ITEM" then if user.side ~= "player" then return false end - if user.expKnockedOff then return false end + local St = battle_state() + if user.expKnockedOff or (St and St.isKnockedOff(ad._st, user)) then return false end local tItem = tonumber(target.item) or 0 if tItem ~= 0 and ad:abilityOf(target) == "STICKY_HOLD" then ad:say(Strings("%s's STICKY HOLD\nmade %s ineffective!", name(ad, target), (M.moveName or "THIEF"))) @@ -369,7 +377,9 @@ function Secondary.set(M, eff, primary, certain, affectsUser) user.item = tItem target.item = 0 persist_item(user, tItem) - if target.side == "player" then persist_item(target, 0) end + -- Both sides, not just the player: leaving the victim's party mon holding + -- an item its battler no longer has duplicates it on the next send-out. + persist_item(target, 0) ad:playAnim("general", "ITEM_STEAL", user, target) ad:say(Strings("%s stole\n%s's %s!", name(ad, user), name(ad, target), item_name(tItem))) return true @@ -454,7 +464,13 @@ function Secondary.set(M, eff, primary, certain, affectsUser) end if tItem == 0 then return false end effBattler.item = 0 + -- The battler is a battle-local view: State.makeBattler rebuilds `item` + -- from held_item(mon), so clearing only the battler lets the knocked-off + -- item return on the next send-out. Write the removal through to the mon. + persist_item(effBattler, 0) effBattler.expKnockedOff = true + local St = battle_state() + if St then St.markKnockedOff(ad._st, effBattler) end ad:playAnim("general", "ITEM_KNOCKOFF", user, effBattler) ad:say(Strings("%s knocked off\n%s's %s!", name(ad, user), name(ad, effBattler), item_name(tItem))) return true diff --git a/src/core/game3/battle/effects/special.lua b/src/core/game3/battle/effects/special.lua index 95928872..aac43ece 100644 --- a/src/core/game3/battle/effects/special.lua +++ b/src/core/game3/battle/effects/special.lua @@ -309,7 +309,11 @@ function Special.trick(ctx) if (target.substituteHP or 0) > 0 then return H.sayFail(ctx) end if not H.accuracy(ctx, "normal") then return end if user.side ~= "player" then return H.sayFail(ctx) end - if user.expKnockedOff or target.expKnockedOff then return H.sayFail(ctx) end + local St = state() + if user.expKnockedOff or target.expKnockedOff + or (St and (St.isKnockedOff(ad._st, user) or St.isKnockedOff(ad._st, target))) then + return H.sayFail(ctx) + end local ui, ti = tonumber(user.item) or 0, tonumber(target.item) or 0 if (ui == 0 and ti == 0) or ui == 175 or ti == 175 or Secondary.isMail(ui) or Secondary.isMail(ti) then return H.sayFail(ctx) @@ -319,7 +323,9 @@ function Special.trick(ctx) end user.item, target.item = ti, ui Secondary.persistItem(user, ti) - if target.side == "player" then Secondary.persistItem(target, ui) end + -- Both sides: the target's party mon must take the item its battler now + -- holds, or it keeps the old one and duplicates it on switch-out. + Secondary.persistItem(target, ui) H.attackAnim(ctx) ad:say(Strings("%s switched\nitems with its opponent!", name(ctx, user))) if ui ~= 0 and ti ~= 0 then diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 37d73767..e0350c0f 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -2699,7 +2699,8 @@ function Battle.update(dt, game) local Naming = package.loaded["src.ui.game3.naming"] if Naming and Naming.isOpen and Naming.isOpen() then - if input then Naming.update(input, dt or (1 / 60)) end + if input then Naming.handleInput(input) end + Naming.update(dt or (1 / 60)) return end diff --git a/src/core/game3/battle/state.lua b/src/core/game3/battle/state.lua index 985764fc..b15e3b40 100644 --- a/src/core/game3/battle/state.lua +++ b/src/core/game3/battle/state.lua @@ -2,6 +2,7 @@ local Damage = require("src.core.game3.battle.damage") local Pokemon = require("src.core.game3.pokemon") +local bit = require("bit") local State = {} @@ -414,6 +415,34 @@ function State.partyMon(battler) return battler._partyMon or battler.mon end +-- pret pokefirered/src/battle_main.c gWishFutureKnock.knockedOffMons: one bit +-- per party index per side. A mon whose item was knocked off stays marked for +-- the rest of the battle even after it switches out and back in -- the +-- per-battler `expKnockedOff` volatile dies with the battler, so Thief and +-- Trick would otherwise be allowed against it again. +local function knocked_off_key(b) + local side = b and b.side + if side ~= "player" and side ~= "enemy" then return nil end + local idx = tonumber(b and b.partyIndex) or 1 + if idx < 1 or idx > 6 then return nil end + return side, bit.lshift(1, idx - 1) +end + +function State.markKnockedOff(st, b) + if not st then return end + local side, flag = knocked_off_key(b) + if not side then return end + st.knockedOff = st.knockedOff or { player = 0, enemy = 0 } + st.knockedOff[side] = bit.bor(st.knockedOff[side] or 0, flag) +end + +function State.isKnockedOff(st, b) + if not st then return false end + local side, flag = knocked_off_key(b) + if not side then return false end + return bit.band((st.knockedOff and st.knockedOff[side]) or 0, flag) ~= 0 +end + function State.wipeVolatilesAndStages(battler, opts) opts = opts or {} if not battler then return end diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index c934a21b..bf040aff 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -188,7 +188,12 @@ function Dataset.buildMaps(warps) -- Fallback inference if header.json was not loaded if regionMapSectionId == nil then local secInfo = MapSectionsExtract.getInfo(nil, mapId, floorNum or 0) - regionMapSectionId = secInfo and secInfo.secId + -- getInfo echoes secId 88 (a real section: Pallet Town) with + -- resolved=false for a map it cannot identify. Taking that id would + -- advertise an unknown map as Pallet Town, so only trust a resolved one. + if secInfo and secInfo.resolved then + regionMapSectionId = secInfo.secId + end end if showMapName == nil then showMapName = 0 @@ -205,7 +210,7 @@ function Dataset.buildMaps(warps) tileset = tileset, warps = warps[mapId] or {}, connections = connections[mapId] or {}, - regionMapSectionId = regionMapSectionId or 88, + regionMapSectionId = regionMapSectionId, showMapName = (showMapName == 1 or showMapName == true) and 1 or 0, floorNum = tonumber(floorNum) or 0, weather = weather or 0, diff --git a/src/core/game3/map.lua b/src/core/game3/map.lua index d1d406cf..5bdff89d 100644 --- a/src/core/game3/map.lua +++ b/src/core/game3/map.lua @@ -377,7 +377,14 @@ function Map.load(mod, game, mapId, opts) -- pokefirered/src/fieldmap.c:93 require("src.core.game3.field").clearMetatiles(def and def.midLayout) local Collision = require("src.core.game3.collision") - if def then Collision.bindMap(game, mapId, def) end + if def then + Collision.bindMap(game, mapId, def) + else + -- No def for this id. Keeping the previous map's grid bound would validate + -- movement against the map we just left; unbind so canEnter falls back to + -- the host map (collision.lua: "Prefer owned grid; fall back to host map"). + Collision.clear() + end -- pret GroundEffect_SpawnOnTallGrass when warping onto grass. if not opts.seamless then diff --git a/src/core/game3/ow_sprites.lua b/src/core/game3/ow_sprites.lua index 4f054444..bd184273 100644 --- a/src/core/game3/ow_sprites.lua +++ b/src/core/game3/ow_sprites.lua @@ -57,6 +57,15 @@ local function load_one(gid) local meta = OwExtract.decodeMeta(metaBlob) if not meta then return nil end local w, h, n = meta.width, meta.height, meta.frameCount + -- decodeMeta reads these as u16 without validating, so a corrupt .meta can + -- carry 65535 for any of them and `aw * ah` then reaches ~4.3e9 pixels on a + -- cache file in the user-writable save directory. Bound them before sizing. + local MAX_FRAME_DIM, MAX_FRAMES = 256, 512 + if type(w) ~= "number" or type(h) ~= "number" or type(n) ~= "number" + or w < 1 or h < 1 or n < 1 + or w > MAX_FRAME_DIM or h > MAX_FRAME_DIM or n > MAX_FRAMES then + return nil + end local aw, ah = w, h * n if #rgba ~= aw * ah * 4 then if #rgba < aw * h * 4 then return nil end diff --git a/src/core/game3/player.lua b/src/core/game3/player.lua index e4227e19..be524951 100644 --- a/src/core/game3/player.lua +++ b/src/core/game3/player.lua @@ -111,6 +111,11 @@ local function dirs_from_input(input) end function Player.reset(x, y, facing) + -- Callers pass the destination facing (Map.load, warp, fly, syncFromSession). + -- An absent or invalid direction keeps the current facing. + if facing ~= nil and DELTA[facing] then + Player.facing = facing + end Player.cellX = tonumber(x) or 0 Player.cellY = tonumber(y) or 0 Player.px = Player.cellX * CELL @@ -136,6 +141,12 @@ function Player.reset(x, y, facing) Player.spriteXOffset = 0 Player.spriteYOffset = 0 Player.biking = false + -- Transient surf state: a reset lands the avatar on its feet, so a warp or + -- whiteout out of the water must not leave surfing set -- Collision.canEnter + -- reads Player.surfing and would treat water as walkable on land. + Player.surfing = false + Player.surfHopping = false + Player.dismounting = false Player.prevCellX = Player.cellX Player.prevCellY = Player.cellY Player.animDisabled = false diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index 2f365e4b..48a57a1e 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -276,7 +276,8 @@ function Runtime.update(dt) local okN, Naming = pcall(require, "src.ui.game3.naming") if okN and Naming.isOpen and Naming.isOpen() then - Naming.update(game and game.input, dt) + Naming.handleInput(game and game.input) + Naming.update(dt) end if not inMenu then diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 891ed5b8..4e8e7192 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -223,6 +223,22 @@ function Schema.toSaveTable(session) secretId = session.secretId, rng = session.rng, vsSeeker = session.vsSeeker, + -- GAME_STAT_* counters: slot-machine jackpots, hatched eggs, link W/L/D, + -- link trades, and the sticker-man brags that read them. + gameStats = session.gameStats or {}, + -- Link battle records (Record Corner / fan club) and the trainer card's + -- link win/loss counters -- both read back by link and UI modules. + linkBattleRecords = type(session.linkBattleRecords) == "table" and session.linkBattleRecords or {}, + trainerCard = type(session.trainerCard) == "table" and session.trainerCard or {}, + -- Hall of Fame induction (pret hall_of_fame.c): written by + -- commit_clear_and_save, read by the trainer card and HOF viewers. + game_cleared = session.game_cleared == true, + hasHallOfFameRecords = session.hasHallOfFameRecords == true, + hofDebutHours = tonumber(session.hofDebutHours), + hofDebutMinutes = tonumber(session.hofDebutMinutes), + hofDebutSeconds = tonumber(session.hofDebutSeconds), + hofDebutTime = session.hofDebutTime, + hallOfFameTeams = type(session.hallOfFameTeams) == "table" and session.hallOfFameTeams or {}, mail = mail_export(session), questLog = require("src.core.game3.quest_log").export(session), modData = session.modData, @@ -275,6 +291,19 @@ function Schema.fromSaveTable(save) secretId = save.secretId, rng = save.rng, vsSeeker = type(save.vsSeeker) == "table" and save.vsSeeker or { steps = 0, charging = 0, rematches = {} }, + -- Additive: a save written before this key exists loads as an empty table. + gameStats = type(save.gameStats) == "table" and save.gameStats or {}, + -- Additive: older saves load these as empty tables. + linkBattleRecords = type(save.linkBattleRecords) == "table" and save.linkBattleRecords or {}, + trainerCard = type(save.trainerCard) == "table" and save.trainerCard or {}, + -- Additive: older saves load these as defaults. + game_cleared = save.game_cleared == true, + hasHallOfFameRecords = save.hasHallOfFameRecords == true, + hofDebutHours = tonumber(save.hofDebutHours), + hofDebutMinutes = tonumber(save.hofDebutMinutes), + hofDebutSeconds = tonumber(save.hofDebutSeconds), + hofDebutTime = save.hofDebutTime, + hallOfFameTeams = type(save.hallOfFameTeams) == "table" and save.hallOfFameTeams or {}, mail = mail_restore(save), questLog = require("src.core.game3.quest_log").restore(save.questLog), modData = type(save.modData) == "table" and save.modData or {}, diff --git a/src/core/game3/scripting/opcodes.lua b/src/core/game3/scripting/opcodes.lua index 78d22b98..51842414 100644 --- a/src/core/game3/scripting/opcodes.lua +++ b/src/core/game3/scripting/opcodes.lua @@ -27,9 +27,12 @@ Opcodes.TABLE = { [0x0e] = op("setmysteryeventstatus", 2, { B }), [0x0f] = op("loadword", 6, { B, W }), [0x10] = op("loadbyte", 3, { B, B }), - [0x11] = op("setptr", 5, { W }), - [0x12] = op("loadbytefromptr", 2, { B }), - [0x13] = op("setptrbyte", 2, { B }), + -- pret asm/macros/event.inc: setptr — .byte value / .4byte ptr + [0x11] = op("setptr", 6, { B, W }), + -- pret asm/macros/event.inc: loadbytefromptr — .byte destIndex / .4byte source + [0x12] = op("loadbytefromptr", 6, { B, W }), + -- pret asm/macros/event.inc: setptrbyte — .byte srcIndex / .4byte destination + [0x13] = op("setptrbyte", 6, { B, W }), [0x14] = op("copylocal", 3, { B, B }), [0x15] = op("copybyte", 9, { W, W }), [0x16] = op("setvar", 5, { H, H }), @@ -131,7 +134,9 @@ Opcodes.TABLE = { [0x76] = op("hidemonpic", 1), [0x77] = op("showcontestpainting", 2, { B }), [0x78] = op("braillemessage", 5, { W }), - [0x79] = op("givemon", 9, { H, B, H, H, H }), + -- pret asm/macros/event.inc: .byte 0x79, .2byte species, .byte level, + -- .2byte item, .4byte unk1, .4byte unk2, .byte unkParam3 (14 operand bytes). + [0x79] = op("givemon", 15, { H, B, H, W, W, B }), [0x7a] = op("giveegg", 3, { H }), [0x7b] = op("setmonmove", 5, { B, B, H }), [0x7c] = op("checkpartymove", 3, { H }), @@ -214,7 +219,8 @@ Opcodes.TABLE = { [0xc9] = op("unloadhelp", 1), [0xca] = op("signmsg", 1), [0xcb] = op("normalmsg", 1), - [0xcc] = op("comparestat", 4, { B, H }), + -- pret asm/macros/event.inc: comparestat — .byte statId / .4byte value + [0xcc] = op("comparestat", 7, { B, W }), [0xcd] = op("setmonmodernfatefulencounter", 3, { H }), [0xce] = op("checkmonmodernfatefulencounter", 3, { H }), [0xcf] = op("trywondercardscript", 1), diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index b4dda68a..95df1e97 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -53,6 +53,38 @@ local function var_get(store, ctx, id) return id end +-- Script local scratch space: pret's ScriptContext.data[4] (include/script.h:21). +local function local_get(ctx, i) + ctx.locals = ctx.locals or {} + return tonumber(ctx.locals[(tonumber(i) or 0) + 1]) or 0 +end + +local function local_set(ctx, i, v) + ctx.locals = ctx.locals or {} + ctx.locals[(tonumber(i) or 0) + 1] = tonumber(v) or 0 +end + +-- The port has no flat address space, so the *ptr family shares a synthetic +-- byte store keyed by the pointer value. Pointers a script writes then reads +-- round-trip; pointers into engine structures read as 0 (they did before too). +local function mem_get(ctx, ptr) + ctx.scriptMem = ctx.scriptMem or {} + return tonumber(ctx.scriptMem[tonumber(ptr) or 0]) or 0 +end + +local function mem_set(ctx, ptr, v) + ctx.scriptMem = ctx.scriptMem or {} + ctx.scriptMem[tonumber(ptr) or 0] = tonumber(v) or 0 +end + +-- pret src/scrcmd.c:358 Compare() +local function cmp(a, b) + a, b = tonumber(a) or 0, tonumber(b) or 0 + if a < b then return 0 end + if a == b then return 1 end + return 2 +end + local function coins_api() local Runtime = package.loaded["src.core.game3.runtime"] local session = Runtime and Runtime.getSession and Runtime.getSession() @@ -238,6 +270,27 @@ local function warp_hole_dest(group, num) or (Versions.seviiMapFor and Versions.seviiMapFor(group, num)) end +-- pret's *at script commands carry an explicit (mapGroup, mapNum) so a script +-- can address another map's objects (asm/macros/event.inc:597-652). The host +-- object/movement seams only address the current map, so resolve the target +-- map first and skip (with a note) when it is somewhere else. +local function objectat_same_map(store, ctx, row, groupIdx) + local group = var_get(store, ctx, row[groupIdx]) + local num = tonumber(var_get(store, ctx, row[groupIdx + 1])) + local Map = package.loaded["src.core.game3.map"] + local current = Map and Map.current + if group == nil or num == nil or not current then return true end + local okC, MapCatalog = pcall(require, "src.import.gba.map_catalog") + local dest = okC and MapCatalog and MapCatalog.mapIdFor(group, num) or nil + if type(dest) ~= "string" then + local okV, Versions = pcall(require, "src.import.gba.versions") + dest = okV and Versions and Versions.mapIdFor + and Versions.mapIdFor(group, num) or nil + end + if type(dest) ~= "string" then return true end + return dest == current +end + local function dispatch(vm, row) local op = row.op local ctx = vm.ctx @@ -315,6 +368,25 @@ local function dispatch(vm, row) end vm:setPc(key, 1) return false + elseif op == "callstd_if" or op == "gotostd_if" then + -- pret asm/macros/event.inc: .byte op / .byte condition / .byte std. + -- callstd_if returns to the caller; gotostd_if does not. + if not cond_ok(ctx, row.cond or row[1]) then return false end + local key = "std:" .. tostring(row.std or row[2]) + if not vm.scripts[key] then + a.log("[game3] missing " .. key .. " — skipping") + return false + end + if op == "callstd_if" then + if #ctx.stack >= 20 then return false end + local cur = ctx.pc + ctx.stack[#ctx.stack + 1] = { + listKey = cur.listKey, + index = cur.index, + } + end + vm:setPc(key, 1) + return false elseif op == "loadword" then local dest = row.dest or row[1] or 0 local value = row.value or row[2] @@ -717,6 +789,194 @@ local function dispatch(vm, row) local lid = var_get(store, ctx, row.localId or row[1]) if a.removeObject then a.removeObject(lid) end return false + elseif op == "comparestat" then + -- pret ScrCmd_comparestat: .byte statIdx / .4byte value; sets + -- ctx.comparisonResult to 0 (lt) / 1 (eq) / 2 (gt) from the game stat. + local statIdx = tonumber(row[1]) or 0 + local value = tonumber(row[2]) or 0 + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + local stats = session and session.gameStats or {} + local statValue = tonumber(stats[statIdx]) or 0 + ctx.comparisonResult = statValue < value and 0 + or (statValue == value and 1 or 2) + return false + elseif op == "bufferitemnameplural" then + -- pret ScrCmd_bufferitemnameplural: the item's name pluralised the way the + -- ROM does -- "S" for a Poké Ball stack, "IES" replacing the final letter + -- for berries, and the plain name otherwise. + local dest = (row.dest or row[1] or 0) + 1 + local item = var_get(store, ctx, row[2]) + local qty = tonumber(var_get(store, ctx, row[3])) or 1 + local ItemsData = require("src.core.game3.items_data") + local name = (ItemsData.displayName and ItemsData.displayName(item)) + or tostring(item) + if qty >= 2 then + if tonumber(item) == 4 then -- ITEM_POKE_BALL (include/constants/items.h:8) + name = name .. "S" + elseif ItemsData.isBerry and ItemsData.isBerry(item) then + name = name:sub(1, -2) .. "IES" + end + end + ctx.stringVars[dest] = name + return false + elseif op == "setmonmove" or op == "setmonmetlocation" + or op == "setmonmodernfatefulencounter" + or op == "checkmonmodernfatefulencounter" then + -- pret ScrCmd_* (src/scrcmd.c:1767, :2239, :2248, :2256). Party indices, + -- move slots and map-section ids are 0-based in the ROM. + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + local idx = (tonumber(var_get(store, ctx, row[1])) or 0) + 1 + local mon = session and session.party and session.party[idx] + if op == "setmonmove" then + local slot = (tonumber(var_get(store, ctx, row[2])) or 0) + 1 + local move = tonumber(var_get(store, ctx, row[3])) or 0 + local Pokemon = require("src.core.game3.pokemon") + if mon and Pokemon.replaceMove then Pokemon.replaceMove(mon, slot, move) end + elseif op == "setmonmetlocation" then + if mon then mon.metLocation = tonumber(row[2]) or 0 end + elseif op == "setmonmodernfatefulencounter" then + if mon then mon.modernFatefulEncounter = true end + else + Flags.setVar(store, ctx, Ctx.VAR_RESULT, + (mon and mon.modernFatefulEncounter) and 1 or 0) + end + return false + elseif op == "copylocal" then + -- pret ScrCmd_copylocal (src/scrcmd.c:321) + local_set(ctx, row[1], local_get(ctx, row[2])) + return false + elseif op == "setptr" then + -- pret ScrCmd_setptr (src/scrcmd.c:300): value byte, then pointer word. + mem_set(ctx, row[2], row[1]) + return false + elseif op == "loadbytefromptr" then + -- pret ScrCmd_loadbytefromptr (src/scrcmd.c:293) + local_set(ctx, row[1], mem_get(ctx, row[2])) + return false + elseif op == "setptrbyte" then + -- pret ScrCmd_setptrbyte (src/scrcmd.c:314) + mem_set(ctx, row[2], local_get(ctx, row[1])) + return false + elseif op == "copybyte" then + -- pret ScrCmd_copybyte (src/scrcmd.c:329) + mem_set(ctx, row[1], mem_get(ctx, row[2])) + return false + elseif op == "compare_local_to_local" then + -- pret ScrCmd_compare_local_to_local (src/scrcmd.c:368) + ctx.comparisonResult = cmp(local_get(ctx, row[1]), local_get(ctx, row[2])) + return false + elseif op == "compare_local_to_value" then + ctx.comparisonResult = cmp(local_get(ctx, row[1]), row[2]) + return false + elseif op == "compare_local_to_ptr" then + ctx.comparisonResult = cmp(local_get(ctx, row[1]), mem_get(ctx, row[2])) + return false + elseif op == "compare_ptr_to_local" then + ctx.comparisonResult = cmp(mem_get(ctx, row[1]), local_get(ctx, row[2])) + return false + elseif op == "compare_ptr_to_value" then + ctx.comparisonResult = cmp(mem_get(ctx, row[1]), row[2]) + return false + elseif op == "compare_ptr_to_ptr" then + ctx.comparisonResult = cmp(mem_get(ctx, row[1]), mem_get(ctx, row[2])) + return false + elseif op == "vgoto" or op == "vcall" or op == "vgoto_if" or op == "vcall_if" then + -- pret ScrCmd_vgoto/vcall/vgoto_if/vcall_if (src/scrcmd.c:180-209). The + -- ROM's sAddressOffset relocation is unnecessary here: script pointers are + -- engine keys, which Opcodes.key()/jump() already resolve. + local cond = true + local dest = row.target or row[1] + if op == "vgoto_if" or op == "vcall_if" then + cond = cond_ok(ctx, row.cond or row[1]) + dest = row.target or row[2] + end + if cond then + if op == "vcall" or op == "vcall_if" then + if #ctx.stack >= 20 then + a.log("[game3] vcall stack overflow") + return false + end + local cur = ctx.pc + ctx.stack[#ctx.stack + 1] = { listKey = cur.listKey, index = cur.index } + end + jump(vm, dest) + end + return false + elseif op == "setvaddress" then + -- pret ScrCmd_setvaddress (src/scrcmd.c:171) records a ROM-address + -- relocation for the v* family; the port resolves pointers by key, so + -- there is nothing to relocate. Kept for bookkeeping only. + ctx.vaddress = row[1] + return false + elseif op == "vmessage" then + -- pret ScrCmd_vmessage (src/scrcmd.c:1580) shows a field message. + return show_message(vm, row[1], false) + elseif op == "vbuffermessage" then + -- pret ScrCmd_vbuffermessage (src/scrcmd.c:1706) expands placeholders into + -- the field message buffer (gStringVar4 → ctx.stringVars[4]). + local ir = resolve_text(vm, row[1]) + ctx.stringVars[4] = ir and TextIR.toPlain(ir, { + stringVars = ctx.stringVars, + playerName = a.playerName, + rivalName = a.rivalName, + }) or "" + return false + elseif op == "vbufferstring" then + -- pret ScrCmd_vbufferstring (src/scrcmd.c:1714) + local dest = (row.dest or row[1] or 0) + 1 + local ir = resolve_text(vm, row.ptr or row[2]) + ctx.stringVars[dest] = ir and TextIR.toPlain(ir, { stringVars = ctx.stringVars }) or "" + return false + elseif op == "endram" then + -- pret ScrCmd_endram (src/scrcmd.c:262) clears the RAM script and stops. + vm:halt() + return true + elseif op == "returnram" then + -- pret ScrCmd_returnram (src/scrcmd.c:256) resumes the RAM script's caller. + local frame = table.remove(ctx.stack) + if not frame then + vm:halt() + return true + end + vm:setPc(frame.listKey, frame.index) + return false + elseif op == "checkpcitem" or op == "addpcitem" then + -- pret ScrCmd_checkpcitem / ScrCmd_addpcitem: the Player PC item bag. + -- VAR_RESULT is 1 for success (check: enough stored; add: stored), else 0. + local item = tostring(var_get(store, ctx, row[1])) + local qty = math.max(1, tonumber(var_get(store, ctx, row[2])) or 1) + local Runtime = package.loaded["src.core.game3.runtime"] + local session = Runtime and Runtime.getSession and Runtime.getSession() + local Storage = require("src.core.game3.storage") + local storage = session and Storage.ensure(session) or nil + local ok = false + if storage then + storage.items = storage.items or {} + local slot + for _, entry in ipairs(storage.items) do + if tostring(entry.id) == item then slot = entry break end + end + if op == "checkpcitem" then + local have = slot and (tonumber(slot.qty) or 0) or 0 + ok = have >= qty + elseif slot then + local room = Storage.MAX_ITEM_QTY - (tonumber(slot.qty) or 0) + if room >= qty then + slot.qty = (tonumber(slot.qty) or 0) + qty + ok = true + end + elseif #storage.items < Storage.PC_ITEMS_COUNT then + storage.items[#storage.items + 1] = { id = item, qty = math.min(qty, Storage.MAX_ITEM_QTY) } + ok = true + end + end + if op == "addpcitem" and not ok then + a.log("[game3] addpcitem had no PC room for " .. item) + end + Flags.setVar(store, ctx, Ctx.VAR_RESULT, ok and 1 or 0) + return false elseif op == "bufferspeciesname" or op == "bufferitemname" or op == "buffermovename" or op == "bufferdecorationname" or op == "bufferstdstring" or op == "bufferpartymonnick" then @@ -783,7 +1043,11 @@ local function dispatch(vm, row) return false elseif op == "hideobjectat" or op == "showobjectat" then local lid = var_get(store, ctx, row.localId or row[1]) - if op == "hideobjectat" and a.hideObject then + if not objectat_same_map(store, ctx, row, 2) then + if a.log then + a.log("[game3] " .. op .. " targets another map — skipped") + end + elseif op == "hideobjectat" and a.hideObject then a.hideObject(lid) elseif op == "showobjectat" and a.showObject then a.showObject(lid) @@ -791,6 +1055,25 @@ local function dispatch(vm, row) a.removeObject(lid) end return false + elseif op == "applymovementat" or op == "waitmovementat" + or op == "removeobjectat" or op == "addobjectat" then + -- pret ScrCmd_applymovementat / waitmovementat / removeobjectat / + -- addobjectat (src/scrcmd.c:993, :1022, :1046, :1064). On the current map + -- they behave exactly like the plain command, so re-dispatch to it. + local groupIdx = (op == "applymovementat") and 3 or 2 + if not objectat_same_map(store, ctx, row, groupIdx) then + if a.log then + a.log("[game3] " .. op .. " targets another map — skipped") + end + return false + end + local plain = ({ + applymovementat = "applymovement", + waitmovementat = "waitmovement", + removeobjectat = "removeobject", + addobjectat = "addobject", + })[op] + return dispatch(vm, { op = plain, row[1], row[2] }) elseif op == "addobject" then local lid = var_get(store, ctx, row.localId or row[1]) if a.addObject then a.addObject(lid) end @@ -799,6 +1082,17 @@ local function dispatch(vm, row) -- Cosmetic on host; waitdooranim yields briefly. if a.doorAnim then a.doorAnim(op, row[1], row[2]) end return false + elseif op == "setdooropen" or op == "setdoorclosed" then + -- pret ScrCmd_setdooropen/setdoorclosed record a door's state (used to + -- restore doors on re-entry). The host exposes only the door animation + -- seam, so map the stored state onto the matching action. + if a.doorAnim then + -- pret ScrCmd_setdooropen/setdoorclosed read their x/y through VarGet + -- (src/scrcmd.c:2156). + a.doorAnim(op == "setdooropen" and "opendoor" or "closedoor", + var_get(store, ctx, row[1]), var_get(store, ctx, row[2])) + end + return false elseif op == "waitdooranim" then -- Short soft wait (no re-entrant tick_vm). Instant adapter done() was -- skipping applymovement that follows (lab door enter). diff --git a/src/core/game3/storage.lua b/src/core/game3/storage.lua index 32bf4a88..28884b24 100644 --- a/src/core/game3/storage.lua +++ b/src/core/game3/storage.lua @@ -437,7 +437,13 @@ function Storage.depositItem(session, bagPocket, bagIdx, qty) if foundIdx then local curQty = storage.items[foundIdx].qty or 0 - storage.items[foundIdx].qty = math.min(Storage.MAX_ITEM_QTY, curQty + qty) + -- Refuse when the stack cannot take the whole deposit. Capping with + -- math.min while the bag below is debited the full qty destroyed the + -- overflow: a stack already at MAX_ITEM_QTY lost every deposited item. + if curQty + qty > Storage.MAX_ITEM_QTY then + return false, "pc_item_stack_full" + end + storage.items[foundIdx].qty = curQty + qty else if #storage.items >= Storage.PC_ITEMS_COUNT then return false, "pc_items_full" diff --git a/src/import/gba/native_pack.lua b/src/import/gba/native_pack.lua index c1ef60e7..e56ad76e 100644 --- a/src/import/gba/native_pack.lua +++ b/src/import/gba/native_pack.lua @@ -111,6 +111,21 @@ function NativePack.decodeIdx(blob) local midCount = read_u16(blob, 7) local atlasCols = read_u16(blob, 9) local atlasRows = read_u16(blob, 11) + -- The header comes from a file in the user-writable cache and was trusted: + -- an absurd midCount walks the pixel loop past the blob (read_u16 does not + -- bounds-check), and an absurd atlas sizes a ~4 TB buffer downstream in + -- bake_or_load. Require the declared tables to fit the blob, and the + -- dimensions to be sane, before reading anything. + local MAX_MIDS, MAX_ATLAS_TILES = 4096, 16384 + if midCount < 1 or atlasCols < 1 or atlasRows < 1 then + return nil, "bad mids.idx dimensions" + end + if midCount > MAX_MIDS or atlasCols * atlasRows > MAX_ATLAS_TILES then + return nil, "mids.idx dimensions out of range" + end + if #blob < 12 + midCount * 2 + midCount * 256 then + return nil, "mids.idx truncated" + end local midIds = {} local off = 13 for i = 1, midCount do diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 5d89758b..0294e685 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -32,7 +32,12 @@ Versions.ROM_SIZE = 16777216 -- had no artwork, so the rock simply vanished instead of breaking apart. -- Both branches had taken 111 for unrelated cache layouts, so this merge -- moves the Deoxys artwork onto its own number instead of sharing one. -Versions.CACHE_VERSION = 112 +-- v113: script opcode layouts corrected against pret asm/macros/event.inc — +-- comparestat is {byte,word} (was {byte,half}), setptr / loadbytefromptr / +-- setptrbyte each carry a leading byte plus a word (were shorter). The +-- old sizes mis-decoded every instruction after one, so every cached +-- script is stale. +Versions.CACHE_VERSION = 113 Versions.NATIVE_VERSION = 6 Versions.OW_VERSION = 1 Versions.ANIM_VERSION = 1 diff --git a/src/ui/game3/hall_of_fame.lua b/src/ui/game3/hall_of_fame.lua index 9a8f107a..9275393a 100644 --- a/src/ui/game3/hall_of_fame.lua +++ b/src/ui/game3/hall_of_fame.lua @@ -66,13 +66,25 @@ local function commit_clear_and_save(session, eligibleMons) end table.insert(session.hallOfFameTeams, teamRecord) - -- 4. Commit atomic save to disk - local okSave, SaveData = pcall(require, "src.core.game3.save") - if okSave and SaveData and SaveData.save then - pcall(SaveData.save, session) + -- 4. Commit to disk through the engine's save path. This used to pcall + -- "src.core.game3.save", which does not exist -- so the clear flag, the debut + -- timestamp and the team above were set in memory and never written. + local Runtime = package.loaded["src.core.game3.runtime"] + local game = Runtime and Runtime._game + if game and type(game.saveGame) == "function" then + local ok, err = pcall(game.saveGame, game) + if not ok then + pcall(function() + require("src.core.Logger").warn("[hall_of_fame] save failed: %s", tostring(err)) + end) + end end end +-- Test seam: the induction commit (pret hall_of_fame.c) sets the clear flag, the +-- debut timestamp and the HOF team, then commits the save. +HallOfFame._commitClearAndSave = commit_clear_and_save + function HallOfFame.start(opts) opts = opts or {} local session = opts.session or {} diff --git a/src/ui/game3/map_name_popup.lua b/src/ui/game3/map_name_popup.lua index 77aa147e..20125d40 100644 --- a/src/ui/game3/map_name_popup.lua +++ b/src/ui/game3/map_name_popup.lua @@ -97,7 +97,10 @@ function MapNamePopup.show(mapDef, opts) local info = MapSectionsExtract.getInfo(secId, mapId, floorNum) local name = info and info.name - if not name or name == "???" or name == "" then + -- getInfo answers with the Pallet Town placeholder for a map it could not + -- identify (resolved == false). Treat that as "no name" so the popup shows + -- the cleaned map id instead of a place the map is not. + if not name or name == "???" or name == "" or (info and info.resolved == false) then name = cleanMapName(mapId) else name = translated_name(info) diff --git a/src/ui/game3/naming.lua b/src/ui/game3/naming.lua index 46489aef..4e03b001 100644 --- a/src/ui/game3/naming.lua +++ b/src/ui/game3/naming.lua @@ -486,10 +486,35 @@ function Naming.dismiss() Stack.pop("naming") end -function Naming.update(input, dt) +-- Timer half of the naming tick. Input arrives through handleInput -- the stack +-- convention Hud.update_top_menu uses. Passing the delta here was the bug: +-- Naming.update(input, dt) was being called as update(dt), so the delta arrived +-- as `input` and indexing it raised every frame the screen was on top. +function Naming.update(dt) if not Naming.openFlag or not Naming._state then return end local st = Naming._state if st.finished then return end + -- The pcPages result screen owns this state: the original returned before + -- touching the blink timer, so keep that here. + if st.pcPages then return end + st.blink = (st.blink or 0) + (dt or 1 / 60) + if st.swapT ~= nil then + st.swapT = st.swapT + 4 + if st.swapT >= 128 then + commitPage(st) + st.swapT = nil + end + end +end + +-- Input half. Call it before update() so the ordering matches the original +-- single function (pcPages and the swap guard are consumed before the timers). +function Naming.handleInput(input) + if not Naming.openFlag or not Naming._state then return end + local st = Naming._state + if st.finished then return end + -- Input is ignored while the page swap runs (the original returned here). + if st.swapT ~= nil then return end -- pokefirered/src/naming_screen.c:759 if st.pcPages then if input and input.wasPressed and input:wasPressed("a") then @@ -501,15 +526,6 @@ function Naming.update(input, dt) end return end - st.blink = (st.blink or 0) + (dt or 1 / 60) - if st.swapT ~= nil then - st.swapT = st.swapT + 4 - if st.swapT >= 128 then - commitPage(st) - st.swapT = nil - end - return - end local function pressed(k) return input and input.wasPressed and input:wasPressed(k) diff --git a/src/ui/game3/new_game_scene.lua b/src/ui/game3/new_game_scene.lua index cdf6b899..2626c147 100644 --- a/src/ui/game3/new_game_scene.lua +++ b/src/ui/game3/new_game_scene.lua @@ -1253,7 +1253,8 @@ function Scene:namingFrame() n.pal:updateFade() if not n.pal:fadeActive() then n.stage = "input" end elseif n.stage == "input" then - Naming.update(self.inputProxy, 1 / Scene.GBA_HZ) + Naming.handleInput(self.inputProxy) + Naming.update(1 / Scene.GBA_HZ) elseif n.stage == "fade_out" then n.pal:updateFade() if not n.pal:fadeActive() then diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua index 11c4f85d..f401ac50 100755 --- a/src/ui/kit/FileBrowser.lua +++ b/src/ui/kit/FileBrowser.lua @@ -3,6 +3,17 @@ -- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog). local Theme = require("src.ui.kit.Theme") +local HostShell = require("src.core.HostShell") + +-- POSIX shell quoting for the ls/test commands below. HostShell.quote is the +-- shared helper, but the launcher may run where HostShell is a minimal shim +-- (the NX gate), so fall back to the same escaping locally. +local function shQuote(path) + if HostShell and type(HostShell.quote) == "function" then + return HostShell.quote(path) + end + return "'" .. tostring(path):gsub("'", "'\\''") .. "'" +end local PAL = Theme.PAL local Kit = nil local function getKit() @@ -48,7 +59,7 @@ local function findSdCardRoot() "/userdata", } for _, path in ipairs(candidates) do - local ok, h = pcall(io.popen, string.format('test -d "%s" && echo "yes"', path)) + local ok, h = pcall(io.popen, string.format("test -d %s && echo \"yes\"", shQuote(path))) if ok and h then local res = h:read("*a") h:close() @@ -97,7 +108,7 @@ local function scanDirectory(dir, mode) dir = normalizePath(dir) local entries = {} - local ok, handle = pcall(io.popen, string.format('ls -1ap "%s" 2>/dev/null', dir:gsub('"', '\\"'))) + local ok, handle = pcall(io.popen, string.format("ls -1ap %s 2>/dev/null", shQuote(dir))) if ok and handle then local output = handle:read("*a") handle:close() diff --git a/tests/engine/game3_cache_module_sandbox_test.lua b/tests/engine/game3_cache_module_sandbox_test.lua new file mode 100644 index 00000000..25914215 --- /dev/null +++ b/tests/engine/game3_cache_module_sandbox_test.lua @@ -0,0 +1,61 @@ +-- Generated cache modules must load without access to the process API. +-- +-- L1 regression: Data's cache loader used +-- loadstring(bytes, "@" .. prefix .. path) +-- with no environment, so any file at /data/generated/.lua ran +-- at boot with the real `os`, `io` and `loadfile` in scope -- local code +-- execution from a directory the game treats as data. Every other generated +-- loader in the engine sandboxes (dataset.lua, doors.lua, field.lua, ... all use +-- load(src, name, "t", {})), and LuaWriter only ever emits literals, so the +-- module format has no need for globals. +-- luajit tests/engine/game3_cache_module_sandbox_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- A generated module that reports whether the process API is reachable. +local HOSTILE = [[ +if os ~= nil or io ~= nil or require ~= nil or loadfile ~= nil or loadstring ~= nil then + return "ESCAPED" +end +return "sandboxed" +]] + +package.preload["src.import.CacheFs"] = function() + return { readActive = function() return HOSTILE end } +end +package.preload["src.core.GameVersion"] = function() + return { cachePrefix = function() return "red/" end } +end + +local Data = require("src.core.Data") + +check(type(Data._loadModule) == "function", "Data exposes its cache loader as a seam") +if type(Data._loadModule) == "function" then + local called, ok, res = pcall(Data._loadModule, nil, "pokemon") + check(called and ok, "the cache module loads through the sandbox") + eq(res, "sandboxed", "a generated module cannot reach os/io/require/loadfile") +end + +-- Real generated data must survive the sandbox: fixture modules are written in +-- the same literal-only format LuaWriter emits, so one must compile and evaluate +-- to its table under the sandbox expression the loader now uses. +do + local f = io.open("tests/fixture_data/pokemon.lua", "r") + check(f ~= nil, "a fixture generated module is readable") + if f then + local src = f:read("*a") + f:close() + local chunk = load(src, "@tests/fixture_data/pokemon.lua", "t", {}) + check(chunk ~= nil, "a real generated module compiles under the sandbox") + if chunk then + local okEval, mod = pcall(chunk) + check(okEval and type(mod) == "table", "...and evaluates to its table") + end + end +end + +T.finish("game3_cache_module_sandbox_test") \ No newline at end of file diff --git a/tests/engine/game3_file_browser_quote_test.lua b/tests/engine/game3_file_browser_quote_test.lua new file mode 100644 index 00000000..059874fd --- /dev/null +++ b/tests/engine/game3_file_browser_quote_test.lua @@ -0,0 +1,50 @@ +-- The launcher file browser must not interpolate a path into a shell unescaped. +-- +-- L3 regression: scanDirectory built +-- 'ls -1ap "' .. dir:gsub('"', '\\"') .. '" 2>/dev/null' +-- so only a double quote was escaped. A directory name containing $(...) or a +-- backtick executed as the user the moment it was entered in the ROM/mod picker. +-- HostShell.quote already escapes for the POSIX shell. +-- luajit tests/engine/game3_file_browser_quote_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local FileBrowser = require("src.ui.kit.FileBrowser") +local HostShell = require("src.core.HostShell") + +-- 1. Injection: a directory named with a command substitution must not run. +-- The marker must not pre-exist (os.tmpname creates its file, so name it here). +local marker = "/tmp/gen1recomp-inject-" .. tostring(os.time()) + .. "-" .. tostring(math.random(1000000)) +check(io.open(marker, "r") == nil, "the injection marker does not exist before the call") +local hostile = "/tmp/gen1recomp-inject-$(touch " .. marker .. ")-x" +FileBrowser.setDirectory(hostile) +local created = io.open(marker, "r") +if created then created:close(); os.remove(marker) end +check(created == nil, + "a command substitution in a directory name is not executed by the file browser") + +-- 2. Shape: the listing command single-quotes the path (HostShell's POSIX form). +local captured +local realPopen = io.popen +io.popen = function(cmd) + captured = cmd + return { read = function() return "" end, close = function() end } +end +FileBrowser.setDirectory("/tmp/it's $(here)") +io.popen = realPopen + +check(type(captured) == "string", "the browser still lists through the shell") +if type(captured) == "string" then + local quoted = HostShell.quote("/tmp/it's $(here)") + check(captured:find(quoted, 1, true) ~= nil, + "the directory is passed through HostShell.quote (" .. tostring(captured) .. ")") + check(captured:find("$(here)", 1, true) == nil or captured:find(quoted, 1, true) ~= nil, + "the raw path is not left unquoted") +end + +T.finish("game3_file_browser_quote_test") diff --git a/tests/engine/game3_gamestats_persistence_test.lua b/tests/engine/game3_gamestats_persistence_test.lua new file mode 100644 index 00000000..f84da0c5 --- /dev/null +++ b/tests/engine/game3_gamestats_persistence_test.lua @@ -0,0 +1,45 @@ +-- Game stats must survive a save/load round trip. +-- +-- Regression: `session.gameStats` is written by five subsystems -- slot-machine +-- jackpots (slot_machine.lua), hatched eggs (step_events.lua), link W/L/D +-- (link/battle.lua), link trades (link/trade.lua) and the sticker-man brags that +-- read them (natives_events.lua) -- but Schema.toSaveTable never emitted it and +-- fromSaveTable never restored it, so every counter reset on Continue. +-- +-- The change is additive: a save without the key loads as an empty table, so the +-- rollback is dropping the key (the counters are then lost, as they are today). +-- luajit tests/engine/game3_gamestats_persistence_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Schema = require("src.core.game3.save_schema_firered") + +local session = Schema.newGame({ name = "RED" }) +session.gameStats = { [13] = 4, [7] = 100, linkBattleWins = 3, linkBattleLosses = 2 } + +local save = Schema.toSaveTable(session) +check(type(save.gameStats) == "table", "toSaveTable writes gameStats") +local written = type(save.gameStats) == "table" and save.gameStats or {} +eq(written[13], 4, "the hatched-egg counter is written") +eq(written[7], 100, "a numeric game-stat id is written") +eq(written.linkBattleWins, 3, "the link battle win count is written") + +local restored = Schema.fromSaveTable(save) +check(type(restored.gameStats) == "table", "fromSaveTable restores gameStats") +local back = type(restored.gameStats) == "table" and restored.gameStats or {} +eq(back[13], 4, "the hatched-egg counter round-trips") +eq(back[7], 100, "a numeric game-stat id round-trips") +eq(back.linkBattleWins, 3, "named keys round-trip") +eq(back.linkBattleLosses, 2, "...for every recorded outcome") + +-- An older save has no gameStats key: it must load with an empty table, and the +-- writers' own `if type(session.gameStats) ~= "table"` guards stay satisfied. +save.gameStats = nil +local old = Schema.fromSaveTable(save) +check(type(old.gameStats) == "table", "a save without gameStats loads with an empty table") + +T.finish("game3_gamestats_persistence_test") diff --git a/tests/engine/game3_givemon_opcode_layout_test.lua b/tests/engine/game3_givemon_opcode_layout_test.lua new file mode 100644 index 00000000..32e67aa4 --- /dev/null +++ b/tests/engine/game3_givemon_opcode_layout_test.lua @@ -0,0 +1,58 @@ +-- The givemon opcode must consume the pret operand layout. +-- +-- Regression: opcodes.lua declared +-- [0x79] = op("givemon", 9, { H, B, H, H, H }) +-- but pret (asm/macros/event.inc) is +-- .byte 0x79 / .2byte species / .byte level / .2byte item / .4byte 0 / .4byte 0 / .byte 0 +-- i.e. two WORDS where the port had two halves -- 14 operand bytes after the +-- opcode, not 9. disasm.decodeOne iterates def.args (the size field is unused) +-- and extract_scripts decodes a script linearly, so every command after a +-- givemon was misaligned and baked into the dataset with wrong operands. +-- +-- Disasm.decodeOne takes a 1-based array of byte values (not a string). +-- luajit tests/engine/game3_givemon_opcode_layout_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Disasm = require("src.core.game3.scripting.disasm") +local Opcodes = require("src.core.game3.scripting.opcodes") + +local function push8(t, v) t[#t + 1] = v % 256 end +local function push16(t, v) push8(t, v % 256); push8(t, math.floor(v / 256) % 256) end +local function push32(t, v) push16(t, v % 65536); push16(t, math.floor(v / 65536) % 65536) end + +-- opcode, species 25, level 10, item 13, two words, trailing byte +local stream = {} +push8(stream, 0x79) +push16(stream, 25) +push8(stream, 10) +push16(stream, 13) +push32(stream, 0x11223344) +push32(stream, 0x55667788) +push8(stream, 0) + +local row, nextIdx = Disasm.decodeOne(stream, 1) +eq(row.op, "givemon", "the opcode decodes as givemon") +eq(nextIdx, 16, "givemon consumes 15 bytes: opcode + the pret operand layout") +eq(row[1], 25, "species") +eq(row[2], 10, "level") +eq(row[3], 13, "item") +eq(row[4], 0x11223344, "the first word operand") +eq(row[5], 0x55667788, "the second word operand") +eq(row[6], 0, "the trailing byte operand") + +-- The desync: the command after a givemon must still decode. +push8(stream, 0x02) -- 0x02 = end +local _, afterGivemon = Disasm.decodeOne(stream, 1) +local following = Disasm.decodeOne(stream, afterGivemon) +eq(following.op, "end", "the command after givemon decodes (no stream desync)") + +-- The declared size must agree with the argument list: every other entry adds +-- the opcode byte to its args (addvar is 5 for two halves). +eq(Opcodes.get(0x79).size, 15, "the declared size includes the opcode byte") + +T.finish("game3_givemon_opcode_layout_test") diff --git a/tests/engine/game3_hall_of_fame_save_test.lua b/tests/engine/game3_hall_of_fame_save_test.lua new file mode 100644 index 00000000..9c94dadc --- /dev/null +++ b/tests/engine/game3_hall_of_fame_save_test.lua @@ -0,0 +1,85 @@ +-- The Hall of Fame induction must actually commit the save. +-- +-- N-A22 regression: commit_clear_and_save() ended with +-- local okSave, SaveData = pcall(require, "src.core.game3.save") +-- if okSave and SaveData and SaveData.save then pcall(SaveData.save, session) end +-- and that module does not exist, so `okSave` was always false: the clear flag, +-- the debut timestamp and the HOF team were set in memory and never written. +-- +-- N-A23 (the same fields are absent from the save schema) is pinned at the end. +-- luajit tests/engine/game3_hall_of_fame_save_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- The commit takes the engine save path through the runtime singleton, looked up +-- in package.loaded exactly as map_name_popup does -- so load it as the game does. +local RuntimeStub = { _game = nil } +package.preload["src.core.game3.runtime"] = function() return RuntimeStub end +local Runtime = require("src.core.game3.runtime") +check(Runtime == RuntimeStub, "the runtime stub is wired into package.loaded") + +local HallOfFame = require("src.ui.game3.hall_of_fame") +local Schema = require("src.core.game3.save_schema_firered") + +local FLAG_SYS_GAME_CLEAR = 0x82C + +local function session() + return { + flags = {}, party = {}, trainerId = 42379, + playTimeHours = 12, playTimeMinutes = 34, playTimeSeconds = 56, + hofDebutHours = 12, hofDebutMinutes = 34, hofDebutSeconds = 56, + } +end + +local mon = { species = 25, level = 50, nickname = "SPARKY", otId = 42379 } + +-- 1. The commit reaches the engine's save path. +local saved = 0 +RuntimeStub._game = { saveGame = function() saved = saved + 1; return true end } +local s = session() +check(pcall(HallOfFame._commitClearAndSave, s, { mon }), + "the induction commit is callable") +eq(saved, 1, "the induction commits the save (the old code called a module that does not exist)") + +-- 2. The in-memory state it is responsible for is still set. +eq(s.flags[FLAG_SYS_GAME_CLEAR], true, "the game-clear flag is set") +eq(s.game_cleared, true, "the session is marked cleared") +eq(s.hasHallOfFameRecords, true, "HOF records are marked present") +eq(s.hofDebutTime, "12:34:56", "the debut timestamp is recorded") +check(type(s.hallOfFameTeams) == "table" and #s.hallOfFameTeams == 1, + "the induction team is recorded") +local team = (type(s.hallOfFameTeams) == "table" and s.hallOfFameTeams[1]) or {} +eq(team[1] and team[1].species, 25, "...with the member species") +eq(team[1] and team[1].nickname, "SPARKY", "...and nickname") + +-- 3. A throwing save is logged, not raised, and the in-memory commit stands. +local s3 = session() +RuntimeStub._game = { saveGame = function() error("simulated disk failure") end } +check(pcall(HallOfFame._commitClearAndSave, s3, { mon }), + "a throwing save does not propagate") +eq(s3.game_cleared, true, "the in-memory commit still happened") + +-- 4. With no game at all it must not raise. +local s4 = session() +RuntimeStub._game = nil +check(pcall(HallOfFame._commitClearAndSave, s4, { mon }), + "the commit is safe when no game is available") + +-- 5. N-A23: the fields the commit writes must be in the save schema. +local written = Schema.toSaveTable(s) +eq(written.hofDebutTime, "12:34:56", "the schema writes hofDebutTime") +eq(written.game_cleared, true, "the schema writes game_cleared") +eq(written.hasHallOfFameRecords, true, "the schema writes hasHallOfFameRecords") +check(type(written.hallOfFameTeams) == "table" and #written.hallOfFameTeams == 1, + "the schema writes hallOfFameTeams") +local restored = Schema.fromSaveTable(written) +eq(restored.hofDebutTime, "12:34:56", "...and restores it") +eq(restored.game_cleared, true, "...and the cleared flag") +check(type(restored.hallOfFameTeams) == "table" and #restored.hallOfFameTeams == 1, + "...and the teams") + +T.finish("game3_hall_of_fame_save_test") diff --git a/tests/engine/game3_item_steal_trick_test.lua b/tests/engine/game3_item_steal_trick_test.lua new file mode 100644 index 00000000..1fdbe0f3 --- /dev/null +++ b/tests/engine/game3_item_steal_trick_test.lua @@ -0,0 +1,103 @@ +-- Thief and Trick must write both sides' item changes through to the party mon. +-- +-- Regression: the STEAL_ITEM branch of Secondary.set and Special.trick both +-- persisted the *target* only when `target.side == "player"`. The common case +-- (the player steals/swaps with an enemy) left the enemy's party mon holding +-- the item it no longer had, so on the next send-out the enemy held it again +-- while the player also held it -- item duplication. +-- +-- persist_item resolves the party mon via State.partyMon(b) (= b._partyMon or +-- b.mon), so these drive the real effect functions with plain battler tables. +-- luajit tests/engine/game3_item_steal_trick_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Secondary = require("src.core.game3.battle.effects.secondary") +local Special = require("src.core.game3.battle.effects.special") + +local failures = 0 + +local function adapter() + return { + abilityOf = function(_, b) return b.ability end, + hp = function(_, b) return b.hp or 100 end, + ownSide = function() return nil end, + foeSide = function() return nil end, + displayName = function(_, b) return b.name or "MON" end, + playAnim = function() end, + say = function() end, + sayFail = function() failures = failures + 1 end, + roll = function(_, lo) return lo end, + pushEvent = function() end, + } +end + +local function battler(side, item, ability) + return { + side = side, + name = side == "player" and "CHARMANDER" or "RATTATA", + hp = 100, item = item, ability = ability, + mon = item and { item = item, heldItem = item } or {}, + } +end + +local function thief(user, target) + return Secondary.set({ + adapter = adapter(), user = user, target = target, + move = { moveName = "THIEF" }, + }, "STEAL_ITEM", false, true, false) +end + +local function trick(user, target) + failures = 0 + return Special.trick({ adapter = adapter(), user = user, target = target }) +end + +-- 1. Thief: the player steals from an enemy. Both party mons must agree with +-- their battlers, or the enemy's item returns on switch-out. +local user, target = battler("player", 0), battler("enemy", 13) +check(thief(user, target), "THIEF reports the steal") +eq(user.item, 13, "the thief's battler holds the stolen item") +eq(user.mon.item, 13, "the thief's party mon holds the stolen item") +eq(target.item, 0, "the victim's battler loses the item") +eq(target.mon.item, nil, "the victim's party mon loses the item (does not return on switch-out)") +eq(target.mon.heldItem, nil, "the victim's party heldItem is cleared") + +-- 2. Thief still refuses STICKY_HOLD, leaving both sides intact. +local u2, t2 = battler("player", 0), battler("enemy", 13, "STICKY_HOLD") +check(not thief(u2, t2), "STICKY_HOLD refuses THIEF") +eq(t2.mon.item, 13, "STICKY_HOLD keeps the victim's party item") + +-- 3. Thief still refuses when the user already holds an item. +local u3, t3 = battler("player", 14), battler("enemy", 13) +check(not thief(u3, t3), "a full-handed thief refuses") +eq(t3.mon.item, 13, "a refused steal leaves the victim's party item") + +-- 4. Trick: the player swaps with an enemy. Both mons must take the item the +-- battler now holds. +local u4, t4 = battler("player", 13), battler("enemy", 14) +trick(u4, t4) +eq(u4.item, 14, "the user's battler takes the target's item") +eq(u4.mon.item, 14, "the user's party mon takes the target's item") +eq(t4.item, 13, "the target's battler takes the user's item") +eq(t4.mon.item, 13, "the target's party mon takes the user's item (no duplication)") +eq(t4.mon.heldItem, 13, "the target's party heldItem follows the swap") + +-- 5. Trick still fails when neither side holds anything. +local u5, t5 = battler("player", 0), battler("enemy", 0) +trick(u5, t5) +eq(failures, 1, "TRICK with no items on either side fails") + +-- 6. One-sided Trick: the user gives its item away, so its mon must be cleared. +local u6, t6 = battler("player", 13), battler("enemy", 0) +trick(u6, t6) +eq(u6.item, 0, "the user's battler gives the item away") +eq(u6.mon.item, nil, "the user's party mon is cleared when it gives its item away") +eq(t6.item, 13, "the target's battler receives the item") +eq(t6.mon.item, 13, "the target's party mon receives the item") + +T.finish("game3_item_steal_trick_test") diff --git a/tests/engine/game3_knock_off_item_test.lua b/tests/engine/game3_knock_off_item_test.lua new file mode 100644 index 00000000..de45cc0e --- /dev/null +++ b/tests/engine/game3_knock_off_item_test.lua @@ -0,0 +1,75 @@ +-- KNOCK_OFF must persist the removed item to the party mon. +-- +-- Regression: the KNOCK_OFF branch of Secondary.set cleared effBattler.item and +-- set effBattler.expKnockedOff, but never wrote through to the party mon. The +-- battler is a battle-local view: State.makeBattler rebuilds `item` from +-- held_item(mon) on the next send-out, so the knocked-off item came back on +-- switch-out (and could then be stolen or knocked off again). +-- +-- persist_item resolves the party mon via State.partyMon(b) (= b._partyMon or +-- b.mon), so this drives the real effect with plain battler tables. +-- luajit tests/engine/game3_knock_off_item_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Secondary = require("src.core.game3.battle.effects.secondary") + +local function adapter() + return { + abilityOf = function(_, b) return b.ability end, + hp = function(_, b) return b.hp or 100 end, + ownSide = function() return nil end, + displayName = function(_, b) return b.name or "MON" end, + playAnim = function() end, + say = function() end, + roll = function(_, lo) return lo end, + } +end + +-- A battler and its party mon carrying the same item, like State.makeBattler +-- builds from held_item(mon). +local function battler(side, item, ability) + return { + side = side, + name = side == "player" and "CHARMANDER" or "RATTATA", + hp = 100, item = item, ability = ability, + mon = item and { item = item, heldItem = item } or {}, + } +end + +local function knock_off(user, target) + return Secondary.set({ + adapter = adapter(), user = user, target = target, + move = { moveName = "KNOCK OFF" }, + }, "KNOCK_OFF", false, true, false) +end + +-- 1. A knocked-off item must not survive on the party mon, or it returns on +-- switch-out (State.makeBattler reads held_item(mon)). +local user, target = battler("enemy", 0), battler("player", 13) +check(knock_off(user, target), "KNOCK_OFF reports the item was removed") +eq(target.item, 0, "the battler's item is cleared") +eq(target.mon.item, nil, "the party mon no longer holds the item (does not return on switch-out)") +eq(target.mon.heldItem, nil, "heldItem is cleared too") + +-- 2. The enemy side persists as well. +local user2, target2 = battler("player", 0), battler("enemy", 13) +knock_off(user2, target2) +eq(target2.item, 0, "the enemy battler's item is cleared") +eq(target2.mon.item, nil, "the enemy party mon no longer holds the item") + +-- 3. STICKY_HOLD refuses and must leave the item intact everywhere. +local user3, target3 = battler("enemy", 0), battler("player", 13, "STICKY_HOLD") +check(not knock_off(user3, target3), "STICKY_HOLD refuses KNOCK_OFF") +eq(target3.item, 13, "STICKY_HOLD keeps the battler item") +eq(target3.mon.item, 13, "STICKY_HOLD keeps the party item") + +-- 4. A target with no item is a no-op. +local user4, target4 = battler("enemy", 0), battler("player", 0) +check(not knock_off(user4, target4), "a target with no item is a no-op") + +T.finish("game3_knock_off_item_test") diff --git a/tests/engine/game3_knocked_off_flag_test.lua b/tests/engine/game3_knocked_off_flag_test.lua new file mode 100644 index 00000000..90ade2b9 --- /dev/null +++ b/tests/engine/game3_knocked_off_flag_test.lua @@ -0,0 +1,99 @@ +-- A knocked-off item must stay marked for the rest of the battle. +-- +-- Regression: KNOCK_OFF only set the per-battler volatile `expKnockedOff`, which +-- dies when that battler leaves the field. pret keeps +-- gWishFutureKnock.knockedOffMons -- one bit per party index per side -- so the +-- guard survives switch-out: a mon whose item was knocked off cannot have an +-- item stolen or swapped for the rest of the battle, even after it comes back +-- holding something else. +-- +-- These drive the real effect functions; the battle-scoped state is the adapter's +-- `_st`, exactly as the engine carries it. +-- luajit tests/engine/game3_knocked_off_flag_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Secondary = require("src.core.game3.battle.effects.secondary") +local Special = require("src.core.game3.battle.effects.special") + +local failures = 0 + +local function adapter(st) + return { + _st = st, + abilityOf = function(_, b) return b.ability end, + hp = function(_, b) return b.hp or 100 end, + ownSide = function() return nil end, + foeSide = function() return nil end, + displayName = function(_, b) return b.name or "MON" end, + playAnim = function() end, + say = function() end, + sayFail = function() failures = failures + 1 end, + roll = function(_, lo) return lo end, + pushEvent = function() end, + } +end + +local function battler(side, partyIndex, item, ability) + return { + side = side, partyIndex = partyIndex, id = 0, + name = side == "player" and "CHARMANDER" or "RATTATA", + hp = 100, item = item, ability = ability, + mon = item and { item = item, heldItem = item } or {}, + } +end + +local function knock_off(st, user, target) + return Secondary.set({ adapter = adapter(st), user = user, target = target, + move = { moveName = "KNOCK OFF" } }, + "KNOCK_OFF", false, true, false) +end + +local function steal(st, user, target) + return Secondary.set({ adapter = adapter(st), user = user, target = target, + move = { moveName = "THIEF" } }, + "STEAL_ITEM", false, true, false) +end + +local function trick(st, user, target) + failures = 0 + Special.trick({ adapter = adapter(st), user = user, target = target }) + return failures +end + +-- 1. After a knock-off, Trick still refuses the victim once it has switched out +-- and come back holding something else. +local st = {} +knock_off(st, battler("player", 1, 0), battler("enemy", 1, 13)) +local returning = battler("enemy", 1, 14) +eq(trick(st, battler("player", 1, 13), returning), 1, + "TRICK refuses a mon knocked off earlier this battle, after switch-out") + +-- 2. A different party index on the same side is unaffected. +eq(trick(st, battler("player", 1, 13), battler("enemy", 2, 14)), 0, + "TRICK still works against a mon that was not knocked off") + +-- 3. Thief also refuses when the *thief* had its own item knocked off earlier, +-- which only the battle-scoped mask can remember. (The thief's hands are +-- empty, as they must be to steal at all.) +local st2 = {} +knock_off(st2, battler("enemy", 1, 0), battler("player", 1, 13)) +check(not steal(st2, battler("player", 1, 0), battler("enemy", 1, 13)), + "THIEF refuses after the user's own item was knocked off earlier this battle") + +-- 4. Control: the same Thief works when nothing was knocked off. +local st3 = {} +check(steal(st3, battler("player", 1, 0), battler("enemy", 1, 13)), + "THIEF still steals when no knock-off happened") + +-- 5. The mask is per side: an enemy knock-off does not block the player. +local st4 = {} +knock_off(st4, battler("player", 1, 0), battler("enemy", 1, 13)) +eq(trick(st4, battler("player", 1, 14), battler("enemy", 2, 13)), 0, + "an enemy knock-off does not block the player's own party index") + +T.finish("game3_knocked_off_flag_test") diff --git a/tests/engine/game3_link_records_persistence_test.lua b/tests/engine/game3_link_records_persistence_test.lua new file mode 100644 index 00000000..4fbef68a --- /dev/null +++ b/tests/engine/game3_link_records_persistence_test.lua @@ -0,0 +1,49 @@ +-- Link battle records and trainer-card counters must survive a save. +-- +-- H2 regression: link/battle.lua writes `session.linkBattleRecords` (the Record +-- Corner / fan-club table) and `session.trainerCard` (link win/loss counters), +-- and link/init.lua, trainer_fan_club.lua and trainer_tower_records.lua read +-- them -- but neither key was in the save schema, so both were wiped on Continue. +-- +-- Additive: a save without the keys loads as empty tables. +-- luajit tests/engine/game3_link_records_persistence_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Schema = require("src.core.game3.save_schema_firered") + +local session = Schema.newGame({ name = "RED" }) +session.linkBattleRecords = { + { name = "AAA", wins = 3, losses = 1, draws = 0 }, + { name = "BBB", wins = 1, losses = 2, draws = 1 }, +} +session.trainerCard = { linkBattleWins = 5, linkBattleLosses = 2 } + +local save = Schema.toSaveTable(session) +check(type(save.linkBattleRecords) == "table", "toSaveTable writes linkBattleRecords") +local wroteRecs = type(save.linkBattleRecords) == "table" and save.linkBattleRecords or {} +eq(#wroteRecs, 2, "both records are written") +eq(wroteRecs[1] and wroteRecs[1].name, "AAA", "record order is preserved") +check(type(save.trainerCard) == "table", "toSaveTable writes trainerCard") +local wroteCard = type(save.trainerCard) == "table" and save.trainerCard or {} +eq(wroteCard.linkBattleWins, 5, "trainer-card link wins are written") + +local back = Schema.fromSaveTable(save) +local backRecs = type(back.linkBattleRecords) == "table" and back.linkBattleRecords or {} +local backCard = type(back.trainerCard) == "table" and back.trainerCard or {} +eq(#backRecs, 2, "records round-trip") +eq(backRecs[2] and backRecs[2].name, "BBB", "...with their order preserved") +eq(backRecs[1] and backRecs[1].wins, 3, "...and their counters") +eq(backCard.linkBattleWins, 5, "trainer-card link wins round-trip") +eq(backCard.linkBattleLosses, 2, "...and link losses") + +save.linkBattleRecords, save.trainerCard = nil, nil +local old = Schema.fromSaveTable(save) +check(type(old.linkBattleRecords) == "table" and type(old.trainerCard) == "table", + "a save without the keys loads with empty tables") + +T.finish("game3_link_records_persistence_test") diff --git a/tests/engine/game3_map_def_less_bind_test.lua b/tests/engine/game3_map_def_less_bind_test.lua new file mode 100644 index 00000000..95326cd4 --- /dev/null +++ b/tests/engine/game3_map_def_less_bind_test.lua @@ -0,0 +1,75 @@ +-- A map loaded without a def must release the previous map's collision grid. +-- +-- Regression: Map.load bound collision only when a def existed +-- (`if def then Collision.bindMap(...) end`), so loading a known-but-def-less id +-- kept the PREVIOUS map's grid live while Map.current, the session and the +-- player all moved on. Movement was then validated against the map we left. +-- Collision.canEnter only falls back to the host map when no grid is bound +-- (collision.lua "Prefer owned grid; fall back to host map if unbound"), so the +-- def-less case must clear the binding. +-- +-- Map.load's unrelated boundaries are stubbed; the collision binding is real. +-- luajit tests/engine/game3_map_def_less_bind_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.preload["src.core.game3.ghosts"] = function() + return { capture = function() end, adopt = function() end } +end +package.preload["src.core.game3.scripting.space"] = function() + return { + bundle = {}, ensureBundle = function() end, attachEventsToMaps = function() end, + activate = function() end, runEnterScripts = function() end, + } +end +package.preload["src.core.game3.field"] = function() + return { lock = function() end, unlock = function() end, + clearMetatiles = function() end, metatileOverrides = {} } +end +package.preload["src.core.game3.runtime"] = function() + return { isActive = function() return false end, + getSession = function() return nil end, _mod = {} } +end +package.preload["src.core.game3.player"] = function() + return { cellX = 0, cellY = 0, px = 0, py = 0, facing = "down", biking = false, + reset = function() end, syncSavePosition = function() end } +end +package.preload["src.core.game3.objects"] = function() return { loadMap = function() end } end +package.preload["src.core.game3.audio"] = function() + return { setSavedSong = function() end, playMapSong = function() end } +end +package.preload["src.core.game3.encounters"] = function() + return { resetRateModifiers = function() end } +end +package.preload["src.core.game3.field_effects"] = function() return {} end +package.preload["src.core.game3.vs_seeker"] = function() return { mapReset = function() end } end +package.preload["src.core.game3.field_view"] = function() + return { setDefaultFlashLevel = function() end } +end + +local Map = require("src.core.game3.map") +local Collision = require("src.core.game3.collision") + +local game = { data = { maps = {} }, save = {} } + +-- Stage a binding as it would be after loading any real map. +Collision._grid = { 0, 0, 0, 0 } +Collision._mapId = "FR_PREV" +Collision._mapDef = { id = "FR_PREV" } +Collision._warps = {} +Collision._widthCells = 2 +Collision._heightCells = 2 +check(Collision._grid ~= nil, "a previous map's collision grid is bound") + +-- game.data.maps has no entry for FR_MISSING, so Map.load resolves no def. +Map.load({}, game, "FR_MISSING", { seamless = true }) + +eq(Collision._grid, nil, "a def-less map load releases the previous collision grid") +eq(Collision._mapId, nil, "and forgets which map was bound") +eq(Collision._mapDef, nil, "and drops the previous map def") + +T.finish("game3_map_def_less_bind_test") diff --git a/tests/engine/game3_map_section_unresolved_test.lua b/tests/engine/game3_map_section_unresolved_test.lua new file mode 100644 index 00000000..28181a78 --- /dev/null +++ b/tests/engine/game3_map_section_unresolved_test.lua @@ -0,0 +1,57 @@ +-- An unresolved region-map section must not be presented as PALLET TOWN. +-- +-- Regression: dataset.lua defaulted a map's regionMapSectionId to 88 +-- ("... or 88") and took getInfo's echoed secId, and getInfo returns the +-- Pallet Town record with secId 88 for anything it cannot identify. Because 88 +-- IS a valid section, `resolved` came back true and the map name popup showed +-- "PALLET TOWN" for maps it had never identified -- and the preview/Fly gates +-- treated the fake section as real. +-- +-- The popup must fall back to the cleaned map id when the section is +-- unresolved, and the dataset must stop fabricating section 88. +-- luajit tests/engine/game3_map_section_unresolved_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local MapSectionsExtract = require("src.import.gba.map_sections_extract") +local MapNamePopup = require("src.ui.game3.map_name_popup") + +-- The mechanism: 88 is a real section, so echoing it reads as "resolved". +local pallet = MapSectionsExtract.getInfo(88, "FR_ANYTHING", 0) +check(pallet.resolved == true, "section 88 resolves, so faking it hides an unknown map") +eq(pallet.name, "PALLET TOWN", "section 88 is Pallet Town") + +-- An id nobody extracted does not resolve (getInfo still echoes the placeholder). +local unknown = MapSectionsExtract.getInfo(nil, "FR_TESTMAP", 0) +check(unknown.resolved == false, "an unknown map id does not resolve") + +-- The popup must show the cleaned map id, not the placeholder place name. +MapNamePopup.dismiss() +MapNamePopup.show({ id = "FR_TESTMAP", showMapName = 1 }, { force = true }) +check(MapNamePopup._name ~= "PALLET TOWN", + "an unresolved map is not labelled PALLET TOWN (got " .. tostring(MapNamePopup._name) .. ")") +eq(MapNamePopup._name, "TESTMAP", "it falls back to the cleaned map id") + +-- A resolved map still shows its real place name. +MapNamePopup.dismiss() +MapNamePopup.show({ id = "FR_PALLET_TOWN", regionMapSectionId = 88, showMapName = 1 }, + { force = true }) +eq(MapNamePopup._name, "PALLET TOWN", "a resolved map still shows its place name") + +-- Static guard on the root cause: dataset must not default the section to 88. +do + local f = io.open("src/core/game3/dataset.lua", "r") + check(f ~= nil, "dataset.lua is readable") + if f then + local src = f:read("*a") + f:close() + check(not src:find("regionMapSectionId or 88", 1, true), + "dataset.lua does not fabricate regionMapSectionId 88") + end +end + +T.finish("game3_map_section_unresolved_test") diff --git a/tests/engine/game3_mids_idx_bounds_test.lua b/tests/engine/game3_mids_idx_bounds_test.lua new file mode 100644 index 00000000..d9c4639a --- /dev/null +++ b/tests/engine/game3_mids_idx_bounds_test.lua @@ -0,0 +1,52 @@ +-- A corrupt mids.idx must not be trusted for its table sizes. +-- +-- N-E3 regression: NativePack.decodeIdx read midCount / atlasCols / atlasRows +-- straight out of the u16 header, then looped `midCount * 256` bytes and, via +-- bake_or_load, sized its buffers from `atlasCols x atlasRows`. With u16 maxima +-- that is a ~4 TB allocation; short of that it raised inside the pixel loop +-- (read_u16 does not bounds-check). The file lives in the user-writable cache. +-- luajit tests/engine/game3_mids_idx_bounds_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local NativePack = require("src.import.gba.native_pack") + +local function u16(v) return string.char(v % 256, math.floor(v / 256) % 256) end + +-- 12-byte header: MAGIC(4) + version + flags + midCount + atlasCols + atlasRows +local function header(midCount, cols, rows) + return NativePack.MAGIC_IDX .. string.char(NativePack.FORMAT_VERSION, 0) + .. u16(midCount) .. u16(cols) .. u16(rows) +end + +-- 1. An impossible mid count with no table data must be rejected, not raise. +local ok1, res1 = pcall(NativePack.decodeIdx, header(65535, 16, 16)) +check(ok1, "decodeIdx does not raise on an impossible mid count") +check(res1 == nil, "...it rejects it") + +-- 2. An impossible atlas must be rejected (it would size a ~4 TB buffer). +local ok2, res2 = pcall(NativePack.decodeIdx, header(1, 65535, 65535)) +check(ok2, "decodeIdx does not raise on an impossible atlas") +check(res2 == nil, "...it rejects it") + +-- 3. A blob shorter than its declared tables must be rejected. +local ok3, res3 = pcall(NativePack.decodeIdx, header(4, 16, 4)) +check(ok3, "decodeIdx does not raise on a truncated blob") +check(res3 == nil, "...it rejects it") + +-- 4. Regression guard: a well-formed idx still decodes. +local good = header(2, 16, 2) .. u16(1) .. u16(2) .. string.rep("\0", 2 * 256) +local decoded = NativePack.decodeIdx(good) +check(type(decoded) == "table", "a well-formed idx decodes") +if type(decoded) == "table" then + eq(decoded.midCount, 2, "...with its mid count") + eq(decoded.atlasCols, 16, "...and atlas columns") + eq(decoded.atlasRows, 2, "...and atlas rows") + eq(decoded.midIds[2], 2, "...and its mid ids") +end + +T.finish("game3_mids_idx_bounds_test") diff --git a/tests/engine/game3_naming_update_contract_test.lua b/tests/engine/game3_naming_update_contract_test.lua new file mode 100644 index 00000000..96710686 --- /dev/null +++ b/tests/engine/game3_naming_update_contract_test.lua @@ -0,0 +1,65 @@ +-- The naming screen must follow the stack's update convention. +-- +-- G4 regression: Hud.update ticks the stack top with `pcall(top.mod.update, dt)`, +-- but Naming's signature is `Naming.update(input, dt)` -- so the delta arrived as +-- `input` and `input.wasPressed` raised on a number, every frame the naming +-- screen was on top. Hud's pcall swallowed it, so the fault was invisible and +-- input never reached the screen through Hud (Runtime passed it correctly, which +-- is why only Hud's path was broken). +-- +-- Convention (Hud.update_top_menu prefers it): menus expose handleInput(input) +-- and update(dt). This pins both halves. +-- luajit tests/engine/game3_naming_update_contract_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Stack = require("src.ui.game3.stack") +local Naming = require("src.ui.game3.naming") +local Hud = require("src.ui.game3.hud") + +local function open_naming() + local st = { + title = "YOUR NAME?", maxLen = 7, name = "", page = 1, + row = 1, col = 1, btn = 1, blink = 0, swapT = nil, + } + Naming._state = st + Naming.openFlag = true + Stack.push("naming", Naming, { hideBelow = true }) + return st +end + +local function input(pressed) + return { + wasPressed = function(_, k) return pressed[k] == true end, + isDown = function() return false end, + } +end + +-- 1. update takes the delta alone (the arity Hud uses). +local st = open_naming() +local ok, err = pcall(Naming.update, 1 / 60) +check(ok, "Naming.update(dt) is callable with just a delta (" .. tostring(err) .. ")") +eq(st.blink, 1 / 60, "update advances the blink timer") + +-- 2. Input reaches the screen through Hud's stack tick. +local st2 = open_naming() +st2.row = 1 +Hud.update({ input = input({ down = true }) }, 1 / 60) +eq(st2.row, 2, "a 'down' press reaches naming through Hud.update") + +-- 3. handleInput is the input entry point and accepts the frame input. +local st3 = open_naming() +check(pcall(Naming.handleInput, input({})), "Naming.handleInput accepts an input object") + +-- 4. The swap-animation guard still suppresses input (the original early return). +local st4 = open_naming() +st4.row = 1 +st4.swapT = 0 +pcall(Naming.handleInput, input({ down = true })) +eq(st4.row, 1, "input is ignored while the page swap is running") + +T.finish("game3_naming_update_contract_test") diff --git a/tests/engine/game3_ow_meta_bounds_test.lua b/tests/engine/game3_ow_meta_bounds_test.lua new file mode 100644 index 00000000..33d30fec --- /dev/null +++ b/tests/engine/game3_ow_meta_bounds_test.lua @@ -0,0 +1,69 @@ +-- A corrupt OW sprite .meta must not size an allocation. +-- +-- L4 regression: load_one read width/height/frameCount straight out of the +-- u16 header (OwExtract.decodeMeta does not validate) and only sanity-checked +-- the byte count against ONE frame. A header with a large frameCount passed +-- that check and then allocated aw x (h * frameCount) pixels -- with +-- frameCount = 65535 and a 32x32 frame that is 2,097,120 rows, and u16 maxima +-- reach ~4.3e9 pixels. The cache that supplies it lives in the user-writable +-- save directory. +-- luajit tests/engine/game3_ow_meta_bounds_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local OwSprites = require("src.core.game3.ow_sprites") +local OwExtract = require("src.import.gba.ow_extract") + +local function u16(v) return string.char(v % 256, math.floor(v / 256) % 256) end + +-- A well-formed header with an absurd frame count: MAGIC + version + inanimate +-- + graphicsId + width + height + frameCount + paletteTag. +local W, H, FRAMES = 32, 32, 65535 +local meta = OwExtract.MAGIC .. string.char(1, 0) .. u16(0) + .. u16(W) .. u16(H) .. u16(FRAMES) .. u16(0) +local rgba = string.rep("\0", W * H * 4) -- exactly one frame of pixels + +local decoded = OwExtract.decodeMeta(meta) +check(decoded and decoded.frameCount == FRAMES, "the fixture carries an absurd frame count") + +OwSprites.install({ + read = function(_, rel) + if rel:sub(-5) == ".meta" then return meta end + if rel:sub(-5) == ".rgba" then return rgba end + return nil + end, +}) + +-- Spy the allocation the loader asks for. +local asked = {} +local realNew = love.image.newImageData +love.image.newImageData = function(a, b, ...) + asked[#asked + 1] = { a, b } + return realNew(a, b, ...) +end +local spr = OwSprites.get(0) +love.image.newImageData = realNew + +check(spr == nil, "a corrupt .meta is rejected instead of allocated") +eq(#asked, 0, "no image allocation is attempted from a corrupt header") +if #asked > 0 then + print(string.format(" (asked for %sx%s)", tostring(asked[1][1]), tostring(asked[1][2]))) +end + +-- A sane header still loads (regression guard). +local sane = OwExtract.MAGIC .. string.char(1, 0) .. u16(0) + .. u16(W) .. u16(H) .. u16(4) .. u16(0) +OwSprites.install({ + read = function(_, rel) + if rel:sub(-5) == ".meta" then return sane end + if rel:sub(-5) == ".rgba" then return string.rep("\0", W * H * 4 * 4) end + return nil + end, +}) +check(OwSprites.get(0) ~= nil, "a sane sprite sheet still loads") + +T.finish("game3_ow_meta_bounds_test") diff --git a/tests/engine/game3_pc_item_capacity_test.lua b/tests/engine/game3_pc_item_capacity_test.lua new file mode 100644 index 00000000..2c5e368b --- /dev/null +++ b/tests/engine/game3_pc_item_capacity_test.lua @@ -0,0 +1,67 @@ +-- Storage.depositItem must not destroy items when the PC stack is at the cap. +-- +-- Regression: the existing-stack branch capped the PC quantity with +-- math.min(MAX_ITEM_QTY, curQty + qty) and then removed the FULL qty from the +-- bag. With a PC stack already at 999, depositing more stored nothing but +-- still deleted the items from the bag -- silent item loss (reachable from +-- PcMenu's deposit action). +-- +-- Fix contract: a deposit that does not fit is refused (like the 50-slot cap), +-- so the bag keeps the items. The caller already maps any failure to +-- "The PC is full.". +-- luajit tests/engine/game3_pc_item_capacity_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Storage = require("src.core.game3.storage") +local Bag = require("src.core.game3.bag") + +local POTION = 13 + +local function session_with(pcQty, bagQty) + local s = { bag = Bag.new(), storage = Storage.new() } + s.storage.items = {} + if pcQty and pcQty > 0 then s.storage.items[1] = { id = POTION, qty = pcQty } end + if bagQty and bagQty > 0 then Bag.add(s.bag, POTION, bagQty) end + return s +end + +-- 1. The bug: a full PC stack must refuse, not swallow the items. +local s = session_with(Storage.MAX_ITEM_QTY, 5) +local ok, err = Storage.depositItem(s, "ITEMS", 1, 5) +check(ok == false, "depositing into a stack at MAX_ITEM_QTY is refused (err=" .. tostring(err) .. ")") +eq(s.storage.items[1].qty, Storage.MAX_ITEM_QTY, "the PC stack stays at the cap") +eq(Bag.get(s.bag, POTION), 5, "the bag keeps the items -- they are not destroyed") + +-- 2. A stack with too little room for the whole deposit is refused too. +s = session_with(995, 5) +ok = Storage.depositItem(s, "ITEMS", 1, 5) +check(ok == false, "a deposit that does not fit in the stack is refused") +eq(s.storage.items[1].qty, 995, "the partial stack is unchanged") +eq(Bag.get(s.bag, POTION), 5, "the bag keeps the items when the deposit is refused") + +-- 3. A deposit that exactly fills the stack succeeds and moves the items once. +s = session_with(994, 5) +ok = Storage.depositItem(s, "ITEMS", 1, 5) +check(ok == true, "a deposit that exactly fills the stack succeeds") +eq(s.storage.items[1].qty, Storage.MAX_ITEM_QTY, "the stack reaches the cap") +eq(Bag.get(s.bag, POTION), 0, "the bag is debited exactly once") + +-- 4. Regression: the ordinary deposit path is unchanged. +s = session_with(0, 20) +ok = Storage.depositItem(s, "ITEMS", 1, 20) +check(ok == true, "an ordinary deposit succeeds") +eq(s.storage.items[1].qty, 20, "the new stack holds the deposited quantity") +eq(Bag.get(s.bag, POTION), 0, "the bag is emptied by the deposit") + +-- 5. Regression: more than the bag holds still fails without touching the PC. +s = session_with(0, 4) +local ok5, err5 = Storage.depositItem(s, "ITEMS", 1, 5) +check(ok5 == false and err5 == "insufficient_bag_qty", "over-depositing the bag fails") +eq(Bag.get(s.bag, POTION), 4, "a refused deposit leaves the bag intact") + +T.finish("game3_pc_item_capacity_test") diff --git a/tests/engine/game3_player_reset_test.lua b/tests/engine/game3_player_reset_test.lua new file mode 100644 index 00000000..be4ad90e --- /dev/null +++ b/tests/engine/game3_player_reset_test.lua @@ -0,0 +1,51 @@ +-- Player.reset must apply the state it is asked to reset to. +-- +-- A1 regression: `reset(x, y, facing)` never assigned Player.facing, so every +-- non-seamless Map.load / warp / fly / syncFromSession left the avatar facing +-- whatever direction the previous map ended on -- and `Player.syncSavePosition` +-- then wrote that stale facing back into the save. +-- +-- (N-A2, the transient surf flags, is covered by the same suite below.) +-- luajit tests/engine/game3_player_reset_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Player = require("src.core.game3.player") + +-- A1: the facing argument is applied. +Player.facing = "down" +Player.reset(4, 5, "up") +eq(Player.facing, "up", "Player.reset applies its facing argument") + +-- An invalid direction must not corrupt the facing. +Player.facing = "left" +Player.reset(4, 5, "sideways") +eq(Player.facing, "left", "an invalid facing argument leaves the facing unchanged") + +-- A nil argument keeps the current facing (reset(x, y) callers). +Player.facing = "right" +Player.reset(4, 5) +eq(Player.facing, "right", "a nil facing argument keeps the current facing") + +-- N-A2: transient surf state must not survive a reset. A warp or whiteout while +-- surfing otherwise leaves Player.surfing set, and Collision.canEnter then +-- treats water as walkable on land maps and reads every step as a dismount. +Player.surfing, Player.surfHopping, Player.dismounting = true, true, true +Player.reset(4, 5, "down") +check(not Player.surfing, "Player.reset clears surfing") +check(not Player.surfHopping, "Player.reset clears surfHopping") +check(not Player.dismounting, "Player.reset clears dismounting") + +-- The ordinary movement state reset is unchanged. +Player.biking, Player.running, Player.jumping, Player.moving = true, true, true, true +Player.reset(6, 7, "left") +check(not Player.biking, "bike state is still cleared") +check(not Player.running, "run state is still cleared") +check(not Player.jumping, "jump state is still cleared") +check(not Player.moving, "movement is still cleared") + +T.finish("game3_player_reset_test") diff --git a/tests/engine/game3_script_verbs_subset_test.lua b/tests/engine/game3_script_verbs_subset_test.lua new file mode 100644 index 00000000..7c4bde6a --- /dev/null +++ b/tests/engine/game3_script_verbs_subset_test.lua @@ -0,0 +1,259 @@ +-- Script verbs that had no handler (E10 significant subset). +-- +-- Implements the conditional std calls, the door-state verbs, the PC-item +-- verbs, comparestat, bufferitemnameplural, the four mon verbs and the (same +-- map) *at verbs. The v* RAM-script family and the compare_* locals family +-- remain unimplemented and are recorded in the review's remaining-scope list. +-- luajit tests/engine/game3_script_verbs_subset_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Vm = require("src.core.game3.scripting.vm") +local Ops = require("src.core.game3.scripting.ops_a") +local Std = require("src.core.game3.scripting.stdscripts") +local Flags = require("src.core.game3.scripting.flags") +local Ctx = require("src.core.game3.scripting.ctx") +local Storage = require("src.core.game3.storage") +local Bag = require("src.core.game3.bag") +local Schema = require("src.core.game3.save_schema_firered") +local Runtime = require("src.core.game3.runtime") + +local VAR_RESULT = (Ctx and Ctx.VAR_RESULT) or 0x800D + +local session = Schema.newGame({ name = "RED" }) +session.bag = Bag.new() +Storage.ensure(session) +session.storage.items = {} +Runtime.session = session +Runtime._game = { data = { maps = {} } } + +local END = { { op = "end" } } +local function new_vm() + local vm = Vm.new({ + store = Flags.newStore(), + scripts = { t_main = END, ["std:1"] = END, ["std:2"] = END }, + text = Std.TEXT, stdscripts = Std.SCRIPTS, + }) + vm.ctx.stack = vm.ctx.stack or {} + return vm +end + +-- 1. callstd_if with a true condition calls the std script. +local vm = new_vm() +vm.ctx.comparisonResult = 1 -- EQ +vm:setPc("t_main", 1) +Ops.dispatch(vm, { op = "callstd_if", [1] = 1, [2] = 1 }) +eq(#vm.ctx.stack, 1, "callstd_if (condition true) pushes a return address") +eq(vm.ctx.pc.listKey, "std:1", "...and enters the std script") + +-- 2. callstd_if with a false condition falls through. +vm = new_vm() +vm.ctx.comparisonResult = 0 -- LT +vm:setPc("t_main", 1) +Ops.dispatch(vm, { op = "callstd_if", [1] = 1, [2] = 1 }) +eq(#vm.ctx.stack, 0, "callstd_if (condition false) does not push") +eq(vm.ctx.pc.listKey, "t_main", "...and stays on the current list") + +-- 3. gotostd_if jumps without a return address, and only when the condition holds. +vm = new_vm() +vm.ctx.comparisonResult = 1 +vm:setPc("t_main", 1) +Ops.dispatch(vm, { op = "gotostd_if", [1] = 1, [2] = 2 }) +eq(vm.ctx.pc.listKey, "std:2", "gotostd_if (condition true) enters the std script") +eq(#vm.ctx.stack, 0, "...without pushing a return address") +vm = new_vm() +vm.ctx.comparisonResult = 0 +vm:setPc("t_main", 1) +Ops.dispatch(vm, { op = "gotostd_if", [1] = 1, [2] = 2 }) +eq(vm.ctx.pc.listKey, "t_main", "gotostd_if (condition false) falls through") + +-- 4. The door-state verbs reach the host door seam. +local seen = {} +vm = new_vm() +-- tolerate (op,x,y) and (adapter,op,x,y) +vm.adapters.doorAnim = function(a1, a2, a3, a4) + local op, x, y = a1, a2, a3 + if a3 == nil and type(a1) == "table" then op, x, y = a2, a3, a4 end + seen[#seen + 1] = { op, x, y } +end +Ops.dispatch(vm, { op = "setdooropen", [1] = 3, [2] = 7 }) +Ops.dispatch(vm, { op = "setdoorclosed", [1] = 4, [2] = 8 }) +eq(#seen, 2, "both door-state verbs reach the door seam") +local first, second = seen[1] or {}, seen[2] or {} +eq(first[1], "opendoor", "setdooropen maps to the open animation") +eq(first[2], 3, "...carrying its x") +eq(second[1], "closedoor", "setdoorclosed maps to the close animation") + +-- 5. checkpcitem reports whether the PC holds enough. +vm = new_vm() +session.storage.items = { { id = 13, qty = 5 } } +Ops.dispatch(vm, { op = "checkpcitem", [1] = 13, [2] = 3 }) +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 1, "checkpcitem reports enough stored") +Ops.dispatch(vm, { op = "checkpcitem", [1] = 13, [2] = 9 }) +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 0, "checkpcitem reports too few stored") + +-- 6. addpcitem stores into the PC and reports success. +vm = new_vm() +session.storage.items = {} +Ops.dispatch(vm, { op = "addpcitem", [1] = 13, [2] = 4 }) +eq(session.storage.items[1] and session.storage.items[1].qty, 4, "addpcitem stores the quantity") +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 1, "addpcitem reports success") +Ops.dispatch(vm, { op = "addpcitem", [1] = 13, [2] = 2 }) +eq((session.storage.items[1] or {}).qty, 6, "...and stacks onto an existing entry") + +-- 7. addpcitem respects the PC stack cap instead of destroying overflow. +vm = new_vm() +session.storage.items = { { id = 13, qty = Storage.MAX_ITEM_QTY } } +Ops.dispatch(vm, { op = "addpcitem", [1] = 13, [2] = 5 }) +eq((session.storage.items[1] or {}).qty, Storage.MAX_ITEM_QTY, "a full stack is not overfilled") +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 0, "...and the verb reports failure") + +-- 8. comparestat (src/scrcmd.c:582) reads {statId byte, value word} and sets +-- ctx.comparisonResult to LT/EQ/GT from the serialized game stat table. +local Opcodes = require("src.core.game3.scripting.opcodes") +local cs = Opcodes.get(0xcc) +eq(cs.size, 7, "comparestat is a 7-byte instruction (0xcc + B + W)") +eq(cs.args[1].kind, "byte", "...statId is a byte") +eq(cs.args[2].kind, "word", "...value is a word") +session.gameStats = { [5] = 10 } +vm = new_vm() +Ops.dispatch(vm, { op = "comparestat", [1] = 5, [2] = 11 }) +eq(vm.ctx.comparisonResult, 0, "comparestat reports LT below the stat") +Ops.dispatch(vm, { op = "comparestat", [1] = 5, [2] = 10 }) +eq(vm.ctx.comparisonResult, 1, "comparestat reports EQ at the stat") +Ops.dispatch(vm, { op = "comparestat", [1] = 5, [2] = 9 }) +eq(vm.ctx.comparisonResult, 2, "comparestat reports GT above the stat") + +-- 9. bufferitemnameplural (src/scrcmd.c:1637) pluralises like the ROM. +vm = new_vm() +local STR = { op = "bufferitemnameplural", [1] = 0, [2] = 4, [3] = 2 } +Ops.dispatch(vm, STR) +eq(vm.ctx.stringVars[1], "POKé BALLS", "Poké Balls pluralise with S") +STR = { op = "bufferitemnameplural", [1] = 0, [2] = 133, [3] = 2 } +Ops.dispatch(vm, STR) +eq(vm.ctx.stringVars[1], "CHERI BERRIES", "berries pluralise to IES") +STR = { op = "bufferitemnameplural", [1] = 0, [2] = 133, [3] = 1 } +Ops.dispatch(vm, STR) +eq(vm.ctx.stringVars[1], "CHERI BERRY", "a single berry stays singular") + +-- 10-12. The party-mon verbs (src/scrcmd.c:1767, :2239, :2248, :2256) use +-- 0-based party indices and move slots. +vm = new_vm() +session.party = { { species = 1, moves = {}, pp = {}, maxPp = {} } } +Ops.dispatch(vm, { op = "setmonmove", [1] = 0, [2] = 0, [3] = 33 }) +eq(session.party[1].moves[1], 33, "setmonmove writes the 0-based slot") +eq(type(session.party[1].pp[1]), "number", "...and resets its PP") +Ops.dispatch(vm, { op = "setmonmetlocation", [1] = 0, [2] = 88 }) +eq(session.party[1].metLocation, 88, "setmonmetlocation writes metLocation") +Ops.dispatch(vm, { op = "setmonmodernfatefulencounter", [1] = 0 }) +eq(session.party[1].modernFatefulEncounter, true, "setmonmodernfatefulencounter flags the mon") +Ops.dispatch(vm, { op = "checkmonmodernfatefulencounter", [1] = 0 }) +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 1, "checkmonmodernfatefulencounter reports it") +Ops.dispatch(vm, { op = "checkmonmodernfatefulencounter", [1] = 1 }) +eq(Flags.getVar(vm.store, vm.ctx, VAR_RESULT), 0, "...and 0 for a party slot with no mon") + +-- 13. The door-state verbs read x/y through VarGet (src/scrcmd.c:2156). +vm = new_vm() +seen = {} +vm.adapters.doorAnim = function(a1, a2, a3, a4) + local op, x, y = a1, a2, a3 + if a3 == nil and type(a1) == "table" then op, x, y = a2, a3, a4 end + seen[#seen + 1] = { op, x, y } +end +local V0x4001 = 0x4001 +Flags.setVar(vm.store, vm.ctx, V0x4001, 6) +Ops.dispatch(vm, { op = "setdooropen", [1] = V0x4001, [2] = 9 }) +eq((seen[1] or {})[2], 6, "setdooropen resolves variable coordinates") +eq((seen[1] or {})[3], 9, "...and passes literal ones through") + +-- 14. The *at verbs (src/scrcmd.c:993-1080) address a specific map. On the +-- current map they behave as the plain command; elsewhere they skip. +local Map = require("src.core.game3.map") +vm = new_vm() +Map.current = "FR_PALLET_TOWN" +local added = {} +vm.adapters.addObject = function(lid) added[#added + 1] = lid end +Ops.dispatch(vm, { op = "addobjectat", [1] = 2, [2] = 3, [3] = 0 }) +eq(#added, 1, "addobjectat on the current map adds the object") +eq(added[1], 2, "...with the resolved local id") +Ops.dispatch(vm, { op = "addobjectat", [1] = 2, [2] = 3, [3] = 1 }) +eq(#added, 1, "addobjectat on another map is skipped") +Ops.dispatch(vm, { op = "applymovementat", [1] = 2, [2] = { 0xFE }, [3] = 3, [4] = 1 }) +eq(vm.ctx.activeMoves[2], nil, "applymovementat on another map is skipped") +Ops.dispatch(vm, { op = "applymovementat", [1] = 2, [2] = { 0xFE }, [3] = 3, [4] = 0 }) +eq(vm.ctx.activeMoves[2] ~= nil, true, "applymovementat on the current map starts the movement") + +-- 15. The pointer family layouts (asm/macros/event.inc) each carry a leading +-- byte plus a word; the table used to declare them a byte short, which +-- desynced every following instruction. +for _, byte in ipairs({ 0x11, 0x12, 0x13 }) do + local d = Opcodes.get(byte) + eq(d.size, 6, string.format("0x%02x is a 6-byte instruction", byte)) + eq(d.args[1].kind, "byte", string.format("0x%02x starts with a byte", byte)) + eq(d.args[2].kind, "word", string.format("0x%02x ends with a word", byte)) +end + +-- 16. Script locals and the synthetic pointer store (src/scrcmd.c:293-375). +vm = new_vm() +vm.ctx.locals = { 10, 20 } +Ops.dispatch(vm, { op = "copylocal", [1] = 0, [2] = 1 }) +eq(vm.ctx.locals[1], 20, "copylocal copies a local") +vm.ctx.locals = { 5, 9 } +Ops.dispatch(vm, { op = "compare_local_to_local", [1] = 0, [2] = 1 }) +eq(vm.ctx.comparisonResult, 0, "compare_local_to_local reports LT") +vm.ctx.locals = { 7, 7 } +Ops.dispatch(vm, { op = "compare_local_to_value", [1] = 0, [2] = 7 }) +eq(vm.ctx.comparisonResult, 1, "compare_local_to_value reports EQ") +Ops.dispatch(vm, { op = "setptr", [1] = 42, [2] = 0x1234 }) +eq(vm.ctx.scriptMem[0x1234], 42, "setptr writes the synthetic byte store") +Ops.dispatch(vm, { op = "loadbytefromptr", [1] = 1, [2] = 0x1234 }) +eq(vm.ctx.locals[2], 42, "loadbytefromptr reads it back into a local") +vm.ctx.locals[3] = 9 +Ops.dispatch(vm, { op = "setptrbyte", [1] = 2, [2] = 0x1235 }) +eq(vm.ctx.scriptMem[0x1235], 9, "setptrbyte stores a local") +Ops.dispatch(vm, { op = "copybyte", [1] = 0x1236, [2] = 0x1235 }) +eq(vm.ctx.scriptMem[0x1236], 9, "copybyte copies between pointers") +Ops.dispatch(vm, { op = "compare_local_to_ptr", [1] = 2, [2] = 0x1234 }) +eq(vm.ctx.comparisonResult, 0, "compare_local_to_ptr compares local vs store") +Ops.dispatch(vm, { op = "compare_ptr_to_ptr", [1] = 0x1234, [2] = 0x1236 }) +eq(vm.ctx.comparisonResult, 2, "compare_ptr_to_ptr compares two stored bytes") + +-- 17. The RAM-script (v*) control flow (src/scrcmd.c:171-209, :1580). +vm = new_vm() +vm:setPc("t_main", 4) +Ops.dispatch(vm, { op = "setvaddress", [1] = 0x800000 }) +eq(vm.ctx.vaddress, 0x800000, "setvaddress is recorded") +Ops.dispatch(vm, { op = "vgoto", [1] = "std:1" }) +eq(vm.ctx.pc.listKey, "std:1", "vgoto jumps to the script") +vm:setPc("t_main", 4) +Ops.dispatch(vm, { op = "vgoto_if", [1] = 2, [2] = "std:1" }) +eq(vm.ctx.pc.listKey, "t_main", "vgoto_if with a false condition falls through") +vm.ctx.comparisonResult = 2 +Ops.dispatch(vm, { op = "vgoto_if", [1] = 2, [2] = "std:1" }) +eq(vm.ctx.pc.listKey, "std:1", "...and jumps when it holds") +vm = new_vm() +vm:setPc("t_main", 4) +Ops.dispatch(vm, { op = "vcall", [1] = "std:1" }) +eq(vm.ctx.pc.listKey, "std:1", "vcall enters the script") +eq(#vm.ctx.stack, 1, "...pushing a return address") +Ops.dispatch(vm, { op = "returnram" }) +eq(vm.ctx.pc.listKey, "t_main", "returnram resumes the caller") +eq(vm.ctx.pc.index, 4, "...at the return site") +vm = new_vm() +Ops.dispatch(vm, { op = "vmessage", [1] = "std:1" }) +eq(vm.ctx.messageOpen, true, "vmessage opens the message box") +vm = new_vm() +Ops.dispatch(vm, { op = "vbuffermessage", [1] = "std:1" }) +eq(type(vm.ctx.stringVars[4]), "string", "vbuffermessage fills the gStringVar4 buffer") +vm = new_vm() +Ops.dispatch(vm, { op = "vbufferstring", [1] = 1, [2] = "std:1" }) +eq(type(vm.ctx.stringVars[2]), "string", "vbufferstring fills the requested string var") +vm = new_vm() +Ops.dispatch(vm, { op = "endram" }) +eq(vm.ctx.status, "shutdown", "endram stops the script") + +T.finish("game3_script_verbs_subset_test")