diff --git a/docs/modding.md b/docs/modding.md index d42f4418..f0286579 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -208,7 +208,9 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine version is compatibility metadata; physical launcher-slot and path identity stays -private. +private. A title-selected context may additionally contain `normalSavedAt`, the +validated matching ordinary-save chronology only; it never exposes normal-save +progress or a slot/path handle. At the title screen only, `mod.storage:selected(game)` returns a bound storage facade for the launcher-selected existing playthrough, or `nil, code, message`. diff --git a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md index a15a4acd..69abedd4 100644 --- a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md +++ b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md @@ -36,6 +36,9 @@ selected existing game-version/playthrough and the calling mod id. It neither accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or another mod namespace. Resolution is read-only; no selected mapping means `no_selected_playthrough`, and opening a title browser never mints an identity. +Its detached context can include only `normalSavedAt` from a matching ordinary +save, so title tools can apply their own resume policy without receiving the +canonical normal-save record. ### `mod.checkpoints:resume(game, checkpoint)` diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index efb29deb..93b43a06 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -630,6 +630,15 @@ local function tryMigrateLegacy(version, fs) local opts = SaveData.loadOptions(fs) opts.saveSlots = opts.saveSlots or {} opts.saveSlots[version] = { 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] + 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 + end SaveData.saveOptions(opts, fs) return id end @@ -655,9 +664,9 @@ 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. -function saveNames(version) +function saveNames(version, injectedFs) version = version or GameVersion.get() - local fs = persistFs(nil) + local fs = persistFs(injectedFs) ensureVersionSlots(version, fs) local slot = activeSlotCache[version] if slot then return slotNames(version, slot) end @@ -984,9 +993,13 @@ function SaveData.selectedPlaythroughId(save, injectedFs) local id = save.meta and save.meta.playthroughId if type(id) == "string" and id ~= "" then return id end + -- 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 opts = SaveData.loadOptions(injectedFs) local byVersion = opts.playthroughIds and opts.playthroughIds[version] - id = byVersion and byVersion[playthroughScope(version, injectedFs)] or nil + id = byVersion and byVersion[scope] or nil if type(id) ~= "string" or id == "" then return nil, "no_selected_playthrough", "The selected playthrough has no durable tool state." @@ -994,6 +1007,34 @@ function SaveData.selectedPlaythroughId(save, injectedFs) return id end +-- Read only the chronology of the ordinary selected save for title tools. +-- This intentionally returns no canonical progress, slot id, path, or raw +-- save handle. A legacy pre-id normal save is valid when the selected scope's +-- engine-owned mapping identifies it; a stamped id must match exactly. +function SaveData.selectedNormalSaveInfo(save, injectedFs) + local playthroughId, code, message = SaveData.selectedPlaythroughId(save, injectedFs) + if not playthroughId then return nil, code, message end + local version = save and (save.version or GameVersion.get()) + local fs = persistFs(injectedFs) + local main, backup, staged = saveNames(version, injectedFs) + local normal = readTable(fs, main) + or readTable(fs, staged) + or readTable(fs, backup) + if type(normal) ~= "table" or normal.version ~= version then + return { savedAt = nil } + end + local normalId = normal.meta and normal.meta.playthroughId + if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then + return { savedAt = nil } + end + local savedAt = normal.meta and normal.meta.savedAt + if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt + or savedAt == math.huge or savedAt == -math.huge then + savedAt = nil + end + return { savedAt = savedAt } +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index 253e92ef..31456566 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -116,12 +116,20 @@ function Storage:selected(game) gameVersion = version, playthroughId = playthroughId, } + local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs) + if type(normal) == "table" and normal.savedAt ~= nil then + context.normalSavedAt = normal.savedAt + end return { - context = function() return { - engineVersion = context.engineVersion, - gameVersion = context.gameVersion, - playthroughId = context.playthroughId, - } end, + context = function() + local copy = { + engineVersion = context.engineVersion, + gameVersion = context.gameVersion, + playthroughId = context.playthroughId, + } + if context.normalSavedAt ~= nil then copy.normalSavedAt = context.normalSavedAt end + return copy + end, read = function(_, key) return self:read(selectedGame, key) end, write = function(_, key, value) return self:write(selectedGame, key, value) end, list = function(_, prefix) return self:list(selectedGame, prefix) end, diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index 85df744c..7bc21835 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -13,6 +13,7 @@ local T = require("tests.harness").suite("mod title playthrough context") local Loader = require("src.mods.Loader") local Runtime = require("src.mods.Runtime") local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") local Version = require("src.core.Version") local GameMethods = require("src.core.Game") local StateStack = require("src.core.StateStack") @@ -227,6 +228,28 @@ if type(storage) == "table" then "failed title reconstruction never writes a normal Pokémon save") end + -- A title policy may compare its own durable checkpoint chronology with the + -- ordinary CONTINUE target, but it must never receive that save's contents, + -- slot path, or a way to open another playthrough. This fixture writes the + -- canonical normal save directly to model an already-completed vanilla SAVE. + active.save.meta.savedAt = 4321 + fs.write("save.lua", SaveSerializer.encode(active.save)) + SaveData.resetSlotState() + local titleWithNormalSave = { + save = SaveData.newGame({ version = "red" }), + stack = { states = { { screenId = "TitleState" } } }, + } + local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave) + T.check(type(selectedWithNormal) == "table", + "legacy-to-slot migration keeps the selected playthrough identity: " + .. tostring(normalCode or normalMessage)) + if type(selectedWithNormal) == "table" then + T.eq(selectedWithNormal:context().normalSavedAt, 4321, + "title selected context exposes only matching normal-save chronology") + end + T.check(titleWithNormalSave.save.meta.playthroughId == nil, + "normal-save chronology lookup does not bind the fresh title skeleton") + local explicitNewGame = SaveData.newGame({ version = "red" }) local freshContext = storage:context({ save = explicitNewGame }) T.check(freshContext and freshContext.playthroughId ~= originalId,