Gen 2: a cart saves into its own scope, not the base game's

A cart's saves are keyed by cart id, not by version.  SaveData resolves
every path through activeScopeKey, which answers cart_<id> while one is
active, and the launcher lists, creates and selects a cart's slots from
the cartSlots registry (RomImporter._refreshSlots / _selectSlot /
_newSlot).

src/core/gen2/Save.lua asked in the version's name alone.  saveNames
built saves/<version>/<slot>.lua or save_<suffix>.lua from the version in
both branches and never consulted the active cart, so a cart on Gold,
Silver or Crystal read and wrote the BASE GAME's playthrough.  Gen 1 was
unaffected because it saves through SaveData itself, which is already
cart-scoped -- so this only showed on a Gen 2 cart.

It was worse than sharing one file.  Save.save opens by asking
activeSlot(version) and, on nil, calling createSlot + setActiveSlot in
the version's name, so the first save inside a cart registered a slot in
the base game's registry and made it active: the cart's playthrough
appeared in the launcher's list for the base version, and the player's
own save there was what the cart then overwrote.

Both sites now resolve the cart scope the way SaveData does, and the
names they build are SaveData's own -- slotDir's saves/cart_<id>/ and
legacyNames' save_cart_<id>.lua -- so the in-game save layer and the
launcher land on one file again.

tests/gen2_save_test.lua covers the cart's flat name, its slot name, the
slot going into the cart's registry rather than the base game's, the
cart's scope winning over a base slot, and the base game keeping its own
once the cart is cleared.  Four of them fail on the unpatched module.

Reported as "when I select Wild Crystal to launch it loads my save from
regular Crystal".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-09-05 17:34:20 +00:00
parent fdd1d61ea6
commit 5920402de4
2 changed files with 93 additions and 4 deletions
+39 -4
View File
@@ -118,13 +118,34 @@ local function saveNames(version)
-- no save and dropped CONTINUE (and a following SAVE wrote a second copy to
-- the flat path the launcher no longer looks at). Reading through the same
-- slot resolution keeps the in-game load/save and the launcher on one file.
--
-- Resolve the SCOPE the same way as well. A cart's saves are keyed by cart
-- id rather than by version: SaveData resolves every path through
-- `activeScopeKey`, which answers `cart_<id>` while one is active, and the
-- launcher lists, creates and selects a cart's slots from the `cartSlots`
-- registry (RomImporter._refreshSlots / _selectSlot / _newSlot). This
-- module asked in the version's name alone, so a Gen 2 cart read and wrote
-- the BASE GAME's playthrough. Gen 1 was unaffected because it saves
-- through SaveData, which is already cart-scoped.
local ok, SaveData = pcall(require, "src.core.SaveData")
local slot = ok and SaveData.activeSlot and SaveData.activeSlot(version) or nil
SaveData = ok and SaveData or nil
local cart = SaveData and SaveData.getCart and SaveData.getCart() or nil
if type(cart) ~= "string" or cart == "" then cart = nil end
local slot
if cart then
slot = SaveData.activeCartSlot and SaveData.activeCartSlot(cart) or nil
elseif SaveData then
slot = SaveData.activeSlot and SaveData.activeSlot(version) or nil
end
if slot then
local main = "saves/" .. version .. "/" .. slot .. ".lua"
local scope = cart and ("cart_" .. cart) or version
local main = "saves/" .. scope .. "/" .. slot .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
-- SaveData.legacyNames names a cart "save_cart_<id>.lua" and a version by
-- its own suffix; this is that, so the two ends agree before slots exist.
local suffix = cart and ("_cart_" .. cart) or GameVersion.saveSuffix(version)
local main = "save" .. suffix .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
@@ -911,7 +932,21 @@ function Save.save(save)
local version = save.version
do
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
-- In the CART's registry when one is active, for the same reason
-- saveNames resolves the cart's scope: otherwise the first save inside a
-- cart registers a slot against the base game and makes it active, so the
-- cart's playthrough appears in the launcher's list for the base version
-- and the player's own save there is what it overwrites.
local cart = ok and SaveData.getCart and SaveData.getCart() or nil
if type(cart) ~= "string" or cart == "" then cart = nil end
if ok and cart then
if SaveData.activeCartSlot and not SaveData.activeCartSlot(cart) then
local id = SaveData.createCartSlot and SaveData.createCartSlot(cart)
if id and SaveData.setActiveCartSlot then
SaveData.setActiveCartSlot(cart, id)
end
end
elseif ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
local id = SaveData.createSlot and SaveData.createSlot(version)
if id and SaveData.setActiveSlot then
SaveData.setActiveSlot(version, id)
+54
View File
@@ -59,6 +59,60 @@ check("silver suffix", GameVersion.saveSuffix("silver"), "_silver")
-- Red keeps its historical un-suffixed name, so the two can never collide.
check("red is unsuffixed", GameVersion.saveSuffix("red"), "")
-- --------------------------------------------------------------- cart scope
--
-- A cart's saves are keyed by cart id rather than by version. SaveData
-- resolves every path that way through `activeScopeKey`, and the launcher
-- lists, creates and selects a cart's slots from the `cartSlots` registry --
-- so this module has to ask in the same scope, or a Gen 2 cart reads and
-- writes the BASE GAME's playthrough. Gen 1 never showed it because Gen 1
-- saves through SaveData itself.
do
local SaveData = require("src.core.SaveData")
local CART = "wild_crystal_nightly"
SaveData.setCart(CART, "hash")
-- Before the cart has a slot of its own: SaveData.legacyNames' cart shape.
check("a cart's flat save", (Save.filenames("gold")),
"save_cart_" .. CART .. ".lua")
check("its backup", (select(2, Save.filenames("gold"))),
"save_cart_" .. CART .. ".lua.bak")
-- Save.save registers a slot when the scope has none, and in a cart that
-- has to be the CART's registry: otherwise the cart's playthrough turns up
-- in the launcher's list for the base version, and the player's own save
-- there is what the cart then overwrites.
Save.save(Save.newGame({ playerName = "GOLD" }))
local opts = SaveData.loadOptions()
check("no phantom slot against the base game",
opts.saveSlots and opts.saveSlots.gold, nil)
local reg = opts.cartSlots and opts.cartSlots[CART]
local slot = reg and reg.list and reg.list[1]
check("one slot in the cart's own registry", reg and #reg.list, 1)
-- And it resolves to SaveData.slotDir's cart shape, which is exactly where
-- the launcher's cart slot list looks for it.
check("a cart's slot save", (Save.filenames("gold")),
"saves/cart_" .. CART .. "/" .. slot .. ".lua")
-- A slot the base game has of its own does not win over the cart's, and the
-- cart's does not follow the base game back out of the cart.
local baseSlot = SaveData.createSlot("gold")
SaveData.setActiveSlot("gold", baseSlot)
check("the cart's scope still wins", (Save.filenames("gold")),
"saves/cart_" .. CART .. "/" .. slot .. ".lua")
SaveData.setCart(nil)
check("and the base game keeps its own", (Save.filenames("gold")),
"saves/gold/" .. baseSlot .. ".lua")
-- Leave no registry behind: the checks below expect the flat name, which is
-- what a scope with no slot registered resolves to.
SaveData.deleteCartSlot(CART, slot)
SaveData.deleteSlot("gold", baseSlot)
SaveData.resetSlotState()
check("nothing is left registered", (Save.filenames("gold")), "save_gold.lua")
end
-- ----------------------------------------------------------------- new game
local fresh = Save.newGame({ playerName = "GOLD", rivalName = "SILVER" })