daemon: stamp a latch-grid client's flips on the panel latch grid (GNLG)

UT
10d ago
+841-78

daemon: stamp a latch-grid client's flips on the panel's latch grid

Branch flip-stamps-on-the-panel-latch-grid (0630e1a), base master (52ed43f). This is the daemon half of fix 1: gamescope's vblank moves onto the panel latch grid, and the compositor sleeps when idle.

Companion PRs: gamescope vblank-on-panel-latch-grid (A53/A54), app gamescope-panel-rate-vblank (-r <panel Hz>, ships this daemon in jniLibs). The cap-60 → 47 fps fix is a separate shim PR on this repo (present-wait-answered-at-commit-in-mailbox); this PR does not change the frame rate.

Problem

Today every flip stamp comes from VblankTimeline::claim: a 1200 Hz walk (833 333 ns) whose phase is set by the first flip and has nothing to do with SurfaceFlinger's latch. gamescope re-anchors its whole vblank grid on that stamp (page_flip_handler → MarkVBlank), so with -r 1200 it wakes about 1200 times/s, and at a panel-rate -r it would paint at an arbitrary, drifting phase against the 8.33 ms latch.

How the stamp reaches gamescope (checked against the code):

  1. commit.rs apply_commit builds the DRM_EVENT_FLIP_COMPLETE record.
  2. commit_worker_loop (lib.rs) publishes to SurfaceControl, then stamps and queues the event right away. It never waits for SurfaceFlinger's completion, deliberately: gamescope blocks its present loop on uPendingFlipCount.
  3. The serve loop writes the record to gamescope's g_DRM.fd; GetNextVBlank then works from stamp + interval - offset.

SET_CLIENT_CAP goes verbatim over the RPC channel (gndrm_shim.c, DRM_IOCTL_SET_CLIENT_CAP with GET_CAP), so the 64-bit cap value passes through untouched and no shim rebuild is needed.

The change

Opt-indrmSetClientCap(fd, GNDRM_CLIENT_CAP_LATCH_GRID = 0x474E4C47 /* "GNLG" */, 1). Card node only. A render node, or a daemon without this change, answers EINVAL.
Stampanchor + k*P - GS_LEAD: the first such point at or after the moment the publish returned, and never earlier than the previous stamp + P/2.
GS_LEAD2 ms by default. GNDRM_GS_LATCH_LEAD_US (read in the app process) overrides it, 0 to 50 000 µs.
Before any latch sampleWalks at the panel period from nativeSetPanelRefreshHz; if that is unknown too, at the 1200 Hz interval.
Period to gamescopeConVar gn_panel_refresh <Hz> through gamescope_private.execute, added to ALLOWED_COMMANDS and posted on every nativeSetPanelRefreshHz. With no injector running it is dropped and the launch -r applies.
LSFG leadWhile a GNLG client is connected the lead loop is frozen and the note pushes min(P/2 + GS_LEAD + 3 ms, P + GS_LEAD): 9.17 ms at 120 Hz, 13.33 ms at 60 Hz.
Clients without the capSame timeline as before, byte for byte. The legacy walk moved into latch.rs unchanged; the existing golden test still checks (1000, 500) then (1000, 1333).

What gamescope sees with the cap set:

  • Each flip stamp is the latch that frame targets, minus GS_LEAD, usually 0 to P in the future when the event arrives. gamescope's next wake is then a fixed distance before the next latch, every period.
  • A publish that returns inside the lead window is stamped on the following latch, so gamescope does not wake for a latch the frame cannot make.
  • A second flip in the same period (for example a cursor repaint) never claims the same latch: the half-period floor moves it to the next grid point, and the grid never jumps backwards.
  • anchor and P come from the vsync tracker, which samples ASurfaceTransactionStats_getLatchTime on every publish completion, follows within err/8 per sample and rephases after 4 off-grid samples.

Files

  • gndrm-uapi/src/drm.rs: GNDRM_CLIENT_CAP_LATCH_GRID.
  • gndrm-daemon/src/latch.rs (new): the grid (set, set_period, get, lead_ns/parse_lead_us, set_client/client_connected, presenter_lead_ns, claim sink, classify, next_grid_point); VblankTimeline moved here, claim = legacy walk, claim_on_grid = new path.
  • gndrm-daemon/src/lib.rs: Device.cap_latch_grid, card-only ioctl_set_client_cap arm (logs on/off once), Drop clears the client count, CommitJob.latch_grid, commit_worker_loop picks claim_on_grid or claim and reports the claimed latch.
  • gndrm-daemon/src/commit.rs: passes latch_grid into the job.
  • gndrm-daemon/src/objects.rs: SESSION_REFRESH_MHZ doc (timeline only for clients without the cap).
  • gndrm-android/src/vsync.rs: sample feeds latch::set(anchor, period); freezes the lead loop and pushes the fixed lead while a GNLG client is connected; Tracker::track gains fixed_lead.
  • gndrm-android/src/lib.rs: set_panel_refresh_hz also calls latch::set_period and posts gn_panel_refresh <Hz>.
  • gndrm-android/src/injector.rs: "gn_panel_refresh" in ALLOWED_COMMANDS.
  • gndrm-android/src/asc.rs: per-registration claimed_ns / claimed_for_applied_ns, LAST_PUBLISHED, note_claim, and latch_misses::record in on_complete (late = latched more than P/2 after its claim, early = more than P/2 before). Logs vsync: gamescope latch misses N/M early E lead=Xus period=Yus every 10 s with GNDRM_LOG_DAEMON, otherwise once a minute on the gated info log.
  • docs/11-refresh-model.md: new section "Latch-grid flip stamps (GNLG)". docs/02-gamenative-drm.md: SET_CLIENT_CAP row.

