mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user