From f82f565caeca4743d2a9257f0598467bf8beab32 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Sun, 30 Aug 2026 11:12:19 -0500 Subject: [PATCH] Fix to vsync measuring and detection and fixes fixestep so it snaps wallclock dt before applying speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the display sync stuff i added was probing whether vsync was actually working, but it was measuring the wrong thing. during the probe we software-cap at 60 for safety, and the probe was looking at the gap between frames... which includes the sleep 😅 ..... so it always looked like sync was fine even when the driver was ignoring it. on something like the ally x on windows thats a real problem. vsync says on, probe says gated, we lift the cap and snap logic to the panel hz, then youre basically uncapped. at 2–4x that turns into hitching, dropped frames, dropped input, that weird half second freeze. speed swapping wasnt desyncing the driver, it was just making the bad path hurt more. the solution is just we time present() itself now, not the gap after it, this way the warmup sleep cant fake a pass. if sync is unclear or broken we just stay on a capped 60 and dont snap logic. Also fixed fixedstep so it snaps wall clock dt before applying speed, so in general the 2-4x speed swaps dont screw the pacing math anymore --- main.lua | 5 +- src/core/FixedStep.lua | 10 ++- src/core/Game.lua | 2 +- src/core/Game2.lua | 4 +- src/core/PresentProbe.lua | 71 +++++++++++++++------ src/core/PresentSync.lua | 7 ++ tests/drivers/linux_present_probe.lua | 3 +- tests/engine/present_probe.lua | 92 +++++++++++++++++++++++++-- tests/engine/present_sync_logic.lua | 22 ++++++- 9 files changed, 181 insertions(+), 35 deletions(-) diff --git a/main.lua b/main.lua index fb6d8933..8b6721ba 100644 --- a/main.lua +++ b/main.lua @@ -1423,8 +1423,9 @@ 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 (Gamescope/XWayland no-op) - -- and no GLX wait bound: FrameCap is the thermal safety net. + -- 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. cap = FrameCap.DEFAULT end diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 4a40623e..69118956 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -62,7 +62,7 @@ end -- intended budget. FixedStep.maxAccum = MAX_ACCUM -function FixedStep:update(dt) +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 -- that one frame as a single step instead of the normal accumulator so @@ -74,6 +74,12 @@ function FixedStep:update(dt) self.callback(self.STEP) return end + -- Snap/smooth against the wall-clock frame dt first. Game speed is a + -- multiplier on how many logic steps that real frame buys — applying it + -- before refresh-period snap made 2–4X look like multi-period frames and + -- destabilized pacing under DISPLAY sync. + speed = tonumber(speed) or 1 + if speed < 1 then speed = 1 end local period = self.refreshPeriod local snapped = false if period and dt > 0 then @@ -104,7 +110,7 @@ function FixedStep:update(dt) local target = phaseOffset(period, self.STEP) self.accum = self.accum - self.accum % self.STEP + target end - self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) + self.accum = math.min(self.accum + dt * speed, self.maxAccum or MAX_ACCUM) while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP self.callback(self.STEP) diff --git a/src/core/Game.lua b/src/core/Game.lua index 9d8b54ee..5308e194 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -400,7 +400,7 @@ function Game:update(dt) 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:update(dt * speed) + FixedStep:update(dt, speed) local step = FixedStep.STEP self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25) while self.audioAccum >= step do diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 592c1c65..c678e661 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1269,12 +1269,12 @@ function Game2:update(dt) if Sound.setRate then Sound.setRate(speed) end if self.phase == "boot" then FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) - FixedStep:update(dt * 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:update(dt * speed) + FixedStep:update(dt, speed) end -- The screen-pixels-per-GB-pixel scale the post passes need so their grid and diff --git a/src/core/PresentProbe.lua b/src/core/PresentProbe.lua index ba98f50f..e3eb36ce 100644 --- a/src/core/PresentProbe.lua +++ b/src/core/PresentProbe.lua @@ -1,14 +1,21 @@ -- Present sync: on every platform, measure whether love.window.setVSync --- actually gates presents. Linux additionally binds a real wait (GLX OML / +-- 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. +-- never used. Ambiguous 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. local PROBE_FRAMES = 45 local INSTANT_MS = 0.0005 local BLOCK_MS = 0.002 +-- If a bound GLX wait ever blocks longer than this, drop it and fall back to +-- FrameCap. Covers a stalled compositor without wedging the game loop. +local WAIT_ABORT_S = 0.1 -- Hot-path: a single cached closure, no ffi.C / string work inside it. local waitFn = nil @@ -23,8 +30,8 @@ local state = { strategy = "none", -- "none" | "sdl" | "oml" | "sgi" needsSoftwareCap = false, probeCount = 0, - lastPresent = nil, - intervals = nil, + presentStart = nil, -- love.timer time entering present(), probe only + intervals = nil, -- present() block times while probing glxGen = 0, bindGen = -1, } @@ -102,6 +109,9 @@ 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. local function classifyGated(intervals) if not intervals or #intervals < 10 then return nil end local sorted = {} @@ -113,11 +123,14 @@ local function classifyGated(intervals) 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 presents are typically << panel period (often < 2ms for this game). + -- Ungated: present returns well before a panel period (often < 2ms). if mid < expect * 0.45 then return false end - if mid > expect * 0.7 and mid < expect * 1.6 then return true end - -- Weird ratio (e.g. half-rate): still treat as gated if slow enough. - return mid >= (1 / 200) + -- 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. + return false end local function loadGlx() @@ -401,7 +414,7 @@ function PresentProbe.reset() state.strategy = "none" state.needsSoftwareCap = false state.probeCount = 0 - state.lastPresent = nil + state.presentStart = nil state.intervals = nil state.glxGen = state.glxGen + 1 state.bindGen = -1 @@ -417,7 +430,7 @@ function PresentProbe.onDisplayChange() state.gated = nil state.probeCount = 0 state.intervals = {} - state.lastPresent = nil + state.presentStart = nil state.strategy = "none" state.needsSoftwareCap = false glLib = nil @@ -430,7 +443,7 @@ function PresentProbe.reprobe() state.gated = nil state.probeCount = 0 state.intervals = {} - state.lastPresent = nil + state.presentStart = nil waitFn = nil state.glxGen = state.glxGen + 1 state.bindGen = -1 @@ -438,10 +451,30 @@ function PresentProbe.reprobe() pickStrategy() end +local function abandonWait() + waitFn = nil + state.strategy = "none" + state.needsSoftwareCap = true + state.gated = false + state.bindGen = state.glxGen +end + -- Hot path: must stay allocation-free and avoid ffi.C / require. +-- Records presentStart AFTER any GLX wait so the probe measures SDL/GL +-- swap-interval block time alone (FrameCap sleep lives after notePresent). function PresentProbe.waitBeforePresent() local fn = waitFn - if fn then fn() end + if fn then + local t0 = love.timer and love.timer.getTime and love.timer.getTime() + local ok = pcall(fn) + local t1 = love.timer and love.timer.getTime and love.timer.getTime() + if not ok or (t0 and t1 and (t1 - t0) > WAIT_ABORT_S) then + abandonWait() + end + end + if state.gated == nil and love.timer and love.timer.getTime then + state.presentStart = love.timer.getTime() + end end function PresentProbe.notePresent() @@ -457,19 +490,19 @@ function PresentProbe.notePresent() return end + local start = state.presentStart + state.presentStart = nil local now = love.timer and love.timer.getTime and love.timer.getTime() - if not now then return end - local last = state.lastPresent - state.lastPresent = now - if not last then return end - local dt = now - last - if dt <= 0 or dt > 0.25 then return end + if not start or not now then return end + local block = now - start + -- Discard hitches / timer glitches; keep sampling until we have clean ones. + if block <= 0 or block > 0.25 then return end local intervals = state.intervals if not intervals then intervals = {} state.intervals = intervals end - intervals[#intervals + 1] = dt + intervals[#intervals + 1] = block state.probeCount = #intervals if #intervals < PROBE_FRAMES then return end diff --git a/src/core/PresentSync.lua b/src/core/PresentSync.lua index c83b4650..4c594ea5 100644 --- a/src/core/PresentSync.lua +++ b/src/core/PresentSync.lua @@ -1,6 +1,10 @@ -- 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). +-- +-- 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. local PresentSync = {} @@ -25,6 +29,8 @@ 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). function PresentSync.needsSoftwareCap() if probe().needsSoftwareCap() then return true end return PresentSync.probingDisplaySync() @@ -63,6 +69,7 @@ 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). function PresentSync.logicRefreshPeriod() local FrameCap = require("src.core.FrameCap") local VSync = require("src.core.VSync") diff --git a/tests/drivers/linux_present_probe.lua b/tests/drivers/linux_present_probe.lua index 9fea0cf6..beafd468 100644 --- a/tests/drivers/linux_present_probe.lua +++ b/tests/drivers/linux_present_probe.lua @@ -1,5 +1,6 @@ -- Live PresentProbe driver. Boots far enough to present, waits for the --- gated/ungated classification, then prints the result and quits. +-- gated/ungated classification (present() block time, not inter-frame gaps), +-- then prints the result and quits. -- -- cd /home/autumn/src/gen1recomp-gaia -- POKEPORT_DRIVER=tests/drivers/linux_present_probe.lua POKEPORT_TOUCH=0 \ diff --git a/tests/engine/present_probe.lua b/tests/engine/present_probe.lua index 7acb0da4..84fe92f3 100644 --- a/tests/engine/present_probe.lua +++ b/tests/engine/present_probe.lua @@ -11,13 +11,43 @@ local fast = {} for i = 1, 20 do fast[i] = 0.001 end T.eq(LPS._testClassifyGated(fast), false, "sub-ms presents count as ungated") -local locked60 = {} -for i = 1, 20 do locked60[i] = 1 / 60 end -T.eq(LPS._testClassifyGated(locked60), true, "1/60 presents count as gated") +local function withHz(hz, fn) + package.loaded["src.core.RefreshRate"] = { + hz = function() return hz end, + period = function() return 1 / hz end, + mismatch = function() return nil end, + sample = function() return false end, + reset = function() end, + } + local ok, err = pcall(fn) + package.loaded["src.core.RefreshRate"] = nil + if not ok then error(err) end +end -local locked90 = {} -for i = 1, 20 do locked90[i] = 1 / 90 end -T.eq(LPS._testClassifyGated(locked90), true, "1/90 presents count as gated") +withHz(60, function() + local locked60 = {} + for i = 1, 20 do locked60[i] = 1 / 60 end + T.eq(LPS._testClassifyGated(locked60), true, "1/60 presents count as gated") + local half60 = {} + for i = 1, 20 do half60[i] = 2 / 60 end + T.eq(LPS._testClassifyGated(half60), true, "half-rate 30Hz on 60Hz counts as gated") + local ambiguous = {} + for i = 1, 20 do ambiguous[i] = 0.010 end + T.eq(LPS._testClassifyGated(ambiguous), false, + "10ms CPU-bound presents are ungated (not evidence of sync)") +end) + +withHz(90, function() + local locked90 = {} + for i = 1, 20 do locked90[i] = 1 / 90 end + T.eq(LPS._testClassifyGated(locked90), true, "1/90 presents count as gated") +end) + +-- Unknown panel Hz: a 90Hz-looking block is NOT trusted (safe FrameCap fallback) +local locked90unknown = {} +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") T.eq(LPS._testClassifyGated({ 0.016, 0.017 }), nil, "too few samples stay unclassified") @@ -181,10 +211,60 @@ LPS.reset() LPS.waitBeforePresent() T.eq(true, true, "waitBeforePresent tolerates a cold module") +-- Probe measures present() block time, not inter-frame gaps: FrameCap sleep +-- after notePresent must not make a broken swap look gated. +LPS.reset() +LPS._testSetState({ osLinux = false, ready = true, nest = "windows", clearGated = true }) +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") + +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") + +-- A bound wait that overruns abandons to FrameCap instead of wedging. +LPS.reset() +LPS._testSetState({ + osLinux = true, ready = true, nest = "x11", gated = true, + strategy = "oml", needsSoftwareCap = false, + waitFn = function() + t = t + 0.2 -- > WAIT_ABORT_S + end, +}) +t = 0 +LPS.waitBeforePresent() +T.eq(LPS.needsSoftwareCap(), true, "an overrunning GLX wait falls back to FrameCap") +T.eq(LPS.status().strategy, "none", "and clears the wait strategy") + +love.timer.getTime = savedGetTime + -- status() on non-Linux stays inert (whatever OS the harness reports) LPS.reset() local status = LPS.status() T.eq(type(status.strategy), "string", "status always reports a strategy string") T.eq(type(status.linux), "boolean", "and whether this process is Linux") +LPS.reset() T.finish("present probe") diff --git a/tests/engine/present_sync_logic.lua b/tests/engine/present_sync_logic.lua index 5665161a..65ac03e0 100644 --- a/tests/engine/present_sync_logic.lua +++ b/tests/engine/present_sync_logic.lua @@ -58,20 +58,38 @@ 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 uses FrameCap until the probe finishes") +T.check(PresentSync.needsSoftwareCap(), + "warmup still uses FrameCap as a thermal net during the probe") 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") +-- 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.eq(PresentSync.logicRefreshPeriod(), nil, "and never snaps logic to the panel") + VSync.apply("on") -PresentProbe._testSetState({ needsSoftwareCap = true }) +PresentProbe._testSetState({ needsSoftwareCap = true, gated = false }) T.check(PresentSync.vsyncEnableBlocked(), "broken sync blocks enabling vsync") T.check(PresentSync.vsyncStepAllowed("on", 1), "but one step to OFF is allowed") T.check(not PresentSync.vsyncStepAllowed("on", -1), "while a step that stays on/adaptive is not") +-- FixedStep snaps wall-clock dt, then applies speed (not the reverse). +FixedStep.refreshPeriod = 1 / 60 +local steps = 0 +FixedStep:init(function() steps = steps + 1 end) +FixedStep.maxAccum = 0.25 +FixedStep:update(1 / 60, 4) +T.eq(steps, 4, "4X on a snapped 60Hz frame runs four logic steps") +steps = 0 +FixedStep:update(1 / 60, 2) +T.eq(steps, 2, "and 2X runs two") +FixedStep.refreshPeriod = nil + love.window.getVSync, love.window.setVSync = nil, nil VSync.reset() PresentSync.reset()