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
+1
View File
@@ -15,6 +15,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
## Gen 2 Specifics
+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()
+432
View File
@@ -0,0 +1,432 @@
local Base64 = require("src.core.Base64")
local GameVersion = require("src.core.GameVersion")
local SafePath = require("src.mods.SafePath")
local SaveSerializer = require("src.core.SaveSerializer")
local Semver = require("src.mods.Semver")
local StreamMD5 = require("src.mods.StreamMD5")
local CartManifest = {}
CartManifest.SCHEMA = 1
CartManifest.EXT = ".g1rcart"
CartManifest.FORMAT = "g1rcart"
CartManifest.DIR = "carts"
CartManifest.SEALS = { sealed = true, open = true }
CartManifest.SOURCES = { github = true, gamebanana = true, ["local"] = true }
CartManifest.ART_ENCODINGS = { base64 = true }
CartManifest.PNG_SIGNATURE = "\137PNG\r\n\26\10"
CartManifest.MAX_ID = 64
CartManifest.MAX_TITLE = 48
CartManifest.MAX_AUTHOR = 64
CartManifest.MAX_SUMMARY = 120
CartManifest.MAX_LABEL = 128
CartManifest.MAX_LABEL_ART = 1024 * 1024
CartManifest.MAX_MODS = 64
CartManifest.MAX_OPTIONS = 64
CartManifest.MAX_OPTION_KEY = 64
CartManifest.MAX_OPTION_TEXT = 256
local function trim(text)
return text:match("^%s*(.-)%s*$")
end
local function isId(value)
return type(value) == "string" and value ~= "" and #value <= CartManifest.MAX_ID
and value:match("^[%w_%-]+$") ~= nil
end
local function isRepo(value)
if type(value) ~= "string" then return false end
local owner, name = value:match("^([%w%._%-]+)/([%w%._%-]+)$")
return owner ~= nil and name ~= nil
end
local function isHex(value, width)
return type(value) == "string" and #value == width
and value:match("^[0-9a-f]+$") ~= nil
end
local function isCount(value)
return type(value) == "number" and value > 0 and value % 1 == 0
end
local function isSemver(value)
return type(value) == "string" and Semver.parse(value) ~= nil
end
local function parseOptions(raw, label)
if raw == nil then return nil end
if type(raw) ~= "table" then
return nil, label .. " options must be a table"
end
local keys = {}
for key in pairs(raw) do
if type(key) ~= "string" then
return nil, label .. " option keys must be strings"
end
if key == "" or #key > CartManifest.MAX_OPTION_KEY then
return nil, ("%s option keys must be 1 to %d characters")
:format(label, CartManifest.MAX_OPTION_KEY)
end
keys[#keys + 1] = key
end
if #keys > CartManifest.MAX_OPTIONS then
return nil, ("%s carries more than %d options")
:format(label, CartManifest.MAX_OPTIONS)
end
local out = {}
for _, key in ipairs(keys) do
local value = raw[key]
local kind = type(value)
if kind == "string" then
if #value > CartManifest.MAX_OPTION_TEXT then
return nil, ("%s option %q must be %d characters or fewer")
:format(label, key, CartManifest.MAX_OPTION_TEXT)
end
elseif kind ~= "number" and kind ~= "boolean" then
return nil, ("%s option %q must be a string, number or boolean")
:format(label, key)
end
out[key] = value
end
return out
end
local function parseMod(raw, index, seen)
local label = ("cart mod #%d"):format(index)
if type(raw) ~= "table" then return nil, label .. " must be a table" end
if not isId(raw.id) then
return nil, ("%s id must be 1 to %d characters of letters, numbers, _ or -")
:format(label, CartManifest.MAX_ID)
end
label = ("cart mod %q"):format(raw.id)
if seen[raw.id] then return nil, label .. " is pinned twice" end
seen[raw.id] = true
local source = raw.source
if type(source) ~= "string" or not CartManifest.SOURCES[source] then
return nil, label .. " source must be github, gamebanana or local"
end
local entry = { id = raw.id, source = source }
if source == "github" then
if not isRepo(raw.repo) then
return nil, label .. " repo must be owner/name"
end
if not isSemver(raw.version) then
return nil, label .. " version must be a semantic version"
end
if not isHex(raw.sha256, 64) then
return nil, label .. " sha256 must be 64 lowercase hex characters"
end
entry.repo = raw.repo
entry.version = trim(raw.version)
entry.sha256 = raw.sha256
elseif source == "local" then
if not isSemver(raw.version) then
return nil, label .. " version must be a semantic version"
end
entry.version = trim(raw.version)
else
if not isCount(raw.mod) then
return nil, label .. " mod must be a positive integer"
end
if not isCount(raw.file) then
return nil, label .. " file must be a positive integer"
end
if not isHex(raw.md5, 32) then
return nil, label .. " md5 must be 32 lowercase hex characters"
end
entry.mod = raw.mod
entry.file = raw.file
entry.md5 = raw.md5
end
local options, err = parseOptions(raw.options, label)
if err then return nil, err end
entry.options = options
return entry
end
local function parseOrder(raw, mods)
local out = {}
if raw == nil then
for i, entry in ipairs(mods) do out[i] = entry.id end
return out
end
if type(raw) ~= "table" then return nil, "cart load_order must be an array" end
if #raw ~= #mods then
return nil, "cart load_order must list every pinned mod exactly once"
end
local pinned, seen = {}, {}
for _, entry in ipairs(mods) do pinned[entry.id] = true end
for i = 1, #raw do
local id = raw[i]
if type(id) ~= "string" or not pinned[id] then
return nil, ("cart load_order names %s, which the cart does not pin")
:format(tostring(id))
end
if seen[id] then
return nil, ("cart load_order names %s twice"):format(id)
end
seen[id] = true
out[i] = id
end
return out
end
function CartManifest.parse(tbl)
if type(tbl) ~= "table" then return nil, "cart must be a table" end
if not isId(tbl.id) then
return nil, ("cart id must be 1 to %d characters of letters, numbers, _ or -")
:format(CartManifest.MAX_ID)
end
if type(tbl.title) ~= "string" then return nil, "cart title is required" end
local title = trim(tbl.title)
if title == "" or #title > CartManifest.MAX_TITLE then
return nil, ("cart title must be 1 to %d characters"):format(CartManifest.MAX_TITLE)
end
if not isSemver(tbl.version) then
return nil, "cart version must be a semantic version"
end
if type(tbl.author) ~= "string" then return nil, "cart author is required" end
local author = trim(tbl.author)
if author == "" or #author > CartManifest.MAX_AUTHOR then
return nil, ("cart author must be 1 to %d characters"):format(CartManifest.MAX_AUTHOR)
end
local repo = nil
if tbl.repo ~= nil then
if not isRepo(tbl.repo) then return nil, "cart repo must be owner/name" end
repo = tbl.repo
end
local summary = nil
if tbl.summary ~= nil then
if type(tbl.summary) ~= "string" then
return nil, "cart summary must be a string"
end
summary = trim(tbl.summary)
if #summary > CartManifest.MAX_SUMMARY then
return nil, ("cart summary must be %d characters or fewer")
:format(CartManifest.MAX_SUMMARY)
end
end
local shell = type(tbl.shell) == "string" and tbl.shell:match("^#(%x%x%x%x%x%x)$")
if not shell then return nil, "cart shell must be a #RRGGBB colour" end
shell = "#" .. shell:lower()
local label = nil
if tbl.label ~= nil then
if type(tbl.label) ~= "string" or #tbl.label > CartManifest.MAX_LABEL then
return nil, ("cart label must be a path of %d characters or fewer")
:format(CartManifest.MAX_LABEL)
end
label = SafePath.safe(tbl.label)
if not label then return nil, "cart label must stay inside the cart" end
end
if type(tbl.base) ~= "string" or not GameVersion.VERSIONS[tbl.base] then
return nil, "cart base must name a game this engine knows"
end
local engine = nil
if tbl.engine ~= nil then
if type(tbl.engine) ~= "string" or trim(tbl.engine) == "" then
return nil, "cart engine must be a non-empty version range"
end
engine = trim(tbl.engine)
end
local seal = tbl.seal
if seal == nil then seal = "sealed" end
if type(seal) ~= "string" or not CartManifest.SEALS[seal] then
return nil, "cart seal must be sealed or open"
end
if type(tbl.mods) ~= "table" then return nil, "cart mods must be an array" end
local count = #tbl.mods
if count < 1 or count > CartManifest.MAX_MODS then
return nil, ("cart must pin 1 to %d mods"):format(CartManifest.MAX_MODS)
end
local mods, seen = {}, {}
for i = 1, count do
local entry, err = parseMod(tbl.mods[i], i, seen)
if not entry then return nil, err end
mods[i] = entry
end
local order, orderErr = parseOrder(tbl.load_order, mods)
if not order then return nil, orderErr end
return {
id = tbl.id,
title = title,
version = trim(tbl.version),
author = author,
repo = repo,
summary = summary,
shell = shell,
label = label,
base = tbl.base,
engine = engine,
seal = seal,
mods = mods,
load_order = order,
}
end
function CartManifest.parseLabelArt(raw)
if raw == nil then return nil, "cart carries no label art" end
if type(raw) ~= "table" then return nil, "cart label art must be a table" end
if type(raw.encoding) ~= "string" or not CartManifest.ART_ENCODINGS[raw.encoding] then
return nil, "cart label art encoding must be base64"
end
if type(raw.data) ~= "string" or raw.data == "" then
return nil, "cart label art data must be a base64 string"
end
local tooBig = ("cart label art must be %d bytes or fewer")
:format(CartManifest.MAX_LABEL_ART)
if #raw.data > math.ceil(CartManifest.MAX_LABEL_ART / 3) * 4 then
return nil, tooBig
end
local bytes, err = Base64.decode(raw.data)
if not bytes then return nil, "cart label art " .. err end
if #bytes > CartManifest.MAX_LABEL_ART then return nil, tooBig end
if not isCount(raw.bytes) or raw.bytes ~= #bytes then
return nil, ("cart label art declares %s bytes but decodes to %d")
:format(tostring(raw.bytes), #bytes)
end
if bytes:sub(1, #CartManifest.PNG_SIGNATURE) ~= CartManifest.PNG_SIGNATURE then
return nil, "cart label art must be a PNG"
end
local name = nil
if raw.name ~= nil then
if type(raw.name) ~= "string" or #raw.name > CartManifest.MAX_LABEL then
return nil, ("cart label art name must be a path of %d characters or fewer")
:format(CartManifest.MAX_LABEL)
end
name = SafePath.safe(raw.name)
if not name then return nil, "cart label art name must stay inside the cart" end
end
return { name = name, encoding = raw.encoding, bytes = raw.bytes, data = raw.data },
nil, bytes
end
function CartManifest.labelArtBytes(cart)
if type(cart) ~= "table" then return nil, "cart must be a table" end
local art, err, bytes = CartManifest.parseLabelArt(cart.labelArt)
if not art then return nil, err end
return bytes, art.name
end
function CartManifest.publishable(cart)
if type(cart) ~= "table" or type(cart.mods) ~= "table" then
return false, "a cart must be parsed before it can be published"
end
local unpinned = {}
for _, entry in ipairs(cart.mods) do
if type(entry) == "table" and entry.source == "local" then
unpinned[#unpinned + 1] = tostring(entry.id)
end
end
if #unpinned == 0 then return true end
table.sort(unpinned)
return false, ("%s %s pinned to this install only, so nobody else can fetch %s: publish needs a repo and an archive hash for %s")
:format(table.concat(unpinned, ", "),
#unpinned == 1 and "is" or "are",
#unpinned == 1 and "it" or "them",
#unpinned == 1 and "it" or "each")
end
local function number(value)
return ("%.17g"):format(value)
end
local function writeText(out, prefix, text)
out[#out + 1] = ("%s%d:%s"):format(prefix, #text, text)
end
local function writeValue(out, value)
local kind = type(value)
if kind == "number" then
out[#out + 1] = "#" .. number(value)
elseif kind == "boolean" then
out[#out + 1] = value and "T" or "F"
else
writeText(out, "$", tostring(value))
end
end
local function writeField(out, name, value)
if value == nil then return end
writeText(out, ".", name)
writeValue(out, value)
end
local CART_FIELDS = { "author", "base", "engine", "id", "label", "repo",
"seal", "shell", "summary", "title", "version" }
local MOD_FIELDS = { "file", "id", "md5", "mod", "repo", "sha256",
"source", "version" }
function CartManifest.canonical(cart)
local out = { "[cart]" }
for _, field in ipairs(CART_FIELDS) do writeField(out, field, cart[field]) end
out[#out + 1] = "[mods]"
for _, entry in ipairs(cart.mods or {}) do
writeText(out, "@", tostring(entry.id))
for _, field in ipairs(MOD_FIELDS) do writeField(out, field, entry[field]) end
out[#out + 1] = "[options]"
local keys = {}
for key in pairs(entry.options or {}) do keys[#keys + 1] = key end
table.sort(keys)
for _, key in ipairs(keys) do writeField(out, key, entry.options[key]) end
end
out[#out + 1] = "[order]"
for _, id in ipairs(cart.load_order or {}) do writeText(out, "@", tostring(id)) end
return table.concat(out)
end
function CartManifest.hash(cart)
return StreamMD5.new():update(CartManifest.canonical(cart)):final()
end
function CartManifest.encode(cart)
return SaveSerializer.encode({
format = CartManifest.FORMAT,
formatVersion = CartManifest.SCHEMA,
labelArt = CartManifest.parseLabelArt(cart.labelArt),
cart = { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, repo = cart.repo, summary = cart.summary,
shell = cart.shell, label = cart.label, base = cart.base,
engine = cart.engine, seal = cart.seal, mods = cart.mods,
load_order = cart.load_order },
})
end
function CartManifest.decode(str)
if type(str) ~= "string" or str == "" then return nil, "EMPTY FILE" end
local data = SaveSerializer.decode(str)
if type(data) ~= "table" then return nil, "BAD FILE" end
if data.format ~= CartManifest.FORMAT then return nil, "NOT A CART" end
if data.formatVersion ~= CartManifest.SCHEMA then
return nil, ("unknown cart schema %s"):format(tostring(data.formatVersion))
end
local cart, err = CartManifest.parse(data.cart)
if not cart then return nil, err end
cart.labelArt = CartManifest.parseLabelArt(data.labelArt)
return cart
end
return CartManifest
+338
View File
@@ -0,0 +1,338 @@
local CartManifest = require("src.carts.CartManifest")
local SaveData = require("src.core.SaveData")
local Semver = require("src.mods.Semver")
local CartStore = {}
CartStore.DIR = CartManifest.DIR
CartStore.EXT = CartManifest.EXT
CartStore.OPTIONS_KEY = "carts"
CartStore.UNPINNED_VERSION = "0.0.0"
local RECORD_FIELDS = { "id", "title", "version", "author", "base", "seal",
"shell", "summary", "hash", "file" }
local function fsOr(fs)
return fs or (love and love.filesystem) or nil
end
local function safeId(id)
return type(id) == "string" and id ~= "" and #id <= CartManifest.MAX_ID
and id:match("^[%w_%-]+$") ~= nil
end
local function fileFor(id)
return CartStore.DIR .. "/" .. id .. CartStore.EXT
end
CartStore.fileFor = fileFor
local function readOptions(fs)
local ok, opts = pcall(SaveData.loadOptions, fs)
if not ok or type(opts) ~= "table" then return {} end
return opts
end
local function writeOptions(opts, fs)
local ok = pcall(SaveData.saveOptions, opts, fs)
return ok and true or false
end
local function registry(opts)
local reg = opts[CartStore.OPTIONS_KEY]
return type(reg) == "table" and reg or nil
end
local function ensureRegistry(opts)
if type(opts[CartStore.OPTIONS_KEY]) ~= "table" then
opts[CartStore.OPTIONS_KEY] = {}
end
return opts[CartStore.OPTIONS_KEY]
end
local function recordFor(cart, hash)
return { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, base = cart.base, seal = cart.seal,
shell = cart.shell, summary = cart.summary,
hash = hash, file = fileFor(cart.id) }
end
local function sameRecord(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
for _, field in ipairs(RECORD_FIELDS) do
if a[field] ~= b[field] then return false end
end
return true
end
local function entryFor(cart, hash)
return { id = cart.id, title = cart.title, version = cart.version,
author = cart.author, base = cart.base, seal = cart.seal,
shell = cart.shell, summary = cart.summary, label = cart.label,
cart = cart, cartHash = hash, file = fileFor(cart.id) }
end
local function readCart(fs, id)
if not safeId(id) then return nil, "unknown cart id" end
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
local path = fileFor(id)
if fs.getInfo and not fs.getInfo(path) then
return nil, ("cart %q is not installed"):format(id)
end
local body = fs.read(path)
if type(body) ~= "string" or body == "" then
return nil, ("cart %q is not installed"):format(id)
end
local ok, cart, err = pcall(CartManifest.decode, body)
if not ok then return nil, "BAD CART" end
if not cart then return nil, err or "BAD CART" end
if cart.id ~= id then
return nil, ("cart file %s names %q"):format(path, tostring(cart.id))
end
local hashed, hash = pcall(CartManifest.hash, cart)
if not hashed then return nil, "BAD CART" end
return cart, hash
end
local function strayIds(fs, seen)
local out = {}
if not (fs and fs.getDirectoryItems) then return out end
if fs.getInfo and not fs.getInfo(CartStore.DIR) then return out end
local ok, items = pcall(fs.getDirectoryItems, CartStore.DIR)
if not ok then return out end
for _, name in ipairs(items or {}) do
if type(name) == "string" and name:sub(-#CartStore.EXT) == CartStore.EXT then
local id = name:sub(1, #name - #CartStore.EXT)
if safeId(id) and not seen[id] then
seen[id] = true
out[#out + 1] = id
end
end
end
table.sort(out)
return out
end
function CartStore.index(fs)
local opts = readOptions(fsOr(fs))
local reg = registry(opts) or {}
local out = {}
for id, record in pairs(reg) do
if safeId(id) and type(record) == "table" then
local row = { id = id, file = fileFor(id) }
for _, field in ipairs(RECORD_FIELDS) do
if record[field] ~= nil then row[field] = record[field] end
end
row.id, row.title = id, record.title or id
out[#out + 1] = row
end
end
table.sort(out, function(a, b)
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
if at ~= bt then return at < bt end
return a.id < b.id
end)
return out
end
function CartStore.list(fs)
fs = fsOr(fs)
local rows = {}
if not fs then return rows end
local opts = readOptions(fs)
local reg = registry(opts) or {}
local ids, seen = {}, {}
for id in pairs(reg) do
if safeId(id) and not seen[id] then
seen[id] = true
ids[#ids + 1] = id
end
end
table.sort(ids)
for _, id in ipairs(strayIds(fs, seen)) do ids[#ids + 1] = id end
for _, id in ipairs(ids) do
local cart, hash = readCart(fs, id)
if cart then rows[#rows + 1] = entryFor(cart, hash) end
end
table.sort(rows, function(a, b)
local at, bt = tostring(a.title):lower(), tostring(b.title):lower()
if at ~= bt then return at < bt end
return a.id < b.id
end)
local healed, changed = {}, false
for _, row in ipairs(rows) do
healed[row.id] = recordFor(row.cart, row.cartHash)
if not sameRecord(reg[row.id], healed[row.id]) then changed = true end
end
for id in pairs(reg) do
if healed[id] == nil then changed = true end
end
if changed then
opts[CartStore.OPTIONS_KEY] = healed
writeOptions(opts, fs)
end
return rows
end
function CartStore.listFor(version, fs)
local out = {}
for _, row in ipairs(CartStore.list(fs)) do
if row.base == version then out[#out + 1] = row end
end
return out
end
function CartStore.get(id, fs)
return readCart(fsOr(fs), id)
end
function CartStore.labelArt(id, fs)
local cart, err = readCart(fsOr(fs), id)
if not cart then return nil, err end
return CartManifest.labelArtBytes(cart)
end
function CartStore.export(id, fs)
local cart, err = readCart(fsOr(fs), id)
if not cart then return nil, err end
return CartManifest.encode(cart), CartManifest.hash(cart)
end
function CartStore.install(bytes, fs)
fs = fsOr(fs)
if not (fs and fs.write) then return nil, "NO FILESYSTEM" end
local ok, cart, err = pcall(CartManifest.decode, bytes)
if not ok then return nil, "BAD CART" end
if not cart then return nil, err or "BAD CART" end
local existing = readCart(fs, cart.id)
if existing then
local order = Semver.compare(cart.version, existing.version)
if order and order < 0 then
return nil, ("%s %s is older than the installed %s")
:format(cart.title, cart.version, existing.version)
end
end
if fs.createDirectory then fs.createDirectory(CartStore.DIR) end
local wrote, writeErr = fs.write(fileFor(cart.id), CartManifest.encode(cart))
if not wrote then return nil, tostring(writeErr) end
local hash = CartManifest.hash(cart)
local opts = readOptions(fs)
ensureRegistry(opts)[cart.id] = recordFor(cart, hash)
writeOptions(opts, fs)
return cart, hash
end
function CartStore.uninstall(id, fs)
fs = fsOr(fs)
if not safeId(id) then return nil, "unknown cart id" end
if not fs then return nil, "NO FILESYSTEM" end
local path = fileFor(id)
local present = not fs.getInfo or fs.getInfo(path) ~= nil
if present and fs.read and fs.read(path) == nil then present = false end
local opts = readOptions(fs)
local reg = registry(opts)
local known = reg ~= nil and reg[id] ~= nil
if not present and not known then
return nil, ("cart %q is not installed"):format(id)
end
if present and fs.remove then fs.remove(path) end
if known then
reg[id] = nil
writeOptions(opts, fs)
end
return true
end
local function manifestOf(row)
return type(row.manifest) == "table" and row.manifest or nil
end
local function textOf(...)
for i = 1, select("#", ...) do
local value = select(i, ...)
if type(value) == "string" and value ~= "" then return value end
end
return nil
end
local function pinRepo(row)
local m = manifestOf(row)
return textOf(row.github, m and m.github)
end
local function pinHash(row)
local m = manifestOf(row)
local hash = textOf(row.sha256, row.archiveSha256,
m and m.sha256, m and m.archiveSha256)
if hash and #hash == 64 and hash:match("^[0-9a-f]+$") then return hash end
return nil
end
local function frozenOptions(bucket)
if type(bucket) ~= "table" then return nil end
local out, any = {}, false
for key, value in pairs(bucket) do
local kind = type(value)
if type(key) == "string" and key ~= ""
and (kind == "string" or kind == "number" or kind == "boolean") then
out[key] = value
any = true
end
end
return any and out or nil
end
local function pinReason(repo, version, semver, hash)
local why = {}
if not repo then why[#why + 1] = "no GitHub repo is recorded" end
if not semver then
why[#why + 1] = ("version %s is not a semantic version, so it pins as %s")
:format(version and ("%q"):format(version) or "is missing",
CartStore.UNPINNED_VERSION)
end
if repo and semver and not hash then
why[#why + 1] = "no archive hash is known yet"
end
return table.concat(why, " and ")
end
function CartStore.capture(identity, available, modOptions)
if type(identity) ~= "table" then return nil, "cart identity is required" end
local mods, order, unresolved = {}, {}, {}
for _, row in ipairs(available or {}) do
if type(row) == "table" and row.enabled and safeId(row.id) then
local m = manifestOf(row)
local version = textOf(row.version, m and m.version)
local semver = version and Semver.parse(version) and version or nil
local repo = pinRepo(row)
local hash = pinHash(row)
local entry
if repo and semver and hash then
entry = { id = row.id, source = "github", repo = repo,
version = semver, sha256 = hash }
else
entry = { id = row.id, source = "local",
version = semver or CartStore.UNPINNED_VERSION }
unresolved[#unresolved + 1] = {
id = row.id,
name = textOf(row.name, m and m.name) or row.id,
version = version,
reason = pinReason(repo, version, semver, hash),
}
end
entry.options = frozenOptions(modOptions and modOptions[row.id])
mods[#mods + 1] = entry
order[#order + 1] = row.id
end
end
local cart, err = CartManifest.parse({
id = identity.id, title = identity.title, version = identity.version,
author = identity.author, repo = identity.repo, summary = identity.summary,
shell = identity.shell, label = identity.label, base = identity.base,
engine = identity.engine, seal = identity.seal,
mods = mods, load_order = order,
})
if not cart then return nil, err end
return cart, unresolved
end
return CartStore
+75
View File
@@ -0,0 +1,75 @@
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
+392 -115
View File
@@ -759,25 +759,41 @@ end
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
-- is used, which keeps a brand-new install and every pre-slots caller
-- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local activeSlotCache = {} -- scope key -> slotId in use, or false when none
local slotsChecked = {} -- scope key -> true once resolved this process
-- At most one New Game can be the live candidate for a first public tool
-- request. A single strong reference models that runtime fact without adding
-- marker data to the save or retaining abandoned playthrough tables.
local freshPlaythrough
local function slotDir(version) return "saves/" .. version end
local CART_PREFIX = "cart_"
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
local activeCart, activeCartHash
local sealBroken = false
local function cartKey(cartId)
if type(cartId) ~= "string" or #cartId > 64 then return nil end
if not cartId:match("^%w[%w%._%-]*$") then return nil end
return CART_PREFIX .. cartId
end
local function isCartKey(key)
return key:sub(1, #CART_PREFIX) == CART_PREFIX
end
local function slotDir(key) return "saves/" .. key end
local function slotNames(key, id)
local main = slotDir(key) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua / save_yellow.lua for the others); still the destination
-- before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
-- the pre-slots flat names a scope uses before any slot exists (save.lua for
-- Red, save_blue.lua / save_yellow.lua for the other versions, and
-- save_cart_<id>.lua for a cart)
local function legacyNames(key)
local suffix = isCartKey(key) and ("_" .. key) or GameVersion.saveSuffix(key)
local main = "save" .. suffix .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
@@ -790,6 +806,37 @@ local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
local function knownScope(key)
if type(key) ~= "string" or key == "" then return false end
if isCartKey(key) then return true end
return knownVersion(key)
end
local function activeScopeKey(version)
if activeCart then return CART_PREFIX .. activeCart end
return version or GameVersion.get()
end
local function registryOf(opts, key)
local root, id = opts.saveSlots, key
if isCartKey(key) then
root, id = opts.cartSlots, key:sub(#CART_PREFIX + 1)
end
if type(root) ~= "table" then return nil end
local reg = root[id]
return type(reg) == "table" and reg or nil
end
local function putRegistry(opts, key, reg)
if isCartKey(key) then
opts.cartSlots = type(opts.cartSlots) == "table" and opts.cartSlots or {}
opts.cartSlots[key:sub(#CART_PREFIX + 1)] = reg
else
opts.saveSlots = type(opts.saveSlots) == "table" and opts.saveSlots or {}
opts.saveSlots[key] = reg
end
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
@@ -802,8 +849,8 @@ end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local function decodeSlot(fs, key, id)
local main, bak, tmp = slotNames(key, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
@@ -818,30 +865,29 @@ end
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local function tryMigrateLegacy(key, fs)
local lmain, lbak, ltmp = legacyNames(key)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
local dmain, dbak = slotNames(key, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
if not decodeSlot(fs, key, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
putRegistry(opts, key, { list = { id }, active = id })
-- A tool may have allocated the legacy scope before the player made their
-- first ordinary SAVE. Promoting that flat save into slot1 must preserve the
-- same opaque identity; otherwise title-selected mod storage becomes
-- unreachable after the migration even though every durable record exists.
local ids = opts.playthroughIds and opts.playthroughIds[version]
local ids = opts.playthroughIds and opts.playthroughIds[key]
if type(ids) == "table" and type(ids.legacy) == "string" and ids.legacy ~= "" then
if type(ids[id]) ~= "string" or ids[id] == "" then ids[id] = ids.legacy end
ids.legacy = nil
@@ -852,9 +898,9 @@ end
-- Scan the filesystem for orphaned slot files under saves/<version>/ when options.lua
-- has no registered slots for this version (e.g. options.lua was reset or lost).
local function scanDiskSlots(version, fs)
local function scanDiskSlots(key, fs)
if not fs then return nil end
local dir = "saves/" .. version
local dir = slotDir(key)
local slots = {}
if fs.getDirectoryItems and fs.getInfo and fs.getInfo(dir) then
local items = pcall(fs.getDirectoryItems, dir) and fs.getDirectoryItems(dir) or {}
@@ -882,49 +928,49 @@ local function scanDiskSlots(version, fs)
return #slots > 0 and slots or nil
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- Resolve (once per scope per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise auto-recover disk slots; otherwise false (flat legacy path).
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
local function ensureSlots(key, fs)
if slotsChecked[key] then return end
slotsChecked[key] = true
if not knownScope(key) then
activeSlotCache[key] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local reg = registryOf(opts, key)
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
activeSlotCache[key] = reg.active or reg.list[1]
return
end
local migrated = tryMigrateLegacy(version, fs)
local migrated = tryMigrateLegacy(key, fs)
if migrated then
activeSlotCache[version] = migrated
activeSlotCache[key] = migrated
return
end
-- Auto-recovery: if options.lua lost its slot registry, scan disk for orphaned slot files
local recovered = scanDiskSlots(version, fs)
local recovered = scanDiskSlots(key, fs)
if recovered and #recovered > 0 then
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = recovered, active = recovered[1] }
putRegistry(opts, key, { list = recovered, active = recovered[1] })
SaveData.saveOptions(opts, fs)
activeSlotCache[version] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, version)
activeSlotCache[key] = recovered[1]
Logger.info("auto-recovered %d save slot(s) for %s from disk", #recovered, key)
return
end
activeSlotCache[version] = false
activeSlotCache[key] = false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
-- the scope -- the active cart when one is set, otherwise the version --
-- falling back to the flat legacy names when no slot is in use.
function saveNames(version, injectedFs)
version = version or GameVersion.get()
local key = activeScopeKey(version)
local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
ensureSlots(key, fs)
local slot = activeSlotCache[key]
if slot then return slotNames(key, slot) end
return legacyNames(key)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
@@ -1005,26 +1051,33 @@ function SaveData.slotDiskPath(version, slotId)
return base .. sep .. rel:gsub("/", sep)
end
-- Slots visible to the launcher: every registered slot for a version, each
-- Slots visible to the launcher: every registered slot for a scope, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
-- is migrated to slot1 first.
local function listSlotsIn(key)
local fs = persistFs(nil)
ensureSlots(key, fs)
local opts = SaveData.loadOptions(fs)
local reg = registryOf(opts, key)
local list = (reg and type(reg.list) == "table" and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, key, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
label = reg.names and reg.names[id] or nil,
cartHash = reg.hashes and reg.hashes[id] or nil,
sealBroken = (reg.broken and reg.broken[id] == true)
or false }
end
return out
end
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
label = reg.names and reg.names[id] or nil }
end
return out
return listSlotsIn(version)
end
function SaveData.readSlotSource(version, slotId, injectedFs)
@@ -1049,17 +1102,14 @@ end
-- needs no save rewrite and an empty slot can be labeled too. The label is
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
-- false + an error string when the id is not registered.
function SaveData.renameSlot(version, slotId, name)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
local function renameSlotIn(key, slotId, name)
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local reg = registryOf(opts, key)
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
@@ -1070,47 +1120,55 @@ function SaveData.renameSlot(version, slotId, name)
reg.names = reg.names or {}
reg.names[slotId] = label
if next(reg.names) == nil then reg.names = nil end
opts.saveSlots[version] = reg
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
return true
end
function SaveData.renameSlot(version, slotId, name)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
return renameSlotIn(version, slotId, name)
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local function setActiveSlotIn(key, slotId)
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local reg = registryOf(opts, key) or { list = {}, active = nil }
reg.list = type(reg.list) == "table" and reg.list or {}
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
slotsChecked[key] = true
activeSlotCache[key] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
return setActiveSlotIn(version, slotId)
end
-- Register a new empty slot for the scope and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local function createSlotIn(key)
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
ensureSlots(key, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local reg = registryOf(opts, key) or { list = {}, active = nil }
reg.list = type(reg.list) == "table" and reg.list or {}
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
@@ -1118,21 +1176,31 @@ function SaveData.createSlot(version)
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
return createSlotIn(version)
end
-- The active slot id in use for a scope (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
local function activeSlotIn(key)
local fs = persistFs(nil)
ensureSlots(key, fs)
return activeSlotCache[key] or nil
end
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
return activeSlotIn(version)
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
@@ -1142,12 +1210,10 @@ end
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
local function writeSlotIn(key, slotId, saveTable)
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local main, bak, tmp = slotNames(key, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
@@ -1164,52 +1230,253 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true
end
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
return writeSlotIn(version, slotId, saveTable)
end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
local function deleteSlotIn(key, slotId)
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
ensureSlots(key, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local reg = registryOf(opts, key)
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
local main, bak, tmp = slotNames(key, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.names then reg.names[slotId] = nil end
if reg.hashes then
reg.hashes[slotId] = nil
if next(reg.hashes) == nil then reg.hashes = nil end
end
if reg.broken then
reg.broken[slotId] = nil
if next(reg.broken) == nil then reg.broken = nil end
end
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
slotsChecked[key] = true
activeSlotCache[key] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
return deleteSlotIn(version, slotId)
end
-- Test seam: drop the process-global slot cache (and the active cart) so a
-- suite can exercise migration/resolution against a freshly injected
-- filesystem. Unused by the game, which resolves each scope exactly once per
-- boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
freshPlaythrough = nil
activeCart, activeCartHash = nil, nil
sealBroken = false
end
-- ------- custom carts
function SaveData.setCart(cartId, cartHash)
if cartId ~= nil and cartKey(cartId) then
activeCart = cartId
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
else
activeCart, activeCartHash = nil, nil
end
return activeCart
end
function SaveData.getCart()
return activeCart
end
function SaveData.setCartHash(cartHash)
activeCartHash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
return activeCartHash
end
function SaveData.getCartHash()
return activeCartHash
end
function SaveData.breakSeal(save)
sealBroken = true
if type(save) == "table" then
save.meta = type(save.meta) == "table" and save.meta or {}
save.meta.sealBroken = true
end
return true
end
function SaveData.isSealBroken(save)
if type(save) == "table" then
return type(save.meta) == "table" and save.meta.sealBroken == true
end
return sealBroken
end
function SaveData.listCartSlots(cartId)
local key = cartKey(cartId or activeCart)
if not key then return {} end
return listSlotsIn(key)
end
function SaveData.createCartSlot(cartId)
local key = cartKey(cartId or activeCart)
if not key then return nil end
return createSlotIn(key)
end
function SaveData.setActiveCartSlot(cartId, slotId)
local key = cartKey(cartId or activeCart)
if not key then return nil end
if type(slotId) ~= "string" or slotId == "" then return nil end
return setActiveSlotIn(key, slotId)
end
function SaveData.activeCartSlot(cartId)
local key = cartKey(cartId or activeCart)
if not key then return nil end
return activeSlotIn(key)
end
function SaveData.renameCartSlot(cartId, slotId, name)
local key = cartKey(cartId or activeCart)
if not key then return false, "unknown cart" end
return renameSlotIn(key, slotId, name)
end
function SaveData.deleteCartSlot(cartId, slotId)
local key = cartKey(cartId or activeCart)
if not key then return false, "unknown cart" end
return deleteSlotIn(key, slotId)
end
function SaveData.writeCartSlot(cartId, slotId, saveTable)
local key = cartKey(cartId or activeCart)
if not key then return false, "unknown cart" end
local ok, err = writeSlotIn(key, slotId, saveTable)
if not ok then return ok, err end
local hash = type(saveTable.meta) == "table" and saveTable.meta.cartHash or nil
if type(hash) == "string" and hash ~= "" then
SaveData.setSlotCartHash(cartId or activeCart, slotId, hash)
end
return true
end
function SaveData.slotCartHash(cartId, slotId)
local key = cartKey(cartId or activeCart)
if not key or type(slotId) ~= "string" then return nil end
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
local hash = reg and type(reg.hashes) == "table" and reg.hashes[slotId] or nil
if type(hash) ~= "string" or hash == "" then return nil end
return hash
end
function SaveData.setSlotCartHash(cartId, slotId, cartHash)
local key = cartKey(cartId or activeCart)
if not key then return false, "unknown cart" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
local reg = registryOf(opts, key)
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then return false, "slot not registered" end
local hash = (type(cartHash) == "string" and cartHash ~= "") and cartHash or nil
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
if reg.hashes[slotId] == hash then return true end
reg.hashes[slotId] = hash
if next(reg.hashes) == nil then reg.hashes = nil end
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
return true
end
function SaveData.slotSealBroken(cartId, slotId)
local key = cartKey(cartId or activeCart)
if not key or type(slotId) ~= "string" then return false end
local reg = registryOf(SaveData.loadOptions(persistFs(nil)), key)
local broken = reg and type(reg.broken) == "table" and reg.broken[slotId]
return broken == true
end
function SaveData.markSlotSealBroken(cartId, slotId)
local key = cartKey(cartId or activeCart)
if not key then return false, "unknown cart" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
local reg = registryOf(opts, key)
if not reg or type(reg.list) ~= "table" then return false, "slot not registered" end
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then return false, "slot not registered" end
reg.broken = type(reg.broken) == "table" and reg.broken or {}
if reg.broken[slotId] == true then return true end
reg.broken[slotId] = true
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
return true
end
function SaveData.adoptCartSeal(cartId)
local id = cartId or activeCart
if not cartKey(id) then return false end
local slot = SaveData.activeCartSlot(id)
if type(slot) ~= "string" or not SaveData.slotSealBroken(id, slot) then
return false
end
SaveData.breakSeal()
return true
end
local function stampActiveCartHash(fs)
if not (activeCart and activeCartHash) then return end
local key = CART_PREFIX .. activeCart
local slot = activeSlotCache[key]
if type(slot) ~= "string" then return end
local opts = SaveData.loadOptions(fs)
local reg = registryOf(opts, key)
if not reg or type(reg.list) ~= "table" then return end
reg.hashes = type(reg.hashes) == "table" and reg.hashes or {}
if reg.hashes[slot] == activeCartHash then return end
reg.hashes[slot] = activeCartHash
putRegistry(opts, key, reg)
SaveData.saveOptions(opts, fs)
end
-- ------- opaque playthrough identity
@@ -1235,10 +1502,10 @@ function SaveData.newPlaythroughId()
end
local function playthroughScope(version, injectedFs)
version = version or GameVersion.get()
local key = activeScopeKey(version)
local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or "legacy"
ensureSlots(key, fs)
return activeSlotCache[key] or "legacy", key
end
local function rememberPlaythroughId(save, opts, injectedFs)
@@ -1246,7 +1513,7 @@ local function rememberPlaythroughId(save, opts, injectedFs)
local id = type(meta) == "table" and meta.playthroughId
if type(id) ~= "string" or id == "" then return opts, false end
local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs)
local scope, key = playthroughScope(version, injectedFs)
local persisted = SaveData.loadOptions(injectedFs)
if opts then
-- Slot selection and opaque playthrough routing are engine-owned launcher
@@ -1254,14 +1521,15 @@ local function rememberPlaythroughId(save, opts, injectedFs)
-- save was promoted to slot1; writing that stale snapshot must not erase
-- the freshly persisted routing and strand tool storage on next boot.
opts.saveSlots = deepCopy(persisted.saveSlots)
opts.cartSlots = deepCopy(persisted.cartSlots)
opts.playthroughIds = deepCopy(persisted.playthroughIds)
else
opts = persisted
end
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
local changed = opts.playthroughIds[version][scope] ~= id
opts.playthroughIds[version][scope] = id
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
local changed = opts.playthroughIds[key][scope] ~= id
opts.playthroughIds[key][scope] = id
return opts, changed
end
@@ -1275,11 +1543,11 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
if type(id) == "string" and id ~= "" then return id end
local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs)
local scope, key = playthroughScope(version, injectedFs)
local opts = SaveData.loadOptions(injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
local existing = byVersion and byVersion[scope]
id = not isFresh and existing or nil
if type(id) ~= "string" or id == "" then
@@ -1296,8 +1564,8 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
-- storage and repeating on every launch.
if not (isFresh and type(existing) == "string" and existing ~= "") then
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
opts.playthroughIds[key] = opts.playthroughIds[key] or {}
opts.playthroughIds[key][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
end
@@ -1325,9 +1593,9 @@ function SaveData.selectedPlaythroughId(save, injectedFs)
-- Resolve the selected scope first. That may perform the one-time legacy
-- save-to-slot migration, which also moves the opaque identity mapping; only
-- then read options so this lookup never observes the pre-migration table.
local scope = playthroughScope(version, injectedFs)
local scope, key = playthroughScope(version, injectedFs)
local opts = SaveData.loadOptions(injectedFs)
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
local byVersion = opts.playthroughIds and opts.playthroughIds[key]
id = byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then
return nil, "no_selected_playthrough",
@@ -1394,6 +1662,8 @@ function SaveData.buildMeta(mods, previous, sessionStart)
savedAt = savedAt,
sessionStart = started,
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
cartHash = type(previous) == "table" and previous.cartHash or nil,
sealBroken = (type(previous) == "table" and previous.sealBroken == true) or nil,
mods = list,
}
end
@@ -1619,6 +1889,12 @@ function SaveData.save(data, mods)
if mods ~= nil or data.meta == nil then
data.meta = SaveData.buildMeta(mods, data.meta)
end
if activeCart and activeCartHash and type(data.meta) == "table" then
data.meta.cartHash = activeCartHash
end
if sealBroken and type(data.meta) == "table" then
data.meta.sealBroken = true
end
local gameOnly = {}
for k, v in pairs(data) do
if k ~= "options" then gameOnly[k] = v end
@@ -1646,6 +1922,7 @@ function SaveData.save(data, mods)
return false
end
remove(fs, TMP_FILENAME)
stampActiveCartHash(fs)
Logger.info("saved game")
return true
end
+401 -43
View File
@@ -411,32 +411,69 @@ local function cartColor(version)
return CART_COLOR[version] or PAL.green
end
local function shellColor(hex)
local r, g, b = tostring(hex):match("^#(%x%x)(%x%x)(%x%x)$")
if not r then return nil end
return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) }
end
local function cartSkin(imp, version)
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
if not row then
return { cacheKey = version, color = cartColor(version),
labelPath = "assets/labels/" .. tostring(version) .. ".png" }
end
return { cacheKey = "cart:" .. tostring(row.id),
color = shellColor(row.shell) or cartColor(version),
name = row.title, cart = row, cartId = row.id }
end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
local function cartridgeState(imp, version)
local function cartridgeState(imp, key)
imp._cartridge = imp._cartridge or {}
local state = imp._cartridge[version]
local state = imp._cartridge[key]
if not state then
state = { spin = 0, lastTime = Kit.time }
imp._cartridge[version] = state
imp._cartridge[key] = state
end
return state
end
local function cartridgeLabel(imp, version)
local function cartLabelImage(cartId)
local CartStore = require("src.carts.CartStore")
local got, bytes = pcall(CartStore.labelArt, cartId)
if not got or type(bytes) ~= "string" or bytes == "" then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local made, image = pcall(function()
return love.graphics.newImage(
love.filesystem.newFileData(bytes, tostring(cartId) .. ".png"))
end)
if not made then return nil end
return image
end
local function cartridgeLabel(imp, key, path, cartId)
imp._cartridgeLabels = imp._cartridgeLabels or {}
local label = imp._cartridgeLabels[version]
local label = imp._cartridgeLabels[key]
if label ~= nil then return label or nil end
local ok, image = pcall(love.graphics.newImage,
"assets/labels/" .. tostring(version) .. ".png")
if not ok then
imp._cartridgeLabels[version] = false
local image
if cartId then
image = cartLabelImage(cartId)
elseif path then
local ok, art = pcall(love.graphics.newImage, path)
if ok then image = art end
end
local sized, iw, ih = false, nil, nil
if image then sized, iw, ih = pcall(image.getDimensions, image) end
if not (sized and type(iw) == "number" and type(ih) == "number"
and iw > 0 and ih > 0) then
imp._cartridgeLabels[key] = false
return nil
end
local iw, ih = image:getDimensions()
label = { image = image, width = iw, height = ih }
imp._cartridgeLabels[version] = label
imp._cartridgeLabels[key] = label
return label
end
@@ -492,10 +529,10 @@ local function cartPill(project, x, y, w, h, z, color, alpha)
cartPolygon(points, color, alpha)
end
local function cartLabelMesh(imp, version, label, points)
local function cartLabelMesh(imp, key, label, points)
if not love.graphics.newMesh then return nil end
imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {}
local mesh = imp._cartridgeLabelMeshes[version]
local mesh = imp._cartridgeLabelMeshes[key]
if not mesh then
mesh = love.graphics.newMesh({
{ 0, 0, 0, 0, 255, 255, 255, 255 },
@@ -504,7 +541,7 @@ local function cartLabelMesh(imp, version, label, points)
{ 0, 0, 0, 1, 255, 255, 255, 255 },
}, "fan", "dynamic")
mesh:setTexture(label.image)
imp._cartridgeLabelMeshes[version] = mesh
imp._cartridgeLabelMeshes[key] = mesh
end
mesh:setVertices({
{ points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 },
@@ -564,8 +601,8 @@ local function cartSendHover(shader, mx, my, hovering, screenScale)
return ok
end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version)
local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
local state = cartridgeState(imp, skin.cacheKey)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h)
@@ -640,7 +677,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local ease = math.exp(-60 * dt)
state.visScale = ease * state.visScale + (1 - ease) * desScale
if not state.animId then
local n, s = 0, tostring(version)
local n, s = 0, tostring(skin.cacheKey)
for i = 1, #s do n = n + s:byte(i) * i end
state.animId = n
end
@@ -696,7 +733,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
capRight + halfW, capH, depth)
local capBack = cartQuad(project, -halfW, -halfH,
capRight + halfW, capH, -depth)
local shell = cartColor(version)
local shell = skin.color
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
@@ -798,8 +835,8 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId)
local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
@@ -934,6 +971,35 @@ local function buildModScopeRow(imp, x, y, w, m)
return h + math.floor(8 * m.s)
end
local function buildSaveCartRow(imp, x, y, w, m)
local version = imp.modScope
local h = m.btnH
local gap = math.floor(8 * m.s)
local label = Strings("Save as cart")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(28 * m.s))
local enabled = version ~= nil and imp:_cartCaptureCount(version) > 0
btn(imp, x, y, bw, h, "mods-save-cart", label, {
kind = "accent", font = "small", enabled = enabled,
action = enabled and function() imp:_beginCartSave(version) end or nil })
local hint
if version == nil then
hint = Strings("Pick one game above to save its enabled mods as a cart.")
elseif not enabled then
hint = Strings("Enable a mod for this game first.")
else
local info = GameVersion.info(version)
hint = Strings("Freeze the mods enabled for %s into a cart.",
(info and (info.launcherName or info.displayName)) or tostring(version))
end
local hx = x + bw + gap
local hw = math.max(0, x + w - hx)
if hw > 0 then
Kit.text("small", Kit.ellipsize("small", hint, hw), hx,
y + (h - Kit.textHeight("small")) / 2, PAL.muted)
end
return h + gap
end
local function findActionFor(entry, installedVersion)
local ModIndex = require("src.mods.ModIndex")
if not ModIndex.canInstall(entry) then
@@ -1512,9 +1578,11 @@ local function chipWidth(label, m)
end
local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
imp:_ensureSlots(version)
local slots = imp.slots[version] or {}
local active = imp.activeSlot[version]
local scope = imp.slotScope and imp:slotScope(version) or version
local onCart = scope ~= version
imp:_ensureSlots(scope)
local slots = imp.slots[scope] or {}
local active = imp.activeSlot[scope]
local n = #slots
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
@@ -1570,7 +1638,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local newBtnH = m.btnH
local sfNotice = imp.saveNotice[version]
local sfNotice = imp.saveNotice[scope]
local hintText, hintCol
if sfNotice then
hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
@@ -1586,7 +1654,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local listH = availH
- (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap)
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12)
local pageKey = "slots-" .. version
local pageKey = "slots-" .. scope
local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage)
setPage(imp, pageKey, cur)
@@ -1603,10 +1671,12 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local savImportLabel = imp.isNX and Strings("Scan again")
or Strings("Import save")
local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s)
btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version,
btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. scope,
savImportLabel, {
kind = "accent", font = "small", enabled = ready and true or false,
action = ready and function() imp:chooseSaveImport(version) end or nil,
kind = "accent", font = "small",
enabled = (ready and not onCart) and true or false,
action = (ready and not onCart)
and function() imp:chooseSaveImport(version) end or nil,
})
local countW = (x + w - pad - impW - math.floor(8 * m.s))
- (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s))
@@ -1629,10 +1699,10 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
for i = first, last do
local slot = slots[i]
local selected = slot.id == active
local rowKey = "slot-" .. version .. "-" .. slot.id
local rowKey = "slot-" .. scope .. "-" .. slot.id
local ry = cy + (i - first) * (rowH + gap)
local ink = rowHit(imp, x + pad, ry, iw, rowH, selected, rowKey,
function() imp:_selectSlot(version, slot.id) end)
function() imp:_selectSlot(scope, slot.id) end)
local px = x + pad + math.floor(10 * m.s)
local inner = iw - math.floor(20 * m.s)
@@ -1661,6 +1731,9 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
else
metaTxt = Strings("empty slot")
end
if slot.sealBroken then
metaTxt = Strings("%s - seal broken", metaTxt)
end
Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly,
selected and PAL.inverse or PAL.muted)
-- Where the chip block starts: centred on the row beside the text, or
@@ -1673,22 +1746,22 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
-- an export is a property of a slot, so the control belongs on the slot
-- it exports (it selects the row first, since the exporter writes
-- whichever slot is active).
local armed = deleteArmed(imp, "slot", slot.id, version)
local armed = deleteArmed(imp, "slot", slot.id, scope)
local chips = {}
if slot.exists then
if slot.exists and not onCart then
chips[#chips + 1] = { label = Strings("Export"), kind = "accent",
key = rowKey .. "-export",
action = function()
imp:_selectSlot(version, slot.id)
imp:_selectSlot(scope, slot.id)
imp:exportSave(version)
end }
end
if not imp.android then
chips[#chips + 1] = { label = Strings("Rename"), kind = "accent",
key = rowKey .. "-rename",
action = function() imp:_beginRename(version, slot.id) end }
action = function() imp:_beginRename(scope, slot.id) end }
end
if imp.onEditSave and slot.exists then
if imp.onEditSave and slot.exists and not onCart then
chips[#chips + 1] = { label = Strings("Edit"), kind = "accent",
key = rowKey .. "-edit",
action = function() imp.onEditSave(version, slot.id) end }
@@ -1699,8 +1772,8 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
w = math.max(chipWidth(DELETE_LABEL(false), m),
chipWidth(DELETE_LABEL(true), m)),
action = function()
imp:pressDelete("slot", slot.id, version, function()
imp:_deleteSlot(version, slot.id)
imp:pressDelete("slot", slot.id, scope, function()
imp:_deleteSlot(scope, slot.id)
end)
end }
for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end
@@ -1729,7 +1802,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2)
if folderRow then
cy = cy + math.floor(4 * m.s)
local key = "sav-folder-" .. version
local key = "sav-folder-" .. scope
local label = Strings("Open folder")
local lw = Kit.textWidth("small", label)
local lh = Kit.textHeight("small")
@@ -1752,19 +1825,101 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
setPage(imp, pageKey, newPage)
cy = cy + pagerH + gap
end
btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. version,
btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. scope,
Strings("+ New save slot"), {
kind = "good",
action = function() imp:_newSlot(version) end,
action = function() imp:_newSlot(scope) end,
})
return h
end
local function SEAL_LABEL(armed)
return armed and Strings("Break it") or Strings("Break the seal")
end
local function sealSlotName(slot)
if not slot then return nil end
if type(slot.label) == "string" and slot.label ~= "" then return slot.label end
return tostring(slot.id):match("^slot(%d+)$") or tostring(slot.id)
end
local function buildCartCard(imp, x, y, w, m, version)
if not imp.cartPlan then return 0 end
local report, slot = imp:cartPlan(version)
if not report then return 0 end
local title = tostring(report.title or report.id or "")
local broken = (slot and slot.sealBroken == true) or false
local state, stateCol, body = nil, PAL.green, {}
if report.refused then
state, stateCol = Strings("This cart will not start"), PAL.red
body[#body + 1] = { report.message, PAL.detail }
body[#body + 1] = { Strings("Break the seal to play it with the mods you have."),
PAL.detail }
elseif broken then
state, stateCol = Strings("Seal broken"), PAL.yellow
body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."),
PAL.detail }
elseif report.sealed then
state = Strings("Sealed - ready to play")
body[#body + 1] = { Strings("This cart loads only the mods it pins."),
PAL.detail }
else
state = Strings("Open cart - ready to play")
body[#body + 1] = { Strings("This cart's pinned mods load first, then your other enabled mods."),
PAL.detail }
end
local scope = imp:slotScope(version)
local offer = report.sealed and not broken
local armed = offer and deleteArmed(imp, "seal", slot and slot.id or nil, scope)
if armed then
local name = sealSlotName(slot)
body[#body + 1] = { name
and Strings("Break the seal on %s, save slot %s?", title, name)
or Strings("Break the seal on %s, on a new save slot?", title),
PAL.yellow }
body[#body + 1] = { Strings("This is permanent and cannot be undone. That save is marked modified from then on."),
PAL.yellow }
body[#body + 1] = { Strings("%s still loads its pinned mods first, with your other enabled mods on top.", title),
PAL.yellow }
body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow }
end
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
local chipH = offer and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
h = h + Kit.wrapHeight("small", line[1] or "", iw, 3)
end
if offer then h = h + math.floor(8 * m.s) + chipH end
h = h + pad
Kit.card(x, y, w, h)
local cy = y + pad
Kit.text("button", Kit.ellipsize("button", state, iw), x + pad, cy, stateCol)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw,
line[2], 3)
end
if offer then
cy = cy + math.floor(8 * m.s)
local cw = math.max(chipWidth(SEAL_LABEL(false), m),
chipWidth(SEAL_LABEL(true), m))
btn(imp, x + pad, cy, cw, chipH, "seal-" .. scope, SEAL_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function() imp:pressBreakSeal(version) end,
})
end
return h
end
local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
imp.panelVersion = version
local info = GameVersion.info(version)
local locked = info == nil
local gameName = info and (info.launcherName or info.displayName)
local skin = cartSkin(imp, version)
local gameName = skin.name or (info and (info.launcherName or info.displayName))
or tostring(version)
local ready = (not locked) and imp.ready[version] or false
@@ -1842,7 +1997,7 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
local cartW = math.min(cartAreaW, math.floor(playH * 0.88))
local cartX = lx + math.floor((cartAreaW - cartW) / 2)
cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version,
version, gameName, function() imp:play(version, true) end)
skin, gameName, function() imp:play(version, true) end)
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", {
@@ -1850,6 +2005,17 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
action = function() imp._gameManage = version end,
})
ly = ly + playH + gap
btn(imp, lx, ly, lw, m.btnH, "carts-" .. version,
Strings("Custom Carts"), {
kind = "accent", font = "small",
action = function()
imp._cartPopup = version
imp._cartNotice = nil
end,
})
ly = ly + m.btnH + gap
local sealH = buildCartCard(imp, lx, ly, lw, m, version)
if sealH > 0 then ly = ly + sealH + gap end
end
-- The ROM card, which now only exists while there is something to report:
@@ -2109,6 +2275,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
cy = cy + buildSaveCartRow(imp, x, cy, w, m)
if #mods == 0 then
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
@@ -3593,6 +3760,195 @@ local function buildGameModal(imp, m)
action = function() imp._gamePopup = nil end })
end
local function cartRowLabel(row)
local seal = (row.seal == "open") and Strings("open") or Strings("sealed")
return Strings("%s - v%s - %s", tostring(row.title or row.id),
tostring(row.version or "?"), seal)
end
local function buildCartModal(imp, m)
local version = imp._cartPopup
local rows = imp:_ensureCarts(version)
local info = GameVersion.info(version)
local baseName = info and (info.displayName or info.launcherName)
or tostring(version)
local active = imp.activeCart[version]
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(420 * m.s)
local rowH = m.btnH
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local notice = imp._cartNotice
local noticeH = notice
and (Kit.wrapHeight("small", notice, w - 2 * pad, 2) + gap) or 0
local emptyH = (#rows == 0) and (Kit.textHeight("small") + gap) or 0
local fixed = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ noticeH + emptyH + 2 * (rowH + gap) + rowH + pad
local perPage = Kit.rowsThatFit(m.H - 2 * m.pad - fixed, rowH, gap, 1, 8)
local pageKey = "cartpop-" .. tostring(version)
local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), #rows, perPage)
setPage(imp, pageKey, cur)
local shown = math.max(0, last - first + 1)
local h = fixed + shown * (rowH + gap) + (pages > 1 and (pagerH + gap) or 0)
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Custom Carts"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
if notice then
cy = cy + Kit.textWrapped("small", notice, px + pad, cy,
pw - 2 * pad, PAL.detail, 2) + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-vanilla", baseName, {
kind = (active == nil) and "primary" or "ghost", font = "small",
action = function() imp:_selectCart(version, nil) end })
cy = cy + rowH + gap
if #rows == 0 then
Kit.text("small", Strings("No carts installed for this game yet."),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
end
local expGap = math.floor(6 * m.s)
local expW = math.min(chipWidth(Strings("Export"), m),
math.floor((pw - 2 * pad) * 0.35))
for i = first, last do
local row = rows[i]
local rowKey = "cartpop-id-" .. tostring(row.id)
local pickW = pw - 2 * pad - expW - expGap
btn(imp, px + pad, cy, pickW, rowH, rowKey, cartRowLabel(row), {
kind = (active == row.id) and "primary" or "ghost", font = "small",
action = function() imp:_selectCart(version, row.id) end })
btn(imp, px + pad + pickW + expGap, cy, expW, rowH, rowKey .. "-export",
Strings("Export"), { kind = "accent", font = "small",
action = function() imp:exportCart(row.id) end })
cy = cy + rowH + gap
end
if pages > 1 then
setPage(imp, pageKey,
Kit.pager(px + pad, cy, pw - 2 * pad, cur, #rows, perPage, pageKey))
cy = cy + pagerH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-more",
Strings("Get more carts"), { kind = "accent", font = "small",
action = function()
imp._cartNotice = Strings("Browsing for carts arrives in a later update.")
end })
cy = cy + rowH + gap
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-close",
Strings("Close"), { font = "small",
action = function()
imp._cartPopup = nil
imp._cartNotice = nil
end })
end
local CART_PIN_LINES = 4
local function cartPinLines(pins)
local out = {}
for i = 1, math.min(#pins, CART_PIN_LINES) do
local pin = pins[i]
out[#out + 1] = Strings("%s - %s", tostring(pin.name or pin.id),
tostring(pin.reason or ""))
end
if #pins > CART_PIN_LINES then
out[#out + 1] = Strings("...and %d more.", #pins - CART_PIN_LINES)
end
return out
end
local function buildCartSaveModal(imp, m)
local st = imp._cartSave
local info = GameVersion.info(st.version)
local gameName = (info and (info.launcherName or info.displayName))
or tostring(st.version)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(500 * m.s)
local inner = w - 2 * pad
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local hint = (st.count == 1)
and Strings("This freezes the 1 mod enabled for %s into a cart.", gameName)
or Strings("This freezes the %d mods enabled for %s into a cart.",
st.count, gameName)
local id = imp:_cartSaveId()
local meta = id
and Strings("id %s - v%s - by %s", id, st.cartVersion, st.author)
or Strings("Type a title - the cart id is built from it.")
local pins = st.unresolved or {}
local pinHead = (#pins > 0) and ((#pins == 1)
and Strings("1 mod could only be pinned to this install:")
or Strings("%d mods could only be pinned to this install:", #pins)) or nil
local pinRows = pinHead and cartPinLines(pins) or {}
local share = st.publishable
and Strings("Every mod is pinned to a release, so this cart can be shared.")
or Strings("This cart can be saved and played here while those mods stay installed at these versions, and cannot be shared.")
local shareCol = st.publishable and PAL.green or PAL.yellow
local pinIndent = math.floor(10 * m.s)
local hintH = Kit.wrapHeight("small", hint, inner, 3) + gap
local metaH = Kit.textHeight("micro") + gap
local errH = st.error
and (Kit.wrapHeight("small", st.error, inner, 2) + gap) or 0
local pinH = 0
if pinHead then
pinH = Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
pinH = pinH + Kit.wrapHeight("small", line, inner - pinIndent, 2)
+ math.floor(2 * m.s)
end
pinH = pinH + gap
end
local shareH = Kit.wrapHeight("small", share, inner, 2) + gap
local footH = Kit.textHeight("micro") + math.floor(8 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(10 * m.s) + hintH
+ fieldH + gap + metaH + errH + pinH + shareH + m.btnH + footH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Save as cart"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
PAL.detail, 3) + gap
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "cartsave-field",
st.text or "", Strings("Cart title"), true)
cy = cy + fieldH + gap
Kit.text("micro", Kit.ellipsize("micro", meta, pw - 2 * pad), px + pad, cy,
PAL.muted)
cy = cy + Kit.textHeight("micro") + gap
if st.error then
cy = cy + Kit.textWrapped("small", st.error, px + pad, cy, pw - 2 * pad,
PAL.red, 2) + gap
end
if pinHead then
Kit.text("small", Kit.ellipsize("small", pinHead, pw - 2 * pad),
px + pad, cy, PAL.yellow)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
cy = cy + Kit.textWrapped("small", line, px + pad + pinIndent, cy,
pw - 2 * pad - pinIndent, PAL.detail, 2) + math.floor(2 * m.s)
end
cy = cy + gap
end
cy = cy + Kit.textWrapped("small", share, px + pad, cy, pw - 2 * pad,
shareCol, 2) + gap
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s))
local okLabel = Strings("Save as cart")
local okW = Kit.textWidth("small", okLabel) + math.floor(28 * m.s)
btn(imp, place(okW), cy, okW, m.btnH, "cartsave-ok", okLabel,
{ kind = "primary", font = "small",
action = function() imp:_commitCartSave() end })
local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s)
btn(imp, place(cw), cy, cw, m.btnH, "cartsave-cancel", Strings("Cancel"),
{ font = "small", action = function() imp:_cancelCartSave() end })
cy = cy + m.btnH + math.floor(8 * m.s)
Kit.text("micro", Strings("Enter to save - Esc to cancel"), px + pad, cy,
PAL.muted)
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -4962,7 +5318,7 @@ local function modalUp(imp)
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._gamePopup or imp._cartPopup or imp._cartSave
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
@@ -5030,6 +5386,7 @@ local function buildModals(imp, m)
})
return true
end
if imp._cartSave then buildCartSaveModal(imp, m) return true end
if imp._settings then buildSettingsModal(imp, m) return true end
if imp._rename then
buildPrompt(imp, m, {
@@ -5105,6 +5462,7 @@ local function buildModals(imp, m)
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._cartPopup then buildCartModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
+445 -38
View File
@@ -28,6 +28,13 @@ local function pickFile(...)
return fn(...) and true or false
end
local CART_SCOPE = "cart_"
local function cartOfScope(scope)
if type(scope) ~= "string" then return nil end
return scope:match("^cart_(.+)$")
end
-- Cache generation tag; bump to force every imported version to re-extract.
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
@@ -1492,12 +1499,13 @@ end
-- column with no Play button would read as the launcher losing the import.
-- Called from new() once self.ready is filled, which is what that check needs.
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
self:_restoreActiveCarts(okOpt and opts or nil)
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local last = okOpt and opts and opts.lastVersion
if last and GameVersion.VERSIONS[last] and self.ready[last] then
self.tab = last
@@ -1507,8 +1515,9 @@ end
-- The launcher runs each GameVersion as an independent tab. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
-- can be imported and played side by side. onComplete(version) hands the
-- chosen game off to boot.
-- can be imported and played side by side. onComplete(version, cartId) hands
-- the chosen game -- and the custom cart its page is showing, if any -- off to
-- boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
@@ -1582,6 +1591,7 @@ function RomImporter.new(onComplete, opts)
-- any slot mutation); activeSlot drives the LOADED pill; slotScroll is the
-- per-version list scroll offset (px), clamped against content in draw.
slots = {}, activeSlot = {}, slotScroll = {},
carts = {}, activeCart = {},
-- SAVE FILES card state: the last import/export result per version, shown as
-- a green/red notice line under the Import save / Export save buttons. A
-- successful export carries { dir } so the notice can offer an open-folder
@@ -1771,6 +1781,11 @@ function RomImporter:focus(f)
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
if self.androidPendingCartExport then
self.androidPendingCartExport = nil
self._cartNotice = Strings("Cart exported.")
return
end
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
@@ -2605,15 +2620,21 @@ end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
function RomImporter:_deleteSlot(scope, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
local cart = cartOfScope(scope)
local ok, err
if cart then
ok, err = SaveData.deleteCartSlot(cart, id)
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
ok, err = SaveData.deleteSlot(scope, id)
end
if ok then
self:_refreshSlots(scope)
self.saveNotice[scope] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[scope] = { ok = false, text = tostring(err) }
end
end
@@ -3225,17 +3246,30 @@ function RomImporter:play(version, fade)
-- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it
-- with the rest of the launcher's persisted state. A failed write only
-- costs the memory of the choice, so it must never block the boot.
local cartId = self.activeCart and self.activeCart[version] or nil
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.lastVersion = version
local map = self:_activeCartMap()
opts.activeCart = next(map) ~= nil and map or nil
SaveData.saveOptions(opts)
end)
resetPointerCursor(self)
-- The game draws with raw love.graphics from here on; drop the view's
-- element tree and canvases before the handoff.
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version) end
if self.onComplete then self.onComplete(version, cartId) end
end
function RomImporter:_activeCartMap()
local out = {}
for version, id in pairs(self.activeCart or {}) do
if GameVersion.VERSIONS[version] and type(id) == "string" then
out[version] = id
end
end
return out
end
-- "re-import" a column: drop it back to the choose/drop state so a fresh ROM
@@ -4083,6 +4117,17 @@ function RomImporter:keypressed(key)
end
return
end
if self._cartSave then
if key == "backspace" then
self._cartSave.text = utf8Back(self._cartSave.text)
self._cartSave.error = nil
elseif key == "return" or key == "kpenter" then
self:_commitCartSave()
elseif key == "escape" then
self:_cancelCartSave()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -4168,6 +4213,13 @@ function RomImporter:keypressed(key)
end
return
end
if self._cartPopup then
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then self._cartPopup = nil end
return
end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
@@ -4209,21 +4261,349 @@ function RomImporter:keypressed(key)
end
end
end
-- Reload a version's slot list + active id from SaveData (the source of truth).
-- Cheap enough to call on any mutation; the per-frame draw only calls it lazily
-- through _ensureSlots so a still list costs nothing after the first paint.
function RomImporter:_refreshSlots(version)
local SaveData = require("src.core.SaveData")
self.slots[version] = SaveData.listSlots(version) or {}
local opts = SaveData.loadOptions()
local reg = opts.saveSlots and opts.saveSlots[version]
-- fall back to the first slot as the shown "loaded" one when the registry
-- has a list but no explicit active id (matches saveNames' own resolution)
self.activeSlot[version] = reg and (reg.active or reg.list[1]) or nil
function RomImporter:_refreshCarts(version)
local out = {}
local ok, rows = pcall(function()
return require("src.carts.CartStore").index()
end)
if ok and type(rows) == "table" then
for _, row in ipairs(rows) do
if row.base == version then out[#out + 1] = row end
end
end
self.carts[version] = out
return out
end
function RomImporter:_ensureSlots(version)
if not self.slots[version] then self:_refreshSlots(version) end
function RomImporter:_ensureCarts(version)
return self.carts[version] or self:_refreshCarts(version)
end
function RomImporter:_cartById(version, id)
if type(id) ~= "string" then return nil end
for _, row in ipairs(self:_ensureCarts(version)) do
if row.id == id then return row end
end
return nil
end
function RomImporter:activeCartRow(version)
local id = self.activeCart[version]
if not id then return nil end
return self:_cartById(version, id)
end
function RomImporter:slotScope(version)
local id = self.activeCart[version]
if id then return CART_SCOPE .. id end
return version
end
function RomImporter:_selectCart(version, id)
self._cartPopup = nil
self._cartNotice = nil
if id ~= nil and not self:_cartById(version, id) then return end
self.activeCart[version] = id
self._cartPlan = nil
local scope = self:slotScope(version)
self.slots[scope] = nil
self.slotScroll[scope] = nil
if self._pages then self._pages["slots-" .. scope] = 1 end
end
function RomImporter:_cartSealSlot(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil, nil end
local scope = self:slotScope(version)
self:_ensureSlots(scope)
local active = self.activeSlot[scope]
for _, slot in ipairs(self.slots[scope] or {}) do
if slot.id == active then return slot, scope end
end
return nil, scope
end
function RomImporter:cartPlan(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil, nil end
local slot = self:_cartSealSlot(version)
local broken = (slot and slot.sealBroken == true) or false
local key = table.concat(
{ id, tostring(slot and slot.id), tostring(broken) }, "|")
local cached = self._cartPlan
if cached and cached.key == key then return cached.report, slot end
local installed = {}
pcall(function()
local rows = require("src.mods.LauncherMods").list(version) or {}
for _, row in ipairs(rows) do
local manifest = type(row.manifest) == "table" and row.manifest or row
if type(manifest.id) == "string" then
installed[manifest.id] =
{ id = manifest.id, version = manifest.version }
end
end
end)
local ok, cart = pcall(function()
return require("src.carts.CartStore").get(id)
end)
if not ok or type(cart) ~= "table" then cart = nil end
local report = require("src.mods.Loader").planCart(cart, installed, broken)
report.id = id
self._cartPlan = { key = key, report = report }
return report, slot
end
function RomImporter:pressBreakSeal(version)
local slot, scope = self:_cartSealSlot(version)
if not scope then return false end
return self:pressDelete("seal", slot and slot.id or nil, scope, function()
self:breakCartSeal(version)
end)
end
function RomImporter:breakCartSeal(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return false end
local SaveData = require("src.core.SaveData")
local scope = self:slotScope(version)
self:_ensureSlots(scope)
local slotId = self.activeSlot[scope]
if type(slotId) ~= "string" then
slotId = SaveData.createCartSlot(id)
if type(slotId) ~= "string" then return false end
SaveData.setActiveCartSlot(id, slotId)
end
local ok = SaveData.markSlotSealBroken(id, slotId)
self:_refreshSlots(scope)
self.activeSlot[scope] = slotId
self._cartPlan = nil
return ok and true or false
end
function RomImporter:_restoreActiveCarts(opts)
local saved = opts and opts.activeCart
if type(saved) ~= "table" then return end
for version, id in pairs(saved) do
if GameVersion.VERSIONS[version] and type(id) == "string"
and self:_cartById(version, id) then
self.activeCart[version] = id
end
end
end
local CART_RAIL = { red = "railRed", blue = "railBlue", yellow = "railGold",
gold = "railAmber", silver = "railSilver" }
local CART_START_VERSION = "1.0.0"
local function cartShellHex(version)
local Theme = require("src.ui.kit.Theme")
local col = Theme.PAL[CART_RAIL[version] or ""] or Theme.PAL.green
return ("#%02x%02x%02x"):format(col[1] or 0, col[2] or 0, col[3] or 0)
end
local function cartIdFromTitle(title)
local CartManifest = require("src.carts.CartManifest")
local id = tostring(title or ""):lower():gsub("[^%w]+", "_")
id = id:sub(1, CartManifest.MAX_ID):gsub("^_+", ""):gsub("_+$", "")
if id == "" then return nil end
return id
end
local function cartModRows(imp, version)
local LauncherMods = require("src.mods.LauncherMods")
local rows, kept = LauncherMods.list(version) or {}, {}
for _, row in ipairs(rows) do
if row.targetsHere ~= false then kept[#kept + 1] = row end
end
return kept
end
function RomImporter:_cartCaptureCount(version)
if not GameVersion.VERSIONS[version] then return 0 end
local n = 0
for _, row in ipairs(cartModRows(self, version)) do
if row.enabled then n = n + 1 end
end
return n
end
function RomImporter:_cartAuthor()
local SaveData = require("src.core.SaveData")
local ok, opts = pcall(SaveData.loadOptions)
local sync = ok and type(opts) == "table" and opts.saveSync or nil
local label = type(sync) == "table" and sync.deviceLabel or nil
if type(label) == "string" and label:match("%S") then return label end
return Strings("Unknown")
end
function RomImporter:_cartSaveId()
local st = self._cartSave
if not st then return nil end
return cartIdFromTitle(st.text)
end
local function cartIdentity(st, id, title)
return { id = id, title = title, version = st.cartVersion,
author = st.author, base = st.version,
shell = st.shell, seal = "sealed" }
end
function RomImporter:_beginCartSave(version)
if not GameVersion.VERSIONS[version] then return end
local LauncherMods = require("src.mods.LauncherMods")
local CartStore = require("src.carts.CartStore")
local st = {
version = version, text = "", author = self:_cartAuthor(),
cartVersion = CART_START_VERSION, shell = cartShellHex(version),
mods = cartModRows(self, version),
modOptions = LauncherMods.modOptions(),
unresolved = {}, publishable = false,
}
st.count = 0
for _, row in ipairs(st.mods) do
if row.enabled then st.count = st.count + 1 end
end
local cart, unresolved = CartStore.capture(
cartIdentity(st, "preview", "Preview"), st.mods, st.modOptions)
if cart then
st.unresolved = unresolved or {}
local CartManifest = require("src.carts.CartManifest")
st.publishable = CartManifest.publishable(cart) and true or false
else
st.error = tostring(unresolved)
end
self._cartSave = st
self:_armTextInput()
end
function RomImporter:_cancelCartSave()
self._cartSave = nil
self:_disarmTextInput()
end
function RomImporter:_commitCartSave()
local st = self._cartSave
if not st then return end
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local title = tostring(st.text or ""):match("^%s*(.-)%s*$")
if title == "" then
st.error = Strings("Type a title for this cart.")
return
end
local id = cartIdFromTitle(title)
if not id then
st.error = Strings("That title has no letters or numbers to build an id from.")
return
end
local existing = CartStore.get(id)
if existing then
st.error = Strings("%s already uses the id %s. Pick a different title.",
tostring(existing.title), id)
return
end
local cart, unresolved = CartStore.capture(
cartIdentity(st, id, title), st.mods, st.modOptions)
if not cart then
st.error = tostring(unresolved)
return
end
st.unresolved = unresolved or {}
st.publishable = CartManifest.publishable(cart) and true or false
local ok, err = CartStore.install(CartManifest.encode(cart))
if not ok then
st.error = tostring(err)
return
end
local version = st.version
self._cartSave = nil
self:_disarmTextInput()
self:_refreshCarts(version)
self._cartPopup = version
self._cartNotice = Strings("Saved %s. It is in this list now.", title)
end
function RomImporter:exportCart(id)
if self.workState == "working" then return end
local CartStore = require("src.carts.CartStore")
local SaveData = require("src.core.SaveData")
local bytes, err = CartStore.export(id)
if type(bytes) ~= "string" then
self._cartNotice = tostring(err or "that cart could not be read")
return
end
local fs = SaveData.portableFs() or (love and love.filesystem)
if not (fs and fs.write) then
self._cartNotice = Strings("No filesystem available to export to.")
return
end
if fs.createDirectory then
fs.createDirectory("exports")
fs.createDirectory("exports/carts")
end
local rel = "exports/carts/" .. id .. CartStore.EXT
local wrote, writeErr = fs.write(rel, bytes)
if not wrote then
self._cartNotice = Strings("Could not write the export: %s", tostring(writeErr))
return
end
local abs, portableBase = rel, SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
abs = portableBase .. sep .. rel:gsub("/", sep)
else
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then abs = base .. "/" .. rel end
end
if self.isNX then
local hint = RomImporter.mtpHintPath(love.filesystem.getSaveDirectory())
if hint ~= "" and hint:sub(-1) ~= "/" then hint = hint .. "/" end
self._cartNotice =
Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/carts/", abs, hint)
return
end
if self.android then
local suggested = id .. CartStore.EXT
local staged = love.filesystem.write("pending_export.sav", bytes)
if staged and love.system.createFile
and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
self.pickPending = true
self.pickTimer = 0
self.androidPendingCartExport = true
self._cartNotice = Strings("Pick where to save %s...", suggested)
else
self._cartNotice = Strings("Exported inside the app folder: %s", abs)
end
return
end
self._cartNotice = Strings("Exported to %s", abs)
end
-- Reload a scope's slot list + active id from SaveData (the source of truth).
-- Cheap enough to call on any mutation; the per-frame draw only calls it lazily
-- through _ensureSlots so a still list costs nothing after the first paint.
function RomImporter:_refreshSlots(scope)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(scope)
if cart then
self.slots[scope] = SaveData.listCartSlots(cart) or {}
local opts = SaveData.loadOptions()
local reg = opts.cartSlots and opts.cartSlots[cart]
self.activeSlot[scope] =
reg and (reg.active or (reg.list and reg.list[1])) or nil
return
end
self.slots[scope] = SaveData.listSlots(scope) or {}
local opts = SaveData.loadOptions()
local reg = opts.saveSlots and opts.saveSlots[scope]
-- fall back to the first slot as the shown "loaded" one when the registry
-- has a list but no explicit active id (matches saveNames' own resolution)
self.activeSlot[scope] = reg and (reg.active or reg.list[1]) or nil
end
function RomImporter:_ensureSlots(scope)
if not self.slots[scope] then self:_refreshSlots(scope) end
end
-- The host calls this when the save editor closes: the edited slot's player
@@ -4235,9 +4615,15 @@ end
-- Point the active slot at id (persisted immediately, per the contract) and
-- reflect it in the LOADED pill without a full relist.
function RomImporter:_selectSlot(version, id)
require("src.core.SaveData").setActiveSlot(version, id)
self.activeSlot[version] = id
function RomImporter:_selectSlot(scope, id)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(scope)
if cart then
SaveData.setActiveCartSlot(cart, id)
else
SaveData.setActiveSlot(scope, id)
end
self.activeSlot[scope] = id
end
-- Inline slot rename (#205): right-click arms a modal text field; Enter
@@ -4282,12 +4668,12 @@ function RomImporter:_blurPanelFields()
self:_disarmTextInput()
end
function RomImporter:_beginRename(version, id)
function RomImporter:_beginRename(scope, id)
local label
for _, slot in ipairs(self.slots[version] or {}) do
for _, slot in ipairs(self.slots[scope] or {}) do
if slot.id == id then label = slot.label break end
end
self._rename = { version = version, id = id, text = label or "" }
self._rename = { version = scope, id = id, text = label or "" }
self._slotPress = nil -- cancel any armed click/drag on the list
self:_armTextInput()
end
@@ -4297,7 +4683,13 @@ function RomImporter:_commitRename()
if not r then return end
self._rename = nil
self:_disarmTextInput()
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(r.version)
if cart then
SaveData.renameCartSlot(cart, r.id, r.text)
else
SaveData.renameSlot(r.version, r.id, r.text)
end
self:_refreshSlots(r.version)
end
@@ -4314,6 +4706,13 @@ function RomImporter:textinput(text)
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._cartSave then
local CartManifest = require("src.carts.CartManifest")
self._cartSave.text =
utf8Cap(self._cartSave.text .. text, CartManifest.MAX_TITLE)
self._cartSave.error = nil
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -4355,13 +4754,20 @@ end
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
-- scroll to the bottom (clamped next draw) so the new row is on screen.
function RomImporter:_newSlot(version)
function RomImporter:_newSlot(scope)
local SaveData = require("src.core.SaveData")
local id = SaveData.createSlot(version)
SaveData.setActiveSlot(version, id)
self:_refreshSlots(version)
self.activeSlot[version] = id
self.slotScroll[version] = math.huge
local cart = cartOfScope(scope)
local id
if cart then
id = SaveData.createCartSlot(cart)
if id then SaveData.setActiveCartSlot(cart, id) end
else
id = SaveData.createSlot(scope)
SaveData.setActiveSlot(scope, id)
end
self:_refreshSlots(scope)
self.activeSlot[scope] = id
self.slotScroll[scope] = math.huge
end
-- Mouse wheel: forwarded into the FlexLove view (installed onto the global
@@ -4379,6 +4785,7 @@ end
function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self._cartPlan = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install
+143 -2
View File
@@ -1,6 +1,7 @@
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local SaveData = require("src.core.SaveData")
local CartStore = require("src.carts.CartStore")
local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
@@ -270,6 +271,7 @@ function Loader.new(opts)
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem),
cart = opts and opts.cart or nil,
dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the
@@ -488,6 +490,141 @@ function Loader:_discover()
end
end
-- ------- custom carts
local function installedVersions(installed)
local out = {}
for key, entry in pairs(installed or {}) do
if type(entry) == "table" then
local manifest = type(entry.manifest) == "table" and entry.manifest or entry
local id = manifest.id or (type(key) == "string" and key or nil)
if type(id) == "string" then out[id] = manifest.version end
end
end
return out
end
local function pinnedVersion(pin)
local version = pin.version
if type(version) ~= "string" or version == "" then return nil end
if pin.source == "local" and version == CartStore.UNPINNED_VERSION then return nil end
return version
end
local function sameVersion(want, have)
local order = Semver.compare(want, have)
if order ~= nil then return order == 0 end
return want == have
end
local function cartComplaints(report)
local parts = {}
for _, row in ipairs(report.missing) do
parts[#parts + 1] = ("%s %s is not installed")
:format(row.id, row.version or "(any version)")
end
for _, row in ipairs(report.mismatched) do
parts[#parts + 1] = ("%s is pinned at %s but %s is installed")
:format(row.id, row.version, row.installed)
end
return parts
end
function Loader.planCart(cart, installed, broken)
local report = { seal = "sealed", sealed = true, broken = broken == true,
order = {}, rank = {}, pins = {}, missing = {}, mismatched = {},
floor = 1, refused = false }
if type(cart) ~= "table" then
report.enforced = true
report.refused = true
report.message = "this cart is not installed"
return report
end
report.id, report.title = cart.id, cart.title
report.seal = cart.seal == "open" and "open" or "sealed"
report.sealed = report.seal == "sealed"
report.enforced = report.sealed and not report.broken
local have = installedVersions(installed)
local pins = {}
for _, pin in ipairs(cart.mods or {}) do
if type(pin) == "table" and type(pin.id) == "string" then pins[pin.id] = pin end
end
for _, id in ipairs(cart.load_order or {}) do
local pin = pins[id]
if pin and not report.pins[id] then
report.order[#report.order + 1] = id
report.rank[id] = #report.order
report.pins[id] = pin
local want, got = pinnedVersion(pin), have[id]
if got == nil then
report.missing[#report.missing + 1] =
{ id = id, version = want, source = pin.source }
elseif want and not sameVersion(want, got) then
report.mismatched[#report.mismatched + 1] =
{ id = id, version = want, installed = got }
end
end
end
report.floor = #report.order + 1
local parts = cartComplaints(report)
if #parts > 0 then
report.message = ("%s: %s"):format(cart.title or cart.id or "cart",
table.concat(parts, "; "))
report.refused = report.enforced
end
return report
end
function Loader:cartStatus()
return self.cartReport
end
function Loader:_applyCart()
local cartId = SaveData.getCart()
if not cartId or self.safeMode then return end
local cart, err = self.cart, nil
if not cart then cart, err = CartStore.get(cartId, self.fs) end
local report = Loader.planCart(cart, self.mods, SaveData.isSealBroken())
report.id = cartId
if not cart then
report.message = ("%s: %s"):format(cartId, tostring(err or "this cart is not installed"))
end
self.cartReport = report
if report.refused then
for _, mod in pairs(self.mods) do
mod.enabled, mod.state = false, "disabled"
end
self.errors[#self.errors + 1] = report.message
Logger.error("cart %s refused: %s", cartId, report.message)
return
end
if report.message then Logger.warn("cart %s: %s", cartId, report.message) end
for id, mod in pairs(self.mods) do
if report.pins[id] then
mod.enabled, mod.state = true, "pending"
elseif report.enforced then
mod.enabled, mod.state = false, "disabled"
end
end
local merged = {}
for id, bucket in pairs(self.modOptions) do merged[id] = bucket end
for id, pin in pairs(report.pins) do
local bucket = {}
for key, value in pairs(pin.options or {}) do bucket[key] = value end
if not report.enforced then
for key, value in pairs(self.modOptions[id] or {}) do bucket[key] = value end
end
merged[id] = bucket
end
self.modOptions = merged
end
function Loader:_cartRank(id)
local report = self.cartReport
if not report then return 0 end
return report.rank[id] or report.floor
end
-- ------- validate and resolve
-- a failed mod keeps the user's enable flag (the manager still shows it as
@@ -773,9 +910,12 @@ function Loader:_order()
if not best then
best = id
else
local ra, rb = self:_cartRank(id), self:_cartRank(best)
local pa, pb = self.mods[id].manifest.priority,
self.mods[best].manifest.priority
if pa < pb or (pa == pb and id < best) then best = id end
if ra < rb or (ra == rb and (pa < pb or (pa == pb and id < best))) then
best = id
end
end
end
end
@@ -1610,6 +1750,7 @@ function Loader:load(data)
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
end
self:_applyCart()
-- engine call sites reach these buses -- and this error feed, for failures
-- that only surface at play time -- through Runtime from here on
Runtime.install(self.events, self.hooks, self.errors)
@@ -1783,7 +1924,7 @@ function Loader:status()
table.sort(available, function(a, b) return a.id < b.id end)
table.sort(loaded, function(a, b) return a.id < b.id end)
return { available = available, loaded = loaded, errors = self.errors,
order = self.order }
order = self.order, cart = self.cartReport }
end
return Loader
+600
View File
@@ -0,0 +1,600 @@
-- Custom carts in the launcher.
-- luajit tests/engine/cart_launcher.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
love.graphics.newShader = love.graphics.newShader or function() return {} end
love.graphics.polygon = love.graphics.polygon or function() end
local Kit = require("src.ui.kit.Kit")
local SaveData = require("src.core.SaveData")
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local RomImporter = require("src.import.RomImporter")
local LauncherView = require("src.import.LauncherView")
local SHA = ("a1b2c3d4"):rep(8)
local function window(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
local function freshLauncher(onComplete)
return RomImporter.new(onComplete or function() end, { launcher = true })
end
local realPrint = love.graphics.print
local function drawAndCapture(imp)
local seen = {}
love.graphics.print = function(str, ...)
seen[#seen + 1] = tostring(str)
return realPrint(str, ...)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.print = realPrint
check(ok, "the frame draws: " .. tostring(err))
return table.concat(seen, "\n")
end
local realSetColor = love.graphics.setColor
local function drawColors(imp)
local seen = {}
love.graphics.setColor = function(r, g, b, a)
if type(r) == "number" and type(g) == "number" and type(b) == "number" then
seen[("%d,%d,%d"):format(math.floor(r * 255 + 0.5),
math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))] = true
end
return realSetColor(r, g, b, a)
end
local ok, err = pcall(LauncherView.draw, imp)
love.graphics.setColor = realSetColor
check(ok, "the frame draws: " .. tostring(err))
return seen
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 } },
}
for key, value in pairs(over or {}) do tbl[key] = value end
return tbl
end
local function install(over)
over = over or {}
local cart, parseErr = CartManifest.parse(cartTable(over))
check(cart ~= nil, "fixture parses: " .. tostring(parseErr))
cart.labelArt = over.labelArt
local ok, err = CartStore.install(CartManifest.encode(cart))
check(ok ~= nil, "fixture installs: " .. tostring(err))
return cart
end
install()
install({ id = "zeta_open", title = "Zeta Open", version = "0.9.0",
seal = "open", shell = "#112233" })
install({ id = "johto_lite", title = "Johto Lite", base = "blue",
shell = "#7a5c2e" })
window(1280, 720)
local imp = freshLauncher()
local red = imp:_ensureCarts("red")
eq(#red, 2, "red lists exactly the carts based on red")
eq(red[1].id, "kanto_plus", "the list is sorted by title")
eq(red[2].id, "zeta_open", "the list is sorted by title")
eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart")
eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite")
eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none")
imp.tab = "red"
imp.ready.red = true
imp._cartPopup = "red"
local picker = drawAndCapture(imp)
check(picker:find("Pokemon Red", 1, true) ~= nil,
"the picker offers the base game as the first row")
check(picker:find("Kanto Plus", 1, true) ~= nil, "the picker lists Kanto Plus")
check(picker:find("Zeta Open", 1, true) ~= nil, "the picker lists Zeta Open")
check(picker:find("Johto Lite", 1, true) == nil,
"the picker does NOT list a cart based on another game")
check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version")
check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state")
check(picker:find("open", 1, true) ~= nil, "including an open one")
check(picker:find("Get more carts", 1, true) ~= nil,
"the last row is the browse placeholder")
imp._cartPopup = nil
local vanillaColors = drawColors(imp)
check(vanillaColors["63,169,245"] == nil,
"a vanilla page never paints the cart's shell colour")
imp:_selectCart("red", "kanto_plus")
eq(imp.activeCart.red, "kanto_plus", "the pick lands on activeCart")
eq(imp._cartPopup, nil, "picking closes the picker")
local titled = drawAndCapture(imp)
check(titled:find("Kanto Plus", 1, true) ~= nil,
"the panel title becomes the cart's title")
local cartColors = drawColors(imp)
eq(cartColors["63,169,245"], true,
"the cartridge takes the cart's shell colour")
eq(imp.tab, "red", "a cart id never reaches imp.tab")
eq(imp.panelVersion, "red", "a cart id never reaches imp.panelVersion")
check(imp._cartridge["cart:kanto_plus"] ~= nil,
"the cart spins on its own cartridge state")
check(imp._cartridge["cart:kanto_plus"] ~= imp._cartridge.red,
"which is not the base game's")
eq(imp._cartridgeLabels["cart:kanto_plus"], false,
"a cart carrying no label art falls through to bare plastic")
local scope = imp:slotScope("red")
eq(scope, "cart_kanto_plus", "an active cart scopes the panel's save slots")
imp:_newSlot(scope)
imp:_newSlot(scope)
eq(#SaveData.listCartSlots("kanto_plus"), 2, "both slots land in the cart")
eq(#SaveData.listSlots("red"), 0, "and none of them in the base game")
imp:_ensureSlots(scope)
eq(#imp.slots[scope], 2, "the panel reads the cart's slots back")
imp:_beginRename(scope, "slot1")
imp._rename.text = "Nuzlocke"
imp:_commitRename()
eq(SaveData.listCartSlots("kanto_plus")[1].label, "Nuzlocke",
"a rename writes into the cart's registry")
imp:_selectSlot(scope, "slot2")
eq(SaveData.activeCartSlot("kanto_plus"), "slot2",
"selecting a row moves the cart's active slot")
imp:_deleteSlot(scope, "slot2")
eq(#SaveData.listCartSlots("kanto_plus"), 1, "a delete removes the cart's slot")
local withCart = drawAndCapture(imp)
check(withCart:find("Nuzlocke", 1, true) ~= nil,
"the slot card shows the cart's slots while the cart is active")
imp:_selectCart("red", nil)
eq(imp.activeCart.red, nil, "choosing the base game clears the active cart")
eq(imp:slotScope("red"), "red", "and the slots go back to the version's own")
imp:_newSlot("red")
eq(#SaveData.listSlots("red"), 1, "a vanilla slot lands in the version")
eq(#SaveData.listCartSlots("kanto_plus"), 1, "and not in the cart")
local backHome = drawAndCapture(imp)
check(backHome:find("Nuzlocke", 1, true) == nil,
"the slot card no longer shows the cart's slots")
check(backHome:find("Kanto Plus", 1, true) == nil,
"and the panel title is the base game's again")
local homeColors = drawColors(imp)
check(homeColors["63,169,245"] == nil,
"the cartridge is back to the base game's shell")
local handed = {}
local player = freshLauncher(function(version, cartId)
handed.version, handed.cart = version, cartId
end)
player.ready.red = true
player:_selectCart("red", "kanto_plus")
player:play("red")
eq(handed.version, "red", "Play still boots the base game")
eq(handed.cart, "kanto_plus", "and names the cart it is running")
local opts = SaveData.loadOptions()
eq(opts.lastVersion, "red", "play still remembers the version")
eq(type(opts.activeCart) == "table" and opts.activeCart.red or nil, "kanto_plus",
"play persists the active cart beside it")
local restored = freshLauncher()
eq(restored.activeCart.red, "kanto_plus",
"a fresh launcher restores the cart its page was on")
eq(restored.tab, "red", "and a cart id still never reaches the tab")
local uninstalled = freshLauncher()
uninstalled.activeCart.red = nil
uninstalled:_restoreActiveCarts({ activeCart = { red = "gone_forever" } })
eq(uninstalled.activeCart.red, nil,
"a remembered cart that is no longer installed is dropped")
local Base64 = require("src.core.Base64")
local PNG = CartManifest.PNG_SIGNATURE .. ("labelart"):rep(4)
local function artOf()
return { encoding = "base64", bytes = #PNG, data = Base64.encode(PNG) }
end
install({ id = "art_cart", title = "Art Cart", base = "yellow",
shell = "#204060", labelArt = artOf() })
install({ id = "bare_cart", title = "Bare Cart", base = "yellow",
shell = "#405060" })
install({ id = "bad_cart", title = "Bad Cart", base = "yellow",
shell = "#605040", labelArt = artOf() })
check(CartStore.labelArt("art_cart") == PNG,
"the store hands back the cart's own PNG bytes")
check(CartStore.labelArt("bare_cart") == nil,
"and nothing for a cart that carries none")
window(1280, 720)
local realNewImage = love.graphics.newImage
local madeImages = 0
love.graphics.newImage = function(...)
madeImages = madeImages + 1
return realNewImage(...)
end
local art = freshLauncher()
art.tab = "yellow"
art.ready.yellow = true
art:_selectCart("yellow", "art_cart")
drawAndCapture(art)
local artLabel = art._cartridgeLabels["cart:art_cart"]
check(type(artLabel) == "table" and artLabel.image ~= nil,
"a cart's own label art becomes a cached cartridge image")
local afterFirst = madeImages
drawAndCapture(art)
eq(madeImages, afterFirst, "the decode happens once, not every frame")
art:_selectCart("yellow", "bare_cart")
drawAndCapture(art)
eq(art._cartridgeLabels["cart:bare_cart"], false,
"a cart with no art renders as bare plastic")
check(art._cartridgeLabels["cart:art_cart"] ~= art._cartridgeLabels["cart:bare_cart"],
"and the two carts never share one label cache entry")
love.graphics.newImage = function(a, ...)
if type(a) == "table" and a._fileData then error("not a PNG this engine reads") end
return realNewImage(a, ...)
end
local bad = freshLauncher()
bad.tab = "yellow"
bad.ready.yellow = true
bad:_selectCart("yellow", "bad_cart")
drawAndCapture(bad)
love.graphics.newImage = realNewImage
eq(bad._cartridgeLabels["cart:bad_cart"], false,
"art that will not decode leaves the cart bare instead of throwing")
drawAndCapture(bad)
eq(bad._cartridgeLabels["cart:bad_cart"], false,
"and it is not retried on the next frame")
local LauncherMods = require("src.mods.LauncherMods")
local realModList = LauncherMods.list
local function fakeRow(over)
local row = { id = "x", name = "X", version = "1.0.0", badge = "MOD",
description = "", enabled = true, status = "ok",
statusDetail = "", experimental = false, targetsHere = true,
targets = nil, safeMode = false, requiredImports = {},
imports = {}, missingRequiredImports = 0,
missingOptionalImports = 0,
enabledByVersion = { red = true, blue = true, yellow = true,
gold = true, silver = true } }
for key, value in pairs(over) do row[key] = value end
row.manifest = row.manifest or { id = row.id, name = row.name,
version = row.version }
return row
end
local FAKE_MODS = {
fakeRow({ id = "rare_soda", name = "Rare Soda", version = "0.4.1",
github = "ren/rare-soda", sha256 = SHA }),
fakeRow({ id = "wide_gym", name = "Wide Gym", version = "dev" }),
fakeRow({ id = "off_mod", name = "Off Mod", enabled = false,
enabledByVersion = { red = false } }),
}
local modsView = freshLauncher()
modsView.tab = "mods"
local modsText = drawAndCapture(modsView)
check(modsText:find("Save as cart", 1, true) ~= nil,
"the mods tab carries the Save as cart control")
LauncherMods.list = function() return FAKE_MODS end
local maker = freshLauncher()
maker.tab = "red"
maker.ready.red = true
maker:_setModScope("red")
eq(maker:_cartCaptureCount("red"), 2,
"the control counts only the mods enabled for this game")
maker:_beginCartSave("red")
check(maker._cartSave ~= nil, "Save as cart opens a form")
eq(maker._cartSave.count, 2, "the form reports the captured mod count")
eq(maker._cartSave.version, "red", "scoped to the game the panel is showing")
eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve")
eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to")
check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil,
"and why it could only be pinned locally")
eq(maker._cartSave.publishable, false, "a local pin makes the cart unpublishable")
local form = drawAndCapture(maker)
check(form:find("Save as cart", 1, true) ~= nil, "the form is titled")
check(form:find("Wide Gym", 1, true) ~= nil,
"the unresolved pin is named BEFORE the player confirms")
check(form:find("could only be pinned to this install", 1, true) ~= nil,
"under a heading that says what a local pin means")
check(form:find("cannot be shared", 1, true) ~= nil,
"and the form says plainly that the result cannot be shared")
maker._cartSave.text = "Kanto Plus"
maker:_commitCartSave()
check(maker._cartSave ~= nil, "a title that collides with an installed cart refuses")
check(tostring(maker._cartSave.error):find("kanto_plus", 1, true) ~= nil,
"and names the id that is already taken")
eq(CartStore.get("kanto_plus").title, "Kanto Plus",
"the installed cart is untouched")
maker._cartSave.text = "Soda Run"
maker:_commitCartSave()
eq(maker._cartSave, nil, "a free title saves the cart and closes the form")
eq(maker._cartPopup, "red", "and drops the player straight into the picker")
local made = CartStore.get("soda_run")
check(made ~= nil, "the cart is installed under the id derived from the title")
eq(made.base, "red", "based on the game the panel was showing")
eq(made.version, "1.0.0", "at the default cart version")
eq(made.seal, "sealed", "sealed by default")
eq(made.shell, "#ff3c48", "wearing the base game's rail colour")
eq(#made.mods, 2, "pinning exactly the enabled mods")
local listedNow = false
for _, row in ipairs(maker:_ensureCarts("red")) do
if row.id == "soda_run" then listedNow = true end
end
check(listedNow, "and the picker lists it immediately")
local picked = drawAndCapture(maker)
check(picked:find("Soda Run", 1, true) ~= nil, "including on screen")
LauncherMods.list = function() return { FAKE_MODS[1] } end
local pure = freshLauncher()
pure.tab = "red"
pure.ready.red = true
pure:_beginCartSave("red")
eq(#pure._cartSave.unresolved, 0, "a fully pinned capture has no local pins")
eq(pure._cartSave.publishable, true, "and it is publishable")
local pureForm = drawAndCapture(pure)
check(pureForm:find("can be shared", 1, true) ~= nil,
"which the form says before the player confirms")
check(pureForm:find("cannot be shared", 1, true) == nil,
"instead of the local-pin warning")
pure._cartSave.text = "Pure Soda"
pure:_commitCartSave()
eq(pure._cartSave, nil, "a fully pinned capture saves too")
local pureOk, pureWhy = CartManifest.publishable(CartStore.get("pure_soda"))
eq(pureOk, true, "and the saved cart really is publishable")
eq(pureWhy, nil, "with nothing holding it back")
LauncherMods.list = function() return FAKE_MODS end
local blank = freshLauncher()
blank:_beginCartSave("red")
blank:_commitCartSave()
check(blank._cartSave ~= nil, "an empty title does not save")
check(tostring(blank._cartSave.error):find("title", 1, true) ~= nil,
"and asks for one")
blank:_cancelCartSave()
eq(blank._cartSave, nil, "Cancel closes the form")
LauncherMods.list = realModList
local exporter = freshLauncher()
exporter:exportCart("kanto_plus")
check(tostring(exporter._cartNotice):find("Exported", 1, true) ~= nil,
"Export reports where the cart file went: " .. tostring(exporter._cartNotice))
local wroteBytes = love.filesystem.read("exports/carts/kanto_plus" .. CartStore.EXT)
check(type(wroteBytes) == "string" and wroteBytes ~= "",
"and the bytes land in the same exports tree a save export uses")
eq(wroteBytes, (CartStore.export("kanto_plus")),
"byte for byte what CartStore.export returned")
local roundTrip = CartManifest.decode(wroteBytes)
check(roundTrip ~= nil and roundTrip.id == "kanto_plus",
"and the file decodes back into the cart")
exporter:exportCart("no_such_cart")
check(tostring(exporter._cartNotice):find("not installed", 1, true) ~= nil,
"exporting a cart that is not installed says so")
window(1920, 1080)
local FULL_MODS = { fakeRow({ id = "rare_soda", name = "Rare Soda",
version = "0.4.1", github = "ren/rare-soda",
sha256 = SHA }) }
LauncherMods.list = function() return FULL_MODS end
local okCart = freshLauncher()
okCart.tab = "red"
okCart.ready.red = true
okCart:_selectCart("red", "kanto_plus")
local okPlan = okCart:cartPlan("red")
eq(okPlan.refused, false, "a cart whose pin is installed is playable")
eq(okPlan.sealed, true, "and is still sealed")
local okText = drawAndCapture(okCart)
check(okText:find("Sealed", 1, true) ~= nil,
"the verdict is on the page before the player commits")
check(okText:find("Break the seal", 1, true) ~= nil,
"and a sealed cart's page offers the escape hatch")
LauncherMods.list = function() return {} end
local gapCart = freshLauncher()
gapCart.tab = "red"
gapCart.ready.red = true
gapCart:_selectCart("red", "kanto_plus")
local gapPlan = gapCart:cartPlan("red")
eq(gapPlan.refused, true, "a cart with an uninstalled pin refuses")
eq(gapPlan.missing[1].id, "rare_soda", "naming the pin it cannot find")
local gapText = drawAndCapture(gapCart)
check(gapText:find("will not start", 1, true) ~= nil,
"which the page says without the player booting into an error")
check(gapText:find("rare_soda", 1, true) ~= nil, "and names the pin")
check(gapText:find("Break the seal", 1, true) ~= nil,
"with the escape hatch beside the refusal")
local sealScope = gapCart:slotScope("red")
gapCart:_ensureSlots(sealScope)
local sealSlot = gapCart.activeSlot[sealScope]
check(type(sealSlot) == "string", "the cart page has a loaded save slot")
eq(gapCart:pressBreakSeal("red"), false, "the first press only arms the confirm")
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), false,
"so one press breaks nothing")
local armedText = drawAndCapture(gapCart)
check(armedText:find("Kanto Plus", 1, true) ~= nil,
"the armed confirm names the cart")
check(armedText:find("save slot", 1, true) ~= nil, "and the save slot")
check(armedText:find("cannot be undone", 1, true) ~= nil,
"says it is permanent")
check(armedText:find("marked modified", 1, true) ~= nil,
"says the file is marked modified from then on")
check(armedText:find("pinned mods first", 1, true) ~= nil,
"and that the cart's own list still loads first")
eq(gapCart:pressBreakSeal("red"), true, "a second press breaks the seal")
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), true,
"which lands on that slot, durably")
eq(gapCart:cartPlan("red").refused, false, "and the cart is no longer refused")
local brokenText = drawAndCapture(gapCart)
check(brokenText:find("Seal broken", 1, true) ~= nil,
"the cart page shows the broken state afterwards")
check(brokenText:find("seal broken", 1, true) ~= nil,
"and so does the cart's slot row")
gapCart:_newSlot(sealScope)
local freshSlot = gapCart.activeSlot[sealScope]
check(freshSlot ~= sealSlot, "a new save slot under the same cart")
eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false,
"starts sealed again")
eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one")
LauncherMods.list = realModList
window(1280, 720)
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
end
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
return x1, y1, x2, y2
end
local function overlap(a, b)
local ax1, ay1, ax2, ay2 = clipped(a)
if not ax1 then return false end
local bx1, by1, bx2, by2 = clipped(b)
if not bx1 then return false end
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
and math.min(ay2, by2) - math.max(ay1, by1) > 1
end
local function auditFrame(label, want)
local controls, found = {}, false
for _, r in ipairs(Kit.audit or {}) do
if r.class == "control" then
controls[#controls + 1] = r
if want and tostring(r.label):find(want, 1, true) then found = true end
end
end
check(#controls > 0, label .. ": the frame dispatched controls at all")
local collisions = 0
for i = 1, #controls do
for j = i + 1, #controls do
if overlap(controls[i], controls[j]) then
collisions = collisions + 1
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
:format(tostring(controls[i].label), tostring(controls[j].label),
controls[i].x, controls[i].y, controls[j].x, controls[j].y))
end
end
end
check(collisions == 0, label .. ": no two controls overlap")
if want then check(found, label .. ": drew " .. want) end
end
local SIZES = {
{ 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 },
{ 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 },
}
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
for _, cart in ipairs({ false, true }) do
local page = freshLauncher()
page.tab = "red"
page.ready.red = true
page:_selectCart("red", cart and "kanto_plus" or nil)
LauncherView.draw(page)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, page)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d %s draws: %s")
:format(W, H, cart and "cart" or "vanilla", tostring(err)))
if ok then
auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"),
"Custom Carts")
end
Kit.audit = nil
page._cartPopup = "red"
if cart then
page._cartNotice = "Browsing for carts arrives in a later update."
end
LauncherView.draw(page)
Kit.audit = {}
ok, err = pcall(LauncherView.draw, page)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d picker draws: %s"):format(W, H, tostring(err)))
if ok then
auditFrame(("%dx%d picker"):format(W, H), "Kanto Plus")
for _, r in ipairs(Kit.audit or {}) do
local label = tostring(r.label)
if label == "Get more carts" or label == "Close" then
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
("%dx%d picker: %q stays inside the window"):format(W, H, label))
end
end
end
Kit.audit = nil
end
end
LauncherMods.list = function() return FAKE_MODS end
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
local form = freshLauncher()
form.tab = "mods"
form.ready.red = true
form:_setModScope("red")
form:_beginCartSave("red")
form._cartSave.text = "Soda Run"
form:_commitCartSave()
check(form._cartSave ~= nil,
("%dx%d save form stays open on a colliding title"):format(W, H))
LauncherView.draw(form)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, form)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d save form draws: %s"):format(W, H, tostring(err)))
if ok then
auditFrame(("%dx%d save form"):format(W, H), "Save as cart")
for _, r in ipairs(Kit.audit or {}) do
if tostring(r.label) == "Cancel" then
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
("%dx%d save form: Cancel stays inside the window"):format(W, H))
end
end
end
Kit.audit = nil
end
LauncherMods.list = realModList
T.finish("cart launcher")
+413
View File
@@ -0,0 +1,413 @@
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")
local Base64 = require("src.core.Base64")
local CartManifest = require("src.carts.CartManifest")
local SaveSerializer = require("src.core.SaveSerializer")
local SHA = ("a1b2c3d4"):rep(8)
local MD5 = ("0123456789abcdef"):rep(2)
local function baseCart()
return {
id = "kanto_plus",
title = " Kanto Plus ",
version = "1.2.0",
author = "Ren",
repo = "ren/kanto-plus",
summary = "A sealed set of five",
shell = "#3FA9F5",
label = "./art/label.png",
base = "red",
engine = ">=1.4.0",
mods = {
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA,
options = { sweetness = 3, flavour = "grape", fizzy = true } },
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
md5 = MD5 },
},
}
end
local function rejects(mutate, fragment, what)
local tbl = baseCart()
mutate(tbl)
local cart, err = CartManifest.parse(tbl)
T.eq(cart, nil, what .. " is rejected")
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
("%s says why (got %s)"):format(what, tostring(err)))
end
local raw = baseCart()
local cart, err = CartManifest.parse(raw)
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
T.eq(cart.title, "Kanto Plus", "title is trimmed")
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
T.eq(cart.seal, "sealed", "seal defaults to sealed")
T.eq(cart.base, "red", "base survives")
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
T.eq(#cart.mods, 2, "both pins survive")
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
local again = CartManifest.parse(baseCart())
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
"two independent parses encode identically")
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
"two independent parses hash identically")
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
local bumped = baseCart()
bumped.mods[1].version = "0.4.2"
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
"a bumped mod version moves the cart hash")
local retuned = baseCart()
retuned.mods[1].options.sweetness = 4
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
"a changed option value moves the cart hash")
local reordered = baseCart()
reordered.load_order = { "hard_mode", "rare_soda" }
local reorderedCart = CartManifest.parse(reordered)
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
"a different load order moves the cart hash")
local encoded = CartManifest.encode(cart)
local decoded, decodeErr = CartManifest.decode(encoded)
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
T.same(decoded, cart, "the round trip is lossless")
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
"the round trip keeps the cart hash")
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
"decode refuses another format's file")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 99, cart = {} }')
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
T.eq(CartManifest.decode('return os.exit(1)'), nil,
"decode refuses a file that tries to call out")
T.eq(CartManifest.decode(
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
rejects(function(c) c.id = nil end, "cart id", "a missing id")
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
rejects(function(c) c.title = nil end, "cart title", "a missing title")
rejects(function(c) c.title = " " end, "cart title", "a blank title")
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
rejects(function(c) c.version = nil end, "cart version", "a missing version")
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
rejects(function(c) c.author = nil end, "cart author", "a missing author")
rejects(function(c) c.author = "" end, "cart author", "an empty author")
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
rejects(function(c) c.base = "crystal" end, "cart base", "an unknown base game")
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
rejects(function(c)
for i = 1, 65 do
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
end
end, "cart must pin", "a cart that pins 65 mods")
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
"a non-table options block")
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
"option keys", "a 65 character option key")
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
"option keys", "a numeric option key")
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
"must be a string, number or boolean", "a table option value")
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
"characters or fewer", "a 257 character option value")
rejects(function(c)
local options = {}
for i = 1, 65 do options["opt" .. i] = i end
c.mods[1].options = options
end, "more than 64 options", "a pin with 65 options")
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
"a string load_order")
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
"a load_order that drops a mod")
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
"exactly once", "a load_order longer than the mods array")
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
"a load_order that repeats a mod")
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
"does not pin", "a load_order naming an unpinned mod")
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
local open = baseCart()
open.seal = "open"
open.repo = nil
open.summary = nil
open.label = nil
open.engine = nil
local openCart = CartManifest.parse(open)
T.check(openCart ~= nil, "an open cart with no optional fields parses")
T.eq(openCart.seal, "open", "an open seal survives")
T.eq(openCart.label, nil, "an absent label stays absent")
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
"an open cart round trips")
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
"the seal is part of the cart hash")
T.eq(CartManifest.publishable(cart), true,
"a cart pinned entirely to github and gamebanana is publishable")
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
local localised = baseCart()
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
repo = "ren/rare-soda", sha256 = SHA,
options = { flavour = "grape" } }
local localCart, localErr = CartManifest.parse(localised)
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
"two parses of a local pin hash identically")
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
"a local pin hashes differently from the github pin it replaced")
local localBump = baseCart()
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
options = { flavour = "grape" } }
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
"a bumped local pin version moves the cart hash")
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
"a cart holding a local pin round trips")
local publishableLocal, localWhy = CartManifest.publishable(localCart)
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
T.check(localWhy:find("hard_mode", 1, true) == nil,
"the reason leaves the publishable pins out")
local allLocal = baseCart()
allLocal.mods = {
{ id = "rare_soda", source = "local", version = "0.4.1" },
{ id = "hard_mode", source = "local", version = "2.0.0" },
}
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
"version must be", "a local pin with no version")
rejects(function(c)
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
end, "version must be", "a local pin with a non-semver version")
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
"a source that merely starts like local")
local VECTORS = {
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
}
for _, row in ipairs(VECTORS) do
T.eq(Base64.encode(row[1]), row[2],
("base64 encodes %q as %s"):format(row[1], row[2]))
T.eq(Base64.decode(row[2]), row[1],
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
end
local seed = 7
local function nextByte()
seed = (seed * 75 + 74) % 65537
return seed % 256
end
for n = 0, 24 do
local chunk = {}
for i = 1, n do chunk[i] = string.char(nextByte()) end
local raw = table.concat(chunk)
local text = Base64.encode(raw)
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
end
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
local ART_DATA = Base64.encode(PNG)
local NONE = {}
local function artTable(over)
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
data = ART_DATA }
for key, value in pairs(over or {}) do
if value == NONE then art[key] = nil else art[key] = value end
end
return art
end
local function bundle(body, art)
return SaveSerializer.encode({ format = CartManifest.FORMAT,
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
end
local arted = CartManifest.parse(baseCart())
arted.labelArt = artTable()
local artedBytes = CartManifest.encode(arted)
local artedRound, artedErr = CartManifest.decode(artedBytes)
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
T.eq(CartManifest.encode(artedRound), artedBytes,
"re-encoding a decoded cart writes the same bytes")
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
"label art is not part of the cart hash")
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
"the canonical form leaves the art payload out")
local repainted = CartManifest.parse(baseCart())
local REPAINT = PNG .. "repainted"
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
"changing only the art leaves the cart hash alone")
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
REPAINT, "the repainted art round trips")
local plain = CartManifest.decode(CartManifest.encode(cart))
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
"a cart with no art writes no labelArt key")
T.same(plain, cart, "a cart with no art round trips exactly as before")
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
local function drops(art, what)
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
end
drops("label.png", "art that is not a table")
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
drops(artTable({ encoding = NONE }), "art with no encoding")
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
drops(artTable({ data = NONE }), "art with no payload")
drops(artTable({ data = "" }), "art with an empty payload")
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
drops(artTable({ bytes = NONE }), "art with no byte count")
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
drops(artTable({ name = 7 }), "art with a numeric name")
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
local _, capErr = CartManifest.parseLabelArt(
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
"the size cap says why (got " .. tostring(capErr) .. ")")
local _, pngErr = CartManifest.parseLabelArt(
artTable({ data = Base64.encode("nope"), bytes = 4 }))
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
T.finish("cart_manifest")
+269
View File
@@ -0,0 +1,269 @@
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")
+470
View File
@@ -0,0 +1,470 @@
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")
+393
View File
@@ -0,0 +1,393 @@
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")
+122
View File
@@ -0,0 +1,122 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: "Existing v<version> tag to build and publish."
required: true
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
env:
CART_ID: "{{CART_ID}}"
CARTKIT_REPO: bryanthaboi/gen1recomp
CARTKIT_REF: dev
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
fetch-depth: 0
- name: Fetch cartkit
run: |
set -euo pipefail
curl -fsSL --retry 3 -o "$RUNNER_TEMP/cartkit.py" \
"https://raw.githubusercontent.com/${CARTKIT_REPO}/${CARTKIT_REF}/tools/cartkit.py"
python3 "$RUNNER_TEMP/cartkit.py" selftest --quiet
- name: Check the tag against cart.json
id: cart
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, sys
with open("cart.json", encoding="utf-8") as fh:
cart = json.load(fh)
version = str(cart.get("version", ""))
cart_id = str(cart.get("id", ""))
tag = os.environ["TAG"]
if tag != f"v{version}":
print(f"::error::tag {tag} does not match cart.json version "
f"{version} (expected v{version})", file=sys.stderr)
raise SystemExit(1)
stamped = os.environ["CART_ID"]
if cart_id != stamped:
print(f"::error::cart.json id is {cart_id}, but this workflow "
f"was stamped for {stamped}; rerun cartkit "
"add-release-workflow", file=sys.stderr)
raise SystemExit(1)
print(f"version={version}")
print(f"id={cart_id}")
print(f"tag={tag}")
PY
- name: Validate every pin
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 "$RUNNER_TEMP/cartkit.py" validate . --online --strict
- name: Pack the cart
env:
CART_VERSION: ${{ steps.cart.outputs.version }}
CART_NAME: ${{ steps.cart.outputs.id }}
run: |
set -euo pipefail
out="$GITHUB_WORKSPACE/dist"
rm -rf "$out"
mkdir -p "$out"
python3 "$RUNNER_TEMP/cartkit.py" pack . \
-o "$out/${CART_NAME}-${CART_VERSION}.g1rcart"
(cd "$out" && sha256sum ./*.g1rcart > sha256sums.txt)
cat "$out/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
CART_VERSION: ${{ steps.cart.outputs.version }}
CART_NAME: ${{ steps.cart.outputs.id }}
TAG: ${{ steps.cart.outputs.tag }}
run: |
set -euo pipefail
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
range="${prev:+${prev}..}${TAG}"
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
notes=$'Download the .g1rcart and open it from the game to install this cart.'
notes+=$'\n\nThe cart is a manifest: it pins each mod to the exact build listed in cart.json and ships no code of its own.'
if [ -n "$changes" ]; then
notes+=$'\n\n## Changes\n\n'"$changes"
fi
asset="dist/${CART_NAME}-${CART_VERSION}.g1rcart"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "$asset" "dist/sha256sums.txt" --clobber
else
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$CART_VERSION" \
--notes "$notes" \
"$asset" \
"dist/sha256sums.txt"
fi
echo "Published $TAG"
+1749
View File
File diff suppressed because it is too large Load Diff