diff --git a/docs/modding.md b/docs/modding.md index a44c40c4..9feb8fd3 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -231,7 +231,18 @@ local deleted, code, message = mod.storage:delete(game, "history/quick/q0001") `context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine 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 slash-separated segments (letters, digits, `_`, `-`); paths and filesystem @@ -250,6 +261,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 @@ -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 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 diff --git a/docs/rfcs/0006-title-playthrough-checkpoint-resume.md b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md new file mode 100644 index 00000000..df26819a --- /dev/null +++ b/docs/rfcs/0006-title-playthrough-checkpoint-resume.md @@ -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. 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 433a7794..618d7ae4 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -257,7 +257,7 @@ end local FACINGS = { up = true, down = true, left = true, right = true } -local function validate(game, checkpoint) +local function validate(game, checkpoint, expectedIdentity) if type(checkpoint) ~= "table" then return nil, "invalid_checkpoint", "Checkpoint root must be a table." end @@ -275,13 +275,16 @@ local function validate(game, checkpoint) end local identity = copy.identity local current = game and game.save - local currentId = current and current.meta and current.meta.playthroughId + local currentId = expectedIdentity and expectedIdentity.playthroughId + or (current and current.meta and current.meta.playthroughId) + local currentVersion = expectedIdentity and expectedIdentity.gameVersion + or (current and current.version) if type(identity) ~= "table" or type(identity.engineVersion) ~= "string" or type(identity.gameVersion) ~= "string" or type(identity.playthroughId) ~= "string" then return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt." end - if identity.gameVersion ~= current.version then + if identity.gameVersion ~= currentVersion then return nil, "wrong_game", "Checkpoint belongs to another game version." end if identity.playthroughId ~= currentId then @@ -394,6 +397,49 @@ local function firstDifference(a, b, path) return nil 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 @@ -411,12 +457,7 @@ function Checkpoint.restore(game, checkpoint) local restored, verifyCode = Checkpoint.capture(game) if restored and validated.rng == nil then restored.rng = nil end if restored and equalData(restored, validated) then - if ModRuntime.wants("checkpoint.restored") then - ModRuntime.emit("checkpoint.restored", { - game = game, - kind = validated.kind, - }) - end + emitRestored(game, validated) return true end 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) 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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index f5bd53cd..70dd44c7 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -766,6 +766,15 @@ local function tryMigrateLegacy(version, fs) local opts = SaveData.loadOptions(fs) opts.saveSlots = opts.saveSlots or {} 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) return id end @@ -791,9 +800,9 @@ end -- (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. -function saveNames(version) +function saveNames(version, injectedFs) version = version or GameVersion.get() - local fs = persistFs(nil) + local fs = persistFs(injectedFs) ensureVersionSlots(version, fs) local slot = activeSlotCache[version] 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 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 @@ -1116,6 +1135,65 @@ function SaveData.ensurePlaythroughId(save, injectedFs) return id end +-- Resolve the already-selected playthrough without changing the supplied save +-- or allocating a replacement id. Title tools use this before a normal SAVE: +-- Game:load intentionally owns a fresh skeleton there, while the selected +-- launcher slot's durable tool data remains bound by the engine-owned mapping. +-- This does not make arbitrary identities addressable; callers still need a +-- higher-level engine capability that decides when selected resolution is safe. +function SaveData.selectedPlaythroughId(save, injectedFs) + if type(save) ~= "table" then + return nil, "not_in_playthrough", "No selected playthrough is available." + end + local version = save.version or GameVersion.get() + if not knownVersion(version) then + return nil, "unknown_game", "The selected game version is unavailable." + end + local id = save.meta and save.meta.playthroughId + if type(id) == "string" and id ~= "" then return id end + + -- 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 -- the version/engine/mod-set stamp every v2 save carries; mods is the diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 66a920bb..79e8eb42 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -951,6 +951,7 @@ function Loader:_api(mod) -- callers never receive paths or a raw filesystem handle. storage = { context = function(_, game) return storage:context(game) end, + selected = function(_, game) return storage:selected(game) end, write = function(_, game, key, value) return storage:write(game, key, value) end, read = function(_, game, key) return storage:read(game, key) end, list = function(_, game, prefix) return storage:list(game, prefix) end, @@ -964,6 +965,12 @@ function Loader:_api(mod) restore = function(_, game, checkpoint) return Checkpoint.restore(game, checkpoint) end, + 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/src/mods/Storage.lua b/src/mods/Storage.lua index e529106c..31456566 100644 --- a/src/mods/Storage.lua +++ b/src/mods/Storage.lua @@ -79,6 +79,64 @@ function Storage:_scope(game) base = base, fs = fs } end +local function isTitleSession(game) + local states = game and game.stack and game.stack.states + if type(states) ~= "table" then return false end + for _, state in ipairs(states) do + if type(state) == "table" and state.screenId == "TitleState" then return true end + end + return false +end + +-- Bind this mod only to the engine-selected existing playthrough while the +-- title session is active. Unlike _scope this must never allocate an identity: +-- browsing history before the first normal SAVE is a read of durable state, +-- not the start of a New Game. The returned facade closes over its private +-- proxy game, so callers cannot substitute another playthrough id or path. +function Storage:selected(game) + if not isTitleSession(game) then + return failure("not_at_title", + "Selected playthrough storage is available only from the title session.") + end + local save = game and game.save + local version = save and save.version + if not validSegment(version) then + return failure("not_in_playthrough", + "The title session has no selected game version.") + end + local playthroughId, code, message = + SaveData.selectedPlaythroughId(save, self.injectedFs) + if not validSegment(playthroughId) then return failure(code, message) end + + local selectedGame = { + save = { version = version, meta = { playthroughId = playthroughId } }, + } + local context = { + engineVersion = Version.engine, + gameVersion = version, + playthroughId = playthroughId, + } + 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) local scope, code, message = self:_scope(game) if not scope then return nil, code, message end 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 new file mode 100644 index 00000000..ab40f26d --- /dev/null +++ b/tests/modkit/cases/title_playthrough_context.lua @@ -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()