From b2f7267b479e739a29003006c10176785d9bc2b6 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:36:16 +0800 Subject: [PATCH 1/4] android: the mode is the session's scan-out size, not the window's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emulated connector advertised exactly one mode: the app window's size. A container's Upscaling Resolution (= gamescope's -W/-H) therefore never matched a mode, `find_mode` fell through to the connector's first mode, and `drm_set_mode` overwrote `g_nOutputWidth/Height` with it. The setting was inert below the panel: the composite, the scan-out images, the pool's buffers and the published layer were all the panel's pixel count (2.25x the area at 720p on a 1080p panel), and gamescope upscaled the guest into that rather than letting the display's compositor upscale a smaller scan-out. The app now names the size before `nativeStart`, and that size is the mode. The presenter takes the window's box separately and fits the committed plane rect into it, so a mode below the window is upscaled once, by SurfaceFlinger, and a mode of another aspect is letterboxed rather than stretched; mode == window is the identity and presents exactly as before. modules/gndrm-android/src/lib.rs `nativeSetScanoutGeometry(w, h)` (JNI) stores the request; `0, 0` clears it, because the value is process-lifetime and a session whose resolution cannot be read must not inherit the previous one. `scanout_geometry` resolves the mode at `nativeStart`: the requested size when it fits the window, else the window's — a larger mode would composite pixels the display cannot show and have them scaled back down, so it is refused with a log rather than clamped. `start` keeps window and mode apart, passes the window size to the server thread, and says both in the start line. `retarget`/`set_window`: a window change adopts the new box (rebuilding the layer when the window itself changed) and never re-modes what gamescope is compositing at. modules/gndrm-android/src/asc.rs The presenter carries `mode_w/mode_h` (pool geometry, `note_framebuffer`, the layer-rect/HIDDEN/shown/republish/teardown lines) and `win_w/win_h` (the compositing box). `publish` maps the CRTC rect through `gndrm_daemon::scanout::layer_rect` before the transaction; the diagnostics print mode and window and the band is measured against the window. modules/gndrm-daemon/src/scanout.rs `layer_rect(crtc, mode, window)`: KMS plane rect in mode pixels → window pixels, clipped to the mode, uniformly scaled and centred. Host-tested in four cases: identity for a mode that fills the window (including a plane letterboxed inside the mode), a 1.5x upscale covering the window, a pillarboxed 4:3 mode, and a plane rect clipped to the mode. modules/gndrm-daemon/tests/golden.rs `connector_advertises_the_session_scanout_size`: GETCONNECTOR and GETCRTC carry the size the device was built with, which is what lets gamescope's -W/-H find a mode instead of falling back to the connector's. tools/exports/libgamenative_drm.map The new JNI entry, so the cdylib's dynamic table stays exactly the allowlist. docs/02-gamenative-drm.md The connector's one mode is the session's scan-out size; the JNI surface lists `nativeSetScanoutGeometry` beside `nativeSetPanelRefreshHz`. docs/03-gamescope-build.md The launch line carries both pairs: -W/-H the scan-out size (the daemon's mode), -w/-h the container screen size (the guest desktop). docs/04-gamenative-app.md Same for the command and env section, and the present path: the CRTC rect is in mode pixels and is fitted into the window, which is where the Upscaling Resolution's upscale happens. --- docs/02-gamenative-drm.md | 5 +- docs/03-gamescope-build.md | 6 +- docs/04-gamenative-app.md | 20 +++- modules/gndrm-android/src/asc.rs | 171 +++++++++++++++++---------- modules/gndrm-android/src/lib.rs | 127 ++++++++++++++++++-- modules/gndrm-daemon/src/scanout.rs | 100 ++++++++++++++++ modules/gndrm-daemon/tests/golden.rs | 55 +++++++++ tools/exports/libgamenative_drm.map | 1 + 8 files changed, 405 insertions(+), 80 deletions(-) diff --git a/docs/02-gamenative-drm.md b/docs/02-gamenative-drm.md index 3b20416..4b5c1e6 100644 --- a/docs/02-gamenative-drm.md +++ b/docs/02-gamenative-drm.md @@ -79,7 +79,7 @@ EINVAL (probing clients treat that as unsupported). | ioctl | notes | |---|---| -| `DRM_IOCTL_MODE_GETCONNECTOR` | 1 connector, `DRM_MODE_CONNECTED`, type `DRM_MODE_CONNECTOR_DSI`; modes: panel mode from the SurfaceView size; two-call count/array semantics; `count_props` exposes the connector props | +| `DRM_IOCTL_MODE_GETCONNECTOR` | 1 connector, `DRM_MODE_CONNECTED`, type `DRM_MODE_CONNECTOR_DSI`; one mode: the session's scan-out size (`nativeSetScanoutGeometry`, the app's Upscaling Resolution), or the window's size when the app named none or the size does not fit the window; two-call count/array semantics; `count_props` exposes the connector props | | `DRM_IOCTL_MODE_GETENCODER` | 1 encoder, `possible_crtcs=0x1`, `possible_clones=0` | | `DRM_IOCTL_MODE_GETCRTC` | current mode = panel mode | | `DRM_IOCTL_MODE_SETCRTC` | never called on the atomic path; EINVAL (gamescope is atomic-only) | @@ -299,6 +299,9 @@ in the app's `jniLibs/arm64-v8a/` and is loaded via - `nativeStart(socketName: String, surface: Surface, width: Int, height: Int): Int` - `nativeStop()`, `nativeRetargetSurface(surface, w, h)` +- session inputs, before `nativeStart`: `nativeSetPanelRefreshHz(hz)` (the mode's + rate) and `nativeSetScanoutGeometry(w, h)` (the mode's size — the app's + Upscaling Resolution; `0, 0` clears it). - injector calls: `nativePointerAbsolute(x, y)`, `nativePointerMotion(dx, dy)`, `nativePointerButton(btn, pressed)`, `nativePointerAxis(axis, value)`, `nativeKey(evdevCode, pressed)`. diff --git a/docs/03-gamescope-build.md b/docs/03-gamescope-build.md index dd19883..8700be1 100644 --- a/docs/03-gamescope-build.md +++ b/docs/03-gamescope-build.md @@ -316,7 +316,9 @@ WLR_XWAYLAND=/bin/Xwayland # guest so winex11 dials XWayland. ``` -Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W -H -- /bin/wine explorer /desktop=gamescope, ` -(width/height = *output* res = Surface size; game res stays wine's window size). +Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W -H -w -h -- /bin/wine explorer /desktop=gamescope, ` +(`-W/-H` = the session's scan-out size, i.e. the container's Upscaling Resolution, +which the daemon emulates its connector mode at; `-w/-h` = the container's screen +size, the guest desktop. Game res stays wine's window size.) `--backend drm` explicit: auto-select would also land on DRM when no parent wayland/X11 exists, but never rely on detection. diff --git a/docs/04-gamenative-app.md b/docs/04-gamenative-app.md index abd62dd..4866ad7 100644 --- a/docs/04-gamenative-app.md +++ b/docs/04-gamenative-app.md @@ -78,11 +78,19 @@ because those run without the gamescope lib env): ``` filesDir/gamescope/bin/gamescope --backend drm --expose-wayland --xwayland-count 1 \ - -W -H -- /bin/wine explorer /desktop=gamescope, + -W -H -w -h -- /bin/wine explorer /desktop=gamescope, ``` -- `-W/-H` = **output** res = the Surface/mode size (03 §7); game res stays wine's - window size. +- `-W/-H` = **the session's scan-out size**: the container's Upscaling Resolution + (Graphics tab; `GamescopeSettings.outputResolution`), passed to the daemon as + `nativeSetScanoutGeometry` so the emulated connector advertises exactly that + mode. The composite, the scan-out pool and the KMS output are that size, and + the presenter fits them into the app's window, so a resolution below the panel + is upscaled by the display's compositor, not by gamescope's composite. A size + that does not fit the window is refused and the window's size is the mode — + gamescope's `-W/-H` then matches no mode and falls back to it. +- `-w/-h` = the container's screen size, i.e. the guest desktop; the game res + stays wine's window size. - `--expose-wayland`: gamescope serves xdg-shell clients and sets `WAYLAND_DISPLAY=gamescope-0` + `XDG_SESSION_TYPE=wayland` for its children itself. @@ -300,7 +308,11 @@ anyway). 4. `surfaceDestroyed` / screen exit → `nativeStop()` before the guest is killed. - The daemon presents per KMS semantics: black outside the plane's CRTC rect, the - FB's SRC rect (16.16 fixed point) scaled into it (02 §7). + FB's SRC rect (16.16 fixed point) scaled into it (02 §7). The CRTC rect is in + the session's mode pixels, so it is fitted into the window before the + transaction: a mode below the window's is scaled up uniformly and centred there + (another aspect is letterboxed, never stretched), which is where the Upscaling + Resolution's upscale happens. - Logging: `GN_DRM` logtag (native side; Kotlin uses Timber), plus a build-stamp line at daemon start — the only stale-.so detector on device (no root/run-as). diff --git a/modules/gndrm-android/src/asc.rs b/modules/gndrm-android/src/asc.rs index 03adc10..a19cf5d 100644 --- a/modules/gndrm-android/src/asc.rs +++ b/modules/gndrm-android/src/asc.rs @@ -806,9 +806,9 @@ impl Drop for ForeignSlot { } /// The layer-rect probe's change key: the published source and destination -/// rects plus the panel the destination is bounded by. A plain tuple, so the -/// probe has a `Copy + PartialEq` value to compare against the last one and -/// prints on a change instead of every frame. +/// rects plus the mode and window the destination is bounded by. A plain tuple, +/// so the probe has a `Copy + PartialEq` value to compare against the last one +/// and prints on a change instead of every frame. type RectLogKey = ( c_int, c_int, @@ -820,6 +820,8 @@ type RectLogKey = ( c_int, c_int, c_int, + c_int, + c_int, ); /// The last buffer actually put on the layer, with the geometry it went out @@ -848,8 +850,15 @@ pub struct AscState { /// [`AscState::publish`] for why that matters and why the SurfaceFlinger /// completion callback does not depend on it. looper: Option, - panel_w: c_int, - panel_h: c_int, + /// The session's mode: the scan-out size the connector advertises and the + /// pool's buffers are allocated at. + mode_w: c_int, + mode_h: c_int, + /// The app's window — the display's own pixels — which the layer is + /// composited in. Equal to the mode unless the app asked for a smaller + /// scan-out size, in which case the window's compositor does the upscale. + win_w: c_int, + win_h: c_int, /// The scan-out buffer pool, on its own lock (see [`ScanoutPool`]). The /// lock is tracked (see `gndrm_daemon::diag`): a request that stalls on it /// must be able to name the thread holding it. @@ -896,11 +905,18 @@ pub struct AscState { unsafe impl Send for AscState {} impl AscState { - /// Create the child layer under `window`. Never fails: a missing publish - /// surface or a null layer leaves the presenter disabled, which drops - /// frames instead of failing the session. + /// Create the child layer under `window`, presenting the session's `mode_w`x`mode_h` + /// scan-out into the `win_w`x`win_h` window. Never fails: a missing publish + /// surface or a null layer leaves the presenter disabled, which drops frames + /// instead of failing the session. #[must_use] - pub fn new(window: *mut ffi::ANativeWindow, panel_w: i32, panel_h: i32) -> Self { + pub fn new( + window: *mut ffi::ANativeWindow, + mode_w: i32, + mode_h: i32, + win_w: i32, + win_h: i32, + ) -> Self { let loaded = ffi::ScApi::load(); // SAFETY: `window` is a live ANativeWindow owned by the caller for the // whole session; createFromWindow only reads it. @@ -925,7 +941,9 @@ impl AscState { } else { log_info!( "{}", - gndrm_obf::obf!("asc: SurfaceControl layer created ({panel_w}x{panel_h} panel)") + gndrm_obf::obf!( + "asc: SurfaceControl layer created (mode {mode_w}x{mode_h} in a {win_w}x{win_h} window)" + ) ); } // The `ALooper` is deliberately *not* prepared here. This function runs @@ -939,8 +957,10 @@ impl AscState { api, sc, looper: None, - panel_w, - panel_h, + mode_w, + mode_h, + win_w, + win_h, pool: Arc::new(TrackedMutex::new( diag::diag(), LOCK_SCANOUT_POOL, @@ -967,14 +987,14 @@ impl AscState { // allocator pool, which is empty at this point, so there is normally // nothing to put up yet — re-publishing is a no-op here and only does // work on a path that re-creates the layer over an existing table. - // The panel mode is a geometry the daemon can serve from a bare length, - // so a mode-sized scan-out allocation resolves by length even before a + // The mode is a geometry the daemon can serve from a bare length, so a + // mode-sized scan-out allocation resolves by length even before a // framebuffer of that size is committed. - if panel_w > 0 && panel_h > 0 { + if mode_w > 0 && mode_h > 0 { state.pool.lock(Role::Commit).note_framebuffer( - panel_w as u32, - panel_h as u32, - panel_w as u64 * panel_h as u64 * 4, + mode_w as u32, + mode_h as u32, + mode_w as u64 * mode_h as u64 * 4, ); } state.republish_last(); @@ -1047,24 +1067,32 @@ impl AscState { return; }; - // KMS geometry: the FB's SRC rect (16.16 fixed point) is scaled into - // the CRTC rect, and the output is black outside it. The layer is - // sized to the CRTC rect only, so the app's window shows through - // around it, and the layer's background covers transparent regions - // inside it (gamescope letterboxes with transparent bars). + // The FB's SRC rect (16.16 fixed point) is the crop inside the buffer, + // which the mode's coordinates then scale into the window. let src = ffi::ARect { left: (plane.src_x >> 16) as c_int, top: (plane.src_y >> 16) as c_int, right: ((plane.src_x + plane.src_w) >> 16) as c_int, bottom: ((plane.src_y + plane.src_h) >> 16) as c_int, }; - // KMS clips the plane to the mode: the destination rect is the CRTC - // rect bounded by the panel. + // KMS clips the plane to the mode, and the layer is composited in the + // window: the CRTC rect (mode pixels) is fitted into the window, so a + // scan-out size below the display's is upscaled by the display's own + // compositor rather than by gamescope's composite, which stops at the + // mode. The output is black outside the rect, and the layer is sized to + // it only, so whatever lies outside is the app's window showing through; + // the layer's background covers transparent regions inside it + // (gamescope letterboxes with transparent bars). + let (dst_left, dst_top, dst_right, dst_bottom) = gndrm_daemon::scanout::layer_rect( + (plane.crtc_x, plane.crtc_y, plane.crtc_w, plane.crtc_h), + (self.mode_w as u32, self.mode_h as u32), + (self.win_w as u32, self.win_h as u32), + ); let dst = ffi::ARect { - left: plane.crtc_x.max(0), - top: plane.crtc_y.max(0), - right: (plane.crtc_x + plane.crtc_w).min(self.panel_w), - bottom: (plane.crtc_y + plane.crtc_h).min(self.panel_h), + left: dst_left, + top: dst_top, + right: dst_right, + bottom: dst_bottom, }; let drawable = src.right > src.left && src.bottom > src.top @@ -1073,11 +1101,12 @@ impl AscState { let keep = self.last_publish.map(|lp| lp.ino); // UNGATED, change-latched: the rect this commit publishes the layer - // with, against the panel it is bounded by. The layer is a child of the - // app's window and is sized to the CRTC rect only, so whatever lies - // inside the panel but outside that rect is the app's own window + // with, against the window it is composited in. The layer is a child of + // the app's window and is sized to the CRTC rect only, so whatever lies + // inside the window but outside that rect is the app's own window // showing through — the bands this probe exists to measure. Prints on a - // rect or panel change and once at session start, never per frame. + // rect, mode or window change and once at session start, never per + // frame. let key: RectLogKey = ( src.left, src.top, @@ -1087,14 +1116,16 @@ impl AscState { dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h, + self.mode_w, + self.mode_h, + self.win_w, + self.win_h, ); if self.last_rect_log != Some(key) { self.last_rect_log = Some(key); asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer rect src=({},{})-({},{}) dst=({},{})-({},{}) panel={}x{} band left={} top={} right={} bottom={} drawable={}",key.0, + gndrm_obf::obf!("gndrm-daemon: asc layer rect src=({},{})-({},{}) dst=({},{})-({},{}) mode={}x{} window={}x{} band left={} top={} right={} bottom={} drawable={}",key.0, key.1, key.2, key.3, @@ -1104,10 +1135,12 @@ impl AscState { key.7, key.8, key.9, + key.10, + key.11, dst.left, dst.top, - self.panel_w - dst.right, - self.panel_h - dst.bottom, + self.win_w - dst.right, + self.win_h - dst.bottom, drawable,) ); } @@ -1374,13 +1407,15 @@ impl AscState { self.hidden = false; asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer shown again fb={} dst=({},{})-({},{}) panel={}x{}",fb.id, + gndrm_obf::obf!("gndrm-daemon: asc layer shown again fb={} dst=({},{})-({},{}) mode={}x{} window={}x{}",fb.id, dst.left, dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); } match build_show(&api, self.sc, ahb, ctx, rect, fence) { @@ -1425,7 +1460,7 @@ impl AscState { self.hidden = true; asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer HIDDEN (plane not drawable) fb={} src=({},{})-({},{}) dst=({},{})-({},{}) panel={}x{}",fb.id, + gndrm_obf::obf!("gndrm-daemon: asc layer HIDDEN (plane not drawable) fb={} src=({},{})-({},{}) dst=({},{})-({},{}) mode={}x{} window={}x{}",fb.id, src.left, src.top, src.right, @@ -1434,8 +1469,10 @@ impl AscState { dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); } if let Some(tx) = build_hide(&api, self.sc) { @@ -1724,10 +1761,12 @@ impl AscState { asc_log!( "{}", gndrm_obf::obf!( - "gndrm-daemon: asc re-published retained ino {} onto a {}x{} layer (frames={})", + "gndrm-daemon: asc re-published retained ino {} onto a mode {}x{} in a {}x{} window (frames={})", lp.ino, - self.panel_w, - self.panel_h, + self.mode_w, + self.mode_h, + self.win_w, + self.win_h, self.frames ) ); @@ -1739,14 +1778,14 @@ impl AscState { /// retained last frame put back on it, so a display power cycle never /// leaves the panel with a bufferless layer. /// + /// The session's mode does not move: it is what gamescope is compositing at + /// and what the connector advertises, and neither can be re-moded from here. + /// A new window of another size is therefore reached by the same fit the + /// first window used. + /// /// Returns `false` when publishing is disabled or the new layer cannot be /// created, in which case the caller keeps the current window and layer. - pub fn retarget( - &mut self, - window: *mut ffi::ANativeWindow, - panel_w: i32, - panel_h: i32, - ) -> bool { + pub fn retarget(&mut self, window: *mut ffi::ANativeWindow, win_w: i32, win_h: i32) -> bool { let Some(api) = self.api else { return false; }; @@ -1770,8 +1809,8 @@ impl AscState { // layer handle, so no completion callback can reach the old layer this // call is about to drop. let old_sc = core::mem::replace(&mut self.sc, new_sc); - self.panel_w = panel_w; - self.panel_h = panel_h; + self.win_w = win_w; + self.win_h = win_h; // SAFETY: the old layer was created by this API, is no longer // referenced by the presenter or any mailbox, and is released exactly // once here. The window it was created from is released by the caller @@ -1781,7 +1820,7 @@ impl AscState { } log_info!( "{}", - gndrm_obf::obf!("asc: retargeted to the new window ({panel_w}x{panel_h})") + gndrm_obf::obf!("asc: retargeted to the new window ({win_w}x{win_h})") ); // A layer with no buffer set composites nothing (the app's background // shows through as a blank panel), so the retained frame goes straight @@ -1790,12 +1829,12 @@ impl AscState { true } - /// Adopt a panel geometry reported by a new window without rebuilding the - /// layer (a resize on the same window: the geometry only bounds the - /// published rect). - pub fn set_panel(&mut self, panel_w: i32, panel_h: i32) { - self.panel_w = panel_w; - self.panel_h = panel_h; + /// Adopt a window geometry reported by the same window without rebuilding the + /// layer (a resize on the same window: the geometry only bounds the published + /// rect). + pub fn set_window(&mut self, win_w: i32, win_h: i32) { + self.win_w = win_w; + self.win_h = win_h; } } @@ -1808,15 +1847,17 @@ impl Drop for AscState { // and this is the last line the presenter can print. asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc teardown frames_published={} hidden={} last_publish_ino={} last_dst=({},{})-({},{}) panel={}x{}",self.frames, + gndrm_obf::obf!("gndrm-daemon: asc teardown frames_published={} hidden={} last_publish_ino={} last_dst=({},{})-({},{}) mode={}x{} window={}x{}",self.frames, self.hidden, self.last_publish.map_or(0, |lp| lp.ino), self.last_publish.map_or(0, |lp| lp.dst.left), self.last_publish.map_or(0, |lp| lp.dst.top), self.last_publish.map_or(0, |lp| lp.dst.right), self.last_publish.map_or(0, |lp| lp.dst.bottom), - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); // Return every buffer before the layer goes: the presenter owns the // daemon's reference on each, and SurfaceFlinger may still be reading diff --git a/modules/gndrm-android/src/lib.rs b/modules/gndrm-android/src/lib.rs index b1f7934..d81c40a 100644 --- a/modules/gndrm-android/src/lib.rs +++ b/modules/gndrm-android/src/lib.rs @@ -26,6 +26,13 @@ //! mode's rate — the refresh advertised to clients — while the grid stays the //! daemon's constant. //! +//! The mode's *size* is the other session-level input. `nativeStart` takes it +//! from the window unless the app named one through `nativeSetScanoutGeometry` +//! first, and a size below the window's is what keeps a session off the panel's +//! pixel count: gamescope composites at the mode, the presenter scales the layer +//! up into the window, and the display's compositor does the upscale. A size +//! that does not fit the window is refused and the window's is the mode. +//! //! On non-Android targets this crate is an empty stub so host-side //! workspace gates (fmt/clippy/test) stay green; the real gates for this //! module are `tools/build-android.sh` and on-device runs. @@ -187,6 +194,48 @@ pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetLsfgCo /// still gets it for every later session. static PANEL_REFRESH_MHZ: AtomicU32 = AtomicU32::new(0); +/// The scan-out size the app asked the session to present at, supplied through +/// [`Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry`] before +/// the session starts; `None` until then, and then the window's size is the mode. +/// +/// It is the emulated connector's mode — what gamescope composites at, and what +/// the scan-out pool allocates — not the window's size. Below the window's it is +/// what keeps a session off the panel's pixel count: gamescope composites at this +/// size and the presenter scales the layer up into the window, so the display's +/// own compositor does the upscale. A size that does not fit the window is +/// refused at `nativeStart`, whose window is then the mode (see +/// [`scanout_geometry`]). +/// +/// Session-level and read once, like the panel rate: the mode is built when +/// `nativeStart` assembles the [`PanelDesc`], so a value set afterwards does not +/// reach the running session. Process-lifetime, not reset per session. +static SCANOUT_GEOMETRY: Mutex> = Mutex::new(None); + +/// The mode geometry for a window of `win_w`x`win_h`: the size the app asked for +/// when it fits, else the window's own. +/// +/// A mode larger than the window would composite more pixels than the display +/// can show and then have them scaled back down, so it is refused rather than +/// clamped to an aspect nobody asked for: the connector then advertises the +/// window's size, which is the pre-setting behaviour, and gamescope's `-W/-H` +/// (the requested size) matches no mode and falls back to it. +fn scanout_geometry(win_w: u16, win_h: u16) -> (u16, u16) { + let Some((want_w, want_h)) = *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) else { + return (win_w, win_h); + }; + if want_w >= 64 && want_h >= 64 && want_w <= u32::from(win_w) && want_h <= u32::from(win_h) { + (want_w as u16, want_h as u16) + } else { + log_info!( + "{}", + gndrm_obf::obf!( + "scan-out geometry {want_w}x{want_h} does not fit the {win_w}x{win_h} window; the mode is the window" + ) + ); + (win_w, win_h) + } +} + /// The app's `JavaVM`, cached from the first JNI entry point (`nativeStart`). /// Needed by the presenter and scan-out threads, which are Rust-spawned threads /// with no `JNIEnv` of their own: they attach with @@ -436,11 +485,12 @@ fn server_thread( listener: RawFd, window: WindowPtr, panel: PanelDesc, + window_size: (u16, u16), fds: Arc>, ready: mpsc::Sender>, String>>, ) { let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - server_thread_inner(listener, window, panel, fds, ready); + server_thread_inner(listener, window, panel, window_size, fds, ready); })); if let Err(e) = caught { report_panic("gndrm server", e.as_ref()); @@ -451,6 +501,7 @@ fn server_thread_inner( listener: RawFd, window: WindowPtr, panel: PanelDesc, + window_size: (u16, u16), fds: Arc>, ready: mpsc::Sender>, String>>, ) { @@ -461,6 +512,8 @@ fn server_thread_inner( window.0, i32::from(panel.width), i32::from(panel.height), + i32::from(window_size.0), + i32::from(window_size.1), ))); let presenter: Box = Box::new(asc::AscPresenter::new(Arc::clone(&asc_state))); @@ -881,11 +934,15 @@ fn start( ffi::ANativeWindow_getHeight(window.0), ) }; - let (panel_w, panel_h) = if win_w > 0 && win_h > 0 { + let (win_w, win_h) = if win_w > 0 && win_h > 0 { (win_w as u16, win_h as u16) } else { (width as u16, height as u16) }; + // The mode is the scan-out size, which the app may have asked to be below the + // window's so the display's compositor upscales instead of the session + // compositing at the panel's pixels (see `SCANOUT_GEOMETRY`). + let (panel_w, panel_h) = scanout_geometry(win_w, win_h); let panel = PanelDesc { width: panel_w, height: panel_h, @@ -945,9 +1002,10 @@ fn start( }; let (tx, rx) = mpsc::channel::>, String>>(); let thread_fds = Arc::clone(&fds); + let window_size = (win_w, win_h); let handle = thread::Builder::new() .name("gndrm".into()) - .spawn(move || server_thread(listener, window, panel, thread_fds, tx)); + .spawn(move || server_thread(listener, window, panel, window_size, thread_fds, tx)); let handle = match handle { Ok(h) => h, Err(e) => { @@ -996,7 +1054,7 @@ fn start( log_info!( "{}", - gndrm_obf::obf!("started: socket={name} {panel_w}x{panel_h}") + gndrm_obf::obf!("started: socket={name} {panel_w}x{panel_h} into {win_w}x{win_h}") ); log_info!( "{}", @@ -1129,7 +1187,9 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j ffi::ANativeWindow_getHeight(raw), ) }; - let (panel_w, panel_h) = if win_w > 0 && win_h > 0 { + // The window's size, not the session's mode: retargeting rebuilds the publish + // layer around a new window and never re-modes what gamescope is compositing at. + let (win_w, win_h) = if win_w > 0 && win_h > 0 { (win_w, win_h) } else { (width, height) @@ -1142,7 +1202,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .set_panel(panel_w, panel_h); + .set_window(win_w, win_h); // SAFETY: `raw` is the extra reference this call acquired. unsafe { ffi::ANativeWindow_release(raw) }; return 0; @@ -1150,7 +1210,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j let rebuilt = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .retarget(raw, panel_w, panel_h); + .retarget(raw, win_w, win_h); if !rebuilt { log_error!( "{}", @@ -1173,7 +1233,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j let _ = injector::gamescope_command("debug_force_repaint"); log_info!( "{}", - gndrm_obf::obf!("retarget: adopted the new window {panel_w}x{panel_h}") + gndrm_obf::obf!("retarget: adopted the new window {win_w}x{win_h}") ); 0 } @@ -1285,6 +1345,57 @@ pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetPanelR } } +/// `GameNativeDrm.nativeSetScanoutGeometry(width, height)` — tell the daemon the +/// scan-out size the session's mode is emulated at, before `nativeStart`. +/// +/// It is the size gamescope composites at and the connector advertises, so it is +/// also the size the scan-out pool allocates and the size the guest's X screen +/// and windows see. Given a size below the window's, the session stays off the +/// panel's pixel count and the presenter scales the layer up into the window. +/// A size that does not fit the window, or one below `64x64`, is refused at +/// `nativeStart` (the window's size is then the mode) — see [`scanout_geometry`]. +/// Safe to call with no session running; a value set after `nativeStart` does not +/// reach that session. +#[no_mangle] +pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry( + _env: JNIEnv, + _class: JClass, + width: jint, + height: jint, +) { + if let Err(e) = std::panic::catch_unwind(|| set_scanout_geometry(width, height)) { + report_panic("gndrm-mode", e.as_ref()); + } +} + +/// Store the scan-out geometry from +/// [`Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry`], or +/// clear it for a zero axis. +/// +/// Only the obviously-unusable is dropped here — a negative or sub-`64` axis on +/// its own, which would make the mode's timings nonsense. Whether the size fits +/// the window is [`scanout_geometry`]'s question, because only `nativeStart` +/// knows the window. Clearing matters because the value is process-lifetime: a +/// session whose resolution cannot be read must not inherit the previous +/// session's size. +fn set_scanout_geometry(width: jint, height: jint) { + if width == 0 || height == 0 { + eprintln!("gndrm-daemon: scan-out geometry cleared"); + *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = None; + return; + } + if width < 64 || height < 64 { + log_warn!( + "{}", + gndrm_obf::obf!("scan-out geometry {width}x{height} below 64x64; ignored") + ); + return; + } + eprintln!("gndrm-daemon: scan-out geometry {}x{}", width, height); + *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = + Some((width as u32, height as u32)); +} + /// Validate and store the panel refresh rate from /// [`Java_app_gamenative_display_GameNativeDrm_nativeSetPanelRefreshHz`]. /// diff --git a/modules/gndrm-daemon/src/scanout.rs b/modules/gndrm-daemon/src/scanout.rs index 09ccc31..6985914 100644 --- a/modules/gndrm-daemon/src/scanout.rs +++ b/modules/gndrm-daemon/src/scanout.rs @@ -46,6 +46,55 @@ pub fn serves_len(buffer_len: u64, requested_len: u64) -> bool { buffer_len >= requested_len } +/// Where a scan-out buffer lands on the session's window: a KMS plane rect in +/// *mode* pixels, mapped into the app's window (the display's own pixels). +/// +/// The session's mode is its scan-out size, which the app may ask for below the +/// window's (the Upscaling Resolution), so the two are not the same box. The +/// layer is composited in window pixels: a smaller mode is scaled up uniformly +/// and centred — a mode of another aspect is letterboxed, never stretched — and +/// that upscale belongs to the display compositor rather than to gamescope, +/// whose composite stops at the mode. Mode equal to window is the identity, so +/// a 1:1 present is unchanged. +/// +/// `crtc` is `(x, y, width, height)` in mode pixels, as the plane committed it; +/// the result is `(left, top, right, bottom)` in window pixels, clipped to the +/// mode (a plane rect can exceed it) and therefore inside the window. +#[must_use] +pub fn layer_rect( + crtc: (i32, i32, i32, i32), + mode: (u32, u32), + window: (u32, u32), +) -> (i32, i32, i32, i32) { + let mw = i64::from(mode.0.max(1)); + let mh = i64::from(mode.1.max(1)); + let ww = i64::from(window.0.max(1)); + let wh = i64::from(window.1.max(1)); + // The mode's box fitted into the window: width-limited or height-limited. + let (fit_w, fit_h) = if ww * mh <= wh * mw { + (ww, mh * ww / mw) + } else { + (mw * wh / mh, wh) + }; + let off_x = (ww - fit_w) / 2; + let off_y = (wh - fit_h) / 2; + // One axis: clamp to the mode's span, scale, offset, round to nearest. + let place = + |v: i64, span: i64, fit: i64, off: i64| off + (v.clamp(0, span) * fit + span / 2) / span; + let (x, y, w, h) = ( + i64::from(crtc.0), + i64::from(crtc.1), + i64::from(crtc.2), + i64::from(crtc.3), + ); + ( + place(x, mw, fit_w, off_x) as i32, + place(y, mh, fit_h, off_y) as i32, + place(x + w, mw, fit_w, off_x) as i32, + place(y + h, mh, fit_h, off_y) as i32, + ) +} + /// Geometry a pooled buffer can serve. A free buffer only matches a request of /// the exact same width, height and format. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -1038,4 +1087,55 @@ mod tests { Reservation::Full => "full", } } + + /// The mode filling the window is the identity, so a session whose scan-out size + /// is the display's presents exactly as before this mapping existed — including a + /// plane rect that is letterboxed inside the mode. + #[test] + fn layer_rect_is_the_identity_for_a_mode_that_fills_the_window() { + assert_eq!( + layer_rect((0, 0, 1280, 720), (1280, 720), (1280, 720)), + (0, 0, 1280, 720) + ); + assert_eq!( + layer_rect((0, 27, 1280, 904), (1280, 960), (1280, 960)), + (0, 27, 1280, 931), + "a window letterboxed inside the mode keeps its rect" + ); + } + + /// A mode below the window is upscaled by the display compositor: one uniform + /// 1.5x here, covering the window. + #[test] + fn layer_rect_upscales_a_mode_below_the_window() { + assert_eq!( + layer_rect((0, 0, 1280, 720), (1280, 720), (1920, 1080)), + (0, 0, 1920, 1080) + ); + assert_eq!( + layer_rect((320, 180, 640, 360), (1280, 720), (1920, 1080)), + (480, 270, 1440, 810), + "a window inside the mode is scaled with it" + ); + } + + /// A mode of another aspect is letterboxed, never stretched. + #[test] + fn layer_rect_letterboxes_a_mode_of_another_aspect() { + assert_eq!( + layer_rect((0, 0, 1024, 768), (1024, 768), (1920, 1080)), + (240, 0, 1680, 1080), + "4:3 in a 16:9 window: pillarboxed" + ); + } + + /// A plane rect that runs past the mode is clipped to it, so the layer never + /// leaves the window. + #[test] + fn layer_rect_clips_to_the_mode() { + assert_eq!( + layer_rect((-40, -40, 1280 + 80, 720 + 80), (1280, 720), (1920, 1080)), + (0, 0, 1920, 1080) + ); + } } diff --git a/modules/gndrm-daemon/tests/golden.rs b/modules/gndrm-daemon/tests/golden.rs index 38e044d..2c0cc52 100644 --- a/modules/gndrm-daemon/tests/golden.rs +++ b/modules/gndrm-daemon/tests/golden.rs @@ -2671,3 +2671,58 @@ fn presenter_gets_the_committed_buffer_as_a_source() { // SAFETY: the test's own reference. unsafe { libc::close(fd) }; } + +// --------------------------------------------------------------------------- +// A50: the mode is the session's scan-out size, not the window's +// --------------------------------------------------------------------------- + +/// The connector advertises the size the app asked the session to present at, +/// so gamescope's `-W/-H` finds a mode and composites at it. This is what makes +/// an Upscaling Resolution below the panel a scan-out size rather than only a +/// scaling hint. +#[test] +fn connector_advertises_the_session_scanout_size() { + let _layout = with_layout(ScanoutLayout::Bgra8888); + let mut d = Device::new( + PanelDesc { + width: 1920, + height: 1080, + ..PanelDesc::default() + }, + Box::::default(), + ); + d.set_clock(Box::new(|| (1000, 500))); + + let r = call( + &mut d, + DRM_IOCTL_MODE_GETCONNECTOR, + &pod(&DrmModeGetConnector { + connector_id: CONNECTOR_ID, + modes_ptr: 1, + count_modes: 1, + ..Default::default() + }), + ); + ok(&r); + let off = core::mem::size_of::(); + let m: DrmModeModeinfo = pod_read(&r.body[off..]).unwrap(); + assert_eq!( + (m.hdisplay, m.vdisplay), + (1920, 1080), + "the mode is the scan-out size the panel was built with" + ); + + // GETCRTC carries the same mode (its `mode_valid` follows a committed + // MODE_ID, so it is 0 on a fresh device and says nothing here). + let r = call( + &mut d, + DRM_IOCTL_MODE_GETCRTC, + &pod(&DrmModeCrtc { + crtc_id: CRTC_ID, + ..Default::default() + }), + ); + ok(&r); + let c: DrmModeCrtc = pod_read(&r.body).unwrap(); + assert_eq!((c.mode.hdisplay, c.mode.vdisplay), (1920, 1080)); +} diff --git a/tools/exports/libgamenative_drm.map b/tools/exports/libgamenative_drm.map index c6532ab..f924b98 100644 --- a/tools/exports/libgamenative_drm.map +++ b/tools/exports/libgamenative_drm.map @@ -22,6 +22,7 @@ Java_app_gamenative_display_GameNativeDrm_nativeReshadeSetEffect; Java_app_gamenative_display_GameNativeDrm_nativeReshadeDisable; Java_app_gamenative_display_GameNativeDrm_nativeSetPanelRefreshHz; + Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry; Java_app_gamenative_display_GameNativeDrm_nativeSetLsfgConfig; Java_app_gamenative_display_GameNativeDrm_nativeStart; Java_app_gamenative_display_GameNativeDrm_nativeStop; From c81a7f1a70b4a6d15903a83f941f1c884073ad74 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:17:04 +0800 Subject: [PATCH 2/4] shim: answer the pool buffer's row stride MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gralloc pads scan-out rows for widths it does not like (1024 px -> 5120 B on Adreno) while a LINEAR Vulkan image is laid out at width * 4. A client that binds the pool buffer at its own pitch writes rows the compositor reads one row apart, which shears every composited frame. Direct scanout never writes that image, so the guest's own buffer stays clean — and a filter, a look or ReShade all force the composite, so the fault reads as "the scaling filter is broken". The shim is the only side that knows the stride: the daemon reports it with every hand-out. It now keeps it, keyed by geometry, and answers it to gamescope's A51 patch, which creates the scan-out image at that stride. An unseen geometry is measured with one throwaway pool allocation — the fd is closed, and the geometry with its padded length stays in the pool's index, which is what the client's real allocation then resolves against. No daemon channel answers 0, and the caller keeps the driver's own layout, so a real KMS session and a shim-less build are untouched. modules/gndrm-shim/gndrm_shim.c The stride table (geometry + length + stride, an entry refreshed on a repeat) and gamenative_drm_scanout_pitch(w, h) with its measure-an-unseen-geometry path: note the stride in scanout_hand_out, and resolve by geometry or by the geometry's tight length for a length-only hand-out. scanout_request's forward declaration moves above the table so the escape has its one definition site. tools/exports/libgndrm_shim.map The new entry point joins the interposed-set allowlist (14 exports). docs/02-gamenative-drm.md A Row stride bullet under §7: whose stride the pool buffer carries, what a client that ignores it looks like, and that the guest's own buffers are the ICD's job (proxy/export) while gamescope's output image is A51's. --- docs/02-gamenative-drm.md | 11 ++++ modules/gndrm-shim/gndrm_shim.c | 104 ++++++++++++++++++++++++++++++++ tools/exports/libgndrm_shim.map | 1 + 3 files changed, 116 insertions(+) diff --git a/docs/02-gamenative-drm.md b/docs/02-gamenative-drm.md index 4b5c1e6..25bbfe4 100644 --- a/docs/02-gamenative-drm.md +++ b/docs/02-gamenative-drm.md @@ -262,6 +262,17 @@ documented in 06-presenter-latency.md; the shape is: The shim requests a pool buffer by geometry and receives a dup'd dma-buf fd; gamescope renders into it; the same `AHardwareBuffer` is published. No import, no copy. +- **Row stride.** The pool buffer's rows are gralloc's, which pads them for + widths it does not like (1024 px → 5120 B, 1440 px → 5888 B on Adreno), while + a LINEAR Vulkan image is otherwise laid out at `width * 4`. A client that + binds the buffer at its own pitch writes rows the compositor reads one row + apart — a sheared frame. The shim answers the pitch (`gamenative_drm_scanout_pitch`, + measuring an unseen geometry with one throwaway pool allocation), and + gamescope's A51 patch creates the scan-out image at that stride through an + explicit LINEAR modifier, so the AddFB2 export reports the same stride the + compositor reads. The guest's own buffers are handled on the ICD side + (`icd_shim` `proxy`/`export`), which does the same thing for the images it + creates. - **Layout per session.** BGRA (`XR24`/`AR24` byte order) is the default. RGBA (`XB24`/`AB24`) is chosen at `nativeStart` on SDK 36+ when SurfaceFlinger's render engine is Skia GLES (`debug.renderengine.backend`, diff --git a/modules/gndrm-shim/gndrm_shim.c b/modules/gndrm-shim/gndrm_shim.c index 52df54b..5cc4361 100644 --- a/modules/gndrm-shim/gndrm_shim.c +++ b/modules/gndrm-shim/gndrm_shim.c @@ -1313,6 +1313,106 @@ _Static_assert(sizeof(struct gndrm_dmaheap_alloc) == 24, static uint32_t g_scan_modes[GNDRM_MAX_MODES][2]; static int g_scan_nmodes; +/* + * The row stride the daemon's pool buffer has for a scan-out geometry. gralloc + * pads rows for widths it does not like (1024 px -> 5120 B on Adreno) while a + * LINEAR Vulkan image is laid out at width * 4, so a client that binds the + * buffer at its own pitch writes rows at one stride into a buffer the + * compositor reads at another. A client that asks here before creating its + * image can lay that image out at the buffer's stride instead; see the + * gamescope A51 patch. + * + * Keyed by geometry because that is what such a client knows: the output size + * it is about to create an image for. + */ +#define GNDRM_MAX_STRIDES 8 + +struct gndrm_stride_note { + uint32_t w; + uint32_t h; + uint32_t stride; + uint64_t len; +}; + +static struct gndrm_stride_note g_scan_strides[GNDRM_MAX_STRIDES]; +static int g_scan_nstrides; + +static int scanout_request(uint32_t w, uint32_t h, uint64_t len, int *out_fd, + uint64_t *out_ino, uint32_t *out_stride, + int *out_attempts, long *out_elapsed_ms); + +/* Remember the stride the daemon reported for a pool buffer of `len` bytes, + * allocated for `w`x`h` when the shim matched a mode, else for whichever + * geometry the daemon resolved. */ +static void scanout_note_stride(uint64_t len, uint32_t w, uint32_t h, + uint32_t stride) +{ + if (!len || !stride) + return; + for (int i = 0; i < g_scan_nstrides; i++) + if ((w && g_scan_strides[i].w == w && g_scan_strides[i].h == h) || + g_scan_strides[i].len == len) { + g_scan_strides[i].w = w; + g_scan_strides[i].h = h; + g_scan_strides[i].len = len; + g_scan_strides[i].stride = stride; + return; + } + if (g_scan_nstrides >= GNDRM_MAX_STRIDES) + return; + g_scan_strides[g_scan_nstrides].w = w; + g_scan_strides[g_scan_nstrides].h = h; + g_scan_strides[g_scan_nstrides].len = len; + g_scan_strides[g_scan_nstrides].stride = stride; + g_scan_nstrides++; +} + +/* The noted stride for `w`x`h` or, failing that, for the tight length of that + * geometry (a length-only hand-out knew the geometry only from its length). */ +static uint32_t scanout_noted_pitch(uint32_t w, uint32_t h) +{ + uint64_t tight = (uint64_t)w * (uint64_t)h * 4u; + for (int i = 0; i < g_scan_nstrides; i++) + if (g_scan_strides[i].w == w && g_scan_strides[i].h == h) + return g_scan_strides[i].stride; + for (int i = 0; i < g_scan_nstrides; i++) + if (g_scan_strides[i].len == tight) + return g_scan_strides[i].stride; + return 0; +} + +/* + * The pool buffer's row stride for a `w`x`h` scan-out image, or 0 when the + * shim cannot find out — no daemon channel, so the allocation has no pool + * buffer behind it and the driver's own pitch is the right one. + * + * gralloc's stride is not derivable from the geometry, so an unrecorded + * geometry is measured by asking the daemon for one buffer of it and keeping + * the stride. The buffer is not kept: its fd is closed, the daemon releases it + * at the next commit and its geometry (and so its padded length) stays in the + * pool's index, which is what the client's real allocation then resolves + * against. + */ +uint32_t gamenative_drm_scanout_pitch(uint32_t w, uint32_t h); +uint32_t gamenative_drm_scanout_pitch(uint32_t w, uint32_t h) +{ + if (!w || !h) + return 0; + uint32_t pitch = scanout_noted_pitch(w, h); + if (pitch) + return pitch; + + int fd = -1; + uint64_t ino = 0; + uint32_t stride = 0; + uint64_t len = (uint64_t)w * (uint64_t)h * 4u; + if (scanout_request(w, h, len, &fd, &ino, &stride, NULL, NULL) != 0) + return 0; + syscall(SYS_close, fd); + scanout_note_stride(len, w, h, stride); + return stride; +} + /* ------------------------------------------------------------------ */ /* scan-out allocator channel */ /* ------------------------------------------------------------------ */ @@ -1719,6 +1819,10 @@ static int scanout_hand_out(struct gndrm_dmaheap_alloc *a, syscall(SYS_fcntl, fd, F_SETFD, FD_CLOEXEC); a->fd = (uint32_t)fd; a->heap_flags = 0; + /* The client that owns this allocation is about to bind it at a pitch of + * its own choosing; recording the daemon's lets it ask before it does + * (A51). */ + scanout_note_stride(len, w, h, stride); if (w && h) LOG("scanout: heap alloc %llu B -> daemon buffer %ux%u fd=%d ino=%llu stride=%u\n", (unsigned long long)len, w, h, fd, (unsigned long long)ino, stride); diff --git a/tools/exports/libgndrm_shim.map b/tools/exports/libgndrm_shim.map index 9bdac94..791bccc 100644 --- a/tools/exports/libgndrm_shim.map +++ b/tools/exports/libgndrm_shim.map @@ -8,6 +8,7 @@ { global: gamenative_drm_connect; + gamenative_drm_scanout_pitch; open; openat; open64; From 34c7a2b330ed26d0f74b7068f484a2c9a5b04ebd Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:11:27 +0800 Subject: [PATCH 3/4] docs: the mode's rate is the panel's, and the EDID follows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh split landed in the daemon earlier — the emulated mode advertises the rate the app reports through nativeSetPanelRefreshHz (raised to the frame-generation output rate when that is higher), and SESSION_REFRESH_MHZ is the vblank grid alone — but three docs still described the single-variable model, and the EDID's detailed timing kept a pixel clock whose comment rested on the same obsolete premise: "the advertised refresh is the 1200 Hz grid, which no DTD can express". docs/10-fps-limit.md -r is the session's grid, and the launcher passes a constant 1200 Hz, so the grid, the app-refresh cycle and the limiter's divisor base all read that constant while the mode's rate reaches the clients. The mode paragraph no longer claims 1280x960@1200 for every -r. "Recommended wiring", which still described carrying the fps limit in -r, becomes "How the cap is applied": the JNI fans out to the injector's set_app_target_refresh_cycle and to the frame cap the shim enforces at acquire. The open points now record that divisor rounding is a gamescope-route artefact, and that -r reaches g_nOutputRefresh only transiently before drm_set_mode overwrites it. docs/11-refresh-model.md The gndrm section said one preferred mode of 1280x960@1200 and that -r declares its value everywhere. It now states the split: the mode carries the panel's rate, -r is the grid (paint tick, app-refresh cycle, divisor base), and a cap is a divisor or the shim's acquire floor. Line references re-verified against the tree. docs/15-fps-limiter-pacing-and-vsync.md The §1 mechanism block: the launcher does pass -r, the mode no longer encodes SESSION_REFRESH_MHZ, and the refresh pushed to clients is the panel's. §8.3's "the panel's rate bounds nothing, PanelDesc carries none" and the stale 1200 Hz intent reference are corrected, and the scope note separates what landed from the pacing arithmetic that has not. modules/gndrm-daemon/src/edid.rs The detailed timing carries the mode's own clock, so the descriptor and the connector describe one timing; a clock the 16-bit 10 kHz field cannot hold (a generated rate above 4K) keeps the nominal 60 Hz fallback. The comment records why this is inert either way — gamescope takes the mode from the connector and the EDID only for colorimetry and HDR. Two tests: the descriptor implies the connector's rate at 1280x960@120Hz, and an unrepresentable clock falls back. --- docs/10-fps-limit.md | 102 ++++++++++++------------ docs/11-refresh-model.md | 81 ++++++++++--------- docs/15-fps-limiter-pacing-and-vsync.md | 61 +++++++------- modules/gndrm-daemon/src/edid.rs | 54 +++++++++++-- 4 files changed, 175 insertions(+), 123 deletions(-) diff --git a/docs/10-fps-limit.md b/docs/10-fps-limit.md index b9fd280..aa57539 100644 --- a/docs/10-fps-limit.md +++ b/docs/10-fps-limit.md @@ -35,23 +35,25 @@ gamescope, so our pacer would be pacing a path the frames no longer take. (interned `steamcompmgr.cpp:9003`, read at `:7371`). Equivalent effect, but it needs a client of *gamescope's* Xwayland — writable by the ICD shim (which already has that connection) or by wine, not by the app process. -3. **`-r ` / `--nested-refresh`** — the launch knob GameNative uses to carry - the FPS limit value directly. `main.cpp:748` stores it as - `g_nNestedRefresh` in **mHz**; `0` is the unset sentinel (only negatives are - rejected) and every consumer falls back to `g_nOutputRefresh`. Verified - consumers: - - `vblankmanager.cpp:77` `GetVBlankInterval()` — the vblank grid itself. - - `vblankmanager.cpp:405` — a sleep computed from the same value. - - `steamcompmgr.cpp:10492-10494` — `nRealRefreshmHz` → - `g_SteamCompMgrAppRefreshCycle`, consumed at `:8252-8253` as the window's - refresh cycle (this is what paces the app's frame callbacks). - - `steamcompmgr.cpp:6911` — the limiter's divisor base `nRefreshHz` (only - used when a target FPS is also set; with `-r` alone `g_nSteamCompMgrTargetFPS` - stays 0, so the cap comes from the app refresh cycle, not the divisor). - - `Backends/DRMBackend.cpp:3910` — passed into `init_drm(..., g_nNestedRefresh)` - → `drm->preferred_refresh` → mode selection (`find_mode`, exact `vrefresh` - match `:1029`) and the declared output refresh (`drm_unset_mode`, `:3646` - `if (bPreferredRefresh) g_nOutputRefresh = drm->preferred_refresh`). +3. **`-r ` / `--nested-refresh`** — the session's vblank grid. + `main.cpp:740-741` stores it as `g_nNestedRefresh` in **mHz**; `0` is the + unset sentinel (only negatives are rejected) and every consumer falls back to + `g_nOutputRefresh`. GameNative launches with a constant 1200 Hz + (`GamescopeState.SESSION_GRID_HZ` → `gridArg()`), high enough that the grid + never binds the session. Verified consumers: + - `vblankmanager.cpp:75-77` `GetRefresh()` — the vblank grid itself, and + `:405` a sleep computed from the same value. + - `steamcompmgr.cpp:10606-10607` — `nRealRefreshmHz` → + `g_SteamCompMgrAppRefreshCycle`, the window's refresh cycle (what paces the + app's frame callbacks). + - `steamcompmgr.cpp:7013` — the limiter's divisor base `nRefreshHz`, used only + when a target FPS reached gamescope through + `set_app_target_refresh_cycle`. + - `Backends/DRMBackend.cpp:3943` — passed into + `init_drm(..., g_nNestedRefresh)` → `drm->preferred_refresh`, which A47 + makes a size-only mode lookup because the grid matches no `vrefresh`; + `drm_set_mode` (`:3728`) then makes the mode's own rate the declared + output refresh. `--framerate-limit` is a dead option in this tree — declared at `main.cpp:97`/documented at `:221`, absent from main.cpp's long-option chain — @@ -63,54 +65,52 @@ Method: deltas of gamescope's unconditional 1/s `drm: A23 flip commit ` over ≥23 s window after ~95 s settle, same scene each run. Device 97c32e85, container `xuser-STEAM_1868140`. -gndrm exposes **one** mode only: `1280x960@1200` (gndrm-daemon `objects.rs` -`PanelDesc::modeinfo` builds a single `DrmModeModeinfo`; `vrefresh` comes from -`SESSION_REFRESH_MHZ`, the daemon's own 1200 Hz constant — not the panel's rate). +gndrm exposes **one** mode: the session's scan-out size at the panel's real rate +(`PanelDesc::modeinfo` in `gndrm-daemon/src/objects.rs`; the rate comes from +`nativeSetPanelRefreshHz`, or from the frame-generation output rate when that is +higher). The vblank grid is a separate value — the daemon's own +`SESSION_REFRESH_MHZ`, 1200 Hz — so `-r` matches no `vrefresh` by design: A47 +re-runs the lookup by size, and `drm_set_mode` makes the mode's rate the +declared output refresh. `-r` is the grid (paint tick, app-refresh cycle) and +never the client-visible rate. -The selected DRM mode is `1280x960@1200` for **every** `-r` value: the connector -exposes only that one mode, `find_mode(N)` finds no exact `vrefresh` match and -the single mode is kept. What `-r N` changes is the declared output refresh -`g_nOutputRefresh = N` and the vblank grid / app-refresh cycle = N — the grid is -re-timed while the mode stays 1200 Hz. +A divisor cap on the grid lands on its target exactly, and the uncapped ceiling +is the client↔gamescope lap — the grid itself never binds. | `-r` | presents/s | |---|---| | `0` (unset→output refresh; uncapped) | ~259 | | `60` | 60.0 | -The 1200 Hz grid never binds: the uncapped ceiling is the client↔gamescope lap, -and a divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s). - ### Pacing cost The flip event is emitted with the publish (see 12), so a paced frame's present does not carry the SurfaceFlinger round trip: `interval ≈ 1 / target`, and a divisor cap reads its target exactly. -## Recommended wiring +## How the cap is applied -- **Runtime (mid-session) changes** stay on the existing request path: a - `nativeSetFpsLimit(fps)` JNI call on the `GameNativeDrm` surface issues +- **`nativeSetFpsLimit(fps)`** (`gndrm-android/src/lib.rs:1454`, `0` = uncapped) + fans out to both routes. `injector::set_fps_limit` issues `gamescope_control.set_app_target_refresh_cycle(fps, internal_display)` and - re-issues it when the setting changes (`fps = 0` = uncapped). This is the only - route that can change the limit mid-session, because `-r` is a launch-time - argument. -- **Launch contract**: pass `-r ` where value = the QuickMenu fps limit — - `0` when the limiter is off (gamescope falls back to the output refresh = - uncapped) and the user's target when it is on. Sourced from the same container - extra (`fpsLimiterTarget`, off ⇒ 0) the runtime request reads, so the launch - value and a mid-session change cannot drift. The launch command builds no - `--framerate-limit`. + 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. +- **`-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 only bites the request path.** With `-r` alone the cap comes - from the grid rate, not from `nVblankDivisor`, so the `nearest divisor` - behaviour applies to runtime changes made while `g_nSteamCompMgrTargetFPS != 0`. -- **`-r` also sets the declared output refresh `g_nOutputRefresh`** (and, since - the connector has a single mode, keeps the 1200 Hz DRM mode). Consequences of a - low `-r` on the client-visible refresh (`_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK`, - `active_display_info` rates) were not measured directly on device — the rates - `active_display_info` carries are the connector's mode list, not - `g_nOutputRefresh`, in this configuration. -- **Engagement feedback**: `gamescope_limiter.state` is the honest source for - "is a limit actually in force". +- **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. +- **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`). diff --git a/docs/11-refresh-model.md b/docs/11-refresh-model.md index 177737e..832f1cc 100644 --- a/docs/11-refresh-model.md +++ b/docs/11-refresh-model.md @@ -2,63 +2,70 @@ ## Two variables, two jobs -**`g_nOutputRefresh`** (mHz, `main.cpp:305`) — the **panel mode's** rate, assigned by the -DRM backend from the selected mode (`Backends/DRMBackend.cpp:3695`). It is what the -whole stack *and the clients* are told: +**`g_nOutputRefresh`** (mHz, `main.cpp:305`) — the **mode's** rate, assigned by the DRM +backend from the mode it selected (`Backends/DRMBackend.cpp:3727-3728`, the +`GetModeRefresh` recompute at `:229-246`). It is what the whole stack *and the clients* +are told: -- the Xwayland server mode (`steamcompmgr.cpp:7356`, `:10382`), -- the `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` root property (`:9283-9288`), -- `gamescope_control.active_display_info`'s rate list (`wlserver.cpp:1538-1541`), -- mangoapp's snapshot (`mangoapp.cpp:205`), ReShade (`reshade_effect_manager.cpp:293`). +- the Xwayland server mode (`steamcompmgr.cpp:7458`, `:10496`), +- the `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` root property (`:9384-9390`), +- `gamescope_control.active_display_info`'s rate list (`wlserver.cpp:1483-1487`), +- mangoapp's snapshot (`mangoapp.cpp:221`), ReShade (`reshade_effect_manager.cpp:293`). **`g_nNestedRefresh`** (mHz, `main.cpp:299`) — `-r/--nested-refresh`; **0 means fall back -to the output refresh** (`main.cpp:749` rejects only negatives). In the nested backends it -*becomes* the output refresh (`SDLBackend.cpp:230`, `WaylandBackend.cpp:1989`, +to the output refresh** (`main.cpp:740-746` rejects only negatives). In the nested backends +it *becomes* the output refresh (`SDLBackend.cpp:230`, `WaylandBackend.cpp:1989`, `HeadlessBackend.cpp:112`). In the **DRM** backend — ours — it is passed to -`init_drm( &g_DRM, w, h, g_nNestedRefresh )` (`Backends/DRMBackend.cpp:3910`) and stored as -`drm->preferred_refresh` (`:1503`). +`init_drm( &g_DRM, w, h, g_nNestedRefresh )` (`Backends/DRMBackend.cpp:3943`) and stored as +`drm->preferred_refresh` (`:1545`). -## A preferred refresh selects the mode +## In the DRM backend they are separate -`find_mode( connector, w, h, preferred_refresh_hz )` (`DRMBackend.cpp:1385`) matches -`mode->vrefresh` **exactly** (`:1029`), and where a preference exists `g_nOutputRefresh` is -taken from it (`:3628`, `:3647`). +`find_mode( connector, w, h, vrefresh )` (`DRMBackend.cpp:1042-1069`) matches `vrefresh` +**exactly** when one is given, so a grid that names no exposed mode falls through — +which is why A47 re-runs the lookup by size with the refresh unconstrained +(`:1417-1426`). `drm_unset_mode` then declares the preferred refresh +(`:3680`), and `drm_set_mode` overwrites it with the selected mode's own rate (`:3728`). -So in the DRM backend **`-r N` is a declaration that the display is N Hz** — it reaches -mode selection, the client-visible refresh and the grid together. It is not a cap layered -on top of a 120 Hz grid. +So in the DRM backend **`-r` is the session's grid** — the compositor's paint tick and +the app-refresh cycle — while the **mode's rate is what clients are told**. A cap is a +grid divisor, or the shim's acquire floor (doc 10); it never re-times the mode. ## What the grid and the pacing read - **Grid:** `CVBlankTimer::GetRefresh()` = `g_nNestedRefresh ?: g_nOutputRefresh` - (`vblankmanager.cpp:77`); the interval comes from `mHzToRefreshCycle(GetRefresh())` + (`vblankmanager.cpp:75-77`); the interval comes from `mHzToRefreshCycle(GetRefresh())` in `GetNextVBlank()`, and the same expression times the timer's idle sleep (`:405`). - **App pacing:** `g_SteamCompMgrAppRefreshCycle = mHzToRefreshCycle( g_nNestedRefresh ?: - g_nOutputRefresh )` (`steamcompmgr.cpp:10492`), consumed per window at `:8246-8253` + g_nOutputRefresh )` (`steamcompmgr.cpp:10606-10607`), consumed per window at `:8355` (that is what throttles the guest's frame callbacks). -- **Limiter divisor base:** `nRefreshHz` = the same nested-expression (`:6911`); the +- **Limiter divisor base:** `nRefreshHz` = the same nested-expression (`:7013`); the divisor only applies while `g_nSteamCompMgrTargetFPS != 0`. Both the grid and the app pacing come from **one** expression, so there is no way to pace -the app at N while keeping the grid at the mode rate — they move together. +the app at N while keeping the grid at the mode's rate — they move together. -## Consequences on the session's own rate +## What gndrm does with it -gndrm advertises **one** preferred mode — `1280x960@1200`, from its own -`SESSION_REFRESH_MHZ` constant (`gndrm-daemon/src/objects.rs`, -`vrefresh: SESSION_REFRESH_MHZ.div_ceil(1000)`), not the panel's rate — so a -preferred refresh with no matching exposed mode has nothing to select, and the -grid, declared output refresh and client-visible refresh move together with `-r`. +GameNative launches with `-r 1200` (`GamescopeState.SESSION_GRID_HZ` → `gridArg()`), the +grid the daemon's flip timeline is also built from (`SESSION_REFRESH_MHZ`, +`gndrm-daemon/src/objects.rs:68`; `lib.rs:2091`), so the grid never binds a session. -- **Uncapped:** `-r 0` falls back to the output refresh (1200 Hz); the loop - reaches its measured ceiling (~259 presents/s). -- **`-r N`:** declares N Hz everywhere — the guest is told N, the grid runs at N — - while the single 1200 Hz DRM mode is kept. -- **Capping at 30/40/60 is exact arithmetic** — all three divide 1200, and a - divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s). +The **one** mode the connector exposes is the session's scan-out size at the panel's real +rate (`PanelDesc::modeinfo`, `objects.rs:121-160`: `mode_refresh_mhz()` is the rate the app +reported through `nativeSetPanelRefreshHz`, raised to the frame-generation output rate when +that is higher, and `clock`/`htotal`/`vtotal`/`vrefresh` all describe it). The rate clients +see is therefore the panel's: + +- **Uncapped:** the loop reaches its measured ceiling (~259 presents/s); the grid is three + orders of magnitude above the panel's frame time and never the limit. +- **Mode rate vs panel rate:** equal unless frame generation raises the output rate, in + which case the mode advertises that higher rate and the shim still paces on the panel's. +- **Capping at 30/40/60 against the grid is exact arithmetic** — all three divide 1200, and + a divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s). ## Open — to measure, not assume -- The `active_display_info` refresh list carries the connector's mode list, not - `g_nOutputRefresh`; the client-visible refresh for a low `-r` was not measured - directly on device. +- `active_display_info`'s rate list is the single mode rate above (there are no dynamic + rates on this connector), so a client sees one entry — the panel's rate, or the + generated one. diff --git a/docs/15-fps-limiter-pacing-and-vsync.md b/docs/15-fps-limiter-pacing-and-vsync.md index e5de4ef..ad959e2 100644 --- a/docs/15-fps-limiter-pacing-and-vsync.md +++ b/docs/15-fps-limiter-pacing-and-vsync.md @@ -5,11 +5,11 @@ Scope: an audit of where frame pacing and the fps cap are decided in SurfaceFlinger`, and a design for a correct cap with correct pacing and an honest refresh model. -Nothing in this document is implemented. The pacing-arithmetic patch remains in -`gamescope/android/patches/disabled/0032-A32-fps-cap-lower-bound-delay.patch` -and is **not** applied to `gamescope/src/`. Line references are against -`gamescope` HEAD `f3c3b96`, `gamenative-drm` `master`, and GameNative -`feat/gamescope-display`. +The refresh split described in §1 is implemented: the mode advertises the +panel's rate and the 1200 Hz constant is the grid alone. The pacing arithmetic +below is not — `steamcompmgr.cpp:10621` still applies the cap as a refresh +divisor, whose patch sits in `gamescope/android/patches/disabled/`. Line +references are against the working trees at the A51 patch train. ## 1. One variable does two jobs @@ -23,39 +23,43 @@ g_nNestedRefresh ? g_nNestedRefresh : g_nOutputRefresh - the grid — `CVBlankTimer::GetRefresh()`, `gamescope/src/vblankmanager.cpp:75-78`, consumed at `:87` (`GetNextVBlank`) and `:127` (`CalcNextWakeupTime`); - app pacing — `g_SteamCompMgrAppRefreshCycle = mHzToRefreshCycle(nRealRefreshmHz)`, - `gamescope/src/steamcompmgr.cpp:10528-10529`; -- the limiter's divisor base — `nRefreshHz`, `gamescope/src/steamcompmgr.cpp:6938`. + `gamescope/src/steamcompmgr.cpp:10606-10607`; +- the limiter's divisor base — `nRefreshHz`, `gamescope/src/steamcompmgr.cpp:7013`. -The launcher passes no `-r` -(`GameNative/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java:591-594`), -so `g_nNestedRefresh` is 0 and all three read `g_nOutputRefresh`. +The launcher passes the constant `-r 1200` +(`GameNative/.../GamescopeState.kt:44`, `:64` → `gridArg()`, appended at +`BionicProgramLauncherComponent.java:640`), so `g_nNestedRefresh` wins for all +three and the grid is 1200 Hz — the app-refresh cycle and the divisor base +included. The DRM backend sets `g_nOutputRefresh` from the mode it selected, recomputed rather than read from the label: `drm_set_mode` → `g_nOutputRefresh = gamescope::GetModeRefresh( mode )` -(`gamescope/src/Backends/DRMBackend.cpp:3674-3685`), and `GetModeRefresh` is -`(clock*1e6/htotal + vtotal/2)/vtotal` (`:229-243`). The daemon exposes one -synthetic mode whose `clock`/`htotal`/`vtotal` encode -`SESSION_REFRESH_MHZ = 1_200_000` -(`gamenative-drm/modules/gndrm-daemon/src/objects.rs:63`, `:91-125`), so the -recomputed rate is 1200 Hz. The mode's `vrefresh` field (`objects.rs:120`) only -has to agree with those three; it is a label, not the rate. +(`gamescope/src/Backends/DRMBackend.cpp:3727-3728`), and `GetModeRefresh` is +`(clock*1e6/htotal + vtotal/2)/vtotal` (`:229-246`). The daemon's one mode carries +its own rate in `clock`/`htotal`/`vtotal`/`vrefresh` alike +(`gamenative-drm/modules/gndrm-daemon/src/objects.rs:121-160`): the panel's rate as +the app reported it through `nativeSetPanelRefreshHz`, raised to the +frame-generation output rate when that is higher. The recomputed rate is therefore +the panel's, and the 1200 Hz constant +(`SESSION_REFRESH_MHZ`, `objects.rs:68`) is the grid alone. Consequences, all structural: - the grid interval is `mHzToRefreshCycle(1200 Hz)` ≈ 833 µs — three orders of magnitude below the panel's frame time, so the composite is never the ceiling and a guest can present faster than the panel at all; -- the refresh pushed to clients is a fabricated 1200 Hz; -- every reachable capped rate is `1200/N`, which is what the limiter's parity - between a requested target and a landed rate is measured against. +- the refresh pushed to clients is the panel's (or the generated rate), never the grid; +- every capped rate reachable through gamescope is `1200/N`, which is what the + limiter's parity between a requested target and a landed rate is measured + against there; the shim's own floor is exact. The daemon's flip-event timebase is built from the same constant — `VblankTimeline::interval_ns = 1e12 / SESSION_REFRESH_MHZ` -(`gamenative-drm/modules/gndrm-daemon/src/lib.rs:1834`) — so the timeline and -the grid agree by construction. `objects.rs:55-62` states the intent: the -advertised rate is a deliberate decoupling from the panel, so that the only cap -on a guest is the user's own fps limit, and the panel downstream coalesces. +(`gamenative-drm/modules/gndrm-daemon/src/lib.rs:2091`) — so the timeline and +the grid agree by construction. `objects.rs:53-68` states the intent: the grid is a +deliberately high timer so the only cap on a guest is the user's own fps limit, +while the mode's rate stays truthful to the panel. ## 2. The current limiter path @@ -155,7 +159,7 @@ Which stage binds is per title, not per build. A title whose lap through the shim and the compositor exceeds the panel's frame time is bound by its own lap (stage 1+2). A fast title is bound by how often gamescope releases its buffers, which is per grid tick (stage 3). Nothing bounds it at the panel rate: that is -the intent of the fabricated 1200 Hz grid (`objects.rs:55-62`). +the intent of the 1200 Hz grid (`objects.rs:53-68`). **A frame presented early** — the guest obtained a slot and committed before the panel's next edge. The daemon publishes it, and `VblankTimeline::claim` stamps it @@ -514,9 +518,10 @@ Three candidates, and which one bounds: repaint (`steamcompmgr.cpp:10762`, `:10817`) — i.e. when a client committed. The project's own figure for that pipeline is ~259 presents/s (`objects.rs:60-61`, from an earlier session and not re-measured here). -- **The panel's real rate.** Nothing. `g_nOutputRefresh` is the fabricated 1200 - and `PanelDesc` carries no rate (`objects.rs:69-75`); the panel reaches the - stack only through SurfaceFlinger, downstream of everything we own. +- **The panel's real rate.** Nothing on the loop: `g_nOutputRefresh` is the + panel's rate, but the grid is the daemon's 1200 Hz constant, so the + compositor's tick is not tied to the panel; the panel reaches the stack only + through SurfaceFlinger, downstream of everything we own. So the loop is bounded by the **guest's present rate** for the per-frame path (shim present, daemon commit, presenter publish), and by the **1200 Hz grid** for diff --git a/modules/gndrm-daemon/src/edid.rs b/modules/gndrm-daemon/src/edid.rs index dac93ae..381c9f7 100644 --- a/modules/gndrm-daemon/src/edid.rs +++ b/modules/gndrm-daemon/src/edid.rs @@ -210,12 +210,12 @@ fn write_chromaticity(edid: &mut [u8; 128], primaries: [(f32, f32); 3], white: ( /// One 18-byte detailed timing descriptor matching the connector's mode. /// -/// The advertised mode's refresh is the daemon's 1200 Hz vblank grid -/// (`SESSION_REFRESH_MHZ`), which no DTD can express (the pixel-clock field -/// tops out at 655.35 MHz). gamescope reads the mode from the connector, not -/// from EDID timings, so the descriptor carries the advertised geometry and -/// porches exactly and a nominal 60 Hz pixel clock — a legal, self-consistent -/// value rather than a truncated one. +/// The pixel clock is the mode's own (`PanelDesc::modeinfo` builds it from the +/// mode's rate), so the descriptor and the connector describe one timing. The +/// 16-bit 10 kHz field tops out at 655.35 MHz, which only a frame-generated rate +/// above 4K reaches; such a mode falls back to a nominal 60 Hz. gamescope reads +/// the mode from the connector and the EDID only for colorimetry and HDR +/// metadata, so these timings are for consistency, not for mode selection. fn detailed_timing(mode: &DrmModeModeinfo, panel: &PanelDesc) -> [u8; 18] { let hact = u32::from(mode.hdisplay); let hblank = u32::from(mode.htotal).saturating_sub(hact); @@ -226,7 +226,11 @@ fn detailed_timing(mode: &DrmModeModeinfo, panel: &PanelDesc) -> [u8; 18] { let vfront = u32::from(mode.vsync_start).saturating_sub(vact); let vsync = u32::from(mode.vsync_end).saturating_sub(u32::from(mode.vsync_start)); let mut d = [0u8; 18]; - let clock_khz = u32::from(mode.htotal) * u32::from(mode.vtotal) * 60 / 1000; + let clock_khz = if (10..=655_350).contains(&mode.clock) { + mode.clock + } else { + u32::from(mode.htotal) * u32::from(mode.vtotal) * 60 / 1000 + }; let pc = (clock_khz / 10) & 0xFFFF; // pixel clock in 10 kHz units d[0] = (pc & 0xFF) as u8; d[1] = (pc >> 8) as u8; @@ -278,6 +282,42 @@ mod tests { PanelDesc::default().modeinfo() } + #[test] + fn the_detailed_timing_carries_the_modes_own_rate() { + let panel = PanelDesc { + width: 1280, + height: 960, + mm_width: 0, + mm_height: 0, + refresh_mhz: 120_000, + }; + let mode = panel.modeinfo(); + let edid = build(None, &mode, &panel); + let pc = u32::from(edid[54]) | (u32::from(edid[55]) << 8); + // The descriptor's timings imply the connector's rate, not a nominal one. + let hz = u64::from(pc) * 10_000 / (u64::from(mode.htotal) * u64::from(mode.vtotal)); + assert!((119..=120).contains(&hz), "descriptor implies {hz} Hz"); + } + + #[test] + fn a_clock_the_field_cannot_hold_falls_back_to_60_hz() { + let panel = PanelDesc { + width: 1920, + height: 1080, + mm_width: 0, + mm_height: 0, + refresh_mhz: 500_000, + }; + let mode = panel.modeinfo(); + assert!( + mode.clock > 655_350, + "the mode must exceed the 10 kHz field" + ); + let edid = build(None, &mode, &panel); + let pc = u32::from(edid[54]) | (u32::from(edid[55]) << 8); + assert_eq!(pc * 10, 2000 * 1100 * 60 / 1000); + } + #[test] fn sdr_edid_has_no_cta_extension() { let edid = build(None, &mode(), &PanelDesc::default()); From dc2a8551d1f00bba8e4ab562c387664f206ebd0b Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 21:01:15 +0530 Subject: [PATCH 4/4] shim: hold the acquire cadence at the cap period (#34) * shim: hold the acquire cadence at the cap period * docs: the shim's floor steps from the deadline; log the cap once it is in force --------- Co-authored-by: Utkarsh Dalal --- docs/10-fps-limit.md | 18 ++++++++++++------ icd_shim/src/scanout.rs | 36 ++++++++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) 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);