Skip to content

shim: the present mode follows the container, the cap runs on the vsync grid under mailbox - #46

Merged
utkarshdalal merged 15 commits into
masterfrom
shim-present-mode-from-container
Nov 3, 2026
Merged

utkarshdalal merged 15 commits into
masterfrom
shim-present-mode-from-container

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

A demonstration for discussion. On gamescope's override route the shim now declares the container's present mode, or the guest's mode when there is no env. There are two exceptions:

  • Non-FIFO mode under a cap at the display's rate: declared FIFO with no gate (source=cap-rate gate=off), which is master's behaviour in that cell. gamescope latches one frame per vblank and its limiter (divisor 1) paces the guest.
  • No cap, and the guest asked for FIFO: the guest's FIFO wins over the container's stamped mailbox default.
    Below the display's rate a non-FIFO mode stays as it is and the shim's gate enforces the cap on the display's real vblank grid; an env of fifo is master's path unchanged.

The daemon change (stamp lead in the VsyncNote) is required for the gate. A shim-only payload behaves exactly like master: until the lead arrives, a capped non-FIFO swapchain is declared FIFO under gamescope's limiter (source=cap-compat).

Problem

7bdcbae made the shim declare FIFO to gamescope whenever a cap is set, and it stopped reading MESA_VK_WSI_PRESENT_MODE on the override route. The idea was to let gamescope's divisor limiter enforce the cap. The cost:

  • Default containers carry a 60 cap and MESA_VK_WSI_PRESENT_MODE=mailbox. So every default session runs FIFO under gamescope's limiter.
  • Under FIFO, each frame is held to whole limited vblanks. A game below the cap therefore drops onto divisors of the refresh rate: it judders between refresh and refresh/2, and a game that waits on every present stays at refresh/2. This is worst on 90 and 120 Hz panels.
  • The user's present-mode choice in the container is ignored on this route.

Before 7bdcbae, mailbox was declared and the shim's acquire gate enforced the cap. That gate ran on its own 1e9/N clock, which drifted against a 59.97 Hz panel and gave 57-59 fps on SurfaceFlinger at cap 60. That drift is why 7bdcbae moved to FIFO.

What changes

The declared mode now follows the container, with one exception: a cap at the display's rate. When the container's mode is not FIFO, the shim enforces the cap with a gate that releases on the display's real vblank grid.

container mode cap declared what limits the rate
mailbox (default) none, guest asked for FIFO (in-game VSync on) fifo (source=guest) the display
mailbox (default) none, guest asked for mailbox or immediate mailbox nobody
mailbox at the display's rate (e.g. 60 on 59.97 Hz) fifo (source=cap-rate) gamescope's limiter (divisor 1), one FIFO commit per vblank; no gate (gate=off), as on master
mailbox below the display's rate (e.g. 60 on 120 Hz, 30 on 60) mailbox the shim's acquire gate, on the display's vblank grid
fifo none fifo the display
fifo set fifo gamescope's divisor limiter (as today)
any, no stamp lead yet set fifo (source=cap-compat) gamescope's limiter (as today)
any, frame generation on any mailbox source-rate gate (unchanged)

Why the guest's FIFO wins with no cap. Every container carries MESA_VK_WSI_PRESENT_MODE=mailbox as a stamped default that users did not choose. With no cap, honouring that default turned a game's own VSync (a FIFO request) into an uncapped mailbox session, which is neither master's behaviour nor what the user asked for.

  • A FIFO request (FIFO-relaxed too) is a specific wish, so it is declared as asked.
  • A mailbox or immediate request means "do not make me wait". The container's mailbox is the substitute for it, as on the X11 route; an uncapped immediate guest gets mailbox, not tearing.
  • An env of fifo is explicit and wins in every case.

Why the display-rate cap declares FIFO instead of mailbox. At that cap the panel shows one frame per vblank under either mode. Under mailbox, though, the shim has to keep two commits out of one gamescope composite, and it cannot see when gamescope latches (see "Finding" below). On the RP6 (DTD, cap 60, VSync off, a guest pacing itself at ~59.3 fps on a 59.98 Hz panel):

  • mailbox with the gate gave 58.3 fps on SurfaceFlinger, with ~1 commit/s replaced in gamescope's mailbox (B4);
  • booking late frames by their predicted composite gave 58.3 (B5);
  • a commit-window queue at a guessed boundary gave 59.0, with ~0.8 commits/s still replaced (B6);
  • master's FIFO gives 59.22.

Under FIFO, gamescope latches one commit per vblank at its own wakeup, and its limiter's divisor at this cap is 1. No gate runs on top of it (2567925): FIFO's back-pressure already holds the guest, and the two stacked produced the regression below. Below the display's rate, mailbox with the gate stays, because the releases are at least two vblanks apart; Witcher 3 was not quantized there, whereas FIFO with gamescope's limiter quantized it to 30 at 60 on 120 Hz.

  • Without the env, the guest's own mode is used. fifo_relaxed and immediate are gated like mailbox, because gamescope's limiter holds only FIFO declarations (wlserver_surface_is_fifo).
  • The gate's clock (icd_shim/src/vsync_gate.rs): releases land on the daemon's VsyncNote latch grid (measured period and phase), moved earlier by the lead the daemon stamps flip events with. That is the grid gamescope composites from.
    • cap at the display's rate: one release per vblank, under the FIFO declaration. 60 on a 59.97 Hz panel gives 59.97.
    • cap that divides the rate: every refresh/cap-th vblank (30 on 60, 60 on 120).
    • any other cap below the rate: the vblank nearest each point of an ideal 1e9/cap timeline. 45 on 60 releases on 3 of every 4 vblanks.
    • cap above the rate: the cap's own clock.
    • A late frame starts at once and the schedule carries on, so a game below the cap keeps its own rate.
    • The gate needs the daemon's stamp lead. Until a note with a non-zero stamp_lead_ns has arrived (no note yet, or an older daemon), a capped swapchain whose mode is not FIFO is declared FIFO, and gamescope's limiter is the cap, exactly as on master. The census shows source=cap-compat. So a payload-only rollout keeps today's pacing until the app ships the new daemon.
  • Daemon: the VsyncNote's reserved word now carries stamp_lead_ns, the flip-event lead. The wire size is unchanged. The daemon sends at least 1 ns while it stamps on a grid, so 0 always means "unknown". An older daemon sends 0, and the shim then falls back to FIFO under a cap (see above). An older shim ignores the field.
  • Slot count: on this route, the container's mode decides the extra mailbox slot only when the stamp lead is already known at swapchain creation. Otherwise the guest's own request decides it, as on master.
  • Census: present mode: declared= requested= source=env|guest|cap-rate|cap-compat route= cap= gate=vsync|clock|off lsfg=, where requested= is the guest's own request.
    • It prints ungated once when frames first flow, and again whenever the declared mode, source, cap or gate changes.
    • With GNDRM_LOG_VKSHIM=1 it also repeats every 10 s with the counters: acquires= commits= declared_fifo= declared_other= releases= feedback= presented= discarded= fallback= gate_holds= gate_late= skipped= slot_waits= commit_lat_us= spacing=[…].
    • "Invalid MESA_VK_WSI_PRESENT_MODE value!" prints ungated, once.

What is unchanged

  • The FIFO path when the user sets fifo: FIFO is declared, gamescope's limiter is the cap, and present wait is answered from presentation feedback.
  • Frame generation: mailbox with the source-rate gate.
  • The opt-in X11 Present route.
  • The present-wait rules. A wait is answered at commit for mailbox and immediate, and on feedback, release or grace for FIFO.
  • The daemon's flip-event timing and the cursor plane.

