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.
+6
View File
@@ -37,6 +37,12 @@ local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle --
-- wild, trainer, link, safari, the old-man demo -- is this metatable, so
-- Game.speedCategoryInStack needs no special-casing beyond this one flag.
BattleState.isBattle = true
function BattleState:romText(label, fallback, ...)
return romText(self.data, label, fallback, ...)
end
+58 -9
View File
@@ -221,24 +221,44 @@ function Game:step(dt)
-- it on its own real-time 60Hz accumulator instead.
end
-- The per-category (RFC 0007) save.options multiplier for whichever of
-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active
-- right now. This is the "vanilla" the core.logic_speed hook wraps below
-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so
-- neither a mod nor the category resolution ever has a seam to defeat them.
function Game:_resolveLogicSpeed()
local GameSpeed = require("src.core.GameSpeed")
local category = Game.speedCategoryInStack(self.stack)
local key = GameSpeed.optionKey(category)
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT)
end
-- The logic multiplier for this frame. Read live rather than cached so the
-- Options row takes effect immediately; speedOverride is the --speed /
-- Options rows take effect immediately; speedOverride is the --speed /
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
-- source including POKEPORT_SPEED and every per-category option.
-- Fast-forward multiplies the logic clock, so a peer at 10X burned a
-- tournament shot clock ten times faster than the opponent it is racing,
-- and drove its own animation/message queue at a different rate than the
-- peer it is locked to. Nothing about a match should depend on what
-- either player set this to -- checked here, before the core.logic_speed
-- hook ever runs, so a mod cannot defeat it either.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
-- Hooks:call only guards against a hook that throws, not one that
-- returns a bad value, so an unclamped result would flow straight into
-- the FixedStep accumulator math below and freeze or destabilize logic.
return GameSpeed.clamp(ModRuntime.call("core.logic_speed",
function(g) return g:_resolveLogicSpeed() end, self))
end
function Game:update(dt)
@@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack)
return nil
end
-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007)
-- applies right now. Whole-stack, the same idiom as fillScaleInStack/
-- wideBattleInStack above: an overlay with neither marker (PartyMenu,
-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and
-- inherits whatever is under it, making the category a property of the
-- STACK POSITION the overlay sits over, not of the overlay itself. A
-- scripted sequence (script.started/ended) never pushes a state of its
-- own either -- it runs through the owning overworld/battle state's own
-- script runner or message queue -- so it inherits the same way. Nothing
-- identifying as either (the title screen, credits, an intro cutscene
-- with nothing under it) falls to "menu", the bucket every non-gameplay
-- screen gets; see the RFC's Decisions section for the full reasoning.
function Game.speedCategoryInStack(stack)
local states = stack and stack.states
for i = #(states or {}), 1, -1 do
local state = states[i]
if state and state.isBattle then return "battle" end
if state and state.isOverworld then return "overworld" end
end
return "menu"
end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
@@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir)
or ow.engaging or ow.emote))
end
if busy then return end
-- Cycles whichever category Game.speedCategoryInStack says is active
-- right now (RFC 0007) -- pressing the hotkey during a battle speeds up
-- just the battle, on the overworld just the walk, in a menu just the
-- menu. A single physical control that means "speed up whatever I'm
-- looking at right now" needs no new UI and matches what a player
-- pressing it mid-battle almost certainly wants.
local GameSpeed = require("src.core.GameSpeed")
self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir)
local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack))
self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir)
self:writeOptions()
end
+15
View File
@@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir)
return levels[nextIdx]
end
-- Per-category speed (RFC 0007): overworld walking, battle turns and menu
-- navigation each cycle their own multiplier instead of one global "speed"
-- value. This list is the single source of truth for which categories
-- exist and the order the Options rows/save.options keys follow;
-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category
-- is active on a given frame, this module only knows the category names.
GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }
-- the save.options field name a category's multiplier lives under, e.g.
-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua,
-- LauncherSettings.lua and the SaveData migration never hand-spell the key.
function GameSpeed.optionKey(category)
return "speed" .. category:sub(1, 1):upper() .. category:sub(2)
end
return GameSpeed
+19 -2
View File
@@ -261,8 +261,12 @@ function SaveData.defaultOptions()
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
pikaVol = 7,
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- Per-category logic fast-forward multiplier (RFC 0007); audio is
-- unaffected (GameSpeed.lua). Superseded from a single "speed" field --
-- mergeOptions migrates an old save's value into all three below.
speedOverworld = 1,
speedBattle = 1,
speedMenu = 1,
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc",
tilt = 0,
@@ -339,6 +343,19 @@ function SaveData.mergeOptions(loaded)
for k, v in pairs(loaded) do
opts[k] = v
end
-- RFC 0007 migration: a save from before per-category GAME SPEED still
-- has a single "speed" and none of the three new fields, so seed all
-- three from it -- 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 kept as a
-- stale alias), so a re-save never re-triggers this migration.
if loaded.speed ~= nil and loaded.speedOverworld == nil
and loaded.speedBattle == nil and loaded.speedMenu == nil then
opts.speedOverworld = loaded.speed
opts.speedBattle = loaded.speed
opts.speedMenu = loaded.speed
end
opts.speed = nil
end
return opts
end
+17 -3
View File
@@ -214,10 +214,24 @@ local function coreRows(opts, hooks)
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
if okSpd then
add(Strings("GAME SPEED"),
function() return GameSpeed.levelLabel(opts.speed) end,
-- Per-category (RFC 0007): overworld/battle/menu each cycle their own
-- multiplier, mirroring OptionsMenu.lua's three rows.
add(Strings("OVERWORLD SPEED"),
function() return GameSpeed.levelLabel(opts.speedOverworld) end,
function(dir)
opts.speed = GameSpeed.cycle(opts.speed, dir)
opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir)
return true
end)
add(Strings("BATTLE SPEED"),
function() return GameSpeed.levelLabel(opts.speedBattle) end,
function(dir)
opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir)
return true
end)
add(Strings("MENU SPEED"),
function() return GameSpeed.levelLabel(opts.speedMenu) end,
function(dir)
opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir)
return true
end)
end
+25 -4
View File
@@ -392,14 +392,35 @@ local function buildRows(game)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = Strings("GAME SPEED"),
-- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category
-- (RFC 0007): overworld walking, battle turns and menu navigation each
-- cycle their own multiplier -- GameSpeed.CATEGORIES is the single
-- source of truth for which three rows exist.
{ id = "speedOverworld", label = Strings("OVERWORLD SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speed)
return GameSpeed.levelLabel(g.save.options.speedOverworld)
end,
step = function(g, dir)
local o = g.save.options
o.speed = GameSpeed.cycle(o.speed, dir)
o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir)
return true
end },
{ id = "speedBattle", label = Strings("BATTLE SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speedBattle)
end,
step = function(g, dir)
local o = g.save.options
o.speedBattle = GameSpeed.cycle(o.speedBattle, dir)
return true
end },
{ id = "speedMenu", label = Strings("MENU SPEED"),
value = function(g)
return GameSpeed.levelLabel(g.save.options.speedMenu)
end,
step = function(g, dir)
local o = g.save.options
o.speedMenu = GameSpeed.cycle(o.speedMenu, dir)
return true
end },
-- the manager's discoverable home (18-mod-manager-ux); inert until
+6 -2
View File
@@ -55,8 +55,12 @@ return function(game)
U.wait(10)
-- the GAME SPEED forcing under test: a link session pins the logic clock
-- to 1X no matter what the option or POKEPORT_SPEED says
game.save.options.speed = 10
-- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set
-- all three per-category speeds high, since the link lock has to win over
-- every one of them, not just whichever category happens to be active.
game.save.options.speedOverworld = 10
game.save.options.speedBattle = 10
game.save.options.speedMenu = 10
U.wait(2)
log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed())
+5 -1
View File
@@ -55,7 +55,11 @@ return function(game)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
game.save.options.speed = 20
-- RFC 0007: set all three per-category speeds high, since the link lock
-- has to win over every one of them, not just whichever is active.
game.save.options.speedOverworld = 20
game.save.options.speedBattle = 20
game.save.options.speedMenu = 20
U.wait(2)
log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed())
@@ -46,7 +46,9 @@ return function(game)
os.getenv("POKEPORT_DRIVER") == nil)
check("no fast-forward multiplier is set",
(tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1
and (game.save.options.speed or 1) == 1)
and (game.save.options.speedOverworld or 1) == 1
and (game.save.options.speedBattle or 1) == 1
and (game.save.options.speedMenu or 1) == 1)
check("a window is up to watch (this is a visual call)",
love.window ~= nil and love.window.isOpen and love.window.isOpen())
U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap),
+196
View File
@@ -0,0 +1,196 @@
-- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack
-- walk, Game:logicSpeed()'s precedence (link lock / run-argument override /
-- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and
-- the core.logic_speed hook itself exercised through the public mod API
-- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a
-- private require).
-- luajit tests/engine/game_speed_categories_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Game = require("src.core.Game")
local GameSpeed = require("src.core.GameSpeed")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
-- ------- Game.speedCategoryInStack: the whole-stack walk
local function stack(...) return { states = { ... } } end
local battle = { isBattle = true }
local overworld = { isOverworld = true }
local overlay = {} -- a party menu/choice box/naming screen/text box: no marker
eq(Game.speedCategoryInStack(nil), "menu", "a nil stack falls to menu")
eq(Game.speedCategoryInStack(stack()), "menu", "an empty stack falls to menu")
eq(Game.speedCategoryInStack(stack(overlay)), "menu",
"an unmarked state alone (title screen, credits, a standalone cutscene) is menu")
eq(Game.speedCategoryInStack(stack(overworld)), "overworld",
"the overworld alone resolves to overworld")
eq(Game.speedCategoryInStack(stack(battle)), "battle",
"a battle alone resolves to battle")
eq(Game.speedCategoryInStack(stack(overworld, overlay)), "overworld",
"a menu opened while walking inherits overworld")
eq(Game.speedCategoryInStack(stack(battle, overlay)), "battle",
"a menu opened mid-battle inherits battle, not menu")
eq(Game.speedCategoryInStack(stack(overworld, overlay, overlay)), "overworld",
"the inheritance walk sees through more than one stacked overlay")
eq(Game.speedCategoryInStack(stack(overworld, battle)), "battle",
"a battle opened over the overworld reads as battle, not the overworld underneath it")
eq(Game.speedCategoryInStack(stack(overworld, battle, overlay)), "battle",
"and a menu on top of THAT still reads as battle")
-- ------- Game:_resolveLogicSpeed: category -> save.options key -> clamp
local unpack = table.unpack or unpack
local function gameWith(states, options)
return setmetatable({
save = { options = options },
stack = stack(unpack(states or {})),
}, { __index = Game })
end
do
local g = gameWith({ overworld },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 4, "overworld reads speedOverworld")
end
do
local g = gameWith({ battle },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle")
end
do
local g = gameWith({ overlay },
{ speedOverworld = 4, speedBattle = 10, speedMenu = 2 })
eq(g:_resolveLogicSpeed(), 2, "menu reads speedMenu")
end
do
local g = gameWith({ overworld }, { speedOverworld = 7 })
eq(g:_resolveLogicSpeed(), GameSpeed.clamp(7),
"an odd value clamps to the nearest LEVELS entry, like the old single field")
end
do
local g = gameWith({ overworld }, nil)
eq(g:_resolveLogicSpeed(), GameSpeed.DEFAULT,
"no save.options at all defaults rather than erroring")
end
-- ------- Game:logicSpeed(): link and speedOverride win over every category
-- and over a hook override; the hook only ever sees the ordinary case
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkSession = true
eq(g:logicSpeed(), 1, "an active link session forces 1X even at 50X battle")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkNet = { closed = false }
eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkNet = { closed = true }
eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X")
end
do
local g = gameWith({ overworld }, { speedOverworld = 4 })
g.speedOverride = 20
eq(g:logicSpeed(), 20,
"speedOverride (--speed/POKEPORT_SPEED) wins over the category option")
end
do
local g = gameWith({ battle }, { speedBattle = 50 })
g.linkSession = true
local bus = Hooks.new()
local savedHooks = Runtime.hooks
Runtime.hooks = bus
local hookRan = false
local unsub = bus:wrap("core.logic_speed", function(next, game)
hookRan = true
return 999
end)
eq(g:logicSpeed(), 1,
"the link lock wins even over a mod's core.logic_speed override")
check(not hookRan, "...because the hook is never called during link play")
unsub()
Runtime.hooks = savedHooks
end
-- ------- core.logic_speed: the mod-API seam, driven through Runtime.call/
-- Hooks.new + bus:wrap like every other hook's public-API test
local function callLogicSpeed(g)
return Runtime.call("core.logic_speed",
function(gg) return gg:_resolveLogicSpeed() end, g)
end
do
local g = gameWith({ battle }, { speedBattle = 4 })
eq(callLogicSpeed(g), 4,
"with no subscriber, the hook returns the vanilla category resolution")
end
do
local g = gameWith({ overworld }, { speedOverworld = 4 })
local bus = Hooks.new()
local savedHooks = Runtime.hooks
Runtime.hooks = bus
local nextArg = nil
local unsub = bus:wrap("core.logic_speed", function(next, game)
nextArg = next(game)
return nextArg
end)
eq(callLogicSpeed(g), 4,
"a subscriber calling next(game) passes the vanilla value through")
eq(nextArg, 4, "...and next(game) itself returned the vanilla resolution")
unsub()
-- a bot mod forcing 1X for one route segment regardless of the category
unsub = bus:wrap("core.logic_speed", function(next, game) return 1 end)
eq(callLogicSpeed(g), 1,
"a subscriber may override the resolved multiplier outright")
unsub()
Runtime.hooks = savedHooks
end
-- ------- Game:_cycleSpeed: cycles whichever category is active, and only it
do
local writeOptions = { calls = 0 }
local g = gameWith({ battle },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end
g:_cycleSpeed(1)
eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle")
eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone")
eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone")
eq(writeOptions.calls, 1, "a successful cycle persists the option")
end
do
local g = gameWith({ overworld },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld")
eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone")
end
do
local g = gameWith({ overlay },
{ speedOverworld = 1, speedBattle = 1, speedMenu = 1 })
function g:writeOptions() end
g:_cycleSpeed(1)
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
end
T.finish("game_speed_categories")
+4 -3
View File
@@ -287,7 +287,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"performance", "colors",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap",
"speed", "mods", "controls" }
"speedOverworld", "speedBattle", "speedMenu",
"mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -383,7 +384,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[22].activate(mgGame)
om.rows[24].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -393,7 +394,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[23].activate(cbGame)
om.rows[25].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+23 -9
View File
@@ -2759,29 +2759,43 @@ do
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
-- RFC 0007: the single GAME SPEED row is now three independent rows,
-- one per GameSpeed.CATEGORIES entry.
press("down")
eq(om.index, 21, "cursor reaches GAME SPEED")
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
-- speed (20X went in for the bot runs) otherwise fails this as a wrap
-- bug when the cycling is fine and the row is simply one longer.
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
press("down")
eq(om.index, 22, "cursor reaches MODS")
eq(om.index, 22, "cursor reaches BATTLE SPEED")
press("a")
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
press("down")
eq(om.index, 23, "cursor reaches CONTROLS")
eq(om.index, 23, "cursor reaches MENU SPEED")
press("a")
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
press("down")
eq(om.index, 24, "CANCEL stays the fixed final row")
eq(om.scroll, 19, "CANCEL keeps the last option boxes on screen")
eq(om.index, 24, "cursor reaches MODS")
press("down")
eq(om.index, 25, "cursor reaches CONTROLS")
press("down")
eq(om.index, 26, "CANCEL stays the fixed final row")
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 24, "up from the top wraps to CANCEL")
eq(om2.scroll, 19, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 26, "up from the top wraps to CANCEL")
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)