From b8138ef850e64ce69b15130dbe20aa2a800de827 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Mon, 10 Aug 2026 14:23:10 +0200 Subject: [PATCH] feat(mods): resume selected checkpoints from title --- docs/modding.md | 20 +- ...006-title-playthrough-checkpoint-resume.md | 70 ++++++ src/core/Checkpoint.lua | 86 +++++++- src/core/SaveData.lua | 27 +++ src/mods/Loader.lua | 4 + src/mods/Storage.lua | 50 +++++ .../cases/title_playthrough_context.lua | 199 ++++++++++++++++++ 7 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/0006-title-playthrough-checkpoint-resume.md create mode 100644 tests/modkit/cases/title_playthrough_context.lua diff --git a/docs/modding.md b/docs/modding.md index b13fb048..d42f4418 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -210,6 +210,15 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") version is compatibility metadata; physical launcher-slot and path identity stays private. +At the title screen only, `mod.storage:selected(game)` returns a bound storage +facade for the launcher-selected existing playthrough, or `nil, code, message`. +Resolving this facade is read-only: it never allocates an identity, adopts a +fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`, +`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same +data-only and transaction contract as `mod.storage`, but remain restricted to +the calling mod's selected existing namespace. It is intended for title tools +that need to browse or manage durable history before the first normal SAVE. + Values must be tables containing serializable data only. Keys are conservative slash-separated segments (letters, digits, `_`, `-`); paths and filesystem handles are never exposed. Writes are staged and decode-verified, reads recover @@ -270,7 +279,16 @@ private state. A mod that deliberately stores progress-coupled truth in cannot distinguish it safely from independent history, configuration, or cache data. -See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes. +`mod.checkpoints:resume(game, checkpoint)` is the title-session counterpart to +live `restore`. It validates the same data-only checkpoint against the +engine-selected existing playthrough, reconstructs only after all validation +passes, preserves current options, and verifies by recapture. A title session +has no live gameplay rollback state: if reconstruction or verification fails, +the engine rebuilds a usable title session and returns `false, code, message`. +It never writes a normal Pokémon save. It is unavailable outside title and does +not broaden capture or arbitrary-frame support. +See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error +codes. ## Developer console diff --git a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md new file mode 100644 index 00000000..a15a4acd --- /dev/null +++ b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md @@ -0,0 +1,70 @@ +# RFC 0006 — Selected title playthrough storage and checkpoint resume + +## Status + +Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and +`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage, +checkpoint, title, save-slot, and no-mod parity suites. + +## Motivation + +A tool checkpoint may be the first durable record of a new playthrough. The +engine intentionally keeps normal Pokémon SAVE independent: before the first +normal write, identity is retained by the engine-owned selected-slot mapping, +while title starts with a fresh New Game skeleton. Calling ordinary active +`mod.storage` there would allocate/adopt an identity, and live +`mod.checkpoints:restore` correctly refuses title because it has no gameplay +rollback state. A generic title capability is required; a tool must not use +private storage paths, slot ids, or a hidden normal SAVE. + +## Additive public API + +### `mod.storage:selected(game)` + +Available only while the engine is in a title session. Returns an opaque bound +facade or `nil, code, message`: + +```lua +local selected = mod.storage:selected(game) +local context = selected:context() +local history = selected:read("history/index") +``` + +The facade exposes `context()`, `read(key)`, `write(key, value)`, +`list(prefix)`, and `delete(key)`. It is bound internally to the launcher- +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. + +### `mod.checkpoints:resume(game, checkpoint)` + +Available only from title. It validates format, data-only structure, selected +game/playthrough identity, canonical save/content, overworld/battle runtime, and +RNG exactly as `restore` does. It then reconstructs semantic overworld or a +supported battle continuation, preserves current options, and differentially +recaptures before committing. On success it emits `checkpoint.restored` once. + +Title has no live runtime rollback. A reconstruction or verification failure +therefore rebuilds a clean title session from the pre-operation title save and +RNG; it emits no success event and never writes normal progress. Validation +failure leaves the existing title session untouched. Stable errors include +`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes, +`resume_failed`, and `title_recovery_failed`. + +## Isolation and migration + +Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a +previous selected mapping and cannot see old tool history. Existing mods change +nothing: no identity, storage, title reconstruction, or event is created unless +the new methods are called. `mod.storage` remains independent durable data and +does not rewind with a checkpoint; canonical `game.save` / `mod.save` does. + +## Verification + +The public SDK test starts a fresh playthrough, stores tool history without a +normal SAVE, simulates title/restart, reads the selected binding without +allocating title identity, resumes an overworld checkpoint, preserves options, +performs no normal SAVE, differentially recaptures, and confirms a later +explicit NEW GAME receives another identity. Existing no-mod, storage, +checkpoint, battle, and title suites prove additive parity. diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index e6a3b036..30d3c579 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -246,7 +246,7 @@ end local FACINGS = { up = true, down = true, left = true, right = true } -local function validate(game, checkpoint) +local function validate(game, checkpoint, expectedIdentity) if type(checkpoint) ~= "table" then return nil, "invalid_checkpoint", "Checkpoint root must be a table." end @@ -264,13 +264,16 @@ local function validate(game, checkpoint) end local identity = copy.identity local current = game and game.save - local currentId = current and current.meta and current.meta.playthroughId + local currentId = expectedIdentity and expectedIdentity.playthroughId + or (current and current.meta and current.meta.playthroughId) + local currentVersion = expectedIdentity and expectedIdentity.gameVersion + or (current and current.version) if type(identity) ~= "table" or type(identity.engineVersion) ~= "string" or type(identity.gameVersion) ~= "string" or type(identity.playthroughId) ~= "string" then return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." end - if identity.gameVersion ~= current.version then + if identity.gameVersion ~= currentVersion then return nil, "wrong_game", "Checkpoint belongs to another game version." end if identity.playthroughId ~= currentId then @@ -421,4 +424,81 @@ function Checkpoint.restore(game, checkpoint) return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err) end +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then return false end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then return true end + end + return false +end + +local function emitRestored(game, checkpoint) + if ModRuntime.wants("checkpoint.restored") then + ModRuntime.emit("checkpoint.restored", { game = game, kind = checkpoint.kind }) + end +end + +local function rebuildTitle(game, savedTitle, rng) + local save, err = dataCopy(savedTitle) + if not save then error("title rollback decode failed: " .. tostring(err), 0) end + game.save = save + if type(game.adoptSave) == "function" then game:adoptSave(save) end + restoreRng(rng) + if not (game.stack and game.stack.top and game.stack.pop and game.stack.push + and type(game.makeTitleState) == "function") then + error("title recovery is unavailable", 0) + end + while game.stack:top() do game.stack:pop() end + game.stack:push(game:makeTitleState()) +end + +-- Reconstruct a validated persistent checkpoint from the title session. This +-- is intentionally separate from restore(): title has no live gameplay state +-- to capture for rollback. Validation happens before any mutation; a failed +-- reconstruction rebuilds a fresh usable title session instead of exposing a +-- half-installed overworld or battle. +function Checkpoint.resume(game, checkpoint) + if not isTitleSession(game) then + return false, "not_at_title", + "Checkpoint resume is available only from the title session." + end + local save = game and game.save + local playthroughId, identityCode, identityMessage = + SaveData.selectedPlaythroughId(save) + if type(playthroughId) ~= "string" or playthroughId == "" then + return false, identityCode, identityMessage + end + local expected = { gameVersion = save and save.version, playthroughId = playthroughId } + local validated, code, message = validate(game, checkpoint, expected) + if not validated then return false, code, message end + + local titleSave, titleErr = dataCopy(save) + if not titleSave then + return false, "title_recovery_unavailable", + "Could not preserve the title session: " .. tostring(titleErr) + end + local titleRng = captureRng() + local currentOptions = save.options + local ok, err = pcall(apply, game, validated, currentOptions) + if ok then + local restored, verifyCode = Checkpoint.capture(game) + if restored and validated.rng == nil then restored.rng = nil end + if restored and equalData(restored, validated) then + emitRestored(game, validated) + return true + end + err = restored and ("resumed state differed at " + .. tostring(firstDifference(validated, restored) or "canonical encoding")) + or ("resumed state could not be captured: " .. tostring(verifyCode)) + end + + local recovered, recoveryErr = pcall(rebuildTitle, game, titleSave, titleRng) + if not recovered then + return false, "title_recovery_failed", + "Checkpoint resume failed and title recovery failed: " .. tostring(recoveryErr) + end + return false, "resume_failed", "Checkpoint resume failed: " .. tostring(err) +end + return Checkpoint diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 4b8511bf..efb29deb 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -967,6 +967,33 @@ function SaveData.ensurePlaythroughId(save, injectedFs) return id end +-- Resolve the already-selected playthrough without changing the supplied save +-- or allocating a replacement id. Title tools use this before a normal SAVE: +-- Game:load intentionally owns a fresh skeleton there, while the selected +-- launcher slot's durable tool data remains bound by the engine-owned mapping. +-- This does not make arbitrary identities addressable; callers still need a +-- higher-level engine capability that decides when selected resolution is safe. +function SaveData.selectedPlaythroughId(save, injectedFs) + if type(save) ~= "table" then + return nil, "not_in_playthrough", "No selected playthrough is available." + end + local version = save.version or GameVersion.get() + if not knownVersion(version) then + return nil, "unknown_game", "The selected game version is unavailable." + end + local id = save.meta and save.meta.playthroughId + if type(id) == "string" and id ~= "" then return id end + + local opts = SaveData.loadOptions(injectedFs) + local byVersion = opts.playthroughIds and opts.playthroughIds[version] + id = byVersion and byVersion[playthroughScope(version, injectedFs)] or nil + if type(id) ~= "string" or id == "" then + return nil, "no_selected_playthrough", + "The selected playthrough has no durable tool state." + end + return id +end + -- ------- meta -- the version/engine/mod-set stamp every v2 save carries; mods is the diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index fa6f6a34..aca7027a 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -666,6 +666,7 @@ function Loader:_api(mod) -- callers never receive paths or a raw filesystem handle. storage = { context = function(_, game) return storage:context(game) end, + selected = function(_, game) return storage:selected(game) end, write = function(_, game, key, value) return storage:write(game, key, value) end, read = function(_, game, key) return storage:read(game, key) end, list = function(_, game, prefix) return storage:list(game, prefix) end, @@ -679,6 +680,9 @@ function Loader:_api(mod) restore = function(_, game, checkpoint) return Checkpoint.restore(game, checkpoint) end, + resume = function(_, game, checkpoint) + return Checkpoint.resume(game, checkpoint) + end, }, options = { define = function(_, schema) diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua index e529106c..253e92ef 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -79,6 +79,56 @@ function Storage:_scope(game) base = base, fs = fs } end +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then return false end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then return true end + end + return false +end + +-- Bind this mod only to the engine-selected existing playthrough while the +-- title session is active. Unlike _scope this must never allocate an identity: +-- browsing history before the first normal SAVE is a read of durable state, +-- not the start of a New Game. The returned facade closes over its private +-- proxy game, so callers cannot substitute another playthrough id or path. +function Storage:selected(game) + if not isTitleSession(game) then + return failure("not_at_title", + "Selected playthrough storage is available only from the title session.") + end + local save = game and game.save + local version = save and save.version + if not validSegment(version) then + return failure("not_in_playthrough", + "The title session has no selected game version.") + end + local playthroughId, code, message = + SaveData.selectedPlaythroughId(save, self.injectedFs) + if not validSegment(playthroughId) then return failure(code, message) end + + local selectedGame = { + save = { version = version, meta = { playthroughId = playthroughId } }, + } + local context = { + engineVersion = Version.engine, + gameVersion = version, + playthroughId = playthroughId, + } + return { + context = function() return { + engineVersion = context.engineVersion, + gameVersion = context.gameVersion, + playthroughId = context.playthroughId, + } 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, + delete = function(_, key) return self:delete(selectedGame, key) end, + } +end + function Storage:context(game) local scope, code, message = self:_scope(game) if not scope then return nil, code, message end diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua new file mode 100644 index 00000000..2ade7892 --- /dev/null +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -0,0 +1,199 @@ +-- A tool can persist a checkpoint before the first normal Pokémon save. After +-- a restart the title runtime is deliberately a fresh save skeleton, so it +-- needs a read-only binding to the already-selected playthrough -- not a call +-- to the normal active-playthrough storage methods, which would mint an id. +-- +-- This is a public SDK contract test. The fixture never reaches into storage +-- paths or launcher slot internals. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +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 Version = require("src.core.Version") +local GameMethods = require("src.core.Game") +local StateStack = require("src.core.StateStack") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function memfs(files) + return { + read = function(path) return files[path] end, + write = function(path, body) files[path] = body return true end, + remove = function(path) files[path] = nil return true end, + createDirectory = function() 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, + getDirectoryItems = function(path) + local prefix, seen, out = 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; out[#out + 1] = child end + end + end + table.sort(out) + return out + end, + } +end + +local files = { + ["mods/probe/manifest.json"] = + '{"id":"probe","name":"probe","version":"1.0.0",' + .. '"entry":"main.lua","api":2,"profile":"content"}', + ["mods/probe/main.lua"] = [[ +return function(mod) + _G.MOD_TITLE_STORAGE = mod.storage + _G.MOD_TITLE_CHECKPOINTS = mod.checkpoints +end +]], +} +local fs = memfs(files) +-- Production storage and checkpoint resume share the same engine persistence +-- backend. Route the test's default SaveData lookup to this fixture backend so +-- the restart path exercises that shared mapping rather than host test files. +local originalLoadOptions = SaveData.loadOptions +SaveData.loadOptions = function(injectedFs) + return originalLoadOptions(injectedFs or fs) +end +local active = { save = SaveData.newGame({ version = "red" }) } +local loader = Loader.new({ fs = fs }) +loader.game = active +T.check(loader:load({}) == true, "title-context fixture mod loads") + +local storage = _G.MOD_TITLE_STORAGE +T.check(type(storage) == "table", "loader exposes the public storage facade") +if type(storage) == "table" then + local written, writeCode, writeMessage = storage:write(active, "history/index", { + format = 1, newest = "q0001", + }) + T.check(written == true, + "a fresh playthrough can durably store tool history: " + .. tostring(writeCode or writeMessage)) + local originalId = active.save.meta and active.save.meta.playthroughId + T.check(type(originalId) == "string" and originalId ~= "", + "first tool persistence allocates the opaque active playthrough identity") + + -- Simulate a fresh process/title session. The normal save was never written: + -- only the engine-owned slot/playthrough mapping and this mod's durable data + -- exist. The title skeleton must remain unmodified by browsing. + SaveData.resetSlotState() + local title = { + save = SaveData.newGame({ version = "red" }), + stack = { + states = { { screenId = "TitleState" } }, + top = function(self) return self.states[#self.states] end, + }, + } + T.check(title.save.meta.playthroughId == nil, + "title starts from an unbound fresh skeleton before normal SAVE") + T.check(type(storage.selected) == "function", + "public storage exposes a read-only selected-playthrough binding at title") + + if type(storage.selected) == "function" then + local selected, selectedCode, selectedMessage = storage:selected(title) + T.check(type(selected) == "table", + "title resolves the selected existing playthrough: " + .. tostring(selectedCode or selectedMessage)) + if type(selected) == "table" then + T.same(selected:context(), { + engineVersion = Version.engine, + gameVersion = "red", + playthroughId = originalId, + }, "selected binding reports the durable playthrough without exposing a slot path") + T.same(selected:read("history/index"), { format = 1, newest = "q0001" }, + "title reads only this mod's selected-playthrough durable history") + end + T.check(title.save.meta.playthroughId == nil, + "opening title history never allocates or adopts a playthrough identity") + end + + local function makeRuntime(save, title) + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local overworld = { + map = { id = "PALLET_TOWN" }, + player = { cellX = 3, cellY = 6, facing = "down", surfing = false }, + scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {}, + runner = { isRunning = function() return false end }, + } + function overworld:captureSave(target) + target.player.map, target.player.x, target.player.y = self.map.id, + self.player.cellX, self.player.cellY + target.player.facing, target.player.surfing = self.player.facing, + self.player.surfing and true or false + end + function overworld:enter(mapId, x, y, facing) + self.map = { id = mapId } + self.player = { cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false } + self.scriptMoves, self.pendingScripts = {}, {} + self.parallelRunners, self.parallelQueue = {}, {} + self.runner = { isRunning = function() return false end } + end + game = setmetatable({ + save = save, stack = stack, overworld = overworld, + data = { + pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} }, + constants = { fallbackMove = "TACKLE" }, + field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } }, + maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } }, + }, + }, { __index = GameMethods }) + stack.states[1] = title and { screenId = "TitleState" } or overworld + return game + end + + local runtime = makeRuntime(active.save, false) + local checkpoints = _G.MOD_TITLE_CHECKPOINTS + T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade") + local checkpoint = checkpoints and checkpoints:capture(runtime) + T.check(type(checkpoint) == "table", + "a fresh playthrough can capture a stable overworld checkpoint") + SaveData.resetSlotState() + local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true) + titleRuntime.save.options = { volume = 9, bindings = {} } + T.check(type(checkpoints and checkpoints.resume) == "function", + "public checkpoints expose validated title-session resume") + if type(checkpoints and checkpoints.resume) == "function" and checkpoint then + local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint) + T.check(resumed == true, + "title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage)) + T.eq(titleRuntime.save.meta.playthroughId, originalId, + "title bootstrap retains the checkpoint's original playthrough identity") + T.eq(titleRuntime.save.options.volume, 9, + "title bootstrap preserves current options rather than rewinding them") + T.check(files["save.lua"] == nil, + "title bootstrap never creates a normal Pokémon save as a side effect") + T.same(checkpoints:capture(titleRuntime), checkpoint, + "bootstrapped overworld differentially recaptures the selected checkpoint") + end + + local explicitNewGame = SaveData.newGame({ version = "red" }) + local freshContext = storage:context({ save = explicitNewGame }) + T.check(freshContext and freshContext.playthroughId ~= originalId, + "an explicit New Game receives a distinct identity and cannot inherit old history") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_TITLE_STORAGE = nil +_G.MOD_TITLE_CHECKPOINTS = nil +SaveData.resetSlotState() +SaveData.loadOptions = originalLoadOptions + +T.finish()