Commits

  1. daemon: the vsync note carries the flip events' stamp lead
  2. shim: an acquire gate on the display's vblank grid, for caps gamescope's limiter does not hold. Nothing takes this path until commit 3.
  3. shim: the present mode follows the container on the gamescope route; a cap no longer forces FIFO
  4. docs: ... (docs/10, docs/15, README)
  5. shim: no vblank-grid gate without the daemon's stamp lead; declare FIFO under a cap until it arrives. This follows from the RP6 run below.
  6. shim: book a late frame to the composite it will make, … (9fb3cda), later backed out by shim: back out the latency booking of late frames; keep its counters (93ab33c).
  7. shim: one commit per gamescope composite under the vblank-grid gate (8cf5834), later backed out by shim: back out the commit window; keep the commit-spacing histogram (476aa3e).
  8. shim: declare FIFO under a cap at the display's rate, with the vblank-grid gate kept (ce1f919).
  9. shim: test switch to keep mailbox at a cap at the display's rate (debug.gndrm.mailbox_at_rate) (b084ead). This is a test flag only; the default rule is unchanged. With the Android property debug.gndrm.mailbox_at_rate=1, or the env GNDRM_MAILBOX_AT_RATE=1 when the property is unset, a cap at the display's rate declares the swapchain's mode with the vblank-grid gate kept. The census then reads source=env-at-rate, and the switch's value is logged once after the ICD loaded line. Artefact: libadrenotools_gamescope.so sha256 33904656a669efc0ae66ddba4c487c03907eefdc649b4392711eed11bdce5f1d, build string 202611030213b084ead. 87 shim unit tests pass and clippy is clean.
  10. shim: a clock-booked frame leaves the grid schedule an ideal instant it can use (47c11aa). This fixes the review blocker: after a booking on the clock, a cap below the display's rate stopped binding. A test now covers both clock-to-grid transitions.
  11. shim, docs: gate_late in the census, the present-mode env as the user's control, docs/15 and docs/17 as the code is (a4a7bb0).
  12. shim: with no cap, a guest's FIFO request wins over the container's default mode (1b708d5).
  13. shim: the present-mode line ungated at start and on a change, its counters behind GNDRM_LOG_VKSHIM; the mailbox-at-rate test switch removed (c9b7c31). This removes b084ead's switch; the measurement it served (H1, H2) is done.
  14. shim: no vblank-grid gate under the cap-rate FIFO declaration; a late start is released at the next grid instant (2567925). This fixes the regression in JT's capture (below).

Tests

  • cargo test -p adrenotools_gamescope --lib (Linux aarch64 container): 88 passed, 0 failed, at 1b708d5. The rule-table test now has the guest's request as a column.
  • cargo test --workspace --no-fail-fast (same container, lld): 305 passed. The one failure was shim_integration a_native_driver_never_drives_a_container_icd, the pre-existing flake; that suite passed 3/3 on rerun.
  • Coverage of the shim tests:
    • the rule table end to end:
      • 60 on 59.97 Hz gives FIFO, cap-rate, gate off;
      • 60 on 120 Hz and 30 on 60 give mailbox with the gate every second vblank;
      • env fifo with a cap gives FIFO with no gate;
      • no cap gives the env's mode with no gate;
      • no lead gives cap-compat;
      • frame generation gives mailbox on the source-rate clock;
    • choose_live_mode and the gate per declaration;
    • the gate's schedule for cap == refresh (59.97 Hz), 30 on 60, 60 on 120 and 45 on 60;
    • a late frame, a game below the cap, and a phase push;
    • no note, a note with lead 0 and a note with a lead;
    • caps above the rate;
    • the clock-to-grid transition: a 30 cap on 60 Hz stays two vblanks apart after frames booked on frame generation's source-rate clock, and after a cap moves from above the panel's rate to below it.
  • cargo test -p gndrm-uapi -p gndrm-android -p gndrm-daemon: 182 passed. The note round-trip, an old note decoding as 0, and the stamp-lead push rule are new. The two abi_ground_truth C-header probes did not run because the test container has no drm/drm.h; this PR does not touch them.
  • cargo clippy --target aarch64-linux-android -p adrenotools_gamescope -p gndrm-android -p gndrm-daemon --all-targets -- -D warnings: clean.

Artefacts

Release aarch64 builds, NDK 27.3:

  • libadrenotools_gamescope.so from 2567925: sha256 6355ee9ecdd1d8d8d6e6128d5e06c68536c085bebeaaaa28e70af517b2af9035, build string 2026110307122567925. It is the cap-rate regression fix, with the logging as in c9b7c31. The device results above were measured on ce1f919 (sha256 b4bff42a…297c) and, for S1, on a4a7bb0. The N1-N4 results were measured on 1b708d5: Witcher 3 at 120 Hz (N3), env fifo (N2) and the no-cap rule (N1) are verified on it.
  • libgamenative_drm.so from 46ccc70 (unchanged since): sha256 08d6276c268ed9ff169a0cc76f059bf7e30dec2288f13617bfa64c6f4ff407f6

App delivery for testing: utkarshdalal/GameNative#2327 (branch gamescope-present-mode-from-container).

  • Payload gamescope-gamenative-20261103.4.tzst, sha256 5126c4ee8f042a69e287a3bde16e3be5a79de0cc052aaeadff5554b6a09fe7b9. It is 20261102.4 with only lib/libadrenotools_gamescope.so replaced (the 2567925 build above); gamescope and Xwayland are unchanged. App commit 40dd84163.
  • Payload 20261103.3 (c9b7c31 shim, cfe96ac2…213e), the one in JT's capture, stays on the CDN.
  • Payload 20261103.2 (1b708d5 shim, a77a7b77…) was used for N4 and stays on the CDN.
  • The app carries the daemon 08d6276c…07f6 above and GamescopePayload.VERSION = 20261103.2.
  • In logcat, expect Gamescope payload 20261103.2 ready and gn-vkshim: ICD loaded [shim 202611030337 1b708d5].

The daemon changed, so the vblank-grid gate needs an app build. With only the new shim in a payload, a capped mailbox container is declared FIFO, the same as master.

Regression found and fixed (2567925)

@joshuatam's capture found it: payload 20261103.3, shim c9b7c31, a 1280x960 device, DTD in game, cap 60, env mailbox, in-game VSync on.

  • At 120 Hz: declared=mailbox source=env gate=vsync held 60 fps, with acquire-to-commit around 7-16 ms.
  • After a live switch to 60 Hz: the census became declared=fifo requested=fifo source=cap-rate cap=60 gate=vsync, and within ~10 s the game settled at 40 fps:
    • shim commits 400 per 10 s, equal to the daemon's 400/600 publishes, with late=0;
    • gate_holds 0, gate_late 400/400, skipped +200 per 10 s;
    • acquire-to-commit 24 ms.
  • Back at 120 Hz it recovered within a second.

Cause. FIFO's back-pressure and the vblank-grid gate stacked in the cap-rate cell. Once a frame took longer than a period from acquire to commit, every start was late against a grid the guest could no longer meet. The RP6 runs of this cell (B7, S1, N4) did not show it because their scene stayed near 12 ms.

Fix (2567925).

  • The cap-rate cell keeps the FIFO declaration with the gate off (source=cap-rate gate=off), which is master's behaviour in that cell.
  • Under a cap below the display's rate, the gate never books the release after a late start more than one grid instant after it.

