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
+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