Skip to content
26 changes: 17 additions & 9 deletions docs/10-fps-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,23 @@ 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.
- **The shim enforces it** (`icd_shim/src/scanout.rs:118-146`,
`next_acquire_after`): the next acquire may start no sooner than one
`1/target` period after the previous *deadline*, so the guest blocks in
`vkAcquireNextImageKHR` — the one point where waiting costs neither rendered
work nor display latency. The deadline, not the wake: the wait returns a hair
after it, and `max(prev, now) + period` made that hair part of every period
(a 60 cap read 59.78 on an Odin 3 menu, 60.00 once stepped from `prev`).
Only a guest a whole period behind is re-based on `now`, so no debt is
carried out of a stall.
- **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.
- **`-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.

Expand Down
5 changes: 3 additions & 2 deletions docs/11-refresh-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ the app at N while keeping the grid at the mode's rate — they move together.

GameNative passes no `-r`: `g_nNestedRefresh` is 0, so the grid, the app-refresh cycle
and the divisor base all fall back to `g_nOutputRefresh` — the mode's rate. The daemon's
flip timeline is built from that same rate (`VblankTimeline`, one mode period), so the
timeline and the grid agree by construction.
flip events carry the display's measured vsync, not that integer rate (`docs/15` §3), and
gamescope re-anchors its grid on every event, so the grid follows the display's real
period while the mode carries its advertised one.

The **one** mode the connector exposes is the session's scan-out size at the display's
rate (`PanelDesc::modeinfo`, `objects.rs:100-160`, which takes the rate in force;
Expand Down
161 changes: 139 additions & 22 deletions docs/15-fps-limiter-pacing-and-vsync.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ cursor, scan-out and mode patches.

| place | what it holds | set by |
|---|---|---|
| the shim's acquire gate | the user's cap, exactly | `FrameCapNote{fps}` from the daemon (`docs/19`) |
| 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 presenter's latch grid | the display's latch points, as measured | `VsyncNote` from the daemon (`docs/17`) |

They do different jobs, and only the first is a cap in the strict sense: the gate parks
an acquire before a frame is drawn, so the guest converges on the requested rate by
arithmetic rather than by a deadline it can miss. The second is a divisor, so with
generation off the app hands gamescope the limit and lets it express what it can:
`nVblankDivisor = nRefreshHz / min(target, nRefreshHz)`.
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`).

## 2. The mode is the display's rate

Expand All @@ -39,34 +39,151 @@ that rate is the display's.
## 3. Vsync, where it is real

