mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +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:
@@ -15,6 +15,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed
|
||||
* **Custom carts**, a named mod set saved from the mods tab and picked from a game's page, with its own shell colour, label art, save slots and export file
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
|
||||
@@ -327,9 +327,9 @@ end
|
||||
local function makeLauncher()
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
return RomImporter.new(function(version)
|
||||
return RomImporter.new(function(version, cartId)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
bootGame(version, cartId)
|
||||
end, {
|
||||
launcher = true,
|
||||
forceImport = forceImport,
|
||||
@@ -394,7 +394,7 @@ local function returnToLauncher()
|
||||
Importer = makeLauncher()
|
||||
end
|
||||
|
||||
function bootGame(version)
|
||||
function bootGame(version, cartId)
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||
@@ -407,6 +407,16 @@ function bootGame(version)
|
||||
-- (Blue/Yellow/Gold caches live under blue/ / yellow/ / gold/).
|
||||
CacheFs.prefix = GameVersion.cachePrefix()
|
||||
CacheFs.mountVersion(GameVersion.get())
|
||||
local cartHash
|
||||
if cartId then
|
||||
local ok, cart, hash = pcall(function()
|
||||
return require("src.carts.CartStore").get(cartId)
|
||||
end)
|
||||
if ok and cart then cartHash = hash else cartId = nil end
|
||||
end
|
||||
local SaveData = require("src.core.SaveData")
|
||||
SaveData.setCart(cartId, cartHash)
|
||||
if cartId then SaveData.adoptCartSeal(cartId) end
|
||||
-- NX: always write nx-asset-probe.log so Yellow/Blue art failures are
|
||||
-- diagnosable from the SD without enabling switch-debug.txt.
|
||||
pcall(function()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
-- Custom carts in the launcher.
|
||||
-- luajit tests/engine/cart_launcher.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
love.graphics.setLineJoin = love.graphics.setLineJoin or function() end
|
||||
love.graphics.newShader = love.graphics.newShader or function() return {} end
|
||||
love.graphics.polygon = love.graphics.polygon or function() end
|
||||
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local LauncherView = require("src.import.LauncherView")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
|
||||
local function window(w, h)
|
||||
love.graphics.getDimensions = function() return w, h end
|
||||
love.graphics.getPixelDimensions = function() return w, h end
|
||||
end
|
||||
|
||||
local function freshLauncher(onComplete)
|
||||
return RomImporter.new(onComplete or function() end, { launcher = true })
|
||||
end
|
||||
|
||||
local realPrint = love.graphics.print
|
||||
local function drawAndCapture(imp)
|
||||
local seen = {}
|
||||
love.graphics.print = function(str, ...)
|
||||
seen[#seen + 1] = tostring(str)
|
||||
return realPrint(str, ...)
|
||||
end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
love.graphics.print = realPrint
|
||||
check(ok, "the frame draws: " .. tostring(err))
|
||||
return table.concat(seen, "\n")
|
||||
end
|
||||
|
||||
local realSetColor = love.graphics.setColor
|
||||
local function drawColors(imp)
|
||||
local seen = {}
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
if type(r) == "number" and type(g) == "number" and type(b) == "number" then
|
||||
seen[("%d,%d,%d"):format(math.floor(r * 255 + 0.5),
|
||||
math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))] = true
|
||||
end
|
||||
return realSetColor(r, g, b, a)
|
||||
end
|
||||
local ok, err = pcall(LauncherView.draw, imp)
|
||||
love.graphics.setColor = realSetColor
|
||||
check(ok, "the frame draws: " .. tostring(err))
|
||||
return seen
|
||||
end
|
||||
|
||||
local function cartTable(over)
|
||||
local tbl = {
|
||||
id = "kanto_plus", title = "Kanto Plus", version = "1.2.0",
|
||||
author = "Ren", shell = "#3fa9f5", base = "red", seal = "sealed",
|
||||
mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA } },
|
||||
}
|
||||
for key, value in pairs(over or {}) do tbl[key] = value end
|
||||
return tbl
|
||||
end
|
||||
|
||||
local function install(over)
|
||||
over = over or {}
|
||||
local cart, parseErr = CartManifest.parse(cartTable(over))
|
||||
check(cart ~= nil, "fixture parses: " .. tostring(parseErr))
|
||||
cart.labelArt = over.labelArt
|
||||
local ok, err = CartStore.install(CartManifest.encode(cart))
|
||||
check(ok ~= nil, "fixture installs: " .. tostring(err))
|
||||
return cart
|
||||
end
|
||||
|
||||
install()
|
||||
install({ id = "zeta_open", title = "Zeta Open", version = "0.9.0",
|
||||
seal = "open", shell = "#112233" })
|
||||
install({ id = "johto_lite", title = "Johto Lite", base = "blue",
|
||||
shell = "#7a5c2e" })
|
||||
|
||||
window(1280, 720)
|
||||
|
||||
local imp = freshLauncher()
|
||||
local red = imp:_ensureCarts("red")
|
||||
eq(#red, 2, "red lists exactly the carts based on red")
|
||||
eq(red[1].id, "kanto_plus", "the list is sorted by title")
|
||||
eq(red[2].id, "zeta_open", "the list is sorted by title")
|
||||
eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart")
|
||||
eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite")
|
||||
eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none")
|
||||
|
||||
imp.tab = "red"
|
||||
imp.ready.red = true
|
||||
imp._cartPopup = "red"
|
||||
local picker = drawAndCapture(imp)
|
||||
check(picker:find("Pokemon Red", 1, true) ~= nil,
|
||||
"the picker offers the base game as the first row")
|
||||
check(picker:find("Kanto Plus", 1, true) ~= nil, "the picker lists Kanto Plus")
|
||||
check(picker:find("Zeta Open", 1, true) ~= nil, "the picker lists Zeta Open")
|
||||
check(picker:find("Johto Lite", 1, true) == nil,
|
||||
"the picker does NOT list a cart based on another game")
|
||||
check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version")
|
||||
check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state")
|
||||
check(picker:find("open", 1, true) ~= nil, "including an open one")
|
||||
check(picker:find("Get more carts", 1, true) ~= nil,
|
||||
"the last row is the browse placeholder")
|
||||
|
||||
imp._cartPopup = nil
|
||||
local vanillaColors = drawColors(imp)
|
||||
check(vanillaColors["63,169,245"] == nil,
|
||||
"a vanilla page never paints the cart's shell colour")
|
||||
|
||||
imp:_selectCart("red", "kanto_plus")
|
||||
eq(imp.activeCart.red, "kanto_plus", "the pick lands on activeCart")
|
||||
eq(imp._cartPopup, nil, "picking closes the picker")
|
||||
|
||||
local titled = drawAndCapture(imp)
|
||||
check(titled:find("Kanto Plus", 1, true) ~= nil,
|
||||
"the panel title becomes the cart's title")
|
||||
|
||||
local cartColors = drawColors(imp)
|
||||
eq(cartColors["63,169,245"], true,
|
||||
"the cartridge takes the cart's shell colour")
|
||||
|
||||
eq(imp.tab, "red", "a cart id never reaches imp.tab")
|
||||
eq(imp.panelVersion, "red", "a cart id never reaches imp.panelVersion")
|
||||
|
||||
check(imp._cartridge["cart:kanto_plus"] ~= nil,
|
||||
"the cart spins on its own cartridge state")
|
||||
check(imp._cartridge["cart:kanto_plus"] ~= imp._cartridge.red,
|
||||
"which is not the base game's")
|
||||
eq(imp._cartridgeLabels["cart:kanto_plus"], false,
|
||||
"a cart carrying no label art falls through to bare plastic")
|
||||
|
||||
local scope = imp:slotScope("red")
|
||||
eq(scope, "cart_kanto_plus", "an active cart scopes the panel's save slots")
|
||||
imp:_newSlot(scope)
|
||||
imp:_newSlot(scope)
|
||||
eq(#SaveData.listCartSlots("kanto_plus"), 2, "both slots land in the cart")
|
||||
eq(#SaveData.listSlots("red"), 0, "and none of them in the base game")
|
||||
imp:_ensureSlots(scope)
|
||||
eq(#imp.slots[scope], 2, "the panel reads the cart's slots back")
|
||||
|
||||
imp:_beginRename(scope, "slot1")
|
||||
imp._rename.text = "Nuzlocke"
|
||||
imp:_commitRename()
|
||||
eq(SaveData.listCartSlots("kanto_plus")[1].label, "Nuzlocke",
|
||||
"a rename writes into the cart's registry")
|
||||
|
||||
imp:_selectSlot(scope, "slot2")
|
||||
eq(SaveData.activeCartSlot("kanto_plus"), "slot2",
|
||||
"selecting a row moves the cart's active slot")
|
||||
imp:_deleteSlot(scope, "slot2")
|
||||
eq(#SaveData.listCartSlots("kanto_plus"), 1, "a delete removes the cart's slot")
|
||||
|
||||
local withCart = drawAndCapture(imp)
|
||||
check(withCart:find("Nuzlocke", 1, true) ~= nil,
|
||||
"the slot card shows the cart's slots while the cart is active")
|
||||
|
||||
imp:_selectCart("red", nil)
|
||||
eq(imp.activeCart.red, nil, "choosing the base game clears the active cart")
|
||||
eq(imp:slotScope("red"), "red", "and the slots go back to the version's own")
|
||||
imp:_newSlot("red")
|
||||
eq(#SaveData.listSlots("red"), 1, "a vanilla slot lands in the version")
|
||||
eq(#SaveData.listCartSlots("kanto_plus"), 1, "and not in the cart")
|
||||
|
||||
local backHome = drawAndCapture(imp)
|
||||
check(backHome:find("Nuzlocke", 1, true) == nil,
|
||||
"the slot card no longer shows the cart's slots")
|
||||
check(backHome:find("Kanto Plus", 1, true) == nil,
|
||||
"and the panel title is the base game's again")
|
||||
local homeColors = drawColors(imp)
|
||||
check(homeColors["63,169,245"] == nil,
|
||||
"the cartridge is back to the base game's shell")
|
||||
|
||||
local handed = {}
|
||||
local player = freshLauncher(function(version, cartId)
|
||||
handed.version, handed.cart = version, cartId
|
||||
end)
|
||||
player.ready.red = true
|
||||
player:_selectCart("red", "kanto_plus")
|
||||
player:play("red")
|
||||
eq(handed.version, "red", "Play still boots the base game")
|
||||
eq(handed.cart, "kanto_plus", "and names the cart it is running")
|
||||
|
||||
local opts = SaveData.loadOptions()
|
||||
eq(opts.lastVersion, "red", "play still remembers the version")
|
||||
eq(type(opts.activeCart) == "table" and opts.activeCart.red or nil, "kanto_plus",
|
||||
"play persists the active cart beside it")
|
||||
|
||||
local restored = freshLauncher()
|
||||
eq(restored.activeCart.red, "kanto_plus",
|
||||
"a fresh launcher restores the cart its page was on")
|
||||
eq(restored.tab, "red", "and a cart id still never reaches the tab")
|
||||
|
||||
local uninstalled = freshLauncher()
|
||||
uninstalled.activeCart.red = nil
|
||||
uninstalled:_restoreActiveCarts({ activeCart = { red = "gone_forever" } })
|
||||
eq(uninstalled.activeCart.red, nil,
|
||||
"a remembered cart that is no longer installed is dropped")
|
||||
|
||||
local Base64 = require("src.core.Base64")
|
||||
local PNG = CartManifest.PNG_SIGNATURE .. ("labelart"):rep(4)
|
||||
local function artOf()
|
||||
return { encoding = "base64", bytes = #PNG, data = Base64.encode(PNG) }
|
||||
end
|
||||
|
||||
install({ id = "art_cart", title = "Art Cart", base = "yellow",
|
||||
shell = "#204060", labelArt = artOf() })
|
||||
install({ id = "bare_cart", title = "Bare Cart", base = "yellow",
|
||||
shell = "#405060" })
|
||||
install({ id = "bad_cart", title = "Bad Cart", base = "yellow",
|
||||
shell = "#605040", labelArt = artOf() })
|
||||
|
||||
check(CartStore.labelArt("art_cart") == PNG,
|
||||
"the store hands back the cart's own PNG bytes")
|
||||
check(CartStore.labelArt("bare_cart") == nil,
|
||||
"and nothing for a cart that carries none")
|
||||
|
||||
window(1280, 720)
|
||||
local realNewImage = love.graphics.newImage
|
||||
local madeImages = 0
|
||||
love.graphics.newImage = function(...)
|
||||
madeImages = madeImages + 1
|
||||
return realNewImage(...)
|
||||
end
|
||||
|
||||
local art = freshLauncher()
|
||||
art.tab = "yellow"
|
||||
art.ready.yellow = true
|
||||
art:_selectCart("yellow", "art_cart")
|
||||
drawAndCapture(art)
|
||||
local artLabel = art._cartridgeLabels["cart:art_cart"]
|
||||
check(type(artLabel) == "table" and artLabel.image ~= nil,
|
||||
"a cart's own label art becomes a cached cartridge image")
|
||||
local afterFirst = madeImages
|
||||
drawAndCapture(art)
|
||||
eq(madeImages, afterFirst, "the decode happens once, not every frame")
|
||||
|
||||
art:_selectCart("yellow", "bare_cart")
|
||||
drawAndCapture(art)
|
||||
eq(art._cartridgeLabels["cart:bare_cart"], false,
|
||||
"a cart with no art renders as bare plastic")
|
||||
check(art._cartridgeLabels["cart:art_cart"] ~= art._cartridgeLabels["cart:bare_cart"],
|
||||
"and the two carts never share one label cache entry")
|
||||
|
||||
love.graphics.newImage = function(a, ...)
|
||||
if type(a) == "table" and a._fileData then error("not a PNG this engine reads") end
|
||||
return realNewImage(a, ...)
|
||||
end
|
||||
local bad = freshLauncher()
|
||||
bad.tab = "yellow"
|
||||
bad.ready.yellow = true
|
||||
bad:_selectCart("yellow", "bad_cart")
|
||||
drawAndCapture(bad)
|
||||
love.graphics.newImage = realNewImage
|
||||
eq(bad._cartridgeLabels["cart:bad_cart"], false,
|
||||
"art that will not decode leaves the cart bare instead of throwing")
|
||||
drawAndCapture(bad)
|
||||
eq(bad._cartridgeLabels["cart:bad_cart"], false,
|
||||
"and it is not retried on the next frame")
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local realModList = LauncherMods.list
|
||||
|
||||
local function fakeRow(over)
|
||||
local row = { id = "x", name = "X", version = "1.0.0", badge = "MOD",
|
||||
description = "", enabled = true, status = "ok",
|
||||
statusDetail = "", experimental = false, targetsHere = true,
|
||||
targets = nil, safeMode = false, requiredImports = {},
|
||||
imports = {}, missingRequiredImports = 0,
|
||||
missingOptionalImports = 0,
|
||||
enabledByVersion = { red = true, blue = true, yellow = true,
|
||||
gold = true, silver = true } }
|
||||
for key, value in pairs(over) do row[key] = value end
|
||||
row.manifest = row.manifest or { id = row.id, name = row.name,
|
||||
version = row.version }
|
||||
return row
|
||||
end
|
||||
|
||||
local FAKE_MODS = {
|
||||
fakeRow({ id = "rare_soda", name = "Rare Soda", version = "0.4.1",
|
||||
github = "ren/rare-soda", sha256 = SHA }),
|
||||
fakeRow({ id = "wide_gym", name = "Wide Gym", version = "dev" }),
|
||||
fakeRow({ id = "off_mod", name = "Off Mod", enabled = false,
|
||||
enabledByVersion = { red = false } }),
|
||||
}
|
||||
|
||||
local modsView = freshLauncher()
|
||||
modsView.tab = "mods"
|
||||
local modsText = drawAndCapture(modsView)
|
||||
check(modsText:find("Save as cart", 1, true) ~= nil,
|
||||
"the mods tab carries the Save as cart control")
|
||||
|
||||
LauncherMods.list = function() return FAKE_MODS end
|
||||
|
||||
local maker = freshLauncher()
|
||||
maker.tab = "red"
|
||||
maker.ready.red = true
|
||||
maker:_setModScope("red")
|
||||
eq(maker:_cartCaptureCount("red"), 2,
|
||||
"the control counts only the mods enabled for this game")
|
||||
|
||||
maker:_beginCartSave("red")
|
||||
check(maker._cartSave ~= nil, "Save as cart opens a form")
|
||||
eq(maker._cartSave.count, 2, "the form reports the captured mod count")
|
||||
eq(maker._cartSave.version, "red", "scoped to the game the panel is showing")
|
||||
eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve")
|
||||
eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to")
|
||||
check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil,
|
||||
"and why it could only be pinned locally")
|
||||
eq(maker._cartSave.publishable, false, "a local pin makes the cart unpublishable")
|
||||
|
||||
local form = drawAndCapture(maker)
|
||||
check(form:find("Save as cart", 1, true) ~= nil, "the form is titled")
|
||||
check(form:find("Wide Gym", 1, true) ~= nil,
|
||||
"the unresolved pin is named BEFORE the player confirms")
|
||||
check(form:find("could only be pinned to this install", 1, true) ~= nil,
|
||||
"under a heading that says what a local pin means")
|
||||
check(form:find("cannot be shared", 1, true) ~= nil,
|
||||
"and the form says plainly that the result cannot be shared")
|
||||
|
||||
maker._cartSave.text = "Kanto Plus"
|
||||
maker:_commitCartSave()
|
||||
check(maker._cartSave ~= nil, "a title that collides with an installed cart refuses")
|
||||
check(tostring(maker._cartSave.error):find("kanto_plus", 1, true) ~= nil,
|
||||
"and names the id that is already taken")
|
||||
eq(CartStore.get("kanto_plus").title, "Kanto Plus",
|
||||
"the installed cart is untouched")
|
||||
|
||||
maker._cartSave.text = "Soda Run"
|
||||
maker:_commitCartSave()
|
||||
eq(maker._cartSave, nil, "a free title saves the cart and closes the form")
|
||||
eq(maker._cartPopup, "red", "and drops the player straight into the picker")
|
||||
local made = CartStore.get("soda_run")
|
||||
check(made ~= nil, "the cart is installed under the id derived from the title")
|
||||
eq(made.base, "red", "based on the game the panel was showing")
|
||||
eq(made.version, "1.0.0", "at the default cart version")
|
||||
eq(made.seal, "sealed", "sealed by default")
|
||||
eq(made.shell, "#ff3c48", "wearing the base game's rail colour")
|
||||
eq(#made.mods, 2, "pinning exactly the enabled mods")
|
||||
|
||||
local listedNow = false
|
||||
for _, row in ipairs(maker:_ensureCarts("red")) do
|
||||
if row.id == "soda_run" then listedNow = true end
|
||||
end
|
||||
check(listedNow, "and the picker lists it immediately")
|
||||
local picked = drawAndCapture(maker)
|
||||
check(picked:find("Soda Run", 1, true) ~= nil, "including on screen")
|
||||
|
||||
LauncherMods.list = function() return { FAKE_MODS[1] } end
|
||||
local pure = freshLauncher()
|
||||
pure.tab = "red"
|
||||
pure.ready.red = true
|
||||
pure:_beginCartSave("red")
|
||||
eq(#pure._cartSave.unresolved, 0, "a fully pinned capture has no local pins")
|
||||
eq(pure._cartSave.publishable, true, "and it is publishable")
|
||||
local pureForm = drawAndCapture(pure)
|
||||
check(pureForm:find("can be shared", 1, true) ~= nil,
|
||||
"which the form says before the player confirms")
|
||||
check(pureForm:find("cannot be shared", 1, true) == nil,
|
||||
"instead of the local-pin warning")
|
||||
pure._cartSave.text = "Pure Soda"
|
||||
pure:_commitCartSave()
|
||||
eq(pure._cartSave, nil, "a fully pinned capture saves too")
|
||||
local pureOk, pureWhy = CartManifest.publishable(CartStore.get("pure_soda"))
|
||||
eq(pureOk, true, "and the saved cart really is publishable")
|
||||
eq(pureWhy, nil, "with nothing holding it back")
|
||||
LauncherMods.list = function() return FAKE_MODS end
|
||||
|
||||
local blank = freshLauncher()
|
||||
blank:_beginCartSave("red")
|
||||
blank:_commitCartSave()
|
||||
check(blank._cartSave ~= nil, "an empty title does not save")
|
||||
check(tostring(blank._cartSave.error):find("title", 1, true) ~= nil,
|
||||
"and asks for one")
|
||||
blank:_cancelCartSave()
|
||||
eq(blank._cartSave, nil, "Cancel closes the form")
|
||||
|
||||
LauncherMods.list = realModList
|
||||
|
||||
local exporter = freshLauncher()
|
||||
exporter:exportCart("kanto_plus")
|
||||
check(tostring(exporter._cartNotice):find("Exported", 1, true) ~= nil,
|
||||
"Export reports where the cart file went: " .. tostring(exporter._cartNotice))
|
||||
local wroteBytes = love.filesystem.read("exports/carts/kanto_plus" .. CartStore.EXT)
|
||||
check(type(wroteBytes) == "string" and wroteBytes ~= "",
|
||||
"and the bytes land in the same exports tree a save export uses")
|
||||
eq(wroteBytes, (CartStore.export("kanto_plus")),
|
||||
"byte for byte what CartStore.export returned")
|
||||
local roundTrip = CartManifest.decode(wroteBytes)
|
||||
check(roundTrip ~= nil and roundTrip.id == "kanto_plus",
|
||||
"and the file decodes back into the cart")
|
||||
|
||||
exporter:exportCart("no_such_cart")
|
||||
check(tostring(exporter._cartNotice):find("not installed", 1, true) ~= nil,
|
||||
"exporting a cart that is not installed says so")
|
||||
|
||||
window(1920, 1080)
|
||||
|
||||
local FULL_MODS = { fakeRow({ id = "rare_soda", name = "Rare Soda",
|
||||
version = "0.4.1", github = "ren/rare-soda",
|
||||
sha256 = SHA }) }
|
||||
|
||||
LauncherMods.list = function() return FULL_MODS end
|
||||
local okCart = freshLauncher()
|
||||
okCart.tab = "red"
|
||||
okCart.ready.red = true
|
||||
okCart:_selectCart("red", "kanto_plus")
|
||||
local okPlan = okCart:cartPlan("red")
|
||||
eq(okPlan.refused, false, "a cart whose pin is installed is playable")
|
||||
eq(okPlan.sealed, true, "and is still sealed")
|
||||
local okText = drawAndCapture(okCart)
|
||||
check(okText:find("Sealed", 1, true) ~= nil,
|
||||
"the verdict is on the page before the player commits")
|
||||
check(okText:find("Break the seal", 1, true) ~= nil,
|
||||
"and a sealed cart's page offers the escape hatch")
|
||||
|
||||
LauncherMods.list = function() return {} end
|
||||
local gapCart = freshLauncher()
|
||||
gapCart.tab = "red"
|
||||
gapCart.ready.red = true
|
||||
gapCart:_selectCart("red", "kanto_plus")
|
||||
local gapPlan = gapCart:cartPlan("red")
|
||||
eq(gapPlan.refused, true, "a cart with an uninstalled pin refuses")
|
||||
eq(gapPlan.missing[1].id, "rare_soda", "naming the pin it cannot find")
|
||||
local gapText = drawAndCapture(gapCart)
|
||||
check(gapText:find("will not start", 1, true) ~= nil,
|
||||
"which the page says without the player booting into an error")
|
||||
check(gapText:find("rare_soda", 1, true) ~= nil, "and names the pin")
|
||||
check(gapText:find("Break the seal", 1, true) ~= nil,
|
||||
"with the escape hatch beside the refusal")
|
||||
|
||||
local sealScope = gapCart:slotScope("red")
|
||||
gapCart:_ensureSlots(sealScope)
|
||||
local sealSlot = gapCart.activeSlot[sealScope]
|
||||
check(type(sealSlot) == "string", "the cart page has a loaded save slot")
|
||||
eq(gapCart:pressBreakSeal("red"), false, "the first press only arms the confirm")
|
||||
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), false,
|
||||
"so one press breaks nothing")
|
||||
local armedText = drawAndCapture(gapCart)
|
||||
check(armedText:find("Kanto Plus", 1, true) ~= nil,
|
||||
"the armed confirm names the cart")
|
||||
check(armedText:find("save slot", 1, true) ~= nil, "and the save slot")
|
||||
check(armedText:find("cannot be undone", 1, true) ~= nil,
|
||||
"says it is permanent")
|
||||
check(armedText:find("marked modified", 1, true) ~= nil,
|
||||
"says the file is marked modified from then on")
|
||||
check(armedText:find("pinned mods first", 1, true) ~= nil,
|
||||
"and that the cart's own list still loads first")
|
||||
eq(gapCart:pressBreakSeal("red"), true, "a second press breaks the seal")
|
||||
eq(SaveData.slotSealBroken("kanto_plus", sealSlot), true,
|
||||
"which lands on that slot, durably")
|
||||
eq(gapCart:cartPlan("red").refused, false, "and the cart is no longer refused")
|
||||
local brokenText = drawAndCapture(gapCart)
|
||||
check(brokenText:find("Seal broken", 1, true) ~= nil,
|
||||
"the cart page shows the broken state afterwards")
|
||||
check(brokenText:find("seal broken", 1, true) ~= nil,
|
||||
"and so does the cart's slot row")
|
||||
|
||||
gapCart:_newSlot(sealScope)
|
||||
local freshSlot = gapCart.activeSlot[sealScope]
|
||||
check(freshSlot ~= sealSlot, "a new save slot under the same cart")
|
||||
eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false,
|
||||
"starts sealed again")
|
||||
eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one")
|
||||
|
||||
LauncherMods.list = realModList
|
||||
window(1280, 720)
|
||||
|
||||
local function clipped(r)
|
||||
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
|
||||
if r.clip then
|
||||
x1 = math.max(x1, r.clip.x); y1 = math.max(y1, r.clip.y)
|
||||
x2 = math.min(x2, r.clip.x + r.clip.w); y2 = math.min(y2, r.clip.y + r.clip.h)
|
||||
end
|
||||
if x2 - x1 <= 1 or y2 - y1 <= 1 then return nil end
|
||||
return x1, y1, x2, y2
|
||||
end
|
||||
|
||||
local function overlap(a, b)
|
||||
local ax1, ay1, ax2, ay2 = clipped(a)
|
||||
if not ax1 then return false end
|
||||
local bx1, by1, bx2, by2 = clipped(b)
|
||||
if not bx1 then return false end
|
||||
return math.min(ax2, bx2) - math.max(ax1, bx1) > 1
|
||||
and math.min(ay2, by2) - math.max(ay1, by1) > 1
|
||||
end
|
||||
|
||||
local function auditFrame(label, want)
|
||||
local controls, found = {}, false
|
||||
for _, r in ipairs(Kit.audit or {}) do
|
||||
if r.class == "control" then
|
||||
controls[#controls + 1] = r
|
||||
if want and tostring(r.label):find(want, 1, true) then found = true end
|
||||
end
|
||||
end
|
||||
check(#controls > 0, label .. ": the frame dispatched controls at all")
|
||||
local collisions = 0
|
||||
for i = 1, #controls do
|
||||
for j = i + 1, #controls do
|
||||
if overlap(controls[i], controls[j]) then
|
||||
collisions = collisions + 1
|
||||
print((" overlap: '%s' vs '%s' at (%.0f,%.0f) / (%.0f,%.0f)")
|
||||
:format(tostring(controls[i].label), tostring(controls[j].label),
|
||||
controls[i].x, controls[i].y, controls[j].x, controls[j].y))
|
||||
end
|
||||
end
|
||||
end
|
||||
check(collisions == 0, label .. ": no two controls overlap")
|
||||
if want then check(found, label .. ": drew " .. want) end
|
||||
end
|
||||
|
||||
local SIZES = {
|
||||
{ 360, 780 }, { 412, 915 }, { 480, 900 }, { 720, 1280 },
|
||||
{ 1280, 720 }, { 1024, 768 }, { 900, 700 }, { 1920, 1080 },
|
||||
}
|
||||
|
||||
for _, size in ipairs(SIZES) do
|
||||
local W, H = size[1], size[2]
|
||||
window(W, H)
|
||||
for _, cart in ipairs({ false, true }) do
|
||||
local page = freshLauncher()
|
||||
page.tab = "red"
|
||||
page.ready.red = true
|
||||
page:_selectCart("red", cart and "kanto_plus" or nil)
|
||||
LauncherView.draw(page)
|
||||
Kit.audit = {}
|
||||
local ok, err = pcall(LauncherView.draw, page)
|
||||
Kit.audit = ok and Kit.audit or nil
|
||||
check(ok, ("%dx%d %s draws: %s")
|
||||
:format(W, H, cart and "cart" or "vanilla", tostring(err)))
|
||||
if ok then
|
||||
auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"),
|
||||
"Custom Carts")
|
||||
end
|
||||
Kit.audit = nil
|
||||
|
||||
page._cartPopup = "red"
|
||||
if cart then
|
||||
page._cartNotice = "Browsing for carts arrives in a later update."
|
||||
end
|
||||
LauncherView.draw(page)
|
||||
Kit.audit = {}
|
||||
ok, err = pcall(LauncherView.draw, page)
|
||||
Kit.audit = ok and Kit.audit or nil
|
||||
check(ok, ("%dx%d picker draws: %s"):format(W, H, tostring(err)))
|
||||
if ok then
|
||||
auditFrame(("%dx%d picker"):format(W, H), "Kanto Plus")
|
||||
for _, r in ipairs(Kit.audit or {}) do
|
||||
local label = tostring(r.label)
|
||||
if label == "Get more carts" or label == "Close" then
|
||||
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
|
||||
("%dx%d picker: %q stays inside the window"):format(W, H, label))
|
||||
end
|
||||
end
|
||||
end
|
||||
Kit.audit = nil
|
||||
end
|
||||
end
|
||||
|
||||
LauncherMods.list = function() return FAKE_MODS end
|
||||
for _, size in ipairs(SIZES) do
|
||||
local W, H = size[1], size[2]
|
||||
window(W, H)
|
||||
local form = freshLauncher()
|
||||
form.tab = "mods"
|
||||
form.ready.red = true
|
||||
form:_setModScope("red")
|
||||
form:_beginCartSave("red")
|
||||
form._cartSave.text = "Soda Run"
|
||||
form:_commitCartSave()
|
||||
check(form._cartSave ~= nil,
|
||||
("%dx%d save form stays open on a colliding title"):format(W, H))
|
||||
LauncherView.draw(form)
|
||||
Kit.audit = {}
|
||||
local ok, err = pcall(LauncherView.draw, form)
|
||||
Kit.audit = ok and Kit.audit or nil
|
||||
check(ok, ("%dx%d save form draws: %s"):format(W, H, tostring(err)))
|
||||
if ok then
|
||||
auditFrame(("%dx%d save form"):format(W, H), "Save as cart")
|
||||
for _, r in ipairs(Kit.audit or {}) do
|
||||
if tostring(r.label) == "Cancel" then
|
||||
check(r.y >= -0.5 and r.y + r.h <= H + 0.5,
|
||||
("%dx%d save form: Cancel stays inside the window"):format(W, H))
|
||||
end
|
||||
end
|
||||
end
|
||||
Kit.audit = nil
|
||||
end
|
||||
LauncherMods.list = realModList
|
||||
|
||||
T.finish("cart launcher")
|
||||
@@ -0,0 +1,413 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
|
||||
local ok, bit32 = pcall(require, "bit32")
|
||||
if ok then _G.bit32 = bit32 end
|
||||
end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Base64 = require("src.core.Base64")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
local MD5 = ("0123456789abcdef"):rep(2)
|
||||
|
||||
local function baseCart()
|
||||
return {
|
||||
id = "kanto_plus",
|
||||
title = " Kanto Plus ",
|
||||
version = "1.2.0",
|
||||
author = "Ren",
|
||||
repo = "ren/kanto-plus",
|
||||
summary = "A sealed set of five",
|
||||
shell = "#3FA9F5",
|
||||
label = "./art/label.png",
|
||||
base = "red",
|
||||
engine = ">=1.4.0",
|
||||
mods = {
|
||||
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA,
|
||||
options = { sweetness = 3, flavour = "grape", fizzy = true } },
|
||||
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
|
||||
md5 = MD5 },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function rejects(mutate, fragment, what)
|
||||
local tbl = baseCart()
|
||||
mutate(tbl)
|
||||
local cart, err = CartManifest.parse(tbl)
|
||||
T.eq(cart, nil, what .. " is rejected")
|
||||
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
|
||||
("%s says why (got %s)"):format(what, tostring(err)))
|
||||
end
|
||||
|
||||
local raw = baseCart()
|
||||
local cart, err = CartManifest.parse(raw)
|
||||
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
|
||||
T.eq(cart.title, "Kanto Plus", "title is trimmed")
|
||||
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
|
||||
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
|
||||
T.eq(cart.seal, "sealed", "seal defaults to sealed")
|
||||
T.eq(cart.base, "red", "base survives")
|
||||
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
|
||||
T.eq(#cart.mods, 2, "both pins survive")
|
||||
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
|
||||
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
|
||||
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
|
||||
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
|
||||
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
|
||||
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
|
||||
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
|
||||
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
|
||||
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
|
||||
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
|
||||
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
|
||||
|
||||
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
|
||||
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
|
||||
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
|
||||
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
|
||||
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
|
||||
|
||||
local again = CartManifest.parse(baseCart())
|
||||
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
|
||||
"two independent parses encode identically")
|
||||
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
|
||||
"two independent parses hash identically")
|
||||
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
|
||||
|
||||
local bumped = baseCart()
|
||||
bumped.mods[1].version = "0.4.2"
|
||||
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
|
||||
"a bumped mod version moves the cart hash")
|
||||
|
||||
local retuned = baseCart()
|
||||
retuned.mods[1].options.sweetness = 4
|
||||
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
|
||||
"a changed option value moves the cart hash")
|
||||
|
||||
local reordered = baseCart()
|
||||
reordered.load_order = { "hard_mode", "rare_soda" }
|
||||
local reorderedCart = CartManifest.parse(reordered)
|
||||
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
|
||||
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
|
||||
"a different load order moves the cart hash")
|
||||
|
||||
local encoded = CartManifest.encode(cart)
|
||||
local decoded, decodeErr = CartManifest.decode(encoded)
|
||||
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
|
||||
T.same(decoded, cart, "the round trip is lossless")
|
||||
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
|
||||
"the round trip keeps the cart hash")
|
||||
|
||||
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
|
||||
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
|
||||
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
|
||||
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
|
||||
"decode refuses another format's file")
|
||||
T.eq(CartManifest.decode(
|
||||
('return { format = "%s", formatVersion = 99, cart = {} }')
|
||||
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
|
||||
T.eq(CartManifest.decode('return os.exit(1)'), nil,
|
||||
"decode refuses a file that tries to call out")
|
||||
T.eq(CartManifest.decode(
|
||||
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
|
||||
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
|
||||
|
||||
rejects(function(c) c.id = nil end, "cart id", "a missing id")
|
||||
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
|
||||
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
|
||||
rejects(function(c) c.title = nil end, "cart title", "a missing title")
|
||||
rejects(function(c) c.title = " " end, "cart title", "a blank title")
|
||||
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
|
||||
rejects(function(c) c.version = nil end, "cart version", "a missing version")
|
||||
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
|
||||
rejects(function(c) c.author = nil end, "cart author", "a missing author")
|
||||
rejects(function(c) c.author = "" end, "cart author", "an empty author")
|
||||
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
|
||||
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
|
||||
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
|
||||
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
|
||||
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
|
||||
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
|
||||
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
|
||||
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
|
||||
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
|
||||
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
|
||||
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
|
||||
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
|
||||
rejects(function(c) c.base = "crystal" end, "cart base", "an unknown base game")
|
||||
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
|
||||
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
|
||||
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
|
||||
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
|
||||
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
|
||||
rejects(function(c)
|
||||
for i = 1, 65 do
|
||||
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
|
||||
end
|
||||
end, "cart must pin", "a cart that pins 65 mods")
|
||||
|
||||
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
|
||||
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
|
||||
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
|
||||
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
|
||||
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
|
||||
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
|
||||
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
|
||||
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
|
||||
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
|
||||
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
|
||||
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
|
||||
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
|
||||
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
|
||||
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
|
||||
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
|
||||
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
|
||||
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
|
||||
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
|
||||
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
|
||||
|
||||
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
|
||||
"a non-table options block")
|
||||
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
|
||||
"option keys", "a 65 character option key")
|
||||
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
|
||||
"option keys", "a numeric option key")
|
||||
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
|
||||
"must be a string, number or boolean", "a table option value")
|
||||
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
|
||||
"characters or fewer", "a 257 character option value")
|
||||
rejects(function(c)
|
||||
local options = {}
|
||||
for i = 1, 65 do options["opt" .. i] = i end
|
||||
c.mods[1].options = options
|
||||
end, "more than 64 options", "a pin with 65 options")
|
||||
|
||||
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
|
||||
"a string load_order")
|
||||
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
|
||||
"a load_order that drops a mod")
|
||||
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
|
||||
"exactly once", "a load_order longer than the mods array")
|
||||
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
|
||||
"a load_order that repeats a mod")
|
||||
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
|
||||
"does not pin", "a load_order naming an unpinned mod")
|
||||
|
||||
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
|
||||
|
||||
local open = baseCart()
|
||||
open.seal = "open"
|
||||
open.repo = nil
|
||||
open.summary = nil
|
||||
open.label = nil
|
||||
open.engine = nil
|
||||
local openCart = CartManifest.parse(open)
|
||||
T.check(openCart ~= nil, "an open cart with no optional fields parses")
|
||||
T.eq(openCart.seal, "open", "an open seal survives")
|
||||
T.eq(openCart.label, nil, "an absent label stays absent")
|
||||
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
|
||||
"an open cart round trips")
|
||||
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
|
||||
"the seal is part of the cart hash")
|
||||
|
||||
T.eq(CartManifest.publishable(cart), true,
|
||||
"a cart pinned entirely to github and gamebanana is publishable")
|
||||
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
|
||||
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
|
||||
|
||||
local localised = baseCart()
|
||||
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
|
||||
repo = "ren/rare-soda", sha256 = SHA,
|
||||
options = { flavour = "grape" } }
|
||||
local localCart, localErr = CartManifest.parse(localised)
|
||||
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
|
||||
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
|
||||
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
|
||||
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
|
||||
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
|
||||
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
|
||||
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
|
||||
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
|
||||
|
||||
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
|
||||
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
|
||||
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
|
||||
"two parses of a local pin hash identically")
|
||||
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
|
||||
"a local pin hashes differently from the github pin it replaced")
|
||||
|
||||
local localBump = baseCart()
|
||||
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
|
||||
options = { flavour = "grape" } }
|
||||
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
|
||||
"a bumped local pin version moves the cart hash")
|
||||
|
||||
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
|
||||
"a cart holding a local pin round trips")
|
||||
|
||||
local publishableLocal, localWhy = CartManifest.publishable(localCart)
|
||||
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
|
||||
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
|
||||
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
|
||||
T.check(localWhy:find("hard_mode", 1, true) == nil,
|
||||
"the reason leaves the publishable pins out")
|
||||
|
||||
local allLocal = baseCart()
|
||||
allLocal.mods = {
|
||||
{ id = "rare_soda", source = "local", version = "0.4.1" },
|
||||
{ id = "hard_mode", source = "local", version = "2.0.0" },
|
||||
}
|
||||
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
|
||||
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
|
||||
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
|
||||
|
||||
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
|
||||
"version must be", "a local pin with no version")
|
||||
rejects(function(c)
|
||||
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
|
||||
end, "version must be", "a local pin with a non-semver version")
|
||||
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
|
||||
"a source that merely starts like local")
|
||||
|
||||
local VECTORS = {
|
||||
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
|
||||
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
|
||||
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
|
||||
}
|
||||
for _, row in ipairs(VECTORS) do
|
||||
T.eq(Base64.encode(row[1]), row[2],
|
||||
("base64 encodes %q as %s"):format(row[1], row[2]))
|
||||
T.eq(Base64.decode(row[2]), row[1],
|
||||
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
|
||||
end
|
||||
|
||||
local seed = 7
|
||||
local function nextByte()
|
||||
seed = (seed * 75 + 74) % 65537
|
||||
return seed % 256
|
||||
end
|
||||
for n = 0, 24 do
|
||||
local chunk = {}
|
||||
for i = 1, n do chunk[i] = string.char(nextByte()) end
|
||||
local raw = table.concat(chunk)
|
||||
local text = Base64.encode(raw)
|
||||
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
|
||||
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
|
||||
end
|
||||
|
||||
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
|
||||
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
|
||||
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
|
||||
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
|
||||
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
|
||||
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
|
||||
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
|
||||
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
|
||||
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
|
||||
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
|
||||
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
|
||||
|
||||
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
|
||||
local ART_DATA = Base64.encode(PNG)
|
||||
|
||||
local NONE = {}
|
||||
|
||||
local function artTable(over)
|
||||
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
|
||||
data = ART_DATA }
|
||||
for key, value in pairs(over or {}) do
|
||||
if value == NONE then art[key] = nil else art[key] = value end
|
||||
end
|
||||
return art
|
||||
end
|
||||
|
||||
local function bundle(body, art)
|
||||
return SaveSerializer.encode({ format = CartManifest.FORMAT,
|
||||
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
|
||||
end
|
||||
|
||||
local arted = CartManifest.parse(baseCart())
|
||||
arted.labelArt = artTable()
|
||||
|
||||
local artedBytes = CartManifest.encode(arted)
|
||||
local artedRound, artedErr = CartManifest.decode(artedBytes)
|
||||
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
|
||||
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
|
||||
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
|
||||
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
|
||||
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
|
||||
T.eq(CartManifest.encode(artedRound), artedBytes,
|
||||
"re-encoding a decoded cart writes the same bytes")
|
||||
|
||||
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
|
||||
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
|
||||
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
|
||||
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
|
||||
|
||||
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
|
||||
"label art is not part of the cart hash")
|
||||
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
|
||||
"the canonical form leaves the art payload out")
|
||||
|
||||
local repainted = CartManifest.parse(baseCart())
|
||||
local REPAINT = PNG .. "repainted"
|
||||
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
|
||||
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
|
||||
"changing only the art leaves the cart hash alone")
|
||||
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
|
||||
REPAINT, "the repainted art round trips")
|
||||
|
||||
local plain = CartManifest.decode(CartManifest.encode(cart))
|
||||
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
|
||||
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
|
||||
"a cart with no art writes no labelArt key")
|
||||
T.same(plain, cart, "a cart with no art round trips exactly as before")
|
||||
|
||||
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
|
||||
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
|
||||
|
||||
local function drops(art, what)
|
||||
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
|
||||
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
|
||||
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
|
||||
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
|
||||
end
|
||||
|
||||
drops("label.png", "art that is not a table")
|
||||
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
|
||||
drops(artTable({ encoding = NONE }), "art with no encoding")
|
||||
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
|
||||
drops(artTable({ data = NONE }), "art with no payload")
|
||||
drops(artTable({ data = "" }), "art with an empty payload")
|
||||
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
|
||||
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
|
||||
drops(artTable({ bytes = NONE }), "art with no byte count")
|
||||
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
|
||||
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
|
||||
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
|
||||
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
|
||||
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
|
||||
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
|
||||
drops(artTable({ name = 7 }), "art with a numeric name")
|
||||
|
||||
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
|
||||
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
|
||||
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
|
||||
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
|
||||
|
||||
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
|
||||
local _, capErr = CartManifest.parseLabelArt(
|
||||
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
|
||||
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
|
||||
"the size cap says why (got " .. tostring(capErr) .. ")")
|
||||
local _, pngErr = CartManifest.parseLabelArt(
|
||||
artTable({ data = Base64.encode("nope"), bytes = 4 }))
|
||||
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
|
||||
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
|
||||
|
||||
T.finish("cart_manifest")
|
||||
@@ -0,0 +1,269 @@
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function options(files)
|
||||
return SaveSerializer.decode(files["options.lua"] or "") or {}
|
||||
end
|
||||
|
||||
local function plainSave(name, hash)
|
||||
return {
|
||||
version = "red",
|
||||
meta = hash and { cartHash = hash } or nil,
|
||||
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
inventory = {},
|
||||
playTime = 0,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
fresh()
|
||||
T.eq(SaveData.getCart(), nil, "no cart is active by default")
|
||||
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
|
||||
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
|
||||
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
|
||||
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
|
||||
T.eq(SaveData.getCart(), nil, "and getCart says so")
|
||||
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
|
||||
|
||||
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
|
||||
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
|
||||
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
|
||||
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
|
||||
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
|
||||
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
|
||||
|
||||
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
|
||||
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
|
||||
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
|
||||
T.check(not ok, "and deleting from it fails")
|
||||
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
|
||||
"with a user-presentable reason")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
|
||||
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
|
||||
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
|
||||
"setActiveCartSlot returns the chosen id")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
|
||||
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
|
||||
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
|
||||
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
|
||||
|
||||
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
|
||||
"renameCartSlot labels a registered slot")
|
||||
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
|
||||
"the label is trimmed and persisted")
|
||||
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
|
||||
"listCartSlots carries the label")
|
||||
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
|
||||
T.eq(options(files).cartSlots.nuzlocke.names, nil,
|
||||
"the names table leaves the registry once empty")
|
||||
|
||||
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
|
||||
T.check(not bad, "renaming an unregistered cart slot fails")
|
||||
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
|
||||
"unknown-slot rename error is user-presentable")
|
||||
|
||||
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
|
||||
"seed the active cart slot")
|
||||
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
|
||||
"the bytes land in the cart's own directory")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
|
||||
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
|
||||
opts = options(files)
|
||||
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
|
||||
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
|
||||
"active falls back to the remaining slot")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
|
||||
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
|
||||
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
|
||||
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
|
||||
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
|
||||
"seed alpha's slot")
|
||||
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
|
||||
"seed beta's slot")
|
||||
|
||||
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
|
||||
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
|
||||
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
|
||||
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
|
||||
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
|
||||
"two carts with the same slot id write different files")
|
||||
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
|
||||
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
|
||||
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
|
||||
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
|
||||
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
|
||||
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
|
||||
T.eq(SaveData.createCartSlot("red"), "slot1",
|
||||
"a cart may even be named after a version")
|
||||
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
|
||||
"seed the version slot")
|
||||
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
|
||||
"seed the cart slot")
|
||||
|
||||
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
|
||||
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
|
||||
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
|
||||
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
|
||||
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
|
||||
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
|
||||
"a version slot carries no cart hash")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
|
||||
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
|
||||
T.eq(options(files).saveSlots.red.list[1], "slot1",
|
||||
"the version registry is independent of the cart one")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
||||
"with no cart slot yet, the flat path follows the version suffix scheme")
|
||||
|
||||
SaveData.createCartSlot("nuzlocke")
|
||||
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
||||
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
|
||||
"the active cart slot owns the save path")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "NUZ"
|
||||
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
|
||||
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
|
||||
T.eq(files["save.lua"], nil, "never into the base game's flat file")
|
||||
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
|
||||
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
|
||||
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
||||
"and the registry mirrors it for a listing with no save decode")
|
||||
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
|
||||
"listCartSlots surfaces the stamp")
|
||||
end
|
||||
|
||||
do
|
||||
fresh()
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
SaveData.createCartSlot("nuzlocke")
|
||||
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
||||
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
|
||||
T.eq(SaveData.load("red").meta.cartHash, "abc123",
|
||||
"buildMeta carries the stamp instead of dropping it")
|
||||
|
||||
SaveData.setCartHash("def456")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
||||
"installing an update does not restamp the slot")
|
||||
loaded = SaveData.load("red")
|
||||
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
|
||||
T.check(SaveData.save(loaded, {}), "save under the new build")
|
||||
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
|
||||
|
||||
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
|
||||
T.check(not ok, "an unregistered slot cannot be stamped")
|
||||
T.check(tostring(err):find("not registered", 1, true) ~= nil,
|
||||
"with a user-presentable reason")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", "slot1")
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "VANILLA"
|
||||
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
|
||||
|
||||
local bytes = files["saves/red/slot1.lua"]
|
||||
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
|
||||
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
||||
"and options.lua grows no cart registry")
|
||||
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.save(loaded), "re-save the migrated shape once")
|
||||
local before = files["saves/red/slot1.lua"]
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
||||
"the cart takes over the path while it is active")
|
||||
SaveData.setCart(nil)
|
||||
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
|
||||
"and hands it straight back")
|
||||
T.check(SaveData.save(loaded), "re-save after the cart round trip")
|
||||
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
|
||||
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
||||
"and options.lua still carries no cart registry")
|
||||
|
||||
local again = SaveData.load("red")
|
||||
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
|
||||
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("cart_saves")
|
||||
@@ -0,0 +1,470 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
getDirectoryItems = function(path)
|
||||
local seen, items = {}, {}
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
local function manifestJson(id)
|
||||
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]])
|
||||
:format(id, id)
|
||||
end
|
||||
|
||||
local function entry(record)
|
||||
return ([[
|
||||
return function(mod)
|
||||
mod.options:define({ { key = "tint", default = "base" } })
|
||||
mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) })
|
||||
end
|
||||
]]):format(record)
|
||||
end
|
||||
|
||||
local function install()
|
||||
local files = {
|
||||
["options.lua"] = SaveSerializer.encode({
|
||||
mods = { beta = false },
|
||||
modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } },
|
||||
}),
|
||||
}
|
||||
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
|
||||
files["mods/" .. id .. "/manifest.json"] = manifestJson(id)
|
||||
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
|
||||
end
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function writeCart(files, tbl)
|
||||
local cart, err = CartManifest.parse(tbl)
|
||||
assert(cart, err)
|
||||
files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart)
|
||||
return cart
|
||||
end
|
||||
|
||||
local function cartTable(id, seal, mods, order)
|
||||
return { id = id, title = id, version = "1.0.0", author = "tester",
|
||||
shell = "#102030", base = "red", seal = seal,
|
||||
mods = mods, load_order = order }
|
||||
end
|
||||
|
||||
local function pin(id, version, options)
|
||||
return { id = id, source = "local", version = version or "1.0.0",
|
||||
options = options }
|
||||
end
|
||||
|
||||
local function boot(files)
|
||||
local data = { pokemon = {} }
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
local ok = loader:load(data)
|
||||
return loader, data, ok
|
||||
end
|
||||
|
||||
local function options(files)
|
||||
return SaveSerializer.decode(files["options.lua"] or "") or {}
|
||||
end
|
||||
|
||||
local function names(list)
|
||||
return table.concat(list, ",")
|
||||
end
|
||||
|
||||
-- ------- a sealed cart loads its pins, in its order, with its options
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("sealed", "sealed",
|
||||
{ pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") },
|
||||
{ "beta", "alpha" }))
|
||||
SaveData.setCart("sealed", "hash1")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed cart whose pins are all installed loads cleanly")
|
||||
T.eq(names(loader.order), "beta,alpha",
|
||||
"the cart's load_order beats priority and the id tie-break")
|
||||
T.eq(data.pokemon.ALPHA.name, "cart",
|
||||
"a frozen option overrides the player's saved value")
|
||||
T.eq(data.pokemon.BETA.name, "base",
|
||||
"a pin that froze no options falls to the schema default, not the player's")
|
||||
T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs")
|
||||
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
|
||||
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
|
||||
T.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads")
|
||||
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.id, "sealed", "the report names the cart")
|
||||
T.eq(report.seal, "sealed", "and its seal")
|
||||
T.eq(report.enforced, true, "which is enforced")
|
||||
T.eq(report.refused, false, "and not refused")
|
||||
T.eq(#report.missing, 0, "with no missing pins")
|
||||
T.eq(#report.mismatched, 0, "and no version mismatches")
|
||||
T.eq(loader:status().cart, report, "status() carries the same report")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
|
||||
"the player's disable flag is untouched on disk")
|
||||
T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false,
|
||||
"and so is the enable flag of the mod the seal left out")
|
||||
T.eq(opts.modOptions.alpha.tint, "player",
|
||||
"the frozen option never overwrote the player's saved value")
|
||||
T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod")
|
||||
end
|
||||
|
||||
-- ------- an open cart layers the player's mods on top
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("open", "open",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) },
|
||||
{ "beta", "gamma" }))
|
||||
SaveData.setCart("open", "hash2")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart loads")
|
||||
T.eq(names(loader.order), "beta,gamma,alpha",
|
||||
"the cart's mods come first in its order, then the player's own")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"an open cart's option is a starting value the player's own setting beats")
|
||||
T.eq(data.pokemon.GAMMA.name, "cart",
|
||||
"and it stands where the player set nothing")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally")
|
||||
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
|
||||
end
|
||||
|
||||
-- ------- a missing pin: refusal when sealed, warning when open
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("gap", "sealed",
|
||||
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
|
||||
SaveData.setCart("gap", "hash3")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(not ok, "a sealed cart with an uninstalled pin fails the load")
|
||||
T.eq(#loader.order, 0, "and plays no subset of itself")
|
||||
T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed")
|
||||
T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods")
|
||||
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.refused, true, "the report refuses the cart")
|
||||
T.eq(#report.missing, 1, "naming one missing pin")
|
||||
T.eq(report.missing[1].id, "delta", "by id")
|
||||
T.eq(report.missing[1].version, "2.0.0", "and by the version it pins")
|
||||
T.eq(report.missing[1].source, "local", "with the source it would come from")
|
||||
T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil,
|
||||
"and a message the launcher can show")
|
||||
T.eq(loader.errors[1], report.message, "the refusal is on the boot error list")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
|
||||
"a refusal still leaves the player's flags alone")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("gap", "open",
|
||||
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
|
||||
SaveData.setCart("gap", "hash4")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart with an uninstalled pin still loads")
|
||||
T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.refused, false, "the missing pin is a warning, not a refusal")
|
||||
T.eq(report.missing[1].id, "delta", "and is still reported by id")
|
||||
end
|
||||
|
||||
-- ------- a pin installed at another version
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("skew", "sealed",
|
||||
{ pin("alpha", "2.0.0") }, { "alpha" }))
|
||||
SaveData.setCart("skew", "hash5")
|
||||
|
||||
local loader, _, ok = boot(files)
|
||||
T.check(not ok, "a sealed cart refuses a pin installed at another version")
|
||||
T.eq(#loader.order, 0, "and loads nothing")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod")
|
||||
T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins")
|
||||
T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed")
|
||||
T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed",
|
||||
1, true) ~= nil, "with a message the launcher can show")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("skew", "open",
|
||||
{ pin("alpha", "2.0.0") }, { "alpha" }))
|
||||
SaveData.setCart("skew", "hash6")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart warns about a version skew instead of refusing")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there")
|
||||
T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it")
|
||||
end
|
||||
|
||||
-- ------- an unreadable cart
|
||||
|
||||
do
|
||||
local files = install()
|
||||
SaveData.setCart("ghost", "hash7")
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(not ok, "a cart that is not installed cannot be played as that cart")
|
||||
T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name")
|
||||
T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil,
|
||||
"and the report names the cart that went missing")
|
||||
end
|
||||
|
||||
-- ------- breaking the seal downgrades a sealed cart to the open answer
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("sealed", "sealed",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
|
||||
{ "beta", "delta" }))
|
||||
SaveData.setCart("sealed", "hash8")
|
||||
SaveData.breakSeal()
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a broken seal no longer refuses over a missing pin")
|
||||
T.eq(names(loader.order), "beta,alpha,gamma",
|
||||
"the player's own mods load alongside the cart's")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"and the player's option values come back with them")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.broken, true, "the report says the seal is broken")
|
||||
T.eq(report.enforced, false, "so the seal enforces nothing")
|
||||
end
|
||||
|
||||
-- ------- vanilla is untouched when no cart is active
|
||||
|
||||
do
|
||||
local files = install()
|
||||
SaveData.resetSlotState()
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a vanilla boot with no cart loads")
|
||||
T.eq(loader:cartStatus(), nil, "and reports no cart at all")
|
||||
T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values")
|
||||
T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off")
|
||||
end
|
||||
|
||||
-- ------- planCart on its own
|
||||
|
||||
do
|
||||
local report = Loader.planCart(nil, {})
|
||||
T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for")
|
||||
T.check(report.message:find("not installed", 1, true) ~= nil,
|
||||
"with a presentable reason")
|
||||
|
||||
local unpinned = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }),
|
||||
{ { id = "gamma", version = "whatever" } })
|
||||
T.eq(#unpinned.mismatched, 0,
|
||||
"a local pin captured with no semantic version makes no version claim")
|
||||
T.eq(unpinned.refused, false, "so it cannot refuse over one")
|
||||
|
||||
local skew = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }),
|
||||
{ gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } })
|
||||
T.eq(skew.mismatched[1].installed, "1.0.0-beta",
|
||||
"a prerelease is a different version to a sealed cart")
|
||||
|
||||
local broken = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true)
|
||||
T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning")
|
||||
T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin")
|
||||
end
|
||||
|
||||
-- ------- the broken-seal stamp
|
||||
|
||||
local function plainSave(name)
|
||||
return {
|
||||
version = "red",
|
||||
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
inventory = {},
|
||||
playTime = 0,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal")
|
||||
local save = plainSave("INTACT")
|
||||
T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save")
|
||||
T.check(SaveData.save(save, {}), "write a save under an intact seal")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save")
|
||||
T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified")
|
||||
T.eq(SaveData.isSealBroken(), true, "and the session is armed")
|
||||
T.check(SaveData.save(loaded, {}), "save the stamped file")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true,
|
||||
"the stamp survives a save/load round trip")
|
||||
|
||||
local again = SaveData.load("red")
|
||||
T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp")
|
||||
|
||||
T.eq(SaveData.unbreakSeal, nil, "there is no public unset")
|
||||
T.eq(SaveData.clearSeal, nil, "under any spelling")
|
||||
T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value")
|
||||
T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears")
|
||||
T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed")
|
||||
local reread = SaveData.load("red")
|
||||
T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good")
|
||||
T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it")
|
||||
|
||||
local fresh = plainSave("FRESH")
|
||||
T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own")
|
||||
end
|
||||
|
||||
-- ------- the durable per-slot broken mark
|
||||
|
||||
do
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
local first = SaveData.createCartSlot("kanto")
|
||||
T.eq(first, "slot1", "a cart's first save slot")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed")
|
||||
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false,
|
||||
"which its launcher row reports without loading a save")
|
||||
|
||||
T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken")
|
||||
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true,
|
||||
"and the launcher row carries it")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true,
|
||||
"the mark survives a restart")
|
||||
|
||||
local second = SaveData.createCartSlot("kanto")
|
||||
T.eq(SaveData.slotSealBroken("kanto", second), false,
|
||||
"a new slot under the same cart starts sealed again")
|
||||
|
||||
T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset")
|
||||
T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling")
|
||||
T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value")
|
||||
T.check(SaveData.markSlotSealBroken("kanto", first, false),
|
||||
"the setter takes no argument that clears")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands")
|
||||
T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false,
|
||||
"a slot that is not registered cannot be marked")
|
||||
|
||||
SaveData.setCart("kanto", "hash9")
|
||||
SaveData.setActiveCartSlot("kanto", second)
|
||||
T.eq(SaveData.adoptCartSeal("kanto"), false,
|
||||
"booting an unmarked slot leaves the session sealed")
|
||||
T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart")
|
||||
SaveData.setActiveCartSlot("kanto", first)
|
||||
T.eq(SaveData.adoptCartSeal("kanto"), true,
|
||||
"booting the marked slot breaks the seal for the session")
|
||||
T.eq(SaveData.isSealBroken(), true, "which is what the loader reads")
|
||||
|
||||
SaveData.deleteCartSlot("kanto", first)
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), false,
|
||||
"deleting the playthrough takes its mark with it")
|
||||
end
|
||||
|
||||
-- ------- a marked slot loads the cart's pins first, then the player's mods
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("marked", "sealed",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
|
||||
{ "beta", "delta" }))
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
SaveData.setCart("marked", "hash10")
|
||||
local slot = SaveData.createCartSlot("marked")
|
||||
SaveData.setActiveCartSlot("marked", slot)
|
||||
|
||||
local intact, _, intactOk = boot(files)
|
||||
T.check(not intactOk, "an unmarked slot still refuses the missing pin")
|
||||
T.eq(intact:cartStatus().refused, true, "with the refusal on its report")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
SaveData.setCart("marked", "hash10")
|
||||
T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken")
|
||||
T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "and the cart loads")
|
||||
T.eq(names(loader.order), "beta,alpha,gamma",
|
||||
"the cart's pins load first, then the player's own enabled mods")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"with the player's own option values back")
|
||||
T.eq(loader:cartStatus().broken, true, "the report says the seal is broken")
|
||||
|
||||
local stamped = plainSave("BROKEN")
|
||||
T.check(SaveData.save(stamped, {}), "a save written under the adopted mark")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true,
|
||||
"carries the save's own permanent stamp")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("cart_seal")
|
||||
@@ -0,0 +1,393 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
|
||||
local ok, bit32 = pcall(require, "bit32")
|
||||
if ok then _G.bit32 = bit32 end
|
||||
end
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Base64 = require("src.core.Base64")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
local SHA2 = ("beefcafe"):rep(8)
|
||||
local MD5 = ("0123456789abcdef"):rep(2)
|
||||
|
||||
local function memfs()
|
||||
local files, dirs = {}, {}
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function(path) dirs[path] = true return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
if dirs[path] then return { type = "directory" } end
|
||||
for name in pairs(files) do
|
||||
if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix = (path == "" or path == nil) and "" or (path .. "/")
|
||||
local out, seen = {}, {}
|
||||
for name in pairs(files) do
|
||||
if name:sub(1, #prefix) == prefix then
|
||||
local child = name:sub(#prefix + 1):match("^([^/]+)")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
local function cartTable(over)
|
||||
local tbl = {
|
||||
id = "kanto_plus",
|
||||
title = "Kanto Plus",
|
||||
version = "1.2.0",
|
||||
author = "Ren",
|
||||
shell = "#3fa9f5",
|
||||
base = "red",
|
||||
seal = "sealed",
|
||||
mods = {
|
||||
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA,
|
||||
options = { flavour = "grape", sweetness = 3 } },
|
||||
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
|
||||
md5 = MD5 },
|
||||
},
|
||||
}
|
||||
for key, value in pairs(over or {}) do tbl[key] = value end
|
||||
return tbl
|
||||
end
|
||||
|
||||
local function bytesOf(over)
|
||||
local cart, err = CartManifest.parse(cartTable(over))
|
||||
if not cart then error("fixture does not parse: " .. tostring(err)) end
|
||||
return CartManifest.encode(cart), cart
|
||||
end
|
||||
|
||||
local fs = memfs()
|
||||
local bytes, fixture = bytesOf()
|
||||
local installed, hash = CartStore.install(bytes, fs)
|
||||
T.check(installed ~= nil, "a good cart installs: " .. tostring(hash))
|
||||
T.eq(installed.id, "kanto_plus", "install returns the parsed cart")
|
||||
T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash")
|
||||
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
|
||||
"install writes carts/<id>.g1rcart")
|
||||
|
||||
local reg = SaveData.loadOptions(fs).carts
|
||||
T.check(type(reg) == "table" and type(reg.kanto_plus) == "table",
|
||||
"install registers the cart in options.carts")
|
||||
T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title")
|
||||
T.eq(reg.kanto_plus.base, "red", "the registry carries the base game")
|
||||
T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version")
|
||||
T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash")
|
||||
T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart",
|
||||
"the registry names the cart file")
|
||||
|
||||
local index = CartStore.index(fs)
|
||||
T.eq(#index, 1, "index lists the registry without reading the files")
|
||||
T.eq(index[1].base, "red", "an index row carries the base game")
|
||||
|
||||
local rows = CartStore.list(fs)
|
||||
T.eq(#rows, 1, "list returns the installed cart")
|
||||
T.eq(rows[1].id, "kanto_plus", "the row names the cart")
|
||||
T.eq(rows[1].base, "red", "the row carries the base")
|
||||
T.eq(rows[1].cartHash, hash, "the row carries the cart hash")
|
||||
T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil,
|
||||
"the row carries the parsed cart")
|
||||
T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins")
|
||||
|
||||
local got, gotHash = CartStore.get("kanto_plus", fs)
|
||||
T.check(got ~= nil, "get returns the cart")
|
||||
T.eq(gotHash, hash, "get returns the cart hash")
|
||||
T.same(got, fixture, "get returns exactly what was installed")
|
||||
T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id")
|
||||
T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id")
|
||||
|
||||
local exported, exportHash = CartStore.export("kanto_plus", fs)
|
||||
T.check(type(exported) == "string", "export hands back bytes")
|
||||
T.eq(exportHash, hash, "export reports the cart hash")
|
||||
T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back")
|
||||
T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id")
|
||||
|
||||
local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite",
|
||||
base = "blue" })
|
||||
T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs")
|
||||
T.eq(#CartStore.list(fs), 2, "list returns both carts")
|
||||
T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title")
|
||||
T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart")
|
||||
T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart")
|
||||
T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart")
|
||||
T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing")
|
||||
|
||||
local newer, newerErr = CartStore.install(
|
||||
bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs)
|
||||
T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr))
|
||||
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
|
||||
"the newer version is what is installed")
|
||||
T.eq(#CartStore.list(fs), 2, "replacing does not add a second row")
|
||||
T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0",
|
||||
"the registry follows the replacement")
|
||||
|
||||
local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs)
|
||||
T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr))
|
||||
|
||||
local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs)
|
||||
T.eq(older, nil, "an older cart is refused")
|
||||
T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil,
|
||||
"the refusal says why (got " .. tostring(olderErr) .. ")")
|
||||
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
|
||||
"the refused install leaves the newer cart in place")
|
||||
|
||||
T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file")
|
||||
T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string")
|
||||
T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise")
|
||||
|
||||
fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }"
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
|
||||
T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success")
|
||||
T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file")
|
||||
T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil,
|
||||
"uninstall clears the registry entry")
|
||||
T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list")
|
||||
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
|
||||
"uninstall leaves the cart's save file alone")
|
||||
local slots = SaveData.loadOptions(fs).cartSlots
|
||||
T.check(type(slots) == "table" and type(slots.kanto_plus) == "table",
|
||||
"uninstall leaves the cart's slot registry alone")
|
||||
T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall")
|
||||
|
||||
local gone, goneErr = CartStore.uninstall("kanto_plus", fs)
|
||||
T.eq(gone, nil, "uninstalling twice is refused")
|
||||
T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil,
|
||||
"the second uninstall says why (got " .. tostring(goneErr) .. ")")
|
||||
T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id")
|
||||
|
||||
T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal")
|
||||
T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart")
|
||||
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
|
||||
"the old playthrough is still there for the reinstalled cart")
|
||||
|
||||
fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }"
|
||||
local damaged = CartStore.list(fs)
|
||||
T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list")
|
||||
T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling")
|
||||
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
|
||||
"listing never deletes the file it could not read")
|
||||
T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable")
|
||||
fs.files["carts/kanto_plus.g1rcart"] = nil
|
||||
|
||||
opts = SaveData.loadOptions(fs)
|
||||
opts.carts = opts.carts or {}
|
||||
opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red",
|
||||
version = "1.0.0", file = "carts/ghost.g1rcart" }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
local haunted = CartStore.list(fs)
|
||||
T.eq(#haunted, 1, "a registry entry with no file is skipped")
|
||||
T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back")
|
||||
T.eq(SaveData.loadOptions(fs).carts.ghost, nil,
|
||||
"listing prunes the registry entry whose file is gone")
|
||||
|
||||
local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" }))
|
||||
fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart)
|
||||
local adopted = CartStore.list(fs)
|
||||
T.eq(#adopted, 2, "a cart file with no registry entry is still listed")
|
||||
T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title")
|
||||
T.eq(adopted[2].cartHash, CartManifest.hash(strayCart),
|
||||
"the stray cart is hashed from its own file")
|
||||
T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil,
|
||||
"listing registers the stray cart it adopted")
|
||||
|
||||
fs.files["carts/readme.txt"] = "hello"
|
||||
fs.files["carts/half written.g1rcart"] = "return { }"
|
||||
T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored")
|
||||
|
||||
local empty = memfs()
|
||||
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
|
||||
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
|
||||
|
||||
local function rowSet()
|
||||
return {
|
||||
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
|
||||
github = "ren/hard-mode",
|
||||
manifest = { id = "hard_mode", version = "2.0.0",
|
||||
github = "ren/hard-mode", sha256 = SHA } },
|
||||
{ id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false,
|
||||
github = "ren/off-mode",
|
||||
manifest = { id = "off_mode", version = "1.0.0",
|
||||
github = "ren/off-mode", sha256 = SHA2 } },
|
||||
{ id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true,
|
||||
github = "ren/rare-soda",
|
||||
manifest = { id = "rare_soda", version = "0.4.1",
|
||||
github = "ren/rare-soda" } },
|
||||
{ id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true,
|
||||
manifest = { id = "sprite_pack", version = "beta" } },
|
||||
}
|
||||
end
|
||||
|
||||
local identity = { id = "my_cart", title = "My Cart", version = "0.1.0",
|
||||
author = "Ren", base = "red", shell = "#FF8800",
|
||||
seal = "open", summary = "Built in the launcher" }
|
||||
local modOptions = {
|
||||
rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } },
|
||||
off_mode = { unused = true },
|
||||
}
|
||||
|
||||
local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions)
|
||||
T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved))
|
||||
T.eq(captured.id, "my_cart", "the captured cart keeps the identity id")
|
||||
T.eq(captured.title, "My Cart", "the captured cart keeps the title")
|
||||
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
|
||||
T.eq(captured.seal, "open", "the captured seal is the author's choice")
|
||||
T.eq(captured.base, "red", "the captured base is the identity's")
|
||||
T.eq(#captured.mods, 3, "only the enabled mods are pinned")
|
||||
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
|
||||
T.eq(captured.load_order[2], "rare_soda", "load order follows the row order")
|
||||
T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order")
|
||||
for _, entry in ipairs(captured.mods) do
|
||||
T.neq(entry.id, "off_mode", "a disabled mod is never pinned")
|
||||
end
|
||||
|
||||
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
|
||||
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
|
||||
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
|
||||
T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally")
|
||||
T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version")
|
||||
T.eq(captured.mods[2].repo, nil, "a local pin carries no repo")
|
||||
T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash")
|
||||
T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in")
|
||||
T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in")
|
||||
T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped")
|
||||
T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally")
|
||||
T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0")
|
||||
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
|
||||
|
||||
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
|
||||
T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named")
|
||||
T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name")
|
||||
T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil,
|
||||
"a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")")
|
||||
T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named")
|
||||
T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil,
|
||||
"a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")")
|
||||
T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil,
|
||||
"an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")")
|
||||
|
||||
local publishable, why = CartManifest.publishable(captured)
|
||||
T.eq(publishable, false, "a captured cart with local pins cannot be published")
|
||||
T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda")
|
||||
T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack")
|
||||
T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out")
|
||||
|
||||
local storeFs = memfs()
|
||||
local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs)
|
||||
T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash))
|
||||
T.same(roundTrip, captured, "a captured cart survives the file round trip")
|
||||
T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk")
|
||||
|
||||
local pinned = rowSet()
|
||||
pinned[3].manifest.sha256 = SHA2
|
||||
pinned[4] = nil
|
||||
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
|
||||
T.check(full ~= nil, "a fully pinned capture builds a cart")
|
||||
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
|
||||
T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github")
|
||||
T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash")
|
||||
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
|
||||
|
||||
local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions)
|
||||
T.eq(noMods, nil, "a capture with nothing enabled is refused")
|
||||
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
|
||||
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
|
||||
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
|
||||
"a capture with a bad identity is refused")
|
||||
T.eq(CartStore.capture(nil, rowSet(), modOptions), nil,
|
||||
"a capture with no identity is refused")
|
||||
|
||||
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label"
|
||||
local ART_DATA = Base64.encode(PNG)
|
||||
|
||||
local function artedCart()
|
||||
local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
|
||||
label = "label.png" }))
|
||||
plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG,
|
||||
data = ART_DATA }
|
||||
return plain
|
||||
end
|
||||
|
||||
local artFs = memfs()
|
||||
local artFixture = artedCart()
|
||||
local packed = CartManifest.encode(artFixture)
|
||||
local artInstalled, artHash = CartStore.install(packed, artFs)
|
||||
T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash))
|
||||
T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art")
|
||||
T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil,
|
||||
"install writes the art payload to the cart file")
|
||||
T.same(CartStore.get("art_cart", artFs), artFixture,
|
||||
"the installed cart reads back with its art")
|
||||
|
||||
local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
|
||||
label = "label.png" }))
|
||||
T.eq(artHash, CartManifest.hash(artless),
|
||||
"the art is not part of the hash a save pins itself to")
|
||||
|
||||
local artExported, artExportHash = CartStore.export("art_cart", artFs)
|
||||
T.eq(artExportHash, artHash, "export reports the same hash for an arted cart")
|
||||
T.eq(artExported, packed, "export hands back the bytes that were packed")
|
||||
local reread = CartManifest.decode(artExported)
|
||||
T.same(reread, artFixture, "the exported bytes decode to the same cart and art")
|
||||
T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical")
|
||||
|
||||
local sharedFs = memfs()
|
||||
T.check(CartStore.install(artExported, sharedFs) ~= nil,
|
||||
"an exported cart installs somewhere else")
|
||||
T.same(CartStore.get("art_cart", sharedFs), artFixture,
|
||||
"pack, install, export and install again leaves the cart unchanged")
|
||||
|
||||
local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs)
|
||||
T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG")
|
||||
T.eq(shownName, "label.png", "labelArt hands back the art name")
|
||||
T.eq(CartStore.labelArt("nothing_here", sharedFs), nil,
|
||||
"labelArt refuses an unknown id")
|
||||
T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil,
|
||||
"labelArt refuses a climbing id")
|
||||
|
||||
local plainFs = memfs()
|
||||
T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil,
|
||||
"a cart with no art still installs")
|
||||
T.eq(CartStore.labelArt("art_cart", plainFs), nil,
|
||||
"a cart with no art has no label art")
|
||||
T.same(CartStore.get("art_cart", plainFs), artless,
|
||||
"a cart with no art round trips exactly as before")
|
||||
|
||||
local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1))
|
||||
T.neq(tamperedBytes, packed, "the tampered bundle really changed")
|
||||
local tamperedFs = memfs()
|
||||
local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs)
|
||||
T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr))
|
||||
T.eq(tampered.labelArt, nil, "the bad art is dropped")
|
||||
T.eq(CartStore.labelArt("art_cart", tamperedFs), nil,
|
||||
"the installed cart shows no art")
|
||||
T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless),
|
||||
"the bad art is not written back to disk")
|
||||
T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art")
|
||||
|
||||
T.finish("cart_store")
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing v<version> tag to build and publish."
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CART_ID: "{{CART_ID}}"
|
||||
CARTKIT_REPO: bryanthaboi/gen1recomp
|
||||
CARTKIT_REF: dev
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch cartkit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL --retry 3 -o "$RUNNER_TEMP/cartkit.py" \
|
||||
"https://raw.githubusercontent.com/${CARTKIT_REPO}/${CARTKIT_REF}/tools/cartkit.py"
|
||||
python3 "$RUNNER_TEMP/cartkit.py" selftest --quiet
|
||||
|
||||
- name: Check the tag against cart.json
|
||||
id: cart
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json, os, sys
|
||||
|
||||
with open("cart.json", encoding="utf-8") as fh:
|
||||
cart = json.load(fh)
|
||||
version = str(cart.get("version", ""))
|
||||
cart_id = str(cart.get("id", ""))
|
||||
tag = os.environ["TAG"]
|
||||
if tag != f"v{version}":
|
||||
print(f"::error::tag {tag} does not match cart.json version "
|
||||
f"{version} (expected v{version})", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
stamped = os.environ["CART_ID"]
|
||||
if cart_id != stamped:
|
||||
print(f"::error::cart.json id is {cart_id}, but this workflow "
|
||||
f"was stamped for {stamped}; rerun cartkit "
|
||||
"add-release-workflow", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"version={version}")
|
||||
print(f"id={cart_id}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
- name: Validate every pin
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 "$RUNNER_TEMP/cartkit.py" validate . --online --strict
|
||||
|
||||
- name: Pack the cart
|
||||
env:
|
||||
CART_VERSION: ${{ steps.cart.outputs.version }}
|
||||
CART_NAME: ${{ steps.cart.outputs.id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="$GITHUB_WORKSPACE/dist"
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
python3 "$RUNNER_TEMP/cartkit.py" pack . \
|
||||
-o "$out/${CART_NAME}-${CART_VERSION}.g1rcart"
|
||||
(cd "$out" && sha256sum ./*.g1rcart > sha256sums.txt)
|
||||
cat "$out/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CART_VERSION: ${{ steps.cart.outputs.version }}
|
||||
CART_NAME: ${{ steps.cart.outputs.id }}
|
||||
TAG: ${{ steps.cart.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
|
||||
range="${prev:+${prev}..}${TAG}"
|
||||
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
|
||||
|
||||
notes=$'Download the .g1rcart and open it from the game to install this cart.'
|
||||
notes+=$'\n\nThe cart is a manifest: it pins each mod to the exact build listed in cart.json and ships no code of its own.'
|
||||
if [ -n "$changes" ]; then
|
||||
notes+=$'\n\n## Changes\n\n'"$changes"
|
||||
fi
|
||||
|
||||
asset="dist/${CART_NAME}-${CART_VERSION}.g1rcart"
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" "$asset" "dist/sha256sums.txt" --clobber
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$CART_VERSION" \
|
||||
--notes "$notes" \
|
||||
"$asset" \
|
||||
"dist/sha256sums.txt"
|
||||
fi
|
||||
|
||||
echo "Published $TAG"
|
||||
+1749
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user