Files
gen1recomp/docs/rfcs/0003-playthrough-storage.md
2026-08-07 17:14:42 +02:00

5.1 KiB

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:

{ 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.