diff --git a/main.lua b/main.lua index 8b6721ba..1fc2c818 100644 --- a/main.lua +++ b/main.lua @@ -1423,9 +1423,10 @@ function love.run() elseif cap == FrameCap.DISPLAY and not VSync.isOn() then cap = FrameCap.DEFAULT elseif cap == FrameCap.DISPLAY and PresentSync.needsSoftwareCap() then - -- Vsync is "on" but presents are ungated (driver no-op, VRR early - -- return, Gamescope/XWayland, etc.) and no platform wait is bound: - -- FrameCap is the thermal / pacing safety net on every OS. + -- Fallback cascade: probe failed / wait abandoned / sync non- + -- deterministic → FrameCap is the live pacing path on every OS. + -- (During an active probe we intentionally leave DISPLAY uncapped so + -- calibration is not grading our own limiter.) cap = FrameCap.DEFAULT end diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 69118956..2d71ad1d 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -5,7 +5,12 @@ local FixedStep = {} FixedStep.STEP = 1 / 60 -local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall +-- Hard ceiling on catch-up debt (seconds of logic time drained in one +-- rendered frame). 0.25s = 15 GB steps. Game:update may lower this for the +-- current speed target but must never raise it: a hitch at high multipliers +-- cannot dump unbounded steps and starve input / spiral the frame. +FixedStep.MAX_ACCUM = 0.25 +local MAX_ACCUM = FixedStep.MAX_ACCUM local SMOOTH_FRAMES = 4 local SMOOTH_MAX = 1 / 60 * 2.5 local STEP_EPS = 1 / 60 * 0.02 @@ -55,13 +60,23 @@ function FixedStep:init(callback) self.phasedFor = nil end --- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 --- steps), which silently throttled the high fast-forward levels: at 100X --- a 60fps frame wants ~100 steps. Game:update raises this to fit the --- current speed target; a stall still cannot snowball past one frame's --- intended budget. +-- The anti-spiral clamp is the steps-per-frame ceiling. Game:update sets +-- maxAccum to the current speed's one-frame budget, hard-capped at MAX_ACCUM +-- so high fast-forward cannot turn a hitch into input starvation. FixedStep.maxAccum = MAX_ACCUM +-- Compute the live catch-up ceiling for a logic-speed multiplier. +-- Always ≤ MAX_ACCUM; at least two steps so ordinary vsync wobble can recover. +function FixedStep.catchupLimit(speed) + speed = tonumber(speed) or 1 + if speed < 1 then speed = 1 end + local target = speed * FixedStep.STEP * 1.5 + local floor = FixedStep.STEP * 2 + if target < floor then target = floor end + if target > MAX_ACCUM then target = MAX_ACCUM end + return target +end + function FixedStep:update(dt, speed) -- A hitch's oversized dt lands on the frame AFTER discardCatchup was -- called (the hitch itself already ran inside the current step); absorb @@ -80,6 +95,9 @@ function FixedStep:update(dt, speed) -- destabilized pacing under DISPLAY sync. speed = tonumber(speed) or 1 if speed < 1 then speed = 1 end + -- Pathological wall-clock stalls: do not let dt alone exceed the catch-up + -- ceiling before speed is applied (speed amplify would just hit the clamp). + if dt > MAX_ACCUM then dt = MAX_ACCUM end local period = self.refreshPeriod local snapped = false if period and dt > 0 then diff --git a/src/core/Game.lua b/src/core/Game.lua index 5308e194..40f37326 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -399,7 +399,7 @@ function Game:update(dt) local speed = self:logicSpeed() local Sound = require("src.core.Sound") if Sound.setRate then Sound.setRate(speed) end - FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5) + FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) local step = FixedStep.STEP self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c678e661..3d34d646 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1268,12 +1268,12 @@ function Game2:update(dt) local Sound = require("src.core.Sound") if Sound.setRate then Sound.setRate(speed) end if self.phase == "boot" then - FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) + FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) return end if not self.world or not self.world.map then return end - FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) + FixedStep.maxAccum = FixedStep.catchupLimit(speed) FixedStep:update(dt, speed) end diff --git a/src/core/PresentProbe.lua b/src/core/PresentProbe.lua index e3eb36ce..c8052841 100644 --- a/src/core/PresentProbe.lua +++ b/src/core/PresentProbe.lua @@ -1,15 +1,15 @@ -- Present sync: on every platform, measure whether love.window.setVSync --- actually gates presents by timing present() itself (not the gap between --- frames — that gap includes FrameCap sleeps and would false-trigger "gated" --- during DISPLAY warmup). Linux additionally binds a real wait (GLX OML / --- SGI on native X11) when SDL's swap interval is a no-op (Gamescope / --- XWayland). Wayland: never duplicate wl_surface.frame. drmWaitVBlank is --- never used. Ambiguous probe results prefer FrameCap over trusting sync. +-- actually gates presents by timing present() itself with the software +-- limiter disabled (probe isolation — no self-measurement artifacts). +-- Linux may additionally bind a real wait (GLX OML / SGI on native X11) +-- when SDL's swap interval is a no-op. Wayland: never duplicate +-- wl_surface.frame. drmWaitVBlank is never used. +-- Ambiguous or unstable probe results prefer FrameCap over trusting sync. local PresentProbe = {} --- Probe present() block time (not inter-frame gaps). Inter-frame gaps include --- FrameCap sleeps and would always look "gated" during DISPLAY warmup. +-- Probe present() block time with FrameCap disabled during calibration. +-- Inter-frame gaps include limiter sleeps and must never be the signal. local PROBE_FRAMES = 45 local INSTANT_MS = 0.0005 local BLOCK_MS = 0.002 @@ -109,9 +109,10 @@ local function detectNest(driver) return "unknown" end --- Classify from present() block times. Prefer false (ungated → FrameCap) --- whenever the signal is ambiguous: trusting a broken swap interval uncapped --- is far worse than pacing in software. +-- Classify from present() block times. +-- Fail closed: anything ambiguous or non-deterministic → ungated → FrameCap. +-- Trusting a broken / VRR-jittery swap interval uncapped is far worse than +-- software pacing. local function classifyGated(intervals) if not intervals or #intervals < 10 then return nil end local sorted = {} @@ -119,17 +120,26 @@ local function classifyGated(intervals) table.sort(sorted) local mid = sorted[math.floor(#sorted / 2) + 1] if not mid or mid <= 0 then return false end + + -- Known panel Hz required before we ever trust hardware gating. local hz = nil local okRR, RR = pcall(require, "src.core.RefreshRate") if okRR then hz = RR.hz() end - local expect = hz and (1 / hz) or (1 / 60) - -- Ungated: present returns well before a panel period (often < 2ms). - if mid < expect * 0.45 then return false end - -- Full-rate vsync: block time sits near the panel period. - if mid >= expect * 0.7 and mid <= expect * 1.55 then return true end - -- Half-rate vsync (e.g. 30Hz on a 60Hz panel). - if mid >= expect * 1.7 and mid <= expect * 2.4 then return true end - -- CPU-bound mid-range (e.g. 8–12ms on 60Hz) is not evidence of sync. + if not hz or hz <= 0 then return false end + local expect = 1 / hz + + -- Spread check: real swap-interval lock is tight; CPU-bound or VRR jitter + -- spreads the distribution and must not pass as "gated". + local q1 = sorted[math.floor(#sorted * 0.25) + 1] + local q3 = sorted[math.floor(#sorted * 0.75) + 1] + if not q1 or not q3 or (q3 - q1) > mid * 0.2 then return false end + + -- Ungated: present returns well before a panel period. + if mid < expect * 0.5 then return false end + -- Full-rate vsync: tight band around the panel period. + if mid >= expect * 0.85 and mid <= expect * 1.15 then return true end + -- Half-rate vsync (e.g. 30Hz on a 60Hz panel): tight band around 2x. + if mid >= expect * 1.85 and mid <= expect * 2.15 then return true end return false end diff --git a/src/core/PresentSync.lua b/src/core/PresentSync.lua index 4c594ea5..83be9b14 100644 --- a/src/core/PresentSync.lua +++ b/src/core/PresentSync.lua @@ -1,10 +1,15 @@ --- Cross-platform present sync: probe whether vsync actually gates presents, --- delegate Linux-specific GLX waits to PresentProbe, and decide when FixedStep --- may snap dt to the panel refresh (DISPLAY + working sync only). +-- Cross-platform present sync: layered fail-safe for vsync / frame pacing. -- --- During DISPLAY+vsync probe warmup, FrameCap still paces at 60 as a thermal --- net. The probe times present() block duration (not inter-frame gaps), so --- that sleep does not contaminate the gated/ungated verdict. +-- Defense in depth: +-- 1. Probe isolation — calibration measures present() block time with no +-- FrameCap sleep and no FixedStep refresh snap, so the probe cannot +-- grade its own limiter. +-- 2. Tight classification — only a stable, panel-aligned present() block +-- distribution counts as hardware-gated; anything else fails closed. +-- 3. Fallback cascade — ungated / failed / abandoned waits force FrameCap +-- immediately; logic never snaps to panel Hz unless sync is confirmed. +-- 4. (FixedStep) catch-up debt is hard-capped so speed multipliers cannot +-- spiral into input-starving multi-frame dumps. local PresentSync = {} @@ -20,7 +25,7 @@ function PresentSync.notePresent() probe().notePresent() end --- True while the probe still has no gated/ungated verdict for DISPLAY+vsync. +-- True while DISPLAY+vsync still has no gated/ungated verdict. function PresentSync.probingDisplaySync() local status = probe().status() if status.gated ~= nil then return false end @@ -29,19 +34,27 @@ function PresentSync.probingDisplaySync() return FrameCap.current == FrameCap.DISPLAY and VSync.isOn() end --- Software FrameCap is required when sync failed, OR while DISPLAY+vsync is --- still probing (thermal net only — probe measures present() block time). +-- Software FrameCap is required only after sync has failed (or a bound wait +-- was abandoned). During probe we deliberately do NOT soft-cap: the +-- calibration must observe the raw swapchain, not our own limiter. function PresentSync.needsSoftwareCap() - if probe().needsSoftwareCap() then return true end - return PresentSync.probingDisplaySync() + return probe().needsSoftwareCap() == true +end + +-- Hardware gating is trusted only after a finished probe says gated and the +-- fallback flag is clear. Probing or failed → not confirmed. +function PresentSync.displaySyncConfirmed() + if PresentSync.needsSoftwareCap() then return false end + local status = probe().status() + return status.gated == true end -- Vsync cannot be turned on usefully once the probe has failed; the row still --- allows stepping to OFF. Warmup (probingDisplaySync) does not block the row. +-- allows stepping to OFF. Warmup does not block the row. function PresentSync.vsyncEnableBlocked() local VSync = require("src.core.VSync") if not VSync.isOn() then return false end - return probe().needsSoftwareCap() + return PresentSync.needsSoftwareCap() end function PresentSync.vsyncStepAllowed(mode, dir) @@ -66,10 +79,8 @@ function PresentSync.status() return probe().status() end --- FixedStep.refreshPeriod is only set when logic cadence should track the --- display. Snapping dt to 144Hz while software-pacing at 60 (#1958) or --- with vsync off is what caused the irregular frame pacing regression. --- Also withheld during probe warmup (needsSoftwareCap includes probing). +-- FixedStep.refreshPeriod only when display sync is confirmed working. +-- Never during probe, never when FrameCap is the live pacing path (#1958). function PresentSync.logicRefreshPeriod() local FrameCap = require("src.core.FrameCap") local VSync = require("src.core.VSync") @@ -78,11 +89,15 @@ function PresentSync.logicRefreshPeriod() if cap == FrameCap.DISPLAY then if not VSync.isOn() then return nil end - if PresentSync.needsSoftwareCap() then return nil end + if not PresentSync.displaySyncConfirmed() then return nil end return RefreshRate.period() end if not cap or cap <= 0 then return nil end + -- Numeric cap matching panel Hz may snap; still withhold while a DISPLAY + -- probe failure forced the software limiter (cap may still read DISPLAY + -- until main.lua overrides the live sleep budget). + if PresentSync.needsSoftwareCap() then return nil end local hz = RefreshRate.hz() if hz and cap == hz then return 1 / hz end diff --git a/tests/engine/present_probe.lua b/tests/engine/present_probe.lua index 84fe92f3..8063611a 100644 --- a/tests/engine/present_probe.lua +++ b/tests/engine/present_probe.lua @@ -49,6 +49,15 @@ for i = 1, 20 do locked90unknown[i] = 1 / 90 end T.eq(LPS._testClassifyGated(locked90unknown), false, "without a panel Hz, 1/90 is inconclusive and prefers FrameCap") +withHz(60, function() + local jittery = {} + -- Median near 1/60 but IQR > 20% of mid → non-deterministic, fail closed. + for i = 1, 10 do jittery[i] = 0.008 end + for i = 11, 20 do jittery[i] = 0.024 end + T.eq(LPS._testClassifyGated(jittery), false, + "high present-time variance fails closed to ungated") +end) + T.eq(LPS._testClassifyGated({ 0.016, 0.017 }), nil, "too few samples stay unclassified") @@ -219,30 +228,32 @@ local t = 0 love.timer = love.timer or {} local savedGetTime = love.timer.getTime love.timer.getTime = function() return t end -for i = 1, 45 do - -- Instant present() (broken vsync), then a fake 16ms FrameCap sleep afterward. - LPS.waitBeforePresent() - t = t + 0.001 - LPS.notePresent() - t = t + 1 / 60 -end -local st = LPS.status() -T.eq(st.gated, false, - "instant present() stays ungated even when FrameCap sleeps 16ms after") -T.eq(LPS.needsSoftwareCap(), true, "so FrameCap remains the thermal net") +withHz(60, function() + for i = 1, 45 do + -- Instant present() (broken vsync), then a fake 16ms FrameCap sleep afterward. + LPS.waitBeforePresent() + t = t + 0.001 + LPS.notePresent() + t = t + 1 / 60 + end + local st = LPS.status() + T.eq(st.gated, false, + "instant present() stays ungated even when FrameCap sleeps 16ms after") + T.eq(LPS.needsSoftwareCap(), true, "so FrameCap remains the thermal net") -LPS.reset() -LPS._testSetState({ osLinux = false, ready = true, nest = "windows", clearGated = true }) -t = 0 -for i = 1, 45 do - LPS.waitBeforePresent() - t = t + 1 / 60 -- present() itself blocks a full panel period - LPS.notePresent() - t = t + 1 / 60 -- trailing FrameCap sleep is ignored by the probe -end -st = LPS.status() -T.eq(st.gated, true, "a present() that blocks a panel period counts as gated") -T.eq(LPS.needsSoftwareCap(), false, "and does not force FrameCap") + LPS.reset() + LPS._testSetState({ osLinux = false, ready = true, nest = "windows", clearGated = true }) + t = 0 + for i = 1, 45 do + LPS.waitBeforePresent() + t = t + 1 / 60 -- present() itself blocks a full panel period + LPS.notePresent() + t = t + 1 / 60 -- trailing FrameCap sleep is ignored by the probe + end + st = LPS.status() + T.eq(st.gated, true, "a present() that blocks a panel period counts as gated") + T.eq(LPS.needsSoftwareCap(), false, "and does not force FrameCap") +end) -- A bound wait that overruns abandons to FrameCap instead of wedging. LPS.reset() diff --git a/tests/engine/present_sync_logic.lua b/tests/engine/present_sync_logic.lua index 65ac03e0..d9882bd3 100644 --- a/tests/engine/present_sync_logic.lua +++ b/tests/engine/present_sync_logic.lua @@ -36,11 +36,14 @@ PresentProbe._testSetState({ osLinux = false, ready = true, gated = false, needsSoftwareCap = true, nest = "windows" }) T.eq(PresentSync.logicRefreshPeriod(), nil, "DISPLAY with broken sync falls back to software cap, not panel snap") +T.check(PresentSync.needsSoftwareCap(), "failed sync trips the FrameCap cascade") +T.check(not PresentSync.displaySyncConfirmed(), "and is not treated as confirmed") PresentProbe._testSetState({ osLinux = false, ready = true, gated = true, needsSoftwareCap = false, nest = "windows" }) T.eq(PresentSync.logicRefreshPeriod(), 1 / 144, "DISPLAY with working sync tracks the panel") +T.check(PresentSync.displaySyncConfirmed(), "gated+clear softcap is confirmed sync") FrameCap.apply(60) T.eq(PresentSync.logicRefreshPeriod(), nil, @@ -53,22 +56,27 @@ T.eq(PresentSync.logicRefreshPeriod(), 1 / 144, PresentSync.applyFixedStepPeriod() T.eq(FixedStep.refreshPeriod, 1 / 144, "applyFixedStepPeriod writes the module field") +-- Probe isolation: during calibration we do NOT soft-cap (raw swapchain), +-- and we never snap logic until sync is confirmed. FrameCap.apply(FrameCap.DISPLAY) VSync.apply("on") PresentProbe._testSetState({ osLinux = false, ready = true, clearGated = true, needsSoftwareCap = false, nest = "windows" }) T.check(PresentSync.probingDisplaySync(), "DISPLAY+vsync probes before a verdict") -T.check(PresentSync.needsSoftwareCap(), - "warmup still uses FrameCap as a thermal net during the probe") +T.check(not PresentSync.needsSoftwareCap(), + "probe isolation: FrameCap is not forced during calibration") +T.check(not PresentSync.displaySyncConfirmed(), "unconfirmed while still probing") T.eq(PresentSync.logicRefreshPeriod(), nil, "and does not snap logic during warmup") PresentProbe._testSetState({ gated = true, needsSoftwareCap = false }) T.check(not PresentSync.probingDisplaySync(), "a finished probe clears warmup") -T.check(not PresentSync.needsSoftwareCap(), "so software cap stops once sync is confirmed") +T.check(PresentSync.displaySyncConfirmed(), "gated verdict confirms display sync") +T.check(not PresentSync.needsSoftwareCap(), "so software cap stays off once sync is confirmed") -- Broken sync after an honest ungated probe keeps the thermal net and no snap. PresentProbe._testSetState({ gated = false, needsSoftwareCap = true }) T.check(PresentSync.needsSoftwareCap(), "ungated DISPLAY keeps FrameCap") +T.check(not PresentSync.displaySyncConfirmed(), "ungated is never confirmed") T.eq(PresentSync.logicRefreshPeriod(), nil, "and never snaps logic to the panel") VSync.apply("on") @@ -82,12 +90,21 @@ T.check(not PresentSync.vsyncStepAllowed("on", -1), FixedStep.refreshPeriod = 1 / 60 local steps = 0 FixedStep:init(function() steps = steps + 1 end) -FixedStep.maxAccum = 0.25 +FixedStep.maxAccum = FixedStep.catchupLimit(4) FixedStep:update(1 / 60, 4) T.eq(steps, 4, "4X on a snapped 60Hz frame runs four logic steps") steps = 0 +FixedStep.maxAccum = FixedStep.catchupLimit(2) FixedStep:update(1 / 60, 2) T.eq(steps, 2, "and 2X runs two") + +-- Catch-up debt hard ceiling: high speed cannot raise maxAccum past MAX_ACCUM. +T.eq(FixedStep.catchupLimit(1), FixedStep.STEP * 2, + "1X catch-up floor is two steps") +T.eq(FixedStep.catchupLimit(4), 4 * FixedStep.STEP * 1.5, + "4X catch-up matches one frame of target work") +T.eq(FixedStep.catchupLimit(200), FixedStep.MAX_ACCUM, + "200X catch-up is hard-capped at MAX_ACCUM (no input-starving spiral)") FixedStep.refreshPeriod = nil love.window.getVSync, love.window.setVSync = nil, nil