mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-01 07:40:29 -04:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user