mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
ebf44f20d3
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.
76 lines
2.4 KiB
Lua
76 lines
2.4 KiB
Lua
local Base64 = {}
|
|
|
|
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
local PAD = 61
|
|
|
|
local ENC, DEC = {}, {}
|
|
for i = 0, 63 do
|
|
local c = ALPHABET:sub(i + 1, i + 1)
|
|
ENC[i] = c
|
|
DEC[c:byte()] = i
|
|
end
|
|
|
|
local floor = math.floor
|
|
local char = string.char
|
|
local concat = table.concat
|
|
|
|
function Base64.encode(bytes)
|
|
if type(bytes) ~= "string" then return nil, "base64 input must be a string" end
|
|
local out, n, i = {}, #bytes, 1
|
|
while i + 2 <= n do
|
|
local a, b, c = bytes:byte(i, i + 2)
|
|
local word = a * 65536 + b * 256 + c
|
|
out[#out + 1] = ENC[floor(word / 262144)] .. ENC[floor(word / 4096) % 64]
|
|
.. ENC[floor(word / 64) % 64] .. ENC[word % 64]
|
|
i = i + 3
|
|
end
|
|
local rest = n - i + 1
|
|
if rest == 1 then
|
|
local a = bytes:byte(i)
|
|
out[#out + 1] = ENC[floor(a / 4)] .. ENC[(a % 4) * 16] .. "=="
|
|
elseif rest == 2 then
|
|
local a, b = bytes:byte(i, i + 1)
|
|
local word = a * 256 + b
|
|
out[#out + 1] = ENC[floor(word / 1024)] .. ENC[floor(word / 16) % 64]
|
|
.. ENC[(word % 16) * 4] .. "="
|
|
end
|
|
return concat(out)
|
|
end
|
|
|
|
function Base64.decode(text)
|
|
if type(text) ~= "string" then return nil, "base64 input must be a string" end
|
|
local n = #text
|
|
if n % 4 ~= 0 then return nil, "base64 length must be a multiple of four" end
|
|
if n == 0 then return "" end
|
|
local out = {}
|
|
local last = n - 3
|
|
for i = 1, n, 4 do
|
|
local b1, b2, b3, b4 = text:byte(i, i + 3)
|
|
local v1, v2 = DEC[b1], DEC[b2]
|
|
if not v1 or not v2 then
|
|
return nil, "base64 holds a character outside the alphabet"
|
|
end
|
|
if i == last and b3 == PAD then
|
|
if b4 ~= PAD then return nil, "base64 padding is malformed" end
|
|
if v2 % 16 ~= 0 then return nil, "base64 padding carries data bits" end
|
|
out[#out + 1] = char(v1 * 4 + floor(v2 / 16))
|
|
elseif i == last and b4 == PAD then
|
|
local v3 = DEC[b3]
|
|
if not v3 then return nil, "base64 holds a character outside the alphabet" end
|
|
if v3 % 4 ~= 0 then return nil, "base64 padding carries data bits" end
|
|
local word = v1 * 1024 + v2 * 16 + floor(v3 / 4)
|
|
out[#out + 1] = char(floor(word / 256), word % 256)
|
|
else
|
|
local v3, v4 = DEC[b3], DEC[b4]
|
|
if not v3 or not v4 then
|
|
return nil, "base64 holds a character outside the alphabet"
|
|
end
|
|
local word = v1 * 262144 + v2 * 4096 + v3 * 64 + v4
|
|
out[#out + 1] = char(floor(word / 65536), floor(word / 256) % 256, word % 256)
|
|
end
|
|
end
|
|
return concat(out)
|
|
end
|
|
|
|
return Base64
|