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.
This commit is contained in:
bryanthaboi
2026-08-22 21:41:19 -04:00
parent ca4d3d283c
commit ebf44f20d3
16 changed files with 6256 additions and 201 deletions
+13 -3
View File
@@ -327,9 +327,9 @@ end
local function makeLauncher()
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version)
return RomImporter.new(function(version, cartId)
Importer = nil
bootGame(version)
bootGame(version, cartId)
end, {
launcher = true,
forceImport = forceImport,
@@ -394,7 +394,7 @@ local function returnToLauncher()
Importer = makeLauncher()
end
function bootGame(version)
function bootGame(version, cartId)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -407,6 +407,16 @@ function bootGame(version)
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
CacheFs.prefix = GameVersion.cachePrefix()
CacheFs.mountVersion(GameVersion.get())
local cartHash
if cartId then
local ok, cart, hash = pcall(function()
return require("src.carts.CartStore").get(cartId)
end)
if ok and cart then cartHash = hash else cartId = nil end
end
local SaveData = require("src.core.SaveData")
SaveData.setCart(cartId, cartHash)
if cartId then SaveData.adoptCartSeal(cartId) end
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
-- diagnosable from the SD without enabling switch-debug.txt.
pcall(function()