Per-category GAME SPEED: overworld/battle/menu + core.logic_speed hook (RFC 0007)

GameSpeed is a single fast-forward multiplier applied uniformly to the
whole logic clock -- overworld walking, menu navigation and battle turns
all scale together. A player who wants 4X battles but 1X overworld (so a
cutscene or NPC dialogue doesn't blur past) has no way to get both.

Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each
cycling independently, with an automatic migration so an existing save's
speed choice carries over. Game.speedCategoryInStack resolves which
category is active by walking the state stack (the same idiom
wideBattleInStack/fillScaleInStack already use), so a menu opened mid-
battle inherits battle speed rather than resetting to whatever "menu"
defaults to. Adds a new core.logic_speed hook so a mod can read or
override the resolved multiplier for the current frame regardless of
which category produced it, sitting after the link-play and run-argument
overrides so neither is a seam a mod can defeat.

RFC 0007 status: Proposed.
This commit is contained in:
david
2026-08-09 20:13:21 -07:00
parent 943ba5dcbf
commit 3c71afb9fa
14 changed files with 602 additions and 34 deletions
+13
View File
@@ -290,5 +290,18 @@ update and input ownership, so a mod can mirror a native menu on another
display without reimplementing it. The default is `true`. Treat the wrapper as
a pure predicate: the renderer may ask it more than once per frame.
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
category `Game.speedCategoryInStack` says is active right now. A mod may
call `next(game)` and return its result to pass that resolution through, or
return a different number outright to override it for that frame (a bot mod
forcing 1X for one route segment, say, regardless of the category or saved
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
cannot destabilize the fixed-step accumulator. This hook runs *after* link
play's 1X lock and the `--speed`/equivalent run-argument override, both of
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.
+212
View File
@@ -0,0 +1,212 @@
# 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.