Skip to content

gamescope pacing on the display's real vsync: Choreographer flip events, FIFO under a cap, present wait from wp_presentation - #44

Merged
utkarshdalal merged 7 commits into
masterfrom
real-vsync-fifo-present-wait
Nov 2, 2026
Merged

utkarshdalal merged 7 commits into
masterfrom
real-vsync-fifo-present-wait

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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.cpp page_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 / N ns. It also beat against the panel (57-59 fps at cap 60). And because the container's MESA_VK_WSI_PRESENT_MODE=mailbox decided 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 through gamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter is engaged. gamescope latches one FIFO commit per limited vblank (steamcompmgr.cpp handle_done_commits_xwayland). The client is paced only by wl_buffer.release. MESA_VK_WSI_PRESENT_MODE acts on Mesa below the layer and never changes what is declared.
  • Mesa's Wayland WSI answers vkWaitForPresentKHR from wp_presentation. Each present that carries an id requests a wp_presentation_feedback, and presented or discarded signals that id. gamescope offers wp_presentation (version 1, CLOCK_MONOTONIC). It sends presented for the commit it latched, with the refresh it targets, and discarded for 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 ALooper keeps one Choreographer callback posted per vsync while a session runs, and parks between sessions. It uses AChoreographer_postVsyncCallback on API 33+ and postFrameCallback64 on 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 from ASurfaceTransactionStats_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 − apply at 3 ms (30 % of a period on faster panels). It starts at 2 ms and is capped at P/3.
    • GNDRM_GS_LATCH_LEAD_US in 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 FrameCapNote carries 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_MODE no 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_presentation is bound with the gamescope swapchain factory. A FIFO commit that carries a wait requests a feedback before wl_surface.commit, and the id is registered as pending first.
  • The answer. presented answers the wait at the reported time; discarded answers 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:
  • 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 / postFrameCallback64 return void: 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 clears LOOPER/SPAWNED, so start() 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_US is 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_mode is skipped when gamescope_swapchain is 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.so e8d23581…, shim b9aedd01…. 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 VsyncNote keeps 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 (icd_shim: answer the present wait at commit under mailbox/immediate (47 -> 60 fps at cap 60) #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=mailbox default 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_MODE is 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 − 1 frames 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 + 1 frames 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 presented every vblank for the last latched commit of every window, visible or not (steamcompmgr.cpp, the comment before handle_presented_xwayland: "we return presented for any window not visible … for forward progress"), and discarded for 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 in fallback=.
  • 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_cycle on 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's FrameCapNote lands 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-utils in rust:1-bookworm (with libwayland-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.
  • 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) is 9d361968…, 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 string 2026110208325831f1); it embeds a build stamp, so it is not bit-reproducible.

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=…
    Expect publishes close to vsyncs, late 0-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=fifo is the cap in force, and presented should climb with commits.
  • 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), and declared= is the guest's own mode.

Utkarsh Dalal added 4 commits November 2, 2026 12:13
…rapher

The flip events were stamped on a timeline of the daemon's own: a constant walk of
1e12 / mode_refresh_mhz() from CLOCK_MONOTONIC, 16 666 666 ns on a "60 Hz" mode.
gamescope re-arms its composite timer from every stamp (DRMBackend.cpp
page_flip_handler -> MarkVBlank; vblankmanager.cpp: wakeup = stamp + interval - draw
time - red zone), so the stamps decide where in the display's cycle it commits. The
Retroid Pocket 6 panel latches at 59.97-59.99 Hz, so the walk slid across
SurfaceFlinger's latch every half minute or so; while the composite sat on the latch
edge, ~1 ms of post jitter put two buffers in one latch window and SurfaceFlinger
dropped the older one (HUD 58.7-59.3 fps, SurfaceFlinger 54-57, at a 60 cap). A KMS
driver stamps the hardware vblank and none of this happens on a PC.

The stamps now follow the display. A thread with an ALooper keeps one Choreographer
callback posted per vsync while a session runs (AChoreographer_postVsyncCallback on
API 33+, postFrameCallback64 on 29-32) and parks between sessions. The daemon keeps
the last 32 vsyncs, counts each step in whole periods (a missed callback is two
steps, not one long one) and fits the period by least squares over them, so the
period is the measured one and never the app's integer Hz. A step that is not a
whole number of periods, or a full ring with no single-period step, re-seeds the
fit, so a live mode switch is followed; mode_refresh_mhz() still serves the
advertised rate to the connector.

