Add custom carts: named, version-pinned mod sets that play as their own game

A custom cart pairs an identity (title, shell colour, label art) with a base
game, a list of mods pinned to exact builds with their option values frozen,
a load order, and a seal. It ships no code of its own: every mod it names is
a separately published mod, which is what keeps a cart auditable before it
runs and reproducible after an author's repo disappears.

Format and storage:
- src/carts/CartManifest.lua parses and validates cart.json, canonicalises it
  for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only
  serialised table read through SaveSerializer, so an imported cart can never
  execute code. Canonical strings are length-prefixed because option keys and
  values are author-controlled and could otherwise forge a record boundary and
  collide two different carts onto one hash.
- Pins name a public source: a GitHub release with its sha256, a GameBanana
  file id with its md5, or "local" for a capture that only exists on this
  install. A local pin is unpublishable by construction, which is what makes
  "build it here, publish later" possible without inventing a hash.
- Label art rides alongside the manifest rather than inside its identity, so
  re-arting a cart does not tell every player their run is out of date.
  src/core/Base64.lua decodes it; strict, with no whitespace tolerance.

Saves:
- Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/),
  so a cart's file never sits beside a vanilla one and uninstalling a cart
  never orphans a save. Every save records the cart build it was made under.

The seal:
- A sealed cart loads its pinned list, in its order, with its options, and
  nothing else. A pinned mod with no frozen options gets an empty bucket so
  unfrozen keys fall to schema defaults, identical for everyone; otherwise two
  players on one cart quietly run different games.
- A sealed cart refuses to load when a pin is missing or installed at another
  version. Playing a subset of the cart is the exact dishonesty the seal
  exists to prevent, so the refusal loads nothing at all.
- Breaking the seal is permanent, marked per save slot, and downgrades that
  playthrough to open behaviour. It cannot be cleared through any public API.

Launcher:
- A game's page carries a Custom Carts control and a picker; choosing a cart
  turns the page into that cart's page, with its own cartridge, title and save
  slots. The rail of five games never grows and a cart id never reaches
  imp.tab or imp.panelVersion.
- Loader.planCart runs before boot so a refusal is visible on the page instead
  of being discovered as an error after launch.
- Save as cart captures the enabled mods for a game and names, before the
  player confirms, every mod that could only be pinned to this install and
  whether the result can be shared at all.

Authoring:
- tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a
  release workflow. Its writer is byte-identical to the engine's serialiser.
