daemon: stamp a latch-grid client's flips on the panel latch grid (GNLG)
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):
commit.rsapply_commitbuilds theDRM_EVENT_FLIP_COMPLETErecord.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 onuPendingFlipCount.- The serve loop writes the record to gamescope's
g_DRM.fd;GetNextVBlankthen works fromstamp + 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-in | drmSetClientCap(fd, GNDRM_CLIENT_CAP_LATCH_GRID = 0x474E4C47 /* "GNLG" */, 1). Card node only. A render node, or a daemon without this change, answers EINVAL. |
| Stamp | anchor + 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_LEAD | 2 ms by default. GNDRM_GS_LATCH_LEAD_US (read in the app process) overrides it, 0 to 50 000 µs. |
| Before any latch sample | Walks at the panel period from nativeSetPanelRefreshHz; if that is unknown too, at the 1200 Hz interval. |
| Period to gamescope | ConVar 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 lead | While 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 cap | Same 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 toPin 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.
anchorandPcome from thevsynctracker, which samplesASurfaceTransactionStats_getLatchTimeon every publish completion, follows withinerr/8per 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);VblankTimelinemoved here,claim= legacy walk,claim_on_grid= new path.gndrm-daemon/src/lib.rs:Device.cap_latch_grid, card-onlyioctl_set_client_caparm (logs on/off once),Dropclears the client count,CommitJob.latch_grid,commit_worker_looppicksclaim_on_gridorclaimand reports the claimed latch.gndrm-daemon/src/commit.rs: passeslatch_gridinto the job.gndrm-daemon/src/objects.rs:SESSION_REFRESH_MHZdoc (timeline only for clients without the cap).gndrm-android/src/vsync.rs:samplefeedslatch::set(anchor, period); freezes the lead loop and pushes the fixed lead while a GNLG client is connected;Tracker::trackgainsfixed_lead.gndrm-android/src/lib.rs:set_panel_refresh_hzalso callslatch::set_periodand postsgn_panel_refresh <Hz>.gndrm-android/src/injector.rs:"gn_panel_refresh"inALLOWED_COMMANDS.gndrm-android/src/asc.rs: per-registrationclaimed_ns/claimed_for_applied_ns,LAST_PUBLISHED,note_claim, andlatch_misses::recordinon_complete(late = latched more than P/2 after its claim, early = more than P/2 before). Logsvsync: gamescope latch misses N/M early E lead=Xus period=Yusevery 10 s withGNDRM_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_CAProw.
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-androidon 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.
- Daemon lib: 64 tests, 10 new in
cargo clippy --all-targets, zero warnings, foraarch64-linux-androidand 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 hidden | X11 | Shipped gamescope (A52, -r 1200) | Fix 1 | Fix 1 + shim fix |
|---|---|---|---|---|
| fps, SF | 59.89 | 47.05 | 46.91 | 59.13 (47 s segment) |
| fps, PowerMetrics | 59.9 | 47.1 | 46.9 | 59.97 (59.3-60.3) |
| 2x / 3x panel-period share, SF | 95.2 / 2.5 % | 41.5 / 41.3 % | 43.3 / 45.6 % | 94.2 / 2.7 % |
| frame interval p95 / p99, SF, ms | 16.7 / 25.0 | 33.3 / 41.6 | 33.3 / 33.3 | 16.7 / 25.0 |
| game process CPU, % of a core | 176.1 | 127.7 | 128.4 | 135.0 (fix 1 + shim + cursor plane run, 60 s) |
| gamescope CPU, % of a core | — | 8.1 | 5.9 | 6.6 (same run) |
| gamescope wakeups/s | — | 1723 | 623 | not captured (segment cut) |
| gamescope-xwm voluntary wakeups/s | — | 1411 | 424 | ~520 (same run) |
| GPU busy %, PowerMetrics | 79.9 | 60.4 | 59.8 | 77.3 |
| CPU temp °C, PowerMetrics mean | 76.5 | 69.8 | 66.3 | 73.7 |
| Cap 30, cursor hidden | X11 | Shipped gamescope (A52, -r 1200) | Fix 1 | Fix 1 + shim fix |
|---|---|---|---|---|
| fps, SF | not run | 30.00 | 30.00 | not run |
| 2x / 3x / 4x share, SF | not run | 0.1 / 7.0 / 85.4 % | 0.1 / 5.3 / 88.9 % | not run |
| gamescope CPU, % of a core | — | 10.2 | 4.2 | not run |
| gamescope wakeups/s | — | 1510 | 401 | not run |
| gamescope-xwm voluntary wakeups/s | — | 1281 | 269 | not run |
| GPU busy %, PowerMetrics | not run | 38.5 | 38.1 | not run |
| CPU temp °C, PowerMetrics mean | not run | 61.9 | 63.9 | not 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), gamescopedrm: 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, soGS_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_refreshreaches an old gamescope as an unknown ConVar and fails silently. - Old daemon + new gamescope: the cap gets
EINVALand 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_LEADahead when the event arrives (old timeline: at most 0.83 ms). The gamescope PR checked every reader; none subtracts the stamp from now. GS_LEADis an estimate. The latch-miss line measures it; adapting the lead automatically is a follow-up.GNDRM_GS_LATCH_LEAD_USis 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.