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.
11 KiB
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_speedneeds no dedicated no-mod test file --tests/engine/gate_hooks.luawalks the live hook catalog (which scanssrcforRuntime.call("...")call sites), so the newRuntime.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, andRuntime.wantsHookreadsfalse. - Mod-API:
tests/engine/game_speed_categories_test.luaexercises 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 vianext(game)and can override it outright -- plus direct coverage ofGame.speedCategoryInStack(battle-on-top, overworld-on-top, an overlay inheriting each, an empty/unmatched stack falling to"menu") andGame: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()reads1during 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.