feat: anchor first checkpoint for cold restart

This commit is contained in:
MaxTomahawk
2026-08-11 08:59:47 +02:00
parent 4e20f4585f
commit 4db97164bb
9 changed files with 305 additions and 19 deletions
+15 -1
View File
@@ -238,6 +238,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
@@ -287,8 +292,17 @@ engine-selected existing playthrough, reconstructs only after all validation
passes, preserves current options, and verifies by recapture. A title session passes, preserves current options, and verifies by recapture. A title session
has no live gameplay rollback state: if reconstruction or verification fails, has no live gameplay rollback state: if reconstruction or verification fails,
the engine rebuilds a usable title session and returns `false, code, message`. 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 It never rewrites a normal Pokémon save. It is unavailable outside title and does
not broaden capture or arbitrary-frame support. 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 See RFC 0003, RFC 0004, RFC 0005, and RFC 0006 for exact contracts and error
codes. codes.
@@ -11,11 +11,13 @@ checkpoint, title, save-slot, and no-mod parity suites.
A tool checkpoint may be the first durable record of a new playthrough. The 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 engine intentionally keeps normal Pokémon SAVE independent: before the first
normal write, identity is retained by the engine-owned selected-slot mapping, normal write, identity is retained by the engine-owned selected-slot mapping,
while title starts with a fresh New Game skeleton. Calling ordinary active 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.storage` there would allocate/adopt an identity, and live
`mod.checkpoints:restore` correctly refuses title because it has no gameplay `mod.checkpoints:restore` correctly refuses title because it has no gameplay
rollback state. A generic title capability is required; a tool must not use rollback state. Generic public capabilities are required; a tool must not use
private storage paths, slot ids, or a hidden normal SAVE. private storage paths, slot ids, or simulate the player's SAVE menu flow.
## Additive public API ## Additive public API
@@ -40,6 +42,21 @@ Its detached context can include only `normalSavedAt` from a matching ordinary
save, so title tools can apply their own resume policy without receiving the save, so title tools can apply their own resume policy without receiving the
canonical normal-save record. 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)` ### `mod.checkpoints:resume(game, checkpoint)`
Available only from title. It validates format, data-only structure, selected Available only from title. It validates format, data-only structure, selected
@@ -50,7 +67,7 @@ recaptures before committing. On success it emits `checkpoint.restored` once.
Title has no live runtime rollback. A reconstruction or verification failure Title has no live runtime rollback. A reconstruction or verification failure
therefore rebuilds a clean title session from the pre-operation title save and 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 RNG; it emits no success event and never rewrites normal progress. Validation
failure leaves the existing title session untouched. Stable errors include failure leaves the existing title session untouched. Stable errors include
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes, `not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
`resume_failed`, and `title_recovery_failed`. `resume_failed`, and `title_recovery_failed`.
@@ -65,9 +82,11 @@ does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
## Verification ## Verification
The public SDK test starts a fresh playthrough, stores tool history without a The public SDK test starts a fresh playthrough, stores tool history, creates and
normal SAVE, simulates title/restart, reads the selected binding without 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, allocating title identity, resumes an overworld checkpoint, preserves options,
performs no normal SAVE, differentially recaptures, and confirms a later differentially recaptures, and confirms a later explicit NEW GAME receives
explicit NEW GAME receives another identity. Existing no-mod, storage, another identity. A separate two-process disk test proves cold-start routing and
checkpoint, battle, and title suites prove additive parity. 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
+41
View File
@@ -388,6 +388,47 @@ end
local emitRestored 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
+14 -4
View File
@@ -941,7 +941,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
@@ -1021,18 +1031,18 @@ function SaveData.selectedNormalSaveInfo(save, injectedFs)
or readTable(fs, staged) or readTable(fs, staged)
or readTable(fs, backup) or readTable(fs, backup)
if type(normal) ~= "table" or normal.version ~= version then if type(normal) ~= "table" or normal.version ~= version then
return { savedAt = nil } return { exists = false, savedAt = nil }
end end
local normalId = normal.meta and normal.meta.playthroughId local normalId = normal.meta and normal.meta.playthroughId
if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then if type(normalId) == "string" and normalId ~= "" and normalId ~= playthroughId then
return { savedAt = nil } return { exists = false, savedAt = nil }
end end
local savedAt = normal.meta and normal.meta.savedAt local savedAt = normal.meta and normal.meta.savedAt
if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt if type(savedAt) ~= "number" or savedAt < 0 or savedAt ~= savedAt
or savedAt == math.huge or savedAt == -math.huge then or savedAt == math.huge or savedAt == -math.huge then
savedAt = nil savedAt = nil
end end
return { savedAt = savedAt } return { exists = true, savedAt = savedAt }
end end
-- ------- meta -- ------- meta
+3
View File
@@ -683,6 +683,9 @@ function Loader:_api(mod)
resume = function(_, game, checkpoint) resume = function(_, game, checkpoint)
return Checkpoint.resume(game, checkpoint) return Checkpoint.resume(game, checkpoint)
end, end,
ensureNormalSave = function(_, game, checkpoint)
return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs)
end,
}, },
options = { options = {
define = function(_, schema) define = function(_, schema)
@@ -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"
@@ -19,6 +19,7 @@ local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack") local StateStack = require("src.core.StateStack")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local realFs = love.filesystem
local function memfs(files) local function memfs(files)
return { return {
@@ -68,6 +69,7 @@ end
]], ]],
} }
local fs = memfs(files) local fs = memfs(files)
love.filesystem = fs
-- Production storage and checkpoint resume share the same engine persistence -- Production storage and checkpoint resume share the same engine persistence
-- backend. Route the test's default SaveData lookup to this fixture backend so -- 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. -- the restart path exercises that shared mapping rather than host test files.
@@ -181,6 +183,30 @@ if type(storage) == "table" then
local checkpoint = checkpoints and checkpoints:capture(runtime) local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table", T.check(type(checkpoint) == "table",
"a fresh playthrough can capture a stable overworld checkpoint") "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() SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true) local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} } titleRuntime.save.options = { volume = 9, bindings = {} }
@@ -194,8 +220,10 @@ if type(storage) == "table" then
"title bootstrap retains the checkpoint's original playthrough identity") "title bootstrap retains the checkpoint's original playthrough identity")
T.eq(titleRuntime.save.options.volume, 9, T.eq(titleRuntime.save.options.volume, 9,
"title bootstrap preserves current options rather than rewinding them") "title bootstrap preserves current options rather than rewinding them")
T.check(files["save.lua"] == nil, T.eq(SaveData.selectedNormalSaveInfo({
"title bootstrap never creates a normal Pokémon save as a side effect") version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
T.same(checkpoints:capture(titleRuntime), checkpoint, T.same(checkpoints:capture(titleRuntime), checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint") "bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1, T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
@@ -223,8 +251,10 @@ if type(storage) == "table" then
"failed title reconstruction restores the unbound title skeleton") "failed title reconstruction restores the unbound title skeleton")
T.eq(failingTitle.save.options.volume, 7, T.eq(failingTitle.save.options.volume, 7,
"failed title reconstruction retains current title options") "failed title reconstruction retains current title options")
T.check(files["save.lua"] == nil, T.eq(SaveData.selectedNormalSaveInfo({
"failed title reconstruction never writes a normal Pokémon save") 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, T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
"failed title reconstruction emits no additional restored lifecycle event") "failed title reconstruction emits no additional restored lifecycle event")
end end
@@ -234,7 +264,8 @@ if type(storage) == "table" then
-- slot path, or a way to open another playthrough. This fixture writes the -- slot path, or a way to open another playthrough. This fixture writes the
-- canonical normal save directly to model an already-completed vanilla SAVE. -- canonical normal save directly to model an already-completed vanilla SAVE.
active.save.meta.savedAt = 4321 active.save.meta.savedAt = 4321
fs.write("save.lua", SaveSerializer.encode(active.save)) T.check(SaveData.save(active.save) == true,
"fixture updates the selected normal save chronology")
SaveData.resetSlotState() SaveData.resetSlotState()
local titleWithNormalSave = { local titleWithNormalSave = {
save = SaveData.newGame({ version = "red" }), save = SaveData.newGame({ version = "red" }),
@@ -265,5 +296,6 @@ _G.MOD_TITLE_RESTORE_COUNT = nil
_G.MOD_TITLE_RESTORE_KIND = nil _G.MOD_TITLE_RESTORE_KIND = nil
SaveData.resetSlotState() SaveData.resetSlotState()
SaveData.loadOptions = originalLoadOptions SaveData.loadOptions = originalLoadOptions
love.filesystem = realFs
T.finish() T.finish()