Which instant is stamped: KMS has one instant per cycle, the vblank, which is both
the point a flip must be queued by and the start of scan-out. Android splits it
into SurfaceFlinger's latch and the present one or more periods later. gamescope
treats the stamp as the point its commit has to beat by its red zone (1.65 ms) plus
its rolling draw time, so the stamps sit on the latch grid: the preferred frame
timeline's deadline on API 33+, moved by where SurfaceFlinger actually latched the
daemon's publishes against it (latch-after-deadline, smoothed from every publish's
ASurfaceTransactionStats_getLatchTime). A present-time stamp would aim gamescope's
margin at an instant whose phase to the latch is the compositor's work duration.
Each publish claims the first latch at or after it, the latch that takes it, even
when that is after Choreographer's deadline.

The stamp is that latch minus a lead. gamescope's red zone was sized for a kernel
commit; here a commit still crosses the commit worker and a binder apply with ~1 ms
of spread. Stamped on the deadline itself (RP6, SWD2, cap 60) the applies landed
1.04 / 2.13 / 4.47 ms (p10 / p50 / p90) ahead of the latch, the tail crossed it, and
SurfaceFlinger showed 58.65 fps on a 59.967 Hz panel. The lead is measured: every
completed publish's margin (latch - apply, folded into [-P/4, 3P/4)) feeds a
quantile loop (+9 steps of 20 us below target, -1 above) that holds the 10th
percentile at 3 ms, or 30 % of the period on faster panels; start 2 ms, cap P/3.
GNDRM_GS_LATCH_LEAD_US (app process environment) fixes it instead. The cost is
latency: about 2 ms more between gamescope's commit and the latch than a deadline
stamp on that device. The stamps are earlier than the light by the lead plus
SurfaceFlinger's pipeline; what reads them (gamescope's grid, mangoapp's frame time)
uses their spacing, which a constant offset does not change.