Not touched: the Android 16 GLES BGRA/RGBA scan-out layout rule, the cursor-plane rules, the shim.

Tests

  • cargo test -p gndrm-android (macOS host): 11 vsync tests, 3 new (fixed lead from the first sample, fixed lead freezing the loop while pacing, switch to fixed and back).
  • cargo test -p gndrm-daemon -p gndrm-uapi -p gndrm-android on Linux (rust:1-bookworm): all green.
    • Daemon lib: 64 tests, 10 new in latch::tests (grid point, first claim, same-period second flip, half-period floor across an anchor shift, late publishes skipping whole periods, walk before the grid is known, unchanged legacy claim, presenter lead, env parse, classify).
    • Golden: 34 tests, 2 new: latch_grid_client_flips_are_stamped_on_the_panel_grid, latch_grid_cap_is_card_only.
    • uapi ABI ground truth: 2 tests.
  • cargo clippy --all-targets, zero warnings, for aarch64-linux-android and Linux host.

Device results

Setup, every column. Retroid Pocket 6 (Adreno 740 class, 1080x1920 panel at 120 Hz, landscape), Dave the Diver (1868140), DXVK 2.7.1-gplasync, proton-10.0-arm64ec-2, container 1280x720, FPS limiter as listed, the same boat-deck scene. Segments are 180 s (launch 4: 60-120 s). "SF" = the game's SurfaceFlinger layer histogram; "PowerMetrics" = the app's own counter (5 s samples). gamescope-xwm is the steamcompmgr thread.

Cap 60, cursor hiddenX11Shipped gamescope (A52, -r 1200)Fix 1Fix 1 + shim fix
fps, SF59.8947.0546.9159.13 (47 s segment)
fps, PowerMetrics59.947.146.959.97 (59.3-60.3)
2x / 3x panel-period share, SF95.2 / 2.5 %41.5 / 41.3 %43.3 / 45.6 %94.2 / 2.7 %
frame interval p95 / p99, SF, ms16.7 / 25.033.3 / 41.633.3 / 33.316.7 / 25.0
game process CPU, % of a core176.1127.7128.4135.0 (fix 1 + shim + cursor plane run, 60 s)
gamescope CPU, % of a core—8.15.96.6 (same run)
gamescope wakeups/s—1723623not captured (segment cut)
gamescope-xwm voluntary wakeups/s—1411424~520 (same run)
GPU busy %, PowerMetrics79.960.459.877.3
CPU temp °C, PowerMetrics mean76.569.866.373.7
Cap 30, cursor hiddenX11Shipped gamescope (A52, -r 1200)Fix 1Fix 1 + shim fix
fps, SFnot run30.0030.00not run
2x / 3x / 4x share, SFnot run0.1 / 7.0 / 85.4 %0.1 / 5.3 / 88.9 %not run
gamescope CPU, % of a core—10.24.2not run
gamescope wakeups/s—1510401not run
gamescope-xwm voluntary wakeups/s—1281269not run
GPU busy %, PowerMetricsnot run38.538.1not run
CPU temp °C, PowerMetrics meannot run61.963.9not run

The fix 1 + shim fix column: SF fps and histogram from a 47 s cap-60 segment (launch 4); PowerMetrics 59.97 fps over 143 samples; game/gamescope CPU from the 60 s hidden-cursor segment of the cursor-plane run (fix 1 + shim + cursor layer, cursor hidden), where the SF fps was 60.00. Temperatures are loose: the X11 run started hot (CPU zone 92.7 °C at segment start).

  • The cap was accepted on the device: daemon vblank: latch-grid flip stamps on (lead 2000us), gamescope drm: vblank: daemon latch-grid stamps on (GNLG accepted, grid 120000 mHz), launch line -r 120.
  • The latch-miss summary was not captured. It needs GNDRM_LOG_DAEMON, which was off, so GS_LEAD = 2 ms is unmeasured.
  • Not exercised: a 120 ↔ 60 Hz switch, a 60 Hz panel, LSFG x2 with the frozen lead, an old gamescope against this daemon.

Compatibility

  • New daemon + old gamescope: the cap is never set, so the 1200 Hz timeline is unchanged. gn_panel_refresh reaches an old gamescope as an unknown ConVar and fails silently.
  • Old daemon + new gamescope: the cap gets EINVAL and the stamps stay on the 1200 Hz timeline.
  • Ships in the app's jniLibs (libgamenative_drm.so), so it needs an app build (the app PR carries it).

Risks

  • Future timestamps: up to P + GS_LEAD ahead when the event arrives (old timeline: at most 0.83 ms). The gamescope PR checked every reader; none subtracts the stamp from now.
  • GS_LEAD is an estimate. The latch-miss line measures it; adapting the lead automatically is a follow-up.
  • GNDRM_GS_LATCH_LEAD_US is read in the app process, so a container env does not reach it.
  • Integer-Hz period (120 vs a true 119.88) drifts about 8 µs per frame between tracker updates; the tracker updates on every completion.
  • The grid is process-global: a new session's first stamps use the previous session's anchor until the first completion arrives. No worse than the old arbitrary phase.
  • Fixed latch spacing is assumed. A SurfaceFlinger frame-rate override or VRR mode that varies it would defeat the tracker.