- **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. The shim answers
`vkWaitForPresentKHR` on that release, or two refresh periods after the commit when no
release report arrives first.
- **Mailbox and immediate** are applied behind the guest's back by
`MESA_VK_WSI_PRESENT_MODE`, while the guest still believes it has FIFO and waits on
every frame. A guest that knew its real mode would not wait at all, so the wait is
answered at the commit rather than holding the guest's frame-latency loop for two
periods (`answers_present_wait_at_commit`).
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
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
later under FIFO, when the next frame is latched, and a guest that waits on every
frame (DXVK with VSync on) turned that into two vblanks per frame (35 fps at a 60
cap), so while a feedback is pending neither the release nor the grace answers. Every
pending feedback is keyed by its swapchain, so a recreated swapchain, whose ids start
again at 1, is never answered by the old one's. gamescope answers every commit: it
sends `presented` each vblank for the last latched commit of *every* window, visible
or not ("we return presented for any window not visible … for forward progress",
`steamcompmgr.cpp` before `handle_presented_xwayland`), and `discarded` for a commit
that is replaced or destroyed (`commit_t::~commit_t`) or whose window is gone. An
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
(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
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
never committed, released or latched under that id, so its wait is answered when it is
discarded instead of timing out.
- **Generated frames** are committed by the shim itself on the display's latch points
(`docs/17`); they carry no present id and no wait.
- **The flip event** the daemon emits carries the vblank the frame lands on
(`VblankTimeline`, one mode period), not the instant it is built, so gamescope reads a
completion time rather than a delivery time.
- **The flip event** carries the display's vsync, as a KMS driver's carries the
hardware vblank. gamescope re-arms its composite timer from every event (wakeup =
stamp + interval − draw time − red zone, `vblankmanager.cpp`), so the stamp decides
where in the display's cycle gamescope finishes; a timeline of its own, however
regular, slides against the display. The source is `AChoreographer`
(`gndrm-android/src/choreographer.rs`): one thread with an `ALooper` keeps a callback
posted per vsync while a session runs (and not while it is suspended), and the daemon
fits the period to the measured vsync spacing over the last 32 vsyncs
(`gndrm-daemon/src/vblank.rs`, `VsyncRing`). The steps are counted against the mode's
advertised period and a fit is accepted only within 2 % of it: a "60 Hz" panel that
latches at 59.973 Hz is stamped at 59.973 Hz, while a callback thread starved into
delivering every second vsync counts two periods per step instead of locking at twice
the period. A live mode change reaches the daemon as a new mode
(`nativeSetPanelRefreshHz`) and starts the fit again. The post functions return
nothing, so a callback that has not come within a second is posted once more; the
flip events meanwhile fall back to the latch grid on their own.
- **Which instant is stamped.** KMS has one instant per cycle: the vblank is both the
point a flip must be queued by and the point scan-out starts. Android splits it into
`SurfaceFlinger`'s latch and the present one or more periods later. gamescope uses
the stamp as the point its next commit must beat by its red zone, so the stamps sit
on the **latch** grid, not the present: the preferred frame timeline's deadline on
API 33+ (`AChoreographer_postVsyncCallback`), moved by where `SurfaceFlinger`
actually latched the daemon's publishes against it (`latch-after-deadline`, smoothed
from `ASurfaceTransactionStats_getLatchTime`). A present-time stamp would aim
gamescope's margin at an instant whose phase to the latch is the compositor's work
duration. A publish is claimed for the first latch at or after its apply returned —
the latch that takes it, even when that is after Choreographer's deadline; claiming
the next one would move gamescope's next wakeup a whole period out and leave a latch
with no new frame.
- **The lead.** The stamp is that latch minus a lead. gamescope's 1.65 ms red zone was
sized for a kernel commit; here a commit still crosses the daemon's commit worker and
a binder apply, with about 1 ms of spread. Stamped on the deadline itself (Retroid
Pocket 6, SWD2, cap 60, 2026-11-02) the applies landed 1.0 / 2.1 / 4.5 ms (p10 / p50 /
p90) ahead of the latch, the tail crossed it, and SurfaceFlinger showed 58.65 fps on a
59.967 Hz panel. The lead moves gamescope's whole schedule earlier by the same amount.
It is measured, not fixed: each completed publish's margin (latch − apply) feeds a
quantile loop that holds the 10th percentile at 3 ms (30 % of a period on faster
panels), starting from 2 ms and capped at a third of a period. `GNDRM_GS_LATCH_LEAD_US`
in the app process's environment fixes it instead. The cost is latency: every frame
waits the lead longer between gamescope's commit and the latch (about 2 ms more than a
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`).
- **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
time's own phase until a latch was seen. With Choreographer silent for a second the
latch grid alone is followed, at the mode's period; with neither reporting yet, the
events walk the mode's interval from the clock.
- **Ordering.** A claim is never earlier than the previous one and never repeats an
instant: a second frame inside one period claims the next instant, and a late frame
skips whole periods. Switching sources keeps the rule (the next claim is at least half
a period after the last).

## 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.
- **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
is bounded by its frame latency.
- **Frame generation turned on mid-session** switches a FIFO guest's swapchain to the
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.

## 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 it moves by more than 300 µs, and once on subscribe.
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
(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.
- The lead: how far ahead of a latch point a commit has to be made to be the frame that
point takes, driven so the margin sits at half a period.
- The flip event's timestamp, as above.

The daemon derives no rate. The launcher hands it the display's rate and the cap, and it
reports back only what it measures.
- The display's vsync timeline: Choreographer's vsyncs on a thread of its own while a
session runs, the period fitted to their spacing, the phase from the frame deadline
(or the latch grid), and the flip event's timestamp on it, as above. One ungated line
marks each lock — `vsync: flip events on the display's vsync: source=… period=…us
(… Hz, mode … Hz) latch-vs-grid=…` — the first, and the first after a rate change.
Another, every 10 s while publishes complete, is the cross-check: `vsync: check
source=… period=…us (… Hz) publishes=… vsyncs=… late=… pushed=… margin
p10/p50/p90=…/…/… lead=… latch-after-deadline=…`. `late` counts publishes that missed
the latch they were aimed at; `pushed`, flip events moved past their latch by the
one-claim-per-instant rule; publishes below vsyncs at a cap at or above the panel's
rate is gamescope skipping a wakeup.
- The stamp lead, as above.

The daemon derives no rate for the mode. The launcher hands it the display's rate and the
cap; the connector advertises that rate, and the daemon reports back only what it
measures — the latch grid to the presenter, the vsync timeline in the flip events.

## 5. Measured on device

Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: the acquire gate, gamescope's divisor, 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: gamescope's limiter on the shim's FIFO declaration, 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.
Expand Down
Loading