Skip to content

android: the mode is the session's scan-out size, and the shim answers its row stride - #32

Merged
joshuatam merged 4 commits into
masterfrom
fix/gamescope-resolution-scanout
Oct 30, 2026
Merged

joshuatam merged 4 commits into
masterfrom
fix/gamescope-resolution-scanout

Conversation

@joshuatam

@joshuatam joshuatam commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

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: composite, scan-out images, the pool's buffers and the published layer were all the panel's pixel count (2.25× 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.

Three commits: the geometry change, the pitch handshake it needs, and the refresh-model docs and EDID timing that the split made stale.

1. android: the mode is the session's scan-out size, not the window's

The app 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.

  • 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's.
  • scanout_geometry resolves the mode at nativeStart: the requested size when it fits the window, else the window's. A larger mode is refused with a log rather than clamped — it would composite pixels the display cannot show and have them scaled back down.
  • 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.
  • Presenter carries mode_w/h (pool geometry, note_framebuffer, the layer-rect/HIDDEN/shown/republish/teardown lines) apart from win_w/h (the compositing box); publish maps the CRTC rect through gndrm_daemon::scanout::layer_rect before the transaction.
  • 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, 1.5× upscale, pillarboxed 4:3, plane clipped to the mode).
  • Golden test connector_advertises_the_session_scanout_size: GETCONNECTOR/GETCRTC carry the size the device was built with, which is what lets gamescope's -W/-H find a mode instead of falling back.

2. shim: answer the pool buffer's row stride

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 — and because a filter, a look or ReShade all force the composite, the fault reads as "the scaling filter is broken". Direct scan-out never writes that image, so the guest's own buffer stays clean.

The shim is the only side that knows the stride, since the daemon reports it with every hand-out. It keeps it keyed by geometry and answers gamenative_drm_scanout_pitch(w, h) 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; the geometry with its padded length stays in the pool's index, which 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.

3. docs: the mode's rate is the panel's, and the EDID follows it

No behaviour change; it removes the last places that described the pre-split model. docs/10-fps-limit.md, docs/11-refresh-model.md and docs/15-fps-limiter-pacing-and-vsync.md all still said the connector exposes one 1280x960@1200 mode for every -r, and that clients are told a fabricated 1200 Hz — the mode now carries the panel's rate and SESSION_REFRESH_MHZ is the grid alone. docs/10's "Recommended wiring" still described carrying the fps limit in -r; it now describes what runs (the JNI's injector request plus the shim's acquire floor).

modules/gndrm-daemon/src/edid.rs: the detailed timing now carries the mode's own pixel clock instead of a hardcoded 60 Hz, so the descriptor and the connector describe one timing (the 16-bit 10 kHz field only overflows for a frame-generated rate above 4K, which keeps the nominal fallback). Inert either way — gamescope takes the mode from the connector and the EDID only for colorimetry and HDR — but the comment no longer rests on the obsolete premise.

Verification

  • cargo test -p gndrm-daemon -p gndrm-uapi — 113 passed, 0 failed (includes the new layer_rect, connector golden and EDID detailed-timing tests).
  • clippy clean on the daemon, gndrm-uapi and --target aarch64-linux-android -p gndrm-android; zero warnings from the shim build.
  • check-exports.sh PASS — libgamenative_drm.so still exports only the three Khronos loader entry points (20 JNI exports pinned), libgndrm_shim.so at 14.
  • Golden/train checks: the gamescope patch train applies cleanly and the built gamescope binary shows gamenative_drm_scanout_pitch as WEAK UND, GLOBAL FUNC in the shim.
  • On device (Retroid Pocket Nova, 1280x960@120Hz, Adreno 740 + Turnip 26.3.0-devel), six sessions across container sizes, Upscaling Resolutions and filters:
    • the connector mode follows the requested size — 1024x768, 1280x720 and 1280x960 sessions each select that exact mode;
    • 1920x1080 requested into a 1280x960 window is refused and the session stays at the window's size;
    • the padded cases log output image 1024x768 at the pool's row stride 5120 (tight 4096) and render coherently, where the same geometry used to shear;
    • SurfaceFlinger sees the matching chain — layer geomBufferSize=[0 0 1024 768], buffer stride:5120 bytes, layer transform 1.2500 → 1280x960.

The full device matrix (container screen × Upscaling Resolution × filter, with screenshots) is in utkarshdalal/GameNative#2283.

Companion PRs

All three are needed together: the daemon without A51 renders sheared frames at padded strides, and gamescope's A51 without the shim export is a silent no-op.

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.
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.
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.
* 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 <utkarsh.dalal@toptal.com>
@joshuatam
joshuatam merged commit 4783956 into master Oct 30, 2026
@joshuatam
joshuatam deleted the fix/gamescope-resolution-scanout branch October 30, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants