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
+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