Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions docs/10-fps-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,21 +96,27 @@ 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.

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.
36 changes: 28 additions & 8 deletions icd_shim/src/scanout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down