mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user