gamescope pacing on the display's real vsync: Choreographer flip events, FIFO under a cap, present wait from wp_presentation
Base master (efd50ab), head real-vsync-fifo-present-wait (ee1efa2). Three commits (one in the daemon, two in the shim) and a one-line docs fix. Supersedes #43.
Problem
Measured on a Retroid Pocket 6 (Android 13, Adreno 740, 1080x1920 panel in its 60 Hz mode), gamescope session, cap 60.
Two free-running 60.000 Hz clocks against a 59.97-59.99 Hz panel.
- The daemon's flip events were stamped on a walk of its own:
1e12 / mode_refresh_mhz()from CLOCK_MONOTONIC, exactly 16 666 666 ns on a "60 Hz" mode. gamescope re-arms its composite timer from every flip event (DRMBackend.cpppage_flip_handler→MarkVBlank;vblankmanager.cpp: wakeup = stamp + interval − draw time − red zone). So the stamps decide where in the display's cycle gamescope commits.- The panel latches at 59.97-59.99 Hz, so the walk slid across SurfaceFlinger's latch about every half minute.
- While gamescope's commit sat on the latch edge, about 1 ms of post jitter put two buffers into one latch window, and SurfaceFlinger dropped the older one. The HUD read 58.7-59.3 fps and SurfaceFlinger 54-57.
- The shim's acquire gate released frames on a third clock, exactly
1e9 / Nns. It also beat against the panel (57-59 fps at cap 60). And because the container'sMESA_VK_WSI_PRESENT_MODE=mailboxdecided the mode declared to gamescope, every commit was mailbox, which gamescope's limiter never holds. A frame released by the gate had one period to reach gamescope's latch, with no queue behind it to absorb a late frame.
35 fps with VSync on. Making the shim declare FIFO (commit 2) exposed a second problem. A guest that waits on every frame (DXVK with in-game VSync on: FIFO, vkWaitForPresentKHR, frame latency 1) ran at 35.3 fps at cap 60, with 364 of 526 intervals two vblanks long. The shim answered the present wait from wl_buffer.release. Under FIFO that comes when the next frame is latched, a vblank after this one was shown, so every frame started a vblank late.
What the native stacks do
- A KMS driver stamps the page-flip event with the hardware vblank the flip took. That vblank is both the point a flip must be queued by and the start of scan-out. Nothing on a PC drifts against the display, because the display is the only clock.
- gamescope's WSI layer (
VkLayer_FROG_gamescope_wsi.cpp) always creates the driver swapchain as MAILBOX and implements FIFO itself. Per present, it declares the app's own mode throughgamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter is engaged. gamescope latches one FIFO commit per limited vblank (steamcompmgr.cpphandle_done_commits_xwayland). The client is paced only bywl_buffer.release.MESA_VK_WSI_PRESENT_MODEacts on Mesa below the layer and never changes what is declared. - Mesa's Wayland WSI answers
vkWaitForPresentKHRfromwp_presentation. Each present that carries an id requests awp_presentation_feedback, andpresentedordiscardedsignals that id. gamescope offerswp_presentation(version 1, CLOCK_MONOTONIC). It sendspresentedfor the commit it latched, with the refresh it targets, anddiscardedfor a commit it replaced or never showed.
The shim already plays the WSI layer's part on gamescope's override route: its own wl_surface, wrapped in a gamescope_swapchain, with slots reused only on release. This PR makes the daemon act like the KMS driver and makes the shim declare and wait the way the layer and Mesa do.
Commits
1. daemon: flip events carry the display's real vsync, read from Choreographer
- The vsync source. A thread with an
ALooperkeeps one Choreographer callback posted per vsync while a session runs, and parks between sessions. It usesAChoreographer_postVsyncCallbackon API 33+ andpostFrameCallback64on 29-32. - The period is fitted by least squares over the last 32 vsyncs. Each step is counted in whole periods, so a missed callback counts as two steps, not one long one. A live mode switch re-seeds the fit. The mode's integer rate never enters it: a "60 Hz" panel that latches at 59.973 Hz is stamped at 59.973 Hz.
- Which instant is stamped. KMS has one instant per cycle. Android splits it into SurfaceFlinger's latch and the present one or more periods later. gamescope uses the stamp as the point its next commit must beat by its red zone, so the stamps sit on the latch grid:
- the base is the preferred frame timeline's deadline;
- it is moved by where SurfaceFlinger actually latched the daemon's publishes (
latch-after-deadline, smoothed fromASurfaceTransactionStats_getLatchTime). - A present-time stamp would aim gamescope's margin at an instant whose phase to the latch is the compositor's work duration.
- The lead. The stamp is that latch minus a measured lead. gamescope's 1.65 ms red zone was sized for a kernel commit. Here a commit still crosses the commit worker and a binder apply, with about 1 ms of spread.
- Stamped on the deadline itself, the applies landed 1.0 / 2.1 / 4.5 ms (p10 / p50 / p90) ahead of the latch, and the tail crossed it: SurfaceFlinger showed 58.65 fps on a 59.967 Hz panel.
- A quantile loop holds the 10th percentile of
latch − applyat 3 ms (30 % of a period on faster panels). It starts at 2 ms and is capped at P/3. GNDRM_GS_LATCH_LEAD_USin the app process's environment fixes the lead instead.- Cost: about 2 ms more between gamescope's commit and the latch.
- The
VsyncNote(frame generation's latch grid) now carries the measured period while Choreographer is locked. It is pushed on a period move above 1 µs. - Fallbacks. On API 29-32, the measured period is used with the latch grid's phase. With Choreographer silent for a second, the latch grid alone is followed at the mode's period. Before either source reports, the old walk runs.
- Ordering. A claim is never earlier than the previous one and never repeats an instant.
2. shim: declare FIFO while a cap is set, as gamescope's WSI layer does; gamescope's limiter is the cap
On the override route, the mode declared per present is:
- mailbox under frame generation, unchanged: generated commits carry no declaration, and a real frame declared FIFO would queue behind them;
- FIFO while the daemon's
FrameCapNotecarries a cap, which is the same value the app hands gamescope's limiter; - otherwise the guest's own mode.
Other effects:
MESA_VK_WSI_PRESENT_MODEno longer decides this route. It still applies on the opt-in X11 Present route.- The extra mailbox slot follows the guest's request.
- The acquire time gate now runs only while frame generation sets the source rate (the app disarms gamescope's limiter then) and on the X11 Present route.
- One ungated line every 10 s reports the declared and requested modes and counts.
3. shim: answer the present wait from gamescope's presentation feedback
- The request.
wp_presentationis bound with the gamescope swapchain factory. A FIFO commit that carries a wait requests a feedback beforewl_surface.commit, and the id is registered as pending first. - The answer.
presentedanswers the wait at the reported time;discardedanswers it at once. While a feedback is pending, neither the release nor the two-period grace answers. A feedback that never comes is answered 100 ms after the commit. - An id is answered once, whichever path gets there first.
- Unchanged:
- without the global, or off the override route, the release and the grace answer as before;
- the mailbox/immediate answer-at-commit rule (#37);
- frame generation's commits, which carry no feedback.
- The present-mode line gains
feedback= presented= discarded= fallback=.
Docs: docs/15 §1, §3 and §4, docs/10, docs/11 and docs/README change with the commits they describe. A fourth commit fixes one sentence in docs/15 §4: the note's measured period serves frame generation.
Review fixes (commits 5-7)
- daemon: hold the vsync source to the NDK's contract and the mode's rate.
AChoreographer_postVsyncCallback/postFrameCallback64returnvoid: declared so, no return check.- The thread never exits after publishing its looper: a callback missing for 1 s is posted once more (logged once) while the flip events fall back to the latch grid. The looper is held with
ALooper_acquire, and any end of the thread clearsLOOPER/SPAWNED, sostart()never wakes a freed looper. - The period fit is bounded to 2 % of the mode's advertised period; a starved callback thread counts two periods per step instead of locking at twice the period. A new mode (
nativeSetPanelRefreshHz) starts the fit again. - Callbacks stop while the session is suspended.
GNDRM_GS_LATCH_LEAD_USis clamped to half a period.
- shim: key present-wait state by swapchain; no declaration without a gamescope swapchain. Pending feedback, commits, answers and the release path are keyed by (id, swapchain), the feedback listener carries a per-request token, and destroying a swapchain drops its state, so a recreated swapchain (ids restart at 1) is never answered by the old one's feedback.
set_present_modeis skipped whengamescope_swapchainis null (guest-surface route). - docs: behaviour changes, and when gamescope answers presentation feedback.
Measured
Retroid Pocket 6 (Android 13, Adreno 740), cap 60, 60 Hz mode, same scene per game, frames counted at SurfaceFlinger (dumpsys SurfaceFlinger --latency).
| Game | Before | After |
|---|---|---|
| Dave the Diver (DXVK), in-game VSync on | 35.3 fps (two-vblank cadence, 364 / 526 intervals doubled) | 59.95 fps at a 59.99 Hz panel; 0 discarded and 0 fallback present-wait answers over 120 s (7195 presented) |
| Dave the Diver, VSync off | 95 % of vsyncs got a new frame from the daemon | 98.8 % |
| SteamWorld Dig 2 (GL via Xwayland) | 57.1 fps (periodic drop zones) | 59.66 fps, drop zones gone |
- Before ran the master daemon (60.000 Hz walk) and the shipped shim (acquire gate, mailbox declared from the env).
- After ran this branch's first four commits: daemon
libgamenative_drm.soe8d23581…, shimb9aedd01…. The review-fix commits (5-7) have not been measured on device yet; payload 20261102.3 carries them. - Caveat: the "after" runs also carried two other changes that are not part of this PR: a later daemon commit with timing diagnostics, and a gamescope/app priority change. The three commits here are the ones that moved these numbers. The extras have not been measured on their own.
The SteamWorld Dig 2 residual (about 1 % doubled intervals) is not fixed by this PR and is under separate investigation.
From the VSync-on run's daemon check lines: margin p10 held at +3.0 ms, late=0 in every window, latch-after-deadline +0.46 to +0.70 ms.
What does not change
- Frame generation (LSFG).
- It keeps mailbox declarations, the shim's time gate as the source-rate limiter, and its own latch-grid commits.
- The
VsyncNotekeeps its wire layout. Its period is now the measured one, which is closer to the display. - Not re-measured on device in this round.
- Mailbox and immediate guests still get their present wait answered at the commit (#37).
- Caps that are not the display's rate over a whole number run at gamescope's divisor rate. gamescope's fixed-rate limiter passes every
floor(refresh / target)-th vblank, so 40 or 45 on 60 Hz runs at 60, and 50 on 120 Hz runs at 60. That is upstream gamescope's behaviour and is left to it for now. Before this PR the shim's time gate held those caps. - The mode the connector advertises is still the display's integer rate.
Behaviour changes
- FIFO games without a cap are now vsynced. Before, the container's
MESA_VK_WSI_PRESENT_MODE=mailboxdefault was declared for every swapchain; a guest that asks for FIFO now gets FIFO, as on a PC. - Mailbox and immediate games keep their requested mode when no cap is set, and are declared FIFO while one is.
MESA_VK_WSI_PRESENT_MODEis ignored on the gamescope route, including a value the user picked in the driver settings. It still applies to the X11 Present route. On a PC it only reaches Mesa below gamescope's layer.- Non-divisor caps run at gamescope's divisor rate:
floor(refresh / target)vblanks, so 40 or 45 on 60 Hz runs at 60, and 50 on 120 Hz at 60. - FIFO queue depth: a guest faster than the cap fills its free slots, so up to
slots − 1frames can wait in gamescope's FIFO list. At low caps that is up to that many limited periods of input latency, as on a PC. Present-wait guests are bounded by their frame latency. - Frame generation turned on mid-session switches a FIFO guest to the mailbox discipline with the slot count it was created with (
minImageCount + 1, one fewer than when created under generation) until the guest recreates its swapchain. - Toggling frame generation sends the cap and the generation settings separately. Between the two, the guest's own mode is declared with no gate, so a mailbox/immediate guest can run uncapped for a few frames.
Known limitations
- Queue latency. Under FIFO with a cap, a guest faster than the cap queues up to
minImageCount + 1frames in gamescope, as on a PC. That can add up to about two limited periods of input latency against the old zero-depth gate. A guest that uses present wait (DXVK with VSync on) is bounded by its frame latency. - Uncapped FIFO guests are now vsynced at the refresh, as on a PC, instead of rendering uncapped into a mailbox.
- The lead adds about 2 ms between gamescope's commit and the latch.
- Choreographer's frame deadline exists on API 33+ only. API 29-32 uses the latch grid's phase, and that path has not been run on device in this round.
- Presentation feedback when the game is not shown. gamescope sends
presentedevery vblank for the last latched commit of every window, visible or not (steamcompmgr.cpp, the comment beforehandle_presented_xwayland: "we return presented for any window not visible … for forward progress"), anddiscardedfor a replaced or destroyed commit (commit_t::~commit_t) or a window that is gone. An unfocused game, or one behind a dialog, is still answered once per vblank. Mesa blocks until the event comes; the shim's 100 ms answer for a feedback that never comes stays as a backstop for a compositor fault and is counted infallback=. - The cap now rests on gamescope's limiter. The daemon keeps the cap in an atomic and the injector re-sends
set_app_target_refresh_cycleon every (re)connection and whenever the value changes (checked every 100 ms), and gamescope's limiter applies to every non-Steam, non-overlay window, not per focus. If the shim'sFrameCapNotelands before the injector's first send, the FIFO-declared game runs at the refresh rate (vsynced, not uncapped) for up to ~100 ms after gamescope's control socket connects.
Tests
cargo test --workspace --exclude gn-utilsinrust:1-bookworm(withlibwayland-dev libdrm-dev libvulkan-dev libxcb-{dri3,present,sync}-dev): all pass.- Covers gndrm-daemon (95 unit, 36 golden, 8 diag), gndrm-android (lib + dump/layout/log_policy/vsync) and gndrm-uapi (19 + 2 ABI ground truth, with
/usr/include/drm→ libdrm headers). - Shim crate:
cargo test -p adrenotools_gamescope --lib, 71 passed. gndrm-daemon unit tests: 99.
- Covers gndrm-daemon (95 unit, 36 golden, 8 diag), gndrm-android (lib + dump/layout/log_policy/vsync) and gndrm-uapi (19 + 2 ABI ground truth, with
- New tests cover:
- the period fit, rate changes, pauses, source selection and staleness;
- the claim rules, the lead loop and the report;
- the live present-mode choice;
- feedback presented / discarded / pending-blocks-release / safety timeout;
- review fixes: a starved callback thread at 120 Hz (32+ double-spaced vsyncs) keeps 8.33 ms, a fit outside the mode band is rejected once, a new mode restarts the fit, the fixed lead clamp, and a recreated swapchain's id 1 is not answered by the old swapchain's feedback or release.
- Reproducibility. NDK 27.3, rustc 1.98.1,
--release --target aarch64-linux-android:- At this head, the daemon built together with the shim (
-p gndrm-android -p adrenotools_gamescope,SOURCE_DATE_EPOCH= head commit time) is9d361968…, reproduced byte-identical after a clean rebuild. Built alone (-p gndrm-android) the hash differs only by cargo's feature unification across the two crates. - The shim at this head is
9e181cd7…(build string2026110208325831f1); it embeds a build stamp, so it is not bit-reproducible.
- At this head, the daemon built together with the shim (
How to test
Use a gamescope session with a cap of 60 on a 60 Hz panel, with payload 20261102.3 and the app build that carries the daemon. In logcat, all of these lines are ungated:
- Daemon lock line, once per lock:
gndrm-daemon: vsync: flip events on the display's vsync: source=choreographer-deadline period=16676.286us (59.965 Hz, mode 60.000 Hz) latch-vs-grid=…
The period should be the panel's real rate, not 60.000. - Daemon check line, every 10 s:
gndrm-daemon: vsync: check source=choreographer-deadline period=… publishes=… vsyncs=600 late=0 pushed=0 margin p10/p50/p90=+3.0ms/… lead=… latch-after-deadline=…
Expectpublishesclose tovsyncs,late0-1, and margin p10 about +3.0 ms. - Shim present-mode line, every 10 s:
gn-vkshim: present mode: declared=fifo requested=… route=gamescope cap=60 lsfg=0 … feedback=on presented=… discarded=0 fallback=0
declared=fifois the cap in force, andpresentedshould climb withcommits. - The rate:
dumpsys SurfaceFlinger --latency <layer>over 30 s should read the panel's rate (about 59.97-59.99 on the RP6). Turning in-game VSync on in a DXVK game should no longer halve the rate. - Regression checks:
- frame generation x2 at cap 40:
declared=mailbox lsfg=1, 80 fps; - an uncapped session:
cap=reads the no-cap sentinel (FPS_UNCAPPED), anddeclared=is the guest's own mode.
- frame generation x2 at cap 40: