Merge pull request #1076 from MaxTomahawk/feat/mod-title-checkpoint-resume

feat(mods): resume selected checkpoints from title
This commit is contained in:
bryanthaboi
2026-08-11 21:23:08 -04:00
committed by GitHub
10 changed files with 872 additions and 14 deletions
+130 -9
View File
@@ -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
+81 -3
View File
@@ -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
+7
View File
@@ -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)
+58
View File
@@ -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