From aa3b2a18ec06d844f42be873278c5232628376fa Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Sat, 8 Aug 2026 13:28:56 +0200 Subject: [PATCH] feat(mods): signal verified checkpoint restores --- docs/modding.md | 28 ++ docs/rfcs/0004-runtime-checkpoints.md | 14 +- docs/rfcs/0005-battle-runtime-checkpoints.md | 17 +- src/core/Checkpoint.lua | 11 +- tests/modkit/cases/checkpoint_cross_mod.lua | 342 +++++++++++++++++++ 5 files changed, 402 insertions(+), 10 deletions(-) create mode 100644 tests/modkit/cases/checkpoint_cross_mod.lua diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..8aef72ed 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -194,6 +194,34 @@ effects; verifies a recapture; and rolls back runtime plus RNG in memory if reconstruction fails. Callers that need crash recovery should durably capture their own recovery checkpoint before restore. +Checkpoint ownership follows the persistence model rather than mod identity: + +- canonical `game.save` progress, including every mod's `save.modData` / + `mod.save` bucket and data-only fields added to saved Pokémon, rewinds; +- global and per-mod options remain at their current values; +- independently written `mod.storage` records do not rewind; and +- mod-owned runtime objects, references, and caches are never serialized. + +Successful restore emits `checkpoint.restored` only after reconstruction and +differential recapture have committed. Mods that cache rewound progress or hold +references to reconstructed runtime objects can re-read their own public state +and rebuild at that point: + +```lua +mod.events:on("checkpoint.restored", function(ev) + -- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed. + cachedQuestStage = mod.save:get("quest_stage", 0) + rebuildRuntimeFor(ev.game, ev.kind) +end) +``` + +The event is not emitted for validation failure, failed reconstruction, or a +successful rollback. Its payload contains no checkpoint data or other mod's +private state. A mod that deliberately stores progress-coupled truth in +`mod.storage` must version and reconcile that relationship itself; the engine +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. ## Developer console diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 54e928de..1df98749 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -97,7 +97,9 @@ events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites it does not emit normal `save.loading`/`save.loaded` lifecycle events. After reconstruction, the engine recaptures and byte-compares normalized data. A failed apply rolls back and returns `restore_failed`; failure of that rollback returns -`rollback_failed`. +`rollback_failed`. Only after a successful comparison does the engine emit +`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation +failure, failed apply, and successful rollback emit nothing. Durable recovery remains a caller responsibility: in-memory rollback handles a runtime exception, not process termination. @@ -112,9 +114,11 @@ support implied here. ## Migration note for existing mods -**Nothing.** No existing hook, event, save, controller, or world action changes +**Nothing required.** No existing hook, save, controller, or world action changes when `mod.checkpoints` is unused. The reconstruction path is called only by a -successful public restore after validation. +successful public restore after validation. Mods whose runtime caches derive from +rewound `game.save` or `mod.save` state may optionally subscribe to +`checkpoint.restored` and rebuild from their own public state. ## Parity tests @@ -124,7 +128,9 @@ successful public restore after validation. unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync, `A -> mutate B -> restore A -> recapture A2` equality across representative progress, settings preservation, compatibility rejection without mutation, - map-side-effect suppression, and injected reconstruction rollback. + map-side-effect suppression, injected reconstruction rollback, mod-owned + metadata and `mod.save` rewind, independent `mod.storage`/options preservation, + and success-only runtime-cache reconciliation through `checkpoint.restored`. ## Deprecation etiquette diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md index 6735f5d2..f94d2ba4 100644 --- a/docs/rfcs/0005-battle-runtime-checkpoints.md +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -90,11 +90,14 @@ mutation. The engine then: 4. binds an engine-owned wild/trainer completion continuation; 5. installs the battle directly at the settled menu without replaying its intro; 6. restores the RNG after reconstruction has finished; and -7. recaptures and compares the complete checkpoint. +7. recaptures and compares the complete checkpoint; and +8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the + comparison succeeds. The pre-operation checkpoint is the transaction rollback. A failed post-install RNG restore is covered: both battle runtime and RNG are reconstructed back to -their original values. +their original values. Validation failure, failed reconstruction, and successful +rollback emit no checkpoint lifecycle event. ## Continuation decision @@ -112,9 +115,10 @@ contract until a separate semantic ScriptRunner checkpoint RFC exists. ## Migration note **Existing mods require no changes.** The facade and format number are unchanged; -the new kind and RNG field are additive. Overworld-only callers may continue to -filter `capability.kind`. No-mod behavior is unchanged when checkpoints are -unused. +the new kind, RNG field, and success-only lifecycle event are additive. +Overworld-only callers may continue to filter `capability.kind`. Mods with derived +runtime caches may rebuild them from restored public state when the event fires. +No-mod behavior is unchanged when checkpoints are unused. ## Verification @@ -129,5 +133,8 @@ unused. raw RNG result after reload; - corrupt content/continuation rejection before mutation; - injected post-install failure with full runtime and RNG rollback; +- mod-added Pokémon metadata and `mod.save` rewind while independent + `mod.storage` and options remain current; +- exactly one post-verification `checkpoint.restored` event and none on failure; - legacy overworld checkpoint compatibility; - complete ROM-free engine and public mod-API suites. diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index f6827fa8..e6a3b036 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -6,6 +6,7 @@ local SaveData = require("src.core.SaveData") local Version = require("src.core.Version") local BattleState = require("src.battle.BattleState") local BattleCheckpoint = require("src.core.BattleCheckpoint") +local ModRuntime = require("src.mods.Runtime") local Checkpoint = {} @@ -398,7 +399,15 @@ function Checkpoint.restore(game, checkpoint) 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 return true end + if restored and equalData(restored, validated) then + if ModRuntime.wants("checkpoint.restored") then + ModRuntime.emit("checkpoint.restored", { + game = game, + kind = validated.kind, + }) + end + return true + end err = restored and ("restored state differed at " .. tostring(firstDifference(validated, restored) or "canonical encoding")) or ("restored state could not be captured: " .. tostring(verifyCode)) diff --git a/tests/modkit/cases/checkpoint_cross_mod.lua b/tests/modkit/cases/checkpoint_cross_mod.lua new file mode 100644 index 00000000..91e1a504 --- /dev/null +++ b/tests/modkit/cases/checkpoint_cross_mod.lua @@ -0,0 +1,342 @@ +-- Cross-mod checkpoint ownership and lifecycle contract through public API only. +-- The Pokemon metadata case models masterwebx/SHINY_POKEMON 1.0.8 at 2141b2e: +-- shiny identity is data on the plain Pokemon record (`dvs` plus `shiny`). + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("checkpoint cross-mod compatibility") +local BattleState = require("src.battle.BattleState") +local Fixtures = require("tests.modkit").fixtures +local GameMethods = require("src.core.Game") +local Loader = require("src.mods.Loader") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Stats = require("src.pokemon.Stats") + +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local oldGetRandomState = love.math.getRandomState +local oldSetRandomState = love.math.setRandomState +local rngState = "cross-mod-rng-A" +love.math.getRandomState = function() return rngState end +love.math.setRandomState = function(state) rngState = state end + +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 function shinyDvs() + return { attack = 2, defense = 10, speed = 10, special = 10, hp = 0 } +end + +local function ordinaryDvs() + return { attack = 1, defense = 1, speed = 1, special = 1, hp = 15 } +end + +local function setPokemonIdentity(data, mon, shiny) + mon.dvs = shiny and shinyDvs() or ordinaryDvs() + mon.shiny = shiny and true or false + mon.stats = Stats.calc(data.pokemon[mon.species], mon.level, mon.dvs, mon.statExp) + mon.hp = math.min(mon.hp or mon.stats.hp, mon.stats.hp) +end + +local function setBattlerIdentity(data, battler, shiny) + setPokemonIdentity(data, battler.mon, shiny) + battler.curStats = battler.mon.stats + battler.shownHP = battler.mon.hp + battler.shownStatus = battler.mon.status +end + +local function makeGame() + local data = Fixtures.fresh() + local save = SaveData.newGame() + save.meta.playthroughId = "cross-mod-playthrough" + save.party = { Pokemon.new(data, "FIXMON_A", 20) } + SaveData.validate(save, data) + save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3 + save.player.facing, save.player.surfing = "left", false + save.options.modOptions = {} + + local stack = setmetatable({ states = {} }, { __index = StateStack }) + local game + local ow = { + map = { id = "FIX_TOWN" }, + player = { cellX = 2, cellY = 3, facing = "left", surfing = false }, + runner = { isRunning = function() return false end }, + parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {}, + } + function ow:captureSave(target) + target.player.map = self.map.id + target.player.x, target.player.y = self.player.cellX, self.player.cellY + target.player.facing = self.player.facing + target.player.surfing = self.player.surfing and true or false + end + function ow:enter(mapId, x, y, facing, opts) + if game.failNextEnter then + game.failNextEnter = false + error("injected cross-mod reconstruction failure") + end + self.map = { id = mapId } + self.player = { + cellX = x, cellY = y, facing = facing, + surfing = game.save.player.surfing and true or false, + } + self.runner = { isRunning = function() return false end } + self.parallelRunners, self.pendingScripts = {}, {} + self.parallelQueue, self.scriptMoves = {}, {} + game.lastCheckpointEnter = opts + end + function ow:restoreBattleContinuation(battle, origin) + if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then + return false + end + battle.onFinish = function() end + return true + end + + game = setmetatable({ + data = data, save = save, stack = stack, overworld = ow, + }, { __index = GameMethods }) + stack.states[1] = ow + return game, ow +end + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local files = { + ["mods/cooperator/manifest.json"] = manifest("cooperator"), + ["mods/cooperator/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + local restoreCount = 0 + mod.options:define({ + { key = "mode", type = "choice", default = "default", + choices = { { "A", "A" }, { "B", "B" }, { "C", "C" } } }, + }) + mod.exports.checkpoints = mod.checkpoints + mod.exports.storage = mod.storage + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end + mod.exports.restoreCount = function() return restoreCount end + mod.events:on("checkpoint.restored", function(ev) + restoreCount = restoreCount + 1 + cachedStage = mod.save:get("stage", "unset") + mod.exports.lastRestore = { + game = ev.game, + kind = ev.kind, + top = ev.game.stack:top(), + } + end) +end +]], + ["mods/passive/manifest.json"] = manifest("passive"), + ["mods/passive/main.lua"] = [[ +return function(mod) + local cachedStage = mod.save:get("stage", "unset") + mod.exports.setStage = function(stage) + mod.save:set("stage", stage) + cachedStage = stage + end + mod.exports.stage = function() return mod.save:get("stage", "unset") end + mod.exports.cachedStage = function() return cachedStage end +end +]], +} + +local game, ow = makeGame() +local loader = Loader.new({ fs = memfs(files) }) +loader.game, game.mods = game, loader +T.check(loader:load({}) == true, "cooperating fixture mods load") +local cooperator = loader.exports.cooperator +local passive = loader.exports.passive +T.check(type(cooperator) == "table" and type(passive) == "table", + "fixture exposes only public mod exports") +if type(cooperator) ~= "table" or type(passive) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + love.math.getRandomState = oldGetRandomState + love.math.setRandomState = oldSetRandomState + T.finish() +end + +cooperator.setStage("A") +passive.setStage("A") +game:adoptSave(game.save, true) +local optionBucket = { mode = "A" } +game.save.options.modOptions.cooperator = optionBucket +loader.modOptions.cooperator = optionBucket +setPokemonIdentity(game.data, game.save.party[1], true) +T.check(Stats.isShiny(game.save.party[1].dvs), + "condition A uses the real Gen 2 DV shiny predicate") +T.check(cooperator.storage:write(game, "history", { generation = "A" }), + "independent history condition A writes through mod.storage") + +local overworldA, captureCode = cooperator.checkpoints:capture(game) +T.check(overworldA ~= nil, "condition A overworld captures: " .. tostring(captureCode)) + +-- Mutate canonical progress, both mod.save buckets, independent storage, +-- options, and runtime caches to condition B. +game.save.money = 999999 +setPokemonIdentity(game.data, game.save.party[1], false) +cooperator.setStage("B") +passive.setStage("B") +optionBucket.mode = "B" +rngState = "cross-mod-rng-B" +T.check(cooperator.storage:write(game, "history", { generation = "B" }), + "independent history advances to condition B") + +local bad = cooperator.checkpoints:capture(game) +bad.format = 99 +local rejected, rejectCode = cooperator.checkpoints:restore(game, bad) +T.check(rejected == false and rejectCode == "unsupported_format", + "failed checkpoint validation is reported") +T.eq(cooperator.restoreCount(), 0, "failed restore emits no lifecycle event") +T.eq(cooperator.cachedStage(), "B", "failed restore leaves runtime cache at B") +T.eq(cooperator.stage(), "B", "failed restore leaves mod.save at B") + +local failedTarget = cooperator.checkpoints:capture(game) +game.failNextEnter = true +local failed, failedCode = cooperator.checkpoints:restore(game, failedTarget) +T.check(failed == false and failedCode == "restore_failed", + "failed reconstruction rolls back without committing") +T.eq(cooperator.restoreCount(), 0, + "failed reconstruction and successful rollback emit no lifecycle event") +T.eq(cooperator.cachedStage(), "B", + "failed reconstruction leaves cooperating runtime cache at B") +T.eq(cooperator.stage(), "B", + "failed reconstruction rollback leaves mod.save at B") + +local restored, restoreCode, restoreMessage = + cooperator.checkpoints:restore(game, overworldA) +T.check(restored == true, + "condition A overworld restores: " .. tostring(restoreCode or restoreMessage)) +T.eq(game.save.money, overworldA.save.money, "core game progress rewinds to A") +T.eq(game.save.party[1].shiny, true, + "shiny marker rewinds with its Pokemon record") +T.check(Stats.isShiny(game.save.party[1].dvs), + "authoritative shiny DVs rewind with the Pokemon record") +T.eq(cooperator.stage(), "A", "cooperating mod.save progress rewinds to A") +T.eq(passive.stage(), "A", "all mods' mod.save progress rewinds generically") +T.eq(passive.cachedStage(), "B", + "runtime-only state is not serialized for a non-cooperating mod") +T.eq(cooperator.cachedStage(), "A", + "checkpoint lifecycle lets a cooperating mod rebuild its runtime cache") +T.same(cooperator.storage:read(game, "history"), { generation = "B" }, + "independent mod.storage history does not rewind") +T.eq(cooperator.restoreCount(), 1, "successful overworld restore emits once") +local overworldEvent = cooperator.lastRestore or {} +T.eq(overworldEvent.game, game, "restore event carries the final live game") +T.eq(overworldEvent.kind, "overworld", "restore event identifies overworld") +T.eq(overworldEvent.top, ow, + "restore event runs after the reconstructed overworld is installed") +T.eq(loader.modOptions.cooperator.mode, "B", + "per-mod global options stay at condition B") +T.eq(game.save.options.modOptions.cooperator.mode, "B", + "checkpoint reattaches the current global options table") + +-- Repeat the same ownership rules at a supported ordinary wild battle safe point. +cooperator.setStage("battle-A") +passive.setStage("battle-A") +setPokemonIdentity(game.data, game.save.party[1], true) +local battle = BattleState.newWild(game, "FIXMON_B", 12) +battle.phase, battle.queue = "menu", {} +battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" } +battle.musicKind = battle:computeMusicKind() +battle.onFinish = function() end +setBattlerIdentity(game.data, battle.enemy, true) +game.stack.states[2] = battle +rngState = "cross-mod-battle-rng-A" + +local battleA, battleCaptureCode = cooperator.checkpoints:capture(game) +T.check(battleA and battleA.kind == "battle", + "condition A battle captures: " .. tostring(battleCaptureCode)) +if battleA then + setBattlerIdentity(game.data, battle.player, false) + setBattlerIdentity(game.data, battle.enemy, false) + cooperator.setStage("battle-B") + passive.setStage("battle-B") + optionBucket.mode = "C" + rngState = "cross-mod-battle-rng-B" + T.check(cooperator.storage:write(game, "history", { generation = "battle-B" }), + "independent history advances during battle") + + local battleRestored, battleRestoreCode, battleRestoreMessage = + cooperator.checkpoints:restore(game, battleA) + T.check(battleRestored == true, + "condition A battle restores: " + .. tostring(battleRestoreCode or battleRestoreMessage)) + local restoredBattle = game.stack:top() + T.eq(restoredBattle.player.mon, game.save.party[1], + "restored player battler rebinds to canonical party Pokemon") + T.eq(restoredBattle.player.mon.shiny, true, + "player shiny metadata rewinds through battle reconstruction") + T.check(Stats.isShiny(restoredBattle.player.mon.dvs), + "player shiny DVs rewind through battle reconstruction") + T.eq(restoredBattle.enemy.mon.shiny, true, + "enemy shiny metadata rewinds with copied battle Pokemon") + T.check(Stats.isShiny(restoredBattle.enemy.mon.dvs), + "enemy shiny DVs rewind through battle reconstruction") + T.eq(cooperator.stage(), "battle-A", "battle restore rewinds mod.save progress") + T.eq(cooperator.cachedStage(), "battle-A", + "battle restore event rebuilds cooperating runtime cache") + T.eq(passive.cachedStage(), "battle-B", + "battle restore still does not serialize arbitrary mod runtime") + T.same(cooperator.storage:read(game, "history"), { generation = "battle-B" }, + "battle restore leaves independent history current") + T.eq(loader.modOptions.cooperator.mode, "C", + "battle restore leaves per-mod global options current") + T.eq(cooperator.restoreCount(), 2, "successful battle restore emits once") + local battleEvent = cooperator.lastRestore or {} + T.eq(battleEvent.kind, "battle", "restore event identifies battle") + T.eq(battleEvent.top, restoredBattle, + "battle restore event runs after final battle installation") + T.same(cooperator.checkpoints:capture(game), battleA, + "combined battle and mod progress is a differential roundtrip") +end + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +love.math.getRandomState = oldGetRandomState +love.math.setRandomState = oldSetRandomState +_G.CROSS_MOD_CHECKPOINT = nil + +T.finish()