Merge branch 'dev' into spidercar2

This commit is contained in:
bryanthaboi
2026-08-24 08:48:24 -04:00
61 changed files with 4280 additions and 563 deletions
+8 -2
View File
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
Generation-agnostic; nothing to adapt.
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
`mod.developer` is the same fixed boot-time boolean on both generations and is
available while the entry chunk runs. Gold does not gain Gen 1's developer
console or F5 hot-reload hotkey; the field reports the loader's mode only.
**`mod.world`.** Same method set, resolved against Gold's world
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
@@ -783,6 +786,9 @@ 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:
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
processing. Gold already has a native weather/between-turn pipeline but does
not yet expose the shared data-only descriptor hook.
- `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
+202 -4
View File
@@ -228,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
### Active Gen 1 block checks
Red, Blue, and Yellow expose
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
ID at one zero-based block coordinate only when `mapId` is the active map.
The value is a scalar snapshot: changing it cannot change the map. This lets a
mod compare a small runtime map signature before it applies a lawful authored
replacement, without reading the mutable map or ROM cache through engine
internals.
The method fails closed. Before an overworld exists it returns
`nil, "no overworld"`; for a different active map it returns
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
return `nil, "invalid block coordinates"`; and negative or out-of-range
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
or malformed active block returns `nil, "block unavailable"`. The caller must
require every expected cell to match before changing presentation. This method
is Gen 1-only; Gold callers receive no parity promise for it.
The same unavailable result covers missing or sparse active block storage and
an accessor result that does not match its validated active block slot.
### Conditional map occupancy
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
decision to eject the player from an otherwise valid loaded map. Its first
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
been erased when the hook runs, and the hook does not replace or suppress any
base or peer map handler.
The wrapper receives `(next, game, context)`. The dock context is a copied
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
record. Vanilla returns `false`. Return exactly `true` to allow the player to
remain; every other value denies occupancy and preserves the normal message
and warp. A composable wrapper calls downstream first and only adds its own
permission:
```lua
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
local allowed = next(game, ctx)
local mine = ctx.mapId == "VERMILION_DOCK"
and ctx.reason == "ss_anne_departed"
and myPublicEligibilityCheck(game)
return allowed == true or mine == true
end)
```
With no wrapper, the hook allocates no context and vanilla behavior is
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
string, number, table, or other malformed final answer fails closed. Disabling
or uninstalling the permitting mod therefore restores vanilla ejection without
changing the S.S. Anne story flag or restoring the ship.
Normal hook-chain ownership applies: a wrapper that does not call `next`
intentionally owns the final answer and does not run lower-priority wrappers.
Permission wrappers must call `next` as shown above to compose. A noncompliant
wrapper that returns false without calling `next` safely denies occupancy and
can suppress downstream permission by this standard rule. A malformed answer
also fails closed and cannot force occupancy.
## Party ordering
Companion UIs and alternate party screens can call
@@ -265,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Self-driven world actors
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
overworld's scripted-movement list. A non-empty list is how the overworld
knows a cutscene is running, so it gates player input for as long as the
actor walks -- right for Oak marching to his lab, wrong for an actor that
moves on its own schedule (a networked player's ghost, an ambient walker).
Five handle methods drive one without that lockout:
```lua
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
sprite = "SPRITE_RED" })
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
ghost:setPassable(true) -- walk-through
```
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
queue. It deliberately does **not** check collision: a caller replaying a
move that was already decided elsewhere (validated on a peer's machine, or
authored) would let the two copies disagree about where the actor is if this
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
`placeAt(x, y, facing)` snaps with no animation and clears any step in
flight, for a warp arrival or a resync too far gone to walk off.
`isMoving()` lets a driver pace itself instead of stomping a move already
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
engine's own user is Yellow's companion Pikachu); a passable object still
draws and can still be talked to.
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
has nothing to say for it. The **`world.talk`** hook is the A press on an
object, raised before the map's text tables get it:
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
if mine(target) then
say(target) -- the mod answers for an object it owns
return -- ...by not calling next()
end
return next(ow, target) -- everything else falls through unchanged
end)
```
With no subscriber the A press reaches `talkTo` exactly as before. An object
mid-step raises no hook, matching the vanilla gate.
## Adopting an already-paired link session
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
session on a transport that is *already* paired, skipping the address/code
entry UI while keeping the hello and fingerprint compatibility exchange
intact. `transport` is anything `Session` accepts, which is what lets a mode
tunnel a battle through its own connection rather than opening a second one.
When the battle finishes, **`link.battle_ended`** reports the outcome:
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = { result, myParty, theirParty, peerName, role }
end)
```
The party copies are the point. Cable rules leave the real party untouched,
so a mode built on link battles -- a tournament ladder, a battle royale --
has no other way to learn what the fight cost, and by the time the state
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
is the title screen's NEW GAME closure made callable, with `opts.intro =
false` to land straight in the world -- a mode that hands out its own starting
state has no use for Oak's speech. `CodeEntry.new` takes an optional
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
can also carry a room code or an address.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -687,15 +821,38 @@ the hook context.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
on the command line:
```sh
love . --developer
```
While developer mode is active:
The mod loader independently derives a matching boolean for every sandboxed
entry chunk as `mod.developer`. It is available while the entry file is
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
out of player builds:
```lua
if mod.developer then
mod.commands:register("my_mod:diagnostics", function(ctx)
-- open or print this mod's diagnostic view
end)
end
```
`mod.developer` is a plain boolean snapshot for this boot. It grants no
permission and exposes neither the process environment nor the loader. In a
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
`true`. On Gen 1 those inputs separately enable the console and hot-reload
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
signal and diagnostics; it does not enable the game's console or hot reload.
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
console or hotkeys. Use a mod option for player-facing feature toggles rather
than treating developer mode as configuration.
While developer mode is active on Gen 1:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
@@ -857,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
## Battle field residual hook
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
damage without mutating live battlers. It is guarded and runs after vanilla
status residuals, before field-token expiry and `battle.turn_ended`. The
wrapper receives `(next, context)`, calls `next(context)` for the existing
descriptor list, and appends data-only rows:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy", amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
`context.field` is a detached, data-only view with the same
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
`field.sides` or any live battler aliases. The projection recursively retains
raw tables and finite numbers, strings, and booleans under scalar keys. It
strips metatables and omits functions, userdata, threads, unsupported keys, and
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
even if a live field token uses one internally, and changing any nested view
value cannot change live field state. `context.battlers.player` and `.enemy` are
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
is the current turn number. A descriptor accepts `side`
(`player` or `enemy`), a positive, finite integer number `amount`, and an
optional string `message`.
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
The engine retains HP-bar, faint, experience, and replacement authority. If
both active battlers take terminal residual damage together and the player has
no healthy reserve, this hook batch queues only the player faint authority and
resolves as a blackout loss without an enemy-faint EXP award or replacement.
That precedence is local to accepted rows from this hook; native faint paths
are unchanged when no hook is active. Hook callbacks remain process-local;
checkpoints serialize only field data. Gold does not yet raise this hook; its
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
## Process-lifecycle hooks
These exist so a platform-specific launcher integration (a native shell
+2 -1
View File
@@ -768,7 +768,8 @@ the same:
So: read the log for coverage problems, and the manager for load problems.
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
## What this guide does not promise
@@ -0,0 +1,142 @@
# RFC 0013: Conditional map occupancy and active-block reads
## Status
Proposed.
## Motivation
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
S.S. Anne has departed. A content mod can add a city-side route back to that
empty harbor, but it cannot preserve the dock visit: replacing the complete
dock handler would discard vanilla behavior and peer handlers, while an added
handler cannot cancel the base handler's ejection.
A mod that changes one active map block also needs to prove that it is looking
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
intentionally presentation-oriented and does not expose block identity.
Requiring internal `Map` state or generated ROM data would cross the public mod
boundary and make a wrong-version edit difficult to fail closed.
## Decision and plan extended
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
ordinary hook composition remains the authority, an empty hook chain is a
provable no-op, and mods receive copied or scalar data rather than mutable
engine state. It supports the approved Mew-under-the-truck implementation plan
without adding any Mew-specific rule, asset, flag, or content to the engine.
## Exact API delta
### `map.occupancy_allowed`
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
ship blocks with water and immediately before it would display the departure
message and warp the player to Vermilion City.
A wrapper has this shape:
```lua
function(next, game, context) -> boolean
```
The context is a new table with these fields:
| Field | Meaning |
|---|---|
| `mapId` | `"VERMILION_DOCK"` at this call site |
| `reason` | Stable reason key `"ss_anne_departed"` |
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
| `x`, `y` | Current player cell coordinates when present |
Vanilla returns `false`. The player remains only when the final chain result is
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
ejection. A wrapper composes by calling `next(game, context)` and returning
true when either downstream or its own narrow rule permits occupancy. The hook
does not replace `MapScripts` registration, merging, or dispatch, and does not
change the departure flag or reconstruct the ship.
As with every wrapper hook, a callback that does not call `next` intentionally
owns the final answer and does not run lower-priority callbacks. Permission
wrappers must call downstream to compose. A false, non-forwarding wrapper
safely denies occupancy and can suppress downstream permission by this normal
rule. A malformed final result also fails closed and cannot permit occupancy.
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
context and follows the prior branch exactly.
### `WorldAPI:activeBlockAt`
Gen 1's public `mod.world` facade adds:
```lua
activeBlockAt(mapId, blockX, blockY) -> blockId
| nil, reason
```
`mapId` must equal the active map ID. Coordinates are finite, integral,
zero-based block coordinates. A successful result is a numeric scalar copied
from the active runtime map. The method never returns the map's mutable block
array and never writes game or save state.
Failure reasons are stable:
| Condition | Reason |
|---|---|
| No active overworld map | `no overworld` |
| `mapId` differs from the active map | `map is not active` |
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
| Active block data is absent or malformed | `block unavailable` |
The block slot and the active map accessor must both contain the same valid
nonnegative integer. Missing or sparse storage and inconsistent accessor data
return `block unavailable`.
Requiring the expected map ID and rejecting all ambiguous input lets a mod
compare every cell in its version-specific signature before it calls an
existing mutation API. Red, Blue, and Yellow each use their own loaded map
data. Gold does not gain this method in this RFC.
## Migration and compatibility
Existing mods change nothing. No hook, event, registry, manifest field, save
field, map handler, or WorldAPI method is removed or renamed. With no hook
subscriber the departed-dock behavior is unchanged. Existing callers cannot
invoke the new WorldAPI method accidentally.
The occupancy answer is not persisted by the engine. Disabling or uninstalling
a mod removes its wrapper through normal owner cleanup, so a later dock entry
uses vanilla ejection. The engine writes no new save state and neither API
returns or serializes ROM or save data.
## Verification requirements
The parity gate must prove:
- vanilla departed-dock message and warp remain with no subscriber;
- one permission wrapper can allow occupancy without removing the ship-erasure
work or any map handler;
- multiple cooperative wrappers preserve downstream permission;
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
closed according to the normal wrapper-chain rule;
- Red, Blue, and Yellow contexts keep their version identity separate;
- disabling or removing the owner restores vanilla behavior without a save
migration;
- `activeBlockAt` accepts only the active map and valid in-range integral
coordinates, returns a scalar, and never mutates map or save state; and
- the test fixtures and resulting changes contain no ROM or save payload.
The hook must be driven through a real public `hooks:wrap` chain. The block API
must be exercised through a public `WorldAPI` instance. Tests must not replace
the production map handler with a test-only implementation.
## Docs with the change
`docs/modding.md` documents both contracts, their failure behavior, the
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
schema changes occur, so generated registry documentation is unchanged.
## Deprecation etiquette
Nothing is removed, superseded, or deprecated.
@@ -0,0 +1,158 @@
# RFC 0014: Mod-driven world actors and adopted link sessions
## Status
Proposed.
## Motivation
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
already start a link battle. It cannot make either of them behave like
something the mod itself owns.
Three walls, each of which stops a mode rather than inconveniencing it:
**An actor cannot move on its own schedule.** The only public way to animate a
spawned object is `Handle:scriptMove`, which queues onto
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
knows a cutscene is running -- `handleInput` gates on it -- so anything
animated that way freezes the player's controls for as long as it walks. That
is correct for Oak marching into his lab and wrong for an ambient walker or a
networked player's ghost, which move continuously and must not lock anyone out.
**A spawned object cannot answer the A press.** Talking is resolved from the
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
path has nothing to say for it, and the mod that created it has no way to say
anything either.
**A link battle cannot ride a connection the mod already has.** `LinkState`
owns pairing, so a mode that already has a socket to its peer must either open
a second connection for the battle or reimplement lockstep. And when the
battle ends, cable rules leave the real party untouched, so the damage exists
only in the battle's own copies -- which are gone by the time the state
unwinds. A mode built on link battles cannot learn what the fight cost.
The immediate consumer is an overworld multiplayer mode, but nothing here is
specific to it: the first two are wanted by any mod with an actor that moves
itself, and the third by any mode that runs battles over its own transport --
a tournament ladder, a draft, a gauntlet.
## The decision it extends
This adds nothing to the compatibility surface's shape; it extends the
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
There is no in-repo D-number registry to amend; the consuming design lives
outside this repository, as it did for RFC 0011.
## Exact API delta
### New hook: `world.talk`
```lua
mod.hooks:wrap("world.talk", function(next, ow, target)
-- ow = the OverworldState raising it
-- target = the object on the faced cell
if mine(target) then
say(target)
return -- the mod answered; the text path is skipped
end
return next(ow, target) -- anything else falls through unchanged
end)
```
Call site: `OverworldState:interact`, on the branch that has already resolved
an object on the faced cell (including across a counter) and confirmed it is
not mid-step and not the Pikachu follower. It runs before the map's text
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
fallthrough, which is a file-local function rather than a per-press closure,
so an unhooked A press allocates nothing it did not allocate before.
### New event: `link.battle_ended`
```lua
mod.events:on("link.battle_ended", function(ev)
-- ev = {
-- result = "win" | "lose" | "draw" | "ended",
-- myParty = lockstep copy of our party,
-- theirParty = lockstep copy of theirs,
-- peerName = string,
-- role = "host" | "guest",
-- }
end)
```
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
state has been popped and before `exitWith` unwinds the link stack. Guarded by
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
is allocated and the branch runs exactly as before.
The party copies are the point of the event. They are the lockstep records the
battle actually fought with, which is where the damage lives under cable rules.
### New `WorldAPI` handle methods
```lua
handle:stepNow(dir) --> true when the step started
handle:canStep(dir) --> would stepNow land somewhere legal?
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
handle:isMoving() --> true while a step is animating
handle:setPassable(flag) --> may the player walk through this object?
```
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
therefore without the input lockout. It does **not** consult collision: the
intended caller is replaying a move already decided elsewhere (validated on a
peer, or authored), and re-judging it locally would let two copies of the same
actor disagree about where it is. `canStep` is the separate opinion for callers
that want it. `setPassable` sets the flag `Collision.occupied` already honours
for Yellow's companion Pikachu; a passable object still draws and still talks.
### Two supporting changes
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
with `opts.intro = false` to land directly in the world. A mode that issues its
own starting state has no use for the intro cutscene.
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
slot-scrub widget that enters a link code can also carry a room code or a
host:port address. Called with no argument it is byte-for-byte the previous
widget.
## Migration and compatibility
**Existing mods change nothing.** Every item above is a new name or a new
optional argument; no existing name, payload, signature, or default changes.
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
`tests/run_modkit.lua` proves on every run.
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
without building an event payload, and no handle method is reachable unless a
mod calls it.
## Verification
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
path. It asserts the unhooked build first (the A press reaches `talkTo`),
then that a mod owning the object suppresses the text path by not calling
`next`, that an object the mod ignores still falls through, and that an
object mid-step raises no hook at all.
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
path. It asserts that with nothing subscribed the event is not wanted (so no
payload is built) and the battle still unwinds, then that a subscriber
receives the result, the role, the peer name and both party copies including
the damage the real party never took, from both sides of the cable.
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
passes 208/208 with both names covered and no DEBT entry added.
## Deprecation etiquette
Nothing is removed, renamed, superseded or deprecated.
+123
View File
@@ -0,0 +1,123 @@
# RFC 0016: Engine-owned field residual descriptors
## Status
Proposed.
## Motivation
Battle-rule mods can keep deterministic data-only state in the public battle
field and observe `battle.turn_ended`, but that event fires after the engine's
residual and faint pipeline. A listener cannot safely deal end-of-round field
damage: directly changing live HP bypasses bar drains, faint messages,
experience, replacements, double-faint resolution, and checkpoint continuation.
Putting callbacks into `battle.field` is also rejected by the checkpoint
serializer, correctly, because executable state is not save-safe.
## Decision and plan extended
This implements **D-AT-004: public engine-owned field residual execution**, the
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
RESIDUALS`. The plan 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 8. The engine delta defines only a generic end-of-round extension point;
it contains no weather names, immunities, damage formula, trainer identity, or
Adaptive Trainers policy.
## Exact API delta
Add the guarded Gen 1 hook:
```lua
mod.hooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
rows[#rows + 1] = {
side = "enemy",
amount = 7,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
```
The hook runs once during an undecided battle's end-of-round processing, after
vanilla status residuals and before field/side token expiry and
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
changes nothing. Vanilla contributes an empty list.
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
recursive projection retains raw tables and finite numbers, strings, and
booleans under scalar keys. It strips metatables and omits functions, userdata,
threads, unsupported keys, and cyclic edges. Thus it exposes neither
`field.sides` nor a graph or executable callback back into live engine state.
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
change the live battle. `turn` is the current Gen 1 turn counter.
A result row is `{ side = "player"|"enemy", amount =
positive_finite_integer_number, message = optional_string }`. Numeric strings,
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
messages fail closed. Damage is clamped to current HP. The engine owns
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
accepted row before scheduling newly fainted battlers. If this hook batch
terminally faints the player with no healthy reserve, it queues only the player
faint authority, so descriptor order cannot race a blackout against an enemy
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
player/enemy order. This precedence is scoped to this hook response; native
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
Wrappers compose by calling `next(context)` and appending their own rows.
Callbacks are not part of the descriptor contract and hook functions are never
stored in the battle.
Gold already owns native weather and a generation-specific between-turn order;
this first additive call site is Gen 1-only. A future Gold site must keep the
same context and descriptor contract and choose its native ordering explicitly.
## Migration and compatibility
Existing mods change nothing. No hook name or payload changes. With no wrapper,
Gen 1 performs the same residual, token, event, and faint work as before,
including native simultaneous-faint resolution, and allocates no context. Gen
2 is unchanged. Existing and new checkpoints keep
serializing only the data stored in `battle.field`; hook callbacks remain
process-local loader state and are never serialized.
The v1 surface remains unchanged: `content.X:register/override/get`,
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
`pokemon.before_give` keep their existing behavior.
## Verification
- The catalog hook parity gate proves null and live-empty buses return the
vanilla list unchanged.
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
detached checkpoint-shaped context, applies damage, and reaches the engine
faint pipeline.
- Engine validation tests cover strict number validation (including numeric
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
omission of functions/userdata/threads/cycles and metatables from the public
field projection, optional messages, non-table results, clamping, and
settled-battle suppression.
- Both descriptor orders are driven through queue completion for a simultaneous
terminal residual. Each proves player blackout loss with no EXP event,
enemy replacement, or replacement UI.
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
context construction. An ordering probe proves the enabled hook runs after
vanilla status residuals and before token expiry and `battle.turn_ended`.
- A no-hook native regression proves a simultaneous zero-HP state still enters
the pre-existing enemy-faint EXP and win authority outside this hook batch.
- Capture/restore/capture evidence proves checkpointed field state round-trips
while the enabled process-local hook remains installed and callable.
## Docs with the change
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
validation, simultaneous-terminal result, and checkpoint boundary.
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
No registry or schema changes are involved, so generated registry docs do not
change.
## Deprecation etiquette
Nothing is deprecated. The hook is additive.
+102
View File
@@ -0,0 +1,102 @@
# RFC 0017: Public mod developer-mode signal
## Status
Proposed.
## Motivation
The loader already derives a boot-time developer-mode flag for its permission
diagnostics and headless test seam. Gen 1's `Game` independently derives a
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
read either one. `mod.commands` can register a diagnostic command but cannot
say whether the current boot is a developer boot. `mod.exports` only publishes
values to other mods. `game.ready` fires after entry registration and carries
only the game. `mod.options` is player configuration, not engine mode, and
`mod.log` logs unconditionally. The pre-sandbox compatibility
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
environment is hidden from mods.
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
Phase H require trainer, boss, Rival, and League diagnostic views plus
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
public signal, the mod must either ship those registrations in production,
misuse a player option, or import loader/Logger internals. All three violate
the approved observability boundary or the sandbox/public-API policy.
## Decision and plan extended
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
the engine's developer-mode decision**. The consuming design is tracked in the
Adaptive Trainers implementation plan,
[`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 9. The engine delta is generic and contains no trainer, balancing,
diagnostic-layout, seed-label, or Adaptive Trainers policy.
## Exact API delta
Every sandboxed mod object adds one field:
```lua
mod.developer -- boolean
```
The loader copies its existing `dev` decision into this field before invoking
the mod's entry chunk. It is therefore available for load-time registration:
```lua
if mod.developer then
mod.commands:register("my_mod:diagnostics", diagnostics_command)
end
```
The value is a plain boolean snapshot, not a loader reference or environment
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
separately make `Game`'s own developer flag true for its console and hot
reload. The loader's existing injected `opts.dev` test seam changes only the
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
and does not expose environment variables. The answer is fixed for the life of
that loader; changing a field on a mod's own table cannot change engine mode.
The field is generation-independent and has identical semantics on Red, Blue,
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
or hot-reload hotkeys from this field.
## Migration and compatibility
Existing mods change nothing. `mod.developer` is additive, requires no
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
entry chunks receive one extra scalar field and retain all prior fields and
methods unchanged. No name is removed or shadowed.
With no mods installed, `Loader:_api` is never called, so the delta allocates no
mod object and changes no data, save, options, event, hook, command, or file.
With mods installed in a normal boot, the new field is `false` unless an author
explicitly reads it. Existing registration and logging behavior is unchanged.
An adopting mod should gate developer-only registrations and verbose logging
directly on `mod.developer`. Player-facing behavior belongs behind
`mod.options`, not this signal.
## Verification
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
the public SDK with developer mode both on and off. It proves the boolean is
available during entry execution and that the same source registers its
diagnostic command only for the developer load. It also covers the
command-line global path and a Gen 2 load.
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
parity suite. It proves both developer answers discover no mods, create no
files, and leave injected vanilla data unchanged. It also loads an unchanged
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
- The full ROM-free engine and modkit tiers remain the compatibility proof for
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
No registry or schema changes are involved, so generated registry documentation
is unaffected.
## Deprecation etiquette
Nothing is removed, renamed, superseded, or deprecated.