Payload 20261103.4 carries it (sha256 5126c4ee…e7b9, utkarshdalal/GameNative#2327 at 40dd84163).

Device verification (Retroid Pocket 6, cap 60)

All runs are on the DTD title scene unless noted. The FIFO reference is master's shipped shim on the same scene.

run stack result
B1 DTD, VSync on (requested=fifo), new shim + new daemon, 59.983 Hz Pass. 59.983 fps, 0 doubles, 3600/3600 acquires/commits, daemon late=0. Equal to the FIFO reference.
A4 DTD, VSync off, new shim + old daemon (stamp_lead_ns = 0) Fail, fixed by 46ccc70. 58.28 fps, 102 doubles. Releasing on the latch instant itself straddled gamescope's composite; at 120 Hz the daemon had late=818 (13 %). Without the lead the shim now uses master's FIFO path (cap-compat).
B4 DTD, VSync off (requested=immediate), 1ffd476 (mailbox + gate) Fail. 58.33 fps, 2.8 % doubles. The shim committed 59.3/s and the daemon published 58.3/s with late=0, so ~1 commit/s was merged in gamescope's mailbox.
B5 same, 9fb3cda (late-frame booking) Fail. 58.26 fps, 2.9 % doubles. ~75 % of acquires were late; the guest paces itself.
B6 same, 8cf5834 (commit-window queue) Fail. 59.0 fps median, 2.7 % doubles. ~0.8 commits/s still merged: the shim cannot see gamescope's latch point (Finding, below).
B7 same, ce1f919 (FIFO at the display's rate + gate; the gate was dropped from this cell in 2567925), 59.97 Hz Pass. Census declared=fifo requested=immediate source=cap-rate route=gamescope cap=60 gate=vsync, declared_other=0. Shim commits 592-594 per 10 s against daemon publishes 591-594 per 600 vsyncs, so the B4-B6 gap is gone. SurfaceFlinger over 60 s: 59.199 fps, 46 doubles (1.3 %), none short. FIFO reference: 59.22 fps, 46 doubles/min. slot_waits=0, daemon late=0, margin p50 +3.4 ms, lead 1.6-2.2 ms. The remaining shortfall is the game's own ~59.3 fps self-pacing.
C1 Witcher 3, GPU-bound, 120 Hz, cap 60, 1ffd476 gate (the same below-rate mailbox + gate path as ce1f919) 31.85 fps; intervals 3-vsync 25.1 %, 4-vsync 70.6 %. Not quantized.
C2 same, shipped FIFO 31.35 fps; 4-vsync 75.3 %, 2-vsync 8.6 %. Quantized toward 30.
SWD2 GL title Not on this path.
B8 Witcher 3, 120 Hz Not run on ce1f919; now verified on 1b708d5 as N3.
B9 env MESA_VK_WSI_PRESENT_MODE=fifo Not run on ce1f919; now verified on 1b708d5 as N2.
S1 DTD, VSync on (requested=fifo), a4a7bb0 with the default rule, shipped gamescope, cap 60, 59.98 Hz Pass. Census declared=fifo requested=fifo source=cap-rate cap=60 gate=vsync. SurfaceFlinger over 60 s: 59.978 fps, 3598 single intervals, 0 doubles. Commits 599-600 per 10 s, equal to the daemon's publishes; slot_waits=0. B1 reference: 59.983 fps, 0 doubles.
N1 DTD, limiter off (no cap), VSync on (requested=fifo), 1b708d5 + 46ccc70 daemon, shipped gamescope, 60 Hz Pass (the no-cap rule, 1b708d5). Census declared=fifo requested=fifo source=guest route=gamescope cap=0 gate=off. SurfaceFlinger 60 s: 59.973 fps, 3598 single intervals, 0 doubles. Commits +600 per 10 s, equal to the daemon's 600/600 publishes, late=0. presented tracks commits (9942/9943); slot_waits=0.
N2 DTD, env MESA_VK_WSI_PRESENT_MODE=fifo, cap 60, VSync on, 1b708d5, 60 Hz Pass. Census declared=fifo requested=fifo source=env cap=60 gate=off. SurfaceFlinger 59.980 fps, 3598 single intervals, 0 doubles. Commits equal publishes, 600/600.
N3 Witcher 3, hillside scene, 120 Hz panel (120.127 Hz measured), cap 60, 1b708d5 Pass on fps and histogram. Census declared=mailbox requested=fifo source=env cap=60 gate=vsync. SurfaceFlinger 32.624 fps (per-second median 33, p5 32). Intervals in 120 Hz periods: 2 = 1.1 %, 3 = 33.5 %, 4 = 61.7 %, 5 = 3.5 %, 6 = 0.2 % (C1: 3 = 25.1 %, 4 = 70.6 %; C2 FIFO: 2 = 8.6 %, 3 = 7.4 %, 4 = 75.3 %, 5 = 7.3 %). Shim commits 319-331 per 10 s, equal to the daemon's 319-332 publishes per ~1200 vsyncs. commit_lat_us ~30-31.5 ms; slot_waits=0. See the N3 observation below.
N4 Delivery smoke: utkarshdalal/GameNative#2327 APK (b70940340) + payload 20261103.2 from the CDN Pass. Payload downloaded in ~3.0 s (Gamescope payload 20261103.2 ready), with the stamp and shim sha (a77a7b77…) as expected; ICD loaded [shim 202611030337 1b708d5]. The file tree before and after differed only in the stamp and the shim. Census declared=fifo requested=fifo source=cap-rate cap=60 gate=vsync. SurfaceFlinger 59.966 fps, 3596 single intervals, 1 double. Commits equal publishes.

Observation for review (S1). During S1 the shim alternated between two steady states, and SurfaceFlinger's output was identical in both:

state per 10 s commit_lat_us
(a) gate_holds +600, gate_late +11-16 about 8.3-9.3 ms
(b) gate_holds 0, gate_late +600 about 16.6 ms

It switched twice over 3 minutes; the census lines at 08:23:37 and 08:25:07 are the transitions. The guest's frame lands either just before its release point or just after it:

  • in (a) the gate holds the frame for a moment;
  • in (b) the frame is a hair late, so it starts at once and is booked to the point it just missed.

Under FIFO with the gate, the panel output does not change. The two states could still differ in input-to-display latency by up to a frame.

@joshuatam, an open question rather than a proposed change: should the gate treat a frame that arrives within a small window after its release point as on time, as the earlier ON_TIME_NS tolerance did for counting? Or is this alternation harmless?

Observation for review (N3). On the below-rate mailbox path at 120 Hz, the daemon logged late=77-175 per ~1200 vsyncs:

  • daemon margin p10 was about −1.5 to −1.8 ms, with the lead at about +2.7 ms;
  • C1, on the earlier gate, showed the same (67-158);
  • master's FIFO shim had 0-24;
  • it did not show in SurfaceFlinger's interval histogram.

@joshuatam, another open question: should the gate's release point on a 120 Hz grid carry a larger lead?
| H1 | DTD, VSync off (requested=immediate), b084ead with debug.gndrm.mailbox_at_rate=1 (mailbox + gate at the display's rate) and gamescope A58 (GameNative/gamescope#31, debug.gamescope.gn_mailbox_hold=1), 59.95 Hz | Pass. Census declared=mailbox requested=immediate source=env-at-rate cap=60 gate=vsync, declared_fifo=0. Shim commits 592-594 per 10 s against daemon publishes 592-594, so nothing is merged. SurfaceFlinger over 60 s: 59.269 fps, 41/3556 doubles (1.2 %), against the FIFO reference's 59.22 / 1.3 % and B4-B6's 58.3-59.0 / 2.7-2.9 %. slot_waits=0. gamescope gn_mailbox_hold=1 source=prop, steady state held=38-164 replaced=0 per 600 vblanks (5 replaced in the whole run, all during loading). |
| H2 | DTD, in-game VSync on (requested=fifo), b084ead + debug.gndrm.mailbox_at_rate=1 + gamescope A58 52e1b1f, 59.98 Hz | Pass on rate, with one caveat. Census declared=mailbox requested=fifo source=env-at-rate cap=60 gate=vsync. SurfaceFlinger over 60 s: 59.984 fps, 3599 singles, 0 doubles. Shim +600 acquires/commits/releases per 10 s, gate_holds +600, late 0-4, skipped 0, slot_waits 0. Daemon 600/600, late=0, margin p50 +3.45 ms, lead ~2.2 ms. Caveat: gamescope's held=1200 per 600 vblanks means every commit was shown one paint late, a standing carry. GameNative/gamescope#31 now bounds it (9fad070, gn_mailbox_hold_max_carry, default 8); a re-run is pending. |

Finding: the shim cannot see gamescope's composite point under mailbox (B5, B6)

Run. DTD, cap 60, in-game VSync off (requested=immediate), 59.98 Hz panel. Results:

  • The guest paces itself at about 59.3 fps (Unity frame-rate target).
  • With 8cf5834 the shim commits 592-594 frames per 10 s.
  • The daemon publishes 583-590 per 600 vsyncs, with late=0. So about 0.8 commits per second are merged inside gamescope's mailbox: two commits land between two gamescope wakeups.
  • SurfaceFlinger shows 59.0 fps with 2.7 % doubles.
  • The shim's one-commit-per-window queue (win_holds 18-48 per 10 s, win_replaced=0) removed only a few of those merges.
  • The growth in skipped (7-8 per 10 s) is not a lost frame. The schedule only counts a skip when an acquire arrives a whole period after its release point. That count matches the guest's own shortfall, 600 − 593 per 10 s. The gate holds none of those frames.

Why measuring from the client cannot fix this. It comes from gamescope's own timing; line numbers are from gamescope src/:

  • gamescope takes client commits at its wakeup. The wakeup is the target vblank minus rolling draw time + red zone (vblankmanager.cpp 142-199).
    • The rolling draw time holds peaks. A draw more than half a red zone above it replaces it at once (164-165), and it then decays per wakeup (167).
    • "Draw time" runs from wakeup to page flip queued (DRMBackend.cpp 4465). On this stack that includes the commit to the daemon, so it is spiky.
    • So the latch point moves by milliseconds, jumps earlier on any spike, and drifts back over the following frames.
  • The client only gets weak evidence. It sees presented carrying the target vblank (steamcompmgr.cpp 8140, 8168) and discarded. Each presented frame gives one inequality on the latch point.
    • The bound tightens only as the guest's commit phase sweeps the period. For 59.3 fps against 59.98 Hz, one sweep takes about 1.5 s, which is longer than the decay.
    • A guest that runs at the grid rate never sweeps, so the bound never tightens where it matters.
    • Any window boundary the client chooses therefore has a moving error band of a few ms. Commits that fall in it still merge, which is what B6 shows.
    • Assigning desired_present_time targets (steamcompmgr.cpp 8040) runs into the same band.
  • Only gamescope knows its latch point. The protocol mechanism that uses it is the FIFO declaration: gamescope latches one FIFO commit per window per vblank (steamcompmgr.cpp 8028-8049).

Outcome. ce1f919 implements this: at the display's rate it declares FIFO and keeps the vblank-grid gate; below the display's rate it keeps mailbox with the gate. The commit window (8cf5834) is backed out.

With gamescope A58

GameNative/gamescope#31 (A58, draft) moves the hold that B6 attempted into gamescope, where the latch point is known. While gamescope's FPS limiter is engaged, the first non-FIFO commit for a paint is latched as today, a newer one is held for the next vblank, and a third replaces the held one. The client is never blocked, and FIFO commits are untouched.

  • H1 shows that mailbox at the display's rate is then at least as good as FIFO: 59.269 fps with 1.2 % doubles, against FIFO's 59.22 with 1.3 %. Shim commits equal daemon publishes.
  • When the payload carries A58, the cap-at-display-rate FIFO exception can be dropped. gamescope and the shim ship in the same archive, so a shim that declares the container's mode at the display's rate never meets a gamescope without the hold.
  • Until then, this branch keeps the exception (source=cap-rate). The debug.gndrm.mailbox_at_rate test switch that H1 and H2 used (b084ead) is removed in c9b7c31.
  • H2 (DTD, in-game VSync on): 59.984 fps with 0 doubles. On A58's first revision, though, every commit was carried one paint, because a guest at exactly one commit per vblank never drains the hold. gamescope#31 9fad070 bounds the carry; H2 and H1 are being re-run on it.

@joshuatam, I'd value your read on two things:

  1. The rule table, in particular declaring FIFO, with the gate, for a non-FIFO container when the cap equals the display's rate.
  2. Whether that cap-rate FIFO case should be a user choice instead. For example, a "frame delivery: compositor-latched / shim-gated" option alongside the present mode, so that someone who prefers mailbox at the display's rate can keep it, accepting the occasional merged frame.

Utkarsh Dalal added 10 commits November 2, 2026 22:49
The VsyncNote gives a shim the panel's latch grid (anchor, measured period)
and frame generation's commit lead. It does not say where gamescope's vblank
grid is: the daemon stamps each page-flip event its latch minus a lead of its
own (vblank.rs LeadLoop, ~2-3 ms, measured), and gamescope schedules its next
composite from that stamp. A shim that wants to release frames on gamescope's
schedule needs that lead too.

The note's reserved word now carries it as stamp_lead_ns: the lead of the
grid the flip events follow at the time (gndrm_daemon::vblank::display_grid),
0 while they follow none. A move of more than the existing 300 us push step
pushes the note, as the phase and the frame-generation lead already do. The
wire size is unchanged: a daemon that predates the field sends 0 there, and a
shim that predates it never reads it.

- modules/gndrm-uapi/src/wire.rs: VsyncNote::stamp_lead_ns in bytes 4..8;
  round-trip test, and an old note decoding as 0.
- modules/gndrm-android/src/vsync.rs: Tracker::track and Tracker::note take
  the stamp lead and push on its move; sample() and note() read it from the
  display grid before taking the tracker's lock; test for the push rule.
…e's limiter does not hold

gamescope's limiter holds only commits declared FIFO (wlserver_surface_is_fifo
compares with VK_PRESENT_MODE_FIFO_KHR alone). A mailbox, immediate or
FIFO-relaxed commit is taken at the next composite whatever the cap. Before
7bdcbae the shim's acquire gate stood in for the cap there, on a clock of its
own (one release every 1e9 / cap ns), and that clock beat against the panel: a
60 cap on a 59.97 Hz panel ran 57-59 fps on SurfaceFlinger.

This adds a gate that releases on the vblank grid gamescope composites from:
the daemon's VsyncNote latch grid (measured period, measured phase) moved
earlier by the stamp lead the daemon stamps flip events with. gamescope wakes
just before each of those instants, so a release there starts the guest's frame
right after gamescope took the previous one.

Release points (vsync_gate::pacing, Schedule::book):
- a cap at the display's rate (within 0.5 %): every vblank, so 60 on a
  59.97 Hz panel is 59.97, one frame per latch;
- a cap that divides the rate: every refresh/cap-th vblank (30 on 60, 60 on
  120);
- any other cap below the rate: the vblank nearest each instant of an ideal
  1e9/cap timeline (45 on 60 releases 3 of 4 vblanks);
- a cap above the rate: the cap's own clock, as before; every vblank already
  gets a frame.
A frame that arrives after its release point starts at once and the next
release is the next point of the schedule, so a guest below the cap keeps its
own rate instead of falling to a divisor. A phase push from the daemon is
followed without letting two releases into one vblank. Before the daemon has
sent a note the grid is the mode's nominal period (phase unknown); with no
rate known at all, the cap's own clock.

On gamescope's override route the gate now runs by the live mode
(gamescope_route_pacing): frame generation's source cap on its clock as
before; FIFO no gate (gamescope's limiter); anything else under a cap, the
vblank grid. The live mode is still FIFO whenever a cap is set, so nothing
takes the new path until the mode rule changes. Off the route (the X11 Present
route) the gate is unchanged.

The 10 s present-mode census gains gate=vsync|nominal|clock|off.

- icd_shim/src/vsync_gate.rs (new): the grid from the note, the pacing rule,
  the schedule; tests for cap == refresh, 30 on 60, 60 on 120, 45 on 60, a late
  frame, a guest below the cap, a phase push, the nominal fallback, caps above
  the rate.
- icd_shim/src/scanout.rs: every VsyncNote also reaches the gate.
- icd_shim/src/proxy.rs: acquire_pacing / gamescope_route_pacing; the
  acquire gate books on a vsync_gate::Schedule (replaces next_acquire_ns);
  the census field; test.
…a cap no longer forces FIFO

7bdcbae made the shim declare FIFO to gamescope whenever a cap was set, and
ignore MESA_VK_WSI_PRESENT_MODE on the override route, so gamescope's
divisor limiter would be the cap. Default containers carry a 60 cap and
mailbox in that env, so every such session ran FIFO under gamescope's
limiter: a game below the cap quantizes to refresh / divisor (refresh/2,
refresh/3 ...), worst on 90 and 120 Hz panels, and the user's present-mode
choice is never seen on this route.

The mode now follows the container, with the cap enforced by the shim's
vblank-grid gate (previous commit) when the mode is not FIFO:

  env mailbox, no cap   mailbox   nobody limits
  env mailbox, cap      mailbox   the shim's gate on the display's vblank grid
  env fifo, no cap      fifo      the display
  env fifo, cap         fifo      gamescope's divisor limiter (as today)
  frame generation on   mailbox   source-rate gate (unchanged)

With no env the guest's own mode is used. fifo_relaxed and immediate are
gated like mailbox: gamescope's limiter holds only FIFO declarations.

- effective_present_mode honours the env on both routes again, and the env's
  mode leads the offered list on both (as in Mesa, and as before 7bdcbae).
- choose_live_mode: mailbox under frame generation, else the swapchain's mode.
- The swapchain keeps the guest's own request (requested_mode) and where its
  mode came from (mode_source); the present wait is answered at commit when
  the guest asked for mailbox/immediate or the live mode is one of them, so
  the rule still follows the mode. The extra mailbox slot follows the mode as
  before.
- The 10 s census gains source=env|guest; requested= is the guest's request.
  A device run reads, e.g.:
    gn-vkshim: present mode: declared=mailbox requested=fifo source=env
    route=gamescope cap=60 gate=vsync lsfg=0 ...
- scanout::cap_in_force is no longer used and is removed.
- Tests: the rule table above end to end (env, guest, cap, frame generation),
  and choose_live_mode.
…the vblank grid under mailbox

- docs/10-fps-limit.md: who enforces the cap follows the declared mode, and the
  mode follows the container; the rule table (env mailbox/fifo x cap, frame
  generation); the gate's release points on the vblank grid, the late-frame
  rule, the nominal fallback and the census fields; divisor rounding bites
  the FIFO route only.
- docs/15-fps-limiter-pacing-and-vsync.md: the shim's gate as a third place
  a rate is decided; FIFO paced by gamescope's latch, other modes by the gate
  on the flip-stamp grid; behaviour changes rewritten for the container's
  mode; the VsyncNote's stamp_lead_ns.
- docs/README.md: index line for 15.
…FO under a cap until it arrives

Measured on a Retroid Pocket 6, cap 60. DTD with the new shim and the new
daemon, VSync on: 59.983 fps on a 59.983 Hz panel, 0 doubles, 3600/3600
acquires/commits, daemon late=0, the same as the FIFO reference. With the
new shim and the old daemon (VsyncNote stamp_lead_ns = 0), VSync off: 58.28
fps, 102 doubles. The shim released 59.3/s on a 59.975 Hz panel and the
daemon published 58.3/s with late=0, so frames were replaced in gamescope's
mailbox before they reached the daemon: released on the latch instant itself,
a frame starts ~2 ms after gamescope's wakeup and straddles the next
composite. At 120 Hz the same pairing gave daemon late=818 (13 %), against
109 under FIFO.

The gate now runs only on a note that carries the stamp lead:
- vsync_gate::current_grid is None until a note with stamp_lead_ns != 0
  has arrived. The nominal-period fallback is gone; nothing runs the gate
  without the daemon's grid.
- choose_live_mode: a cap set, a mode other than FIFO and no lead give FIFO,
  so gamescope's limiter is the cap, exactly as before the container's mode
  was honoured. The rule is read per present, so a lead that arrives
  mid-session moves the declaration on the next commit. Frame generation and
  an env of fifo are unaffected.
- The slot count follows the container's mode only when the lead is known
  at creation. Before that the guest's own request decides it, as it did
  before; a later lead does not change the count until the swapchain is
  recreated.
- The census reads source=cap-compat while FIFO is declared for want of the
  lead. gate=nominal is gone.
- daemon: the stamp lead is sent as at least 1 ns while the flip events
  follow a grid, so 0 always means "not known" (a GNDRM_GS_LATCH_LEAD_US=0
  override would otherwise read as an old daemon).

A payload that ships only this shim keeps today's pacing until the app ships
the daemon that sends the lead.

- icd_shim/src/vsync_gate.rs: current_grid needs the lead; GateClock::Nominal
  removed; the module notes give the measurement; test for no note, a note
  with lead 0 and a note with a lead.
- icd_shim/src/proxy.rs: choose_live_mode(mode, generating, capped,
  lead_known); slot count; census; the rule-table test gains the no-lead rows
  (env mailbox + cap -> FIFO, env fifo -> fifo either way).
- modules/gndrm-android/src/vsync.rs: stamp_lead_ns() sends max(lead, 1).
- docs/10, docs/15: the compat rule and the payload-only rollout.
…or does not replace it

Retroid Pocket 6, DTD, cap 60, in-game VSync off (requested=immediate),
1ffd476 shim and the new daemon: declared=mailbox gate=vsync, acquires =
commits = releases at 59.3/s on a 59.983 Hz panel, the daemon publishing
582-588 per 600 vsyncs with late=0, SurfaceFlinger 58.33 fps with 2.8 %
double intervals. With VSync on (requested=fifo) the same shim, daemon and
gate gave 59.983 fps and no doubles. On master's FIFO the VSync-off scene
gives 59.22 fps, so the guest itself under-delivers by ~0.7 frames a second
there; the gate added ~1 more double a second on top.

The shim committed every frame it acquired (commits = acquires), and the
daemon missed no latch, so the frames lost between the shim's 59.3 and the
daemon's 58.3 were replaced inside gamescope's mailbox: two commits inside
one composite. That is what Schedule::book did to a late frame. An acquire
that started after its release point went at once (correct), but was booked
to the point it missed, so its successor was released one period later. The
commit waits the frame's render fence (worker_main, then commit_real), so the
late frame reaches gamescope after the composite it was released for; the
successor, on time, reaches it before the next one; gamescope shows only the
successor and the panel repeats the frame before. Each late start cost a
replaced frame and a double. A VSync-on guest blocks in the acquire and is
never late, which is why the FIFO-requesting cell was clean. Neither the
slot count nor the present-wait rule differs between the two cells on this
build (the extra slot follows the container's mailbox in both; both are
answered at commit), and there is no acquire path around the gate.

A late start is now booked by the composite it will make. The present
worker measures each gated frame's acquire-to-commit time and keeps a
smoothed estimate per swapchain; a frame late by delta makes a later
composite than an on-time one when floor((delta + latency + margin) / P) >
floor((latency + margin) / P), with margin 2.5 ms for gamescope's red zone
and composite. A frame that still makes its composite keeps its point and
the schedule goes on as before; one that misses it is booked to the next
point, so its successor goes a period later and each composite gets one new
frame. A guest pipelined over more than a period is judged against its own
on-time latency, so nothing is skipped for it. Late frames are still never
held, so a guest below the cap is still not quantized.

The ungated census gains the counters a device run separates the causes by:
gate_holds (acquires the gate held), late (acquires that started after
their point), skipped (points moved past for them), slot_waits (acquires
that passed the gate and then waited on a compositor release for an image)
and commit_lat_us (the smoothed acquire-to-commit time).

- icd_shim/src/vsync_gate.rs: Schedule::book takes the latency; ON_TIME_NS,
  COMPOSITE_MARGIN_NS, smooth_latency, late_counts; tests: a late frame that
  misses its composite does not share the next one (it fails with the old
  booking), a late frame that still makes it keeps its slot, a pipelined
  guest skips nothing.
- icd_shim/src/proxy.rs: GnSlot::acquired_ns, GnSwapchain::commit_latency_ns
  (set at the gated acquire, measured in commit_real); SLOT_WAIT_N; census.
- docs/10, docs/15: the late-frame rule and the census fields.
Retroid Pocket 6, DTD, cap 60, VSync off, shim 9fb3cda: no change
(SurfaceFlinger 58.26 fps, 2.9 % doubles, daemon publishes 580-586 per 600
vsyncs with late=0, shim 593 acquires/commits/releases per 10 s). The
census showed why: per 10 s, gate_holds 97-154, late 425-478, skipped 6-8,
slot_waits 0, commit_lat_us 14090-16949. The guest paces itself at ~59.3 fps
(Unity's targetFrameRate with VSync off; its HUD reads "limited by: fps
cap"), just below the 59.97 Hz grid, so about three acquires in four arrive
after their release point and the gate shapes almost nothing. Booking those
late starts by their predicted composite moved a handful of points and left
the stream as it was: the frames reach gamescope on the guest's own clock,
and its jitter puts two in one composite now and then.

Schedule::book goes back to the booking of 2c0c609 (a late start is booked
to the point it missed, the next release the next point of the schedule).
The counters stay, because they are what separated the causes: late and
skipped are counted by the same rule, slot_waits and commit_lat_us as
before (the acquire-to-commit time is now census-only).

- icd_shim/src/vsync_gate.rs: book() without the latency; the latency tests
  go; COMPOSITE_MARGIN_NS goes with them.
- icd_shim/src/proxy.rs: book_paced without the latency; comments.
- docs/10, docs/15: the late-frame sentence as before 9fb3cda.
Retroid Pocket 6, DTD, cap 60, VSync off, shim 9fb3cda and the new daemon:
SurfaceFlinger 58.26 fps, 2.9 % doubles; the shim acquired, committed and
was released 593 times per 10 s while the daemon published 580-586 per 600
vsyncs with late=0. Every frame the shim committed reached gamescope, and
every frame gamescope published made its latch, so the ~1 frame a second in
between was replaced inside gamescope's mailbox: two commits between two of
its wakeups. The census (gate_holds 97-154, late 425-478, slot_waits 0,
commit_lat_us 14-17 ms per 10 s) says the guest paces itself (Unity's frame
rate target with VSync off, ~59.3 fps) just below the 59.97 Hz grid: three
acquires in four arrive after their release point, so the gate limits the
rate but shapes nothing, and the frames reach the commit with the guest's
own jitter. FIFO absorbs that jitter with its queue, which is why master's
FIFO declaration gives 59.22 fps on this scene.

The present worker now keeps one commit per composite window while the
acquire gate runs on the vblank grid. Windows run from one gamescope wakeup,
taken as 2.5 ms (its 1.65 ms red zone plus its composite) before a stamp
instant, to the next. A ready frame whose window already has a commit is
held to the stamp instant that closes that window: after the window's
composite, so gamescope shows it at the next one. If a newer frame's render
finishes while one is held, the newer replaces it, as a mailbox present
does (the replaced frame's present wait is answered as discarded). A frame
is held at most one period plus the margin. The hold is on the worker
thread: the guest's acquire and present never wait on it, and the acquire
gate still limits a fast guest's rate. A guest well below the rate rarely
has two frames in one window, so it is rarely held. Present-wait answers are
unchanged: a frame's wait is answered when it is committed, as before.

The census gains win_holds and win_replaced, and the spacing of
consecutive commits since the last line in buckets of <15, 15-16.2,
16.2-17.2, 17.2-20 and >20 ms, the guest's own cadence at the commit.

In a model of the DTD case (59.3 fps +- 1.5 ms on a 59.97 Hz grid, 15 ms to
commit, 1200 frames), commits straight from the fence put two frames in one
composite 6 times; with the window none, 24 frames held, none replaced in
the shim.

- icd_shim/src/vsync_gate.rs: CommitWindow (hold_until, committed),
  COMPOSITE_MARGIN_NS, the window counters; tests: a self-paced guest at
  59.3 fps with jitter gets one commit per composite (and does not without
  the window), a 32 fps guest is never held, a 300 fps guest is held to one
  frame per vblank by the gate and the window replaces nothing, a second
  frame in a window is held to the next stamp instant, re-anchoring.
- icd_shim/src/proxy.rs: hold_for_window and slot_fence_signalled on the
  worker before commit_real; GnSwapchain::window; the commit-spacing
  histogram; census fields.
- docs/10, docs/15: the commit window and the census fields.
Retroid Pocket 6, DTD, cap 60, VSync off, shim 8cf5834: SurfaceFlinger
59.0 fps median, 2.7 % doubles; shim commits 592-594 per 10 s against
daemon publishes 583-590 per 600 vsyncs with late=0, win_holds 18-48 and
win_replaced 0 per 10 s. gamescope still merged ~0.8 commits a second.

The window's boundary was a guess at gamescope's latch point, and that
point is not a fixed offset: gamescope takes client commits at its wakeup,
the target vblank minus its rolling draw time and red zone
(vblankmanager.cpp, 142-199), and the rolling draw time is a peak hold
that jumps on any slow draw (164-165) and decays per wakeup (167). Draw
time runs from wakeup to the page flip queued (DRMBackend.cpp 4465), on
this stack through the daemon's commit, so it is spiky. The client sees
only presented (stamped with the wakeup's target vblank, steamcompmgr.cpp
8140, 8168) and discarded, one inequality per frame, and the bounds close
only as the guest's commit phase sweeps the period, slower than the decay
and never for a guest locked to the grid. A client-side boundary keeps a
moving error band of a few ms, and commits inside it still merge.

The window and its counters go. The histogram of commit spacing stays: it
reads the guest's own cadence at the commit, which is what showed the
guest pacing itself.

- icd_shim/src/vsync_gate.rs: CommitWindow, COMPOSITE_MARGIN_NS and the
  window counters removed with their tests.
- icd_shim/src/proxy.rs: hold_for_window, slot_fence_signalled and
  GnSwapchain::window removed; win_holds/win_replaced leave the census;
  the spacing histogram stays.
- docs/10, docs/15: the window paragraphs removed; the spacing field kept.
…-grid gate kept

Under mailbox at a cap equal to the display's rate the shim has to keep two
commits out of one gamescope composite, and it cannot see when gamescope
latches (previous commit). Retroid Pocket 6, DTD, cap 60, in-game VSync off,
a guest pacing itself at ~59.3 fps on a 59.98 Hz panel:
- mailbox with the gate (1ffd476): 58.33 fps on SurfaceFlinger, 2.8 %
  double intervals, ~1 commit a second replaced in gamescope's mailbox;
- with the late-frame booking (9fb3cda): 58.26 fps, 2.9 %;
- with a one-commit-per-window queue at a guessed boundary (8cf5834): 59.0
  fps, 2.7 %, ~0.8 a second still replaced;
- master's FIFO declaration: 59.22 fps.
At this cap one frame per vblank is all the panel shows under either mode;
under FIFO gamescope latches one commit per vblank at its own wakeup
(steamcompmgr.cpp 8028-8049) and its limiter's divisor is 1.

The rule on the override route, frame generation off:
- the container's (else the guest's) mode is FIFO: FIFO, no gate,
  gamescope's limiter, as before;
- any other mode, a cap at the display's rate (refresh / cap snaps to 1):
  FIFO, and the acquire gate stays on the vblank grid, one release per
  vblank, so the guest never gets ahead of gamescope's FIFO latch and the
  queue stays about one frame deep (source=cap-rate);
- any other mode, a cap below the display's rate: that mode, the gate
  (unchanged: releases at least two vblanks apart rarely put two frames in
  one composite, and FIFO with gamescope's limiter quantized a 32 fps game to
  30 at 60 on 120 Hz);
- no cap: the mode, no gate;
- no stamp lead: FIFO, no gate (source=cap-compat), unchanged.
A cap above the display's rate keeps the mode with the gate on its own
clock. The choice is read per present, so a cap changed mid-session moves
the declaration on the next commit. The slot count still follows the
container's mode; with the gate holding the guest to one frame per vblank
the spare image does not deepen the FIFO queue. A guest that asked for
mailbox or immediate still has its present wait answered at commit; a FIFO
guest's is answered from the feedback, as under any FIFO declaration.

- icd_shim/src/vsync_gate.rs: at_display_rate.
- icd_shim/src/proxy.rs: Declared (Mode, Generating, CapCompat, CapRate);
  choose_live_mode returns the mode and why, from the cap and the grid;
  live_choice; gamescope_route_pacing keeps the gate for CapRate and turns
  it off for a FIFO swapchain and for CapCompat; census source=cap-rate;
  tests: 60 on 59.97 Hz is FIFO with the gate every vblank, 60 on 120 Hz
  and 30 on 60 are mailbox with the gate every second vblank, env fifo with
  a cap is FIFO with no gate, no cap is the env's mode with no gate, no lead
  is cap-compat, frame generation is mailbox on the source-rate clock.
- docs/10, docs/15: the rule table, why mailbox is not declared at the
  display's rate (with the measurements and gamescope's source), the census.

@joshuatam joshuatam left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ce1f919. The design is right and the measurements hold it up: I checked the load-bearing premise in gamescope itself — wlserver_surface_is_fifo (src/wlserver.cpp:3412) is FIFO-only and handle_done_commits_xwayland holds a commit only when entry.fifo (src/steamcompmgr.cpp:8254), so "the limiter is the cap for FIFO, the gate is the cap for everything else" is sound. The rule table, choose_live_mode, gamescope_route_pacing, pacing() and the cap-compat fallback all match it. Wire change is compatible both ways (24 bytes, reserved word at offset 4, old daemon writes zeros → reads as "unknown"). cargo test --workspace 304 pass on your tip, clippy clean with -D warnings. (The occasional shim_integration failure is pre-existing — master flakes the same way, 1/10 runs.)

One blocker: Schedule loses the cap after a clock-booked frame.

book_clock computes the clock plan correctly but leaves ideal_ns = 0, and book's on-time branch trusts it as an absolute instant:

Some(due) if at <= due => (due, self.ideal_ns),

nearest(ideal_ns + interval) then resolves near CLOCK_MONOTONIC zero, max(slot + period) wins every time, and because the guest keeps arriving on time the restart branch never fires — permanent. Test against this tip, 30 cap on 60 Hz, expecting two vblanks per release:

gaps in vblanks: [0, 1, 1, 1, 1, 1]
assertion failed: a 30 cap must stay two vblanks apart  (left: 16675004, right: 33350008)

Reachable mid-session two ways, since both produce Pacing::Clock first: turning frame generation off at a cap below the display rate, and moving a cap from above the panel rate to below it. After either, a 30-on-60 or 60-on-120 cap stops binding. The default 60-at-~60 Hz is immune (interval == period), which is why B1/B7 and C1 wouldn't have shown it. Suggested one-line fix inline; with it the gaps become [1, 2, 2, 2, 2, 2] and all 87 tests pass. Worth a test for the clock→grid transition either way.

Smaller:

  • docs/15 §5 still reports the removed gate (gate_holds, gate_avg_us ≈ 22 ms) as what holds a capped session, which §3 now contradicts. This PR owns that document.
  • Two different late= counters now exist — your gate-side one and the daemon's missed-latch one. Rename one, or call the distinction out in docs/10's census paragraph.
  • docs/17 should say the generation path still runs on the cap's own clock, so nobody reads the vblank grid as covering LSFG.
  • MESA_VK_WSI_PRESENT_MODE is the user-facing control now, and it is already where a user can reach it (the container env, written from the driver's present-mode setting). Worth documenting: it takes Mesa's names (fifo, relaxed, mailbox, immediate — fifo_relaxed is rejected), it is overridden when the cap equals the display's rate (source=cap-rate), and it affects zink guests too since Mesa reads the same variable. Also consider making the "Invalid MESA_VK_WSI_PRESENT_MODE value!" log ungated — today a typo is silent in a shipped session.

On (1) — the rule table. It holds up as written, including FIFO at cap == display rate with the gate kept. B4/B5/B6 justify it: mailbox with any window the shim can guess loses ~1 fps and ~100 replaced frames per minute, because the shim cannot see gamescope's latch point and the draw-time peak hold moves it. Keeping the gate under that declaration is what stops the FIFO queue deepening, so the pair is coherent rather than belt-and-braces.

On (2) — a user choice for that case. No. The container's present mode is already the override everywhere else, and the one place it is overridden is the one you measured losing that fps and those replaced frames — a knob would ship the losing option as a supported configuration, invisible in a support log unless the census is read. MESA_VK_WSI_PRESENT_MODE already exists as an escape hatch for a diagnostic, and source= in the census says afterwards which path ran, which is the property worth having.

Comment thread icd_shim/src/vsync_gate.rs Outdated
/// ([`crate::scanout::next_acquire_after`]).
pub(crate) fn book_clock(&mut self, now_ns: u64, period_ns: u64) -> u64 {
self.next_ns = crate::scanout::next_acquire_after(self.next_ns, now_ns, period_ns);
self.ideal_ns = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The trigger for the state bug described in the review: ideal_ns is read as an absolute CLOCK_MONOTONIC instant by the next book, so leaving it at 0 makes nearest(ideal_ns + interval) resolve near zero and max(slot + period) win every time — the schedule stays at one release per vblank while the guest keeps arriving on time.

Verified against this commit: a 30 cap on 60 Hz gives gaps [0, 1, 1, 1, 1, 1]; with this line they become [1, 2, 2, 2, 2, 2] and all 87 tests pass. Keeping ideal_ns coherent with next_ns also covers the Pacing::Off → grid path, where an ideal from before the pause used to survive and poison the first grid booking.

Suggested change
self.ideal_ns = 0;
// `book` reads `ideal_ns` as an absolute instant: leaving it at 0
// resolves `nearest(ideal_ns + interval)` near CLOCK_MONOTONIC zero and
// the schedule collapses to one release per vblank.
self.ideal_ns = self.next_ns;

…ug.gndrm.mailbox_at_rate)

A test flag for the mailbox-at-display-rate study, not a rule change: the
default rule (FIFO at a cap at the display's rate, source=cap-rate) is
unchanged.

With the Android property debug.gndrm.mailbox_at_rate=1 (or, when the
property is unset, the env GNDRM_MAILBOX_AT_RATE=1), a cap at the display's
rate declares the swapchain's own mode (the container's mailbox) with the
vblank-grid acquire gate kept, instead of FIFO. The census reads
source=env-at-rate. This pairs with a gamescope that holds a second mailbox
commit for the next vblank under its limiter (GameNative/gamescope branch
gn-mailbox-hold-under-limiter, A58), so that path can be measured against
the FIFO declaration.

The effective value is logged once, right after the ICD loaded line:
"mailbox_at_rate=<0|1> source=<prop|env|default>". Unit test covers the
rule with the switch on and the precedence.
Utkarsh Dalal added 2 commits November 3, 2026 08:15
…it can use

Schedule::book_clock left ideal_ns at 0, and Schedule::book's on-time
branch reads ideal_ns as an absolute instant: nearest(0 + interval)
resolved near CLOCK_MONOTONIC zero, max(slot + period) won every time, and
because the guest kept arriving on time the restart branch never fired. A
30 cap on 60 Hz after a clock booking released every vblank (gaps
[0, 1, 1, 1, 1, 1] instead of two apart) for the rest of the session.
Both clock-to-grid transitions reach it: frame generation turned off at a
cap below the display's rate (the source-rate clock first), and a cap moved
from above the panel's rate to below it. A cap at the display's rate was
immune (interval == period), which is why the device runs did not show it.

book_clock now leaves ideal_ns at next_ns, the instant the next acquire is
due, so the first grid booking continues from it.

Reported by @joshuatam in the review of #46, with the fix.

- icd_shim/src/vsync_gate.rs: book_clock sets ideal_ns = next_ns; test: a
  30 cap on 60 Hz stays two vblanks apart after frames booked on the
  source-rate clock and on a cap-above-rate clock (it fails with the old
  line), and with no clock booking.
…'s control, docs/15 and docs/17 as the code is

From the review of #46 by @joshuatam:
- The census's late= (the acquire gate's late starts) read like the
  daemon's late= on its vsync check line (publishes that missed their
  latch). It is now gate_late=, and docs/10 names the difference.
- MESA_VK_WSI_PRESENT_MODE is the user's present-mode control on the
  gamescope route. docs/10 says so: Mesa's names (fifo, relaxed, mailbox,
  immediate; fifo_relaxed is rejected as Mesa rejects it, and the shim's
  parser already matches Mesa's), the one override (a non-FIFO value under a
  cap at the display's rate, source=cap-rate), and that Mesa reads the same
  variable for a zink guest's own swapchain. The rule table's "fifo_relaxed"
  becomes the env's "relaxed". "Invalid MESA_VK_WSI_PRESENT_MODE value!" is
  now logged whether or not GNDRM_LOG_VKSHIM is set, so a typo shows in a
  shipped session.
- docs/15 section 5 still gave the clock gate (gate_holds, gate_avg_us
  ~22 ms) as what holds a capped session, against section 3. It now leads
  with the Retroid Pocket 6 runs of this rule and marks the Retroid Pocket
  Nova numbers as measured with the clock gate, which now runs only for
  frame generation's source rate, a cap above the display's rate and the
  X11 Present route.
- docs/17 says frame generation's source rate is held on the cap's own
  clock (gate=clock), not on the vblank grid.

- icd_shim/src/proxy.rs: census gate_late=; the invalid-value line ungated.
- icd_shim/src/vsync_gate.rs: comment.
- docs/10, docs/15, docs/17 as above.
@utkarshdalal

utkarshdalal commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor Author

@joshuatam thanks for the careful review, and for the fix and the repro. Everything is addressed on the branch:

  • Blocker: Schedule losing the cap after a clock booking. Fixed in 47c11aa with your line: book_clock now leaves ideal_ns = next_ns. The new test a_grid_cap_binds_after_frames_booked_on_the_clock covers both reachable paths:

    • frames booked on frame generation's 40 fps source clock, then a 30 cap on 60 Hz;
    • a 90 cap (above the panel, so on the cap's own clock) moved to 30 on 60 Hz.

    The gaps after the transition are now [2, 2, 2, 2, 2]. The first grid release after a clock booking may land one vblank later, because the clock's last due point is off the grid; the full gaps are [3, 2, 2, 2, 2, 2] after the cap move and [2, 2, 2, 2, 2, 2] with no clock booking. With the old line the test fails with your [0, 1, 1, 1, 1, 1] signature.

  • docs/15 §5: it now leads with the RP6 runs of this rule. The Nova numbers (gate_avg_us ≈ 22 ms) are marked as measured with the clock gate, which now runs only for frame generation's source rate, caps above the display's rate and the X11 Present route (a4a7bb0).

  • Two late= counters: the gate's is now gate_late= in the census. docs/10 says it is not the daemon's late= on the vsync: check line (a4a7bb0).

  • docs/17: now says the generation path's source rate is held on the cap's own clock (gate=clock), not the vblank grid (a4a7bb0).

  • MESA_VK_WSI_PRESENT_MODE as the user's control: documented in docs/10 (a4a7bb0):

    • It takes Mesa's names (fifo, relaxed, mailbox, immediate). fifo_relaxed is rejected, as in Mesa; the shim's parser already accepted exactly those four, so only the docs' "fifo_relaxed" wording needed fixing.
    • It is overridden by source=cap-rate.
    • It also reaches zink guests, because Mesa reads the same variable.

    The "Invalid MESA_VK_WSI_PRESENT_MODE value!" line is now ungated.

Tests at a4a7bb0:

  • cargo test -p adrenotools_gamescope --lib: 88 pass.
  • cargo test --workspace: 305 pass, plus the pre-existing shim_integration flake once (3/3 on rerun).
  • clippy with -D warnings on the shim, daemon and android crates: clean.

Shim artefact: sha256 70aa2f50d5d978228eb7f72004f7dee3ded7c41abbf3f25c504c378d475ba03b, build string 202611030248a4a7bb0.

For context on (2): since your review, gamescope draft PR GameNative/gamescope#31 (A58, a one-deep hold of a second mailbox commit under the limiter) was measured on the same RP6 DTD scene, with mailbox declared at cap == display rate. That is the debug.gndrm.mailbox_at_rate test switch in b084ead; the default is unchanged. Results:

VSync mailbox + A58 master FIFO
off 59.27 fps, 1.2 % doubles 59.22 fps, 1.3 % doubles
on 59.984 fps, 0 doubles

replaced=0 throughout. So the cap-rate exception is only needed while the payload's gamescope lacks A58. Whether to drop it once A58 ships is your and Utkarsh's decision; this PR keeps the exception.

…efault mode

Every container carries MESA_VK_WSI_PRESENT_MODE=mailbox as a stamped
default the user did not choose. With no cap in force the shim declared it
whatever the guest asked for, so a game's own VSync (a FIFO request) became
an uncapped mailbox session: not master's behaviour (master declared the
guest's mode there) and not what the user asked for.

With no cap, on the override route:
- the container's env is fifo: FIFO, as before (an explicit wish wins);
- the guest asked for FIFO or FIFO-relaxed: its own mode (source=guest);
- the guest asked for mailbox or immediate: the container's mode, mailbox
  by default (source=env). Such a request means "do not make me wait", and
  the container's mode is the substitute for it, as on the X11 route; an
  uncapped immediate guest therefore gets mailbox, not tearing;
- no env: the guest's mode, as before.
Capped rows are unchanged: below the display's rate the container's mode
with the vblank-grid gate; at the display's rate FIFO with the gate
(source=cap-rate); env fifo FIFO with gamescope's limiter; frame generation
and cap-compat as before. The rule is read per present, so turning the cap
on or off mid-session moves the declaration on the next commit. The slot
count follows the guest's request in the new case, as on master, when no
cap is in force at creation.

- icd_shim/src/proxy.rs: choose_live_mode takes the mode's source and the
  guest's request; Declared::GuestVsync (census source=guest); the slot
  count; tests: the rule table with the guest's request as a column (no cap
  + env mailbox + guest fifo or relaxed -> that mode, source=guest; guest
  mailbox or immediate -> mailbox, source=env; env fifo -> fifo for every
  guest and cap; capped rows for fifo, mailbox and immediate guests).
- docs/10, docs/15: the rule table and the reason.
@utkarshdalal

Copy link
Copy Markdown
Contributor Author

@joshuatam, one more change, to a single cell of the rule table: 1b708d5, no cap in force.

Before: the container's env always won. Every container carries MESA_VK_WSI_PRESENT_MODE=mailbox as a stamped default that users did not choose. So with no cap, a game's own VSync (a FIFO request) became an uncapped mailbox session. Master didn't do that (it declared the guest's mode there), and it isn't what the user asked for.

Now, with no cap:

  • A guest that asked for FIFO, or FIFO-relaxed, gets it (source=guest).
  • A guest that asked for mailbox or immediate gets the container's mode, mailbox by default (source=env). Such a request means "do not make me wait", and that is the substitute the X11 route uses too. So an uncapped immediate guest gets mailbox, not tearing.
  • An env of fifo still wins everywhere.

Every capped row, frame generation and cap-compat are unchanged. The slot count follows the guest's request in the new case, as on master. The rule-table test now has the guest's request as a column.

Shim: sha256 6501239a4b62ffabce065cfe6243288fe16929701f781ce03d8ac25c693fd63f, build string 2026110303251b708d5. 88 unit tests pass, and clippy is clean.

utkarshdalal pushed a commit to utkarshdalal/GameNative that referenced this pull request Nov 3, 2026
Payload 20261103.2 is 20261102.4 with only lib/libadrenotools_gamescope.so
replaced (a77a7b77), the shim from GameNative/gamenative-drm#46 at 1b708d5:
with no cap, a guest's FIFO request now wins over the container's default
mode. gamescope and Xwayland are unchanged; libgamenative_drm.so is
unchanged (the daemon sources have not changed since 46ccc70).
…nters behind GNDRM_LOG_VKSHIM; the mailbox-at-rate test switch removed

For shipping, the shim's ungated output stays short:
- The present-mode census prints ungated once when frames first flow, and
  again whenever the declared mode, its source, the cap or the gate changes:
    present mode: declared=fifo requested=fifo source=cap-rate
    route=gamescope cap=60 gate=vsync lsfg=0
  source= stays in it, so a support log still says which path ran. The
  10 s repeat with the counters (acquires, commits, declared_fifo/other,
  releases, feedback/presented/discarded/fallback, gate_holds, gate_late,
  skipped, slot_waits, commit_lat_us, spacing) moves behind
  GNDRM_LOG_VKSHIM=1, the switch the shim's stats line already uses. The
  counters themselves still count from the session's start.
- "Invalid MESA_VK_WSI_PRESENT_MODE value!" stays ungated but prints once:
  the env is parsed on every present-mode query and swapchain creation.
- The debug.gndrm.mailbox_at_rate / GNDRM_MAILBOX_AT_RATE test switch from
  b084ead goes, with its load-time line, Declared::EnvAtRate and its test:
  it served the mailbox-at-display-rate measurement, which is done. The rule
  is as before it: FIFO at a cap at the display's rate (source=cap-rate).

- icd_shim/src/proxy.rs: ModeLine and MODE_LINE; mode_census prints the
  short line on a change and the full line behind vkshim_enabled();
  choose_live_mode without the switch; the one-shot invalid-value line.
- icd_shim/src/lib.rs: the switch's load-time call removed.
- docs/10, docs/15: the census paragraphs.
utkarshdalal pushed a commit to utkarshdalal/GameNative that referenced this pull request Nov 3, 2026
Payload 20261103.3 is 20261102.4 with only lib/libadrenotools_gamescope.so
replaced (cfe96ac2, build string 202611030510c9b7c31), the shim from
GameNative/gamenative-drm#46 at c9b7c31: the same present-mode rule as
1b708d5, with the shim's logging gated for shipping. The present-mode line
prints once when frames start and again when the declared mode, source, cap
or gate changes; its 10 s repeat with the counters needs GNDRM_LOG_VKSHIM=1.
The mailbox-at-display-rate test switch is gone. gamescope, Xwayland and
libgamenative_drm.so are unchanged.
@utkarshdalal
utkarshdalal merged commit 150ba5b into master Nov 3, 2026
@utkarshdalal
utkarshdalal deleted the shim-present-mode-from-container branch November 3, 2026 05:58
utkarshdalal added a commit to utkarshdalal/GameNative that referenced this pull request Nov 3, 2026
…play's vsync grid (payload 20261103.1) (#2327)

* gamescope: the present mode follows the container, the cap on the display's vsync grid, payload 20261103.1

libgamenative_drm.so is rebuilt (08d6276c) from gamenative-drm
shim-present-mode-from-container (#46) at a4a7bb0: the daemon's vsync note
carries the flip events' stamp lead, which the shim needs before it gates a
cap on the display's real vblank grid. Daemon sources are unchanged since
46ccc70.

Payload 20261103.1 is 20261102.4 with only lib/libadrenotools_gamescope.so
replaced (fa50add9), the shim from the same head: on the gamescope route it
declares the container's present mode; a non-FIFO mode under a cap at the
display's rate is declared FIFO with the vblank-grid gate kept, and a cap
below the display's rate stays mailbox, enforced by the gate on the real
vblank grid instead of gamescope's divisor limiter. gamescope and Xwayland
are unchanged.

* gamescope: payload 20261103.2, shim from gamenative-drm#46 at 1b708d5

Payload 20261103.2 is 20261102.4 with only lib/libadrenotools_gamescope.so
replaced (a77a7b77), the shim from GameNative/gamenative-drm#46 at 1b708d5:
with no cap, a guest's FIFO request now wins over the container's default
mode. gamescope and Xwayland are unchanged; libgamenative_drm.so is
unchanged (the daemon sources have not changed since 46ccc70).

* gamescope: payload 20261103.3, shim from gamenative-drm#46 at c9b7c31

Payload 20261103.3 is 20261102.4 with only lib/libadrenotools_gamescope.so
replaced (cfe96ac2, build string 202611030510c9b7c31), the shim from
GameNative/gamenative-drm#46 at c9b7c31: the same present-mode rule as
1b708d5, with the shim's logging gated for shipping. The present-mode line
prints once when frames start and again when the declared mode, source, cap
or gate changes; its 10 s repeat with the counters needs GNDRM_LOG_VKSHIM=1.
The mailbox-at-display-rate test switch is gone. gamescope, Xwayland and
libgamenative_drm.so are unchanged.

---------

Co-authored-by: Utkarsh Dalal <utkarsh.dalal@toptal.com>
@utkarshdalal

Copy link
Copy Markdown
Contributor Author

@joshuatam your capture reproduced a real regression. Thanks for taking it.

What it was. In the cap-rate cell (a non-FIFO container under a cap at the display's rate), #46 declared FIFO and kept the vblank-grid acquire gate. FIFO's back-pressure and the gate stacked. Once your scene's acquire-to-commit grew past a period after the live 120 to 60 Hz switch (24 ms, against 7-16 ms at 120 Hz), every start was late against a grid the guest could no longer meet, and the session settled at 40 fps. Your counters show it:

  • gate_holds 0, gate_late 400/400, skipped +200 per 10 s;
  • 400/600 publishes, with the daemon's late=0.

Our RP6 runs of that cell stayed near 12 ms, so they never crossed the period.

Fix: 2567925.

  • The cap-rate cell keeps the FIFO declaration with the gate off (source=cap-rate gate=off). That is master's behaviour in that cell: gamescope's limiter, divisor 1.
  • Under a cap below the display's rate, the release after a late start is now never more than one grid instant after it.
  • The slot count follows the guest's request whenever the swapchain is declared FIFO for a reason other than its own mode, as on master.
  • Tests:
    • 60 on 59.97 Hz is FIFO, cap-rate, gate off;
    • 60 on 120 and 30 on 60 stay mailbox with the gate every second vblank;
    • a guest with 16 ms of work at 59.97 Hz, late every seventh frame, gets a start every vblank.

88 unit tests pass, and clippy is clean.

Payload 20261103.4 will carry it.

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