From 1ce9e32dd18bc20023fc5c71d26fb5c92b97b416 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 23 Jul 2026 14:20:23 -0400 Subject: [PATCH] frame cap update --- docs/new-features.md | 4 +- main.lua | 89 ++++++++++++++++++++++++++++++++++++++++++ src/core/FrameCap.lua | 72 ++++++++++++++++++++++++++++++++++ src/core/Game.lua | 3 ++ src/core/SaveData.lua | 2 + src/ui/OptionsMenu.lua | 14 +++++++ tests/mod_ui_tests.lua | 44 +++++++++++++++++++-- tests/run_tests.lua | 24 ++++++++---- 8 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 src/core/FrameCap.lua diff --git a/docs/new-features.md b/docs/new-features.md index 2bb38dfc..3e611a22 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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` \ No newline at end of file +- 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`). \ No newline at end of file diff --git a/main.lua b/main.lua index cb882414..192fae70 100644 --- a/main.lua +++ b/main.lua @@ -299,3 +299,92 @@ function love.filedropped(file) end if Importer then Importer:filedropped(file) end end + +-- Frame pacing (issue #88): on a machine whose driver control panel forces +-- vsync off, the 160x144 game is so cheap that love.run presents thousands +-- of frames a second, which over hours degrades the graphics driver until a +-- restart (and burns power with the window merely open in the background). +-- A hard render cap bounds it. Render-only: the logic clock is fixed-step +-- off dt (src/core/FixedStep.lua), so capping present() leaves timing, +-- audio, and determinism untouched, and vsync (conf.lua) is left alone. +-- +-- Scripted / headless runs must keep full speed so CI screenshot and bot +-- tooling is not throttled, so they opt out entirely -- same env vars +-- love.load already reads for the scripted-boot path. +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 + +-- LÖVE 11.5's default run loop, copied faithfully, with a frame-pacing sleep +-- swapped in for the stock trailing love.timer.sleep(0.001). Kept resilient: +-- with no love.timer (headless) nothing sleeps, exactly as today. +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 diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua new file mode 100644 index 00000000..b9009031 --- /dev/null +++ b/src/core/FrameCap.lua @@ -0,0 +1,72 @@ +-- Render frame-rate cap (issue #88). 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 diff --git a/src/core/Game.lua b/src/core/Game.lua index b8f0e02a..7c519e05 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index c07016ad..b1ceb91a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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 = {}, diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 2a68f0a5..767df6ee 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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", diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index b8705bef..237858ab 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 0cba823e..7e1cd020 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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)