Frame Cap (#124)

This commit is contained in:
bryanthaboi
2026-07-23 14:35:26 -04:00
committed by GitHub
parent 352a9a0eb8
commit 2c9970a643
8 changed files with 227 additions and 11 deletions
+3 -1
View File
@@ -148,4 +148,6 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
+75
View File
@@ -299,3 +299,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+72
View File
@@ -0,0 +1,72 @@
-- Render frame-rate cap. With a driver control panel forcing
-- vsync off, the 160x144 game is trivially cheap and love.run will present
-- thousands of frames a second; over hours that cooks the graphics driver
-- until a restart, and it wastes power whenever the window is left open in
-- the background. A hard cap bounds the present rate. Render-only: game
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
-- changes nothing about timing, audio, or determinism.
--
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
-- frame for its sleep budget. The module never touches love.timer itself,
-- so it stays safe under the headless test stub.
local FrameCap = {}
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
-- The live cap the run loop paces to. Defaults so the launcher and the
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
local best, bestDiff = FrameCap.DEFAULT, math.huge
for _, step in ipairs(FrameCap.STEPS) do
local diff = math.abs(step - value)
if diff < bestDiff then best, bestDiff = step, diff end
end
return best
end
-- plain numeric text for the options row (e.g. "60")
function FrameCap.label(value)
return tostring(FrameCap.normalize(value))
end
-- cycle to the next/previous step, wrapping (the options row idiom)
function FrameCap.cycle(value, dir)
local steps = FrameCap.STEPS
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(steps) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
return steps[nextIdx]
end
-- Store the chosen cap as the live value the run loop paces to. Never
-- touches love.timer, so it is safe headless -- the loop just reads the
-- number back. Returns the normalized value it stored.
function FrameCap.apply(value)
FrameCap.current = FrameCap.normalize(value)
return FrameCap.current
end
function FrameCap.applyOptions(opts)
FrameCap.apply(opts and opts.fpsCap)
end
return FrameCap
+3
View File
@@ -445,6 +445,9 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
end
+2
View File
@@ -192,6 +192,8 @@ function SaveData.defaultOptions()
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
+14
View File
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
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 = "GAME SPEED",
+41 -3
View File
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
@@ -205,7 +206,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "speed", "mods", "controls" }
"gbcfx", "videoMode", "fpsCap", "speed", "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)
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[13].activate(mgGame)
om.rows[14].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[14].activate(cbGame)
om.rows[15].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+17 -7
View File
@@ -2091,6 +2091,7 @@ do
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -2155,7 +2156,16 @@ do
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 12, "cursor reaches GAME SPEED")
eq(om.index, 12, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
-- Driven by the step list rather than a literal press count, like GAME
-- 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")
press("down")
eq(om.index, 13, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2164,19 +2174,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 13, "cursor reaches MODS")
eq(om.index, 14, "cursor reaches MODS")
press("down")
eq(om.index, 14, "cursor reaches CONTROLS")
eq(om.index, 15, "cursor reaches CONTROLS")
press("down")
eq(om.index, 15, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
eq(om.index, 16, "CANCEL stays the fixed final row")
eq(om.scroll, 11, "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, 15, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 16, "up from the top wraps to CANCEL")
eq(om2.scroll, 11, "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)