Merge pull request #1076 from MaxTomahawk/feat/mod-title-checkpoint-resume

feat(mods): resume selected checkpoints from title
This commit is contained in:
bryanthaboi
2026-08-11 21:23:08 -04:00
committed by GitHub
10 changed files with 872 additions and 14 deletions
+36 -2
View File
@@ -231,7 +231,18 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays 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`.
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 Values must be tables containing serializable data only. Keys are conservative
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
@@ -250,6 +261,11 @@ if capability.canCapture then
end end
local ok, code, message = mod.checkpoints:restore(game, checkpoint) local ok, code, message = mod.checkpoints:restore(game, checkpoint)
-- After the tool has durably committed its first checkpoint, make a
-- never-saved playthrough reachable through ordinary title boot exactly once.
local anchored, anchorCode, anchorMessage =
mod.checkpoints:ensureNormalSave(game, checkpoint)
``` ```
Checkpoint format 1 supports settled overworld control and proven battle Checkpoint format 1 supports settled overworld control and proven battle
@@ -296,7 +312,25 @@ private state. A mod that deliberately stores progress-coupled truth in
cannot distinguish it safely from independent history, configuration, or cache cannot distinguish it safely from independent history, configuration, or cache
data. 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 rewrites a normal Pokémon save. It is unavailable outside title and does
not broaden capture or arbitrary-frame support.
`mod.checkpoints:ensureNormalSave(game, checkpoint)` is a separate live-runtime
operation for durable checkpoint tools. It creates ordinary progress only when
none exists, only after validating that the supplied checkpoint is the exact
current safe runtime, and through the normal atomic save lifecycle. Once an
ordinary save exists it returns `true, "already_exists"` without writing, so
subsequent checkpoints and the player's later SAVE commands remain independent.
Call it only after the tool's own checkpoint/index commit; treat an anchoring
failure as a failed first checkpoint rather than claiming restart safety.
See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
codes.
## Developer console ## Developer console
@@ -0,0 +1,92 @@
# 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. A durable checkpoint tool may
explicitly create one ordinary progress anchor after its first checkpoint has
committed; later tool writes must remain independent. 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. Generic public capabilities are required; a tool must not use
private storage paths, slot ids, or simulate the player's SAVE menu flow.
## 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.
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:ensureNormalSave(game, checkpoint)`
Available only at a live checkpoint-safe boundary. After a tool has durably
committed the supplied current checkpoint, it may request an ordinary progress
anchor for a playthrough that has never had one. The engine validates the
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
The operation is idempotent. It returns `true, "already_exists"` without writing
when matching normal progress already exists, so later checkpoints never move
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
write veto/failure, or failed readback returns a structured failure. A tool
should call it only after its own checkpoint and index are durable and must not
report that first checkpoint as successful if the required anchor fails.
### `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 rewrites 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, creates and
readback-verifies exactly one normal anchor, proves subsequent calls do not
rewrite it, simulates title/restart, reads the selected binding without
allocating title identity, resumes an overworld checkpoint, preserves options,
differentially recaptures, and confirms a later explicit NEW GAME receives
another identity. A separate two-process disk test proves cold-start routing and
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
prove additive parity.
+2
View File
@@ -77,6 +77,8 @@ run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
run_tier "T4 title checkpoint cold restart" \
bash tests/integration/title_checkpoint_cold_start.sh
# The modded-link desync suite (symmetric mod, handshake fail-closed, # The modded-link desync suite (symmetric mod, handshake fail-closed,
# extra-bag round trip) is ROM-free and runs inside the T4 tier above, as # extra-bag round trip) is ROM-free and runs inside the T4 tier above, as
+130 -9
View File
@@ -257,7 +257,7 @@ end
local FACINGS = { up = true, down = true, left = true, right = true } 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 if type(checkpoint) ~= "table" then
return nil, "invalid_checkpoint", "Checkpoint root must be a table." return nil, "invalid_checkpoint", "Checkpoint root must be a table."
end end
@@ -275,13 +275,16 @@ local function validate(game, checkpoint)
end end
local identity = copy.identity local identity = copy.identity
local current = game and game.save 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" if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
or type(identity.gameVersion) ~= "string" or type(identity.gameVersion) ~= "string"
or type(identity.playthroughId) ~= "string" then or type(identity.playthroughId) ~= "string" then
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
end end
if identity.gameVersion ~= current.version then if identity.gameVersion ~= currentVersion then
return nil, "wrong_game", "Checkpoint belongs to another game version." return nil, "wrong_game", "Checkpoint belongs to another game version."
end end
if identity.playthroughId ~= currentId then if identity.playthroughId ~= currentId then
@@ -394,6 +397,49 @@ local function firstDifference(a, b, path)
return nil return nil
end end
local emitRestored
-- Persist the current verified checkpoint as the ordinary progress anchor only
-- when this playthrough has never had one. This is intentionally idempotent:
-- durable checkpoint tools can make a first session resumable without turning
-- every later checkpoint into a hidden normal SAVE. The live runtime must still
-- match the supplied checkpoint, and the ordinary save.write veto/lifecycle
-- remains authoritative through Game:writeSave().
function Checkpoint.ensureNormalSave(game, checkpoint, injectedFs)
local capability = Checkpoint.inspect(game)
if not capability.canCapture then
return false, capability.reason, capability.message
end
local validated, code, message = validate(game, checkpoint)
if not validated then return false, code, message end
local info, infoCode, infoMessage =
SaveData.selectedNormalSaveInfo(game.save, injectedFs)
if not info then return false, infoCode, infoMessage end
if info.exists then return true, "already_exists" end
local current, captureCode, captureMessage = Checkpoint.capture(game)
if not current then return false, captureCode, captureMessage end
if not equalData(current, validated) then
return false, "checkpoint_not_current",
"The active runtime changed after this checkpoint was captured."
end
if type(game.writeSave) ~= "function" then
return false, "save_unavailable",
"The active runtime cannot persist ordinary progress."
end
local ok, saved = pcall(game.writeSave, game)
if not ok or saved == false then
return false, "save_failed", "Could not create the first ordinary progress save."
end
local verified = SaveData.selectedNormalSaveInfo(game.save, injectedFs)
if type(verified) ~= "table" or not verified.exists then
return false, "save_verify_failed",
"The first ordinary progress save could not be verified."
end
return true
end
function Checkpoint.restore(game, checkpoint) function Checkpoint.restore(game, checkpoint)
local capability = Checkpoint.inspect(game) local capability = Checkpoint.inspect(game)
if not capability.canRestore then if not capability.canRestore then
@@ -411,12 +457,7 @@ function Checkpoint.restore(game, checkpoint)
local restored, verifyCode = Checkpoint.capture(game) local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end if restored and validated.rng == nil then restored.rng = nil end
if restored and equalData(restored, validated) then if restored and equalData(restored, validated) then
if ModRuntime.wants("checkpoint.restored") then emitRestored(game, validated)
ModRuntime.emit("checkpoint.restored", {
game = game,
kind = validated.kind,
})
end
return true return true
end end
err = restored and ("restored state differed at " err = restored and ("restored state differed at "
@@ -432,4 +473,84 @@ function Checkpoint.restore(game, checkpoint)
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err) return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
end end
emitRestored = function(game, checkpoint)
if ModRuntime.wants("checkpoint.restored") then
ModRuntime.emit("checkpoint.restored", {
game = game,
kind = checkpoint.kind,
})
end
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 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 return Checkpoint
+81 -3
View File
@@ -766,6 +766,15 @@ local function tryMigrateLegacy(version, fs)
local opts = SaveData.loadOptions(fs) local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {} opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id } 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) SaveData.saveOptions(opts, fs)
return id return id
end end
@@ -791,9 +800,9 @@ end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for -- (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. -- 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() version = version or GameVersion.get()
local fs = persistFs(nil) local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs) ensureVersionSlots(version, fs)
local slot = activeSlotCache[version] local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end if slot then return slotNames(version, slot) end
@@ -1081,7 +1090,17 @@ local function rememberPlaythroughId(save, opts, injectedFs)
if type(id) ~= "string" or id == "" then return opts, false end if type(id) ~= "string" or id == "" then return opts, false end
local version = save.version or GameVersion.get() local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs) local scope = playthroughScope(version, injectedFs)
opts = opts or SaveData.loadOptions(injectedFs) local persisted = SaveData.loadOptions(injectedFs)
if opts then
-- Slot selection and opaque playthrough routing are engine-owned launcher
-- state. A live game may carry an options snapshot from before a legacy
-- save was promoted to slot1; writing that stale snapshot must not erase
-- the freshly persisted routing and strand tool storage on next boot.
opts.saveSlots = deepCopy(persisted.saveSlots)
opts.playthroughIds = deepCopy(persisted.playthroughIds)
else
opts = persisted
end
opts.playthroughIds = opts.playthroughIds or {} opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {} opts.playthroughIds[version] = opts.playthroughIds[version] or {}
local changed = opts.playthroughIds[version][scope] ~= id local changed = opts.playthroughIds[version][scope] ~= id
@@ -1116,6 +1135,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
return id return id
end 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
-- 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[scope] 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
-- 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 { exists = false, savedAt = nil }
end
local normalId = normal.meta and normal.meta.playthroughId
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
return { exists = false, 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 { exists = true, savedAt = savedAt }
end
-- ------- meta -- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the -- the version/engine/mod-set stamp every v2 save carries; mods is the
+7
View File
@@ -951,6 +951,7 @@ function Loader:_api(mod)
-- callers never receive paths or a raw filesystem handle. -- callers never receive paths or a raw filesystem handle.
storage = { storage = {
context = function(_, game) return storage:context(game) end, 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, write = function(_, game, key, value) return storage:write(game, key, value) end,
read = function(_, game, key) return storage:read(game, key) end, read = function(_, game, key) return storage:read(game, key) end,
list = function(_, game, prefix) return storage:list(game, prefix) end, list = function(_, game, prefix) return storage:list(game, prefix) end,
@@ -964,6 +965,12 @@ function Loader:_api(mod)
restore = function(_, game, checkpoint) restore = function(_, game, checkpoint)
return Checkpoint.restore(game, checkpoint) return Checkpoint.restore(game, checkpoint)
end, end,
resume = function(_, game, checkpoint)
return Checkpoint.resume(game, checkpoint)
end,
ensureNormalSave = function(_, game, checkpoint)
return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs)
end,
}, },
options = { options = {
define = function(_, schema) define = function(_, schema)
+58
View File
@@ -79,6 +79,64 @@ function Storage:_scope(game)
base = base, fs = fs } base = base, fs = fs }
end 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,
}
local normal = SaveData.selectedNormalSaveInfo(save, self.injectedFs)
if type(normal) == "table" and normal.savedAt ~= nil then
context.normalSavedAt = normal.savedAt
end
return {
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,
delete = function(_, key) return self:delete(selectedGame, key) end,
}
end
function Storage:context(game) function Storage:context(game)
local scope, code, message = self:_scope(game) local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end if not scope then return nil, code, message end
@@ -0,0 +1,155 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local phase, root = arg[1], arg[2]
assert(phase == "capture" or phase == "resume", "phase must be capture or resume")
assert(type(root) == "string" and root ~= "", "test needs a persistence root")
local function quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function full(path) return root .. "/" .. path end
local fs = {}
function fs.createDirectory(path)
return os.execute("mkdir -p " .. quote(full(path))) == 0
end
function fs.write(path, body)
local parent = path:match("^(.*)/[^/]+$")
if parent then assert(fs.createDirectory(parent)) end
local handle = assert(io.open(full(path), "wb"))
handle:write(body)
handle:close()
return true
end
function fs.read(path)
local handle = io.open(full(path), "rb")
if not handle then return nil end
local body = handle:read("*a")
handle:close()
return body
end
function fs.remove(path)
os.remove(full(path))
return true
end
function fs.getInfo(path)
if os.execute("test -d " .. quote(full(path))) == 0 then
return { type = "directory" }
end
local handle = io.open(full(path), "rb")
if handle then handle:close(); return { type = "file" } end
return nil
end
function fs.load(path)
local body = fs.read(path)
if not body then return nil, "no file: " .. path end
return load(body, "@" .. path)
end
function fs.getDirectoryItems(path)
local items = {}
local pipe = io.popen("find " .. quote(full(path))
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
if pipe then
for item in pipe:lines() do items[#items + 1] = item end
pipe:close()
end
table.sort(items)
return items
end
function fs.getSaveDirectory() return root end
love = require("tests.love_stub")
love.filesystem = fs
local Loader = require("src.mods.Loader")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local function writeProbe()
fs.write("mods/cold_start_probe/manifest.json",
'{"id":"cold_start_probe","name":"cold start probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}')
fs.write("mods/cold_start_probe/main.lua", [[
return function(mod)
_G.COLD_STORAGE = mod.storage
_G.COLD_CHECKPOINTS = mod.checkpoints
end
]])
end
local function runtime(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
if title then function game:makeTitleState() return { screenId = "TitleState" } end end
return game
end
writeProbe()
SaveData.resetSlotState()
local loader = Loader.new({ fs = fs })
if phase == "capture" then
local game = runtime(SaveData.newGame({ version = "red" }), false)
loader.game = game
assert(loader:load({}) == true)
assert(_G.COLD_STORAGE:write(game, "history/index", { newest = "q0001" }))
local checkpoint = assert(_G.COLD_CHECKPOINTS:capture(game))
assert(_G.COLD_STORAGE:write(game, "history/q0001", checkpoint))
local id = assert(game.save.meta.playthroughId)
assert(_G.COLD_CHECKPOINTS:ensureNormalSave(game, checkpoint))
local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == id)
fs.write("cold-start-witness.lua", SaveSerializer.encode({ playthroughId = id }))
print("cold-start capture persisted")
else
local title = runtime(SaveData.newGame({ version = "red" }), true)
title.save.options = { volume = 7, bindings = {} }
loader.game = title
assert(loader:load({}) == true)
local selected = assert(_G.COLD_STORAGE:selected(title))
local witness = assert(SaveSerializer.decode(assert(fs.read("cold-start-witness.lua"))))
assert(selected:context().playthroughId == witness.playthroughId)
assert(selected:read("history/index").newest == "q0001")
local checkpoint = assert(selected:read("history/q0001"))
assert(_G.COLD_CHECKPOINTS:resume(title, checkpoint))
assert(title.save.meta.playthroughId == witness.playthroughId)
assert(title.save.options.volume == 7)
assert(SaveSerializer.encode(_G.COLD_CHECKPOINTS:capture(title))
== SaveSerializer.encode(checkpoint))
local normal = assert(SaveData.load("red"))
assert(normal.meta.playthroughId == witness.playthroughId)
print("cold-start resume reconstructed")
end
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
test_root="$(mktemp -d)"
trap 'rm -rf "$test_root"' EXIT
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua capture "$test_root"
"${LUA:-luajit}" tests/integration/title_checkpoint_cold_start.lua resume "$test_root"
@@ -0,0 +1,301 @@
-- 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 non-allocating 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 SaveSerializer = require("src.core.SaveSerializer")
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 realFs = love.filesystem
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
mod.events:on("checkpoint.restored", function(ev)
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
_G.MOD_TITLE_RESTORE_KIND = ev.kind
end)
end
]],
}
local fs = memfs(files)
love.filesystem = fs
-- 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")
local nonTitleSelected, nonTitleCode = storage:selected(active)
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
"selected-playthrough storage cannot be used from active gameplay")
-- 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")
T.check(selected:write("history/title-operation", { allowed = true }) == true,
"title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough")
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
if title then
-- The failure-injection path needs the same title recovery contract as a
-- real Game without constructing renderer-owned title content.
function game:makeTitleState() return { screenId = "TitleState" } end
end
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")
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
"public checkpoints expose an idempotent first-save anchor")
local normalWrites = 0
local writeSave = runtime.writeSave
function runtime:writeSave()
normalWrites = normalWrites + 1
return writeSave(self)
end
local anchored, anchorCode, anchorMessage =
checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchored == true,
"first persisted checkpoint can anchor normal progress: "
.. tostring(anchorCode or anchorMessage))
T.eq(normalWrites, 1,
"first checkpoint creates exactly one normal Pokemon save")
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchoredAgain == true and againCode == "already_exists",
"later checkpoints leave the established normal save independent")
T.eq(normalWrites, 1,
"idempotent anchor never rewrites the established normal save")
local normalBytes = files["save.lua"]
T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
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.eq(SaveData.selectedNormalSaveInfo({
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the
-- checkpoint's canonical save and overworld. Title has no live checkpoint
-- rollback, so it must rebuild a clean title session.
SaveData.resetSlotState()
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
failingTitle.save.options = { volume = 7, bindings = {} }
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
function failingTitle:restoreCheckpointSave(loaded)
restoreCheckpointSave(self, loaded)
error("forced title reconstruction failure")
end
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
T.check(failed == false and failureCode == "resume_failed",
"failed title reconstruction reports a recoverable bootstrap failure")
T.eq(failingTitle.stack:top().screenId, "TitleState",
"failed title reconstruction returns to a usable title session")
T.check(failingTitle.save.meta.playthroughId == nil,
"failed title reconstruction restores the unbound title skeleton")
T.eq(failingTitle.save.options.volume, 7,
"failed title reconstruction retains current title options")
T.eq(SaveData.selectedNormalSaveInfo({
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"failed title reconstruction never rewrites the normal Pokémon save")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"failed title reconstruction emits no additional restored lifecycle event")
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
T.check(SaveData.save(active.save) == true,
"fixture updates the selected normal save chronology")
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,
"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
_G.MOD_TITLE_RESTORE_COUNT = nil
_G.MOD_TITLE_RESTORE_KIND = nil
SaveData.resetSlotState()
SaveData.loadOptions = originalLoadOptions
love.filesystem = realFs
T.finish()