feat(mods): resume selected checkpoints from title

This commit is contained in:
MaxTomahawk
2026-08-10 14:23:10 +02:00
parent 79ed37699e
commit b8138ef850
7 changed files with 452 additions and 4 deletions
+19 -1
View File
@@ -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
@@ -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.
+83 -3
View File
@@ -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
+27
View File
@@ -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
+4
View File
@@ -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)
+50
View File
@@ -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
@@ -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()