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

Merged
JO
11d ago
+730-211

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

  • utkarshdalal/GameNative#2283 — the app side: hands the Upscaling Resolution to the daemon as the scan-out size, defaults it to Off.
  • GameNative/gamescope#20 — 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.