From 17a7089348ad36b5c654c1e3060bd889dbfb8f90 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:25:07 +0530 Subject: [PATCH 1/4] shim: answer the panel's latch grid to gamescope's frame-rate limit --- icd_shim/src/lib.rs | 34 +++++++++++++++ modules/gndrm-shim/gndrm_shim.c | 49 ++++++++++++++++++++++ tools/exports/libadrenotools_gamescope.map | 7 +++- tools/exports/libgndrm_shim.map | 1 + 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/icd_shim/src/lib.rs b/icd_shim/src/lib.rs index 2a57bee..ac2018d 100644 --- a/icd_shim/src/lib.rs +++ b/icd_shim/src/lib.rs @@ -678,6 +678,40 @@ pub fn proxy_active() -> bool { unsafe { DRIVER.proxy_active } } +/// The panel's latch grid as the daemon last reported it, for the process this +/// ICD is loaded into: `anchor_ns` a latch point (`CLOCK_MONOTONIC`), `period_ns` +/// the spacing of latch points, `lead_ns` how far ahead of one a commit must be +/// made to land on it. Returns 1 and fills the out-params when a grid has been +/// reported, 0 (out-params untouched) before the first note. gamescope reaches +/// it through `libgndrm_shim.so`'s `gamenative_drm_latch_grid`, which resolves +/// this symbol from the loaded ICD: the compositor's frame-rate limit places its +/// releases on this grid so each one's commit lands in its own latch window. +/// +/// # Safety +/// Each non-null pointer must be valid for a write of its type. +#[no_mangle] +pub unsafe extern "C" fn gamenative_drm_icd_latch_grid( + anchor_ns: *mut u64, + period_ns: *mut u32, + lead_ns: *mut u32, +) -> i32 { + let Some(grid) = crate::lsfg::latch_grid() else { + return 0; + }; + unsafe { + if !anchor_ns.is_null() { + *anchor_ns = grid.anchor_ns; + } + if !period_ns.is_null() { + *period_ns = u32::try_from(grid.period_ns).unwrap_or(u32::MAX); + } + if !lead_ns.is_null() { + *lead_ns = u32::try_from(grid.lead_ns).unwrap_or(u32::MAX); + } + } + 1 +} + /// # Safety: loader-ICD v5 interface (called by the Khronos loader). #[allow(non_snake_case)] #[no_mangle] diff --git a/modules/gndrm-shim/gndrm_shim.c b/modules/gndrm-shim/gndrm_shim.c index 5cc4361..17d86fa 100644 --- a/modules/gndrm-shim/gndrm_shim.c +++ b/modules/gndrm-shim/gndrm_shim.c @@ -1413,6 +1413,55 @@ uint32_t gamenative_drm_scanout_pitch(uint32_t w, uint32_t h) return stride; } +/* + * The panel's latch grid for gamescope's frame-rate limit: a latch point + * (CLOCK_MONOTONIC ns), the spacing of latch points, and how far ahead of one a + * commit has to be made to land on it. The daemon measures the grid from + * SurfaceFlinger's latch stats and pushes it to the Vulkan ICD shim loaded in + * this same process, which keeps the latest note; this LD_PRELOAD library is + * the one gamescope can bind a weak reference to, so it answers on the ICD's + * behalf. 1 with the out-params filled once a grid has been reported, 0 before + * that and when the ICD is not loaded (gamescope then keeps its free-running + * grid). The ICD is looked up by its loaded name, never loaded here. + */ +int gamenative_drm_latch_grid(uint64_t *anchor_ns, uint32_t *period_ns, uint32_t *lead_ns); +int gamenative_drm_latch_grid(uint64_t *anchor_ns, uint32_t *period_ns, uint32_t *lead_ns) +{ + typedef int (*latch_grid_fn)(uint64_t *, uint32_t *, uint32_t *); + static latch_grid_fn fn; + static int looked_up; + if (!looked_up) { + /* The ICD carries no DT_SONAME, so it is found by the path it was + * loaded from, read off this process's maps; RTLD_NOLOAD never loads. */ + char path[512] = ""; + FILE *maps = fopen("/proc/self/maps", "r"); + if (maps) { + char line[1024]; + while (fgets(line, sizeof line, maps)) { + char *p = strchr(line, '/'); + if (!p || !strstr(p, "/libadrenotools_gamescope.so")) + continue; + p[strcspn(p, "\n")] = 0; + snprintf(path, sizeof path, "%s", p); + break; + } + fclose(maps); + } + void *h = path[0] ? dlopen(path, RTLD_NOLOAD | RTLD_LAZY) : NULL; + if (!h) + h = dlopen("libadrenotools_gamescope.so", RTLD_NOLOAD | RTLD_LAZY); + if (!h) + return 0; + fn = (latch_grid_fn)dlsym(h, "gamenative_drm_icd_latch_grid"); + looked_up = 1; + if (!fn) + LOG("latch grid: the loaded ICD has no gamenative_drm_icd_latch_grid\n"); + } + if (!fn) + return 0; + return fn(anchor_ns, period_ns, lead_ns); +} + /* ------------------------------------------------------------------ */ /* scan-out allocator channel */ /* ------------------------------------------------------------------ */ diff --git a/tools/exports/libadrenotools_gamescope.map b/tools/exports/libadrenotools_gamescope.map index 671be74..4418a34 100644 --- a/tools/exports/libadrenotools_gamescope.map +++ b/tools/exports/libadrenotools_gamescope.map @@ -1,13 +1,16 @@ /* Export allowlist for libadrenotools_gamescope.so (the Vulkan ICD shim). * * The Khronos loader resolves exactly these three entry points by dlsym; a - * missing one means the loader cannot load the shim at all. Everything else — - * the interception/proxy internals — is local. Audited by + * missing one means the loader cannot load the shim at all. The latch-grid + * getter is read by libgndrm_shim.so on behalf of gamescope (see + * gamenative_drm_latch_grid there). Everything else — the interception/proxy + * internals — is local. Audited by * tools/check-exports.sh. */ { global: vk_icdGetInstanceProcAddr; vk_icdGetPhysicalDeviceProcAddr; vk_icdNegotiateLoaderICDInterfaceVersion; + gamenative_drm_icd_latch_grid; local: *; }; diff --git a/tools/exports/libgndrm_shim.map b/tools/exports/libgndrm_shim.map index 791bccc..8a888fe 100644 --- a/tools/exports/libgndrm_shim.map +++ b/tools/exports/libgndrm_shim.map @@ -9,6 +9,7 @@ global: gamenative_drm_connect; gamenative_drm_scanout_pitch; + gamenative_drm_latch_grid; open; openat; open64; From 54f65b3cbe704c7649d39b3f405a2329a5fa0479 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:37:37 +0530 Subject: [PATCH 2/4] shim: say where the latch grid was answered from --- modules/gndrm-shim/gndrm_shim.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modules/gndrm-shim/gndrm_shim.c b/modules/gndrm-shim/gndrm_shim.c index 17d86fa..55a7b74 100644 --- a/modules/gndrm-shim/gndrm_shim.c +++ b/modules/gndrm-shim/gndrm_shim.c @@ -1450,11 +1450,16 @@ int gamenative_drm_latch_grid(uint64_t *anchor_ns, uint32_t *period_ns, uint32_t void *h = path[0] ? dlopen(path, RTLD_NOLOAD | RTLD_LAZY) : NULL; if (!h) h = dlopen("libadrenotools_gamescope.so", RTLD_NOLOAD | RTLD_LAZY); - if (!h) + if (!h) { + /* Not loaded yet (the Vulkan loader opens it on the first instance): + * ask again next time. */ return 0; + } fn = (latch_grid_fn)dlsym(h, "gamenative_drm_icd_latch_grid"); looked_up = 1; - if (!fn) + if (fn) + LOG("latch grid: answered from %s\n", path[0] ? path : "the loaded ICD"); + else LOG("latch grid: the loaded ICD has no gamenative_drm_icd_latch_grid\n"); } if (!fn) From 323bb29b8a1ec7b9ed6387650a1031ee165043d8 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:58:11 +0530 Subject: [PATCH 3/4] shim: say how the latch-grid lookup went, and retry it no more than once a second --- modules/gndrm-shim/gndrm_shim.c | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/modules/gndrm-shim/gndrm_shim.c b/modules/gndrm-shim/gndrm_shim.c index 55a7b74..cc28616 100644 --- a/modules/gndrm-shim/gndrm_shim.c +++ b/modules/gndrm-shim/gndrm_shim.c @@ -1430,7 +1430,17 @@ int gamenative_drm_latch_grid(uint64_t *anchor_ns, uint32_t *period_ns, uint32_t typedef int (*latch_grid_fn)(uint64_t *, uint32_t *, uint32_t *); static latch_grid_fn fn; static int looked_up; + static uint64_t last_try_ns; if (!looked_up) { + /* The Vulkan loader opens the ICD on the first instance, so the lookup + * may run before it is there: ask again, at most once a second, since + * the caller asks on every vblank. */ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + uint64_t now_ns = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; + if (last_try_ns && now_ns - last_try_ns < 1000000000ull) + return 0; + last_try_ns = now_ns; /* The ICD carries no DT_SONAME, so it is found by the path it was * loaded from, read off this process's maps; RTLD_NOLOAD never loads. */ char path[512] = ""; @@ -1451,16 +1461,19 @@ int gamenative_drm_latch_grid(uint64_t *anchor_ns, uint32_t *period_ns, uint32_t if (!h) h = dlopen("libadrenotools_gamescope.so", RTLD_NOLOAD | RTLD_LAZY); if (!h) { - /* Not loaded yet (the Vulkan loader opens it on the first instance): - * ask again next time. */ + static int said; + if (!said++) + log_info("latch grid: ICD %s (%s)\n", + path[0] ? "mapped but dlopen(RTLD_NOLOAD) refused it" : "not mapped yet", + path[0] ? path : "-"); return 0; } fn = (latch_grid_fn)dlsym(h, "gamenative_drm_icd_latch_grid"); looked_up = 1; if (fn) - LOG("latch grid: answered from %s\n", path[0] ? path : "the loaded ICD"); + log_info("latch grid: answered from %s\n", path[0] ? path : "the loaded ICD"); else - LOG("latch grid: the loaded ICD has no gamenative_drm_icd_latch_grid\n"); + log_info("latch grid: the loaded ICD has no gamenative_drm_icd_latch_grid\n"); } if (!fn) return 0; From 55a1edf9b7142c97e8a475f73dd7e7fa7895719d Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 17:20:53 +0530 Subject: [PATCH 4/4] shim: hold the acquire cadence at the period, and on the panel's latch grid --- icd_shim/src/present.rs | 13 +++- icd_shim/src/proxy.rs | 2 +- icd_shim/src/scanout.rs | 144 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/icd_shim/src/present.rs b/icd_shim/src/present.rs index 3db8555..2782022 100644 --- a/icd_shim/src/present.rs +++ b/icd_shim/src/present.rs @@ -107,6 +107,12 @@ pub(crate) fn arm(id: u64, sc: usize) { /// A completed present, reported by the daemon with the panel's latch time. pub(crate) fn record(id: u64, latch_ns: u64) { + record_at(id, latch_ns, true); +} + +/// `from_latch`: the time is the panel's latch of this frame, which steers the +/// acquire gate; a release-time stand-in only answers the wait. +fn record_at(id: u64, latch_ns: u64, from_latch: bool) { if id == 0 { return; } @@ -114,6 +120,11 @@ pub(crate) fn record(id: u64, latch_ns: u64) { if let Some(i) = w.armed.iter().position(|&(armed, _)| armed == id) { w.armed.remove(i); } + if from_latch { + if let Some(&(_, commit_ns)) = w.committed.iter().find(|&&(c, _)| c == id) { + crate::scanout::note_latch_margin(latch_ns, commit_ns); + } + } w.committed.retain(|&(c, _)| c != id); if w.done.len() >= CAP { w.done.remove(0); @@ -151,7 +162,7 @@ pub(crate) fn complete_on_release(id: u64, release_ns: u64) { w.armed.iter().any(|&(armed, _)| armed == id) }; if armed { - record(id, release_ns); + record_at(id, release_ns, false); } } diff --git a/icd_shim/src/proxy.rs b/icd_shim/src/proxy.rs index 63251a1..5537ba6 100644 --- a/icd_shim/src/proxy.rs +++ b/icd_shim/src/proxy.rs @@ -2274,7 +2274,7 @@ unsafe fn gn_acquire_common( // not start before this one plus a period. let now_ns = monotonic_ns(); (*sc).next_acquire_ns = - crate::scanout::next_acquire_after((*sc).next_acquire_ns, now_ns, period); + crate::scanout::next_acquire_gated((*sc).next_acquire_ns, now_ns, period); } break; } diff --git a/icd_shim/src/scanout.rs b/icd_shim/src/scanout.rs index 7c63071..f9bffa5 100644 --- a/icd_shim/src/scanout.rs +++ b/icd_shim/src/scanout.rs @@ -23,7 +23,7 @@ use gndrm_uapi::wire::{ }; use std::ffi::c_void; use std::os::fd::RawFd; -use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::Mutex; /// Default budget for one exchange, matching the LD_PRELOAD shim's: a daemon @@ -126,15 +126,126 @@ 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) + } +} + +/// The acquire gate's lead on the panel's latch grid, ns: how far ahead of a +/// latch point the guest is let into its frame, so the frame's commit lands in +/// the middle of that point's window. Steered from the latch reports. +static GATE_LEAD_NS: AtomicU64 = AtomicU64::new(0); +static GATE_ON_GRID: AtomicBool = AtomicBool::new(false); +static GATE_ACQUIRES: AtomicU64 = AtomicU64::new(0); + +/// `v` reduced into `[-period/2, period/2)`. +fn wrap_ns(v: i64, period: i64) -> i64 { + let half = period / 2; + ((v + half).rem_euclid(period)) - half +} + +/// The next acquire deadline: `prev + period` while the guest keeps up (the +/// wait returns a hair after the deadline, and that hair must not become the +/// cadence), `now + period` once it fell a whole period behind (no debt). Then, +/// when the panel's latch grid is known and the period is a whole number of +/// latch periods, snapped onto that grid less the lead, so every frame's commit +/// lands on its own latch point: a cadence that is merely the right rate drifts +/// across the latch deadline, where two commits share one point and one is +/// dropped. +#[must_use] +pub(crate) fn next_acquire_gated(prev_ns: u64, now_ns: u64, period_ns: u64) -> u64 { + if period_ns == 0 { + return prev_ns; + } + let mut next = next_acquire_after(prev_ns, now_ns, period_ns); + let grid = if crate::lsfg::source_cap().is_some() { + None + } else { + crate::lsfg::latch_grid() + }; + let Some(grid) = grid else { + GATE_ON_GRID.store(false, Ordering::Relaxed); + return next; + }; + let latch = grid.period_ns; + if latch == 0 { + return next; + } + let mult = (period_ns + latch / 2) / latch; + if mult == 0 || (mult * latch).abs_diff(period_ns) * 100 > period_ns { + GATE_ON_GRID.store(false, Ordering::Relaxed); + return next; + } + let step = mult * latch; + let mut lead = GATE_LEAD_NS.load(Ordering::Relaxed); + if lead == 0 { + // Until the latch reports steer it: half a window for the commit's own + // margin plus a frame of rendering. + lead = (latch / 2 + 4_000_000).min(step - 1); + GATE_LEAD_NS.store(lead, Ordering::Relaxed); + } + let base = grid.anchor_ns as i64 - lead as i64; + let off = wrap_ns(next as i64 - base, step as i64); + next = (next as i64 - off).max(0) as u64; + if !GATE_ON_GRID.swap(true, Ordering::Relaxed) { + crate::logf!(gndrm_obf::obf!( + "scanout frame cap: on the panel latch grid period={}us x{} lead={}us", + latch / 1000, + mult, + lead / 1000 + )); + } + let n = GATE_ACQUIRES.fetch_add(1, Ordering::Relaxed) + 1; + if n.is_multiple_of(600) { + crate::logf!(gndrm_obf::obf!( + "scanout frame cap: latch grid lead={}us after {} frames", + GATE_LEAD_NS.load(Ordering::Relaxed) / 1000, + n + )); + } + next +} + +/// A frame the daemon latched at `latch_ns`, committed by this shim at +/// `commit_ns`: steer the gate's lead so commits land half a window plus the +/// compositor's hop ahead of their latch point. Only latches on the grid count: +/// a release-time stand-in is not one. +pub(crate) fn note_latch_margin(latch_ns: u64, commit_ns: u64) { + if !GATE_ON_GRID.load(Ordering::Relaxed) || commit_ns == 0 || latch_ns <= commit_ns { + return; + } + let Some(grid) = crate::lsfg::latch_grid() else { + return; + }; + let latch = grid.period_ns as i64; + if latch == 0 { + return; + } + let margin = (latch_ns - commit_ns) as i64; + if margin >= 2 * latch { + return; + } + if wrap_ns(latch_ns as i64 - grid.anchor_ns as i64, latch).abs() > latch / 8 { + return; + } + let target = latch / 2 + 2_000_000; + let err = margin - target; + let lead = GATE_LEAD_NS.load(Ordering::Relaxed) as i64; + let step = lead - err / 8; + GATE_LEAD_NS.store(step.clamp(0, latch * 64) as u64, Ordering::Relaxed); } /// Adopt one frame-cap note: the target it carries. @@ -916,6 +1027,29 @@ 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 gated_cadence_does_not_carry_the_wakeup_overshoot() { + // No grid in a host test: the drift fix alone. + let period = 16_666_666; + let first = next_acquire_gated(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_gated(first, first + 150_000, period); + assert_eq!(second, first + period); + // A whole period late: no debt. + let third = next_acquire_gated(second, second + period + 5_000_000, period); + assert_eq!(third, second + period + 5_000_000 + period); + } + + #[test] + fn wrap_is_centred() { + assert_eq!(wrap_ns(0, 100), 0); + assert_eq!(wrap_ns(49, 100), 49); + assert_eq!(wrap_ns(50, 100), -50); + assert_eq!(wrap_ns(-1, 100), -1); + assert_eq!(wrap_ns(151, 100), -49); + } + #[test] fn the_acquire_schedule_spaces_frame_starts() { let period = period_ns(40);