Files
gen1recomp/src/core/FrameCap.lua
T
2026-09-02 10:02:32 -04:00

146 lines
5.2 KiB
Lua

-- 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 = {}
local function isHandheldEnv()
return os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
end
-- Platforms where PresentSync should pace via the panel (DISPLAY), not a
-- numeric FrameCap default. Android/iOS/UWP need uncapped probe isolation
-- so composed GLES swapchains can lock; PortMaster handhelds likewise
-- follow KMSDRM through PresentSync.
function FrameCap.loopSupportsPanelSync()
return rawget(_G, "POKEPORT_LOOP_PANEL_SYNC") == true
end
function FrameCap.prefersPanelSync()
if not FrameCap.loopSupportsPanelSync() then return false end
if isHandheldEnv() then return true end
if love and love.system and love.system.getOS then
local osName = love.system.getOS()
if osName == "Android" or osName == "iOS" or osName == "UWP" then
return true
end
end
return false
end
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
FrameCap.DISPLAY = 0
FrameCap.CYCLE = {}
for i, step in ipairs(FrameCap.STEPS) do FrameCap.CYCLE[i] = step end
FrameCap.CYCLE[#FrameCap.CYCLE + 1] = FrameCap.DISPLAY
-- 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
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
if value <= 0 then
if FrameCap.loopSupportsPanelSync() then return FrameCap.DISPLAY end
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
function FrameCap.label(value)
local cap = FrameCap.normalize(value)
local text = cap == FrameCap.DISPLAY and "DISPLAY" or tostring(cap)
local ok, hz = pcall(function()
return require("src.core.RefreshRate").mismatch()
end)
if ok and hz then text = string.format("%s (%dHZ)", text, math.floor(hz + 0.5)) end
return text
end
function FrameCap.cycle(value, dir)
local ring = FrameCap.CYCLE
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(ring) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #ring + 1
return ring[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)
local cap = opts and opts.fpsCap
-- Handheld / Android / iOS / UWP follow the panel through PresentSync; the
-- stored default 60 would bypass DISPLAY pacing and force the software
-- limiter (and on composed GLES nests that also prevents vsync locking).
if (cap == nil or cap == FrameCap.DEFAULT) and FrameCap.prefersPanelSync() then
cap = FrameCap.DISPLAY
end
return FrameCap.apply(cap)
end
-- Launcher / pre-save boot: same DISPLAY default before any save applies.
function FrameCap.bootPanelSync()
if FrameCap.prefersPanelSync() and FrameCap.current == FrameCap.DEFAULT then
return FrameCap.apply(FrameCap.DISPLAY)
end
return FrameCap.current
end
-- Alias kept for existing call sites / docs.
FrameCap.bootHandheld = FrameCap.bootPanelSync
-- Performance LOW tier caps extras; do not rewrite DISPLAY to numeric 60 when
-- the panel is already at or below the ceiling (that bypasses PresentSync).
function FrameCap.clampToPerformance(fpsMax)
fpsMax = tonumber(fpsMax)
if not fpsMax then return FrameCap.current end
if FrameCap.current > fpsMax then
return FrameCap.apply(fpsMax)
end
if FrameCap.current == FrameCap.DISPLAY then
local ok, RR = pcall(require, "src.core.RefreshRate")
local hz = ok and RR.hz()
if hz and hz > fpsMax then
return FrameCap.apply(fpsMax)
end
end
return FrameCap.current
end
return FrameCap