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.
This commit is contained in:
Shane McGovern
2026-09-22 03:28:39 +01:00
parent a5f55d3498
commit 2c356f28ea
38 changed files with 1847 additions and 36 deletions
+9 -1
View File
@@ -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()
+18 -2
View File
@@ -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
+8 -2
View File
@@ -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
+2 -1
View File
@@ -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
+29
View File
@@ -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
+7 -2
View File
@@ -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,
+8 -1
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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
+2 -1
View File
@@ -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
+29
View File
@@ -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 {},
+11 -5
View File
@@ -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),
+295 -1
View File
@@ -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).
+7 -1
View File
@@ -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"
+15
View File
@@ -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
+6 -1
View File
@@ -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
+16 -4
View File
@@ -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 {}
+4 -1
View File
@@ -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)
+26 -10
View File
@@ -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)
+2 -1
View File
@@ -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
+13 -2
View File
@@ -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()
@@ -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 <save dir>/data/generated/<name>.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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
@@ -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")
+51
View File
@@ -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")
@@ -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")