Files
gen1recomp/tests/engine/game3_script_verbs_subset_test.lua
T
Shane McGovern 2c356f28ea 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.
2026-09-22 03:28:39 +01:00

260 lines
12 KiB
Lua

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