Merge pull request #993 from MaxTomahawk/feat/checkpoint-restore-event

feat(mods): signal verified checkpoint restores
This commit is contained in:
bryanthaboi
2026-08-10 14:31:45 -04:00
committed by GitHub
5 changed files with 402 additions and 10 deletions
+28
View File
@@ -242,6 +242,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
+10 -4
View File
@@ -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
+12 -5
View File
@@ -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.
+10 -1
View File
@@ -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))
+342
View File
@@ -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()