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)