diff --git a/docs/new-features.md b/docs/new-features.md index dccc76ea..9e07ed9a 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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 diff --git a/main.lua b/main.lua index 7b6fba4c..ee5b41f8 100644 --- a/main.lua +++ b/main.lua @@ -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() diff --git a/src/carts/CartManifest.lua b/src/carts/CartManifest.lua new file mode 100644 index 00000000..cb27e68a --- /dev/null +++ b/src/carts/CartManifest.lua @@ -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 diff --git a/src/carts/CartStore.lua b/src/carts/CartStore.lua new file mode 100644 index 00000000..2516a6fd --- /dev/null +++ b/src/carts/CartStore.lua @@ -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 diff --git a/src/core/Base64.lua b/src/core/Base64.lua new file mode 100644 index 00000000..1af84ac3 --- /dev/null +++ b/src/core/Base64.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 514bd433..8d3d2fd0 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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_.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// 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 diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index f88c6b11..fae3cfe4 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 20b573dc..2b0097f7 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 84b087cd..0b298d20 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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 diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua new file mode 100644 index 00000000..7eeda51d --- /dev/null +++ b/tests/engine/cart_launcher.lua @@ -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") diff --git a/tests/engine/cart_manifest.lua b/tests/engine/cart_manifest.lua new file mode 100644 index 00000000..37a4a197 --- /dev/null +++ b/tests/engine/cart_manifest.lua @@ -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") diff --git a/tests/engine/cart_saves.lua b/tests/engine/cart_saves.lua new file mode 100644 index 00000000..3b14225a --- /dev/null +++ b/tests/engine/cart_saves.lua @@ -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") diff --git a/tests/engine/cart_seal.lua b/tests/engine/cart_seal.lua new file mode 100644 index 00000000..d0edfa0e --- /dev/null +++ b/tests/engine/cart_seal.lua @@ -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") diff --git a/tests/engine/cart_store.lua b/tests/engine/cart_store.lua new file mode 100644 index 00000000..6eea56b0 --- /dev/null +++ b/tests/engine/cart_store.lua @@ -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/.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") diff --git a/tools/cart_release_workflow.yml b/tools/cart_release_workflow.yml new file mode 100644 index 00000000..b08d1ad6 --- /dev/null +++ b/tools/cart_release_workflow.yml @@ -0,0 +1,122 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: "Existing v 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" diff --git a/tools/cartkit.py b/tools/cartkit.py new file mode 100644 index 00000000..4852eba9 --- /dev/null +++ b/tools/cartkit.py @@ -0,0 +1,1749 @@ +#!/usr/bin/env python3 +"""cartkit: the custom-cart authoring CLI. + + python3 tools/cartkit.py [args] + +Subcommands: + scaffold [--title T] [--author A] [--base red|blue|yellow|gold|silver] + [--shell "#rrggbb"] [--seal sealed|open] [--summary S] + [--github owner/repo] [--into DIR] [--force] + validate [--online] [--no-download] [--strict] + pin [--id ID] [--option k=v]... [--file N] + [--clear-options] + pack [-o out.g1rcart] [--online] [--no-download] + add-release-workflow [--force] + selftest + +A cart is a manifest, not code: cart.json names a base game and a list of mods +pinned to exact published builds. for pin is "owner/repo@1.2.3", a +GameBanana mod URL, or a GameBanana mod id. + +Global flags: --repo PATH, --json, --quiet. +Exit codes: 0 success, 1 validation/pack failure, 2 usage error. + +validate is offline by default and checks the manifest alone; --online also +resolves every pin against GitHub and GameBanana and compares the published +hash. A pin that a reachable API says does not exist is a finding; an API +that could not be reached is a note, so a rate limit or a dropped connection +never fails a cart that is fine. pack runs validate --strict, so a warning +refuses the bundle too. +""" + +import argparse +import base64 +import contextlib +import hashlib +import io +import json +import os +import re +import shutil +import struct +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import zlib + +CARTKIT_VERSION = "1.0.0" +USER_AGENT = ("cartkit/%s (gen1recomp custom carts; " + "+https://github.com/bryanthaboi/gen1recomp)" % CARTKIT_VERSION) + +CART_FILE = "cart.json" +CART_EXT = ".g1rcart" +BUNDLE_FORMAT = "g1rcart" +BUNDLE_VERSION = 1 +CART_SCHEMA = 1 + +BASES = ("red", "blue", "yellow", "gold", "silver") +SEALS = ("sealed", "open") +SOURCES = ("github", "gamebanana") +MAX_MODS = 64 +MAX_OPTIONS = 64 +MAX_LABEL_PATH = 128 +LABEL_WARN_BYTES = 256 * 1024 +LABEL_MAX_BYTES = 1024 * 1024 + +CART_KEYS = ("schema", "id", "title", "version", "author", "repo", "summary", + "shell", "label", "base", "engine", "seal", "mods", "load_order") +MOD_KEYS = ("id", "source", "repo", "version", "sha256", "mod", "file", "md5", + "options") + +ID_RE = re.compile(r"[A-Za-z0-9_-]{1,64}") +SEMVER_RE = re.compile( + r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" + r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?") +SHELL_RE = re.compile(r"#[0-9a-fA-F]{6}") +SHA256_RE = re.compile(r"[0-9a-f]{64}") +MD5_RE = re.compile(r"[0-9a-f]{32}") +REPO_RE = re.compile(r"[A-Za-z0-9][\w.\-]*/[A-Za-z0-9][\w.\-]*") +LABEL_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.\-]*") +CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]") +LUA_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + +GITHUB_SPEC = re.compile( + r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?@(.+)$") +GITHUB_SLUG = re.compile( + r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?/?$") +GAMEBANANA_SPEC = re.compile( + r"^(?:gamebanana:|(?:https?://)?(?:www\.)?gamebanana\.com/mods/)?(\d+)/?$") + +PLACEHOLDER_REPO = "owner/example-mod" +PLACEHOLDER_SHA = "0" * 64 + + +# ---------------------------------------------------------------- findings + +class Finding: + def __init__(self, rule, severity, message, path=None): + self.rule = rule + self.severity = severity + self.message = message + self.path = path + + def as_dict(self): + return {"rule": self.rule, "severity": self.severity, + "message": self.message, "path": self.path} + + def line(self): + where = f"{self.path}: " if self.path else "" + return f"{self.rule} {self.severity.upper():5} {where}{self.message}" + + +def err(rule, message, path=CART_FILE): + return Finding(rule, "error", message, path) + + +def warn(rule, message, path=CART_FILE): + return Finding(rule, "warn", message, path) + + +def report(findings, args, summary_ok, summary_fail, notes=None): + notes = notes or [] + errors = [f for f in findings if f.severity == "error"] + if getattr(args, "strict", False): + errors = list(findings) + if args.json: + print(json.dumps({"ok": not errors, + "findings": [f.as_dict() for f in findings], + "notes": notes})) + else: + for f in findings: + print(f.line()) + if not args.quiet: + for note in notes: + print(f"cartkit: {note}") + print(summary_fail if errors else summary_ok) + return 1 if errors else 0 + + +# ---------------------------------------------------------------- repo/root + +def find_repo(start): + node = os.path.abspath(start) + while True: + if os.path.isfile(os.path.join(node, "tools", "rom_manifest.json")): + return node + parent = os.path.dirname(node) + if parent == node: + return None + node = parent + + +def engine_version(repo): + try: + src = open(os.path.join(repo, "src", "core", "Version.lua"), + encoding="utf-8").read() + except OSError: + return "0.0.0-dev" + match = re.search(r'engine\s*=\s*"([^"]+)"', src) + return match.group(1) if match else "0.0.0-dev" + + +def resolve_cart_dir(repo, arg): + path = os.path.abspath(arg) + if os.path.isfile(path) and os.path.basename(path) == CART_FILE: + return os.path.dirname(path) + if os.path.isdir(path): + return path + if repo: + candidate = os.path.join(repo, "carts", arg) + if os.path.isdir(candidate): + return candidate + return None + + +def read_cart(cart_dir): + path = os.path.join(cart_dir, CART_FILE) + if not os.path.isfile(path): + return None, err("CK001", f"{CART_FILE} missing; run " + "cartkit scaffold to make one") + try: + cart = json.load(open(path, encoding="utf-8")) + except ValueError as problem: + return None, err("CK001", f"{CART_FILE} unparseable: {problem}") + if not isinstance(cart, dict): + return None, err("CK001", f"{CART_FILE} must be a JSON object") + return cart, None + + +def write_cart(cart_dir, cart): + ordered = {key: cart[key] for key in CART_KEYS if key in cart} + for key in cart: + if key not in ordered: + ordered[key] = cart[key] + with open(os.path.join(cart_dir, CART_FILE), "w", encoding="utf-8") as fh: + json.dump(ordered, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + +# ---------------------------------------------------------------- schema + +def text_problem(value, low, high): + if not isinstance(value, str): + return "must be a string" + if CONTROL_RE.search(value): + return "must not contain control characters" + if len(value) < low: + return f"must be at least {low} character(s)" + if len(value) > high: + return f"must be at most {high} characters (got {len(value)})" + return None + + +def full_match(pattern, value): + return isinstance(value, str) and pattern.fullmatch(value) is not None + + +RANGE_OPS = {"", "=", "==", ">", ">=", "<", "<=", "^"} +RANGE_CORE = re.compile(r"[vV]?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.\-]+)?" + r"(?:\+[0-9A-Za-z.\-]+)?") + + +def range_problem(text): + if not isinstance(text, str) or not text.strip(): + return "must be a non-empty semver range string" + for alternative in text.split("||"): + tokens = alternative.split() + if not tokens: + return "has an empty alternative around '||'" + for token in tokens: + head = re.match(r"^[=<>^]*", token).group(0) + if head not in RANGE_OPS: + return f"has an unknown comparator {head!r}" + if not RANGE_CORE.fullmatch(token[len(head):]): + return f"has an unparsable version in {token!r}" + return None + + +def label_problem(value): + if not isinstance(value, str) or not value: + return "must be a non-empty string" + if len(value) > MAX_LABEL_PATH: + return f"must be at most {MAX_LABEL_PATH} characters" + if value.startswith("/") or "\\" in value \ + or re.match(r"^[A-Za-z]:", value): + return "must be a relative path inside the cart" + segments = [s for s in value.split("/") if s and s != "."] + if not segments or any(s == ".." for s in segments): + return "must not leave the cart directory" + if not all(LABEL_RE.fullmatch(s) for s in segments): + return ("must be a plain relative path " + "(letters, digits, . _ - and /)") + return None + + +def option_problems(options, label): + problems = [] + if not isinstance(options, dict): + return [f"{label} options must be an object"] + if len(options) > MAX_OPTIONS: + problems.append(f"{label} has {len(options)} options (max " + f"{MAX_OPTIONS})") + for key, value in sorted(options.items()): + if not isinstance(key, str) or CONTROL_RE.search(key): + problems.append(f"{label} option key {key!r} must be plain text") + continue + if not 1 <= len(key) <= 64: + problems.append(f"{label} option key {key!r} must be 1..64 " + "characters") + if isinstance(value, bool): + continue + if isinstance(value, str): + if CONTROL_RE.search(value): + problems.append(f"{label} option {key!r} must not contain " + "control characters") + elif len(value) > 256: + problems.append(f"{label} option {key!r} is {len(value)} " + "characters (max 256)") + elif isinstance(value, (int, float)): + if isinstance(value, float) and (value != value or + value in (float("inf"), + float("-inf"))): + problems.append(f"{label} option {key!r} must be a finite " + "number") + else: + problems.append(f"{label} option {key!r} must be a string, " + "number or boolean") + return problems + + +def check_identity(cart, findings): + schema = cart.get("schema") + if schema is None: + findings.append(err("CK002", '"schema" is missing; add "schema": 1')) + elif isinstance(schema, bool) or not isinstance(schema, int): + findings.append(err("CK002", '"schema" must be the number 1')) + elif schema != CART_SCHEMA: + findings.append(err("CK002", f'"schema" is {schema}; this cartkit ' + f"writes and reads schema {CART_SCHEMA}")) + + if not full_match(ID_RE, cart.get("id")): + findings.append(err("CK002", '"id" must be 1..64 characters of ' + "letters, digits, _ or -")) + + for key, low, high in (("title", 1, 48), ("author", 1, 64)): + problem = text_problem(cart.get(key), low, high) + if problem: + findings.append(err("CK002", f'"{key}" {problem}')) + + if not full_match(SEMVER_RE, cart.get("version")): + findings.append(err("CK002", '"version" must be semver, e.g. 1.0.0')) + + if "repo" in cart and cart["repo"] is not None: + if not full_match(REPO_RE, cart["repo"]): + findings.append(err("CK002", '"repo" must be owner/name')) + + if "summary" in cart and cart["summary"] is not None: + problem = text_problem(cart["summary"], 0, 120) + if problem: + findings.append(err("CK002", f'"summary" {problem}')) + + if not full_match(SHELL_RE, cart.get("shell")): + findings.append(err("CK002", '"shell" must be "#rrggbb", e.g. ' + '"#8b1a1a"')) + + if cart.get("base") not in BASES: + findings.append(err("CK002", '"base" must be one of ' + + ", ".join(BASES))) + + if "engine" in cart and cart["engine"] is not None: + problem = range_problem(cart["engine"]) + if problem: + findings.append(err("CK002", f'"engine" {problem}; write it like ' + '">=1.0.0 <2.0.0"')) + + seal = cart.get("seal", "sealed") + if seal not in SEALS: + findings.append(err("CK002", '"seal" must be "sealed" or "open"')) + + for key in cart: + if key not in CART_KEYS: + findings.append(warn("CK001", f"unknown field {key!r}; cartkit " + "packs only the documented fields")) + + +def check_label(cart, cart_dir, findings): + label = cart.get("label") + if label is None: + return + problem = label_problem(label) + if problem: + findings.append(err("CK003", f'"label" {problem}')) + return + if cart_dir is None: + return + path = os.path.join(cart_dir, label) + if not os.path.isfile(path): + findings.append(err("CK003", f"label art {label!r} is missing from " + "the cart directory", label)) + return + if not os.path.realpath(path).startswith( + os.path.realpath(cart_dir) + os.sep): + findings.append(err("CK003", f"label art {label!r} resolves outside " + "the cart directory", label)) + return + if not label.lower().endswith(".png"): + findings.append(warn("CK003", "label art should be a .png; the game " + "draws nothing else", label)) + size = os.path.getsize(path) + if size > LABEL_MAX_BYTES: + findings.append(err("CK003", f"label art is {size} bytes; keep it " + f"under {LABEL_MAX_BYTES} so the bundle " + "stays small", label)) + elif size > LABEL_WARN_BYTES: + findings.append(warn("CK003", f"label art is {size} bytes; a cart " + "label wants a few KB, not a photo", + label)) + + +def check_mods(cart, findings): + mods = cart.get("mods") + if not isinstance(mods, list) or not mods: + findings.append(err("CK004", '"mods" must list 1..64 pinned mods; a ' + "cart with no mods is just the base " + "game")) + return [] + if len(mods) > MAX_MODS: + findings.append(err("CK004", f'"mods" has {len(mods)} entries (max ' + f"{MAX_MODS})")) + seen = {} + ids = [] + for index, entry in enumerate(mods): + label = f"mods[{index + 1}]" + if not isinstance(entry, dict): + findings.append(err("CK004", f"{label} must be an object")) + continue + mod_id = entry.get("id") + if not full_match(ID_RE, mod_id): + findings.append(err("CK004", f"{label} id must be 1..64 " + "characters of letters, digits, _ " + "or -")) + else: + label = f"mods[{index + 1}] {mod_id}" + if mod_id in seen: + findings.append(err("CK004", f"{label} repeats the id pinned " + f"at mods[{seen[mod_id] + 1}]; " + "one pin per mod")) + else: + seen[mod_id] = index + ids.append(mod_id) + source = entry.get("source") + if source == "local": + findings.append(err("CK004", f"{label} is pinned to one install; " + "a published cart needs a github or " + "gamebanana pin nobody else has to " + "guess at")) + elif source not in SOURCES: + findings.append(err("CK004", f"{label} source must be " + + " or ".join(SOURCES))) + elif source == "github": + check_github_pin(entry, label, findings) + else: + check_gamebanana_pin(entry, label, findings) + if "options" in entry and entry["options"] is not None: + for problem in option_problems(entry["options"], label): + findings.append(err("CK004", problem)) + for key in entry: + if key not in MOD_KEYS: + findings.append(warn("CK004", f"{label} has unknown field " + f"{key!r}")) + return ids + + +def check_github_pin(entry, label, findings): + if not full_match(REPO_RE, entry.get("repo")): + findings.append(err("CK004", f"{label} repo must be owner/name")) + elif str(entry["repo"]).lower() == PLACEHOLDER_REPO: + findings.append(warn("CK004", f"{label} still points at the scaffold " + "placeholder; pin a real release with " + "cartkit pin owner/repo@X.Y.Z")) + if not full_match(SEMVER_RE, entry.get("version")): + findings.append(err("CK004", f"{label} version must be semver, e.g. " + "1.2.3")) + sha = entry.get("sha256") + if not full_match(SHA256_RE, sha): + findings.append(err("CK004", f"{label} sha256 must be 64 lowercase " + "hex characters; cartkit pin resolves it " + "for you")) + elif sha == PLACEHOLDER_SHA: + findings.append(warn("CK004", f"{label} carries the scaffold " + "placeholder hash; resolve it with " + "cartkit pin")) + for key in ("mod", "file", "md5"): + if key in entry: + findings.append(warn("CK004", f"{label} has {key!r}, which " + "belongs to a gamebanana pin")) + + +def check_gamebanana_pin(entry, label, findings): + for key in ("mod", "file"): + value = entry.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + findings.append(err("CK004", f"{label} {key} must be a positive " + "integer id")) + if not full_match(MD5_RE, entry.get("md5")): + findings.append(err("CK004", f"{label} md5 must be 32 lowercase hex " + "characters; cartkit pin resolves it for " + "you")) + for key in ("repo", "version", "sha256"): + if key in entry: + findings.append(warn("CK004", f"{label} has {key!r}, which " + "belongs to a github pin")) + + +def check_load_order(cart, ids, findings): + order = cart.get("load_order") + if order is None: + return + if not isinstance(order, list) or any(not isinstance(x, str) + for x in order): + findings.append(err("CK005", '"load_order" must be an array of the ' + "mod ids")) + return + if not ids: + return + missing = [x for x in ids if x not in order] + extra = [x for x in order if x not in ids] + if len(order) != len(set(order)): + findings.append(err("CK005", '"load_order" repeats an id; it is a ' + "permutation of the mods, not a list of " + "passes")) + if missing: + findings.append(err("CK005", '"load_order" leaves out ' + + ", ".join(missing) + + "; name every pinned mod or drop the field")) + if extra: + findings.append(err("CK005", '"load_order" names ' + ", ".join(extra) + + ", which is not pinned in mods")) + + +def schema_findings(cart, cart_dir=None): + findings = [] + check_identity(cart, findings) + check_label(cart, cart_dir, findings) + ids = check_mods(cart, findings) + check_load_order(cart, ids, findings) + return findings + + +# ------------------------------------------------------------------- http + +class Unreachable(Exception): + pass + + +class NotFound(Exception): + pass + + +_LAST_REQUEST = [0.0] +MIN_INTERVAL = 0.4 +MAX_BACKOFF = 30.0 + + +def github_token(): + for name in ("CARTKIT_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"): + value = os.environ.get(name, "").strip() + if value: + return value + return None + + +def _pause(): + wait = MIN_INTERVAL - (time.monotonic() - _LAST_REQUEST[0]) + if wait > 0: + time.sleep(wait) + + +def _retry_after(response): + raw = response.headers.get("Retry-After") if response.headers else None + try: + return min(float(raw), MAX_BACKOFF) if raw else None + except ValueError: + return None + + +def open_url(url, accept=None, token=None, timeout=30, attempts=3): + headers = {"User-Agent": USER_AGENT} + if accept: + headers["Accept"] = accept + if token: + headers["Authorization"] = "Bearer " + token + request = urllib.request.Request(url, headers=headers) + for attempt in range(attempts): + _pause() + try: + response = urllib.request.urlopen(request, timeout=timeout) + _LAST_REQUEST[0] = time.monotonic() + return response + except urllib.error.HTTPError as problem: + _LAST_REQUEST[0] = time.monotonic() + if problem.code in (404, 410): + raise NotFound(url) + if problem.code == 403 and \ + problem.headers.get("X-RateLimit-Remaining") == "0": + raise Unreachable( + "GitHub rate limit reached; set GITHUB_TOKEN to a token " + "with public read and try again") + retriable = problem.code in (403, 429) or 500 <= problem.code < 600 + if retriable and attempt + 1 < attempts: + time.sleep(_retry_after(problem) or min(2 ** attempt * 2, + MAX_BACKOFF)) + continue + raise Unreachable(f"{url}: HTTP {problem.code} {problem.reason}") + except Exception as problem: + _LAST_REQUEST[0] = time.monotonic() + if attempt + 1 < attempts: + time.sleep(min(2 ** attempt, MAX_BACKOFF)) + continue + raise Unreachable(f"{url}: {problem}") + raise Unreachable(url) + + +def get_json(url, accept=None, token=None): + with open_url(url, accept, token) as response: + body = response.read() + try: + return json.loads(body.decode("utf-8")) + except ValueError as problem: + raise Unreachable(f"{url}: response was not JSON ({problem})") + + +def get_text(url, token=None): + with open_url(url, None, token) as response: + return response.read().decode("utf-8", "replace") + + +def stream_digest(url, algorithm="sha256"): + digest = hashlib.new(algorithm) + total = 0 + with open_url(url, timeout=120) as response: + while True: + chunk = response.read(262144) + if not chunk: + break + total += len(chunk) + digest.update(chunk) + return digest.hexdigest(), total + + +# --------------------------------------------------------------- resolvers + +def github_release(slug, version, token): + tags = [f"v{version}", version] + for tag in tags: + url = (f"https://api.github.com/repos/{slug}/releases/tags/" + + urllib.parse.quote(tag)) + try: + return get_json(url, "application/vnd.github+json", token), tag + except NotFound: + continue + raise NotFound(f"{slug} has no release tagged " + + " or ".join(tags)) + + +def pick_asset(release, mod_id, version): + assets = [a for a in release.get("assets", []) + if isinstance(a, dict) and a.get("name")] + wanted = f"{mod_id}-{version}.zip" + for asset in assets: + if asset["name"] == wanted: + return asset + zips = [a for a in assets if a["name"].lower().endswith(".zip")] + if len(zips) == 1: + return zips[0] + if not zips: + raise NotFound(f"release {release.get('tag_name')} has no .zip asset; " + "publish the mod archive on the release") + names = ", ".join(sorted(a["name"] for a in zips)) + raise NotFound(f"release {release.get('tag_name')} has {len(zips)} .zip " + f"assets ({names}); the game picks {wanted}, so name the " + "mod archive that way") + + +def sums_digest(release, asset_name, token): + for asset in release.get("assets", []): + if asset.get("name") == "sha256sums.txt": + body = get_text(asset["browser_download_url"], None) + for line in body.splitlines(): + parts = line.split() + if len(parts) == 2 and os.path.basename( + parts[1].lstrip("*")) == asset_name: + return parts[0].lower() + return None + + +def resolve_github(slug, version, mod_id, token, download=True): + release, tag = github_release(slug, version, token) + asset = pick_asset(release, mod_id, version) + digest = sums_digest(release, asset["name"], token) + how = "sha256sums.txt" + if digest is None: + if not download: + return {"tag": tag, "asset": asset["name"], + "size": asset.get("size", 0), "sha256": None, + "how": "not published"} + digest, size = stream_digest(asset["browser_download_url"]) + how = f"downloading {size} bytes" + return {"tag": tag, "asset": asset["name"], "size": asset.get("size", 0), + "sha256": digest, "how": how} + + +def gamebanana_files(mod_id): + url = f"https://gamebanana.com/apiv11/Mod/{int(mod_id)}/DownloadPage" + payload = get_json(url) + if not isinstance(payload, dict): + raise Unreachable(f"{url}: unexpected response") + if payload.get("_bIsTrashed") or payload.get("_bIsWithheld"): + raise NotFound(f"GameBanana mod {mod_id} is trashed or withheld") + files = payload.get("_aFiles") + if not isinstance(files, list) or not files: + raise NotFound(f"GameBanana mod {mod_id} publishes no files") + return files + + +def gamebanana_file(files, file_id): + for entry in files: + if isinstance(entry, dict) and entry.get("_idRow") == int(file_id): + return entry + return None + + +# ------------------------------------------------------------ online rules + +def online_findings(cart, findings, notes, download=True): + token = github_token() + if not token: + notes.append("no GITHUB_TOKEN in the environment; GitHub allows 60 " + "anonymous API calls an hour") + for index, entry in enumerate(cart.get("mods") or []): + if not isinstance(entry, dict): + continue + mod_id = entry.get("id") + label = f"mods[{index + 1}] {mod_id}" + if entry.get("source") == "github": + resolve_github_pin(entry, label, findings, notes, token, download) + elif entry.get("source") == "gamebanana": + resolve_gamebanana_pin(entry, label, findings, notes) + + +def resolve_github_pin(entry, label, findings, notes, token, download): + slug, version = entry.get("repo"), entry.get("version") + if not (full_match(REPO_RE, slug) and full_match(SEMVER_RE, version)): + return + try: + found = resolve_github(slug, version, entry.get("id") or "", + token, download) + except NotFound as problem: + findings.append(err("CK100", f"{label} does not resolve: {problem}")) + return + except Unreachable as problem: + notes.append(f"{label} not resolved: {problem}") + return + if found["sha256"] is None: + notes.append(f"{label} hash not checked: the release publishes no " + "sha256sums.txt and --no-download was given") + return + if found["sha256"] != entry.get("sha256"): + findings.append(err("CK101", f"{label} pins sha256 " + f"{entry.get('sha256')} but " + f"{found['asset']} on {slug} " + f"{found['tag']} hashes to " + f"{found['sha256']}; re-pin it")) + + +def resolve_gamebanana_pin(entry, label, findings, notes): + mod, file_id = entry.get("mod"), entry.get("file") + if isinstance(mod, bool) or not isinstance(mod, int) or mod <= 0: + return + if isinstance(file_id, bool) or not isinstance(file_id, int) \ + or file_id <= 0: + return + try: + files = gamebanana_files(mod) + except NotFound as problem: + findings.append(err("CK110", f"{label} does not resolve: {problem}")) + return + except Unreachable as problem: + notes.append(f"{label} not resolved: {problem}") + return + found = gamebanana_file(files, file_id) + if found is None: + have = ", ".join(str(f.get("_idRow")) for f in files + if isinstance(f, dict)) + findings.append(err("CK110", f"{label} pins file {file_id}, which is " + f"not on GameBanana mod {mod} " + f"(it publishes {have})")) + return + published = str(found.get("_sMd5Checksum") or "").lower() + if published != entry.get("md5"): + findings.append(err("CK111", f"{label} pins md5 {entry.get('md5')} " + f"but file {file_id} " + f"({found.get('_sFile')}) publishes " + f"{published or 'no checksum'}; " + "re-pin it")) + + +# -------------------------------------------------------------- lua bundle + +def lua_string(text): + out = ['"'] + for position, ch in enumerate(text): + code = ord(ch) + if ch in '"\\\n': + out.append("\\" + ch) + elif code < 32 or code == 127: + following = text[position + 1:position + 2] + out.append("\\%03d" % code if following.isdigit() + else "\\%d" % code) + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def lua_number(value): + if isinstance(value, int): + return str(value) + if value != value or value in (float("inf"), float("-inf")): + raise ValueError(f"cannot serialize {value!r}") + text = "%.14g" % value + return text + + +def lua_value(value, indent=0): + pad = " " * indent + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return lua_number(value) + if isinstance(value, str): + return lua_string(value) + if isinstance(value, (list, tuple)): + pairs = [(position + 1, item) for position, item in enumerate(value)] + elif isinstance(value, dict): + pairs = sorted(value.items(), key=lambda kv: kv[0].encode("utf-8")) + else: + raise ValueError(f"cannot serialize {type(value).__name__}") + if not pairs: + return "{}" + lines = [] + for key, item in pairs: + if isinstance(key, str) and LUA_NAME_RE.fullmatch(key): + rendered = key + elif isinstance(key, str): + rendered = "[" + lua_string(key) + "]" + else: + rendered = "[" + lua_number(key) + "]" + lines.append(pad + " " + rendered + " = " + + lua_value(item, indent + 1)) + return "{\n" + ",\n".join(lines) + ",\n" + pad + "}" + + +def lua_encode(data): + return "return " + lua_value(data, 0) + "\n" + + +def packed_cart(cart): + out = {} + for key in CART_KEYS: + if key in cart and cart[key] is not None: + out[key] = cart[key] + out.pop("schema", None) + out["shell"] = str(cart.get("shell", "")).lower() + out["seal"] = cart.get("seal", "sealed") + mods = [] + for entry in cart.get("mods") or []: + pin = {key: entry[key] for key in MOD_KEYS + if key in entry and entry[key] is not None} + if not pin.get("options"): + pin.pop("options", None) + mods.append(pin) + out["mods"] = mods + out["load_order"] = list(cart.get("load_order") + or [entry.get("id") for entry in mods]) + return out + + +def bundle_table(cart, label_bytes=None, label_name=None): + root = {"format": BUNDLE_FORMAT, "formatVersion": BUNDLE_VERSION, + "cart": packed_cart(cart)} + if label_bytes: + root["labelArt"] = { + "name": label_name, + "encoding": "base64", + "bytes": len(label_bytes), + "data": base64.b64encode(label_bytes).decode("ascii"), + } + return root + + +def bundle_bytes(cart, cart_dir=None): + label_bytes, label_name = None, None + label = cart.get("label") + if label and cart_dir: + path = os.path.join(cart_dir, label) + if os.path.isfile(path): + label_bytes = open(path, "rb").read() + label_name = os.path.basename(label) + return lua_encode(bundle_table(cart, label_bytes, + label_name)).encode("utf-8") + + +# ------------------------------------------------------------------ label + +def png_bytes(width, height, rows): + raw = bytearray() + for row in rows: + raw.append(0) + for pixel in row: + raw += bytes(pixel) + + def chunk(tag, body): + return (struct.pack(">I", len(body)) + tag + body + + struct.pack(">I", zlib.crc32(tag + body) & 0xffffffff)) + + header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + + chunk(b"IEND", b"")) + + +def shell_rgb(shell): + value = shell.lstrip("#") + return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) + + +def mix(colour, target, amount): + return tuple(int(round(c + (t - c) * amount)) + for c, t in zip(colour, target)) + + +def label_art(shell): + size = 96 + face = shell_rgb(shell) + edge = mix(face, (0, 0, 0), 0.55) + sticker = mix(face, (255, 255, 255), 0.78) + ink = mix(face, (0, 0, 0), 0.35) + rows = [] + for y in range(size): + row = [] + for x in range(size): + inset = min(x, y, size - 1 - x, size - 1 - y) + if inset < 5: + row.append(edge) + elif 12 <= x < size - 12 and 16 <= y < size - 28: + row.append(sticker if (x + y) % 16 else ink) + else: + row.append(face) + rows.append(row) + return png_bytes(size, size, rows) + + +# --------------------------------------------------------------- templates + +README_TEMPLATE = """# {{title}} + +A custom cart for the LOVE2D Pokemon engine: a pinned mod setup that plays as +its own game on top of `{{base}}`. It ships no code -- every mod named here is +published separately, and the cart pins each one to an exact build. + +## Files + +- `cart.json` - identity, base game, seal, and one pin per mod +- `{{label}}` - the cart label art the launcher draws +- `.github/workflows/release.yml` - packs and publishes on a `v*` tag + +## Loop + +Every command below is `tools/cartkit.py` from a gen1recomp checkout, pointed +at this directory. + +1. pin a mod: + + ```sh + python3 tools/cartkit.py pin . owner/repo@1.2.3 + python3 tools/cartkit.py pin . https://gamebanana.com/mods/546899 + ``` + +2. freeze an option the player inherits: + + ```sh + python3 tools/cartkit.py pin . owner/repo@1.2.3 --option difficulty=hard + ``` + +3. check it, hashes and all: + + ```sh + python3 tools/cartkit.py validate . --online + ``` + +4. build the bundle players load: + + ```sh + python3 tools/cartkit.py pack . + ``` + +`seal` is `{{seal}}`. A sealed cart loads exactly the mods listed below and +nothing else; an open cart lets the player add more on top. + +## Releasing + +Bump `version` in `cart.json`, tag it `v`, and push the tag. The +workflow validates, packs, and attaches `{{id}}-.g1rcart` to the +release. +""" + +CHANGELOG_TEMPLATE = """# Changelog + +All notable changes to this cart are recorded here, newest first. + +## [0.1.0] - unreleased + +### Added + +- First cut of {{title}}. +""" + + +def cmd_scaffold(args, repo): + if not ID_RE.fullmatch(args.id): + print(f"cartkit: bad id {args.id!r} (letters, digits, _ or -, " + "1..64 characters)") + return 2 + if args.shell and not SHELL_RE.fullmatch(args.shell): + print(f"cartkit: bad shell {args.shell!r} (expected #rrggbb)") + return 2 + github = "" + if args.github: + match = GITHUB_SLUG.fullmatch(args.github.strip()) + if not match: + print(f"cartkit: bad --github {args.github!r} " + "(expected owner/name or a github.com URL)") + return 2 + github = f"{match.group(1)}/{match.group(2)}" + + dest_root = args.into or args.dest or (os.path.join(repo, "carts") + if repo else os.getcwd()) + dest = os.path.join(dest_root, args.id) + if os.path.exists(dest) and not args.force: + print(f"cartkit: {dest} exists (use --force to overwrite)") + return 2 + + title = args.title or args.id.replace("_", " ").replace("-", " ").title() + if text_problem(title, 1, 48): + print(f"cartkit: bad title {title!r} (1..48 characters)") + return 2 + shell = (args.shell or "#8b1a1a").lower() + engine = engine_version(repo) if repo else "0.0.0" + engine_range = ">=%s <%d.0.0" % (engine, int(engine.split(".")[0]) + 1) + + cart = { + "schema": CART_SCHEMA, + "id": args.id, + "title": title, + "version": "0.1.0", + "author": args.author or "TODO your handle", + "summary": args.summary or "", + "shell": shell, + "label": "label.png", + "base": args.base, + "engine": engine_range, + "seal": args.seal, + "mods": [{ + "id": "example-mod", + "source": "github", + "repo": PLACEHOLDER_REPO, + "version": "0.1.0", + "sha256": PLACEHOLDER_SHA, + }], + } + if github: + cart["repo"] = github + if not cart["summary"]: + del cart["summary"] + + os.makedirs(dest, exist_ok=True) + write_cart(dest, cart) + with open(os.path.join(dest, "label.png"), "wb") as fh: + fh.write(label_art(shell)) + subst = {"{{id}}": args.id, "{{title}}": title, "{{base}}": args.base, + "{{seal}}": args.seal, "{{label}}": "label.png"} + for name, template in (("README.md", README_TEMPLATE), + ("CHANGELOG.md", CHANGELOG_TEMPLATE)): + body = template + for key, value in subst.items(): + body = body.replace(key, value) + with open(os.path.join(dest, name), "w", encoding="utf-8") as fh: + fh.write(body) + install_workflow(repo, dest, args.id, force=True) + + if not args.quiet: + print(f"created {dest} ({args.base} base, {args.seal})") + print("next: python3 tools/cartkit.py pin " + f"{dest} owner/repo@1.2.3 (replaces the placeholder pin)") + print(f"then: python3 tools/cartkit.py validate {dest} --online") + return 0 + + +# ---------------------------------------------------------------- workflow + +def install_workflow(repo, cart_dir, cart_id, force=False): + template = os.path.join(repo or "", "tools", "cart_release_workflow.yml") + if not os.path.isfile(template): + return f"missing template {template}" + dest_dir = os.path.join(cart_dir, ".github", "workflows") + dest = os.path.join(dest_dir, "release.yml") + if os.path.exists(dest) and not force: + return f"{dest} exists (use --force to overwrite)" + body = open(template, encoding="utf-8").read().replace("{{CART_ID}}", + cart_id) + os.makedirs(dest_dir, exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(body) + return None + + +def cmd_add_release_workflow(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + cart_id = cart.get("id") or os.path.basename(cart_dir) + trouble = install_workflow(repo, cart_dir, cart_id, args.force) + if trouble: + print(f"cartkit: {trouble}") + return 2 + if not args.quiet: + dest = os.path.join(cart_dir, ".github", "workflows", "release.yml") + print(f"wrote {dest}") + print("push the cart as its own GitHub repo and tag v to " + f"publish {cart_id}-{CART_EXT}") + return 0 + + +# ---------------------------------------------------------------- validate + +def cmd_validate(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r} (expected a directory " + f"holding {CART_FILE})") + return 2 + cart, problem = read_cart(cart_dir) + findings, notes = [], [] + if problem: + findings.append(problem) + name = os.path.basename(cart_dir) + else: + findings.extend(schema_findings(cart, cart_dir)) + name = cart.get("id") or os.path.basename(cart_dir) + if args.online: + online_findings(cart, findings, notes, not args.no_download) + else: + notes.append("pins not resolved; rerun with --online to check " + "every release, file id and hash") + return report(findings, args, f"ok {name} valid", f"FAIL {name} invalid", + notes) + + +# -------------------------------------------------------------------- pack + +def cmd_pack(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + findings = schema_findings(cart, cart_dir) + notes = [] + if args.online: + online_findings(cart, findings, notes, not args.no_download) + for finding in findings: + print(finding.line()) + if not args.quiet: + for note in notes: + print(f"cartkit: {note}") + if findings: + print("cartkit: pack refused (pack runs validate --strict, so the " + "warnings above are fatal too)") + return 1 + + out = args.output or f"{cart['id']}-{cart['version']}{CART_EXT}" + body = bundle_bytes(cart, cart_dir) + with open(out, "wb") as fh: + fh.write(body) + if not args.quiet: + mods = len(cart.get("mods") or []) + label = " + label art" if cart.get("label") else "" + plural = "" if mods == 1 else "s" + print(f"wrote {out} ({len(body)} bytes, {mods} pinned " + f"mod{plural}{label})") + return 0 + + +# --------------------------------------------------------------------- pin + +def parse_spec(spec): + match = GITHUB_SPEC.fullmatch(spec.strip()) + if match: + return ("github", f"{match.group(1)}/{match.group(2)}", + match.group(3).lstrip("vV")) + match = GAMEBANANA_SPEC.fullmatch(spec.strip()) + if match: + return ("gamebanana", int(match.group(1)), None) + return (None, None, None) + + +def parse_option(text): + key, sep, raw = text.partition("=") + if not sep or not key: + raise ValueError(f"--option wants key=value (got {text!r})") + lowered = raw.strip().lower() + if lowered in ("true", "false"): + return key, lowered == "true" + try: + return key, int(raw) + except ValueError: + pass + try: + return key, float(raw) + except ValueError: + pass + return key, raw + + +def is_placeholder(entry): + return (isinstance(entry, dict) + and str(entry.get("repo", "")).lower() == PLACEHOLDER_REPO + and entry.get("sha256") == PLACEHOLDER_SHA) + + +def derive_id(text): + cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", text.strip()).strip("-") + return (cleaned or "mod")[:64].lower() + + +def cmd_pin(args, repo): + cart_dir = resolve_cart_dir(repo, args.cart) + if not cart_dir: + print(f"cartkit: no cart at {args.cart!r}") + return 2 + cart, problem = read_cart(cart_dir) + if problem: + print(problem.line()) + return 1 + source, target, version = parse_spec(args.spec) + if not source: + print(f"cartkit: cannot read {args.spec!r}; write owner/repo@1.2.3, a " + "gamebanana mod url, or a gamebanana mod id") + return 2 + if source == "github" and not SEMVER_RE.fullmatch(version): + print(f"cartkit: {version!r} is not semver; pin an exact release like " + "owner/repo@1.2.3") + return 2 + options = {} + try: + for text in args.option or []: + key, value = parse_option(text) + options[key] = value + except ValueError as problem: + print(f"cartkit: {problem}") + return 2 + + try: + if source == "github": + entry, note = pin_github(target, version, args, options) + else: + entry, note = pin_gamebanana(target, args, options) + except NotFound as problem: + print(f"cartkit: {problem}") + return 1 + except Unreachable as problem: + print(f"cartkit: {problem}") + return 1 + if entry is None: + return 2 + + mods = cart.setdefault("mods", []) + replaced = False + for index, existing in enumerate(mods): + if isinstance(existing, dict) and existing.get("id") == entry["id"]: + keep = existing.get("options") or {} + if args.clear_options: + keep = {} + keep.update(entry.get("options") or {}) + if keep: + entry["options"] = keep + mods[index] = entry + replaced = True + break + dropped = [] + if not replaced: + for existing in list(mods): + if is_placeholder(existing): + mods.remove(existing) + dropped.append(existing.get("id")) + mods.append(entry) + order = cart.get("load_order") + if isinstance(order, list): + cart["load_order"] = [x for x in order if x not in dropped] + cart["load_order"].append(entry["id"]) + write_cart(cart_dir, cart) + + if not args.quiet: + verb = "updated" if replaced else "added" + print(f"{verb} pin {entry['id']} ({note})") + for name in dropped: + print(f"dropped the scaffold placeholder pin {name}") + print(f"wrote {os.path.join(cart_dir, CART_FILE)}") + return 0 + + +def pin_github(slug, version, args, options): + mod_id = args.id or derive_id(slug.split("/")[1]) + found = resolve_github(slug, version, mod_id, github_token(), True) + if found["sha256"] is None: + raise NotFound(f"{slug} {found['tag']} publishes no hash for " + f"{found['asset']}") + entry = {"id": mod_id, "source": "github", "repo": slug, + "version": version, "sha256": found["sha256"]} + if options: + entry["options"] = options + return entry, (f"{slug} {found['tag']} -> {found['asset']}, sha256 from " + f"{found['how']}") + + +def pin_gamebanana(mod_id, args, options): + files = gamebanana_files(mod_id) + chosen = None + if args.file: + chosen = gamebanana_file(files, args.file) + if chosen is None: + have = ", ".join(f"{f.get('_idRow')} ({f.get('_sFile')})" + for f in files if isinstance(f, dict)) + raise NotFound(f"GameBanana mod {mod_id} has no file " + f"{args.file}; it publishes {have}") + elif len(files) == 1: + chosen = files[0] + else: + have = "\n".join(f" --file {f.get('_idRow')} {f.get('_sFile')}" + for f in files if isinstance(f, dict)) + print(f"cartkit: GameBanana mod {mod_id} publishes {len(files)} " + f"files; pick one:\n{have}") + return None, None + md5 = str(chosen.get("_sMd5Checksum") or "").lower() + if not MD5_RE.fullmatch(md5): + raise NotFound(f"GameBanana file {chosen.get('_idRow')} publishes no " + "md5; a cart cannot pin it") + name = args.id or derive_id(os.path.splitext( + str(chosen.get("_sFile") or ""))[0]) or f"gb{mod_id}" + entry = {"id": name, "source": "gamebanana", "mod": int(mod_id), + "file": int(chosen["_idRow"]), "md5": md5} + if options: + entry["options"] = options + return entry, (f"gamebanana {mod_id} -> file {chosen['_idRow']} " + f"({chosen.get('_sFile')}), md5 from the v11 API") + + +# ---------------------------------------------------------------- selftest + +def good_cart(): + return { + "schema": 1, + "id": "kanto-hard", + "title": "Kanto Hard Mode", + "version": "1.0.0", + "author": "someone", + "repo": "someone/kanto-hard", + "summary": "Every trainer bites back.", + "shell": "#8b1a1a", + "label": "label.png", + "base": "red", + "engine": ">=1.0.0 <2.0.0", + "seal": "sealed", + "mods": [ + {"id": "harder-trainers", "source": "github", + "repo": "someone/harder-trainers", "version": "2.1.0", + "sha256": "a" * 64, + "options": {"difficulty": "brutal", "levelCap": 100, + "nuzlocke": True}}, + {"id": "new-music", "source": "gamebanana", "mod": 546899, + "file": 1294214, "md5": "b" * 32}, + ], + "load_order": ["new-music", "harder-trainers"], + } + + +def rules(findings): + return sorted({f.rule for f in findings if f.severity == "error"}) + + +def t_good(): + found = schema_findings(good_cart()) + assert not found, rules(found) + + +def t_identity(): + for key, value in (("id", "no spaces here"), ("id", "x" * 65), + ("title", ""), ("title", "t" * 49), + ("version", "1.0"), ("version", "v1.0.0"), + ("author", ""), ("shell", "8b1a1a"), + ("shell", "#8b1a1"), ("base", "crystal"), + ("seal", "welded"), ("schema", 2), + ("summary", "s" * 121), ("repo", "someone"), + ("engine", ">=1.0.0 <<2.0.0")): + cart = good_cart() + cart[key] = value + assert "CK002" in rules(schema_findings(cart)), (key, value) + + +def t_optional_fields(): + for key in ("repo", "summary", "engine", "label", "load_order", "seal"): + cart = good_cart() + del cart[key] + assert not schema_findings(cart), key + + +def t_unknown_field(): + cart = good_cart() + cart["colour"] = "red" + assert any(f.rule == "CK001" and f.severity == "warn" + for f in schema_findings(cart)) + + +def t_label(): + for value in ("/etc/passwd", "../out.png", "a/../../b.png", "x" * 129, + "C:\\art.png"): + cart = good_cart() + cart["label"] = value + assert "CK003" in rules(schema_findings(cart)), value + + +def t_mods(): + cart = good_cart() + cart["mods"] = [] + assert "CK004" in rules(schema_findings(cart)) + cart = good_cart() + cart["mods"] = [dict(cart["mods"][0]) for _ in range(65)] + assert "CK004" in rules(schema_findings(cart)) + cart = good_cart() + cart["mods"][1] = dict(cart["mods"][0]) + cart["load_order"] = ["harder-trainers", "harder-trainers"] + assert "CK004" in rules(schema_findings(cart)) + + +def t_pins(): + cases = [ + {"source": "torrent"}, + {"repo": "nope"}, + {"version": "1.0"}, + {"sha256": "A" * 64}, + {"sha256": "a" * 63}, + ] + for patch in cases: + cart = good_cart() + cart["mods"][0].update(patch) + assert "CK004" in rules(schema_findings(cart)), patch + for patch in [{"mod": 0}, {"mod": "546899"}, {"file": -1}, + {"md5": "B" * 32}, {"md5": "b" * 31}]: + cart = good_cart() + cart["mods"][1].update(patch) + assert "CK004" in rules(schema_findings(cart)), patch + + +def t_placeholder_warns(): + cart = good_cart() + cart["mods"][0]["repo"] = PLACEHOLDER_REPO + cart["mods"][0]["sha256"] = PLACEHOLDER_SHA + found = schema_findings(cart) + assert not rules(found) + assert sum(1 for f in found if f.severity == "warn") == 2 + + +def t_options(): + for value in ({"a": {"nested": 1}}, {"a": None}, {"a": ["list"]}, + {"k" * 65: 1}, {"a": "x" * 257}, + dict((str(n), n) for n in range(65))): + cart = good_cart() + cart["mods"][0]["options"] = value + assert "CK004" in rules(schema_findings(cart)), value + + +def t_load_order(): + for value in (["harder-trainers"], ["harder-trainers", "ghost"], + ["harder-trainers", "harder-trainers"], "harder-trainers", + [1, 2]): + cart = good_cart() + cart["load_order"] = value + assert "CK005" in rules(schema_findings(cart)), value + + +def t_lua_strings(): + assert lua_string('a"b') == '"a\\"b"' + assert lua_string("a\\b") == '"a\\\\b"' + assert lua_string("a\nb") == '"a\\\nb"' + assert lua_string("a\tb") == '"a\\9b"' + assert lua_string("a\t1") == '"a\\0091"' + assert lua_string("a\rb") == '"a\\13b"' + assert lua_string("Pokémon") == '"Pokémon"' + + +def t_lua_numbers(): + assert lua_value(3) == "3" + assert lua_value(-2) == "-2" + assert lua_value(0.5) == "0.5" + assert lua_value(True) == "true" + assert lua_value([]) == "{}" + assert lua_value({}) == "{}" + + +def t_bundle_shape(): + body = lua_encode(bundle_table(good_cart())) + assert body.startswith("return {\n") + assert body.endswith("}\n") + assert ' format = "g1rcart",' in body + assert " formatVersion = 1,\n" in body + assert body.index("cart =") < body.index("format =") < \ + body.index("formatVersion =") + assert '[1] = {' in body + + +def t_bundle_defaults(): + cart = good_cart() + del cart["seal"] + del cart["load_order"] + packed = packed_cart(cart) + assert packed["seal"] == "sealed" + assert packed["load_order"] == ["harder-trainers", "new-music"] + + +def t_bundle_deterministic(): + cart = good_cart() + shuffled = {key: cart[key] for key in reversed(list(cart))} + assert lua_encode(bundle_table(cart)) == lua_encode(bundle_table(shuffled)) + + +def t_bundle_label(): + art = label_art("#8b1a1a") + table = bundle_table(good_cart(), art, "label.png") + assert table["labelArt"]["bytes"] == len(art) + assert base64.b64decode(table["labelArt"]["data"]) == art + + +def t_specs(): + assert parse_spec("owner/repo@1.2.3") == ("github", "owner/repo", "1.2.3") + assert parse_spec("https://github.com/owner/repo@1.2.3") == \ + ("github", "owner/repo", "1.2.3") + assert parse_spec("owner/repo.git@v1.2.3") == \ + ("github", "owner/repo", "1.2.3") + assert parse_spec("https://gamebanana.com/mods/546899") == \ + ("gamebanana", 546899, None) + assert parse_spec("gamebanana:546899") == ("gamebanana", 546899, None) + assert parse_spec("546899") == ("gamebanana", 546899, None) + assert parse_spec("not a spec")[0] is None + + +def t_options_parse(): + assert parse_option("a=true") == ("a", True) + assert parse_option("a=3") == ("a", 3) + assert parse_option("a=1.5") == ("a", 1.5) + assert parse_option("a=hard") == ("a", "hard") + try: + parse_option("nope") + except ValueError: + return + raise AssertionError("--option without = must be rejected") + + +def t_ranges(): + for text in (">=1.0.0 <2.0.0", "^1.2", "1.2.3", ">1 || <0.9", "<=2"): + assert range_problem(text) is None, text + for text in ("", ">=x", ">>1.0.0", "1.0.0 || "): + assert range_problem(text) is not None, text + + +def t_png(): + art = label_art("#123456") + assert art.startswith(b"\x89PNG\r\n\x1a\n") + assert art[12:16] == b"IHDR" and art.endswith(b"IEND\xae\x42\x60\x82") + assert label_art("#123456") == art + + +def t_roundtrip(): + root = tempfile.mkdtemp(prefix="cartkit-selftest-") + repo = find_repo(os.path.dirname(os.path.abspath(__file__))) + try: + with contextlib.redirect_stdout(io.StringIO()): + _roundtrip(root, repo) + finally: + shutil.rmtree(root, ignore_errors=True) + + +def _roundtrip(root, repo): + assert main(["scaffold", "demo_cart", "--into", root, + "--quiet"]) == 0, "scaffold failed" + cart_dir = os.path.join(root, "demo_cart") + assert os.path.isfile(os.path.join(cart_dir, CART_FILE)) + assert os.path.isfile(os.path.join(cart_dir, "label.png")) + assert os.path.isfile(os.path.join(cart_dir, "README.md")) + assert os.path.isfile(os.path.join(cart_dir, "CHANGELOG.md")) + assert os.path.isfile(os.path.join(cart_dir, ".github", "workflows", + "release.yml")) or not repo + assert main(["validate", cart_dir, "--quiet"]) == 0, "validate failed" + assert main(["validate", cart_dir, "--quiet", "--strict"]) == 1, \ + "the placeholder pin must fail --strict" + cart, problem = read_cart(cart_dir) + assert problem is None + cart["mods"][0] = {"id": "harder-trainers", "source": "github", + "repo": "someone/harder-trainers", + "version": "2.1.0", "sha256": "c" * 64} + write_cart(cart_dir, cart) + assert main(["pack", cart_dir, "-o", + os.path.join(root, "out" + CART_EXT), "--quiet"]) == 0 + first = open(os.path.join(root, "out" + CART_EXT), "rb").read() + assert main(["pack", cart_dir, "-o", + os.path.join(root, "again" + CART_EXT), "--quiet"]) == 0 + again = open(os.path.join(root, "again" + CART_EXT), "rb").read() + assert first == again, "pack is not byte-identical twice" + assert first.startswith(b"return {\n") + assert b'id = "demo_cart"' in first + assert b'labelArt' in first + + +CHECKS = [ + ("schema accepts a full cart", t_good), + ("identity fields are checked", t_identity), + ("optional fields stay optional", t_optional_fields), + ("unknown top-level fields warn", t_unknown_field), + ("label paths cannot escape", t_label), + ("mods list bounds", t_mods), + ("pin fields per source", t_pins), + ("scaffold placeholders warn", t_placeholder_warns), + ("frozen options are scalars", t_options), + ("load_order is a permutation", t_load_order), + ("lua string escapes", t_lua_strings), + ("lua scalars and empty tables", t_lua_numbers), + ("bundle shape and key order", t_bundle_shape), + ("bundle materializes defaults", t_bundle_defaults), + ("bundle is order-independent", t_bundle_deterministic), + ("bundle carries the label art", t_bundle_label), + ("pin specs", t_specs), + ("option parsing", t_options_parse), + ("engine ranges", t_ranges), + ("label png is deterministic", t_png), + ("scaffold validate pack round trip", t_roundtrip), +] + + +def cmd_selftest(args, repo): + failures = [] + for name, check in CHECKS: + try: + check() + except Exception as problem: + failures.append((name, problem)) + if not args.quiet: + print(f"FAIL {name}: {problem!r}") + else: + if not args.quiet: + print(f"ok {name}") + if failures: + print(f"FAIL {len(failures)} of {len(CHECKS)} checks") + return 1 + if not args.quiet: + print(f"ok {len(CHECKS)} checks") + return 0 + + +# -------------------------------------------------------------------- main + +def main(argv): + shared = argparse.ArgumentParser(add_help=False) + shared.add_argument("--repo", default=argparse.SUPPRESS, + help="repo root override") + shared.add_argument("--json", action="store_true", + default=argparse.SUPPRESS) + shared.add_argument("--quiet", action="store_true", + default=argparse.SUPPRESS) + + parser = argparse.ArgumentParser(prog="cartkit", parents=[shared], + description="author, check and pack " + "custom carts") + sub = parser.add_subparsers(dest="command") + + p = sub.add_parser("scaffold", parents=[shared], + help="write a new cart repo") + p.add_argument("id") + p.add_argument("--title", help="shown on the cart, 1..48 characters") + p.add_argument("--author") + p.add_argument("--summary", help="one line, <=120 characters") + p.add_argument("--base", default="red", choices=list(BASES)) + p.add_argument("--shell", help="cart shell colour as #rrggbb") + p.add_argument("--seal", default="sealed", choices=list(SEALS), + help="sealed loads only the pinned mods; open lets the " + "player add more") + p.add_argument("--github", help="owner/repo this cart is published from") + p.add_argument("--into", help="parent directory for the new cart") + p.add_argument("--dest", help=argparse.SUPPRESS) + p.add_argument("--force", action="store_true") + + p = sub.add_parser("validate", parents=[shared], + help="check cart.json, offline unless --online") + p.add_argument("cart") + p.add_argument("--online", action="store_true", + help="resolve every pin against GitHub and GameBanana") + p.add_argument("--no-download", action="store_true", + help="never fetch a release asset just to hash it") + p.add_argument("--strict", action="store_true") + + p = sub.add_parser("pin", parents=[shared], + help="add or update one pin, resolving its hash") + p.add_argument("cart") + p.add_argument("spec", help="owner/repo@1.2.3, a gamebanana mod url, or " + "a gamebanana mod id") + p.add_argument("--id", help="mod id in the cart (default: derived)") + p.add_argument("--file", type=int, + help="gamebanana file id when the mod publishes several") + p.add_argument("--option", action="append", metavar="KEY=VALUE", + help="freeze an option value; true/false and numbers are " + "stored as such") + p.add_argument("--clear-options", action="store_true", + help="drop option values already frozen on this pin") + + p = sub.add_parser("pack", parents=[shared], + help="write -" + CART_EXT) + p.add_argument("cart") + p.add_argument("-o", "--output") + p.add_argument("--online", action="store_true") + p.add_argument("--no-download", action="store_true") + + p = sub.add_parser("add-release-workflow", parents=[shared], + help="copy the GitHub Actions release workflow in") + p.add_argument("cart") + p.add_argument("--force", action="store_true") + + sub.add_parser("selftest", parents=[shared], + help="run cartkit's own checks") + + args = parser.parse_args(argv) + for dest, fallback in (("repo", None), ("json", False), ("quiet", False)): + if not hasattr(args, dest): + setattr(args, dest, fallback) + if not args.command: + parser.print_help() + return 2 + + repo = args.repo or find_repo(os.getcwd()) or find_repo( + os.path.dirname(os.path.abspath(__file__))) + if repo: + repo = os.path.abspath(repo) + elif args.command in ("scaffold", "add-release-workflow"): + print("cartkit: cannot find the repo root " + "(looked for tools/rom_manifest.json); pass --repo PATH") + return 2 + + handler = { + "scaffold": cmd_scaffold, + "validate": cmd_validate, + "pin": cmd_pin, + "pack": cmd_pack, + "add-release-workflow": cmd_add_release_workflow, + "selftest": cmd_selftest, + }[args.command] + return handler(args, repo) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))