Repository navigation
android: the mode is the session's scan-out size, and the shim answers its row stride - #32
Merged
Merged
Conversation
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.
This was referenced Oct 30, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_modefell through to the connector's first mode, anddrm_set_modeoverwroteg_nOutputWidth/Heightwith 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'sThe 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 == windowis the identity and presents exactly as before.nativeSetScanoutGeometry(w, h)(JNI) stores the request;0, 0clears it, because the value is process-lifetime and a session whose resolution cannot be read must not inherit the previous one's.scanout_geometryresolves the mode atnativeStart: 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.mode_w/h(pool geometry,note_framebuffer, the layer-rect/HIDDEN/shown/republish/teardown lines) apart fromwin_w/h(the compositing box);publishmaps the CRTC rect throughgndrm_daemon::scanout::layer_rectbefore 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).connector_advertises_the_session_scanout_size:GETCONNECTOR/GETCRTCcarry the size the device was built with, which is what lets gamescope's-W/-Hfind a mode instead of falling back.2.
shim: answer the pool buffer's row stridegralloc pads scan-out rows for widths it does not like (
1024 px → 5120 Bon Adreno) while aLINEARVulkan image is laid out atwidth * 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 itNo behaviour change; it removes the last places that described the pre-split model.
docs/10-fps-limit.md,docs/11-refresh-model.mdanddocs/15-fps-limiter-pacing-and-vsync.mdall still said the connector exposes one1280x960@1200mode for every-r, and that clients are told a fabricated 1200 Hz — the mode now carries the panel's rate andSESSION_REFRESH_MHZis 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 newlayer_rect, connector golden and EDID detailed-timing tests).gndrm-uapiand--target aarch64-linux-android -p gndrm-android; zero warnings from the shim build.check-exports.shPASS —libgamenative_drm.sostill exports only the three Khronos loader entry points (20 JNI exports pinned),libgndrm_shim.soat 14.gamescopebinary showsgamenative_drm_scanout_pitchasWEAK UND,GLOBAL FUNCin the shim.1280x960@120Hz, Adreno 740 + Turnip 26.3.0-devel), six sessions across container sizes, Upscaling Resolutions and filters:1024x768,1280x720and1280x960sessions each select that exact mode;1920x1080requested into a1280x960window is refused and the session stays at the window's size;output image 1024x768 at the pool's row stride 5120 (tight 4096)and render coherently, where the same geometry used to shear;geomBufferSize=[0 0 1024 768], bufferstride:5120 bytes, layer transform1.2500→1280x960.The full device matrix (container screen × Upscaling Resolution × filter, with screenshots) is in utkarshdalal/GameNative#2283.
Companion PRs
A51, the gamescope side: create the scan-out image at the pool's row stride (consumes the shim symbol added here).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.