This commit is contained in:
bryanthaboi
2026-08-14 17:07:08 -04:00
23 changed files with 1276 additions and 62 deletions
+9 -1
View File
@@ -240,7 +240,7 @@ resolves to the weaker claim:
| `warned` | present, answers nil or degrades, and names itself once with the mod attributed |
| `absent` | deliberately not served; a nil read is the honest failure |
Today that is 288 backed, 32 warned and 161 absent across the fifteen modules.
Today that is 291 backed, 32 warned and 161 absent across the fifteen modules.
`notes` keys are documentation topics rather than a member list -- dotted paths
(`save.money`), field names (`warpAt`), hook names (`hook ui.pc.items`) and
bare topics (`identity`, `iteration`, `rawset`) all appear there. `members` is
@@ -486,6 +486,9 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
**by name, before the first row runs**, so a mod never gets a half-run queue.
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual
bicycle and fishing records in both games. Each engine keeps ownership of its
inventory, terrain, surfing, bike, and fishing rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
@@ -763,6 +766,11 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
The list is much shorter than it was. What is outstanding, in descending value:
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
preparation boundary or a battle-local player-party view. Gen 1 mods can use
the hook documented in `docs/modding.md`; do not claim Gold compatibility
when that selection is required.
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
+84 -11
View File
@@ -147,6 +147,20 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Contextual field items
`mod.world:availableFieldActions()` returns the field items that can start at
the player's current position. Red and Gold currently expose `bicycle` and
`fish`; fishing rows include the owned rods that are valid choices. The list
is empty while the world is busy, while riding states or terrain forbid an
action, or when the required item is not owned.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific bike, collision, or fishing logic.
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
@@ -308,6 +322,25 @@ local keys, code, message = mod.storage:list(game, "history/quick")
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
```
For independently generated binary data, use the opaque byte methods. They
accept and return the exact Lua string of bytes, including NUL bytes and bytes
that are not valid text:
```lua
local ok, code, message = mod.storage:writeBytes(
game, "cache/maps/pallet/terrain", encodedMesh)
local encodedMesh, code, message = mod.storage:readBytes(
game, "cache/maps/pallet/terrain")
```
Opaque values are limited to 512 MiB per key. The engine stores them without
decoding, compression, or an engine-defined file format, and never executes
them. A consuming mod owns validation of its format, fingerprint, checksum,
and compression metadata. Byte writes are staged and compared byte-for-byte
before replacement, and reads can recover a valid backup after an interrupted
write. Existing table values and opaque byte values use one shared logical key
space; delete a key before changing its value from one type to the other.
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
version is compatibility metadata; physical launcher-slot and path identity stays
private. A title-selected context may additionally contain `normalSavedAt`, the
@@ -316,19 +349,22 @@ progress or a slot/path handle.
At the title screen only, `mod.storage:selected(game)` returns a bound storage
facade for the launcher-selected existing playthrough, or `nil, code, message`.
Resolving this facade is read-only: it never allocates an identity, adopts a
Resolving this facade is non-allocating: it never allocates an identity, adopts a
fresh New Game, or exposes a slot id/path. Its `context()`, `read(key)`,
`write(key, value)`, `list(prefix)`, and `delete(key)` methods have the same
data-only and transaction contract as `mod.storage`, but remain restricted to
the calling mod's selected existing namespace. It is intended for title tools
that need to browse or manage durable history before the first normal SAVE.
`write(key, value)`, `readBytes(key)`, `writeBytes(key, bytes)`,
`list(prefix)`, and `delete(key)` methods have the same scoped and
transactional contract as `mod.storage`, but remain restricted to the calling
mod's selected existing namespace. It is intended for title tools that need to
browse or manage durable history before the first normal SAVE.
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.
Table values must contain serializable data only. Opaque values must be Lua
strings. Keys are conservative slash-separated segments (letters, digits, `_`,
`-`); paths and filesystem handles are never exposed. Table writes are staged
and decode-verified; opaque writes are staged and byte-verified; reads recover
from a valid staged/backup generation. Methods return structured errors for
normal data, byte validation, and 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:
@@ -423,6 +459,43 @@ animation/messages, forced choices, and every phase that cannot safely be
checkpointed remain excluded. Exceptions are contained by normal hook isolation
and fall through without advancing a turn.
Gen 1 trainer encounters also expose `trainer.before_battle` after the
challenge text and immediately before battle construction. This lets a mod
defer the encounter while it collects a player choice through a registered
screen, then resume with a battle-local view of the save party:
```lua
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
-- context = { trainerClass, partyIndex, mapId, npcId }
mod.ui.push(game, "party_registration", {
onConfirm = function(indices)
continue({ playerPartyIndices = indices })
end,
onCancel = function()
continue({ cancel = true })
end,
})
return true
end)
```
Return `true` only when retaining `continue` for a later callback. Calling
`continue({ cancel = true })` ends the encounter without constructing a battle;
the normal encounter completion callback returns control to the overworld and
no trainer-defeated state is written. A cancelled sight encounter is suppressed
at the current player cell so it cannot immediately reopen; moving one cell or
talking to the trainer permits a new challenge. Calling `continue()` uses the
full save party; passing
`{ playerPartyIndices = { 2, 4, 5 } }` uses those ordered, one-based party
members for initial send, switching and forced replacement, exhaustion,
experience traversal, and battle party displays. The continuation is one-shot.
An empty, duplicate, out-of-range, or otherwise malformed list safely falls
back to the full party. The view references the original Pokemon records and
never reorders or replaces `game.save.party`; trainer battle checkpoints retain
the selected indices. Mods remain responsible for selection policy and should
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
contract and compatibility guarantees.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+2 -2
View File
@@ -325,7 +325,7 @@ This is not a dev-mode feature; it installs on any Gold boot that has mods.
| `src.pokemon.Boxes` | facade | over `src/core/gen2/Boxes.lua` | 22 / 0 / 0 |
| `src.battle.BattleState` | facade | over `src/ui/gen2/BattleState.lua` | 16 / 2 / 39 |
| `src.ui.PartyMenu` | facade | over `src/ui/gen2/PartyMenu.lua` | 15 / 2 / 16 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 12 / 2 / 0 |
| `src.world.WorldAPI` | alias | `src/world/gen2/WorldAPI.lua` | 15 / 2 / 0 |
| `src.world.PikachuFollower` | alias | `src/world/gen2/Follower.lua` | 10 / 0 / 11 |
| `src.script.ScriptRunner` | facade | over `src/script/gen2/Vm.lua` | 10 / 7 / 1 |
| `src.ui.OptionsMenu` | facade | over `src/ui/gen2/OptionsMenu.lua` | 8 / 0 / 1 |
@@ -774,7 +774,7 @@ profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
- **Coverage is partial and will stay partial.** 15 Gen 1 modules are served
out of a much larger engine, and within those 15 the coverage table records
288 backed members against 32 warned and 161 absent. The absent ones are not
291 backed members against 32 warned and 161 absent. The absent ones are not
a backlog; most are absent because there is no honest Gen 2 answer, and each
one carries its reason. The counts move as the adapter learns something: a
member that turns out to answer nil is demoted from backed to warned or
+29 -6
View File
@@ -26,8 +26,8 @@ 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.
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
@@ -75,6 +75,25 @@ 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
@@ -92,9 +111,9 @@ 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.
`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
@@ -107,6 +126,8 @@ boundary.
**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
@@ -115,8 +136,10 @@ playthrough id is created unless a mod invokes `mod.storage` or
- **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, exact delete, and no-mod no-write.
corrupt-main recovery, failure retention, selected-playthrough access, exact
delete, and no-mod no-write.
## Deprecation etiquette
@@ -0,0 +1,92 @@
# 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.