Files
gen1recomp/tests/engine/cart_store.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

394 lines
19 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
local ok, bit32 = pcall(require, "bit32")
if ok then _G.bit32 = bit32 end
end
local T = require("tests.harness")
love = love or require("tests.love_stub")
local Base64 = require("src.core.Base64")
local SaveData = require("src.core.SaveData")
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local SHA = ("a1b2c3d4"):rep(8)
local SHA2 = ("beefcafe"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function memfs()
local files, dirs = {}, {}
local fs
fs = {
files = files,
write = function(path, body) files[path] = body return true end,
read = function(path) return files[path] end,
remove = function(path) files[path] = nil return true end,
createDirectory = function(path) dirs[path] = true return true end,
getInfo = function(path)
if files[path] ~= nil then return { type = "file" } end
if dirs[path] then return { type = "directory" } end
for name in pairs(files) do
if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end
end
return nil
end,
getDirectoryItems = function(path)
local prefix = (path == "" or path == nil) and "" or (path .. "/")
local out, seen = {}, {}
for name in pairs(files) do
if name:sub(1, #prefix) == prefix then
local child = name:sub(#prefix + 1):match("^([^/]+)")
if child and not seen[child] then
seen[child] = true
out[#out + 1] = child
end
end
end
table.sort(out)
return out
end,
}
return fs
end
local function cartTable(over)
local tbl = {
id = "kanto_plus",
title = "Kanto Plus",
version = "1.2.0",
author = "Ren",
shell = "#3fa9f5",
base = "red",
seal = "sealed",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { flavour = "grape", sweetness = 3 } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
for key, value in pairs(over or {}) do tbl[key] = value end
return tbl
end
local function bytesOf(over)
local cart, err = CartManifest.parse(cartTable(over))
if not cart then error("fixture does not parse: " .. tostring(err)) end
return CartManifest.encode(cart), cart
end
local fs = memfs()
local bytes, fixture = bytesOf()
local installed, hash = CartStore.install(bytes, fs)
T.check(installed ~= nil, "a good cart installs: " .. tostring(hash))
T.eq(installed.id, "kanto_plus", "install returns the parsed cart")
T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"install writes carts/<id>.g1rcart")
local reg = SaveData.loadOptions(fs).carts
T.check(type(reg) == "table" and type(reg.kanto_plus) == "table",
"install registers the cart in options.carts")
T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title")
T.eq(reg.kanto_plus.base, "red", "the registry carries the base game")
T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version")
T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash")
T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart",
"the registry names the cart file")
local index = CartStore.index(fs)
T.eq(#index, 1, "index lists the registry without reading the files")
T.eq(index[1].base, "red", "an index row carries the base game")
local rows = CartStore.list(fs)
T.eq(#rows, 1, "list returns the installed cart")
T.eq(rows[1].id, "kanto_plus", "the row names the cart")
T.eq(rows[1].base, "red", "the row carries the base")
T.eq(rows[1].cartHash, hash, "the row carries the cart hash")
T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil,
"the row carries the parsed cart")
T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins")
local got, gotHash = CartStore.get("kanto_plus", fs)
T.check(got ~= nil, "get returns the cart")
T.eq(gotHash, hash, "get returns the cart hash")
T.same(got, fixture, "get returns exactly what was installed")
T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id")
T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id")
local exported, exportHash = CartStore.export("kanto_plus", fs)
T.check(type(exported) == "string", "export hands back bytes")
T.eq(exportHash, hash, "export reports the cart hash")
T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back")
T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id")
local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite",
base = "blue" })
T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs")
T.eq(#CartStore.list(fs), 2, "list returns both carts")
T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title")
T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart")
T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart")
T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart")
T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing")
local newer, newerErr = CartStore.install(
bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs)
T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr))
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the newer version is what is installed")
T.eq(#CartStore.list(fs), 2, "replacing does not add a second row")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0",
"the registry follows the replacement")
local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs)
T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr))
local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs)
T.eq(older, nil, "an older cart is refused")
T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil,
"the refusal says why (got " .. tostring(olderErr) .. ")")
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
"the refused install leaves the newer cart in place")
T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file")
T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string")
T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise")
fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }"
local opts = SaveData.loadOptions(fs)
opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } }
SaveData.saveOptions(opts, fs)
T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success")
T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file")
T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil,
"uninstall clears the registry entry")
T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"uninstall leaves the cart's save file alone")
local slots = SaveData.loadOptions(fs).cartSlots
T.check(type(slots) == "table" and type(slots.kanto_plus) == "table",
"uninstall leaves the cart's slot registry alone")
T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall")
local gone, goneErr = CartStore.uninstall("kanto_plus", fs)
T.eq(gone, nil, "uninstalling twice is refused")
T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil,
"the second uninstall says why (got " .. tostring(goneErr) .. ")")
T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id")
T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal")
T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart")
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
"the old playthrough is still there for the reinstalled cart")
fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }"
local damaged = CartStore.list(fs)
T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list")
T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling")
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
"listing never deletes the file it could not read")
T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable")
fs.files["carts/kanto_plus.g1rcart"] = nil
opts = SaveData.loadOptions(fs)
opts.carts = opts.carts or {}
opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red",
version = "1.0.0", file = "carts/ghost.g1rcart" }
SaveData.saveOptions(opts, fs)
local haunted = CartStore.list(fs)
T.eq(#haunted, 1, "a registry entry with no file is skipped")
T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back")
T.eq(SaveData.loadOptions(fs).carts.ghost, nil,
"listing prunes the registry entry whose file is gone")
local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" }))
fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart)
local adopted = CartStore.list(fs)
T.eq(#adopted, 2, "a cart file with no registry entry is still listed")
T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title")
T.eq(adopted[2].cartHash, CartManifest.hash(strayCart),
"the stray cart is hashed from its own file")
T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil,
"listing registers the stray cart it adopted")
fs.files["carts/readme.txt"] = "hello"
fs.files["carts/half written.g1rcart"] = "return { }"
T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored")
local empty = memfs()
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
local function rowSet()
return {
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
github = "ren/hard-mode",
manifest = { id = "hard_mode", version = "2.0.0",
github = "ren/hard-mode", sha256 = SHA } },
{ id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false,
github = "ren/off-mode",
manifest = { id = "off_mode", version = "1.0.0",
github = "ren/off-mode", sha256 = SHA2 } },
{ id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true,
github = "ren/rare-soda",
manifest = { id = "rare_soda", version = "0.4.1",
github = "ren/rare-soda" } },
{ id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true,
manifest = { id = "sprite_pack", version = "beta" } },
}
end
local identity = { id = "my_cart", title = "My Cart", version = "0.1.0",
author = "Ren", base = "red", shell = "#FF8800",
seal = "open", summary = "Built in the launcher" }
local modOptions = {
rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } },
off_mode = { unused = true },
}
local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions)
T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved))
T.eq(captured.id, "my_cart", "the captured cart keeps the identity id")
T.eq(captured.title, "My Cart", "the captured cart keeps the title")
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
T.eq(captured.seal, "open", "the captured seal is the author's choice")
T.eq(captured.base, "red", "the captured base is the identity's")
T.eq(#captured.mods, 3, "only the enabled mods are pinned")
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
T.eq(captured.load_order[2], "rare_soda", "load order follows the row order")
T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order")
for _, entry in ipairs(captured.mods) do
T.neq(entry.id, "off_mode", "a disabled mod is never pinned")
end
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally")
T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version")
T.eq(captured.mods[2].repo, nil, "a local pin carries no repo")
T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash")
T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in")
T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in")
T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped")
T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally")
T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0")
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named")
T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name")
T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil,
"a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")")
T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named")
T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil,
"a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")")
T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil,
"an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")")
local publishable, why = CartManifest.publishable(captured)
T.eq(publishable, false, "a captured cart with local pins cannot be published")
T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda")
T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack")
T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out")
local storeFs = memfs()
local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs)
T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash))
T.same(roundTrip, captured, "a captured cart survives the file round trip")
T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk")
local pinned = rowSet()
pinned[3].manifest.sha256 = SHA2
pinned[4] = nil
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
T.check(full ~= nil, "a fully pinned capture builds a cart")
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github")
T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash")
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions)
T.eq(noMods, nil, "a capture with nothing enabled is refused")
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
"a capture with a bad identity is refused")
T.eq(CartStore.capture(nil, rowSet(), modOptions), nil,
"a capture with no identity is refused")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label"
local ART_DATA = Base64.encode(PNG)
local function artedCart()
local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
return plain
end
local artFs = memfs()
local artFixture = artedCart()
local packed = CartManifest.encode(artFixture)
local artInstalled, artHash = CartStore.install(packed, artFs)
T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash))
T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art")
T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil,
"install writes the art payload to the cart file")
T.same(CartStore.get("art_cart", artFs), artFixture,
"the installed cart reads back with its art")
local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
label = "label.png" }))
T.eq(artHash, CartManifest.hash(artless),
"the art is not part of the hash a save pins itself to")
local artExported, artExportHash = CartStore.export("art_cart", artFs)
T.eq(artExportHash, artHash, "export reports the same hash for an arted cart")
T.eq(artExported, packed, "export hands back the bytes that were packed")
local reread = CartManifest.decode(artExported)
T.same(reread, artFixture, "the exported bytes decode to the same cart and art")
T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical")
local sharedFs = memfs()
T.check(CartStore.install(artExported, sharedFs) ~= nil,
"an exported cart installs somewhere else")
T.same(CartStore.get("art_cart", sharedFs), artFixture,
"pack, install, export and install again leaves the cart unchanged")
local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs)
T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG")
T.eq(shownName, "label.png", "labelArt hands back the art name")
T.eq(CartStore.labelArt("nothing_here", sharedFs), nil,
"labelArt refuses an unknown id")
T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil,
"labelArt refuses a climbing id")
local plainFs = memfs()
T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil,
"a cart with no art still installs")
T.eq(CartStore.labelArt("art_cart", plainFs), nil,
"a cart with no art has no label art")
T.same(CartStore.get("art_cart", plainFs), artless,
"a cart with no art round trips exactly as before")
local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1))
T.neq(tamperedBytes, packed, "the tampered bundle really changed")
local tamperedFs = memfs()
local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs)
T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr))
T.eq(tampered.labelArt, nil, "the bad art is dropped")
T.eq(CartStore.labelArt("art_cart", tamperedFs), nil,
"the installed cart shows no art")
T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless),
"the bad art is not written back to disk")
T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art")
T.finish("cart_store")