mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
new launcher and save converts and pipeline
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = {} }
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
}, {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user