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

270 lines
12 KiB
Lua

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
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
local function fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function options(files)
return SaveSerializer.decode(files["options.lua"] or "") or {}
end
local function plainSave(name, hash)
return {
version = "red",
meta = hash and { cartHash = hash } or nil,
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
pokedex = { seen = {}, owned = {} },
inventory = {},
playTime = 0,
}
end
do
fresh()
T.eq(SaveData.getCart(), nil, "no cart is active by default")
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
T.eq(SaveData.getCart(), nil, "and getCart says so")
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
end
do
local files = fresh()
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
T.check(not ok, "and deleting from it fails")
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
"setActiveCartSlot returns the chosen id")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
local opts = options(files)
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
"renameCartSlot labels a registered slot")
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
"the label is trimmed and persisted")
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
"listCartSlots carries the label")
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
T.eq(options(files).cartSlots.nuzlocke.names, nil,
"the names table leaves the registry once empty")
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
T.check(not bad, "renaming an unregistered cart slot fails")
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
"unknown-slot rename error is user-presentable")
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
"seed the active cart slot")
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
"the bytes land in the cart's own directory")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
opts = options(files)
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
"active falls back to the remaining slot")
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
end
do
local files = fresh()
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
"seed alpha's slot")
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
"seed beta's slot")
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
"two carts with the same slot id write different files")
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
end
do
local files = fresh()
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
T.eq(SaveData.createCartSlot("red"), "slot1",
"a cart may even be named after a version")
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
"seed the version slot")
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
"seed the cart slot")
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
"a version slot carries no cart hash")
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
T.eq(options(files).saveSlots.red.list[1], "slot1",
"the version registry is independent of the cart one")
end
do
local files = fresh()
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"with no cart slot yet, the flat path follows the version suffix scheme")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
"the active cart slot owns the save path")
local save = SaveData.newGame()
save.player.name = "NUZ"
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
T.eq(files["save.lua"], nil, "never into the base game's flat file")
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
local loaded = SaveData.load("red")
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"and the registry mirrors it for a listing with no save decode")
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
"listCartSlots surfaces the stamp")
end
do
fresh()
SaveData.setCart("nuzlocke", "abc123")
SaveData.createCartSlot("nuzlocke")
SaveData.setActiveCartSlot("nuzlocke", "slot1")
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
T.eq(SaveData.load("red").meta.cartHash, "abc123",
"buildMeta carries the stamp instead of dropping it")
SaveData.setCartHash("def456")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
"installing an update does not restamp the slot")
loaded = SaveData.load("red")
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
T.check(SaveData.save(loaded, {}), "save under the new build")
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
T.check(not ok, "an unregistered slot cannot be stamped")
T.check(tostring(err):find("not registered", 1, true) ~= nil,
"with a user-presentable reason")
end
do
local files = fresh()
SaveData.createSlot("red")
SaveData.setActiveSlot("red", "slot1")
local save = SaveData.newGame()
save.player.name = "VANILLA"
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
local bytes = files["saves/red/slot1.lua"]
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua grows no cart registry")
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
local loaded = SaveData.load("red")
T.check(SaveData.save(loaded), "re-save the migrated shape once")
local before = files["saves/red/slot1.lua"]
SaveData.setCart("nuzlocke", "abc123")
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
"the cart takes over the path while it is active")
SaveData.setCart(nil)
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
"and hands it straight back")
T.check(SaveData.save(loaded), "re-save after the cart round trip")
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
"and options.lua still carries no cart registry")
local again = SaveData.load("red")
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
end
love.filesystem = realFS
T.finish("cart_saves")