The VsyncNote every scan-out client receives now carries the measured period while
Choreographer is locked and fresh (the mode's advertised rate otherwise); a period
move pushes the note only above 1 us, since the fit wanders by a few microseconds
and the 300 us phase push re-anchors long before that adds up.

Fallbacks: on API 29-32 the measured period is used with the latch grid's phase
(vsync.rs), or the vsync time's own phase until a latch was seen; with
Choreographer silent for a second the latch grid alone is followed at the mode's
period; with neither reporting yet, the previous walk of the mode's interval. A
claim is never earlier than the previous one and never repeats an instant: a
second frame inside a period claims the next instant, a late frame skips whole
periods, and a source switch keeps at least half a period between claims; claims
moved past their latch by that rule are counted.

Ungated lines:
  vsync: flip events on the display's vsync: source=choreographer-deadline
  period=16675.123us (59.969 Hz, mode 60.000 Hz) latch-vs-grid=+250us
once per lock (the first, and the first after a rate change), and every 10 s
while publishes complete:
  vsync: check source=choreographer-deadline period=16675.672us (59.967 Hz)
  publishes=598 vsyncs=600 late=0 pushed=0 margin p10/p50/p90=+3.0ms/+4.1ms/+5.9ms
  lead=+2.8ms latch-after-deadline=+0.4ms
late = publishes that missed their latch, pushed = claims moved by the ordering
rule, publishes below vsyncs at a cap at or above the panel's rate = gamescope
skipping a wakeup.

- modules/gndrm-daemon/src/vblank.rs (new): VsyncRing (period fit), Grid,
  DisplayVsync (source selection, freshness, measured_period_ns), LeadLoop,
  fold_margin, VsyncReport, the process-global feed and VblankTimeline, moved out
  of lib.rs and taking the grid; unit tests for the fit, rate changes, pauses,
  source selection, staleness, the claim rules, the lead and the report.
- modules/gndrm-daemon/src/lib.rs: the commit worker claims on the display grid,
  with the mode's interval as the walk.
- modules/gndrm-android/src/choreographer.rs (new): the vsync thread and the
  Choreographer FFI (dlsym'd from libandroid), started by nativeStart and stopped
  by nativeStop; the lock and check lines.
- modules/gndrm-android/src/vsync.rs: the latch tracker's anchor feeds the daemon,
  every completion feeds note_publish_latch, the note's period is the measured one
  (PERIOD_STEP_NS).
- modules/gndrm-android/src/ffi.rs: sym() is crate-visible for the new table.
- modules/gndrm-android/src/lib.rs: module wiring and the header's account of the
  grid.
- modules/gndrm-daemon/tests/golden.rs: the first-claim comment names the no-source
  case (no assertion changes).
- docs/15-fps-limiter-pacing-and-vsync.md: section 3 on the vsync source, the latch
  grid, the claim, the lead and its cost, fallbacks and ordering; section 4 on the
  vsync timeline, the note's period and the two lines. docs/11-refresh-model.md:
  the flip timeline is no longer "one mode period".
…ayer does; gamescope's limiter is the cap

On a PC, gamescope's WSI layer creates the driver swapchain as mailbox and
implements FIFO itself: per present it declares the app's own mode to gamescope
through gamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter
is engaged. gamescope latches one FIFO commit per limited vblank
(steamcompmgr.cpp handle_done_commits_xwayland) and the client is paced only by
wl_buffer.release. MESA_VK_WSI_PRESENT_MODE reaches Mesa below the layer and
never changes what is declared.

The shim is that layer on gamescope's override route (its own wl_surface wrapped
in a gamescope_swapchain), but it let MESA_VK_WSI_PRESENT_MODE decide the mode,
and every container carries mailbox there as a default for the X11 path. So
every commit was declared mailbox, which gamescope's limiter never holds, and an
acquire gate on the shim's own clock stood in for the cap. That gate beat
against the display (57-59 fps at cap 60), and with mailbox there was no queue to
absorb a late frame.

On the override route, per present:
- frame generation on: mailbox, exactly as before (generated frames are
  committed without a declaration, and a real frame declared FIFO would queue
  behind them);
- a cap in force (the daemon's FrameCapNote, the value the app also hands
  gamescope's limiter): FIFO;
- otherwise: the guest's own mode.
The same rule picks the present discipline and both set_present_mode sites. The
env no longer leads the offered mode list there, and the extra mailbox slot
follows the guest's request (or frame generation at creation). A present wait is
answered at commit when the guest asked for mailbox or immediate, or its frames
go out as mailbox; a FIFO guest declared FIFO keeps the release-or-grace answer.
Off the override route (the opt-in X11 Present route) nothing changes.

The time gate now runs on the override route only while frame generation sets
the source rate (the app disarms gamescope's limiter then); otherwise gamescope's
limiter is the cap. A cap that is not the display's rate over a whole number
(40 or 45 on 60 Hz) runs at gamescope's next divisor rate above it, upstream
gamescope's behaviour, left to it for now.

One ungated line every 10 s while frames are presented:
  gn-vkshim: present mode: declared=fifo requested=fifo route=gamescope cap=60
  lsfg=0 acquires=… commits=… declared_fifo=… declared_other=… releases=…

- icd_shim/src/proxy.rs: live_present_mode / choose_live_mode,
  declared_present_mode, mode_census; effective_present_mode ignores the env on
  the override route; present_dispatch, both declarations, the present-wait
  rule, the slot count, the offered modes and the time gate follow; tests.
- icd_shim/src/scanout.rs: cap_in_force, cap_fps_note.
- docs/10-fps-limit.md, docs/15-fps-limiter-pacing-and-vsync.md, docs/README.md:
  the cap is gamescope's limiter on the shim's FIFO declaration; the acquire
  gate is described as frame generation's source-rate gate.
With FIFO declared, a guest that waits on every frame (DXVK with in-game VSync
on: FIFO, present wait, frame latency 1) ran at 35 fps at a 60 cap on a 60 Hz
panel, 364 of 526 intervals two vblanks long. The shim answered
vkWaitForPresentKHR from the compositor's release, which under FIFO comes when
the next frame is latched, a vblank after this one was shown, or two refresh
periods after the commit; the guest's next frame started a vblank late every
time.

Mesa's Wayland WSI answers the present wait from wp_presentation: each present
that carries an id requests a wp_presentation_feedback on the surface, and
presented or discarded signals that id. gamescope offers wp_presentation
(version 1, CLOCK_MONOTONIC), sends presented for the commit it latched with the
refresh it targets, and discarded for a commit it replaced or never showed
(including a surface that goes away). The shim now does the same on the
override route:

- wp_presentation is bound with the gamescope swapchain factory (not on the
  guest-surface route); its clock_id is recorded.
- A FIFO commit that carries a wait requests a feedback before
  wl_surface.commit, under the send lock, with the id as the listener's data.
  The id is registered as feedback-pending before the commit, so nothing can
  answer it first.
- presented answers the wait at the reported time (CLOCK_MONOTONIC; the receipt
  time otherwise); discarded answers it at once. The object is destroyed in
  either event.
- While a feedback is pending, the release does not answer the id and the
  two-period grace does not apply; a feedback that never arrives is answered
  100 ms after the commit, well inside the guest's one-second bound.
- An id is answered once: record() ignores ids no longer armed, so a second
  answer from another path (the daemon's latch report, a release) leaves
  nothing stale.
- Without the global, off the override route, or when the feedback request
  fails, the release and the grace answer as before. The mailbox/immediate
  answer-at-commit rule (drm #37) is unchanged, and so is frame generation's
  path (its commits carry no feedback).

The ungated present-mode line adds feedback=on|off presented= discarded=
fallback= (waits answered by release, grace or the safety).

- icd_shim/src/wlproto.rs: wp_presentation / wp_presentation_feedback
  interfaces, presentation_feedback().
- icd_shim/src/proxy.rs: bind, clock and feedback listeners, the request in
  commit_real, census fields.
- icd_shim/src/present.rs: expect_feedback, feedback_presented /
  feedback_discarded, cancel_feedback, the pending rule for release and grace,
  counters; tests.
- docs/15-fps-limiter-pacing-and-vsync.md: the FIFO present-wait answer.
Utkarsh Dalal added 3 commits November 2, 2026 14:02
Review fixes for the Choreographer vsync source:

- AChoreographer_postVsyncCallback and AChoreographer_postFrameCallback64
  return void (<android/choreographer.h>, NDK 27.3). They were declared to
  return an int and the thread exited on a non-zero value, which was whatever
  the callee left in w0. Both are now declared `-> ()` with no check.
- The thread no longer has an exit after it publishes its looper. A callback
  that has not come within a second is posted once more (logged once), and the
  flip events fall back to the latch grid on their own while the vsync is stale.
  The looper is held with ALooper_acquire for the life of the process, and any
  end of the thread (a panic included) clears LOOPER and SPAWNED, so start()
  never wakes a freed looper and the next session spawns a new thread.
- The fit is bounded by the mode's advertised period. Steps are counted against
  it and a fit more than 2 % from it is rejected (the previous period stays,
  logged once per run): a callback thread starved into delivering every second
  vsync counts two periods per step instead of locking the timeline at twice the
  period. A new advertised mode (nativeSetPanelRefreshHz on a display change)
  starts the fit again. With no mode reported the old spacing rules apply.
- The callbacks stop while a session is suspended and resume with it
  (nativeInjectorSetSuspended).
- GNDRM_GS_LATCH_LEAD_US is clamped to half a period: a lead of a period or
  more would stamp every event before the previous latch.
…amescope swapchain

Review fixes for the present-wait and declaration paths:

- Present ids restart at 1 on every swapchain a guest creates (DXVK recreates on
  a resize or a mode change), and the pending feedback, the commit list, the
  answers and the release path were keyed by id alone, so an old swapchain's
  late `discarded` (its surface torn down) or release could answer the new
  swapchain's frame early. Every entry is now keyed by (id, swapchain); the
  feedback listener carries a per-request token instead of the id; destroying a
  swapchain drops its pending feedback, commits and unconsumed answers. The
  daemon's latch report, which names an id only, answers the armed frame with
  that id.
- commit_real and present_sync_commit declared the present mode on
  gamescope_swapchain unconditionally; on the guest-surface route (or a
  gamescope without the swapchain factory) that proxy is null. The declaration
  is now skipped there.
…wers presentation feedback

docs/15 gains a "Behaviour changes" section (FIFO games vsynced without a cap,
mailbox/immediate keep their mode, MESA_VK_WSI_PRESENT_MODE ignored on the
gamescope route, non-divisor caps at gamescope's divisor rate, FIFO queue
latency, frame generation switched on mid-session, the toggle's uncapped
moment) and records what gamescope does with presentation feedback: `presented`
every vblank for the last latched commit of every window, visible or not, and
`discarded` for a replaced or destroyed commit, so an unfocused game is still
answered per vblank and the 100 ms safety is only a backstop. §3 also covers
the period's bound to the mode and the callback re-post.
@utkarshdalal

Copy link
Copy Markdown
Contributor Author

Review fixes pushed (5831f1e). Each finding of the adversarial review, verified against the code, the NDK 27.3 headers and gamescope's source:

# Finding Outcome
1 Choreographer post functions return void but were checked as int Fixed (1b16523): declared -> (), no check
2 Thread exit leaves a dangling LOOPER; next start() wakes a freed looper Fixed (1b16523): no exit after the looper is published; ALooper_acquire keeps it alive; any end clears LOOPER/SPAWNED; a callback missing 1 s is posted again once and the flip events use the latch grid meanwhile
3 Starved callback thread locks the period at 2× Fixed (1b16523): steps counted against the mode's period, fits outside 2 % rejected (logged once), a new mode restarts the fit; test with 64 double-spaced vsyncs at 120 Hz
4 Cap now depends only on gamescope's limiter Not changed. The cap lives in an atomic; the injector re-sends it on every (re)connection and on any change (100 ms loop); gamescope's limiter applies to all non-Steam, non-overlay windows, not per focus. A note before the first send means a vsynced (not uncapped) game for ≤ ~100 ms. Documented under Known limitations
5 Input latency at low caps (FIFO queue depth) By design (PC parity); documented under Behaviour changes
6 MESA_VK_WSI_PRESENT_MODE ignored on the gamescope route By design (gamescope's WSI layer ignores it too); documented under Behaviour changes
7 Frame-rate override / ARR / thermal refresh changes Partly covered by #3: fits outside 2 % of the mode are rejected, a 60 Hz app vsync on a 120 Hz mode counts two periods per step. A setFrameRate vote on the daemon's layer is a separate change
8 Present waits take 100 ms when gamescope does not show the surface Not real: gamescope sends presented every vblank for every window's last latched commit, visible or not (steamcompmgr.cpp, comment before handle_presented_xwayland), and discarded for replaced/destroyed commits. Documented in docs/15 and the body
9 Present-id collisions across swapchains Fixed (e03a8c6): all present-wait state keyed by (id, swapchain), per-request feedback token, swapchain destroy drops its state; test
10 LSFG on mid-session runs mailbox with one fewer slot Documented (Behaviour changes); not changed in this PR
11 Brief uncapped burst when LSFG is toggled Documented (Behaviour changes); the fix belongs to the app's ordering
12 Uncapped FIFO games now vsynced By design; documented
13 Choreographer keeps waking while suspended Fixed (1b16523): stopped on suspend, restarted on resume of a running session
14 Fixed-lead override not clamped Fixed (1b16523): clamped to half a period
15 API 29-32 with latch 0 uses the raw vsync phase Not changed: the lock line already names source=choreographer-vsync
16 Stale state across sessions Not changed: a new mode now restarts the fit (#3); the carried lead is clamped and re-converges
17 Null swapchain_obj on the guest-surface route Fixed (e03a8c6): declaration skipped
18 wl_output_interface import Not in this repo: belongs in the payload build's checks (the payload's libwayland-client exports it)
19 Wire compatibility Info, no change

Builds at 5831f1e (NDK 27.3, --release, daemon and shim together): daemon 9d3619681d9d5e382f78f2953c98ba1eff645334df8b7af8e88bc166ea774828 (reproduced after a clean rebuild), shim 9e181cd76b5a23e0b614dea39219ee0349efba23f8c1d20b0b5e2082b1d173b5. Tests: workspace all pass (daemon 99 unit, 36 golden), shim 71; clippy clean on host and aarch64-android.

@utkarshdalal

Copy link
Copy Markdown
Contributor Author

The shim (9e181cd7…, build string 2026110208325831f1) and daemon (9d361968…) built from 5831f1e now ship in app PR utkarshdalal/GameNative#2320. The shim is in payload gamescope-gamenative-20261102.3.tzst (sha256 d102d40b4b747e1653b4437c5d43f89679c4e8ffb8e0ad9526710d6d1951a4ff), which is uploaded and verified on both CDN hosts. The test instructions now name payload 20261102.3. The device re-anchor on the RP6 and Odin 3 is pending.

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.

1 participant