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()