Files
gen1recomp/tests/engine/cart_seal.lua
T
bryanthaboi ebf44f20d3 Add custom carts: named, version-pinned mod sets that play as their own game
A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.

Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
  for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
  serialised table read through SaveSerializer, so an imported cart can never
  execute code. Canonical strings are length-prefixed because option keys and
  values are author-controlled and could otherwise forge a record boundary and
  collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
  file id with its md5, or "local" for a capture that only exists on this
  install. A local pin is unpublishable by construction, which is what makes
  "build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
  re-arting a cart does not tell every player their run is out of date.
  src/core/Base64.lua decodes it; strict, with no whitespace tolerance.

Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
  so a cart's file never sits beside a vanilla one and uninstalling a cart
  never orphans a save. Every save records the cart build it was made under.

The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
  nothing else. A pinned mod with no frozen options gets an empty bucket so
  unfrozen keys fall to schema defaults, identical for everyone; otherwise two
  players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
  version. Playing a subset of the cart is the exact dishonesty the seal
  exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
  playthrough to open behaviour. It cannot be cleared through any public API.

Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
  turns the page into that cart's page, with its own cartridge, title and save
  slots. The rail of five games never grows and a cart id never reaches
  imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
  of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
  player confirms, every mod that could only be pinned to this install and
  whether the result can be shared at all.

Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
  release workflow. Its writer is byte-identical to the engine's serialiser.
2026-08-23 12:18:29 -04:00

