mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362 (#1395)
* CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362 * conv
This commit is contained in:
@@ -1,114 +0,0 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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 either decoded table
|
||||
values or exact opaque byte strings, 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:writeBytes(game, key, bytes)`
|
||||
|
||||
Accepts a Lua string containing opaque bytes and returns `true`, or
|
||||
`false, code, message`. Empty strings are valid. Payloads are limited to 512
|
||||
MiB per key. The engine writes the supplied bytes exactly as received, without
|
||||
decoding, compression, checksums, or an engine-defined envelope. The consuming
|
||||
mod owns semantic validation of its format.
|
||||
|
||||
Byte records use private `.bin`, `.bin.tmp`, and `.bin.bak` witnesses. A staged
|
||||
and replacement write is read back and compared byte-for-byte before it is
|
||||
committed. A failed write leaves the previous verified generation readable.
|
||||
Byte storage never passes its payload to the Lua serializer, loader, or module
|
||||
resolver.
|
||||
|
||||
Table and byte records share one logical key namespace and a key has one type.
|
||||
Writing one type over the other returns `type_conflict`; callers must delete the
|
||||
key before changing its type. `mod.storage:selected(game)` exposes the same
|
||||
`readBytes` and `writeBytes` operations for the selected playthrough facade.
|
||||
|
||||
### `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`, `invalid_bytes`, `size_limit`, `type_conflict`,
|
||||
`type_mismatch`, `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.
|
||||
Existing files outside the scoped storage contract are not imported; a caller
|
||||
must rebuild them through `writeBytes`.
|
||||
|
||||
## 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,
|
||||
opaque byte roundtrip including NUL bytes, no execution, size/type rejection,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, selected-playthrough access, exact
|
||||
delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||
persistence/identity helpers.
|
||||
@@ -1,138 +0,0 @@
|
||||
# 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`. 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.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**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. 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
|
||||
|
||||
- **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, 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
|
||||
|
||||
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||
reconstruction route.
|
||||
@@ -1,158 +0,0 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. Ordinary encounters
|
||||
use fixed engine-owned completion descriptors. A scripted story encounter may
|
||||
also participate when its active command row and remaining row-list are
|
||||
detached data, its NPC can be rebound by stable object id, and its completion is
|
||||
one of the engine-declared semantic forms. The suspended coroutine itself is
|
||||
never captured.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when a supported single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The battle must carry an engine-owned semantic continuation descriptor. An
|
||||
ordinary encounter requires an idle overworld. A scripted story encounter may
|
||||
have exactly its originating foreground runner suspended at the battle command;
|
||||
queued/parallel scripts and scripted movement remain unsafe.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, opaque callback continuations, non-data-only
|
||||
scripts, and unsupported concurrent script work remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
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; 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. Validation failure, failed reconstruction, and successful
|
||||
rollback emit no checkpoint lifecycle event.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
For `start_battle`, `rival_battle`, and `static_battle`, the runner records the
|
||||
detached row list and current command program counter plus stable source/NPC
|
||||
identity where present. On restore, battle completion starts a fresh runner at
|
||||
that command with a one-use semantic battle result. Replaying the current
|
||||
command (rather than skipping to the next row) preserves wrapper behavior such
|
||||
as rival-party consequences, static-object removal, `lastCheck`, and deferred
|
||||
`afterBattle` evolution ordering. The reconstructed overworld starts with
|
||||
normalized input/NPC freeze state, so an engine-marked `release_npc` callback
|
||||
needs no closure revival.
|
||||
|
||||
Arbitrary `onDone` callbacks, function-bearing rows, unknown commands, missing
|
||||
NPC identities, old-man/demo flows, and concurrent scripts fail closed. This is
|
||||
not a general ScriptRunner snapshot: no coroutine, Lua stack, local variable,
|
||||
function, or runtime object enters the checkpoint.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
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
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
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.
|
||||
- scripted trainer and static/wild story continuation, including wrapper-row
|
||||
replay, cold reconstruction, malformed row rejection, and opaque callback
|
||||
refusal.
|
||||
@@ -1,107 +0,0 @@
|
||||
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||
engine and owns its own UI around the game window (a mobile app shell,
|
||||
say, presenting its own settings/import/save screens and only handing
|
||||
control to the LÖVE window once play starts) needs three things no
|
||||
current hook covers:
|
||||
|
||||
1. Pause the simulation while its own UI is on top of the game window.
|
||||
2. Live-reload options it wrote from outside any Lua UI.
|
||||
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||
behavior when the platform shell owns that job itself -- without this,
|
||||
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||
back into `HostShell.restart()`'s in-process reboot instead.
|
||||
|
||||
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||
directly ties every such integration to editing the one file every other
|
||||
engine change also touches, guaranteeing merge conflicts for any second
|
||||
platform integration (or any unrelated engine PR landing around the same
|
||||
time). No existing hook covers "should the per-frame simulation step run"
|
||||
or "should closing the window return to the Lua launcher."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `core.update`
|
||||
|
||||
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||
`(next, game, dt)`, called once per frame from `love.update` via
|
||||
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||
simulation for that frame, or do additional per-frame work before/after
|
||||
calling it regardless of whether it calls `next`.
|
||||
|
||||
### `core.quit_to_launcher`
|
||||
|
||||
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||
called once from `love.quit()` via
|
||||
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||
non-platform-specific decision (`Game and not Importer and not
|
||||
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||
may return `false` outright to veto returning to the Lua launcher (without
|
||||
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||
`next()` and return its result to pass the vanilla decision through
|
||||
unchanged.
|
||||
|
||||
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||
every call, matching the existing `input.step` precedent
|
||||
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||
`vanilla(...)` call when no mod has wrapped the name.
|
||||
|
||||
### `Manifest.force_enable_env`
|
||||
|
||||
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||
a mod carrying this field whenever that variable is set to `"1"`,
|
||||
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||
one build and cannot function disabled there, but must still behave like
|
||||
every other mod (a manifest opt-in, not an engine special case) on every
|
||||
build that doesn't set its variable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||
behavior on every platform where no mod wraps either hook. A manifest with
|
||||
no `force_enable_env` field behaves exactly as before.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||
through unchanged. Both hooks are picked up automatically by the
|
||||
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||
no-mod test file.
|
||||
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||
through a fixture mod loaded via the public loader (not the engine's
|
||||
internals), that a subscriber can skip the vanilla update call (pause),
|
||||
run extra per-frame polling regardless of pause state, and veto the
|
||||
quit-to-launcher decision without the vanilla condition ever running.
|
||||
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||
disabled, and an unset one leaves the saved disable alone.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||
into `src/core/PlatformHooks.lua`.
|
||||
@@ -1,92 +0,0 @@
|
||||
# RFC 0006 — Selected title playthrough storage and checkpoint resume
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `Storage.lua`, `Checkpoint.lua`, and
|
||||
`Loader.lua`. Tests: `title_playthrough_context.lua`, existing storage,
|
||||
checkpoint, title, save-slot, and no-mod parity suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
A tool checkpoint may be the first durable record of a new playthrough. The
|
||||
engine intentionally keeps normal Pokémon SAVE independent: before the first
|
||||
normal write, identity is retained by the engine-owned selected-slot mapping,
|
||||
while title starts with a fresh New Game skeleton. A durable checkpoint tool may
|
||||
explicitly create one ordinary progress anchor after its first checkpoint has
|
||||
committed; later tool writes must remain independent. Calling ordinary active
|
||||
`mod.storage` there would allocate/adopt an identity, and live
|
||||
`mod.checkpoints:restore` correctly refuses title because it has no gameplay
|
||||
rollback state. Generic public capabilities are required; a tool must not use
|
||||
private storage paths, slot ids, or simulate the player's SAVE menu flow.
|
||||
|
||||
## Additive public API
|
||||
|
||||
### `mod.storage:selected(game)`
|
||||
|
||||
Available only while the engine is in a title session. Returns an opaque bound
|
||||
facade or `nil, code, message`:
|
||||
|
||||
```lua
|
||||
local selected = mod.storage:selected(game)
|
||||
local context = selected:context()
|
||||
local history = selected:read("history/index")
|
||||
```
|
||||
|
||||
The facade exposes `context()`, `read(key)`, `write(key, value)`,
|
||||
`list(prefix)`, and `delete(key)`. It is bound internally to the launcher-
|
||||
selected existing game-version/playthrough and the calling mod id. It neither
|
||||
accepts an arbitrary playthrough id nor reveals a slot id, filesystem path, or
|
||||
another mod namespace. Resolution is read-only; no selected mapping means
|
||||
`no_selected_playthrough`, and opening a title browser never mints an identity.
|
||||
Its detached context can include only `normalSavedAt` from a matching ordinary
|
||||
save, so title tools can apply their own resume policy without receiving the
|
||||
canonical normal-save record.
|
||||
|
||||
### `mod.checkpoints:ensureNormalSave(game, checkpoint)`
|
||||
|
||||
Available only at a live checkpoint-safe boundary. After a tool has durably
|
||||
committed the supplied current checkpoint, it may request an ordinary progress
|
||||
anchor for a playthrough that has never had one. The engine validates the
|
||||
checkpoint, proves it exactly matches a fresh capture of the live runtime, and
|
||||
uses the normal atomic save path including `save.write` lifecycle/veto hooks.
|
||||
|
||||
The operation is idempotent. It returns `true, "already_exists"` without writing
|
||||
when matching normal progress already exists, so later checkpoints never move
|
||||
the vanilla CONTINUE target. A stale/non-current checkpoint, unsafe runtime,
|
||||
write veto/failure, or failed readback returns a structured failure. A tool
|
||||
should call it only after its own checkpoint and index are durable and must not
|
||||
report that first checkpoint as successful if the required anchor fails.
|
||||
|
||||
### `mod.checkpoints:resume(game, checkpoint)`
|
||||
|
||||
Available only from title. It validates format, data-only structure, selected
|
||||
game/playthrough identity, canonical save/content, overworld/battle runtime, and
|
||||
RNG exactly as `restore` does. It then reconstructs semantic overworld or a
|
||||
supported battle continuation, preserves current options, and differentially
|
||||
recaptures before committing. On success it emits `checkpoint.restored` once.
|
||||
|
||||
Title has no live runtime rollback. A reconstruction or verification failure
|
||||
therefore rebuilds a clean title session from the pre-operation title save and
|
||||
RNG; it emits no success event and never rewrites normal progress. Validation
|
||||
failure leaves the existing title session untouched. Stable errors include
|
||||
`not_at_title`, `no_selected_playthrough`, normal checkpoint validation codes,
|
||||
`resume_failed`, and `title_recovery_failed`.
|
||||
|
||||
## Isolation and migration
|
||||
|
||||
Explicit NEW GAME retains its existing fresh-identity rule. It does not reuse a
|
||||
previous selected mapping and cannot see old tool history. Existing mods change
|
||||
nothing: no identity, storage, title reconstruction, or event is created unless
|
||||
the new methods are called. `mod.storage` remains independent durable data and
|
||||
does not rewind with a checkpoint; canonical `game.save` / `mod.save` does.
|
||||
|
||||
## Verification
|
||||
|
||||
The public SDK test starts a fresh playthrough, stores tool history, creates and
|
||||
readback-verifies exactly one normal anchor, proves subsequent calls do not
|
||||
rewrite it, simulates title/restart, reads the selected binding without
|
||||
allocating title identity, resumes an overworld checkpoint, preserves options,
|
||||
differentially recaptures, and confirms a later explicit NEW GAME receives
|
||||
another identity. A separate two-process disk test proves cold-start routing and
|
||||
reconstruction. Existing no-mod, storage, checkpoint, battle, and title suites
|
||||
prove additive parity.
|
||||
@@ -1,46 +0,0 @@
|
||||
# RFC 0007: Battle menu auxiliary actions
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Problem
|
||||
|
||||
Tool mods can inspect/capture a persistent checkpoint only at a settled
|
||||
ordinary wild/trainer player-decision boundary. Before this proposal, that
|
||||
boundary had no public semantic input/action seam: `BattleState` consumed the
|
||||
command loop directly. A mod could reach it only through private battle/input
|
||||
internals, which would be unsafe and incompatible with controller/touch input.
|
||||
|
||||
## Contract
|
||||
|
||||
`mod.hooks:wrap("battle.menu_auxiliary", callback)` is called only when START
|
||||
is pressed at the existing checkpoint-safe player-decision boundary. The
|
||||
callback signature is:
|
||||
|
||||
```lua
|
||||
function callback(next, game, context)
|
||||
-- context is { kind = "wild" } or { kind = "trainer" }
|
||||
-- return true after claiming START, otherwise return next(game, context)
|
||||
end
|
||||
```
|
||||
|
||||
The context is data-only. No live battle controller, input object, serializer,
|
||||
or restoration primitive is exposed. A `true` result consumes START for that
|
||||
fixed step without selecting a battle command. With no installed handler,
|
||||
START is inert exactly as before. Hook priorities and error isolation are the
|
||||
existing generic wrapper semantics: a throwing handler is skipped and cannot
|
||||
advance battle state.
|
||||
|
||||
The engine reuses the same internal safety predicate as battle checkpoint
|
||||
capture. Link, Safari, ghost/demo, unsupported origins, scripts, queues,
|
||||
animations, messages, forced replacement/locked actions, and unsettled HP or
|
||||
status presentation never invoke the hook.
|
||||
|
||||
## Compatibility and verification
|
||||
|
||||
The call is additive and no-op with no handler. ROM-free engine tests prove
|
||||
wild/trainer delivery, cursor/turn preservation, and unsafe-phase refusal;
|
||||
the mod-SDK fixture proves a loaded mod can consume the semantic action using
|
||||
only its public hook facade. `gate_hooks` automatically includes the new call
|
||||
site in no-mod parity coverage.
|
||||
@@ -1,212 +0,0 @@
|
||||
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
|
||||
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
|
||||
`tests/engine/game_speed_categories_test.lua`,
|
||||
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
|
||||
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
|
||||
|
||||
## Motivation
|
||||
|
||||
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
|
||||
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
|
||||
`Game:update()` -- overworld walking, menu navigation and battle turns all
|
||||
scale together. A player who wants 4X battles (grinding, a long gym fight)
|
||||
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
|
||||
has no way to get both; the one GAME SPEED row is a single ladder that
|
||||
applies everywhere at once.
|
||||
|
||||
This needs to be an engine change, not a mod: there is no per-frame seam a
|
||||
mod can use to swap the multiplier mid-step, and no public event granular
|
||||
enough to say "which category is active" (`screen.pushed`/`screen.popped`
|
||||
and `battle.started`/`battle.ended` are the closest and are not enough --
|
||||
see Decisions below). The engine's own speed resolution has to become
|
||||
category-aware.
|
||||
|
||||
A category-aware speed resolution is also the general seam a
|
||||
platform-launcher integration or automation tool needs to read or override
|
||||
the effective multiplier for a given frame without caring which category
|
||||
produced it -- this RFC's `core.logic_speed` hook is written for that case
|
||||
alongside the player-facing Options rows.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
|
||||
with per-category resolution.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible except for one save-data field rename, which ships with
|
||||
an automatic migration (see below) -- nothing in the public mod API (hooks,
|
||||
events, registries, `mod.*`) is renamed or removed.
|
||||
|
||||
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
|
||||
|
||||
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
|
||||
of categories, and `GameSpeed.optionKey(category)` maps a category to its
|
||||
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
|
||||
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
|
||||
unchanged -- the ladder and its behavior are exactly what they were, just
|
||||
applied three times instead of once.
|
||||
|
||||
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
|
||||
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
|
||||
loaded options table that still has `speed` and none of the three new
|
||||
fields seeds all three from it, so an existing player's fast-forward
|
||||
preference carries over instead of two of the three categories silently
|
||||
resetting to 1X. `speed` is dropped on the way out (not carried forward),
|
||||
so a re-save never re-triggers the migration.
|
||||
|
||||
### `Game.speedCategoryInStack(stack)`
|
||||
|
||||
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
|
||||
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
|
||||
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
|
||||
marker, `BattleState.isBattle = true`, covering every battle: wild,
|
||||
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
|
||||
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
|
||||
first match wins; a state with neither marker (a menu, a text box, a
|
||||
naming screen, a cutscene) is transparent to the walk and falls through to
|
||||
whatever is under it. Nothing in the stack matching either falls back to
|
||||
`"menu"`.
|
||||
|
||||
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
|
||||
|
||||
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
|
||||
against the live stack, maps the category to its `save.options` key via
|
||||
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
|
||||
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
|
||||
hook wraps.
|
||||
|
||||
`Game:logicSpeed()` keeps its existing early returns -- link play forces
|
||||
`1`, a run-argument speed override wins over the saved option -- unchanged,
|
||||
and in the same order, before ever calling the hook. Only once neither
|
||||
applies does it call the `core.logic_speed` hook.
|
||||
|
||||
### `Game:_cycleSpeed(dir)`
|
||||
|
||||
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
|
||||
the single `speed` option now cycle whichever category
|
||||
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
|
||||
battle speeds up just the battle, on the overworld just the walk, in a menu
|
||||
just the menu. This is the natural per-category answer for a control that
|
||||
used to have one option to reach and now has three -- see Decisions below
|
||||
for why this reading was chosen over, say, always cycling `overworld`.
|
||||
|
||||
### `core.logic_speed`
|
||||
|
||||
New hook, `(game) -> number` through the public wrapper signature
|
||||
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
|
||||
Vanilla behavior (used when no mod claims the hook) is
|
||||
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
|
||||
nothing else. A subscriber may call `next(game)` and return its result to
|
||||
pass the vanilla multiplier through, or return a different number outright
|
||||
to override it for that frame (e.g. a bot mod forcing `1` during one route
|
||||
segment regardless of what category or option is active).
|
||||
|
||||
This intentionally sits *after* the link and speed-override checks in
|
||||
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
|
||||
matter what either player set this to" is exactly the invariant that would
|
||||
break if a mod's hook could override it, and the run-argument override
|
||||
exists so a bot/screenshot run's speed does not depend on a mod any more
|
||||
than on the player's saved option. Both stay unconditional early returns a
|
||||
mod never sees.
|
||||
|
||||
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
|
||||
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
|
||||
fires every frame regardless.
|
||||
|
||||
## Decisions on the issue's open questions
|
||||
|
||||
**1. Overlays on top of another category's state (a party menu, a choice
|
||||
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
|
||||
making the category a property of stack *position*, not of the overlay's
|
||||
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
|
||||
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
|
||||
party swap opened mid-battle reads as `"battle"`; a bag opened while
|
||||
walking reads as `"overworld"`. This was chosen over giving every UI state
|
||||
its own fixed category (which would make a fast-forwarded battle visibly
|
||||
stutter back to 1X every time its party menu opens) because it matches
|
||||
what the player is actually doing moment to moment, and it reuses a
|
||||
pattern the codebase already leans on for exactly this "menus opened over
|
||||
X should behave like X" class of problem (`Game.fillScaleInStack`,
|
||||
`Game.wideBattleInStack`).
|
||||
|
||||
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
|
||||
through the owning state's own machinery -- the overworld's script runner
|
||||
or a battle's message queue -- rather than pushing a state of its own, so
|
||||
it is already covered by decision 1: it inherits whatever category the
|
||||
state driving it resolves to. A cutscene state that genuinely has nothing
|
||||
under it (a pre-game intro) falls to `"menu"`, the default for anything
|
||||
that is not battle or overworld gameplay -- consistent with those being
|
||||
pre-game presentation, not something a player is likely to want scaled
|
||||
differently from menu navigation.
|
||||
|
||||
**3. Category granularity (splitting "menu" further).** Deferred. Start
|
||||
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
|
||||
are written so adding a fourth later (a Pokédex/Bag category, say) is one
|
||||
entry plus one new `save.options` field, not a resolution-logic rewrite.
|
||||
No current request motivates it.
|
||||
|
||||
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
|
||||
things.** `Game:_cycleSpeed` now cycles whichever category is currently
|
||||
active (`Game.speedCategoryInStack`), rather than, say, always cycling
|
||||
`overworld` or requiring a modifier key to pick a category. A single
|
||||
physical control that means "speed up whatever I'm looking at right now"
|
||||
is the reading that needs no new UI and matches what a player pressing it
|
||||
mid-battle almost certainly wants.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing**, for the mod API surface: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
|
||||
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
|
||||
keep their exact signatures and behavior.
|
||||
|
||||
**One save-data field**, for anything that read `save.options.speed`
|
||||
directly (not a formal registry/hook surface, but worth naming): it is
|
||||
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
|
||||
automatically on load (see above) so a save from before this RFC keeps its
|
||||
player's chosen speed. A mod reading `save.options.speed` after this change
|
||||
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
|
||||
should read the per-category fields, or hook `core.logic_speed` to observe
|
||||
the resolved multiplier directly regardless of which category produced it.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
|
||||
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
|
||||
`src` for `Runtime.call("...")` call sites), so the new
|
||||
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
|
||||
automatically: vanilla runs exactly once with an empty hook chain, an
|
||||
unsubscribed-but-live bus passes values and multiple returns through
|
||||
unchanged, and `Runtime.wantsHook` reads `false`.
|
||||
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
|
||||
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
|
||||
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
|
||||
subscriber can read the vanilla category resolution via `next(game)` and
|
||||
can override it outright -- plus direct coverage of
|
||||
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
|
||||
inheriting each, an empty/unmatched stack falling to `"menu"`) and
|
||||
`Game:logicSpeed()`'s precedence (link forces 1X over all three
|
||||
categories and over a hook override; the run-argument override wins over
|
||||
the category resolution).
|
||||
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
|
||||
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
|
||||
independently, in place of the old single GAME SPEED row.
|
||||
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
|
||||
activations (MODS, CONTROLS) are updated for the two extra rows.
|
||||
- A link-play driver should set all three per-category speeds high before
|
||||
asserting `game:logicSpeed()` reads `1` during a real link session,
|
||||
proving the lock wins over every category at once, not just whichever
|
||||
one happens to be active.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
|
||||
adds one hook, additive. The `save.options.speed` field is superseded with
|
||||
an automatic migration rather than a deprecation notice, since it was never
|
||||
a registered mod-API surface (no schema entry, no registry) -- the same
|
||||
treatment any other `save.options` field would get if it needed reshaping.
|
||||
@@ -1,49 +0,0 @@
|
||||
# RFC 0008 — Read-only device power information for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Loader.lua`, `Sandbox.lua`. Test:
|
||||
`tests/modkit/cases/device_power_info.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A handheld UI mod can show the player's battery state and warn before power
|
||||
loss. The sandbox correctly removes `love.system` because that module also
|
||||
launches URLs and exposes other host operations, but it leaves no scoped way
|
||||
to read the harmless power values that LÖVE already provides.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua`: blocked host modules stay
|
||||
blocked while legitimate operations receive narrow engine-owned facades.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Add `mod.device:powerInfo() -> state, percent`.
|
||||
|
||||
The engine calls `love.system.getPowerInfo()` outside the mod sandbox and
|
||||
returns only its first two values. `state` is one of LÖVE's standard power
|
||||
states. `percent` is `0` through `100` or `nil`. When the platform has no
|
||||
power-information backend, the result is `"unknown", nil`.
|
||||
|
||||
No permission grants access to `love.system`; URL launching, clipboard access,
|
||||
OS identification, and the module table itself remain unavailable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that used `love.system.getPowerInfo()` replace that call with
|
||||
`mod.device:powerInfo()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** loading no mods does not call the platform power backend.
|
||||
- **Mod API:** a fixture mod loaded through the public loader receives state
|
||||
and percentage through `mod.device`, while the existing sandbox suite keeps
|
||||
proving that direct `love.system` access is refused.
|
||||
- **Unavailable backend:** the public facade returns `"unknown", nil` rather
|
||||
than inventing battery data or failing mod load.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system` block
|
||||
remains in force.
|
||||
@@ -1,68 +0,0 @@
|
||||
# RFC 0009 — Permission-gated step bridge for sandboxed mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Steps.lua` (new), `Loader.lua`, `Manifest.lua`,
|
||||
`Sandbox.lua`. Test: `tests/modkit/cases/steps_bridge.lua`. Issue: #1186.
|
||||
|
||||
## Motivation
|
||||
|
||||
The iOS and Android builds count the player's real-world steps natively
|
||||
(#452, #489), exposed to Lua as `love.system.syncHealthSteps()` and
|
||||
delivered as `steps_pending.json` in the save-directory root. The sandbox
|
||||
correctly blocks both — `love.system` also launches URLs, and the file API
|
||||
names paths — but that leaves the bridge with no consumer: the mod that
|
||||
step counting was built for (Pokéwalker, steps→EXP) can no longer be
|
||||
written.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the mod sandbox in `src/mods/Sandbox.lua` (blocked host modules
|
||||
stay blocked; legitimate operations receive narrow engine-owned facades)
|
||||
and the permission model `network` established: a `manifest.json`
|
||||
permission the player sees in the mod manager that genuinely gates a
|
||||
capability.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
A new manifest permission token, `steps`, and a `mod.steps` facade:
|
||||
|
||||
- `mod.steps:available() -> boolean` — whether this build carries the
|
||||
native bridge. Answers `false` without the permission, so a probe stays
|
||||
quiet.
|
||||
- `mod.steps:sync() -> boolean` — asks the platform to refresh its count
|
||||
(async; the OS consent sheet still appears on first use, exactly as
|
||||
before the sandbox). `false` when there is no bridge.
|
||||
- `mod.steps:poll() -> { steps = n, from = iso?, to = iso? } | nil` — the
|
||||
next delivery for this mod, engine-consumed from the pending file. Each
|
||||
permissioned mod receives its own copy of a delivery.
|
||||
|
||||
Without the permission, `sync` and `poll` raise an error naming the
|
||||
missing permission, the way the network gate does. The engine owns the
|
||||
pending file: mods never learn its name or location, and only the three
|
||||
contract fields travel. No new event, hook, or registry names.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
Mods that called `love.system.syncHealthSteps()` and read
|
||||
`steps_pending.json` themselves add `"steps"` to `permissions` and switch
|
||||
to `mod.steps:sync()` / `mod.steps:poll()`. No other mod changes.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No mod:** with nothing installed the bridge is never called and a
|
||||
pending file on disk is left untouched.
|
||||
- **Mod API:** a fixture mod with the permission syncs and receives a
|
||||
delivery through the public loader; two permissioned mods both receive
|
||||
the same walk; a second poll returns nil.
|
||||
- **No permission:** `available()` is false and the acting calls name the
|
||||
missing permission; the sandbox suite keeps proving direct
|
||||
`love.system` access is refused.
|
||||
- **Malformed delivery:** a bad or empty pending file is dropped whole
|
||||
rather than crashing a poll (the native anchor only advances on a
|
||||
successful sync, so nothing is lost).
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The facade is additive; the sandbox's `love.system`
|
||||
and `love.filesystem` blocks remain in force.
|
||||
@@ -1,92 +0,0 @@
|
||||
# RFC 0010: Deferred trainer preparation and battle-local party scope
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Challenge and tournament mods sometimes need a player to choose an eligible
|
||||
subset of the save party before a trainer battle. The current public surface
|
||||
can replace the opponent through `trainer.party` and observe
|
||||
`world.trainer_engaged`, but it cannot pause the engagement before battle
|
||||
construction or keep unselected save-party members out of initial send,
|
||||
switch, replacement, exhaustion, experience, and party-menu traversal.
|
||||
|
||||
Temporarily rewriting `game.save.party` is not a safe substitute: it changes
|
||||
authoritative save state, composes poorly with checkpoints and other mods, and
|
||||
can strand excluded Pokémon if a callback or process fails.
|
||||
|
||||
## Decision and plan extended
|
||||
|
||||
This implements **D-AT-001: battle-local Gym registration without save-party
|
||||
mutation**, the consuming design decision tracked as capability `AT-SP-001` in
|
||||
the Adaptive Trainers implementation plan. The plan file is
|
||||
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||
Task 5. The engine delta also extends the additive, guarded public-hook
|
||||
decision used by RFC 0007 and the screen facade documented in
|
||||
`docs/modding.md`; it deliberately contains none of the consuming mod's Gym
|
||||
or party-size policy.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
Add the guarded hook:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
-- context = { trainerClass, partyIndex, mapId, npcId }
|
||||
-- Return true only when the battle has been deferred.
|
||||
-- continue({ cancel = true }) returns without constructing a battle.
|
||||
-- Call continue() for the full save party, or:
|
||||
-- continue({ playerPartyIndices = { 2, 4, 5 } })
|
||||
end)
|
||||
```
|
||||
|
||||
The hook runs after the trainer's challenge text and immediately before the
|
||||
trainer battle is constructed. A mod may push a registered screen with
|
||||
`mod.ui.push`, return `true`, and retain `continue` for its confirm/cancel
|
||||
callback. `continue` is one-shot and returns `false` after the first call.
|
||||
Returning anything other than `true` without calling it continues immediately
|
||||
with vanilla scope. With no subscriber, no context or continuation is built.
|
||||
|
||||
`playerPartyIndices` is an ordered, one-based list into `game.save.party`.
|
||||
Valid unique indices create `battle.playerParty` as a battle-local view of the
|
||||
same Pokémon records; the save party itself is never reordered or replaced.
|
||||
Malformed or empty scopes degrade to the full party. The view governs initial
|
||||
send, all battle party menus and targets, voluntary and forced replacement,
|
||||
exhaustion/blackout checks, participant and EXP.ALL traversal, party counts,
|
||||
and party-ball presentation. Checkpoints preserve the index list and rebuild
|
||||
the same view before restoring battlers.
|
||||
|
||||
`{ cancel = true }` ends a deferred encounter through its normal completion
|
||||
callback without constructing a battle or writing trainer-defeated state. A
|
||||
cancelled sight encounter is suppressed while the player remains on the same
|
||||
cell, preventing immediate reacquisition; moving or directly talking permits a
|
||||
new challenge. Cancellation is also one-shot; if supplied alongside a party
|
||||
index list, cancellation wins.
|
||||
|
||||
The API sets no maximum, chooses no members, identifies no boss, and contains
|
||||
no scaling or challenge policy.
|
||||
|
||||
## Migration and compatibility
|
||||
|
||||
Existing mods change nothing. `BattleState.newTrainer(game, class, index)`
|
||||
keeps its current behavior; the optional fourth argument is additive. Existing
|
||||
battle checkpoints without a party scope restore against the full save party.
|
||||
Wild, Safari, link, and no-mod battles are unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- The catalog-driven hook gate proves empty-chain parity and the guarded hot
|
||||
path proves no-mod engagement starts exactly once without allocation.
|
||||
- A sandboxed fixture mod defers through its public hook facade, inspects the
|
||||
data-only context, and resumes once with ordered indices.
|
||||
- Engine tests cover initial send, party menus, replacement/exhaustion,
|
||||
EXP traversal, invalid-scope fallback, and save-party identity.
|
||||
- Battle-checkpoint tests prove scoped capture/restore and old-checkpoint
|
||||
compatibility.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing is deprecated. The hook and optional constructor argument are
|
||||
additive.
|
||||
Reference in New Issue
Block a user