diff --git a/docs/10-fps-limit.md b/docs/10-fps-limit.md index aa57539..ff82e48 100644 --- a/docs/10-fps-limit.md +++ b/docs/10-fps-limit.md @@ -96,10 +96,15 @@ divisor cap reads its target exactly. re-issues it when the value or the panel's screen type changes (`injector.rs:717-722`); `set_frame_cap_target` (`lib.rs:322`) holds the target so the scan-out channel pushes a `FrameCapNote` to a subscribed shim. -- **The shim enforces it** (`icd_shim/src/scanout.rs:118-140`): the next acquire - may start no sooner than one `1/target` period after the previous one, so the - guest blocks in `vkAcquireNextImageKHR` — the one point where waiting costs - neither rendered work nor display latency. +- **The shim enforces it** (`icd_shim/src/scanout.rs:118-146`, + `next_acquire_after`): the next acquire may start no sooner than one + `1/target` period after the previous *deadline*, so the guest blocks in + `vkAcquireNextImageKHR` — the one point where waiting costs neither rendered + work nor display latency. The deadline, not the wake: the wait returns a hair + after it, and `max(prev, now) + period` made that hair part of every period + (a 60 cap read 59.78 on an Odin 3 menu, 60.00 once stepped from `prev`). + Only a guest a whole period behind is re-based on `now`, so no debt is + carried out of a stall. - **`-r` is not part of it**: the grid is the constant 1200 Hz, and no launch argument carries the limit. `--framerate-limit` is never built. @@ -107,10 +112,11 @@ Open points: - **Divisor rounding bites the gamescope route only.** `nVblankDivisor = nRefreshHz / nTargetFPS` (`steamcompmgr.cpp:7040`, and `:10621` for the window's limited cycle) lands on the nearest divisor of the grid; the shim's floor is - exact. + exact (measured: 60.00 commits/s at a 60 cap). - **The client-visible refresh is the mode's.** `-r` reaches `g_nOutputRefresh` only transiently (`drm_unset_mode`, `:3680`), before `drm_set_mode` (`:3728`) overwrites it with the mode's rate — `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` and `active_display_info` therefore carry the panel's rate, never the grid. - **Engagement feedback**: `gamescope_limiter.state` covers the gamescope route; - the shim logs `scanout frame cap: fps=… effective=…` (`scanout.rs:141-149`). + the shim logs `scanout frame cap: fps=… effective=…` (`scanout.rs:148-158`), + `effective` being the target in force once the note is adopted. diff --git a/icd_shim/src/scanout.rs b/icd_shim/src/scanout.rs index 7c63071..234f9f8 100644 --- a/icd_shim/src/scanout.rs +++ b/icd_shim/src/scanout.rs @@ -126,28 +126,35 @@ pub(crate) fn period_ns(target_fps: u32) -> u64 { /// /// The cap is applied here rather than to a release: a frame that may not start /// yet is the only point where waiting costs neither rendered work nor display -/// latency. `max(prev, now) + period` holds the interval at the target when the -/// guest is faster and lets it be its own rate when it is slower — never two -/// frames closer than one period, and no debt carried out of a stall. +/// latency. `prev + period` holds the interval at the target while the guest +/// keeps up: the wait for `prev` returns a hair late, and that hair must not +/// become the cadence (it did, as `max(prev, now) + period`: a 60 cap ran at +/// 59.4). Once the guest is a whole period behind it is its own rate again, +/// `now + period` — never two frames closer than one period, and no debt +/// carried out of a stall. #[must_use] pub(crate) fn next_acquire_after(prev_ns: u64, now_ns: u64, period_ns: u64) -> u64 { if period_ns == 0 { return prev_ns; } - prev_ns.max(now_ns).saturating_add(period_ns) + if prev_ns != 0 && now_ns < prev_ns.saturating_add(period_ns) { + prev_ns + period_ns + } else { + now_ns.saturating_add(period_ns) + } } /// Adopt one frame-cap note: the target it carries. fn apply_note(note: FrameCapNote) { - // 0 while frame generation paces the session: log what is actually in force, - // not what the note carried. + NOTES_N.fetch_add(1, Ordering::Relaxed); + CAP_FPS.store(note.fps, Ordering::Relaxed); + // 0 while frame generation paces the session: log what is actually in force + // now that the note is adopted, not what it carried. crate::logf!(gndrm_obf::obf!( "scanout frame cap: fps={} effective={}", note.fps, target_fps() )); - NOTES_N.fetch_add(1, Ordering::Relaxed); - CAP_FPS.store(note.fps, Ordering::Relaxed); } /// The target in force, [`FPS_UNCAPPED`] when none has: read by the shim's @@ -916,6 +923,19 @@ mod tests { /// The acquire schedule: one period after the previous start, never closer, /// and never later than the guest's own next frame when it is slower. + #[test] + fn the_cadence_does_not_carry_the_wakeup_overshoot() { + let period = 16_666_666; + let first = next_acquire_after(0, 1_000_000_000, period); + assert_eq!(first, 1_016_666_666); + // The wait returned 150 us late: the next deadline is still one period on. + let second = next_acquire_after(first, first + 150_000, period); + assert_eq!(second, first + period); + // A whole period late: no debt. + let third = next_acquire_after(second, second + period + 5_000_000, period); + assert_eq!(third, second + period + 5_000_000 + period); + } + #[test] fn the_acquire_schedule_spaces_frame_starts() { let period = period_ns(40);