diff --git a/docs/10-fps-limit.md b/docs/10-fps-limit.md
index ae00beb..4701b7f 100644
--- a/docs/10-fps-limit.md
+++ b/docs/10-fps-limit.md
@@ -104,31 +104,133 @@ divisor cap reads its target exactly.
took the slot; a subscriber whose peer is gone is dropped there, and the
connection currently being served stays its own loop's to close. This is what
makes a cap change made mid-session reach an already-connected shim at all.
-- **gamescope's limiter enforces it; the shim declares FIFO while it is set.** On a
- PC, gamescope's WSI layer declares FIFO to gamescope (`gamescope_swapchain.
- set_present_mode`) while its limiter is engaged, gamescope latches one FIFO commit per
- limited vblank (`steamcompmgr.cpp` `handle_done_commits_xwayland`), and the client is
- paced by `wl_buffer.release`. The shim is that layer here: on gamescope's override
- route it declares FIFO on every commit while the daemon's `FrameCapNote` carries a
- cap, the guest's own mode otherwise, and mailbox while frame generation runs
- (`icd_shim/src/proxy.rs` `live_present_mode`). `MESA_VK_WSI_PRESENT_MODE`, a container
- default for the X11 path, no longer decides that route: honouring it declared every
- commit mailbox, which the limiter never holds, and that is why an acquire gate on the
- shim's own clock used to stand in for the cap. That time gate
- (`scanout::next_acquire_after`) now runs only while frame generation sets the source
- rate, and on the opt-in X11 Present route, which has no compositor declaration.
- gamescope's fixed-rate limiter passes every `refresh / target`-th vblank with an integer
- divisor, so a cap that does not divide the display's rate (40 or 45 on 60 Hz, 45 or 50
- on 120 Hz) runs at the next divisor rate above it; that is upstream gamescope's
- behaviour and is left to it for now.
+- **Who enforces it follows the present mode, and the mode follows the container.**
+ On gamescope's override route the shim declares to gamescope, per commit
+ (`gamescope_swapchain.set_present_mode`), the container's
+ `MESA_VK_WSI_PRESENT_MODE` (mailbox is the container default; the user may set
+ fifo, relaxed or immediate), or the guest's own mode when the env is absent
+ (`icd_shim/src/proxy.rs` `effective_present_mode`, `live_present_mode`).
+ gamescope's limiter holds only commits declared FIFO (`wlserver_surface_is_fifo`):
+ it latches one per limited vblank (`steamcompmgr.cpp` `handle_done_commits_xwayland`)
+ and the client is paced by `wl_buffer.release`. Any other mode is taken at the
+ next composite, so there the shim's acquire gate is the cap:
+
+ | container mode | cap | declared | what limits the rate |
+ |---|---|---|---|
+ | mailbox (default) | none, guest asked for FIFO (in-game VSync on) | fifo (`source=guest`) | the display (FIFO) |
+ | mailbox (default) | none, guest asked for mailbox or immediate | mailbox | nobody |
+ | mailbox | at the display's rate | fifo (`source=cap-rate`) | the shim's acquire gate, every vblank; gamescope latches one FIFO commit per vblank |
+ | mailbox | below the display's rate | mailbox | the shim's acquire gate, on the display's vblank grid |
+ | fifo | none | fifo | the display (FIFO) |
+ | fifo | set | fifo | gamescope's divisor limiter |
+ | any, frame generation on | any | mailbox | the source-rate gate (`docs/17`) |
+
+ FIFO-relaxed (`relaxed`) and immediate behave as mailbox in this table. No env: the guest's
+ own mode takes the env's place. "At the display's rate" is a cap within 0.5 %
+ of the measured rate (60 on a 59.97 Hz panel); a cap above it runs on the
+ gate's own clock under the container's mode.
+
+ **With no cap a guest's FIFO request wins over the container's default.** Every
+ container carries `MESA_VK_WSI_PRESENT_MODE=mailbox` as a stamped default the user
+ did not choose. With no cap, honouring it 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 and
+ is declared as asked (`source=guest`); a mailbox or immediate request means "do not
+ make me wait", and the container's mailbox is the substitute, as on the X11 route
+ (an uncapped immediate guest gets mailbox, not tearing). An env of `fifo` is an
+ explicit wish and wins in every case. Under a cap the table above applies whatever
+ the guest asked for.
+
+ **The user's control is `MESA_VK_WSI_PRESENT_MODE`.** It sits in the container's
+ environment, written from the driver's present-mode setting, and takes Mesa's
+ names: `fifo`, `relaxed` (FIFO-relaxed), `mailbox`, `immediate`. Anything else,
+ `fifo_relaxed` included, is rejected as Mesa rejects it, the guest's own mode is
+ used, and the shim logs `Invalid MESA_VK_WSI_PRESENT_MODE value!` whether or not
+ `GNDRM_LOG_VKSHIM` is set, so a typo shows in a shipped session. It is
+ overridden in one case: a non-FIFO value under a cap at the display's rate is
+ declared FIFO (`source=cap-rate`, below). Mesa reads the same variable, so it
+ also sets the mode a zink guest's own swapchain asks for below the shim.
+
+ **Why mailbox is not declared at the display's rate.** At that cap the panel
+ shows one frame per vblank under either mode, and under mailbox the shim has to
+ keep two commits out of one gamescope composite. It cannot see when gamescope
+ latches: gamescope takes commits at its wakeup, the target vblank minus its
+ rolling draw time and red zone (`vblankmanager.cpp` 142-199), and that draw time
+ is a peak hold that jumps on any slow draw (164-165) and decays per wakeup
+ (167); draw time runs to the page flip queued (`DRMBackend.cpp` 4465), on this
+ stack through the daemon's commit. The client sees only `presented` (stamped
+ with the wakeup's target vblank, `steamcompmgr.cpp` 8140, 8168) and `discarded`,
+ one inequality per frame. Measured on a 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 gave 58.3 fps on `SurfaceFlinger` with ~1 commit a second replaced in
+ gamescope's mailbox; a one-commit-per-window queue in the shim at a guessed
+ 2.5 ms boundary gave 59.0 with ~0.8 a second still replaced; FIFO on master
+ gives 59.22 on the same scene. Under FIFO gamescope latches one commit per
+ vblank at its own wakeup (`steamcompmgr.cpp` 8028-8049), its limiter's divisor
+ at this cap is 1, and the gate keeps the guest from getting ahead, so the FIFO
+ queue stays about one frame deep. Below the display's rate the gate's releases
+ are at least two vblanks apart and two frames in one composite are rare, so
+ mailbox stays (FIFO with gamescope's limiter quantized a 32 fps game to 30 at a
+ 60 cap on 120 Hz; mailbox with the gate did not).
+
+ The gate (`icd_shim/src/vsync_gate.rs`) releases acquires on the vblank grid
+ gamescope composites from, not on a clock of its own: the daemon's `VsyncNote`
+ latch grid (measured period and phase) moved earlier by the lead the daemon
+ stamps flip events with (`stamp_lead_ns`). A cap at the display's rate releases
+ every vblank (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, which averages the cap on vblank boundaries (45 on 60
+ releases 3 of 4 vblanks); a cap above the rate, the cap's own clock. A frame
+ that arrives after its release point starts at once, and the next release is
+ the next point of the schedule, so a game below the cap keeps its own rate. A
+ gate on the shim's own `1e9 / N` clock, as before 7bdcbae, beat against the
+ panel: 57-59 fps on `SurfaceFlinger` at a 60 cap on a 59.97 Hz panel.
+
+ The gate needs the stamp lead. Until a note carrying one has arrived (no note
+ yet, or a daemon that predates the field and sends 0), a capped swapchain whose
+ mode is not FIFO is declared FIFO and gamescope's limiter is the cap, as before
+ the container's mode was honoured here; the census reads `source=cap-compat`.
+ Released on the latch points themselves, frames start ~2 ms after gamescope's
+ wakeup and straddle its composite (Retroid Pocket 6, cap 60: 58.3 fps and 102
+ frames replaced in gamescope's mailbox per minute at 60 Hz, 13 % of publishes
+ late at 120 Hz). A rollout that ships only the payload (the shim) therefore keeps
+ today's pacing until the app ships the daemon that sends the lead. A swapchain
+ created before the lead is known keeps the slot count of the guest's own request
+ until it is recreated.
+
+ The census says which path ran. Ungated, once when frames first flow and again
+ whenever the declared mode, its source, the cap or the gate changes:
+ `present mode: declared=… requested=… source=env|guest|cap-rate|cap-compat
+ route=gamescope cap=… gate=vsync|clock|off lsfg=…` (`source=guest`: the guest's
+ own mode, no env or a FIFO request with no cap; `source=cap-rate`: declared FIFO
+ because the cap is the display's rate, with `gate=vsync`; `source=cap-compat`:
+ declared FIFO for want of the stamp lead, with `gate=off`; `gate=clock` is frame
+ generation's source rate or a cap above the display's rate). With
+ `GNDRM_LOG_VKSHIM=1` the same line repeats every 10 s with the counters a device
+ run checks the pacing against: `acquires= commits= declared_fifo=
+ declared_other= releases= feedback= presented= discarded= fallback=
+ gate_holds= gate_late= skipped= slot_waits= commit_lat_us= spacing=[…]`. They
+ are cumulative except the latency and the spacing: `gate_holds`, acquires the
+ gate held; `gate_late`, acquires that started after their release point (not
+ the daemon's `late=` on its `vsync: check` line, which counts publishes that
+ missed their latch); `skipped`, release points moved past for them;
+ `slot_waits`, acquires that passed the gate and then waited on a compositor
+ release for a free image; `commit_lat_us`, the smoothed acquire-to-commit time;
+ `spacing=[<15:… 15-16.2:… 16.2-17.2:… 17.2-20:… >20:…]`, the spacing of
+ consecutive commits in ms since the last line, the game's own cadence as it
+ reaches gamescope. Off the override route (the opt-in X11
+ Present route) the env decides the mode as in Mesa and the gate runs on the
+ cap's own clock, as before.
- **`-r` is not part of it, and no launch argument carries the limit.** The app
sends the cap to the shim's gate; `--framerate-limit` is never built.
Open points:
-- **Divisor rounding bites the gamescope route only.** `nVblankDivisor =
+- **Divisor rounding bites the FIFO route only.** `nVblankDivisor =
nRefreshHz / nTargetFPS` (`steamcompmgr.cpp:7040`, and `:10621` for the window's
- limited cycle) lands on the nearest divisor of the grid; the shim's floor is
- exact (measured: 60.00 commits/s at a 60 cap).
+ limited cycle) lands on the nearest divisor of the grid, so with the container
+ set to fifo a 40 or 45 cap on 60 Hz runs at 60. Under mailbox the shim's gate
+ averages a non-divisor cap on vblanks instead.
- **The client-visible refresh is the mode's, and the mode is the display's.**
`-r` reaches `g_nOutputRefresh` only transiently (`drm_unset_mode`, `:3680`),
before `drm_set_mode` (`:3728`) overwrites it with the mode's rate —
diff --git a/docs/15-fps-limiter-pacing-and-vsync.md b/docs/15-fps-limiter-pacing-and-vsync.md
index 321c9ed..0564bc6 100644
--- a/docs/15-fps-limiter-pacing-and-vsync.md
+++ b/docs/15-fps-limiter-pacing-and-vsync.md
@@ -11,15 +11,23 @@ cursor, scan-out and mode patches.
| place | what it holds | set by |
|---|---|---|
-| the shim's declared present mode | FIFO while a cap is set, so gamescope's limiter holds each frame to its limited vblank | `FrameCapNote{fps}` from the daemon (`docs/10`) |
-| gamescope's own limiter | `nRefreshHz / nTargetFPS`, a divisor of the mode's rate | `debug_set_fps_limit`, sent only when generation is off |
+| the shim's declared present mode | the container's `MESA_VK_WSI_PRESENT_MODE` (else the guest's mode); with no cap, a guest's FIFO request over a non-FIFO env; FIFO under a cap at the display's rate; mailbox under frame generation | the container, the guest and the cap (`docs/10`) |
+| gamescope's own limiter | `nRefreshHz / nTargetFPS`, a divisor of the mode's rate; holds FIFO commits only | `debug_set_fps_limit`, sent only when generation is off |
+| the shim's acquire gate | the cap, on the display's vblank grid, unless the container's mode is FIFO | `FrameCapNote{fps}` and `VsyncNote` from the daemon |
| the presenter's latch grid | the display's latch points, as measured | `VsyncNote` from the daemon (`docs/17`) |
-They work together: the app hands gamescope the cap, gamescope's limiter passes every
-`nRefreshHz / target`-th vblank (`nVblankDivisor = nRefreshHz / min(target, nRefreshHz)`),
-and the shim's FIFO declaration is what makes the limiter hold the guest's commits. Under
-frame generation the app disarms gamescope's limiter and the shim's time gate holds the
-source rate instead (`docs/17`).
+Which one holds the cap follows the declared mode. Declared FIFO (the container set
+fifo, or no env and a FIFO guest), gamescope's limiter passes every
+`nRefreshHz / target`-th vblank (`nVblankDivisor = nRefreshHz / min(target, nRefreshHz)`)
+and holds the guest's commits to it. Declared mailbox, immediate or FIFO-relaxed (mailbox
+is the container default), the limiter does not hold them and the shim's acquire gate
+releases the guest's frames on the display's vblank grid. A container in one of those
+modes under a cap at the display's rate is declared FIFO with the gate kept
+(`source=cap-rate`): gamescope latches one commit per vblank at its own wakeup, which the
+shim cannot see under mailbox (`docs/10`), and the gate keeps the FIFO queue about one
+frame deep. Under frame generation the app
+disarms gamescope's limiter and the shim's time gate holds the source rate instead
+(`docs/17`).
## 2. The mode is the display's rate
@@ -40,9 +48,9 @@ that rate is the display's.
- **FIFO.** A present is requeued until the display releases a buffer, so the guest is
paced to the panel whether or not it asked to be. On gamescope's override route the
- mode declared per commit is FIFO while a cap is set, the guest's own otherwise, and
- mailbox under frame generation; `MESA_VK_WSI_PRESENT_MODE` decides it only off that
- route (`docs/10`). On that route the shim answers `vkWaitForPresentKHR` from
+ mode declared per commit is the container's `MESA_VK_WSI_PRESENT_MODE` (the guest's
+ own without it), cap or no cap, and mailbox under frame generation (`docs/10`). On
+ that route the shim answers `vkWaitForPresentKHR` from
gamescope's presentation feedback, as Mesa's Wayland WSI does: every commit that
carries a wait requests a `wp_presentation_feedback`, and the wait is answered on
`presented` (at the refresh it reports) or `discarded`. The release comes a vblank
@@ -58,12 +66,12 @@ that rate is the display's.
unfocused game, or one behind a dialog, is therefore still answered once per vblank.
Mesa blocks until the event comes; the shim answers a feedback that never comes
100 ms after the commit, a backstop for a compositor fault, not a path a working
- session takes (the census's `fallback=` counts it). Without the global
+ session takes (the `fallback=` counter of the periodic `present mode:` line counts it; that line repeats with `GNDRM_LOG_VKSHIM=1`, while the ungated one prints at session start and on a change of the declared mode, its source, the cap or the gate, `docs/10`). Without the global
(or off the override route) the wait is answered on the release, or two refresh
periods after the commit when no release report arrives first.
- **Mailbox and immediate.** A guest that asked for either does not wait, and neither
- would one whose frames go out as mailbox (frame generation, or the env off the
- override route while the guest believes it has FIFO), so those waits are answered at
+ would one whose frames go out as mailbox or immediate (frame generation, or the
+ container's env while the guest believes it has FIFO), so those waits are answered at
the commit rather than holding the guest's frame-latency loop for two periods
(`answers_present_wait_at_commit`).
- **A replaced frame.** Under mailbox a frame can be replaced before it is shown. It is
@@ -114,15 +122,32 @@ that rate is the display's.
deadline stamp on that device). The stamps are earlier than the light by the lead plus
the compositor's pipeline; consumers read differences between stamps (frame time),
which a constant offset does not touch.
-- **The guest is paced by gamescope's FIFO latch, not by a gate of its own.** With a
- cap set, the shim declares FIFO on every commit, as gamescope's WSI layer does on a PC
- while the limiter is engaged; gamescope latches one FIFO commit per limited vblank, on
- the display's vblank stream above, and hands the previous buffer back with
- `wl_buffer.release`, which is what frees the guest's next acquire. A gate on the shim's
- own clock (exactly 1e9 / N ns) beat against the display's 59.97-59.99 Hz and, with the
- env's mailbox declaration, left no queue to absorb a late frame. A cap that is not the
- display's rate over a whole number runs at gamescope's next divisor rate above it
- (`docs/10`).
+- **Under FIFO the guest is paced by gamescope's FIFO latch; otherwise by a gate on
+ the display's vblank grid.** Declared FIFO with a cap set, gamescope latches one FIFO
+ commit per limited vblank, on the display's vblank stream above, and hands the
+ previous buffer back with `wl_buffer.release`, which is what frees the guest's next
+ acquire; a cap that is not the display's rate over a whole number runs at gamescope's
+ next divisor rate above it. Declared FIFO because the cap is the display's rate
+ (`source=cap-rate`), the gate below also runs, so the guest starts at most one frame
+ per vblank and the FIFO list stays about one frame deep. Declared anything else,
+ gamescope takes the newest commit at each composite and the shim's acquire gate is
+ the cap (`icd_shim/src/vsync_gate.rs`).
+ Its release points are the instants the flip events are stamped at: the daemon's
+ latch grid (`VsyncNote` anchor and measured period) minus the stamp lead
+ (`stamp_lead_ns`). gamescope wakes to composite just before each (wakeup = stamp +
+ interval − draw time − red zone), so a frame released there starts right after
+ gamescope took the previous one and has the period, less gamescope's margin, to reach
+ the next composite. A cap at the display's rate releases every vblank, a divisor cap
+ every `refresh / cap`-th, any other cap below the rate the vblank nearest each instant
+ of an ideal `1e9 / cap` timeline; a frame late for its point starts at once and the
+ schedule goes on, so a guest below the cap is never quantized to a divisor; a phase
+ push never lets two releases into one vblank. The gate this replaces ran on the shim's
+ own clock (exactly 1e9 / N ns) and beat against the display's 59.97-59.99 Hz
+ (57-59 fps at a 60 cap). A cap above the rate runs on the cap's own clock
+ (`gate=clock`, also frame generation's source rate). Without the stamp lead (no
+ note yet, or a daemon that predates it) the gate does not run: a capped swapchain is
+ declared FIFO and gamescope's limiter holds it (`source=cap-compat`), so a
+ payload-only rollout keeps today's pacing until the app ships the daemon.
- **Fallbacks.** On API 29-32 Choreographer reports the vsync time only
(`AChoreographer_postFrameCallback64`): its measured period is used with the latch
grid's phase (`vsync.rs`, from `ASurfaceTransactionStats_getLatchTime`), or the vsync
@@ -136,16 +161,24 @@ that rate is the display's.
## Behaviour changes in gamescope sessions
-- **FIFO games without a cap are vsynced.** The env's mailbox used to be declared for
- every swapchain; now a guest that asks for FIFO gets FIFO, as on a PC.
-- **Mailbox and immediate games keep the mode they asked for** when no cap is set, and
- are declared FIFO while one is.
-- **`MESA_VK_WSI_PRESENT_MODE` is ignored on the gamescope route** (it still applies to
- the X11 Present route), including a value the user picked in the driver settings; on a
- PC it reaches only Mesa below gamescope's layer.
-- **Caps that do not divide the display's rate** 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.
+- **The container's present mode is declared, except under a cap at the display's
+ rate.** `MESA_VK_WSI_PRESENT_MODE` decides the mode on the gamescope route as on the
+ X11 Present route; without it the guest's own mode is declared. A container left at
+ the mailbox default runs, with no cap, FIFO for a guest that asked for FIFO (its
+ own VSync, `source=guest`) and mailbox for any other guest, and mailbox capped by the shim's
+ vblank-grid gate under a cap below the display's rate. Under a cap at the display's
+ rate it is declared FIFO with the gate kept (`source=cap-rate`), because the shim
+ cannot keep two mailbox commits out of one gamescope composite (`docs/10`). One set
+ to fifo runs FIFO under gamescope's limiter.
+- **A game below the cap keeps its own rate under mailbox** (caps below the display's
+ rate). Under FIFO and gamescope's
+ limiter each frame is held to whole limited vblanks, so a game below the cap judders
+ between divisors of the display's rate, and one that waits on every present holds at
+ the next divisor below (refresh / 2 at a refresh-rate cap); the gate starts a late
+ frame at once instead.
+- **Caps that do not divide the display's rate** run at gamescope's divisor rate under
+ FIFO: `floor(refresh / target)` vblanks, so 40 or 45 on 60 Hz runs at 60 and 50 on
+ 120 Hz at 60. Under mailbox the gate averages them on vblanks.
- **Queue latency.** Under FIFO 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. A guest that uses present wait
@@ -154,15 +187,19 @@ that rate is the display's.
mailbox discipline with the slot count it was created with (`minImageCount + 1`, one
fewer than a swapchain created with generation on) until the guest recreates it.
- **Toggling frame generation** sends the cap change and the generation settings
- separately; between the two, for a moment, the guest's own mode is declared with no
- gate, so a mailbox/immediate guest can run uncapped for a few frames.
+ separately; between the two, for a moment, the swapchain's mode is declared with no
+ cap, so a mailbox/immediate swapchain can run uncapped for a few frames.
## 4. What the daemon owns
- The latch grid: `ASurfaceTransactionStats_getLatchTime` on every publish, the phase
- tracked in `vsync.rs`, and a `VsyncNote` (`anchor_ns, period_ns, lead_ns`) pushed
- whenever its phase moves by more than 300 µs or its period by more than 1 µs, and once
- on subscribe. Its period is the one measured from Choreographer while that is locked
+ tracked in `vsync.rs`, and a `VsyncNote` (`anchor_ns, period_ns, lead_ns,
+ stamp_lead_ns`) pushed whenever its phase, lead or stamp lead moves by more than
+ 300 µs or its period by more than 1 µs, and once on subscribe. `stamp_lead_ns` is the
+ flip events' lead (below), so `anchor_ns - stamp_lead_ns` is gamescope's vblank grid,
+ the one the shim's acquire gate releases on. The daemon sends at least 1 ns while
+ the flip events follow a grid, so 0 means "not known" (no grid, or a daemon that
+ predates the field), and the shim then declares FIFO under a cap instead of gating. Its period is the one measured from Choreographer while that is locked
(the mode's advertised rate otherwise), so frame generation extrapolates
the display's real rate. Its anchor is also the
flip timeline's phase where Choreographer gives no deadline, and its fallback.
@@ -187,11 +224,26 @@ measures — the latch grid to the presenter, the vsync timeline in the flip eve
## 5. Measured on device
+Retroid Pocket 6 (59.97-59.98 Hz), DTD title scene, cap 60, against master's FIFO
+declaration on the same scene (59.22 fps, 1.3 % doubles):
+
+- **A cap at the display's rate, declared FIFO with the vblank-grid gate**
+ (`source=cap-rate gate=vsync`): in-game VSync off, 59.199 fps on `SurfaceFlinger`
+ and 1.3 % doubles over 60 s, shim commits equal to the daemon's publishes within
+ 0-2 per 10 s (the guest paces itself at ~59.3); VSync on, 59.983 fps and no
+ doubles.
+- **The same cap declared mailbox with the gate** lost ~1 commit a second inside
+ gamescope's mailbox (58.3 fps, 2.8 % doubles), which is why it is declared FIFO
+ (`docs/10`).
+
Retroid Pocket Nova (120 Hz panel, a 60 Hz mode available), Hades II, Vulkan through
-zink:
+zink, measured before the vblank-grid gate, when the acquire gate ran on the cap's
+own clock for every capped session (it now does so only for frame generation's
+source rate, a cap above the display's rate and the X11 Present route; a capped
+gamescope session is held as in §3):
-- **The gate holds the cap.** Cap 40: 40.2 acquires a second over 30 s, 0.1 skipped
- batches a second, `gate_avg_us` ≈ 22 ms against a 25 ms period.
+- **The clock gate held the cap.** Cap 40: 40.2 acquires a second over 30 s, 0.1
+ skipped batches a second, `gate_avg_us` ≈ 22 ms against a 25 ms period.
- **Generation follows the multiplier, the display does not.** Cap 40 with frame
generation at x2 / x3 / x4 produces and counts 80 / 120 / 160 frames a second, while
the mode stays at the display's rate (120 Hz, or 60 Hz on a 60 Hz panel).
diff --git a/docs/17-lsfg-frame-generation.md b/docs/17-lsfg-frame-generation.md
index eb03fff..720fff2 100644
--- a/docs/17-lsfg-frame-generation.md
+++ b/docs/17-lsfg-frame-generation.md
@@ -90,7 +90,11 @@ drifting pattern. Three rules keep one frame per period, in order:
64 fps on 120 Hz, flips between x2 and none from batch to batch), and the
display never reshapes it: a 40 cap runs 40 acquires a second on a 60 Hz panel
as much as on a 120 Hz one, and the multiplier decides what is generated on
- top. The panel's rate is the one the app reported (`LsfgConfig.refresh_mhz`),
+ top. The source rate is held by the acquire gate on the cap's own clock
+ (`scanout::next_acquire_after`, `gate=clock` in the census), not on the
+ display's vblank grid: the vblank-grid gate of `docs/10` covers a cap without
+ frame generation only, and generated frames are placed on the latch grid
+ below. The panel's rate is the one the app reported (`LsfgConfig.refresh_mhz`),
not a measurement: while the daemon is the only thing publishing, the latch
samples are spaced by its own rate, so a panel SurfaceFlinger runs below that
rate (an LTPO panel that dropped to 60 Hz) is still paced at the reported rate
diff --git a/docs/README.md b/docs/README.md
index 3fd0f68..8f9847d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -44,7 +44,7 @@ winlator X11 path (which remains the default for normal launches).
12. [12-present-latency-budget.md](12-present-latency-budget.md) — the present-latency budget: what each frame on the paced path pays, and why the flip event is emitted with the publish.
13. [13-single-layer-windowing.md](13-single-layer-windowing.md) — the window/layer model on the single-layer path: what plays layer 0/1/N here, why the content-override mechanism exists, the per-patch layer audit, and the target architecture.
14. [14-guest-surface-commit-stall.md](14-guest-surface-commit-stall.md) — the guest-surface route's queued defect: the commit stream stalls when the guest's Vulkan swapchain comes up, the two candidate causes, and the instrumentation that would decide between them.
-15. [15-fps-limiter-pacing-and-vsync.md](15-fps-limiter-pacing-and-vsync.md) — where a frame's rate is decided: gamescope's limiter on the shim's FIFO declaration, and the presenter's latch grid; and where vsync is real.
+15. [15-fps-limiter-pacing-and-vsync.md](15-fps-limiter-pacing-and-vsync.md) — where a frame's rate is decided: the container's present mode, gamescope's limiter under FIFO or the shim's gate on the display's vblank grid otherwise, and the presenter's latch grid; and where vsync is real.
16. [16-hdr-verification-and-gaps.md](16-hdr-verification-and-gaps.md) — what HDR verification measured, and the gaps left open.
17. [17-lsfg-frame-generation.md](17-lsfg-frame-generation.md) — plan for a `gamenative_drm` LSFG backend: the app's own engine inside the ICD shim, reading the container's `Lossless.dll` and building its own shader cache, reusing `LsfgVkManager` and the quick-menu tab, with the artifacts and the measurements that decide it.
18. [18-lsfg-driver-facts.md](18-lsfg-driver-facts.md) — the properties the LSFG presenter rests on, each one silent when broken: queue-family matching, the engine writing only into its own images, the blit capture, destination depth, guest-only release pacing, the `Rgba8` storage binding, and the settings channel that carries the DLL path.
diff --git a/icd_shim/src/lib.rs b/icd_shim/src/lib.rs
index f836233..94a31fa 100644
--- a/icd_shim/src/lib.rs
+++ b/icd_shim/src/lib.rs
@@ -121,6 +121,7 @@ pub mod segv;
pub mod thunk;
pub mod vk;
pub mod vk_dispatch_gen;
+pub mod vsync_gate;
pub mod wlproto;
pub mod x11;
diff --git a/icd_shim/src/proxy.rs b/icd_shim/src/proxy.rs
index c09512b..f38e07a 100644
--- a/icd_shim/src/proxy.rs
+++ b/icd_shim/src/proxy.rs
@@ -233,13 +233,32 @@ const X11_TRACE_N: usize = 24;
/// so it is bounded to the first few presents only.
const X11_VIEW_N: u32 = 3;
static RATE_STALL: Mutex