This commit is contained in:
bryanthaboi
2026-08-22 21:41:19 -04:00
parent ca4d3d283c
commit ebf44f20d3
16 changed files with 6256 additions and 201 deletions
+401 -43
View File
@@ -411,32 +411,69 @@ local function cartColor(version)
return CART_COLOR[version] or PAL.green
end
local function shellColor(hex)
local r, g, b = tostring(hex):match("^#(%x%x)(%x%x)(%x%x)$")
if not r then return nil end
return { tonumber(r, 16), tonumber(g, 16), tonumber(b, 16) }
end
local function cartSkin(imp, version)
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
if not row then
return { cacheKey = version, color = cartColor(version),
labelPath = "assets/labels/" .. tostring(version) .. ".png" }
end
return { cacheKey = "cart:" .. tostring(row.id),
color = shellColor(row.shell) or cartColor(version),
name = row.title, cart = row, cartId = row.id }
end
local CART_DRAG_SLOP = 8
local TAU = math.pi * 2
local function cartridgeState(imp, version)
local function cartridgeState(imp, key)
imp._cartridge = imp._cartridge or {}
local state = imp._cartridge[version]
local state = imp._cartridge[key]
if not state then
state = { spin = 0, lastTime = Kit.time }
imp._cartridge[version] = state
imp._cartridge[key] = state
end
return state
end
local function cartridgeLabel(imp, version)
local function cartLabelImage(cartId)
local CartStore = require("src.carts.CartStore")
local got, bytes = pcall(CartStore.labelArt, cartId)
if not got or type(bytes) ~= "string" or bytes == "" then return nil end
if not (love.filesystem and love.filesystem.newFileData) then return nil end
local made, image = pcall(function()
return love.graphics.newImage(
love.filesystem.newFileData(bytes, tostring(cartId) .. ".png"))
end)
if not made then return nil end
return image
end
local function cartridgeLabel(imp, key, path, cartId)
imp._cartridgeLabels = imp._cartridgeLabels or {}
local label = imp._cartridgeLabels[version]
local label = imp._cartridgeLabels[key]
if label ~= nil then return label or nil end
local ok, image = pcall(love.graphics.newImage,
"assets/labels/" .. tostring(version) .. ".png")
if not ok then
imp._cartridgeLabels[version] = false
local image
if cartId then
image = cartLabelImage(cartId)
elseif path then
local ok, art = pcall(love.graphics.newImage, path)
if ok then image = art end
end
local sized, iw, ih = false, nil, nil
if image then sized, iw, ih = pcall(image.getDimensions, image) end
if not (sized and type(iw) == "number" and type(ih) == "number"
and iw > 0 and ih > 0) then
imp._cartridgeLabels[key] = false
return nil
end
local iw, ih = image:getDimensions()
label = { image = image, width = iw, height = ih }
imp._cartridgeLabels[version] = label
imp._cartridgeLabels[key] = label
return label
end
@@ -492,10 +529,10 @@ local function cartPill(project, x, y, w, h, z, color, alpha)
cartPolygon(points, color, alpha)
end
local function cartLabelMesh(imp, version, label, points)
local function cartLabelMesh(imp, key, label, points)
if not love.graphics.newMesh then return nil end
imp._cartridgeLabelMeshes = imp._cartridgeLabelMeshes or {}
local mesh = imp._cartridgeLabelMeshes[version]
local mesh = imp._cartridgeLabelMeshes[key]
if not mesh then
mesh = love.graphics.newMesh({
{ 0, 0, 0, 0, 255, 255, 255, 255 },
@@ -504,7 +541,7 @@ local function cartLabelMesh(imp, version, label, points)
{ 0, 0, 0, 1, 255, 255, 255, 255 },
}, "fan", "dynamic")
mesh:setTexture(label.image)
imp._cartridgeLabelMeshes[version] = mesh
imp._cartridgeLabelMeshes[key] = mesh
end
mesh:setVertices({
{ points[1][1], points[1][2], 0, 0, 255, 255, 255, 255 },
@@ -564,8 +601,8 @@ local function cartSendHover(shader, mx, my, hovering, screenScale)
return ok
end
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local state = cartridgeState(imp, version)
local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action)
local state = cartridgeState(imp, skin.cacheKey)
markNoDrag(imp, x, y, w, h)
local focused = Kit.focusable(key, x, y, w, h)
local hot = Kit.hover(x, y, w, h)
@@ -640,7 +677,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local ease = math.exp(-60 * dt)
state.visScale = ease * state.visScale + (1 - ease) * desScale
if not state.animId then
local n, s = 0, tostring(version)
local n, s = 0, tostring(skin.cacheKey)
for i = 1, #s do n = n + s:byte(i) * i end
state.animId = n
end
@@ -696,7 +733,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
capRight + halfW, capH, depth)
local capBack = cartQuad(project, -halfW, -halfH,
capRight + halfW, capH, -depth)
local shell = cartColor(version)
local shell = skin.color
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
math.floor(shell[3] * 0.54) }
@@ -798,8 +835,8 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
cartPolygon(plate, side, 0.95)
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
local label = cartridgeLabel(imp, version)
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
local label = cartridgeLabel(imp, skin.cacheKey, skin.labelPath, skin.cartId)
local mesh = label and cartLabelMesh(imp, skin.cacheKey, label, labelPoints)
if mesh then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mesh)
@@ -934,6 +971,35 @@ local function buildModScopeRow(imp, x, y, w, m)
return h + math.floor(8 * m.s)
end
local function buildSaveCartRow(imp, x, y, w, m)
local version = imp.modScope
local h = m.btnH
local gap = math.floor(8 * m.s)
local label = Strings("Save as cart")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(28 * m.s))
local enabled = version ~= nil and imp:_cartCaptureCount(version) > 0
btn(imp, x, y, bw, h, "mods-save-cart", label, {
kind = "accent", font = "small", enabled = enabled,
action = enabled and function() imp:_beginCartSave(version) end or nil })
local hint
if version == nil then
hint = Strings("Pick one game above to save its enabled mods as a cart.")
elseif not enabled then
hint = Strings("Enable a mod for this game first.")
else
local info = GameVersion.info(version)
hint = Strings("Freeze the mods enabled for %s into a cart.",
(info and (info.launcherName or info.displayName)) or tostring(version))
end
local hx = x + bw + gap
local hw = math.max(0, x + w - hx)
if hw > 0 then
Kit.text("small", Kit.ellipsize("small", hint, hw), hx,
y + (h - Kit.textHeight("small")) / 2, PAL.muted)
end
return h + gap
end
local function findActionFor(entry, installedVersion)
local ModIndex = require("src.mods.ModIndex")
if not ModIndex.canInstall(entry) then
@@ -1512,9 +1578,11 @@ local function chipWidth(label, m)
end
local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
imp:_ensureSlots(version)
local slots = imp.slots[version] or {}
local active = imp.activeSlot[version]
local scope = imp.slotScope and imp:slotScope(version) or version
local onCart = scope ~= version
imp:_ensureSlots(scope)
local slots = imp.slots[scope] or {}
local active = imp.activeSlot[scope]
local n = #slots
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
@@ -1570,7 +1638,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local headH = math.max(Kit.textHeight("caption"), m.btnH) + math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local newBtnH = m.btnH
local sfNotice = imp.saveNotice[version]
local sfNotice = imp.saveNotice[scope]
local hintText, hintCol
if sfNotice then
hintText, hintCol = sfNotice.text, (sfNotice.ok and PAL.green or PAL.red)
@@ -1586,7 +1654,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local listH = availH
- (pad * 2 + headH + hintH + pagerH + gap + newBtnH + gap)
local perPage = Kit.rowsThatFit(listH, rowH, gap, 1, 12)
local pageKey = "slots-" .. version
local pageKey = "slots-" .. scope
local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), n, perPage)
setPage(imp, pageKey, cur)
@@ -1603,10 +1671,12 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
local savImportLabel = imp.isNX and Strings("Scan again")
or Strings("Import save")
local impW = chipWidth(savImportLabel, m) + math.floor(8 * m.s)
btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. version,
btn(imp, x + w - pad - impW, cy, impW, m.btnH, "sav-import-" .. scope,
savImportLabel, {
kind = "accent", font = "small", enabled = ready and true or false,
action = ready and function() imp:chooseSaveImport(version) end or nil,
kind = "accent", font = "small",
enabled = (ready and not onCart) and true or false,
action = (ready and not onCart)
and function() imp:chooseSaveImport(version) end or nil,
})
local countW = (x + w - pad - impW - math.floor(8 * m.s))
- (x + pad + Kit.captionWidth(Strings("SAVE SLOT")) + math.floor(8 * m.s))
@@ -1629,10 +1699,10 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
for i = first, last do
local slot = slots[i]
local selected = slot.id == active
local rowKey = "slot-" .. version .. "-" .. slot.id
local rowKey = "slot-" .. scope .. "-" .. slot.id
local ry = cy + (i - first) * (rowH + gap)
local ink = rowHit(imp, x + pad, ry, iw, rowH, selected, rowKey,
function() imp:_selectSlot(version, slot.id) end)
function() imp:_selectSlot(scope, slot.id) end)
local px = x + pad + math.floor(10 * m.s)
local inner = iw - math.floor(20 * m.s)
@@ -1661,6 +1731,9 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
else
metaTxt = Strings("empty slot")
end
if slot.sealBroken then
metaTxt = Strings("%s - seal broken", metaTxt)
end
Kit.text("small", Kit.ellipsize("small", metaTxt, textW), px, ly,
selected and PAL.inverse or PAL.muted)
-- Where the chip block starts: centred on the row beside the text, or
@@ -1673,22 +1746,22 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
-- an export is a property of a slot, so the control belongs on the slot
-- it exports (it selects the row first, since the exporter writes
-- whichever slot is active).
local armed = deleteArmed(imp, "slot", slot.id, version)
local armed = deleteArmed(imp, "slot", slot.id, scope)
local chips = {}
if slot.exists then
if slot.exists and not onCart then
chips[#chips + 1] = { label = Strings("Export"), kind = "accent",
key = rowKey .. "-export",
action = function()
imp:_selectSlot(version, slot.id)
imp:_selectSlot(scope, slot.id)
imp:exportSave(version)
end }
end
if not imp.android then
chips[#chips + 1] = { label = Strings("Rename"), kind = "accent",
key = rowKey .. "-rename",
action = function() imp:_beginRename(version, slot.id) end }
action = function() imp:_beginRename(scope, slot.id) end }
end
if imp.onEditSave and slot.exists then
if imp.onEditSave and slot.exists and not onCart then
chips[#chips + 1] = { label = Strings("Edit"), kind = "accent",
key = rowKey .. "-edit",
action = function() imp.onEditSave(version, slot.id) end }
@@ -1699,8 +1772,8 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
w = math.max(chipWidth(DELETE_LABEL(false), m),
chipWidth(DELETE_LABEL(true), m)),
action = function()
imp:pressDelete("slot", slot.id, version, function()
imp:_deleteSlot(version, slot.id)
imp:pressDelete("slot", slot.id, scope, function()
imp:_deleteSlot(scope, slot.id)
end)
end }
for _, c in ipairs(chips) do c.w = c.w or chipWidth(c.label, m) end
@@ -1729,7 +1802,7 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
cy = cy + Kit.textWrapped("small", hintText, x + pad, cy, iw, hintCol, 2)
if folderRow then
cy = cy + math.floor(4 * m.s)
local key = "sav-folder-" .. version
local key = "sav-folder-" .. scope
local label = Strings("Open folder")
local lw = Kit.textWidth("small", label)
local lh = Kit.textHeight("small")
@@ -1752,19 +1825,101 @@ local function buildSlotCard(imp, x, y, w, availH, m, version, ready)
setPage(imp, pageKey, newPage)
cy = cy + pagerH + gap
end
btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. version,
btn(imp, x + pad, cy, iw, newBtnH, "slot-new-" .. scope,
Strings("+ New save slot"), {
kind = "good",
action = function() imp:_newSlot(version) end,
action = function() imp:_newSlot(scope) end,
})
return h
end
local function SEAL_LABEL(armed)
return armed and Strings("Break it") or Strings("Break the seal")
end
local function sealSlotName(slot)
if not slot then return nil end
if type(slot.label) == "string" and slot.label ~= "" then return slot.label end
return tostring(slot.id):match("^slot(%d+)$") or tostring(slot.id)
end
local function buildCartCard(imp, x, y, w, m, version)
if not imp.cartPlan then return 0 end
local report, slot = imp:cartPlan(version)
if not report then return 0 end
local title = tostring(report.title or report.id or "")
local broken = (slot and slot.sealBroken == true) or false
local state, stateCol, body = nil, PAL.green, {}
if report.refused then
state, stateCol = Strings("This cart will not start"), PAL.red
body[#body + 1] = { report.message, PAL.detail }
body[#body + 1] = { Strings("Break the seal to play it with the mods you have."),
PAL.detail }
elseif broken then
state, stateCol = Strings("Seal broken"), PAL.yellow
body[#body + 1] = { Strings("This save loads the cart's pinned mods first, then your other enabled mods. It is marked modified."),
PAL.detail }
elseif report.sealed then
state = Strings("Sealed - ready to play")
body[#body + 1] = { Strings("This cart loads only the mods it pins."),
PAL.detail }
else
state = Strings("Open cart - ready to play")
body[#body + 1] = { Strings("This cart's pinned mods load first, then your other enabled mods."),
PAL.detail }
end
local scope = imp:slotScope(version)
local offer = report.sealed and not broken
local armed = offer and deleteArmed(imp, "seal", slot and slot.id or nil, scope)
if armed then
local name = sealSlotName(slot)
body[#body + 1] = { name
and Strings("Break the seal on %s, save slot %s?", title, name)
or Strings("Break the seal on %s, on a new save slot?", title),
PAL.yellow }
body[#body + 1] = { Strings("This is permanent and cannot be undone. That save is marked modified from then on."),
PAL.yellow }
body[#body + 1] = { Strings("%s still loads its pinned mods first, with your other enabled mods on top.", title),
PAL.yellow }
body[#body + 1] = { Strings("Press Break it again to do it."), PAL.yellow }
end
local pad = math.floor(14 * m.s)
local iw = w - 2 * pad
local chipH = offer and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
h = h + Kit.wrapHeight("small", line[1] or "", iw, 3)
end
if offer then h = h + math.floor(8 * m.s) + chipH end
h = h + pad
Kit.card(x, y, w, h)
local cy = y + pad
Kit.text("button", Kit.ellipsize("button", state, iw), x + pad, cy, stateCol)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
for _, line in ipairs(body) do
cy = cy + Kit.textWrapped("small", line[1] or "", x + pad, cy, iw,
line[2], 3)
end
if offer then
cy = cy + math.floor(8 * m.s)
local cw = math.max(chipWidth(SEAL_LABEL(false), m),
chipWidth(SEAL_LABEL(true), m))
btn(imp, x + pad, cy, cw, chipH, "seal-" .. scope, SEAL_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function() imp:pressBreakSeal(version) end,
})
end
return h
end
local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
imp.panelVersion = version
local info = GameVersion.info(version)
local locked = info == nil
local gameName = info and (info.launcherName or info.displayName)
local skin = cartSkin(imp, version)
local gameName = skin.name or (info and (info.launcherName or info.displayName))
or tostring(version)
local ready = (not locked) and imp.ready[version] or false
@@ -1842,7 +1997,7 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
local cartW = math.min(cartAreaW, math.floor(playH * 0.88))
local cartX = lx + math.floor((cartAreaW - cartW) / 2)
cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version,
version, gameName, function() imp:play(version, true) end)
skin, gameName, function() imp:play(version, true) end)
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", {
@@ -1850,6 +2005,17 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH)
action = function() imp._gameManage = version end,
})
ly = ly + playH + gap
btn(imp, lx, ly, lw, m.btnH, "carts-" .. version,
Strings("Custom Carts"), {
kind = "accent", font = "small",
action = function()
imp._cartPopup = version
imp._cartNotice = nil
end,
})
ly = ly + m.btnH + gap
local sealH = buildCartCard(imp, lx, ly, lw, m, version)
if sealH > 0 then ly = ly + sealH + gap end
end
-- The ROM card, which now only exists while there is something to report:
@@ -2109,6 +2275,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
+ math.floor(8 * m.s)
cy = cy + buildModScopeRow(imp, x, cy, w, m)
cy = cy + buildSaveCartRow(imp, x, cy, w, m)
if #mods == 0 then
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
@@ -3593,6 +3760,195 @@ local function buildGameModal(imp, m)
action = function() imp._gamePopup = nil end })
end
local function cartRowLabel(row)
local seal = (row.seal == "open") and Strings("open") or Strings("sealed")
return Strings("%s - v%s - %s", tostring(row.title or row.id),
tostring(row.version or "?"), seal)
end
local function buildCartModal(imp, m)
local version = imp._cartPopup
local rows = imp:_ensureCarts(version)
local info = GameVersion.info(version)
local baseName = info and (info.displayName or info.launcherName)
or tostring(version)
local active = imp.activeCart[version]
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(420 * m.s)
local rowH = m.btnH
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local notice = imp._cartNotice
local noticeH = notice
and (Kit.wrapHeight("small", notice, w - 2 * pad, 2) + gap) or 0
local emptyH = (#rows == 0) and (Kit.textHeight("small") + gap) or 0
local fixed = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ noticeH + emptyH + 2 * (rowH + gap) + rowH + pad
local perPage = Kit.rowsThatFit(m.H - 2 * m.pad - fixed, rowH, gap, 1, 8)
local pageKey = "cartpop-" .. tostring(version)
local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), #rows, perPage)
setPage(imp, pageKey, cur)
local shown = math.max(0, last - first + 1)
local h = fixed + shown * (rowH + gap) + (pages > 1 and (pagerH + gap) or 0)
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Custom Carts"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
if notice then
cy = cy + Kit.textWrapped("small", notice, px + pad, cy,
pw - 2 * pad, PAL.detail, 2) + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-vanilla", baseName, {
kind = (active == nil) and "primary" or "ghost", font = "small",
action = function() imp:_selectCart(version, nil) end })
cy = cy + rowH + gap
if #rows == 0 then
Kit.text("small", Strings("No carts installed for this game yet."),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
end
local expGap = math.floor(6 * m.s)
local expW = math.min(chipWidth(Strings("Export"), m),
math.floor((pw - 2 * pad) * 0.35))
for i = first, last do
local row = rows[i]
local rowKey = "cartpop-id-" .. tostring(row.id)
local pickW = pw - 2 * pad - expW - expGap
btn(imp, px + pad, cy, pickW, rowH, rowKey, cartRowLabel(row), {
kind = (active == row.id) and "primary" or "ghost", font = "small",
action = function() imp:_selectCart(version, row.id) end })
btn(imp, px + pad + pickW + expGap, cy, expW, rowH, rowKey .. "-export",
Strings("Export"), { kind = "accent", font = "small",
action = function() imp:exportCart(row.id) end })
cy = cy + rowH + gap
end
if pages > 1 then
setPage(imp, pageKey,
Kit.pager(px + pad, cy, pw - 2 * pad, cur, #rows, perPage, pageKey))
cy = cy + pagerH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-more",
Strings("Get more carts"), { kind = "accent", font = "small",
action = function()
imp._cartNotice = Strings("Browsing for carts arrives in a later update.")
end })
cy = cy + rowH + gap
btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-close",
Strings("Close"), { font = "small",
action = function()
imp._cartPopup = nil
imp._cartNotice = nil
end })
end
local CART_PIN_LINES = 4
local function cartPinLines(pins)
local out = {}
for i = 1, math.min(#pins, CART_PIN_LINES) do
local pin = pins[i]
out[#out + 1] = Strings("%s - %s", tostring(pin.name or pin.id),
tostring(pin.reason or ""))
end
if #pins > CART_PIN_LINES then
out[#out + 1] = Strings("...and %d more.", #pins - CART_PIN_LINES)
end
return out
end
local function buildCartSaveModal(imp, m)
local st = imp._cartSave
local info = GameVersion.info(st.version)
local gameName = (info and (info.launcherName or info.displayName))
or tostring(st.version)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(500 * m.s)
local inner = w - 2 * pad
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local hint = (st.count == 1)
and Strings("This freezes the 1 mod enabled for %s into a cart.", gameName)
or Strings("This freezes the %d mods enabled for %s into a cart.",
st.count, gameName)
local id = imp:_cartSaveId()
local meta = id
and Strings("id %s - v%s - by %s", id, st.cartVersion, st.author)
or Strings("Type a title - the cart id is built from it.")
local pins = st.unresolved or {}
local pinHead = (#pins > 0) and ((#pins == 1)
and Strings("1 mod could only be pinned to this install:")
or Strings("%d mods could only be pinned to this install:", #pins)) or nil
local pinRows = pinHead and cartPinLines(pins) or {}
local share = st.publishable
and Strings("Every mod is pinned to a release, so this cart can be shared.")
or Strings("This cart can be saved and played here while those mods stay installed at these versions, and cannot be shared.")
local shareCol = st.publishable and PAL.green or PAL.yellow
local pinIndent = math.floor(10 * m.s)
local hintH = Kit.wrapHeight("small", hint, inner, 3) + gap
local metaH = Kit.textHeight("micro") + gap
local errH = st.error
and (Kit.wrapHeight("small", st.error, inner, 2) + gap) or 0
local pinH = 0
if pinHead then
pinH = Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
pinH = pinH + Kit.wrapHeight("small", line, inner - pinIndent, 2)
+ math.floor(2 * m.s)
end
pinH = pinH + gap
end
local shareH = Kit.wrapHeight("small", share, inner, 2) + gap
local footH = Kit.textHeight("micro") + math.floor(8 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(10 * m.s) + hintH
+ fieldH + gap + metaH + errH + pinH + shareH + m.btnH + footH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Save as cart"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(10 * m.s)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad,
PAL.detail, 3) + gap
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "cartsave-field",
st.text or "", Strings("Cart title"), true)
cy = cy + fieldH + gap
Kit.text("micro", Kit.ellipsize("micro", meta, pw - 2 * pad), px + pad, cy,
PAL.muted)
cy = cy + Kit.textHeight("micro") + gap
if st.error then
cy = cy + Kit.textWrapped("small", st.error, px + pad, cy, pw - 2 * pad,
PAL.red, 2) + gap
end
if pinHead then
Kit.text("small", Kit.ellipsize("small", pinHead, pw - 2 * pad),
px + pad, cy, PAL.yellow)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
for _, line in ipairs(pinRows) do
cy = cy + Kit.textWrapped("small", line, px + pad + pinIndent, cy,
pw - 2 * pad - pinIndent, PAL.detail, 2) + math.floor(2 * m.s)
end
cy = cy + gap
end
cy = cy + Kit.textWrapped("small", share, px + pad, cy, pw - 2 * pad,
shareCol, 2) + gap
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(8 * m.s))
local okLabel = Strings("Save as cart")
local okW = Kit.textWidth("small", okLabel) + math.floor(28 * m.s)
btn(imp, place(okW), cy, okW, m.btnH, "cartsave-ok", okLabel,
{ kind = "primary", font = "small",
action = function() imp:_commitCartSave() end })
local cw = Kit.textWidth("small", Strings("Cancel")) + math.floor(28 * m.s)
btn(imp, place(cw), cy, cw, m.btnH, "cartsave-cancel", Strings("Cancel"),
{ font = "small", action = function() imp:_cancelCartSave() end })
cy = cy + m.btnH + math.floor(8 * m.s)
Kit.text("micro", Strings("Enter to save - Esc to cancel"), px + pad, cy,
PAL.muted)
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -4962,7 +5318,7 @@ local function modalUp(imp)
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._gamePopup or imp._cartPopup or imp._cartSave
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
@@ -5030,6 +5386,7 @@ local function buildModals(imp, m)
})
return true
end
if imp._cartSave then buildCartSaveModal(imp, m) return true end
if imp._settings then buildSettingsModal(imp, m) return true end
if imp._rename then
buildPrompt(imp, m, {
@@ -5105,6 +5462,7 @@ local function buildModals(imp, m)
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._cartPopup then buildCartModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
+445 -38
View File
@@ -28,6 +28,13 @@ local function pickFile(...)
return fn(...) and true or false
end
local CART_SCOPE = "cart_"
local function cartOfScope(scope)
if type(scope) ~= "string" then return nil end
return scope:match("^cart_(.+)$")
end
-- Cache generation tag; bump to force every imported version to re-extract.
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
-- carry Red's bank $1f header, wave-table, and CryData offsets.
@@ -1492,12 +1499,13 @@ end
-- column with no Play button would read as the launcher losing the import.
-- Called from new() once self.ready is filled, which is what that check needs.
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
self:_restoreActiveCarts(okOpt and opts or nil)
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
local last = okOpt and opts and opts.lastVersion
if last and GameVersion.VERSIONS[last] and self.ready[last] then
self.tab = last
@@ -1507,8 +1515,9 @@ end
-- The launcher runs each GameVersion as an independent tab. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
-- can be imported and played side by side. onComplete(version) hands the
-- chosen game off to boot.
-- can be imported and played side by side. onComplete(version, cartId) hands
-- the chosen game -- and the custom cart its page is showing, if any -- off to
-- boot.
-- opts: launcher (a fresh import stays on the launcher instead of auto-booting),
-- forceImport (treat every version as not-yet-imported, so re-import is forced),
-- onEditSave(version, slotId) (host handler for the Edit affordance on a save
@@ -1582,6 +1591,7 @@ function RomImporter.new(onComplete, opts)
-- any slot mutation); activeSlot drives the LOADED pill; slotScroll is the
-- per-version list scroll offset (px), clamped against content in draw.
slots = {}, activeSlot = {}, slotScroll = {},
carts = {}, activeCart = {},
-- SAVE FILES card state: the last import/export result per version, shown as
-- a green/red notice line under the Import save / Export save buttons. A
-- successful export carries { dir } so the notice can offer an open-folder
@@ -1771,6 +1781,11 @@ function RomImporter:focus(f)
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
if self.androidPendingCartExport then
self.androidPendingCartExport = nil
self._cartNotice = Strings("Cart exported.")
return
end
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
@@ -2605,15 +2620,21 @@ end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
function RomImporter:_deleteSlot(scope, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
local cart = cartOfScope(scope)
local ok, err
if cart then
ok, err = SaveData.deleteCartSlot(cart, id)
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
ok, err = SaveData.deleteSlot(scope, id)
end
if ok then
self:_refreshSlots(scope)
self.saveNotice[scope] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[scope] = { ok = false, text = tostring(err) }
end
end
@@ -3225,17 +3246,30 @@ function RomImporter:play(version, fade)
-- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it
-- with the rest of the launcher's persisted state. A failed write only
-- costs the memory of the choice, so it must never block the boot.
local cartId = self.activeCart and self.activeCart[version] or nil
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.lastVersion = version
local map = self:_activeCartMap()
opts.activeCart = next(map) ~= nil and map or nil
SaveData.saveOptions(opts)
end)
resetPointerCursor(self)
-- The game draws with raw love.graphics from here on; drop the view's
-- element tree and canvases before the handoff.
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version) end
if self.onComplete then self.onComplete(version, cartId) end
end
function RomImporter:_activeCartMap()
local out = {}
for version, id in pairs(self.activeCart or {}) do
if GameVersion.VERSIONS[version] and type(id) == "string" then
out[version] = id
end
end
return out
end
-- "re-import" a column: drop it back to the choose/drop state so a fresh ROM
@@ -4083,6 +4117,17 @@ function RomImporter:keypressed(key)
end
return
end
if self._cartSave then
if key == "backspace" then
self._cartSave.text = utf8Back(self._cartSave.text)
self._cartSave.error = nil
elseif key == "return" or key == "kpenter" then
self:_commitCartSave()
elseif key == "escape" then
self:_cancelCartSave()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -4168,6 +4213,13 @@ function RomImporter:keypressed(key)
end
return
end
if self._cartPopup then
if self._flex and require("src.import.LauncherView").keypressed(self, key) then
return
end
if key == "escape" then self._cartPopup = nil end
return
end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
@@ -4209,21 +4261,349 @@ function RomImporter:keypressed(key)
end
end
end
-- Reload a version's slot list + active id from SaveData (the source of truth).
-- Cheap enough to call on any mutation; the per-frame draw only calls it lazily
-- through _ensureSlots so a still list costs nothing after the first paint.
function RomImporter:_refreshSlots(version)
local SaveData = require("src.core.SaveData")
self.slots[version] = SaveData.listSlots(version) or {}
local opts = SaveData.loadOptions()
local reg = opts.saveSlots and opts.saveSlots[version]
-- fall back to the first slot as the shown "loaded" one when the registry
-- has a list but no explicit active id (matches saveNames' own resolution)
self.activeSlot[version] = reg and (reg.active or reg.list[1]) or nil
function RomImporter:_refreshCarts(version)
local out = {}
local ok, rows = pcall(function()
return require("src.carts.CartStore").index()
end)
if ok and type(rows) == "table" then
for _, row in ipairs(rows) do
if row.base == version then out[#out + 1] = row end
end
end
self.carts[version] = out
return out
end
function RomImporter:_ensureSlots(version)
if not self.slots[version] then self:_refreshSlots(version) end
function RomImporter:_ensureCarts(version)
return self.carts[version] or self:_refreshCarts(version)
end
function RomImporter:_cartById(version, id)
if type(id) ~= "string" then return nil end
for _, row in ipairs(self:_ensureCarts(version)) do
if row.id == id then return row end
end
return nil
end
function RomImporter:activeCartRow(version)
local id = self.activeCart[version]
if not id then return nil end
return self:_cartById(version, id)
end
function RomImporter:slotScope(version)
local id = self.activeCart[version]
if id then return CART_SCOPE .. id end
return version
end
function RomImporter:_selectCart(version, id)
self._cartPopup = nil
self._cartNotice = nil
if id ~= nil and not self:_cartById(version, id) then return end
self.activeCart[version] = id
self._cartPlan = nil
local scope = self:slotScope(version)
self.slots[scope] = nil
self.slotScroll[scope] = nil
if self._pages then self._pages["slots-" .. scope] = 1 end
end
function RomImporter:_cartSealSlot(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil, nil end
local scope = self:slotScope(version)
self:_ensureSlots(scope)
local active = self.activeSlot[scope]
for _, slot in ipairs(self.slots[scope] or {}) do
if slot.id == active then return slot, scope end
end
return nil, scope
end
function RomImporter:cartPlan(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil, nil end
local slot = self:_cartSealSlot(version)
local broken = (slot and slot.sealBroken == true) or false
local key = table.concat(
{ id, tostring(slot and slot.id), tostring(broken) }, "|")
local cached = self._cartPlan
if cached and cached.key == key then return cached.report, slot end
local installed = {}
pcall(function()
local rows = require("src.mods.LauncherMods").list(version) or {}
for _, row in ipairs(rows) do
local manifest = type(row.manifest) == "table" and row.manifest or row
if type(manifest.id) == "string" then
installed[manifest.id] =
{ id = manifest.id, version = manifest.version }
end
end
end)
local ok, cart = pcall(function()
return require("src.carts.CartStore").get(id)
end)
if not ok or type(cart) ~= "table" then cart = nil end
local report = require("src.mods.Loader").planCart(cart, installed, broken)
report.id = id
self._cartPlan = { key = key, report = report }
return report, slot
end
function RomImporter:pressBreakSeal(version)
local slot, scope = self:_cartSealSlot(version)
if not scope then return false end
return self:pressDelete("seal", slot and slot.id or nil, scope, function()
self:breakCartSeal(version)
end)
end
function RomImporter:breakCartSeal(version)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return false end
local SaveData = require("src.core.SaveData")
local scope = self:slotScope(version)
self:_ensureSlots(scope)
local slotId = self.activeSlot[scope]
if type(slotId) ~= "string" then
slotId = SaveData.createCartSlot(id)
if type(slotId) ~= "string" then return false end
SaveData.setActiveCartSlot(id, slotId)
end
local ok = SaveData.markSlotSealBroken(id, slotId)
self:_refreshSlots(scope)
self.activeSlot[scope] = slotId
self._cartPlan = nil
return ok and true or false
end
function RomImporter:_restoreActiveCarts(opts)
local saved = opts and opts.activeCart
if type(saved) ~= "table" then return end
for version, id in pairs(saved) do
if GameVersion.VERSIONS[version] and type(id) == "string"
and self:_cartById(version, id) then
self.activeCart[version] = id
end
end
end
local CART_RAIL = { red = "railRed", blue = "railBlue", yellow = "railGold",
gold = "railAmber", silver = "railSilver" }
local CART_START_VERSION = "1.0.0"
local function cartShellHex(version)
local Theme = require("src.ui.kit.Theme")
local col = Theme.PAL[CART_RAIL[version] or ""] or Theme.PAL.green
return ("#%02x%02x%02x"):format(col[1] or 0, col[2] or 0, col[3] or 0)
end
local function cartIdFromTitle(title)
local CartManifest = require("src.carts.CartManifest")
local id = tostring(title or ""):lower():gsub("[^%w]+", "_")
id = id:sub(1, CartManifest.MAX_ID):gsub("^_+", ""):gsub("_+$", "")
if id == "" then return nil end
return id
end
local function cartModRows(imp, version)
local LauncherMods = require("src.mods.LauncherMods")
local rows, kept = LauncherMods.list(version) or {}, {}
for _, row in ipairs(rows) do
if row.targetsHere ~= false then kept[#kept + 1] = row end
end
return kept
end
function RomImporter:_cartCaptureCount(version)
if not GameVersion.VERSIONS[version] then return 0 end
local n = 0
for _, row in ipairs(cartModRows(self, version)) do
if row.enabled then n = n + 1 end
end
return n
end
function RomImporter:_cartAuthor()
local SaveData = require("src.core.SaveData")
local ok, opts = pcall(SaveData.loadOptions)
local sync = ok and type(opts) == "table" and opts.saveSync or nil
local label = type(sync) == "table" and sync.deviceLabel or nil
if type(label) == "string" and label:match("%S") then return label end
return Strings("Unknown")
end
function RomImporter:_cartSaveId()
local st = self._cartSave
if not st then return nil end
return cartIdFromTitle(st.text)
end
local function cartIdentity(st, id, title)
return { id = id, title = title, version = st.cartVersion,
author = st.author, base = st.version,
shell = st.shell, seal = "sealed" }
end
function RomImporter:_beginCartSave(version)
if not GameVersion.VERSIONS[version] then return end
local LauncherMods = require("src.mods.LauncherMods")
local CartStore = require("src.carts.CartStore")
local st = {
version = version, text = "", author = self:_cartAuthor(),
cartVersion = CART_START_VERSION, shell = cartShellHex(version),
mods = cartModRows(self, version),
modOptions = LauncherMods.modOptions(),
unresolved = {}, publishable = false,
}
st.count = 0
for _, row in ipairs(st.mods) do
if row.enabled then st.count = st.count + 1 end
end
local cart, unresolved = CartStore.capture(
cartIdentity(st, "preview", "Preview"), st.mods, st.modOptions)
if cart then
st.unresolved = unresolved or {}
local CartManifest = require("src.carts.CartManifest")
st.publishable = CartManifest.publishable(cart) and true or false
else
st.error = tostring(unresolved)
end
self._cartSave = st
self:_armTextInput()
end
function RomImporter:_cancelCartSave()
self._cartSave = nil
self:_disarmTextInput()
end
function RomImporter:_commitCartSave()
local st = self._cartSave
if not st then return end
local CartManifest = require("src.carts.CartManifest")
local CartStore = require("src.carts.CartStore")
local title = tostring(st.text or ""):match("^%s*(.-)%s*$")
if title == "" then
st.error = Strings("Type a title for this cart.")
return
end
local id = cartIdFromTitle(title)
if not id then
st.error = Strings("That title has no letters or numbers to build an id from.")
return
end
local existing = CartStore.get(id)
if existing then
st.error = Strings("%s already uses the id %s. Pick a different title.",
tostring(existing.title), id)
return
end
local cart, unresolved = CartStore.capture(
cartIdentity(st, id, title), st.mods, st.modOptions)
if not cart then
st.error = tostring(unresolved)
return
end
st.unresolved = unresolved or {}
st.publishable = CartManifest.publishable(cart) and true or false
local ok, err = CartStore.install(CartManifest.encode(cart))
if not ok then
st.error = tostring(err)
return
end
local version = st.version
self._cartSave = nil
self:_disarmTextInput()
self:_refreshCarts(version)
self._cartPopup = version
self._cartNotice = Strings("Saved %s. It is in this list now.", title)
end
function RomImporter:exportCart(id)
if self.workState == "working" then return end
local CartStore = require("src.carts.CartStore")
local SaveData = require("src.core.SaveData")
local bytes, err = CartStore.export(id)
if type(bytes) ~= "string" then
self._cartNotice = tostring(err or "that cart could not be read")
return
end
local fs = SaveData.portableFs() or (love and love.filesystem)
if not (fs and fs.write) then
self._cartNotice = Strings("No filesystem available to export to.")
return
end
if fs.createDirectory then
fs.createDirectory("exports")
fs.createDirectory("exports/carts")
end
local rel = "exports/carts/" .. id .. CartStore.EXT
local wrote, writeErr = fs.write(rel, bytes)
if not wrote then
self._cartNotice = Strings("Could not write the export: %s", tostring(writeErr))
return
end
local abs, portableBase = rel, SaveData.portableBaseDir()
if portableBase then
local sep = package.config:sub(1, 1)
abs = portableBase .. sep .. rel:gsub("/", sep)
else
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then abs = base .. "/" .. rel end
end
if self.isNX then
local hint = RomImporter.mtpHintPath(love.filesystem.getSaveDirectory())
if hint ~= "" and hint:sub(-1) ~= "/" then hint = hint .. "/" end
self._cartNotice =
Strings("Exported to %s\nDBI MTP → 1: SD Card/%sexports/carts/", abs, hint)
return
end
if self.android then
local suggested = id .. CartStore.EXT
local staged = love.filesystem.write("pending_export.sav", bytes)
if staged and love.system.createFile
and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
self.pickPending = true
self.pickTimer = 0
self.androidPendingCartExport = true
self._cartNotice = Strings("Pick where to save %s...", suggested)
else
self._cartNotice = Strings("Exported inside the app folder: %s", abs)
end
return
end
self._cartNotice = Strings("Exported to %s", abs)
end
-- Reload a scope's slot list + active id from SaveData (the source of truth).
-- Cheap enough to call on any mutation; the per-frame draw only calls it lazily
-- through _ensureSlots so a still list costs nothing after the first paint.
function RomImporter:_refreshSlots(scope)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(scope)
if cart then
self.slots[scope] = SaveData.listCartSlots(cart) or {}
local opts = SaveData.loadOptions()
local reg = opts.cartSlots and opts.cartSlots[cart]
self.activeSlot[scope] =
reg and (reg.active or (reg.list and reg.list[1])) or nil
return
end
self.slots[scope] = SaveData.listSlots(scope) or {}
local opts = SaveData.loadOptions()
local reg = opts.saveSlots and opts.saveSlots[scope]
-- fall back to the first slot as the shown "loaded" one when the registry
-- has a list but no explicit active id (matches saveNames' own resolution)
self.activeSlot[scope] = reg and (reg.active or reg.list[1]) or nil
end
function RomImporter:_ensureSlots(scope)
if not self.slots[scope] then self:_refreshSlots(scope) end
end
-- The host calls this when the save editor closes: the edited slot's player
@@ -4235,9 +4615,15 @@ end
-- Point the active slot at id (persisted immediately, per the contract) and
-- reflect it in the LOADED pill without a full relist.
function RomImporter:_selectSlot(version, id)
require("src.core.SaveData").setActiveSlot(version, id)
self.activeSlot[version] = id
function RomImporter:_selectSlot(scope, id)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(scope)
if cart then
SaveData.setActiveCartSlot(cart, id)
else
SaveData.setActiveSlot(scope, id)
end
self.activeSlot[scope] = id
end
-- Inline slot rename (#205): right-click arms a modal text field; Enter
@@ -4282,12 +4668,12 @@ function RomImporter:_blurPanelFields()
self:_disarmTextInput()
end
function RomImporter:_beginRename(version, id)
function RomImporter:_beginRename(scope, id)
local label
for _, slot in ipairs(self.slots[version] or {}) do
for _, slot in ipairs(self.slots[scope] or {}) do
if slot.id == id then label = slot.label break end
end
self._rename = { version = version, id = id, text = label or "" }
self._rename = { version = scope, id = id, text = label or "" }
self._slotPress = nil -- cancel any armed click/drag on the list
self:_armTextInput()
end
@@ -4297,7 +4683,13 @@ function RomImporter:_commitRename()
if not r then return end
self._rename = nil
self:_disarmTextInput()
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
local SaveData = require("src.core.SaveData")
local cart = cartOfScope(r.version)
if cart then
SaveData.renameCartSlot(cart, r.id, r.text)
else
SaveData.renameSlot(r.version, r.id, r.text)
end
self:_refreshSlots(r.version)
end
@@ -4314,6 +4706,13 @@ function RomImporter:textinput(text)
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._cartSave then
local CartManifest = require("src.carts.CartManifest")
self._cartSave.text =
utf8Cap(self._cartSave.text .. text, CartManifest.MAX_TITLE)
self._cartSave.error = nil
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -4355,13 +4754,20 @@ end
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
-- scroll to the bottom (clamped next draw) so the new row is on screen.
function RomImporter:_newSlot(version)
function RomImporter:_newSlot(scope)
local SaveData = require("src.core.SaveData")
local id = SaveData.createSlot(version)
SaveData.setActiveSlot(version, id)
self:_refreshSlots(version)
self.activeSlot[version] = id
self.slotScroll[version] = math.huge
local cart = cartOfScope(scope)
local id
if cart then
id = SaveData.createCartSlot(cart)
if id then SaveData.setActiveCartSlot(cart, id) end
else
id = SaveData.createSlot(scope)
SaveData.setActiveSlot(scope, id)
end
self:_refreshSlots(scope)
self.activeSlot[scope] = id
self.slotScroll[scope] = math.huge
end
-- Mouse wheel: forwarded into the FlexLove view (installed onto the global
@@ -4379,6 +4785,7 @@ end
function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self._cartPlan = nil
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install