471 lines
18 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Loader = require("src.mods.Loader")
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
local fs
fs = {
files = files,
read = function(path) return files[path] end,
write = function(path, content) files[path] = content return true 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,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
createDirectory = function() return true 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,
}
return fs
end
local function manifestJson(id)
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]])
:format(id, id)
end
local function entry(record)
return ([[
return function(mod)
mod.options:define({ { key = "tint", default = "base" } })
mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) })
end
]]):format(record)
end
local function install()
local files = {
["options.lua"] = SaveSerializer.encode({
mods = { beta = false },
modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } },
}),
}
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
files["mods/" .. id .. "/manifest.json"] = manifestJson(id)
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
end
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function writeCart(files, tbl)
local cart, err = CartManifest.parse(tbl)
assert(cart, err)
files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart)
return cart
end
local function cartTable(id, seal, mods, order)
return { id = id, title = id, version = "1.0.0", author = "tester",
shell = "#102030", base = "red", seal = seal,
mods = mods, load_order = order }
end
local function pin(id, version, options)
return { id = id, source = "local", version = version or "1.0.0",
options = options }
end
local function boot(files)
local data = { pokemon = {} }
local loader = Loader.new({ fs = memfs(files) })
local ok = loader:load(data)
return loader, data, ok
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function names(list)
return table.concat(list, ",")
end
-- ------- a sealed cart loads its pins, in its order, with its options
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") },
{ "beta", "alpha" }))
SaveData.setCart("sealed", "hash1")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart whose pins are all installed loads cleanly")
T.eq(names(loader.order), "beta,alpha",
"the cart's load_order beats priority and the id tie-break")
T.eq(data.pokemon.ALPHA.name, "cart",
"a frozen option overrides the player's saved value")
T.eq(data.pokemon.BETA.name, "base",
"a pin that froze no options falls to the schema default, not the player's")
T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs")
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
T.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads")
local report = loader:cartStatus()
T.eq(report.id, "sealed", "the report names the cart")
T.eq(report.seal, "sealed", "and its seal")
T.eq(report.enforced, true, "which is enforced")
T.eq(report.refused, false, "and not refused")
T.eq(#report.missing, 0, "with no missing pins")
T.eq(#report.mismatched, 0, "and no version mismatches")
T.eq(loader:status().cart, report, "status() carries the same report")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"the player's disable flag is untouched on disk")
T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false,
"and so is the enable flag of the mod the seal left out")
T.eq(opts.modOptions.alpha.tint, "player",
"the frozen option never overwrote the player's saved value")
T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod")
end
-- ------- an open cart layers the player's mods on top
do
local files = install()
writeCart(files, cartTable("open", "open",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) },
{ "beta", "gamma" }))
SaveData.setCart("open", "hash2")
local loader, data, ok = boot(files)
T.check(ok, "an open cart loads")
T.eq(names(loader.order), "beta,gamma,alpha",
"the cart's mods come first in its order, then the player's own")
T.eq(data.pokemon.BETA.name, "player",
"an open cart's option is a starting value the player's own setting beats")
T.eq(data.pokemon.GAMMA.name, "cart",
"and it stands where the player set nothing")
T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally")
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
end
-- ------- a missing pin: refusal when sealed, warning when open
do
local files = install()
writeCart(files, cartTable("gap", "sealed",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash3")
local loader, data, ok = boot(files)
T.check(not ok, "a sealed cart with an uninstalled pin fails the load")
T.eq(#loader.order, 0, "and plays no subset of itself")
T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed")
T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods")
local report = loader:cartStatus()
T.eq(report.refused, true, "the report refuses the cart")
T.eq(#report.missing, 1, "naming one missing pin")
T.eq(report.missing[1].id, "delta", "by id")
T.eq(report.missing[1].version, "2.0.0", "and by the version it pins")
T.eq(report.missing[1].source, "local", "with the source it would come from")
T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil,
"and a message the launcher can show")
T.eq(loader.errors[1], report.message, "the refusal is on the boot error list")
local opts = options(files)
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
"a refusal still leaves the player's flags alone")
end
do
local files = install()
writeCart(files, cartTable("gap", "open",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("gap", "hash4")
local loader, data, ok = boot(files)
T.check(ok, "an open cart with an uninstalled pin still loads")
T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest")
T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs")
local report = loader:cartStatus()
T.eq(report.refused, false, "the missing pin is a warning, not a refusal")
T.eq(report.missing[1].id, "delta", "and is still reported by id")
end
-- ------- a pin installed at another version
do
local files = install()
writeCart(files, cartTable("skew", "sealed",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash5")
local loader, _, ok = boot(files)
T.check(not ok, "a sealed cart refuses a pin installed at another version")
T.eq(#loader.order, 0, "and loads nothing")
local report = loader:cartStatus()
T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod")
T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins")
T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed")
T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed",
1, true) ~= nil, "with a message the launcher can show")
end
do
local files = install()
writeCart(files, cartTable("skew", "open",
{ pin("alpha", "2.0.0") }, { "alpha" }))
SaveData.setCart("skew", "hash6")
local loader, data, ok = boot(files)
T.check(ok, "an open cart warns about a version skew instead of refusing")
T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there")
T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it")
end
-- ------- an unreadable cart
do
local files = install()
SaveData.setCart("ghost", "hash7")
local loader, data, ok = boot(files)
T.check(not ok, "a cart that is not installed cannot be played as that cart")
T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name")
T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil,
"and the report names the cart that went missing")
end
-- ------- breaking the seal downgrades a sealed cart to the open answer
do
local files = install()
writeCart(files, cartTable("sealed", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
SaveData.setCart("sealed", "hash8")
SaveData.breakSeal()
local loader, data, ok = boot(files)
T.check(ok, "a broken seal no longer refuses over a missing pin")
T.eq(names(loader.order), "beta,alpha,gamma",
"the player's own mods load alongside the cart's")
T.eq(data.pokemon.BETA.name, "player",
"and the player's option values come back with them")
local report = loader:cartStatus()
T.eq(report.broken, true, "the report says the seal is broken")
T.eq(report.enforced, false, "so the seal enforces nothing")
end
-- ------- vanilla is untouched when no cart is active
do
local files = install()
SaveData.resetSlotState()
local loader, data, ok = boot(files)
T.check(ok, "a vanilla boot with no cart loads")
T.eq(loader:cartStatus(), nil, "and reports no cart at all")
T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order")
T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values")
T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off")
end
-- ------- planCart on its own
do
local report = Loader.planCart(nil, {})
T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for")
T.check(report.message:find("not installed", 1, true) ~= nil,
"with a presentable reason")
local unpinned = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }),
{ { id = "gamma", version = "whatever" } })
T.eq(#unpinned.mismatched, 0,
"a local pin captured with no semantic version makes no version claim")
T.eq(unpinned.refused, false, "so it cannot refuse over one")
local skew = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }),
{ gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } })
T.eq(skew.mismatched[1].installed, "1.0.0-beta",
"a prerelease is a different version to a sealed cart")
local broken = Loader.planCart(
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true)
T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning")
T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin")
end
-- ------- the broken-seal stamp
local function plainSave(name)
return {
version = "red",
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal")
local save = plainSave("INTACT")
T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save")
T.check(SaveData.save(save, {}), "write a save under an intact seal")
T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp")
local loaded = SaveData.load("red")
T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save")
T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified")
T.eq(SaveData.isSealBroken(), true, "and the session is armed")
T.check(SaveData.save(loaded, {}), "save the stamped file")
T.eq(SaveData.load("red").meta.sealBroken, true,
"the stamp survives a save/load round trip")
local again = SaveData.load("red")
T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp")
T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp")
T.eq(SaveData.unbreakSeal, nil, "there is no public unset")
T.eq(SaveData.clearSeal, nil, "under any spelling")
T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears")
T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there")
SaveData.resetSlotState()
T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed")
local reread = SaveData.load("red")
T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good")
T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session")
T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it")
local fresh = plainSave("FRESH")
T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed")
T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own")
end
-- ------- the durable per-slot broken mark
do
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
local first = SaveData.createCartSlot("kanto")
T.eq(first, "slot1", "a cart's first save slot")
T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false,
"which its launcher row reports without loading a save")
T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal")
T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken")
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true,
"and the launcher row carries it")
SaveData.resetSlotState()
T.eq(SaveData.slotSealBroken("kanto", first), true,
"the mark survives a restart")
local second = SaveData.createCartSlot("kanto")
T.eq(SaveData.slotSealBroken("kanto", second), false,
"a new slot under the same cart starts sealed again")
T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset")
T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling")
T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value")
T.check(SaveData.markSlotSealBroken("kanto", first, false),
"the setter takes no argument that clears")
T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands")
T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false,
"a slot that is not registered cannot be marked")
SaveData.setCart("kanto", "hash9")
SaveData.setActiveCartSlot("kanto", second)
T.eq(SaveData.adoptCartSeal("kanto"), false,
"booting an unmarked slot leaves the session sealed")
T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart")
SaveData.setActiveCartSlot("kanto", first)
T.eq(SaveData.adoptCartSeal("kanto"), true,
"booting the marked slot breaks the seal for the session")
T.eq(SaveData.isSealBroken(), true, "which is what the loader reads")
SaveData.deleteCartSlot("kanto", first)
T.eq(SaveData.slotSealBroken("kanto", first), false,
"deleting the playthrough takes its mark with it")
end
-- ------- a marked slot loads the cart's pins first, then the player's mods
do
local files = install()
writeCart(files, cartTable("marked", "sealed",
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
{ "beta", "delta" }))
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
SaveData.setCart("marked", "hash10")
local slot = SaveData.createCartSlot("marked")
SaveData.setActiveCartSlot("marked", slot)
local intact, _, intactOk = boot(files)
T.check(not intactOk, "an unmarked slot still refuses the missing pin")
T.eq(intact:cartStatus().refused, true, "with the refusal on its report")
SaveData.resetSlotState()
SaveData.setCart("marked", "hash10")
T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken")
T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark")
local loader, data, ok = boot(files)
T.check(ok, "and the cart loads")
T.eq(names(loader.order), "beta,alpha,gamma",
"the cart's pins load first, then the player's own enabled mods")
T.eq(data.pokemon.BETA.name, "player",
"with the player's own option values back")
T.eq(loader:cartStatus().broken, true, "the report says the seal is broken")
local stamped = plainSave("BROKEN")
T.check(SaveData.save(stamped, {}), "a save written under the adopted mark")
T.eq(SaveData.load("red").meta.sealBroken, true,
"carries the save's own permanent stamp")
end
love.filesystem = realFS
T.finish("cart_seal")