new launcher and save converts and pipeline

This commit is contained in:
bryanthaboi
2026-07-25 08:34:49 -04:00
parent 6625391f76
commit 9307488dc1
58 changed files with 10971 additions and 348 deletions
+193
View File
@@ -0,0 +1,193 @@
-- Love-free coverage for the pure halves of src/mods/LauncherMods.lua: the
-- status derivation (deriveList) over a synthetic manifest list + options
-- table, and the archive-root location logic (locateRoot). The discovery and
-- installZip paths need love.filesystem and are exercised by the launcher; the
-- decision logic under them lives here so a bad range/conflict/root call fails
-- one line instead of the app.
-- luajit tests/engine/launcher_mods_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Manifest = require("src.mods.Manifest")
local Version = require("src.core.Version")
local LauncherMods = require("src.mods.LauncherMods")
-- validated manifests are the exact shape deriveList/resolveToggle read
local function mf(raw)
return Manifest.validate(raw)
end
-- index a deriveList result by mod id for assertions
local function byId(list)
local m = {}
for _, row in ipairs(list) do m[row.id] = row end
return m
end
-- ------- badge derivation: category, then profile, then MOD (uppercased)
do
local list = LauncherMods.deriveList({
mf({ id = "cat", name = "Cat Mod", version = "1.0.0", entry = "m.lua",
category = "gameplay" }),
mf({ id = "prof", name = "Prof Mod", version = "1.0.0", entry = "m.lua",
profile = "overhaul" }),
mf({ id = "plain", name = "Plain", version = "1.0.0", entry = "m.lua" }),
}, { mods = {} })
local m = byId(list)
eq(m.cat.badge, "GAMEPLAY", "badge uses the manifest category, uppercased")
eq(m.prof.badge, "OVERHAUL", "badge falls back to the profile when no category")
-- no category field, so the fallback reaches the profile default ("content")
eq(m.plain.badge, "CONTENT", "bare manifest badge falls back to the profile")
eq(#list, 3, "every discovered manifest yields one row")
check(m.cat.id < m.plain.id and m.plain.id < m.prof.id,
"rows come back sorted by id (cat < plain < prof)")
end
-- ------- enabled defaults to true; a false entry disables
do
local manifests = {
mf({ id = "aaa", name = "A", version = "1.0.0", entry = "m.lua" }),
mf({ id = "bbb", name = "B", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
check(not m.bbb.enabled, "an explicit false disables the mod")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end
-- ------- conflict: only when this mod is enabled and the other is too
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" } }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
-- both enabled: the declaring side (and, symmetrically, the other) conflict
local both = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(both.alpha.status, "conflict", "enabled mod conflicting with an enabled mod")
check(both.alpha.statusDetail:find("Beta", 1, true) ~= nil,
"conflict detail names the other mod")
eq(both.beta.status, "conflict",
"resolveToggle conflict is bidirectional: the target is flagged too")
-- disable beta: alpha no longer conflicts (nothing enabled to conflict with)
local off = byId(LauncherMods.deriveList(manifests, { mods = { beta = false } }))
eq(off.alpha.status, "ok", "no conflict once the other side is disabled")
eq(off.beta.status, "ok", "a disabled mod is never a conflict")
end
-- ------- warn: unsatisfied game_version range against Version.engine
do
-- a range the -dev engine cannot satisfy (needs a released >=1.0.0)
local manifests = {
mf({ id = "future", name = "Future", version = "1.0.0", entry = "m.lua",
game_version = ">=1.0.0" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.future.status, "warn", "engine outside the game_version range warns")
check(m.future.statusDetail:find(">=1.0.0", 1, true) ~= nil,
"version warn detail quotes the required range")
check(m.future.statusDetail:find(Version.engine, 1, true) ~= nil,
"version warn detail quotes the engine version")
end
-- ------- warn: hard dependency missing, disabled, or wrong version
do
local base = { id = "base", name = "Base", version = "1.0.0", entry = "m.lua" }
local needsMissing = { id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "ghost" } }
local m = byId(LauncherMods.deriveList({ mf(needsMissing) }, { mods = {} }))
eq(m.needy.status, "warn", "a missing hard dependency warns")
check(m.needy.statusDetail:find("not installed", 1, true) ~= nil,
"missing-dep detail says not installed")
-- present but disabled
local m2 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base" } }) },
{ mods = { base = false } }))
eq(m2.needy.status, "warn", "a disabled hard dependency warns")
check(m2.needy.statusDetail:find("disabled", 1, true) ~= nil,
"disabled-dep detail says disabled")
-- present, enabled, but the version is out of range
local m3 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=2.0.0" } }) },
{ mods = {} }))
eq(m3.needy.status, "warn", "a dependency below the required range warns")
eq(m3.base.status, "ok", "the satisfied dependency itself stays ok")
-- the same dep satisfied: needy is ok
local m4 = byId(LauncherMods.deriveList(
{ mf(base), mf({ id = "needy", name = "Needy", version = "1.0.0",
entry = "m.lua", dependencies = { "base@>=1.0.0" } }) },
{ mods = {} }))
eq(m4.needy.status, "ok", "a satisfied dependency clears the warn")
end
-- ------- conflict outranks warn when a mod trips both
do
local manifests = {
mf({ id = "alpha", name = "Alpha", version = "1.0.0", entry = "m.lua",
conflicts = { "beta" }, game_version = ">=1.0.0" }),
mf({ id = "beta", name = "Beta", version = "1.0.0", entry = "m.lua" }),
}
local m = byId(LauncherMods.deriveList(manifests, { mods = {} }))
eq(m.alpha.status, "conflict",
"conflict is reported ahead of a version warn on the same mod")
end
-- ------- locateRoot: manifest at the archive root
do
local root, err = LauncherMods.locateRoot({ "manifest.json", "main.lua" })
eq(root, "", "a root-level manifest.json resolves to the empty prefix")
eq(err, nil, "no error for a root-level manifest")
end
-- ------- locateRoot: manifest inside a single top-level folder
do
local root = LauncherMods.locateRoot({
"mymod/manifest.json", "mymod/main.lua", "mymod/assets/x.png" })
eq(root, "mymod", "a single wrapping folder resolves to that folder name")
end
-- ------- locateRoot: no manifest anywhere
do
local root, err = LauncherMods.locateRoot({ "readme.txt", "stuff/x.lua" })
eq(root, nil, "an archive with no manifest.json resolves to nil")
check(err:find("no manifest.json", 1, true) ~= nil,
"the no-manifest reason is user-presentable")
end
-- ------- locateRoot: multiple top-level folders is ambiguous
do
local root, err = LauncherMods.locateRoot({
"one/manifest.json", "two/manifest.json" })
eq(root, nil, "two candidate mod folders resolves to nil")
check(err:find("single mod folder", 1, true) ~= nil,
"the ambiguous reason asks for a single mod folder")
end
-- ------- locateRoot: a lone folder without a manifest is not a root
do
local root, err = LauncherMods.locateRoot({ "assets/x.png" })
eq(root, nil, "a single folder with no manifest is not a mod root")
check(err ~= nil, "the no-root case carries a reason")
end
T.finish("launcher_mods")
+233
View File
@@ -0,0 +1,233 @@
-- Launcher save Import/Export glue (src/import/SaveFileIO.lua): the end-to-end
-- importToSlot -> listSlots roundtrip and the exportActiveSlot output-byte
-- sanity check, driven love-free through the same in-memory filesystem stub
-- tests/engine/save_slots.lua uses. A synthetic 32KB SRAM image is built via
-- GenSave.encode (no real save checked in); a fixture-gated case exercises the
-- real .sav when POKEPORT_SAV_FIXTURE points at one.
-- luajit tests/engine/save_file_io_tests.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 GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = require("src.import.SaveFileIO")
local realFS = love.filesystem
-- A love.filesystem stub keyed by full path, extended past save_slots' memfs
-- with the export surface SaveFileIO reaches for (createDirectory /
-- getSaveDirectory). A directory key is implied by any file under it.
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
createDirectory = function() return true end,
getSaveDirectory = function() return "/fake/save" end,
}
end
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do)
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- independent checksum re-derivation (complement of the additive byte sum) so
-- the export sanity check does not trust the encoder that wrote it
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function mainChecksumValid(bytes)
return rawChecksum(bytes, OFF.checksumStart, OFF.checksumEnd)
== bytes:byte(OFF.mainChecksum + 1)
end
local function syntheticSave(name)
local seed = SaveData.newGame({ playerName = name, rivalName = "BLUE" })
seed.money = 4321
seed.inventory = { POTION = 2, POKE_BALL = 7, BOULDERBADGE = 1 }
seed.bagOrder = { "POTION", "POKE_BALL" }
seed.party = { {
species = "SQUIRTLE", level = 6, exp = 200,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 },
hp = 22, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
} }
return GenSave.encode(seed, data, nil)
end
-- ---------------------------------------------- importToSlot -> listSlots
do
fresh()
local bytes = syntheticSave("IMP")
eq(#bytes, GenSave.SAVE_SIZE, "the synthetic save is 32768 bytes")
local ok, slotId = SaveFileIO.importToSlot(bytes, "red")
eq(ok, true, "importToSlot succeeds on a valid 32KB save")
eq(slotId, "slot1", "the first import registers slot1")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "the imported save shows up as exactly one slot")
eq(slots[1].id, "slot1", "the listed slot is slot1")
eq(slots[1].exists, true, "the imported slot reports a save present")
eq(slots[1].name, "IMP", "the imported slot surfaces the decoded player name")
eq(SaveData.activeSlot("red"), "slot1", "the imported slot is made active")
-- the slot loads cleanly (meta re-stamped from gen1_import to the numeric
-- format, so runMigrations does not choke)
local loaded = SaveData.load("red")
check(loaded ~= nil, "the imported slot loads back")
eq(loaded and loaded.player.name, "IMP", "loaded save keeps the player name")
eq(loaded and loaded.money, 4321, "loaded save keeps the money")
eq(loaded and #loaded.party, 1, "loaded save keeps the party")
-- a second import allocates a fresh slot and makes it active
local ok2, slot2 = SaveFileIO.importToSlot(syntheticSave("TWO"), "red")
eq(ok2, true, "a second import succeeds")
eq(slot2, "slot2", "the second import allocates slot2")
eq(#SaveData.listSlots("red"), 2, "both imported slots are listed")
eq(SaveData.activeSlot("red"), "slot2", "the newest import becomes active")
end
-- ---------------------------------------------- exportActiveSlot byte sanity
do
local files = fresh()
SaveFileIO.importToSlot(syntheticSave("EXP"), "red")
local ok, path = SaveFileIO.exportActiveSlot("red")
eq(ok, true, "exportActiveSlot succeeds for an active slot with a save")
eq(path, "/fake/save/exports/gen1recomp-red-slot1.sav",
"the export path is absolute and names the version + slot")
local outBytes = files["exports/gen1recomp-red-slot1.sav"]
check(outBytes ~= nil, "the export file lands in the save-dir exports/ folder")
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"the export carries a valid main-data checksum")
-- the export re-imports to an equivalent save
local re = SaveConvert.importSav(outBytes, "red")
check(re ~= nil, "the export re-imports through SaveConvert")
eq(re and re.player and re.player.name, "EXP", "the export round-trips the player name")
eq(re and re.party[1] and re.party[1].species, "SQUIRTLE",
"the export round-trips the party")
eq(re and re.inventory and re.inventory.BOULDERBADGE, 1,
"the export round-trips a badge")
end
-- ---------------------------------------------- failure UX (never raises)
do
fresh()
-- wrong size via a DroppedFile-shaped source (100 bytes)
local shortFile = {
_bytes = string.rep("\0", 100),
open = function() return true end,
getSize = function(self) return #self._bytes end,
read = function(self) return self._bytes end,
close = function() return true end,
}
local ok, err = SaveFileIO.importToSlot(shortFile, "red")
eq(ok, false, "a wrong-size save is rejected, not imported")
check(type(err) == "string" and err:find("32", 1, true) ~= nil,
"the wrong-size error names the required size")
eq(#SaveData.listSlots("red"), 0, "a rejected import creates no slot")
-- bad checksum: flip a modeled byte in an otherwise valid image
local good = syntheticSave("BAD")
local corrupt = good:sub(1, OFF.money)
.. string.char((good:byte(OFF.money + 1) + 1) % 256)
.. good:sub(OFF.money + 2)
local okc, errc = SaveFileIO.importToSlot(corrupt, "red")
eq(okc, false, "a bad-checksum save is rejected")
check(type(errc) == "string" and errc:find("checksum", 1, true) ~= nil,
"the bad-checksum error mentions the checksum")
eq(#SaveData.listSlots("red"), 0, "a rejected checksum creates no slot")
-- export with nothing to export
local oke, erre = SaveFileIO.exportActiveSlot("red")
eq(oke, false, "exportActiveSlot fails cleanly when there is no save")
check(type(erre) == "string", "the empty-export failure carries a message")
end
-- ---------------------------------------------- fixture-gated real save
do
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("save_file_io fixture case skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav)")
else
local files = fresh()
local ok, slotId = SaveFileIO.importToSlot(fixtureBytes, "red")
eq(ok, true, "fixture: a real .sav imports to a slot")
check(slotId ~= nil, "fixture: the import returns a slot id")
local slots = SaveData.listSlots("red")
eq(#slots, 1, "fixture: the real save shows as one slot")
check(slots[1].exists and type(slots[1].name) == "string" and #slots[1].name > 0,
"fixture: the imported slot has a non-empty player name")
local loaded = SaveData.load("red")
check(loaded ~= nil and #loaded.party >= 1 and #loaded.party <= 6,
"fixture: the imported slot loads with a 1..6 party")
local eok, path = SaveFileIO.exportActiveSlot("red")
eq(eok, true, "fixture: the imported real save exports")
local rel = path:gsub("^/fake/save/", "")
local outBytes = files[rel]
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "fixture: the export is 32768 bytes")
check(outBytes and mainChecksumValid(outBytes),
"fixture: the export has a valid main-data checksum")
end
end
love.filesystem = realFS
T.finish("save_file_io")
+211
View File
@@ -0,0 +1,211 @@
-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot
-- registry in options.lua, listSlots/setActiveSlot/createSlot, and the
-- active-slot resolution behind saveNames/save/load. Self-contained: it
-- installs the love stub only for a swappable in-memory filesystem, the
-- same way tests/mod_save_tests isolates its save round-trips.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
-- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua"
-- needs no directory support (createDirectory is deliberately absent, which
-- is exactly what the ensureParentDir no-op path handles)
local function memfs(files)
return {
files = files,
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
return nil
end,
}
end
-- a fresh filesystem + cleared process globals: each scenario is a first boot
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
-- a minimal but fully decodable Red save
local function legacySave(name, dexOwned, badges, playTime)
local owned = {}
for _, id in ipairs(dexOwned or {}) do owned[id] = true end
local inv = {}
for _, id in ipairs(badges or {}) do inv[id] = true end
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = owned },
inventory = inv,
playTime = playTime or 0,
}
end
-- ---------------------------------------------- slotSummary (pure)
do
local name, meta = SaveData.slotSummary(
legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" },
{ "BOULDERBADGE", "CASCADEBADGE" }, 3661))
T.eq(name, "ASH", "slotSummary reads the player name")
T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries")
T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory")
T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM")
local n2, m2 = SaveData.slotSummary(nil)
T.eq(n2, nil, "slotSummary of an empty slot has no name")
T.eq(m2, nil, "slotSummary of an empty slot has no meta")
end
-- ---------------------------------------------- legacy migration happy path
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325))
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "legacy save migrates into exactly one slot")
T.eq(slots[1].id, "slot1", "the migrated slot is slot1")
T.eq(slots[1].exists, true, "the migrated slot reports a save present")
T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name")
T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count")
T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count")
T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time")
T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration")
T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active")
T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot")
-- load() now resolves the active slot and reads the migrated save
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "RED", "load reads the active slot")
end
-- ---------------------------------------------- migration idempotence
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0))
SaveData.listSlots("red") -- first boot: migrates
local slotBytes = files["saves/red/slot1.lua"]
-- a second boot: registry exists, no flat file, so nothing re-migrates
SaveData.resetSlotState()
local slots = SaveData.listSlots("red")
T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot")
T.eq(files["save.lua"], nil, "no flat file reappears on re-boot")
T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot")
end
-- ---------------------------------------------- mixed real / empty slots
do
local files = fresh()
files["save.lua"] = SaveSerializer.encode(
legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60))
SaveData.listSlots("red") -- slot1 = the migrated real save
local empty = SaveData.createSlot("red")
T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1")
local slots = SaveData.listSlots("red")
T.eq(#slots, 2, "both the real and empty slots are listed")
T.eq(slots[1].exists, true, "the migrated slot still reports a save")
T.eq(slots[1].name, "REAL", "the real slot keeps its name")
T.eq(slots[2].exists, false, "the freshly created slot is empty")
T.eq(slots[2].name, nil, "an empty slot has no name")
T.eq(slots[2].meta, nil, "an empty slot has no meta")
end
-- ---------------------------------------------- setActiveSlot persistence
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id")
T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved")
T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list")
-- selecting a slot that was never registered adds it
SaveData.setActiveSlot("red", "slot7")
opts = SaveSerializer.decode(files["options.lua"])
T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added")
T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list")
end
-- ---------------------------------------------- createSlot id allocation
do
fresh()
T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1")
T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2")
T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest")
end
-- ---------------------------------------------- saveNames follows the slot
do
local files = fresh()
SaveData.createSlot("red") -- slot1
SaveData.createSlot("red") -- slot2
SaveData.setActiveSlot("red", "slot2")
local save = SaveData.newGame()
save.player.name = "SLOT2"
T.check(SaveData.save(save), "save writes to the active slot")
T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file")
T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save")
T.eq(files["save.lua"], nil, "no flat file is written once a slot is active")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2")
end
-- ---------------------------------------------- a version with no slots
do
local files = fresh()
local slots = SaveData.listSlots("red")
T.eq(#slots, 0, "a fresh install with no legacy save lists no slots")
-- with nothing registered, save/load use the flat legacy path, exactly
-- as they did before slots existed
local save = SaveData.newGame()
save.player.name = "FLAT"
T.check(SaveData.save(save), "a slotless version saves to the flat file")
T.check(files["save.lua"] ~= nil, "the flat save.lua is written")
T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file")
T.eq(SaveData.saveFilename("red"), "save.lua",
"saveFilename still resolves the flat name with no slot in use")
end
love.filesystem = realFS
T.finish("save_slots")
+65
View File
@@ -0,0 +1,65 @@
-- Pure-surface coverage for src/update/Check.lua (the self-update release
-- check / payload download module). The network, hashing and archive-probe
-- logic lives in src/update/check_worker.lua and needs love + curl; these are
-- the love-free extraction/parsing seams the worker and UI both trust.
-- luajit tests/engine/update_check_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- releaseUrl is the fixed public landing page the UI links on needs_full
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
-- parseRelease: a well-formed release with the .love payload and its sums
local body = Json.encode({
tag_name = "v1.4.2",
assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
},
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "leading v stripped from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version")
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "version parsed without assets")
eq(noPayload.payload, nil, "no payload asset -> nil")
eq(noPayload.sums, nil, "no sums asset -> nil")
-- rejects: non-semver tag, and a document with no tag at all
local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(bad, nil, "non-X.Y.Z tag rejected")
check(badErr ~= nil, "rejection carries an error string")
eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected")
-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './'
-- prefix and CRLF line endings; unrelated lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"not a checksum line\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed")
eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered")
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash")
eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly")
-- pickAsset guards a non-table assets field
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
T.finish("update_check")
+300
View File
@@ -0,0 +1,300 @@
-- Pure-logic coverage for the self-updater (src/update/*). Every export
-- exercised here is love-free: Semver's parse/compare, Boot's select()
-- decision function, and Check's release-JSON / sha256sums / asset parsers.
-- The love-bound halves (Boot.run's mount+chainload, Check's thread worker,
-- curl, hashing) need a real LOVE process and are covered elsewhere; this
-- suite is the plain-Lua seam the whole updater trusts.
-- luajit tests/engine/update_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Semver = require("src.update.Semver")
local Boot = require("src.update.Boot")
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- ---------------------------------------------------------------------------
-- Semver.parse
-- ---------------------------------------------------------------------------
-- valid triples decode to numeric fields, not strings
local p = Semver.parse("1.2.3")
check(p ~= nil, "parse accepts a plain X.Y.Z")
eq(p.major, 1, "parse major")
eq(p.minor, 2, "parse minor")
eq(p.patch, 3, "parse patch")
eq(type(p.major), "number", "parse yields numbers, not strings")
local zero = Semver.parse("0.0.0")
eq(zero.major, 0, "parse zeros: major")
eq(zero.patch, 0, "parse zeros: patch")
local big = Semver.parse("10.20.30")
eq(big.major, 10, "parse multi-digit major")
eq(big.minor, 20, "parse multi-digit minor")
eq(big.patch, 30, "parse multi-digit patch")
-- an optional leading lowercase "v" is stripped
local v = Semver.parse("v2.5.9")
check(v ~= nil, "parse accepts a leading v")
eq(v.major, 2, "leading v: major")
eq(v.minor, 5, "leading v: minor")
eq(v.patch, 9, "leading v: patch")
-- rejects: partial versions, extra components, non-numeric parts, suffixes,
-- a bare v, whitespace, empties, and non-string inputs -- all return nil, not
-- a raise (the safe answer for the updater is "not a real version")
eq(Semver.parse("1.2"), nil, "parse rejects a two-part version")
eq(Semver.parse("1"), nil, "parse rejects a one-part version")
eq(Semver.parse("1.2.3.4"), nil, "parse rejects a four-part version")
eq(Semver.parse("1.2.x"), nil, "parse rejects a non-numeric part")
eq(Semver.parse("1.2.3-dev"), nil, "parse rejects a pre-release suffix")
eq(Semver.parse("0.0.0-dev"), nil, "parse rejects the working-tree placeholder")
eq(Semver.parse("v"), nil, "parse rejects a bare v")
eq(Semver.parse(" 1.2.3"), nil, "parse rejects leading whitespace (anchored)")
eq(Semver.parse("1.2.3 "), nil, "parse rejects trailing whitespace (anchored)")
eq(Semver.parse(""), nil, "parse rejects the empty string")
eq(Semver.parse("nightly"), nil, "parse rejects a non-numeric tag")
eq(Semver.parse(nil), nil, "parse rejects nil")
eq(Semver.parse(123), nil, "parse rejects a number")
eq(Semver.parse({}), nil, "parse rejects a table")
-- ---------------------------------------------------------------------------
-- Semver.compare
-- ---------------------------------------------------------------------------
-- ordering is major, then minor, then patch
eq(Semver.compare("2.0.0", "1.9.9"), 1, "compare: major dominates (a > b)")
eq(Semver.compare("1.0.0", "2.0.0"), -1, "compare: major dominates (a < b)")
eq(Semver.compare("1.2.0", "1.1.9"), 1, "compare: minor breaks a major tie (a > b)")
eq(Semver.compare("1.1.0", "1.2.0"), -1, "compare: minor breaks a major tie (a < b)")
eq(Semver.compare("1.1.2", "1.1.1"), 1, "compare: patch breaks a minor tie (a > b)")
eq(Semver.compare("1.1.1", "1.1.2"), -1, "compare: patch breaks a minor tie (a < b)")
-- equality
eq(Semver.compare("1.2.3", "1.2.3"), 0, "compare: identical versions are equal")
eq(Semver.compare("v1.2.3", "1.2.3"), 0, "compare: leading v does not change value")
-- string and already-parsed-table inputs interoperate on either side
eq(Semver.compare(Semver.parse("1.2.3"), "1.2.4"), -1, "compare: parsed-table a vs string b")
eq(Semver.compare("1.3.0", Semver.parse("1.2.9")), 1, "compare: string a vs parsed-table b")
eq(Semver.compare({ major = 2, minor = 0, patch = 0 },
{ major = 1, minor = 9, patch = 9 }), 1, "compare: raw tables on both sides")
eq(Semver.compare(Semver.parse("4.4.4"), Semver.parse("4.4.4")), 0, "compare: equal parsed tables")
-- an unparseable side sorts as the lowest possible version, so a bogus value
-- never wins a "newer" test; two unparseable sides are equal
eq(Semver.compare("garbage", "1.0.0"), -1, "compare: unparseable a loses to a real version")
eq(Semver.compare("1.0.0", "garbage"), 1, "compare: a real version beats an unparseable b")
eq(Semver.compare("garbage", "junk"), 0, "compare: two unparseable sides are equal")
eq(Semver.compare(nil, "1.0.0"), -1, "compare: nil a sorts lowest")
eq(Semver.compare("1.0.0", nil), 1, "compare: nil b sorts lowest")
-- ---------------------------------------------------------------------------
-- Boot.select (pure: no love.*)
-- ---------------------------------------------------------------------------
-- membership helper: toDelete order is deterministic, but assert on the set so
-- the tests document intent rather than iteration accidents
local function nameSet(list)
local s = {}
for _, n in ipairs(list) do s[n] = true end
return s
end
-- empty candidate list: nothing to run, nothing to delete
do
local chosen, del = Boot.select({}, "1.0.0", 1)
eq(chosen, nil, "select: empty candidate list picks nothing")
eq(#del, 0, "select: empty candidate list deletes nothing")
end
-- picks the highest eligible payload and marks the lower runnable ones (still
-- newer than bundled, but superseded by the winner) for deletion
do
local candidates = {
{ name = "a.love", engine = "1.1.0" }, -- no minShell -> defaults to 1
{ name = "b.love", engine = "1.3.0", minShell = 1 },
{ name = "c.love", engine = "1.2.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "b.love", "select: picks the highest eligible engine")
local d = nameSet(del)
eq(#del, 2, "select: both losers are marked for deletion")
check(d["a.love"] and d["c.love"], "select: superseded runnable payloads are deleted")
check(not d["b.love"], "select: the chosen payload is never deleted")
end
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
-- otherwise-newer one for a future shell upgrade instead of deleting it
do
local candidates = {
{ name = "future.love", engine = "2.0.0", minShell = 2 }, -- unrunnable at shell 1
{ name = "ok.love", engine = "1.5.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "ok.love", "select: skips a payload whose minShell exceeds the bundled shell")
eq(#del, 0, "select: a newer-but-unrunnable payload is kept, not deleted")
check(not nameSet(del)["future.love"], "select: unrunnable-newer payload survives")
end
-- skips payloads not strictly newer than bundled (older AND equal) and marks
-- them stale for deletion
do
local candidates = {
{ name = "old.love", engine = "0.9.0", minShell = 1 }, -- older than bundled
{ name = "same.love", engine = "1.0.0", minShell = 1 }, -- equal to bundled
{ name = "new.love", engine = "1.1.0", minShell = 1 }, -- the only real update
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, "new.love", "select: only a strictly-newer payload is eligible")
local d = nameSet(del)
eq(#del, 2, "select: older and equal payloads are both stale")
check(d["old.love"], "select: an older payload is deleted")
check(d["same.love"], "select: a same-version payload is deleted")
check(not d["new.love"], "select: the winner is not in the delete list")
end
-- no eligible payload at all (all older or equal): pick nothing, delete every
-- stale candidate
do
local candidates = {
{ name = "old.love", engine = "0.5.0", minShell = 1 },
{ name = "same.love", engine = "1.0.0", minShell = 1 },
}
local chosen, del = Boot.select(candidates, "1.0.0", 1)
eq(chosen, nil, "select: no strictly-newer payload -> nothing chosen")
eq(#del, 2, "select: every stale candidate is cleaned up when nothing wins")
end
-- the full mix in one pass: a superseded runnable one and a stale old one are
-- deleted; the chosen winner and a newer-but-unrunnable payload both survive
do
local candidates = {
{ name = "sup.love", engine = "1.2.0", minShell = 1 }, -- newer, runnable, < winner
{ name = "win.love", engine = "1.4.0", minShell = 1 }, -- the winner
{ name = "future.love", engine = "2.0.0", minShell = 5 }, -- newer than winner, unrunnable
{ name = "old.love", engine = "0.1.0", minShell = 1 }, -- stale
}
local chosen, del = Boot.select(candidates, "1.1.0", 1)
eq(chosen, "win.love", "select(mix): highest runnable-newer engine wins")
local d = nameSet(del)
eq(#del, 2, "select(mix): exactly the superseded and stale payloads are deleted")
check(d["sup.love"], "select(mix): a runnable payload below the winner is superseded")
check(d["old.love"], "select(mix): a stale payload is cleaned up")
check(not d["future.love"], "select(mix): a newer-but-unrunnable payload is kept")
check(not d["win.love"], "select(mix): the winner is kept")
end
-- ---------------------------------------------------------------------------
-- Check.pickAsset
-- ---------------------------------------------------------------------------
local assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
}
local picked = Check.pickAsset(assets, "gen1recomp-1.4.2.love")
check(picked ~= nil, "pickAsset finds an asset by exact name")
eq(picked.url, "http://x/love", "pickAsset returns the download url")
eq(picked.size, 12345, "pickAsset returns the numeric size")
eq(Check.pickAsset(assets, "does-not-exist.love"), nil, "pickAsset misses cleanly on an unknown name")
-- coerces a string size to a number and tolerates non-table junk entries mixed
-- into the asset list
local coerced = Check.pickAsset({ "junk", 42, { name = "w", browser_download_url = "U", size = "7" } }, "w")
eq(coerced.size, 7, "pickAsset coerces a string size to a number")
eq(type(coerced.size), "number", "pickAsset size is a number after coercion")
-- guards a non-table / nil assets field instead of raising
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
eq(Check.pickAsset("nope", "x"), nil, "pickAsset tolerates a non-table asset list")
-- ---------------------------------------------------------------------------
-- Check.parseRelease (release-JSON extraction)
-- ---------------------------------------------------------------------------
-- a well-formed release: version (leading v stripped), derived payload name,
-- and both the .love payload and its sums asset with url + size
local body = Json.encode({
tag_name = "v1.4.2",
assets = assets,
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "parseRelease strips the leading v from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "parseRelease derives the payload name from the version")
eq(rel.payload.url, "http://x/love", "parseRelease picks the payload asset url")
eq(rel.payload.size, 12345, "parseRelease picks the payload asset size")
eq(rel.sums.url, "http://x/sums", "parseRelease picks the sums asset url")
eq(rel.sums.size, 99, "parseRelease picks the sums asset size")
-- a release with no .love yet still parses; payload/sums are nil so the worker
-- routes to a full reinstall rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "parseRelease reads the version without any assets")
eq(noPayload.payload, nil, "parseRelease reports a missing payload asset as nil")
eq(noPayload.sums, nil, "parseRelease reports a missing sums asset as nil")
-- rejections carry an error string and never raise
local badTag, badTagErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(badTag, nil, "parseRelease rejects a non-X.Y.Z tag")
check(badTagErr ~= nil, "parseRelease rejection carries an error string")
local noTag, noTagErr = Check.parseRelease(Json.encode({ foo = 1 }))
eq(noTag, nil, "parseRelease rejects a document with no tag_name")
check(noTagErr ~= nil, "parseRelease missing-tag rejection carries an error string")
-- malformed input returns nil rather than raising (Json.decode yields nil, and
-- a bare non-object literal has no tag_name)
local ok1, garbage = pcall(Check.parseRelease, "this is not json {{{")
check(ok1, "parseRelease does not raise on unparseable JSON")
eq(garbage, nil, "parseRelease returns nil on unparseable JSON")
local ok2, empty = pcall(Check.parseRelease, "")
check(ok2, "parseRelease does not raise on empty input")
eq(empty, nil, "parseRelease returns nil on empty input")
local ok3, literal = pcall(Check.parseRelease, "42")
check(ok3, "parseRelease does not raise on a bare JSON literal")
eq(literal, nil, "parseRelease returns nil on a non-object JSON literal")
-- ---------------------------------------------------------------------------
-- Check.parseSums (shasum -a 256 line parsing)
-- ---------------------------------------------------------------------------
-- standard "<hex> <file>" lines, the '*' binary marker, a './' prefix, CRLF
-- endings and mixed hash case; junk lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"deadBEEF ./nested.love\n" ..
"not a checksum line at all\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "parseSums reads a bare-name line")
eq(map["sha256sums.txt"], "bbbb2222", "parseSums strips the * marker and ./ prefix and lowercases")
eq(map["nested.love"], "deadbeef", "parseSums lowercases a mixed-case hash and strips ./")
eq(map["not a checksum line at all"], nil, "parseSums skips lines that are not checksums")
-- the target form returns just that file's hash (hit / miss)
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "parseSums(target) returns the matching hash")
eq(Check.parseSums(sums, "missing.love"), nil, "parseSums(target) misses cleanly on an unknown file")
-- degenerate inputs: empty text yields an empty map, a targeted miss is nil,
-- and a nil text does not raise
local emptyMap = Check.parseSums("")
eq(type(emptyMap), "table", "parseSums('') returns an (empty) table")
eq(next(emptyMap), nil, "parseSums('') has no entries")
eq(Check.parseSums(nil, "anything"), nil, "parseSums(nil, target) returns nil without raising")
-- ---------------------------------------------------------------------------
-- Check.releaseUrl (the fixed public landing page)
-- ---------------------------------------------------------------------------
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest",
"releaseUrl points at the repo's latest release")
T.finish("update")
+40 -5
View File
@@ -28,6 +28,17 @@ end
local files = {} -- in-memory love.filesystem
-- Minimal graphics-state tracking so push("all")/pop actually save and
-- restore, and getShader/getCanvas/etc can be read back. The render-pipeline
-- fold fences each mod callback between push("all")/pop so a callback that
-- dirties state cannot leak into the engine composite; mod_render_tests
-- asserts exactly that, which needs the stub to model the save/restore rather
-- than no-op it. Plain push()/pop() (the tilt upright pass) ride the same
-- stack and restore the same fields, which for those call sites is a no-op.
local gstate = { shader = nil, canvas = nil, blend = "alpha",
color = { 1, 1, 1, 1 } }
local gstack = {}
stub.graphics = {
newImage = function(path)
local w, h = pngSize(path)
@@ -44,13 +55,37 @@ stub.graphics = {
function batch:setTexture(tex) self.texture = tex end
return batch
end,
draw = noop, rectangle = noop, setColor = noop, clear = noop,
setCanvas = noop, setDefaultFilter = noop, print = noop,
draw = noop, rectangle = noop, clear = noop,
setDefaultFilter = noop, print = noop,
setColor = function(r, g, b, a) gstate.color = { r, g, b, a } end,
getColor = function()
local c = gstate.color
return c[1], c[2], c[3], c[4]
end,
setCanvas = function(c) gstate.canvas = c or nil end,
getCanvas = function() return gstate.canvas end,
setShader = function(s) gstate.shader = s or nil end,
getShader = function() return gstate.shader end,
setBlendMode = function(m) gstate.blend = m or "alpha" end,
getBlendMode = function() return gstate.blend end,
-- coordinate-transform + state stack used by the tilt-mode upright pass
-- (billboards); plain no-ops here (tests that need to observe them swap
-- (billboards) and the render-pipeline fold; push snapshots the tracked
-- state, pop restores it (tests that need to observe the transforms swap
-- in their own recorders, e.g. tests/parity_tilt.lua)
push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
push = function()
gstack[#gstack + 1] = { shader = gstate.shader, canvas = gstate.canvas,
blend = gstate.blend, color = gstate.color }
end,
pop = function()
local s = gstack[#gstack]
if s then
gstack[#gstack] = nil
gstate.shader, gstate.canvas = s.shader, s.canvas
gstate.blend, gstate.color = s.blend, s.color
end
end,
translate = noop, scale = noop,
rotate = noop, origin = noop, setScissor = noop,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
+250
View File
@@ -0,0 +1,250 @@
-- The battle-sprite-scale seam, exercised through the public mod API.
--
-- Modders scale battle pics per species (pokemon.battleScaleFront /
-- battleScaleBack) or per image path (the battle_sprite_scales registry,
-- the only handle on non-species pics like the trainer back). The
-- properties worth pinning: the schema rejects out-of-range scales and a
-- pathless record, image-level beats species-level beats the vanilla
-- default, and above all the pic stays GROUNDED -- feet on the text-box
-- top, bottom edge in its slot -- at every scale and through the send-out
-- grow. The placement math and the scale resolver are pure (no love.*),
-- so the grounding contract is asserted directly.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Loader = require("src.mods.Loader")
local Schemas = require("src.mods.Schemas")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("mod battle scale")
local check, eq = S.check, S.eq
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
end
local function manifest(id, extra)
return ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' ..
'"entry":"main.lua"%s}'):format(id, id, extra or "")
end
-- a minimal, internally consistent base so a pokemon patch has something
-- to fold onto; the cross-reference pass skips registries no mod touched,
-- so the untouched type/move refs on these records never surface
local function baseData()
return {
pokemon = {
PIKACHU = { id = "PIKACHU", name = "PIKACHU", dex = 25,
types = { "ELECTRIC" },
baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, special = 50 },
catchRate = 190, baseExp = 82, level1Moves = { "THUNDERSHOCK" },
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
spriteFront = "pikachu_front.png", spriteBack = "pikachu_back.png",
frontSize = 5 },
RAICHU = { id = "RAICHU", name = "RAICHU", dex = 26,
types = { "ELECTRIC" },
baseStats = { hp = 60, attack = 90, defense = 55, speed = 110, special = 90 },
catchRate = 75, baseExp = 122, level1Moves = { "THUNDERSHOCK" },
growthRate = "MEDIUM_FAST", learnset = {}, evolutions = {},
spriteFront = "raichu_front.png", spriteBack = "raichu_back.png",
frontSize = 6 },
},
moves = {
THUNDERSHOCK = { id = "THUNDERSHOCK", name = "THUNDERSHOCK",
type = "ELECTRIC", power = 40, accuracy = 100, pp = 30,
effect = "PARALYZE_SIDE_EFFECT1" },
},
}
end
-- ------- schema: the battle_sprite_scales registry
do
local spec = Schemas.REGISTRIES.battle_sprite_scales
check(spec ~= nil, "the battle_sprite_scales registry is in the catalog")
eq(spec.semantics, "record", "battle_sprite_scales merges as records")
eq(spec.target, "battle_sprite_scales",
"battle_sprite_scales writes to its own namespace")
local ok = Schemas.check(spec, "battle_sprite_scales", "abra_back",
{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 }, "register")
check(ok, "a path + in-range scale validates")
-- the boundaries are inclusive
check(Schemas.check(spec, "battle_sprite_scales", "lo",
{ path = "x.png", scale = 0.25 }, "register"), "scale 0.25 is accepted")
check(Schemas.check(spec, "battle_sprite_scales", "hi",
{ path = "x.png", scale = 4.0 }, "register"), "scale 4.0 is accepted")
local tooBig, bigErr = Schemas.check(spec, "battle_sprite_scales", "big",
{ path = "x.png", scale = 5 }, "register")
check(not tooBig, "a scale above 4.0 is rejected")
check(tostring(bigErr):find("0.25", 1, true) ~= nil,
"the rejection names the range it wanted: " .. tostring(bigErr))
check(not Schemas.check(spec, "battle_sprite_scales", "small",
{ path = "x.png", scale = 0.1 }, "register"),
"a scale below 0.25 is rejected")
local noPath, pathErr = Schemas.check(spec, "battle_sprite_scales", "nopath",
{ scale = 1 }, "register")
check(not noPath, "a record with no path is rejected")
check(tostring(pathErr):find("path", 1, true) ~= nil,
"the rejection names the missing path: " .. tostring(pathErr))
check(not Schemas.check(spec, "battle_sprite_scales", "emptypath",
{ path = "", scale = 1 }, "register"), "an empty path is rejected")
end
-- ------- schema: the per-species scale fields
do
local spec = Schemas.REGISTRIES.pokemon
check(Schemas.check(spec, "pokemon", "PIKACHU",
{ battleScaleBack = 3, battleScaleFront = 0.5 }, "patch"),
"in-range species scale overrides validate as a patch")
local bad, err = Schemas.check(spec, "pokemon", "PIKACHU",
{ battleScaleBack = 5 }, "patch")
check(not bad, "an out-of-range species scale is rejected")
check(tostring(err):find("battleScaleBack", 1, true) ~= nil,
"the rejection names the field: " .. tostring(err))
end
-- ------- the full merge: a mod patches a species and registers an image
local FILES = {
["mods/biggun/manifest.json"] = manifest("biggun"),
["mods/biggun/main.lua"] = [[
local mod = ...
-- species-level: PIKACHU's back pic at 3x
mod.content.pokemon:patch("PIKACHU", { battleScaleBack = 3 })
-- image-level, keyed by path: overrides the species scale for this pic
mod.content.battle_sprite_scales:register("pika_back", {
path = "pikachu_back.png", scale = 1.25,
})
-- and a bare, non-species pic (a trainer back) reachable only here
mod.content.battle_sprite_scales:register("hero_back", {
path = "assets/generated/battle/back/redb.png", scale = 1.5,
})
]],
}
local data = baseData()
local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the scale mod loads clean: " .. table.concat(loader.errors, "; "))
eq(data.pokemon.PIKACHU.battleScaleBack, 3,
"the species patch reached the merged data")
check(type(data.battle_sprite_scales) == "table",
"the merge created the battle_sprite_scales namespace")
-- image-level beats species-level for the same pic
eq(BattleState.resolveBattleScale(data, "back", "pikachu_back.png", "PIKACHU"),
1.25, "an image-level entry overrides the species scale for its path")
-- a different pic of the same species falls through to the species scale
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "PIKACHU"),
3, "a species with an override but no image entry uses the species scale")
-- the non-species trainer back is reachable only by path
eq(BattleState.resolveBattleScale(data, "back",
"assets/generated/battle/back/redb.png", nil),
1.5, "a bare pic is scaled by its image-level entry with no species")
-- an unregistered species, unregistered path: the vanilla side defaults
eq(BattleState.resolveBattleScale(data, "front", "raichu_front.png", "RAICHU"),
1, "enemy front defaults to 1x when nothing is registered")
eq(BattleState.resolveBattleScale(data, "back", "raichu_back.png", "RAICHU"),
2, "player back defaults to 2x when nothing is registered")
-- ------- default unchanged with no registry at all
do
local bare = { pokemon = { PIKACHU = {} } }
eq(BattleState.resolveBattleScale(bare, "front", "any.png", "PIKACHU"), 1,
"front default holds with no battle_sprite_scales table")
eq(BattleState.resolveBattleScale(bare, "back", "any.png", "PIKACHU"), 2,
"back default holds with no battle_sprite_scales table")
eq(BattleState.resolveBattleScale({}, "back", nil, nil), 2,
"back default holds with empty data and no path or species")
end
-- ------- placement math: feet stay pinned at every scale
local W, H, PAD, PADL = 56, 40, 3, 2
do
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
local x, y, sc = BattleState.backPlacement(W, H, PAD, PADL, s)
eq(sc, s, "back placement returns the scale (scale " .. s .. ")")
eq(y + (H - PAD) * s, 96,
"player feet stay on the text-box top at scale " .. s)
eq(x, 8 - PADL * s,
"player left pad is pulled back proportionally at scale " .. s)
end
local ex, ey = 100, 20
for _, s in ipairs({ 0.5, 1, 2, 3 }) do
local x, y = BattleState.frontPlacement(ex, ey, W, H, s)
eq(y + H * s, ey + H,
"enemy bottom edge stays pinned to its slot at scale " .. s)
eq(x + W * s / 2, ex + W / 2,
"enemy horizontal centre stays pinned at scale " .. s)
end
-- the s=1 case is the vanilla draw exactly: no shift
local x1, y1 = BattleState.frontPlacement(ex, ey, W, H, 1)
check(x1 == ex and y1 == ey, "scale 1 front placement is the untouched slot")
end
-- ------- composition with the send-out grow
do
-- growInScale returns the AnimateSendingOutMon stages; a mod scale
-- composes multiplicatively, and the composed pic is still grounded
local moddedBack = BattleState.resolveBattleScale(
{ pokemon = { GROWMON = { battleScaleBack = 1.5 } } }, "back", nil, "GROWMON")
eq(moddedBack, 1.5, "species back override resolved for the grow test")
for _, gs in ipairs({ 3 / 7, 5 / 7, 1 }) do
local eff = moddedBack * gs
local _, y = BattleState.backPlacement(W, H, PAD, PADL, eff)
eq(y + (H - PAD) * eff, 96,
"player feet stay pinned through grow stage " .. gs)
local ex, ey = 100, 20
local _, ey2 = BattleState.frontPlacement(ex, ey, W, H, eff)
eq(ey2 + H * eff, ey + H,
"enemy bottom stays pinned through grow stage " .. gs)
end
end
S.finish()
+1 -1
View File
@@ -160,7 +160,7 @@ check(not pcall(Manifest.validate, {
local versionLoader = Loader.new({ fs = memfs({
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
["mods/future/main.lua"] = "return function(mod) mod.content.items:register('NOPE', {}) end",
["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=1.0 <2.0"' }),
["mods/current/manifest.json"] = manifestJson("current", { game_version = '">=0.0.0-0 <2.0"' }),
["mods/current/main.lua"] = NOOP,
}) })
local versionData = { items = {} }
+352
View File
@@ -0,0 +1,352 @@
-- The rendering-pipeline seam, exercised through the public mod API.
--
-- A render pipeline is the one extension point that owns part of the
-- frame, so the properties worth pinning are the ones a mod cannot be
-- trusted to honor on its own: that a pipeline nobody switched on costs
-- nothing, that its callbacks are dispatched in priority order, and above
-- all that a mod which throws mid-frame degrades to the vanilla 2D path
-- instead of taking the frame down with it.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Loader = require("src.mods.Loader")
local Schemas = require("src.mods.Schemas")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
local S = require("tests.harness").suite("mod render")
local check, eq = S.check, S.eq
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local seen, items = {}, {}
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
end
table.sort(items)
return items
end,
}
end
local function manifest(id, extra)
local body = ('{"id":"%s","name":"%s","version":"1.0.0","api":2,' ..
'"entry":"main.lua"%s}'):format(id, id, extra or "")
return body
end
-- ------- schema: a record must actually do something
do
local spec = Schemas.REGISTRIES.render_pipelines
check(spec ~= nil, "the render_pipelines registry is in the catalog")
eq(spec.semantics, "record", "render_pipelines merges as records")
eq(spec.target, "render_pipelines", "render_pipelines writes to its own namespace")
local ok = Schemas.check(spec, "render_pipelines", "good",
{ label = "GOOD", drawWorld = function() end }, "register")
check(ok, "a drawWorld-only record validates")
local okPresent = Schemas.check(spec, "render_pipelines", "grade",
{ label = "GRADE", present = function() end }, "register")
check(okPresent, "a present-only record validates")
-- the whole point of the record is to draw something
local bad, err = Schemas.check(spec, "render_pipelines", "inert",
{ label = "INERT" }, "register")
check(not bad, "a record with no draw callback is rejected")
check(tostring(err):find("drawWorld", 1, true) ~= nil,
"the rejection names the callbacks it wanted: " .. tostring(err))
local wrong = Schemas.check(spec, "render_pipelines", "typo",
{ label = "T", drawWorld = "not a function" }, "register")
check(not wrong, "a non-function draw callback is rejected")
end
-- ------- a mod registers two pipelines and the engine dispatches them
local trace = {}
local FILES = {
["mods/painter/manifest.json"] = manifest("painter", ',"priority":10'),
["mods/painter/main.lua"] = [[
local mod = ...
local T = _G.__RENDER_TEST
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA",
levels = { "OFF", "LOW", "HIGH" },
hotkey = "7",
priority = 20,
available = function() return T.available end,
update = function(dt, level) T.trace[#T.trace + 1] = "update:" .. level end,
-- the folds composite only a real Canvas, so the mod hands back the
-- canvases the test pre-created (see T.worldOut / T.blurOut / T.gradeOut)
drawWorld = function(ctx)
T.trace[#T.trace + 1] = "world:" .. tostring(ctx.tag)
return T.worldOut
end,
worldPresent = function(canvas)
T.trace[#T.trace + 1] = "worldPresent"
return T.blurOut
end,
})
mod.content.render_pipelines:register("grade", {
label = "GRADE",
priority = 5,
present = function(canvas)
T.trace[#T.trace + 1] = "present"
return T.gradeOut
end,
})
]],
}
_G.__RENDER_TEST = { trace = trace, available = true }
-- the world/present folds accept only a real Canvas, so give the mod concrete
-- ones to return and pin identity through the dispatch
_G.__RENDER_TEST.worldOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.blurOut = love.graphics.newCanvas(2, 2)
_G.__RENDER_TEST.gradeOut = love.graphics.newCanvas(2, 2)
local data = {}
local loader = Loader.new({ fs = memfs(FILES) })
local okLoad = loader:load(data)
check(okLoad, "the pipeline mod loads clean: " .. table.concat(loader.errors, "; "))
Pipelines.install(data)
check(type(data.render_pipelines) == "table",
"the merge created the render_pipelines namespace")
eq(data.render_pipelines._owners.diorama, "painter",
"the merge stamped the owning mod for runtime attribution")
-- priority order, highest first, is what selection and the folds walk
local list = Pipelines.list()
eq(#list, 2, "both pipelines are catalogued")
eq(list[1].id, "diorama", "the higher-priority pipeline sorts first")
eq(list[2].id, "grade", "the lower-priority pipeline sorts second")
check(list[1].id ~= "_owners" and list[2].id ~= "_owners",
"the provenance key is not mistaken for a pipeline")
-- ------- switched off costs nothing
eq(Pipelines.worldPipeline(), nil, "nothing owns the world while off")
eq(Pipelines.wantsPresent(), false, "no present pass is wanted while off")
eq(Pipelines.present("frame"), "frame", "present is identity while off")
eq(Pipelines.worldPresent("frame"), "frame", "worldPresent is identity while off")
eq(#trace, 0, "no callback ran for a switched-off pipeline")
-- update ticks every pipeline regardless, so a mode easing out still eases
Pipelines.update(0.016)
eq(trace[1], "update:0", "update ticks a switched-off pipeline")
-- ------- switched on, the callbacks dispatch
trace[1] = nil
Pipelines.setLevel("diorama", 2)
Pipelines.setLevel("grade", 1)
eq(Pipelines.worldPipeline(), "diorama",
"the eligible world pipeline claims the world pass")
eq(Pipelines.drawWorld("diorama", { tag = "ctx" }), _G.__RENDER_TEST.worldOut,
"drawWorld returns the mod's canvas")
eq(trace[#trace], "world:ctx", "drawWorld received the frame context")
eq(Pipelines.worldPresent(_G.__RENDER_TEST.worldOut), _G.__RENDER_TEST.blurOut,
"worldPresent folds its canvas over the world image")
eq(Pipelines.wantsPresent(), true, "a live present pass asks for the canvas")
eq(Pipelines.present(_G.__RENDER_TEST.gradeOut), _G.__RENDER_TEST.gradeOut,
"present folds its canvas over the finished composite")
-- ------- the hardware gate
_G.__RENDER_TEST.available = false
eq(Pipelines.worldPipeline(), nil,
"an unavailable pipeline never takes the world pass")
eq(Pipelines.worldPresent("world-canvas"), "world-canvas",
"an unavailable pipeline's worldPresent is skipped")
_G.__RENDER_TEST.available = true
eq(Pipelines.worldPipeline(), "diorama", "availability is re-read each frame")
-- ------- the gate governs input, never the draw
--
-- Regression: gating the DRAW on the free-roam state made the world drop
-- to the flat 2D path for the handful of frames a warp is transitioning,
-- so walking through a door flashed 2D before snapping back to 3D. A mode
-- that is on renders until it is off; the gate only stops the player
-- CHANGING it at a bad moment.
Pipelines.setLevel("diorama", 2)
-- a state that every free-roam gate refuses: mid-warp, and running a script
local warping = { transitioning = true }
local overworld = warping
eq(Pipelines.canToggle("diorama", warping, overworld), false,
"the gate refuses a mode change mid-warp")
eq(Pipelines.worldPipeline(), "diorama",
"but the mode keeps rendering through the warp -- no 2D flash")
local scripted = { runner = { isRunning = function() return true end } }
eq(Pipelines.canToggle("diorama", scripted, scripted), false,
"the gate refuses a mode change mid-cutscene")
eq(Pipelines.worldPipeline(), "diorama",
"and the mode keeps rendering through the cutscene")
-- a menu on top of the overworld is not the overworld, so the gate refuses
-- there too -- and the world beneath it must still be the 3D one
eq(Pipelines.canToggle("diorama", { menu = true }, overworld), false,
"the gate refuses a mode change from a menu")
eq(Pipelines.worldPipeline(), "diorama",
"the world under an open menu keeps rendering in the pipeline")
eq(Pipelines.hotkey("7", warping, overworld), nil,
"a hotkey press mid-warp is refused")
eq(Pipelines.level("diorama"), 2, "and the refused press changed no level")
-- ------- mutual exclusion
Tilt.setLevel(3)
Pipelines.setLevel("diorama", 1)
eq(Tilt.level, 0, "a world pipeline switches the engine's TILT off")
Tilt.setLevel(0)
-- ------- a throwing mod loses its pipeline, not the frame
local BOOM = {
["mods/boom/manifest.json"] = manifest("boom"),
["mods/boom/main.lua"] = [[
local mod = ...
mod.content.render_pipelines:register("boom", {
label = "BOOM",
drawWorld = function() error("pipeline exploded", 0) end,
})
]],
}
local boomData = {}
local boomLoader = Loader.new({ fs = memfs(BOOM) })
boomLoader:load(boomData)
Pipelines.install(boomData)
Pipelines.setLevel("boom", 1)
eq(Pipelines.worldPipeline(), "boom", "the pipeline is eligible before it throws")
eq(Pipelines.drawWorld("boom", {}), nil,
"a throwing drawWorld yields nil, so the caller falls back to 2D")
eq(Pipelines.worldPipeline(), nil,
"a pipeline that threw is retired rather than retried every frame")
-- the failure has to reach the feed the mod manager shows, named after the
-- mod that owns it -- a console line alone leaves the player with a world
-- that silently stopped being 3D and nothing to disable
local blamed = nil
for _, message in ipairs(boomLoader.errors) do
if message:find("boom:", 1, true) and message:find("pipeline exploded", 1, true) then
blamed = message
end
end
check(blamed ~= nil,
"the runtime failure is attributed to its mod in the manager's error feed")
-- ------- a non-canvas return is ignored, and a dirty callback cannot leak
--
-- The fold composites only a real Canvas, so a present that forgets its
-- return -- or hands back a truthy shade string, flag or number -- must leave
-- the composite untouched rather than blank or crash the frame. Unlike a
-- throw, a clean-but-useless return is NOT a crash, so the pipeline stays
-- eligible instead of being retired. Separately, a callback that returns
-- cleanly while leaving a shader bound or the canvas redirected must not leak
-- that state into the engine composite that follows: the fold fences each
-- dispatch in push("all")/pop.
local SLOPPY = {
["mods/sloppy/manifest.json"] = manifest("sloppy"),
["mods/sloppy/main.lua"] = [[
local mod = ...
local T = _G.__SLOPPY
mod.content.render_pipelines:register("sloppy", {
label = "SLOPPY",
present = function(canvas)
T.ran = (T.ran or 0) + 1
return T.ret
end,
})
mod.content.render_pipelines:register("dirty", {
label = "DIRTY",
present = function(canvas)
love.graphics.setShader("mod-shader")
love.graphics.setCanvas("mod-canvas")
love.graphics.setColor(0.1, 0.2, 0.3, 0.4)
love.graphics.setBlendMode("add")
return canvas
end,
})
]],
}
_G.__SLOPPY = { ran = 0 }
local sloppyData = {}
local sloppyLoader = Loader.new({ fs = memfs(SLOPPY) })
sloppyLoader:load(sloppyData)
Pipelines.install(sloppyData)
local composite = love.graphics.newCanvas(4, 4)
Pipelines.setLevel("sloppy", 1)
for _, bad in ipairs({ "just-a-string", true, 42 }) do
_G.__SLOPPY.ret = bad
eq(Pipelines.present(composite), composite,
"a present returning a " .. type(bad) .. " leaves the composite untouched")
end
check(_G.__SLOPPY.ran == 3, "the present callback still ran each frame")
check(Pipelines.eligible("sloppy") == true,
"a non-canvas return does not retire the pipeline as broken")
Pipelines.setLevel("sloppy", 0)
love.graphics.setShader("engine-shader")
love.graphics.setCanvas("engine-canvas")
love.graphics.setBlendMode("alpha")
love.graphics.setColor(1, 1, 1, 1)
Pipelines.setLevel("dirty", 1)
eq(Pipelines.present(composite), composite,
"a dirty present that returns its input leaves the composite unchanged")
eq(love.graphics.getShader(), "engine-shader",
"a present that bound a shader cannot leak it past the fold")
eq(love.graphics.getCanvas(), "engine-canvas",
"a present that redirected the canvas cannot leak it past the fold")
eq(love.graphics.getBlendMode(), "alpha",
"a present that changed blend mode cannot leak it past the fold")
Pipelines.setLevel("dirty", 0)
_G.__SLOPPY = nil
Pipelines.reset()
Pipelines.install(nil)
_G.__RENDER_TEST = nil
-- ------- and with no mods at all, the whole subsystem is inert
eq(#Pipelines.list(), 0, "a mod-free boot registers no pipelines")
eq(Pipelines.worldPipeline(), nil, "a mod-free boot draws the vanilla world")
eq(Pipelines.wantsPresent(), false, "a mod-free boot allocates no present canvas")
eq(Pipelines.present("frame"), "frame", "a mod-free present is the identity")
eq(#Pipelines.rows({}), 0, "a mod-free options menu gains no rows")
eq(Pipelines.hotkey("6", nil, nil), nil, "a mod-free build claims no hotkeys")
S.finish()
+3 -2
View File
@@ -4,6 +4,7 @@ local Registry = require("src.mods.Registry")
local Events = require("src.mods.Events")
local Hooks = require("src.mods.Hooks")
local Manifest = require("src.mods.Manifest")
local Semver = require("src.mods.Semver")
local Logger = require("src.core.Logger")
local Version = require("src.core.Version")
local Runtime = require("src.mods.Runtime")
@@ -141,8 +142,8 @@ check(manifest.id == "test_mod" and manifest.path == "mods/test_mod",
"manifest validation")
check(type(Version.engine) == "string"
and Version.engine:match("^%d+%.%d+%.%d+$") ~= nil,
"engine version is a semver triple")
and Semver.parse(Version.engine) ~= nil,
"engine version parses as a semver (triple, optionally with a pre-release)")
check(Version.modApi == 2, "mod api version is 2")
check(Version.title("X") == "X v" .. Version.engine,
"window title carries the engine version")
+1
View File
@@ -2940,6 +2940,7 @@ runSuites(orderedGlob("tests/mod_*.lua tests/modkit_tests.lua", {
"tests/mod_constants_tests.lua", "tests/mod_catalog_tests.lua",
"tests/mod_audio_tests.lua", "tests/mod_world_tests.lua",
"tests/mod_battle_tests.lua", "tests/mod_graphics_tests.lua",
"tests/mod_render_tests.lua", "tests/mod_battle_scale_tests.lua",
"tests/mod_scripting_tests.lua", "tests/mod_ui_tests.lua",
"tests/mod_save_tests.lua", "tests/modkit_tests.lua",
}, {
+476
View File
@@ -0,0 +1,476 @@
-- Gen1 save transformer tests (src/save_convert/GenSave.lua): a
-- round-trip of a fresh SaveData.newGame() save (no real save data
-- checked in as a fixture), plus targeted checks for the checksum
-- routine, the species/move/item/map crosswalks, and name encoding.
--
-- Run: luajit tests/save_convert_tests.lua
package.path = "./?.lua;" .. package.path
_G.love = require("tests.love_stub")
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local checks, failures = 0, 0
local function check(cond, msg)
checks = checks + 1
if not cond then
failures = failures + 1
print("FAIL: " .. msg)
end
end
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local data = {
pokemon = loadfile("data/generated/pokemon.lua")(),
moves = loadfile("data/generated/moves.lua")(),
items = loadfile("data/generated/items.lua")(),
maps = loadfile("data/generated/maps.lua")(),
eventFlags = loadfile("src/save_convert/data/event_flags.lua")(),
}
-- Independent re-implementation of CalcCheckSum (complement of the additive
-- byte sum) so the export scenarios below can verify all three SRAM checksums
-- straight off the emitted bytes, without trusting GenSave's own writer.
local bit = require("bit")
local OFF = GenSave.OFFSETS
local function rawChecksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + bytes:byte(i + 1), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
local function checksumValid(bytes, from, to, storeOff)
return rawChecksum(bytes, from, to) == bytes:byte(storeOff + 1)
end
-- A box bank (2 or 3) holds 6 box regions, one one-byte checksum each, plus a
-- bank aggregate computed over the ENTIRE six-box region (pokered
-- engine/menus/save.asm: CalcCheckSum over all 6 x 1122 bytes), independently
-- re-derived here so this test cannot inherit an encoder bug.
local function boxBankChecksumValid(bytes, bankBase, aggOff, indivOff)
for b = 0, 5 do
local base = bankBase + b * GenSave.BOX_REGION_SIZE
local c = rawChecksum(bytes, base, base + GenSave.BOX_REGION_SIZE)
if c ~= bytes:byte(indivOff + b + 1) then return false end
end
local agg = rawChecksum(bytes, bankBase, bankBase + 6 * GenSave.BOX_REGION_SIZE)
return agg == bytes:byte(aggOff + 1)
end
-- ------------------------------------------------------------------
-- crosswalks: one species/move/item/map roundtrip each
-- ------------------------------------------------------------------
local cw = GenSave.crosswalks(data)
check(cw.pokemonIndex.MEW == 21, "MEW's internal ROM index is 21 (the classic MissingNo fact)")
check(cw.pokemonByIndex[21] == "MEW", "index 21 resolves back to MEW")
check(cw.pokemonDex.MEW == 151, "MEW's national dex number is 151 (BaseStats[151])")
check(cw.pokemonByDex[151] == "MEW", "dex 151 resolves back to MEW")
check(cw.pokemonDex.BULBASAUR == 1, "BULBASAUR is dex #1")
check(cw.movesByIndex[cw.movesIndex.THUNDERBOLT] == "THUNDERBOLT",
"a move id round-trips through its index")
check(cw.itemsByIndex[cw.itemsIndex.POKE_BALL] == "POKE_BALL",
"an item id round-trips through its index")
check(cw.itemsByIndex[cw.itemsIndex.TM_THUNDER_WAVE] == "TM_THUNDER_WAVE",
"a TM item (no `index` field, only machine.number) round-trips via its derived item id")
check(cw.mapsIndex.PALLET_TOWN == 0, "PALLET_TOWN is map index 0")
check(cw.mapsByIndex[0] == "PALLET_TOWN", "map index 0 resolves back to PALLET_TOWN")
-- ------------------------------------------------------------------
-- a fresh new-game save round-trips through encode -> decode
-- ------------------------------------------------------------------
local fresh = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" })
-- newGame's party/boxes are empty and its map is an interior with no
-- gen1-save equivalent tileset concerns -- exactly the baseline this
-- codec needs to handle cleanly with no real playthrough data at all.
local bytes = GenSave.encode(fresh, data, nil)
check(#bytes == GenSave.SAVE_SIZE, "encode() produces exactly 32768 bytes")
local decoded = GenSave.decode(bytes, data)
check(#decoded.warnings == 0, "a freshly-encoded save passes its own checksum")
check(decoded.player.name == "RED", "player name round-trips")
check(decoded.player.rival == "BLUE", "rival name round-trips")
check(decoded.player.map == fresh.player.map, "spawn map round-trips (" ..
tostring(decoded.player.map) .. " vs " .. tostring(fresh.player.map) .. ")")
check(decoded.player.x == fresh.player.x and decoded.player.y == fresh.player.y,
"spawn position round-trips")
check(decoded.money == fresh.money, "money round-trips")
check(#decoded.party == 0, "an empty party stays empty")
-- ------------------------------------------------------------------
-- a populated save: party, boxes, badges, bag, pokedex, flags
-- ------------------------------------------------------------------
local save = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" })
save.player.id = 12345
save.money = 3000
save.coins = 50
save.inventory = { POKE_BALL = 5, BOULDERBADGE = 1, ANTIDOTE = 1 }
save.bagOrder = { "POKE_BALL", "ANTIDOTE" }
save.pcItems = { REVIVE = 2 }
save.pokedex = { seen = { MEW = true, PIKACHU = true }, owned = { PIKACHU = true } }
save.flags = { EVENT_GOT_STARTER = true, EVENT_GOT_POKEDEX = true }
save.boxes = {}
save.party = {
{
species = "MEW", level = 100, exp = 1059860,
dvs = { hp = 13, attack = 15, defense = 11, speed = 12, special = 15 },
statExp = { hp = 65535, attack = 65535, defense = 65535, speed = 65535, special = 65535 },
stats = { hp = 399, attack = 298, defense = 290, speed = 292, special = 298 },
hp = 399, status = nil,
moves = { { id = "TRANSFORM", pp = 16, ppUps = 3 }, { id = "MEGA_PUNCH", pp = 16, ppUps = 3 } },
nickname = "MEW", ot = "Lt<DOT>Ash", otId = 55721, catchRate = 45,
},
}
for i = 1, 12 do save.boxes[i] = {} end
save.boxes[3] = { {
species = "PIKACHU", level = 10, exp = 1000,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
hp = 30, status = "PSN",
moves = { { id = "THUNDERSHOCK", pp = 30, ppUps = 0 } },
nickname = "PIKA", ot = "ASH", otId = 12345, catchRate = 190,
} }
save.currentBox = 1
local bytes2 = GenSave.encode(save, data, nil)
local decoded2 = GenSave.decode(bytes2, data)
check(#decoded2.warnings == 0, "a populated save passes its own checksum")
check(decoded2.player.id == 12345, "player ID round-trips")
check(decoded2.money == 3000 and decoded2.coins == 50, "money and coins round-trip")
check(decoded2.inventory.BOULDERBADGE == 1, "a badge round-trips as a truthy inventory entry")
check(decoded2.inventory.POKE_BALL == 5 and decoded2.inventory.ANTIDOTE == 1,
"bag items round-trip")
check(decoded2.inventory.POKE_BALL and not decoded2.pcItems.POKE_BALL,
"bag items don't leak into PC storage")
check(decoded2.pcItems.REVIVE == 2, "PC items round-trip")
check(decoded2.pokedex.seen.MEW and decoded2.pokedex.seen.PIKACHU and decoded2.pokedex.owned.PIKACHU,
"pokedex seen/owned round-trip")
check(not decoded2.pokedex.owned.MEW, "a species only marked seen doesn't also come back owned")
check(decoded2.flags.EVENT_GOT_STARTER and decoded2.flags.EVENT_GOT_POKEDEX,
"event flags round-trip")
local mon1 = decoded2.party[1]
check(mon1 and mon1.species == "MEW", "party mon species round-trips")
check(mon1 and mon1.level == 100 and mon1.exp == 1059860, "party mon level/exp round-trip")
check(mon1 and mon1.hp == 399 and mon1.stats and mon1.stats.hp == 399,
"party mon current HP and max HP stat round-trip")
check(mon1 and mon1.dvs.attack == 15 and mon1.dvs.speed == 12, "party mon DVs round-trip")
check(mon1 and mon1.moves[1].id == "TRANSFORM" and mon1.moves[1].pp == 16
and mon1.moves[1].ppUps == 3, "party mon move/PP/PP-Up round-trip")
check(mon1 and mon1.nickname == "MEW" and mon1.otId == 55721, "party mon nickname/OT ID round-trip")
check(mon1 and mon1.ot == "Lt<DOT>Ash",
"an OT name containing a bracketed charmap token (\"<DOT>\") round-trips as one unit, "..
"not per-byte \"?\" (got " .. tostring(mon1 and mon1.ot) .. ")")
local box3mon = decoded2.boxes[3][1]
check(box3mon and box3mon.species == "PIKACHU" and box3mon.status == "PSN",
"a boxed mon's species and status condition round-trip")
check(box3mon and box3mon.moves[1].id == "THUNDERSHOCK", "a boxed mon's move round-trips")
check(decoded2.currentBox == 1, "current box selection round-trips")
-- ------------------------------------------------------------------
-- checksum: a corrupted byte is detected on decode (warned, not thrown --
-- decode() must still succeed on a foreign save with a bad checksum)
-- ------------------------------------------------------------------
local O = GenSave.OFFSETS
local corrupted = bytes2:sub(1, O.money) ..
string.char((bytes2:byte(O.money + 1) + 1) % 256) ..
bytes2:sub(O.money + 2)
local decodedCorrupt = GenSave.decode(corrupted, data)
check(#decodedCorrupt.warnings == 1, "a corrupted byte trips the checksum warning")
-- ------------------------------------------------------------------
-- Scenario 2 (always-on): engine-origin export. A save that never came
-- from a real cartridge -- SaveData.newGame() plus a small party, bag,
-- PC and badge set built through the documented save shape -- must
-- encode with NO template into a structurally valid 32768-byte SRAM
-- image: exact size, all three SRAM checksums valid, and a clean
-- re-import that reproduces party / items / badges / name. (The vendor
-- gen1lib parse of this same image is exercised out-of-band under Lua
-- 5.4, since gen1lib cannot even be loaded by luajit.)
-- ------------------------------------------------------------------
local eng = SaveData.newGame({ playerName = "OAK", rivalName = "BLUE" })
eng.money = 1234
eng.inventory = { POTION = 3, POKE_BALL = 10, THUNDERBADGE = 1 }
eng.bagOrder = { "POTION", "POKE_BALL" }
eng.pcItems = { REVIVE = 1, FULL_RESTORE = 2 }
eng.pcOrder = { "REVIVE", "FULL_RESTORE" }
eng.party = {
{
species = "CHARMANDER", level = 5, exp = 135,
dvs = { hp = 8, attack = 9, defense = 10, speed = 11, special = 12 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 20, attack = 11, defense = 10, speed = 12, special = 11 },
hp = 20, status = nil,
moves = { { id = "SCRATCH", pp = 35, ppUps = 0 }, { id = "GROWL", pp = 40, ppUps = 0 } },
nickname = "CHAR", ot = "OAK", otId = eng.player.id, catchRate = 45,
},
{
species = "PIDGEY", level = 4, exp = 64,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 18, attack = 9, defense = 9, speed = 10, special = 8 },
hp = 18, status = nil,
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "PIDGE", ot = "OAK", otId = eng.player.id, catchRate = 255,
},
}
local engBytes = GenSave.encode(eng, data, nil) -- NO template: pure engine origin
check(#engBytes == GenSave.SAVE_SIZE, "engine-origin: encode is exactly 32768 bytes")
check(checksumValid(engBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"engine-origin: main data checksum valid")
check(boxBankChecksumValid(engBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums),
"engine-origin: bank 2 box checksums valid")
check(boxBankChecksumValid(engBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums),
"engine-origin: bank 3 box checksums valid")
local engDec = GenSave.decode(engBytes, data)
check(#engDec.warnings == 0, "engine-origin: re-import passes its own checksum")
check(engDec.player.name == "OAK", "engine-origin: player name reproduces")
check(#engDec.party == 2, "engine-origin: party size reproduces (got " .. #engDec.party .. ")")
check(engDec.party[1] and engDec.party[1].species == "CHARMANDER" and engDec.party[1].level == 5,
"engine-origin: party[1] species/level reproduce")
check(engDec.party[2] and engDec.party[2].species == "PIDGEY"
and engDec.party[2].moves[1] and engDec.party[2].moves[1].id == "TACKLE",
"engine-origin: party[2] species/move reproduce")
check(engDec.inventory.POTION == 3 and engDec.inventory.POKE_BALL == 10,
"engine-origin: bag items reproduce")
check(engDec.pcItems.REVIVE == 1 and engDec.pcItems.FULL_RESTORE == 2,
"engine-origin: PC items reproduce")
check(engDec.inventory.THUNDERBADGE == 1, "engine-origin: badge reproduces as an inventory entry")
check(not engDec.pcItems.THUNDERBADGE, "engine-origin: badge doesn't leak into PC items")
-- expose the image for the out-of-band gen1lib parse (scenario 2 oracle)
do local w = io.open("/tmp/engine_origin.sav", "wb"); if w then w:write(engBytes); w:close() end end
-- ------------------------------------------------------------------
-- SaveConvert: the runtime-facing module (moved paths + shared merge).
-- SaveConvert loads its OWN crosswalk data through `require` (the same
-- src/core/Data.lua pattern), independent of the `data` table above, so
-- these checks also prove the moved src/save_convert/{GenSave,data/*} paths
-- resolve. bytes2 (a valid populated image built earlier) is the input.
-- ------------------------------------------------------------------
local scSave, scErr = SaveConvert.importSav(bytes2, 2)
check(scSave ~= nil, "SaveConvert.importSav returns a save table (" .. tostring(scErr) .. ")")
check(scSave and scSave.player and scSave.player.id == 12345,
"SaveConvert.importSav: decoded player fields survive the merge")
check(scSave and scSave.inventory and scSave.inventory.POKE_BALL == 5,
"SaveConvert.importSav: decoded bag items survive the merge")
-- merge over new-game defaults
check(scSave and type(scSave.options) == "table" and scSave.options.ruleset == "gen1_faithful",
"SaveConvert.importSav: new-game default options merged in")
check(scSave and type(scSave.defeatedTrainers) == "table" and type(scSave.modData) == "table"
and scSave.repelSteps == 0,
"SaveConvert.importSav: default defeatedTrainers/modData/repelSteps merged in")
-- version tag
check(scSave and scSave.meta and scSave.meta.version == 2,
"SaveConvert.importSav: save is tagged with the requested version")
-- derived heal/outdoor anchors
check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map,
"SaveConvert.importSav: lastHeal derives from the decoded position")
check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil,
"SaveConvert.importSav: lastOutdoor is set")
-- the import template + decode warnings never leak into the slot table
check(scSave and scSave.rawImport == nil and scSave.warnings == nil,
"SaveConvert.importSav: rawImport/warnings stripped from the returned table")
-- size / type validation
local badSize, badSizeErr = SaveConvert.importSav("too short", 2)
check(badSize == nil and type(badSizeErr) == "string",
"SaveConvert.importSav: rejects a wrong-size input with an error")
local nilIn, nilInErr = SaveConvert.importSav(nil, 2)
check(nilIn == nil and type(nilInErr) == "string",
"SaveConvert.importSav: rejects a non-string input with an error")
-- checksum validation: flip a modeled byte so the stored checksum no longer
-- matches -> importSav must reject (GenSave.decode alone only warns).
local scCorrupt = bytes2:sub(1, OFF.money) ..
string.char((bytes2:byte(OFF.money + 1) + 1) % 256) ..
bytes2:sub(OFF.money + 2)
local scBad, scBadErr = SaveConvert.importSav(scCorrupt, 2)
check(scBad == nil and type(scBadErr) == "string" and tostring(scBadErr):find("checksum"),
"SaveConvert.importSav: rejects a bad-checksum save with a checksum error")
-- exportSav zero-fill path: a merged import table carries no template, so the
-- export must still be a structurally valid 32768-byte image.
local scOut, scOutErr = SaveConvert.exportSav(scSave)
check(scOut ~= nil and #scOut == GenSave.SAVE_SIZE,
"SaveConvert.exportSav: produces exactly 32768 bytes (" .. tostring(scOutErr) .. ")")
check(scOut and checksumValid(scOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"SaveConvert.exportSav: main data checksum valid on a templateless export")
local scRt = SaveConvert.importSav(scOut, 2)
check(scRt and scRt.party[1] and scRt.party[1].species == "MEW",
"SaveConvert import -> export -> import round-trips the party")
check(scRt and scRt.inventory.BOULDERBADGE == 1,
"SaveConvert round-trip preserves a badge")
-- exportSav bad input
local scNilOut, scNilOutErr = SaveConvert.exportSav("not a table")
check(scNilOut == nil and type(scNilOutErr) == "string",
"SaveConvert.exportSav: rejects a non-table input with an error")
-- exportSav template-aware path: a table still carrying the stashed import
-- template reproduces the source's UNMODELED regions byte-for-byte. Poke a
-- sentinel into the sprite-buffer region (inside the checksum window but not
-- written by encode), decode straight through GenSave (which keeps rawImport),
-- and confirm it survives on export while a templateless export zero-fills it.
local spriteOff = OFF.spriteData + 10
local sentinel = 0xAB
local templateSrc = bytes2:sub(1, spriteOff) .. string.char(sentinel) .. bytes2:sub(spriteOff + 2)
local tmplSave = GenSave.decode(templateSrc, data) -- rawImport = templateSrc
local tmplOut, tmplErr = SaveConvert.exportSav(tmplSave)
check(tmplOut ~= nil and #tmplOut == GenSave.SAVE_SIZE,
"SaveConvert.exportSav: template-aware export is 32768 bytes (" .. tostring(tmplErr) .. ")")
check(tmplOut and tmplOut:byte(spriteOff + 1) == sentinel,
"SaveConvert.exportSav: template-aware export carries an unmodeled region byte through")
check(tmplOut and checksumValid(tmplOut, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"SaveConvert.exportSav: template-aware export still writes a valid checksum")
check(scOut and scOut:byte(spriteOff + 1) == 0,
"SaveConvert.exportSav: templateless export zero-fills the same unmodeled region")
-- SaveConvert.loadData exposes the shared crosswalk set (require-loaded).
local scData = SaveConvert.loadData()
check(type(scData) == "table" and type(scData.pokemon) == "table"
and type(scData.eventFlags) == "table",
"SaveConvert.loadData: returns the crosswalk data set via require")
-- ------------------------------------------------------------------
-- Real-save import audit (fixture-gated). POKEPORT_SAV_FIXTURE must point
-- at a readable 32768-byte battery save (a personal .sav never checked in);
-- when it's unset or unusable the whole block skips with one notice, so the
-- suite stays green on any machine. When present, the full import is run and
-- audited for plausibility -- the same checks convert.lua's output has to
-- satisfy for SaveData.load to accept it without quarantine.
-- ------------------------------------------------------------------
local fixturePath = os.getenv("POKEPORT_SAV_FIXTURE")
local fixtureBytes
if fixturePath then
local ff = io.open(fixturePath, "rb")
if ff then
fixtureBytes = ff:read("*a")
ff:close()
if #fixtureBytes ~= GenSave.SAVE_SIZE then fixtureBytes = nil end
end
end
if not fixtureBytes then
print("fixture checks skipped (set POKEPORT_SAV_FIXTURE to a 32KB .sav to run them)")
else
local rs = GenSave.decode(fixtureBytes, data)
check(type(rs.player.name) == "string" and #rs.player.name > 0,
"fixture: player name decodes non-empty")
local badges = 0
for bit0 = 0, 7 do
local names = { "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE",
"SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE" }
if rs.inventory[names[bit0 + 1]] then badges = badges + 1 end
end
check(badges >= 0 and badges <= 8, "fixture: badge count in 0..8 (got " .. badges .. ")")
check(type(rs.money) == "number" and rs.money >= 0 and rs.money <= 999999,
"fixture: money is a sane BCD value (got " .. tostring(rs.money) .. ")")
check(type(rs.coins) == "number" and rs.coins >= 0 and rs.coins <= 9999,
"fixture: coins is a sane BCD value (got " .. tostring(rs.coins) .. ")")
check(#rs.party >= 1 and #rs.party <= 6,
"fixture: party holds 1..6 mons (got " .. #rs.party .. ")")
for i, mon in ipairs(rs.party) do
check(data.pokemon[mon.species] ~= nil,
"fixture: party mon " .. i .. " has a known species (" .. tostring(mon.species) .. ")")
check(mon.level >= 2 and mon.level <= 100,
"fixture: party mon " .. i .. " level in 2..100 (got " .. tostring(mon.level) .. ")")
check(#mon.moves >= 1 and #mon.moves <= 4,
"fixture: party mon " .. i .. " has 1..4 moves (got " .. #mon.moves .. ")")
for _, mv in ipairs(mon.moves) do
check(data.moves[mv.id] ~= nil,
"fixture: party mon " .. i .. " move is known (" .. tostring(mv.id) .. ")")
check(mv.pp >= 0 and mv.pp <= 63,
"fixture: party mon " .. i .. " move PP in 0..63 (got " .. tostring(mv.pp) .. ")")
end
check(type(mon.exp) == "number" and mon.exp > 0,
"fixture: party mon " .. i .. " has nonzero EXP")
end
local owned, seen = 0, 0
for _ in pairs(rs.pokedex.owned) do owned = owned + 1 end
for _ in pairs(rs.pokedex.seen) do seen = seen + 1 end
check(owned <= seen and seen <= 151,
"fixture: pokedex owned <= seen <= 151 (owned " .. owned .. ", seen " .. seen .. ")")
check(rs.player.map ~= nil and data.maps[rs.player.map] ~= nil,
"fixture: current map resolves to a real map id (" .. tostring(rs.player.map) .. ")")
check(type(rs.player.x) == "number" and type(rs.player.y) == "number"
and rs.player.x >= 0 and rs.player.y >= 0,
"fixture: player position is in-bounds non-negative")
local boxed = 0
for b = 1, 12 do boxed = boxed + #rs.boxes[b] end
check(boxed >= 0 and boxed <= 12 * 20,
"fixture: boxed mon count within 12 boxes x 20 (got " .. boxed .. ")")
local nflags = 0
for _ in pairs(rs.flags) do nflags = nflags + 1 end
check(nflags > 0, "fixture: at least one event flag populated (got " .. nflags .. ")")
-- play time (mapped from wPlayTimeHours/Minutes/Seconds/Frames into
-- save.playTime seconds): a real playthrough has a positive clock, and
-- it must round-trip through encode() back to the same H:M:S:F.
check(type(rs.playTime) == "number" and rs.playTime > 0,
"fixture: play time decodes to a positive second count (got "
.. tostring(rs.playTime) .. ")")
local rtBytes = GenSave.encode(rs, data, fixtureBytes)
local rt = GenSave.decode(rtBytes, data)
check(math.abs(rt.playTime - rs.playTime) < 1e-6,
"fixture: play time round-trips through encode()")
check(#rt.warnings == 0, "fixture: re-encoded save passes its own checksum")
-- Scenario 1 (fixture-gated): full export fidelity of the real save.
check(#rtBytes == GenSave.SAVE_SIZE, "fixture export: exactly 32768 bytes")
check(checksumValid(rtBytes, OFF.checksumStart, OFF.checksumEnd, OFF.mainChecksum),
"fixture export: main data checksum valid")
check(boxBankChecksumValid(rtBytes, OFF.box1, OFF.boxBank2Checksum, OFF.boxBank2IndividualChecksums),
"fixture export: bank 2 box checksums valid")
check(boxBankChecksumValid(rtBytes, OFF.box7, OFF.boxBank3Checksum, OFF.boxBank3IndividualChecksums),
"fixture export: bank 3 box checksums valid")
-- Byte-for-byte fidelity with the original as template: every byte GenSave
-- emits must reproduce the source EXCEPT the derived integrity bytes (the
-- main checksum and the two 7-byte box-bank checksum footers). Zero content
-- diffs proves both that every modeled region re-encodes identically AND
-- that the template carries each unmodeled region (sprite buffers, Hall of
-- Fame, Day Care, options, connection cache, ...) through untouched. A
-- tampered source can carry stale box checksums; the export rewrites them to
-- valid values, which is why the checksum bytes are the only exemptions.
local exempt = {}
exempt[OFF.mainChecksum] = true
for b = 0, 6 do exempt[OFF.boxBank2Checksum + b] = true end
for b = 0, 6 do exempt[OFF.boxBank3Checksum + b] = true end
local contentDiffs = 0
for i = 0, GenSave.SAVE_SIZE - 1 do
if not exempt[i] and rtBytes:byte(i + 1) ~= fixtureBytes:byte(i + 1) then
contentDiffs = contentDiffs + 1
end
end
check(contentDiffs == 0,
"fixture export: modeled + template-preserved bytes reproduce the source "
.. "byte-for-byte (got " .. contentDiffs .. " unexpected diffs)")
-- Expose the export so crosscheck.lua can be reused as the vendor oracle:
-- POKEPORT_SAV_FIXTURE=/tmp/roundtrip.sav lua tools/save_convert/crosscheck.lua
-- confirms gen1lib parse_save accepts these exported bytes and agrees.
do local w = io.open("/tmp/roundtrip.sav", "wb"); if w then w:write(rtBytes); w:close() end end
print(("fixture audit OK: name=%s badges=%d money=%d party=%d boxed=%d dex=%d/%d play=%dh%02dm"):format(
rs.player.name, badges, rs.money, #rs.party, boxed, owned, seen,
math.floor(rs.playTime / 3600), math.floor(rs.playTime / 60) % 60))
end
print(string.format("save convert: %d/%d checks passed", checks - failures, checks))
if failures > 0 then os.exit(1) end