Merge pull request #952 from MaxTomahawk/feat/mod-state-checkpoints

Add playthrough-scoped storage and stable overworld checkpoints
This commit is contained in:
bryanthaboi
2026-08-07 12:39:32 -04:00
committed by GitHub
13 changed files with 1593 additions and 18 deletions
+53
View File
@@ -140,6 +140,59 @@ default** (1x front, 2x back).
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Durable tool storage and runtime checkpoints
`mod.save` remains the right place for state that should travel with the next
normal Pokémon SAVE. Tools that need independently written, larger data-only
records can use `mod.storage`; the engine scopes every logical key by game
version, opaque playthrough identity, and mod id, and routes it through the same
standard or portable persistence backend as saves:
```lua
local context, code, message = mod.storage:context(game)
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
format = 1, createdAt = os.time(), payload = { money = 3000 },
})
local value, code, message = mod.storage:read(game, "history/quick/q0001")
local keys, code, message = mod.storage:list(game, "history/quick")
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.
Values must be tables containing serializable data only. Keys are conservative
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
handles are never exposed. Writes are staged and decode-verified, reads recover
from a valid staged/backup generation, and methods return structured errors for
normal data or I/O failures. The playthrough identity is allocated lazily on the
first storage/checkpoint call, so an unused API changes no save bytes.
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
```lua
local capability = mod.checkpoints:inspect(game)
if capability.canCapture then
local checkpoint, code, message = mod.checkpoints:capture(game)
-- Store the detached data-only checkpoint through mod.storage.
end
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
```
Checkpoint format 1 supports settled overworld control only: the overworld must
be topmost, the player stationary on a tile, and no transition, menu, script,
queued script movement, or partial field animation may be active. Refusals carry
a stable `reason` and readable `message`. Capture excludes global options and
runtime objects. Restore validates format, game/playthrough identity, content,
and coordinates before mutation; preserves current options; suppresses normal
map-entry/save-load side effects; verifies a recapture; and rolls back in memory
if reconstruction fails. Callers that need crash recovery should durably capture
their own recovery checkpoint before restore.
See RFC 0003 and RFC 0004 for exact contracts and error codes.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+124
View File
@@ -0,0 +1,124 @@
# RFC 0003 — Playthrough-scoped mod storage
## Status
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
save-slot and mod-save suites.
## Motivation
`mod.save` intentionally lives inside the normal progress record. That is the
right home for quest state, but not for independent tool data such as replay
captures, checkpoint histories, or recovery records: writing it would require a
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
recursively embed the save that contains them.
Mods also cannot safely infer which launcher slot or portable filesystem backs
the active playthrough. Direct filesystem access would expose private paths and
make isolation dependent on engine implementation details.
## The decision it extends
Extends the per-mod persistence contract documented in `docs/modding.md` and the
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
facade to the calling mod id. Mods receive logical keys and decoded values, never
filesystem handles or physical paths.
### Lazy opaque playthrough identity
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
32-hex-character identity without consuming gameplay RNG. It is called only when
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
and ordinary load remain byte-compatible when no caller uses either API.
The id is stored in `save.meta.playthroughId` after allocation. Until the next
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
saves stable by game version and active launcher slot (or the legacy flat-save
scope). A newly created playthrough never adopts the previous playthrough's
mapping for that slot.
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
implementation. It follows the same standard/portable backend as progress and
honors injected test filesystems; it is not exposed on the mod object.
### `mod.storage:context(game)`
Returns:
```lua
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
```
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
the context intentionally omits launcher slot ids and paths.
### `mod.storage:write(game, key, value)`
Accepts a data-only table and returns `true`, or
`false, code, message`. Keys are nonempty slash-separated segments containing
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
`.`/`..`, and other characters are rejected.
The engine encodes deterministically, stages and decodes a `.tmp` witness,
preserves the previous valid generation, writes and decodes the main record,
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
verified prior generation readable.
### `mod.storage:read(game, key)`
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
then backup data. A valid staged/backup value is returned and promoted
best-effort; corrupt bytes are never executed.
### `mod.storage:list(game[, prefix])`
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
names one, or `nil, code, message`. Physical witness filenames are hidden.
### `mod.storage:delete(game, key)`
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
`false, code, message`.
### Scope and errors
Physical records are scoped as:
`persistence root / mod_storage / game version / playthrough id / mod id`
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
`not_found`. Ordinary data and I/O failures are return values, not callback-
terminating errors.
The restricted serializer's recursive writer runs outside LuaJIT traces. A
1,000-process GC stress regression found compiled recursion could intermittently
drop a newly inserted nested identity entry and produce undecodable bytes; save
encoding is infrequent and I/O-bound, so interpreter execution is the safe
boundary.
## Migration note for existing mods
**Nothing.** No API is removed, no manifest field changes, and no storage path or
playthrough id is created unless a mod invokes `mod.storage` or
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
## Parity tests
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
file; the existing save-slot and mod-save suites remain green.
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
mapping, fresh-playthrough replacement, and version/slot isolation.
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
deterministic listing, key rejection, mod/game/playthrough isolation,
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
## Deprecation etiquette
Nothing deprecated. The additions are one bound public facade and engine-private
persistence/identity helpers.
+131
View File
@@ -0,0 +1,131 @@
# RFC 0004 — Stable runtime checkpoints for mods
## Status
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
## Motivation
Mods can observe world events and request semantic actions, but no supported API
can capture canonical progress at a proven-safe runtime boundary or reconstruct
the overworld without replaying map-entry scripts. Reaching into the state stack,
controller, ScriptRunner, or save restore internals would bind distributable mods
to private objects and can duplicate story side effects.
The engine is the only component that can authoritatively decide whether the
runtime is settled and rebuild its controller objects. A generic checkpoint seam
lets tools store data-only records while keeping those responsibilities private.
## The decision it extends
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
## The exact API delta
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
internals inside a checkpoint.
### `mod.checkpoints:inspect(game)`
Returns a capability record. Stable overworld control returns:
```lua
{ canCapture = true, canRestore = true, kind = "overworld" }
```
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
player-readable `message`. Format-1 supports only an overworld whose controller
is topmost, player movement has settled on a tile, and no transition, foreground
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
teleport, field animation, or similar partial controller mutation is active.
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
Identity allocation is lazy and happens only after an active topmost overworld
has been established.
### `mod.checkpoints:capture(game)`
Returns a detached data-only format-1 checkpoint, or
`nil, code, message`:
```lua
{
format = 1,
kind = "overworld",
identity = {
engineVersion = "...", gameVersion = "red", playthroughId = "...",
},
save = { -- canonical dynamic progress, excluding global options },
runtime = { overworld = {
map = "PALLET_TOWN", x = 5, y = 6,
facing = "down", surfing = false,
} },
}
```
`engineVersion` is metadata for caller compatibility warnings; the engine does
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
state. It excludes `save.options`, functions, userdata, threads, metatables as
behavior, controller instances, and static content registries. Failure code
`capture_failed` covers non-data progress and synchronization errors.
### `mod.checkpoints:restore(game, checkpoint)`
Returns `true`, or `false, code, message`. Before mutation it requires the current
runtime to be capturable and validates a detached copy of the complete record:
format, kind, internal identity consistency, current game/playthrough identity,
map availability, integral in-bounds tile, facing, surfing, and synchronized save
position.
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
`invalid_position`, in addition to the capability refusal reasons.
The canonical save validator runs against the detached record. Unlike ordinary
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
repair: any such content change returns `invalid_content` before live mutation.
The engine captures an in-memory rollback checkpoint, preserves current global
options, then reconstructs semantic overworld state through
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
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`.
Durable recovery remains a caller responsibility: in-memory rollback handles a
runtime exception, not process termination.
## Runtime boundary and future kinds
Format 1 intentionally rejects battles, menus, transitions, animations, and
suspended/queued scripts. Future battle or explicit script-checkpoint kinds must
have separate inventories, validation, reconstruction, deterministic RNG, and
differential tests; they are not implied by this RFC.
## Migration note for existing mods
**Nothing.** No existing hook, event, save, controller, or world action changes
when `mod.checkpoints` is unused. The reconstruction path is called only by a
successful public restore after validation.
## Parity tests
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
green; ordinary New Game/save/load allocates no checkpoint identity.
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
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.
## Deprecation etiquette
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
reconstruction route.
+266
View File
@@ -0,0 +1,266 @@
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
-- methods; mods never receive controller or state-stack internals from here.
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local Checkpoint = {}
Checkpoint.FORMAT = 1
local function refusal(kind, reason, message)
return {
canCapture = false,
canRestore = false,
kind = kind or "unknown",
reason = reason,
message = message,
}
end
local function running(runner)
return runner and runner.isRunning and runner:isRunning()
end
local function nonempty(value)
return type(value) == "table" and next(value) ~= nil
end
function Checkpoint.inspect(game)
local save = game and game.save
if type(save) ~= "table" or type(save.version) ~= "string" then
return refusal("unknown", "not_in_playthrough",
"A checkpoint requires an identified active playthrough.")
end
local ow = game.overworld
if type(ow) ~= "table" or type(ow.map) ~= "table"
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
return refusal("unknown", "not_overworld",
"Only a settled overworld can be checkpointed.")
end
local top = game.stack and game.stack.top and game.stack:top()
if top ~= ow then
return refusal("overworld", "screen_busy",
"Close the active menu or screen before creating a checkpoint.")
end
local identity = save.meta and save.meta.playthroughId
if type(identity) ~= "string" or identity == "" then
identity = SaveData.ensurePlaythroughId(save)
end
if type(identity) ~= "string" or identity == "" then
return refusal("overworld", "not_in_playthrough",
"The active playthrough could not be identified.")
end
if ow.transitioning then
return refusal("overworld", "transition_busy",
"Wait for the map transition to finish.")
end
if running(ow.runner) or nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves) then
return refusal("overworld", "script_busy",
"Wait for the active or queued script to finish.")
end
local animationFields = {
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
"pikaHop", "healAnim", "flyAnim", "flyArrive",
}
for _, field in ipairs(animationFields) do
if ow[field] then
return refusal("overworld", "animation_busy",
"Wait for the overworld animation to finish.")
end
end
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
return refusal("overworld", "movement_busy",
"Wait for movement to settle on a tile.")
end
return { canCapture = true, canRestore = true, kind = "overworld" }
end
local function dataCopy(value)
local ok, encoded = pcall(SaveSerializer.encode, value)
if not ok then return nil, tostring(encoded) end
local decoded, err = SaveSerializer.decode(encoded)
if not decoded then return nil, err end
return decoded
end
function Checkpoint.capture(game)
local capability = Checkpoint.inspect(game)
if not capability.canCapture then
return nil, capability.reason, capability.message
end
local progress = {}
for key, value in pairs(game.save) do
if key ~= "options" then progress[key] = value end
end
progress = dataCopy(progress)
if not progress then
return nil, "capture_failed", "Progress contains non-serializable runtime data."
end
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
if not ok then
return nil, "capture_failed", "Could not synchronize overworld progress: "
.. tostring(err)
end
progress, err = dataCopy(progress)
if not progress then
return nil, "capture_failed", "Synchronized progress is not data-only: "
.. tostring(err)
end
local player = game.overworld.player
return {
format = Checkpoint.FORMAT,
kind = "overworld",
identity = {
engineVersion = Version.engine,
gameVersion = game.save.version,
playthroughId = game.save.meta.playthroughId,
},
save = progress,
runtime = { overworld = {
map = game.overworld.map.id,
x = player.cellX,
y = player.cellY,
facing = player.facing,
surfing = player.surfing and true or false,
} },
}
end
local FACINGS = { up = true, down = true, left = true, right = true }
local function validate(game, checkpoint)
if type(checkpoint) ~= "table" then
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
end
if checkpoint.format ~= Checkpoint.FORMAT then
return nil, "unsupported_format", "This checkpoint format is not supported."
end
if checkpoint.kind ~= "overworld" then
return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported."
end
local copy, copyErr = dataCopy(checkpoint)
if not copy then
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
.. tostring(copyErr)
end
local identity = copy.identity
local current = game and game.save
local currentId = current and current.meta and current.meta.playthroughId
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
return nil, "wrong_game", "Checkpoint belongs to another game version."
end
if identity.playthroughId ~= currentId then
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
end
local save = copy.save
local runtime = copy.runtime and copy.runtime.overworld
if type(save) ~= "table" or type(save.player) ~= "table"
or type(runtime) ~= "table" then
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
end
if save.version ~= identity.gameVersion
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
end
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
end
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
or (save.player.surfing and true or false) ~= runtime.surfing then
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
end
local map = game.data and game.data.maps and game.data.maps[runtime.map]
if type(map) ~= "table" then
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
end
local width, height = tonumber(map.width), tonumber(map.height)
if not width or not height or runtime.x < 0 or runtime.y < 0
or runtime.x >= width * 2 or runtime.y >= height * 2 then
return nil, "invalid_position", "Checkpoint position is outside the map."
end
-- A checkpoint is a strict restoration record, not an ordinary CONTINUE
-- migration. Reuse the canonical save validator on the detached copy, but
-- reject any quarantine, remap, reclaim, clamp, or content repair it would
-- perform instead of silently changing the state the caller selected.
local beforeContent = SaveSerializer.encode(copy.save)
local validOk, report = pcall(SaveData.validate, copy.save, game.data)
local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save)
if not validOk or not afterOk or not SaveData.emptyReport(report)
or afterContent ~= beforeContent then
return nil, "invalid_content",
"Checkpoint references unavailable or invalid game content."
end
return copy
end
local function apply(game, checkpoint, options)
local save, err = dataCopy(checkpoint.save)
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
local runtime = checkpoint.runtime.overworld
save.options = options
save.player.map = runtime.map
save.player.x = runtime.x
save.player.y = runtime.y
save.player.facing = runtime.facing
save.player.surfing = runtime.surfing
if type(game.restoreCheckpointSave) ~= "function" then
error("game has no checkpoint reconstruction path", 0)
end
game:restoreCheckpointSave(save)
end
local function equalData(a, b)
local okA, encodedA = pcall(SaveSerializer.encode, a)
local okB, encodedB = pcall(SaveSerializer.encode, b)
return okA and okB and encodedA == encodedB
end
function Checkpoint.restore(game, checkpoint)
local capability = Checkpoint.inspect(game)
if not capability.canRestore 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 rollback, captureCode, captureMessage = Checkpoint.capture(game)
if not rollback then return false, captureCode, captureMessage end
local options = game.save.options
local ok, err = pcall(apply, game, validated, options)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and equalData(restored, validated) then return true end
err = "restored state did not match checkpoint: " .. tostring(verifyCode)
end
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
if not rolledBack then
return false, "rollback_failed",
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
end
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
end
return Checkpoint
+13
View File
@@ -1130,4 +1130,17 @@ function Game:restoreSave(loaded, recovered)
end
end
-- Reconstruct a previously validated runtime checkpoint without replaying the
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
-- save.loading/save.loaded events must not run a second time. Validation,
-- identity checks and transactional rollback live in Checkpoint.lua.
function Game:restoreCheckpointSave(loaded)
self.save = loaded
self:adoptSave(loaded)
while self.stack:top() do self.stack:pop() end
self.stack:push(self.overworld, loaded.player.map,
loaded.player.x, loaded.player.y, loaded.player.facing,
{ via = "checkpoint", checkpoint = true })
end
return Game
+98 -3
View File
@@ -216,6 +216,13 @@ local function persistFs(fs)
return SaveData.portableFs() or fs or (love and love.filesystem)
end
-- Engine-owned persistence routing for subsystems that must follow the same
-- standard/portable root as saves without exposing raw filesystem access to a
-- mod. An explicitly injected headless filesystem still wins for tests.
function SaveData.persistenceFs(fs)
return persistFs(fs)
end
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions()
@@ -479,6 +486,10 @@ end
-- working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
-- At most one New Game can be the live candidate for a first public tool
-- request. A single strong reference models that runtime fact without adding
-- marker data to the save or retaining abandoned playthrough tables.
local freshPlaythrough
local function slotDir(version) return "saves/" .. version end
@@ -815,6 +826,77 @@ end
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
freshPlaythrough = nil
end
-- ------- opaque playthrough identity
-- An id must never perturb the engine's gameplay RNG: savestate tools need
-- repeatable random outcomes, and allocating persistence scope is not gameplay.
-- Combine wall/process time, a process-local sequence and a fresh table address
-- into four hex words. This is an opaque collision-resistant identifier, not a
-- secret or a player-visible value.
local playthroughSeq = 0
local function word(n)
return math.floor(tonumber(n) or 0) % 4294967296
end
function SaveData.newPlaythroughId()
playthroughSeq = playthroughSeq + 1
local address = tostring({}):match("0x(%x+)") or "0"
local addressLo = tonumber(address:sub(-8), 16) or 0
local clock = math.floor((os.clock() or 0) * 1000000)
return ("%08x%08x%08x%08x"):format(
word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
end
local function playthroughScope(version, injectedFs)
version = version or GameVersion.get()
local fs = persistFs(injectedFs)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or "legacy"
end
local function rememberPlaythroughId(save, opts, injectedFs)
local meta = type(save) == "table" and save.meta
local id = type(meta) == "table" and meta.playthroughId
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)
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
local changed = opts.playthroughIds[version][scope] ~= id
opts.playthroughIds[version][scope] = id
return opts, changed
end
-- Return an existing save identity or give a pre-identity save a stable one.
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
-- into progress, so installing a tool mod never rewrites the player's checkpoint.
function SaveData.ensurePlaythroughId(save, injectedFs)
if type(save) ~= "table" then return nil end
save.meta = type(save.meta) == "table" and save.meta or {}
local id = save.meta.playthroughId
if type(id) == "string" and id ~= "" then return id end
local version = save.version or GameVersion.get()
local scope = playthroughScope(version, injectedFs)
local opts = SaveData.loadOptions(injectedFs)
local isFresh = save == freshPlaythrough
if isFresh then freshPlaythrough = nil end
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
id = not isFresh and byVersion and byVersion[scope] or nil
if type(id) ~= "string" or id == "" then
id = SaveData.newPlaythroughId()
opts.playthroughIds = opts.playthroughIds or {}
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
opts.playthroughIds[version][scope] = id
SaveData.saveOptions(opts, injectedFs)
end
save.meta.playthroughId = id
return id
end
-- ------- meta
@@ -838,6 +920,7 @@ function SaveData.buildMeta(mods, previous)
format = Version.saveFormat,
engine = Version.engine,
savedAt = os.time(),
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
mods = list,
}
end
@@ -1048,7 +1131,15 @@ function SaveData.save(data, mods)
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
local opts = data.options
if data.meta and data.meta.playthroughId then
opts = rememberPlaythroughId(data, data.options)
end
data.options = opts
SaveData.saveOptions(opts)
elseif data.meta and data.meta.playthroughId then
local opts, changed = rememberPlaythroughId(data)
if changed then SaveData.saveOptions(opts) end
end
if mods ~= nil or data.meta == nil then
data.meta = SaveData.buildMeta(mods, data.meta)
@@ -1470,8 +1561,12 @@ function SaveData.newGame(boot)
options = SaveData.loadOptions(),
}
-- a total conversion reshapes the skeleton (spawn, party, money)
-- before anything reads it; unhooked this returns save unchanged
return Runtime.call("save.new_game", function(s) return s end, save)
-- before anything reads it; unhooked this returns save unchanged. Keep the
-- "fresh playthrough" marker outside the serialized table so a later tool
-- request can distinguish two unsaved New Games sharing one vanilla slot.
save = Runtime.call("save.new_game", function(s) return s end, save)
freshPlaythrough = save
return save
end
return SaveData
+7
View File
@@ -41,6 +41,13 @@ local function serialize(v, indent)
error("cannot serialize " .. t)
end
-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands
-- inside a compiled recursive serialization trace. The symptom is valid input
-- becoming `{" followed by only the trailing comma, which then cannot be read
-- back. Save encoding is infrequent and I/O-bound, so keep this correctness-
-- critical recursion in the interpreter while leaving the game JIT enabled.
if jit and jit.off then jit.off(serialize, true) end
function SaveSerializer.encode(data)
return "return " .. serialize(data) .. "\n"
end
+22
View File
@@ -569,6 +569,9 @@ end
function Loader:_api(mod)
local loader = self
local modId = mod.manifest.id
local Storage = engineRequire("src.mods.Storage")
local storage = Storage and Storage.new(modId, loader.fs)
local Checkpoint = engineRequire("src.core.Checkpoint")
local api = {
id = modId,
version = mod.manifest.version,
@@ -658,6 +661,25 @@ function Loader:_api(mod)
bucket[key] = value
end,
},
-- Data-only state independent of the vanilla progress checkpoint. The
-- engine binds version/playthrough/mod scope and portable persistence;
-- callers never receive paths or a raw filesystem handle.
storage = {
context = function(_, game) return storage:context(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,
delete = function(_, game, key) return storage:delete(game, key) end,
},
-- Runtime safety and reconstruction stay engine-owned. Checkpoints contain
-- data only; no controller, stack, coroutine or renderer object crosses out.
checkpoints = {
inspect = function(_, game) return Checkpoint.inspect(game) end,
capture = function(_, game) return Checkpoint.capture(game) end,
restore = function(_, game, checkpoint)
return Checkpoint.restore(game, checkpoint)
end,
},
options = {
define = function(_, schema)
assert(type(schema) == "table", "options schema must be a table of rows")
+220
View File
@@ -0,0 +1,220 @@
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
-- This module is engine-private; Loader exposes only the bound facade methods.
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
local Storage = {}
Storage.__index = Storage
local ROOT = "mod_storage"
local function failure(code, message)
return nil, code, message
end
local function validSegment(value)
return type(value) == "string" and value ~= ""
and value:match("^[%w_-]+$") ~= nil
end
local function validKey(key, allowEmpty)
if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end
if key == "" then return true end
if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then
return false
end
for segment in key:gmatch("[^/]+") do
if not validSegment(segment) then return false end
end
return true
end
local function ensureParent(fs, path)
local dir = path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
local function remove(fs, path)
if fs.remove then fs.remove(path) end
end
local function decodeAt(fs, path)
if not (fs.getInfo and fs.getInfo(path)) then return nil end
local body = fs.read and fs.read(path)
if type(body) ~= "string" then return nil end
local data = SaveSerializer.decode(body)
if not data then return nil end
return data, body
end
function Storage.new(modId, fs)
assert(validSegment(modId), "Storage.new needs a safe mod id")
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
end
function Storage:_scope(game)
local save = game and game.save
local meta = save and save.meta
local version = save and save.version
if not (save and validSegment(version)) then
return failure("not_in_playthrough",
"Storage is available only inside an identified playthrough.")
end
local playthroughId = meta and meta.playthroughId
if not validSegment(playthroughId) then
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
end
if not validSegment(playthroughId) then
return failure("not_in_playthrough",
"Storage could not identify the active playthrough.")
end
local fs = SaveData.persistenceFs(self.injectedFs)
if not (fs and fs.read and fs.write and fs.getInfo) then
return failure("storage_unavailable", "The persistence backend is unavailable.")
end
local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/")
return { gameVersion = version, playthroughId = playthroughId,
base = base, fs = fs }
end
function Storage:context(game)
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
return {
engineVersion = Version.engine,
gameVersion = scope.gameVersion,
playthroughId = scope.playthroughId,
}
end
function Storage:_names(game, key, allowEmpty)
if not validKey(key, allowEmpty) then
return failure("invalid_key",
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
end
local scope, code, message = self:_scope(game)
if not scope then return nil, code, message end
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
end
function Storage:write(game, key, value)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return false, main, bak end
if type(value) ~= "table" then
return false, "encode_failed", "Storage values must be data-only tables."
end
local encodedOk, encoded = pcall(SaveSerializer.encode, value)
if not encodedOk then
return false, "encode_failed", "Storage value is not serializable data: "
.. tostring(encoded)
end
local fs = scope.fs
ensureParent(fs, main)
local _, previous = decodeAt(fs, main)
if not previous then _, previous = decodeAt(fs, bak) end
local ok, err = fs.write(tmp, encoded)
if not ok then
return false, "write_failed", "Could not stage storage data: " .. tostring(err)
end
local staged = decodeAt(fs, tmp)
if not staged then
remove(fs, tmp)
return false, "verify_failed", "Staged storage data could not be verified."
end
if previous then fs.write(bak, previous) end
ok, err = fs.write(main, encoded)
if not ok then
remove(fs, tmp)
return false, "write_failed", "Could not replace storage data: " .. tostring(err)
end
local verified = decodeAt(fs, main)
if not verified then
remove(fs, main)
remove(fs, tmp)
return false, "verify_failed", "Replacement storage data could not be verified."
end
-- At rest both main and backup hold the newest verified record. If a later
-- write dies after rolling this copy aside, one verified generation remains.
fs.write(bak, encoded)
remove(fs, tmp)
return true
end
function Storage:read(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return nil, main, bak end
local fs = scope.fs
local data, body = decodeAt(fs, main)
if data then return data end
data, body = decodeAt(fs, tmp)
if not data then data, body = decodeAt(fs, bak) end
if not data then
return nil, "not_found", "No valid stored value exists for this key."
end
-- Best-effort healing. The recovered copy remains in tmp/bak if promotion
-- cannot land, so returning it is still safe and the next read can retry.
ensureParent(fs, main)
if fs.write(main, body) then fs.write(bak, body) end
remove(fs, tmp)
return data
end
function Storage:list(game, prefix)
prefix = prefix or ""
local scope, main, codeOrBak = self:_names(game, prefix, true)
if not scope then return nil, main, codeOrBak end
local fs = scope.fs
if not fs.getDirectoryItems then
return nil, "storage_unavailable", "The persistence backend cannot enumerate keys."
end
local base = scope.base
local start = prefix == "" and base or (base .. "/" .. prefix)
local out = {}
local function walk(path, logical)
local info = fs.getInfo(path)
if not info then return end
if info.type == "file" then
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
return
end
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
local childLogical = logical == "" and child or (logical .. "/" .. child)
walk(path .. "/" .. child, childLogical)
end
end
-- A prefix may identify one exact key or a directory of keys.
if fs.getInfo(start .. ".lua") then
out[#out + 1] = prefix
else
walk(start, prefix)
end
table.sort(out)
return out
end
function Storage:delete(game, key)
local scope, main, bak, tmp = self:_names(game, key, false)
if not scope then return false, main, bak end
local fs = scope.fs
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
return false, "not_found", "No stored value exists for this key."
end
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
return true
end
return Storage
+10 -6
View File
@@ -206,7 +206,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
return out
end
function OverworldState:enter(mapId, x, y, facing)
function OverworldState:enter(mapId, x, y, facing, opts)
Game = require("src.core.Game")
Game.overworld = self
Collision.load(Game.data) -- tile-pair (elevation) collisions
@@ -227,7 +227,7 @@ function OverworldState:enter(mapId, x, y, facing)
-- survives save/load: a loaded game may start inside a building whose
-- exit mat is a LAST_MAP warp
self.lastOutdoor = Game.save.lastOutdoor
self:setMap(mapId, x, y, facing, { via = "boot" })
self:setMap(mapId, x, y, facing, opts or { via = "boot" })
-- boot/load: derive the flag from the tile the save left us standing on,
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
-- door mat can still walk straight back out (issue #378)
@@ -282,7 +282,7 @@ end
function OverworldState:setMap(mapId, x, y, facing, opts)
local fromMapId = self.map and self.map.id
if fromMapId then
if fromMapId and not (opts and opts.checkpoint) then
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
end
-- ambient choreography is per-map: parallel runners die here, and the
@@ -460,13 +460,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
-- idle on those cells waiting for a player step.
self:checkSeafoamCurrent()
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
@@ -476,27 +476,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- fires before the onEnter chain so a listener sees the map in the same
-- state the map script does
if not (opts and opts.checkpoint) then
Runtime.emit("map.entered", {
mapId = mapId, map = self.map, fromMapId = fromMapId,
via = (opts and opts.via)
or (opts and opts.seamless and "connection")
or (fromMapId and "warp" or "boot"),
})
end
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
-- the player B-cancels the floor menu without .UpdateWarp.
if not (opts and opts.checkpoint) then
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self, fromMapId)
end
end
self:rebuildNeighbors()
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
end
-- Neighbor maps drawn at the composed connection offsets: at least the
+154
View File
@@ -0,0 +1,154 @@
-- Opaque playthrough identity: New Game uniqueness, save/load persistence,
-- stable legacy backfill, and version/slot isolation. No real save directory.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = love or require("tests.love_stub")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local GameVersion = require("src.core.GameVersion")
local realFS = love.filesystem
local function memfs(files)
return {
write = function(path, content) files[path] = content return true end,
read = function(path) return files[path] 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,
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 fresh()
local files = {}
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
return files
end
local function legacy(version, name)
return {
version = version,
meta = { format = 4, mods = {} },
player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 },
flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {},
money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
}
end
-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope.
do
fresh()
local first = SaveData.newGame({ version = "red" })
local second = SaveData.newGame({ version = "red" })
T.eq(first.meta.playthroughId, nil,
"New Game allocates no playthrough id before a public tool requests it")
T.check(SaveData.save(first), "unused identity fixture saves")
local untouched = SaveData.load("red")
T.eq(untouched.meta.playthroughId, nil,
"normal save/load stays identity-free when no tool uses the capability")
local firstId = SaveData.ensurePlaythroughId(first)
local secondId = SaveData.ensurePlaythroughId(second)
T.check(type(firstId) == "string" and firstId ~= "",
"the first tool request allocates an opaque playthrough id")
T.neq(secondId, firstId,
"separate New Games receive separate requested playthrough ids")
end
-- Dropping the id from buildMeta or save encoding must fail the roundtrip.
do
fresh()
local save = SaveData.newGame({ version = "red" })
local expected = SaveData.ensurePlaythroughId(save)
T.check(SaveData.save(save), "identity fixture saves")
local loaded = SaveData.load("red")
T.eq(loaded and loaded.meta.playthroughId, expected,
"normal save/load preserves the playthrough id")
end
-- Legacy identity is persisted independently: the legacy progress bytes remain
-- unchanged, yet two loads resolve the same id before a normal SAVE occurs.
do
local files = fresh()
local raw = legacy("red", "LEGACY")
files["save.lua"] = SaveSerializer.encode(raw)
local first = SaveData.load("red")
T.eq(first and first.meta.playthroughId, nil,
"loading a legacy save alone does not allocate tool identity")
local id = SaveData.ensurePlaythroughId(first)
T.check(type(id) == "string" and id ~= "",
"a legacy save receives a playthrough id")
local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "")
T.check(mappedOptions ~= nil,
"legacy identity mapping remains decodable: " .. tostring(mappedErr))
local slotBytes = files["saves/red/slot1.lua"]
local onDisk = slotBytes and SaveSerializer.decode(slotBytes)
T.eq(onDisk and onDisk.meta.playthroughId, nil,
"legacy backfill does not rewrite normal progress")
SaveData.resetSlotState()
local second = SaveData.load("red")
T.eq(SaveData.ensurePlaythroughId(second), id,
"legacy backfill is stable across reload before normal SAVE")
end
-- Reusing names and coordinates cannot merge identities across slots or games.
do
fresh()
local redA = SaveData.createSlot("red")
local redB = SaveData.createSlot("red")
SaveData.setActiveSlot("red", redA)
T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")),
"seed red slot A")
local idA = SaveData.ensurePlaythroughId(SaveData.load("red"))
SaveData.setActiveSlot("red", redB)
T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")),
"seed red slot B")
local idB = SaveData.ensurePlaythroughId(SaveData.load("red"))
GameVersion.set("blue")
local blue = SaveData.createSlot("blue")
SaveData.setActiveSlot("blue", blue)
T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")),
"seed blue slot")
local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue"))
T.neq(idA, idB, "two active slots do not share legacy identity")
T.neq(idA, idBlue, "Red and Blue do not share legacy identity")
T.neq(idB, idBlue, "every version/slot scope is isolated")
end
love.filesystem = realFS
SaveData.resetSlotState()
GameVersion.set("red")
T.finish("playthrough_identity")
+305
View File
@@ -0,0 +1,305 @@
-- Public mod.checkpoints contract over a semantic Game/StateStack fixture.
-- The mod entry chunk sees no private module; the harness builds the engine side.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod checkpoints")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
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 baseSave()
return {
version = "red",
meta = { format = 4, mods = {}, playthroughId = "play-a" },
player = {
map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false,
name = "RED", rival = "BLUE", id = 7,
},
money = 3000,
party = { { species = "BULBASAUR", level = 5, hp = 19,
moves = { "TACKLE" } } },
flags = { GOT_STARTER = true },
inventory = { POTION = 1 },
pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {},
pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } },
modData = {},
options = { volume = 4, bindings = {} },
}
end
local function makeGame()
local stack = setmetatable({ states = {} }, { __index = StateStack })
local game
local ow = {
map = { id = "PALLET_TOWN" },
player = { cellX = 5, cellY = 6, facing = "down", surfing = false },
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
runner = { isRunning = function() return false end },
}
function ow:captureSave(save)
save.player.map = self.map.id
save.player.x = self.player.cellX
save.player.y = self.player.cellY
save.player.facing = self.player.facing
save.player.surfing = self.player.surfing and true or false
end
function ow:enter(mapId, x, y, facing, opts)
game.lastEnterOpts = opts
if game.failNextEnter then
game.failNextEnter = false
error("injected 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.scriptMoves, self.pendingScripts = {}, {}
self.parallelRunners, self.parallelQueue = {}, {}
self.runner = { isRunning = function() return false end }
end
game = setmetatable({
save = baseSave(), stack = stack, overworld = ow,
data = {
pokemon = { BULBASAUR = { dex = 1 } },
moves = { TACKLE = { pp = 35 } },
items = { POTION = {} },
constants = { fallbackMove = "TACKLE" },
field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } },
maps = {
PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 },
ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 },
BROKEN = { id = "BROKEN", width = 10, height = 9 },
},
},
}, { __index = GameMethods })
stack.states[1] = ow
return game, ow
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_CHECKPOINTS = mod.checkpoints end
]],
}
local game, ow = makeGame()
local loader = Loader.new({ fs = memfs(files) })
loader.game = game
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
local checkpoints = _G.MOD_CHECKPOINTS
T.check(type(checkpoints) == "table",
"Loader exposes mod.checkpoints through the public mod object")
if type(checkpoints) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_CHECKPOINTS = nil
T.finish()
end
local capability = checkpoints:inspect(game)
T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" },
"plain overworld control is a stable checkpoint boundary")
local function refused(mutator, expectedCode, message)
local undo = mutator()
local result = checkpoints:inspect(game)
T.check(result.canCapture == false and result.reason == expectedCode, message)
undo()
end
refused(function()
ow.transitioning = true
return function() ow.transitioning = nil end
end, "transition_busy", "transition frames are rejected")
refused(function()
ow.runner = { isRunning = function() return true end }
return function() ow.runner = { isRunning = function() return false end } end
end, "script_busy", "foreground suspended scripts are rejected")
refused(function()
ow.parallelRunners = { { isRunning = function() return true end } }
return function() ow.parallelRunners = {} end
end, "script_busy", "parallel suspended scripts are rejected")
refused(function()
ow.pendingScripts = { { rows = {} } }
return function() ow.pendingScripts = {} end
end, "script_busy", "queued scripts are rejected")
refused(function()
ow.scriptMoves = { { entity = ow.player } }
return function() ow.scriptMoves = {} end
end, "script_busy", "scripted movement is rejected")
refused(function()
game.stack.states[2] = { screenId = "StartMenu" }
return function() game.stack.states[2] = nil end
end, "screen_busy", "modal screens over the overworld are rejected")
refused(function()
ow.emote = { frames = 1 }
return function() ow.emote = nil end
end, "animation_busy", "partial overworld animations are rejected")
refused(function()
ow.player.moving = true
return function() ow.player.moving = nil end
end, "movement_busy", "partial player movement is rejected")
local titleGame = { save = game.save, stack = {
top = function() return { screenId = "TitleState" } end,
} }
local titleCapability = checkpoints:inspect(titleGame)
T.check(titleCapability.canCapture == false
and titleCapability.reason == "not_overworld",
"title and non-playthrough runtime is rejected")
-- Capture synchronizes semantic position into a detached data-only record.
ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8
ow.player.facing, ow.player.surfing = "left", true
local snapshot, code, message = checkpoints:capture(game)
T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message))
T.eq(snapshot.format, 1, "checkpoint format is explicit")
T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit")
T.same(snapshot.identity, {
engineVersion = Version.engine,
gameVersion = "red",
playthroughId = "play-a",
},
"checkpoint carries compatibility identity")
T.same(snapshot.runtime.overworld,
{ map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true },
"checkpoint carries exact semantic overworld position")
T.eq(snapshot.save.player.map, "ROUTE_1",
"captured progress is synchronized from the live controller")
T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind")
snapshot.save.money = 1
snapshot.runtime.overworld.x = 1
T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress")
T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player")
-- Recapture the unmodified canonical A used for the differential roundtrip.
snapshot = checkpoints:capture(game)
local original = snapshot
game.save.money = 999999
game.save.flags.GOT_STARTER = nil
game.save.party[1].hp = 1
game.save.options.volume = 9
ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3
ow.player.facing, ow.player.surfing = "up", false
local restored, restoreCode, restoreMessage = checkpoints:restore(game, original)
T.check(restored == true,
"valid checkpoint restores: " .. tostring(restoreCode or restoreMessage))
local recaptured = checkpoints:capture(game)
T.same(recaptured, original,
"capture A, mutate B, restore A, capture A2 yields normalized A == A2")
T.eq(game.save.options.volume, 9,
"checkpoint restoration preserves current global settings")
T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true,
"engine reconstruction is marked to suppress map-entry side effects")
-- Compatibility and schema failures occur before any mutation.
local beforeRejected = checkpoints:capture(game)
local wrongFormat = checkpoints:capture(game)
wrongFormat.format = 99
restored, restoreCode = checkpoints:restore(game, wrongFormat)
T.check(not restored and restoreCode == "unsupported_format",
"unknown checkpoint format is rejected")
local wrongGame = checkpoints:capture(game)
wrongGame.identity.gameVersion = "blue"
restored, restoreCode = checkpoints:restore(game, wrongGame)
T.check(not restored and restoreCode == "wrong_game",
"another game version is rejected")
local wrongProfile = checkpoints:capture(game)
wrongProfile.identity.playthroughId = "play-b"
restored, restoreCode = checkpoints:restore(game, wrongProfile)
T.check(not restored and restoreCode == "wrong_playthrough",
"another playthrough is rejected")
local badMap = checkpoints:capture(game)
badMap.runtime.overworld.map = "MISSING_MAP"
badMap.save.player.map = "MISSING_MAP"
restored, restoreCode = checkpoints:restore(game, badMap)
T.check(not restored and restoreCode == "invalid_map",
"unknown content reference is rejected")
T.same(checkpoints:capture(game), beforeRejected,
"validation failures leave the live state unchanged")
local invalidGame = makeGame()
local badSpecies = checkpoints:capture(invalidGame)
badSpecies.save.party[1].species = "MISSING_SPECIES"
restored, restoreCode = checkpoints:restore(invalidGame, badSpecies)
T.check(not restored and restoreCode == "invalid_content",
"unknown Pokemon content is rejected before reconstruction")
T.eq(invalidGame.save.party[1].species, "BULBASAUR",
"invalid Pokemon content leaves the live party unchanged")
-- A reconstruction exception rolls back to the exact pre-operation state.
local target = checkpoints:capture(game)
target.runtime.overworld.map = "BROKEN"
target.runtime.overworld.x, target.runtime.overworld.y = 1, 1
target.save.player.map = "BROKEN"
target.save.player.x, target.save.player.y = 1, 1
target.save.money = 42
local beforeFailure = checkpoints:capture(game)
game.failNextEnter = true
restored, restoreCode = checkpoints:restore(game, target)
T.check(not restored and restoreCode == "restore_failed",
"reconstruction exception is returned as a structured failure")
T.same(checkpoints:capture(game), beforeFailure,
"failed reconstruction rolls back the complete pre-operation checkpoint")
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
T.finish()
+181
View File
@@ -0,0 +1,181 @@
-- Public mod.storage contract: data-only transactions, namespace isolation,
-- deterministic listing, recovery, and failure retention.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod storage")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
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 function memfs(files)
local fs = { files = files, failTmp = false, failMain = false }
function fs.read(path) return files[path] end
function fs.write(path, body)
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
files[path] = body
return true
end
function fs.remove(path) files[path] = nil return true end
function fs.createDirectory() return true end
function fs.getInfo(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
function fs.load(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end
function fs.getDirectoryItems(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
return fs
end
local function game(version, playthroughId)
return { save = {
version = version,
meta = { format = 4, mods = {}, playthroughId = playthroughId },
} }
end
local files = {
["mods/alpha/manifest.json"] = manifest("alpha"),
["mods/alpha/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
]],
["mods/beta/manifest.json"] = manifest("beta"),
["mods/beta/main.lua"] = [[
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
]],
}
local fs = memfs(files)
local loader = Loader.new({ fs = fs })
local current = game("red", "play-a")
loader.game = current
T.check(loader:load({}) == true, "storage fixture mods load")
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
T.check(type(alpha) == "table" and type(beta) == "table",
"Loader exposes mod.storage through the public mod object")
if type(alpha) ~= "table" or type(beta) ~= "table" then
Runtime.events, Runtime.hooks = savedEvents, savedHooks
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()
end
-- Removing scope identity or exposing a mutable private slot id breaks this.
local context = alpha:context(current)
T.same(context, {
engineVersion = Version.engine,
gameVersion = "red",
playthroughId = "play-a",
}, "context exposes stable engine/game/playthrough compatibility identity")
-- Data-only write/read. The literal expected table is independent of storage.
local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } }
local ok, code, message = alpha:write(current, "states/quick/q1", payload)
T.check(ok == true, "data-only payload writes: " .. tostring(code or message))
local loaded = alpha:read(current, "states/quick/q1")
T.same(loaded, payload, "stored payload roundtrips as data")
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
"read returns decoded data rather than the caller's live table")
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
T.check(not bad and badCode == "encode_failed",
"functions are rejected with a stable data-only error")
local escaped, escapedCode = alpha:write(current, "../escape", {})
T.check(not escaped and escapedCode == "invalid_key",
"path traversal is rejected before persistence")
-- Logical enumeration is deterministic and prefix-scoped.
T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
local keys = alpha:list(current, "states/quick")
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
"list returns sorted logical keys under the requested prefix")
-- Mod, playthrough, and game namespaces cannot observe each other.
local missing, missingCode = beta:read(current, "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's payload")
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the payload")
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the payload")
-- Find the implementation-owned file only to inject corruption; assertions stay
-- on public read behavior, not the path shape.
local function mainFor(fragment)
for path in pairs(files) do
if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end
end
end
local q1Main = mainFor("q1")
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
files[q1Main] = "not a serialized table"
loaded, code = alpha:read(current, "states/quick/q1")
T.same(loaded, payload, "corrupt main recovers the last verified payload")
T.eq(code, nil, "successful recovery is a normal read")
-- A failed replacement cannot destroy the prior verified value.
T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value")
fs.failTmp = true
ok, code = alpha:write(current, "replace", { version = 2 })
fs.failTmp = false
T.check(not ok and code == "write_failed", "staging failure is reported")
T.same(alpha:read(current, "replace"), { version = 1 },
"staging failure leaves the prior value readable")
-- Delete is exact and idempotent-not-found is explicit.
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
T.check(alpha:delete(current, "delete/me") == true, "delete removes its target")
missing, missingCode = alpha:read(current, "delete/me")
T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable")
T.same(alpha:read(current, "delete/keep"), { yes = true },
"delete leaves neighboring keys untouched")
-- No-mod parity: constructing/loading an empty loader creates no storage bytes.
local emptyFiles, emptyFs = {}, nil
emptyFs = memfs(emptyFiles)
local emptyLoader = Loader.new({ fs = emptyFs })
emptyLoader.game = current
T.check(emptyLoader:load({}) == true, "no-mod loader still boots")
T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
T.finish()