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