mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
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:
+9
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user