From 4db97164bb6a48f0de10d1ebc841dc42b1a6d0cf Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Tue, 11 Aug 2026 08:59:47 +0200 Subject: [PATCH] feat: anchor first checkpoint for cold restart --- docs/modding.md | 16 +- ...006-title-playthrough-checkpoint-resume.md | 37 ++++- scripts/test.sh | 2 + src/core/Checkpoint.lua | 41 +++++ src/core/SaveData.lua | 18 +- src/mods/Loader.lua | 3 + .../title_checkpoint_cold_start.lua | 155 ++++++++++++++++++ .../title_checkpoint_cold_start.sh | 10 ++ .../cases/title_playthrough_context.lua | 42 ++++- 9 files changed, 305 insertions(+), 19 deletions(-) create mode 100644 tests/integration/title_checkpoint_cold_start.lua create mode 100755 tests/integration/title_checkpoint_cold_start.sh diff --git a/docs/modding.md b/docs/modding.md index f0286579..d5b621a1 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -238,6 +238,11 @@ if capability.canCapture then end 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 @@ -287,8 +292,17 @@ 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 +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. diff --git a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md index 69abedd4..df26819a 100644 --- a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md +++ b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md @@ -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 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 +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. A generic title capability is required; a tool must not use -private storage paths, slot ids, or a hidden normal SAVE. +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 @@ -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 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 @@ -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 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 `not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes, `resume_failed`, and `title_recovery_failed`. @@ -65,9 +82,11 @@ 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 +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, -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. +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. diff --git a/scripts/test.sh b/scripts/test.sh index d88e6128..3b4913b2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -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 "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.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, # extra-bag round trip) is ROM-free and runs inside the T4 tier above, as diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index c210bbd1..ea6b9130 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -388,6 +388,47 @@ 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) local capability = Checkpoint.inspect(game) if not capability.canRestore then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 93b43a06..02464ee4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -941,7 +941,17 @@ local function rememberPlaythroughId(save, opts, injectedFs) if type(id) ~= "string" or id == "" then return opts, false end local version = save.version or GameVersion.get() 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[version] = opts.playthroughIds[version] or {} local changed = opts.playthroughIds[version][scope] ~= id @@ -1021,18 +1031,18 @@ function SaveData.selectedNormalSaveInfo(save, injectedFs) or readTable(fs, staged) or readTable(fs, backup) if type(normal) ~= "table" or normal.version ~= version then - return { savedAt = nil } + 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 { savedAt = nil } + 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 { savedAt = savedAt } + return { exists = true, savedAt = savedAt } end -- ------- meta diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index aca7027a..403893b0 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -683,6 +683,9 @@ function Loader:_api(mod) resume = function(_, game, checkpoint) return Checkpoint.resume(game, checkpoint) end, + ensureNormalSave = function(_, game, checkpoint) + return Checkpoint.ensureNormalSave(game, checkpoint, loader.fs) + end, }, options = { define = function(_, schema) diff --git a/tests/integration/title_checkpoint_cold_start.lua b/tests/integration/title_checkpoint_cold_start.lua new file mode 100644 index 00000000..6f754155 --- /dev/null +++ b/tests/integration/title_checkpoint_cold_start.lua @@ -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 diff --git a/tests/integration/title_checkpoint_cold_start.sh b/tests/integration/title_checkpoint_cold_start.sh new file mode 100755 index 00000000..c4e851fc --- /dev/null +++ b/tests/integration/title_checkpoint_cold_start.sh @@ -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" + diff --git a/tests/modkit/cases/title_playthrough_context.lua b/tests/modkit/cases/title_playthrough_context.lua index 8adb87e5..ab40f26d 100644 --- a/tests/modkit/cases/title_playthrough_context.lua +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -19,6 +19,7 @@ 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 { @@ -68,6 +69,7 @@ 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. @@ -181,6 +183,30 @@ if type(storage) == "table" then 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 = {} } @@ -194,8 +220,10 @@ if type(storage) == "table" then "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.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, @@ -223,8 +251,10 @@ if type(storage) == "table" then "failed title reconstruction restores the unbound title skeleton") T.eq(failingTitle.save.options.volume, 7, "failed title reconstruction retains current title options") - T.check(files["save.lua"] == nil, - "failed title reconstruction never writes a normal Pokémon save") + 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 @@ -234,7 +264,8 @@ if type(storage) == "table" then -- slot path, or a way to open another playthrough. This fixture writes the -- canonical normal save directly to model an already-completed vanilla SAVE. active.save.meta.savedAt = 4321 - fs.write("save.lua", SaveSerializer.encode(active.save)) + T.check(SaveData.save(active.save) == true, + "fixture updates the selected normal save chronology") SaveData.resetSlotState() local titleWithNormalSave = { save = SaveData.newGame({ version = "red" }), @@ -265,5 +296,6 @@ _G.MOD_TITLE_RESTORE_COUNT = nil _G.MOD_TITLE_RESTORE_KIND = nil SaveData.resetSlotState() SaveData.loadOptions = originalLoadOptions +love.filesystem = realFs T.finish()