Fix to vsync measuring and detection and fixes fixestep so it snaps wallclock dt before applying speed

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
This commit is contained in:
1jamie
2026-08-30 11:12:19 -05:00
parent 308605fd61
commit f82f565cae
9 changed files with 181 additions and 35 deletions
+3 -2
View File
@@ -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
+8 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+52 -19
View File
@@ -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
+7
View File
@@ -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")
+2 -1
View File
@@ -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 \
+86 -6
View File
@@ -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")
+20 -2
View File
@@ -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()