Skip to content

shim: hold the acquire cadence at the cap period - #34

Merged
joshuatam merged 2 commits into
fix/gamescope-resolution-scanoutfrom
acquire-cap-on-the-latch-grid
Oct 30, 2026
Merged

joshuatam merged 2 commits into
fix/gamescope-resolution-scanoutfrom
acquire-cap-on-the-latch-grid

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes

The ICD shim's frame cap scheduled the next acquire at max(prev, now) + period. The condvar wait for prev always returns a hair after the deadline, so now won every frame and that overshoot was added to every period: the cap ran below its target by the wake-up latency per frame, and by more on a loaded or throttled device.

next_acquire_after now steps prev + period while the guest keeps up, and only re-bases on now once the guest is a whole period behind (no debt out of a stall). The two existing cases in the unit test are unchanged by the new rule; a third covers the overshoot.

Verification

Odin 3 (8 Elite, panel at 60 Hz), JT's stack (app #2283 + active-refresh report, payload 20261030.2) with only the ICD swapped, Dave the Diver title menu (renders above 60 uncapped), cap 60, two 30 s windows each:

ICD commits/s mean interval
shipped 20261030.2 59.78 16.729 ms
this PR 60.00 16.667 ms

The displayed rate from dumpsys SurfaceFlinger --latency stays at 59.4–59.8 in both: a handful of presents per 30 s still take two vsyncs downstream, which this change does not address.

Scope: 24 lines in icd_shim/src/scanout.rs. JT's #32 does not touch icd_shim/, so this applies identically on master or on fix/gamescope-resolution-scanout; it was tested on the latter's payload.

@utkarshdalal
utkarshdalal force-pushed the acquire-cap-on-the-latch-grid branch from e441f7e to c72dfd1 Compare October 30, 2026 14:45
@utkarshdalal utkarshdalal changed the title shim: hold the acquire cadence at the cap period, and on the panel's latch grid shim: hold the acquire cadence at the cap period Oct 30, 2026
@utkarshdalal
utkarshdalal force-pushed the acquire-cap-on-the-latch-grid branch from c72dfd1 to b545713 Compare October 30, 2026 14:53
@utkarshdalal
utkarshdalal changed the base branch from master to fix/gamescope-resolution-scanout October 30, 2026 14:53
@joshuatam
joshuatam merged commit dc2a855 into fix/gamescope-resolution-scanout Oct 30, 2026
@joshuatam
joshuatam deleted the acquire-cap-on-the-latch-grid branch October 30, 2026 15:31
joshuatam added a commit that referenced this pull request Oct 30, 2026
…s its row stride (#32)

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

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.

* 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. 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: the mode's rate is the panel's, and the EDID follows it

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 (#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 <utkarsh.dalal@toptal.com>

---------

Co-authored-by: Utkarsh Dalal <dalal.utkarsh@gmail.com>
Co-authored-by: Utkarsh Dalal <utkarsh.dalal@toptal.com>
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