Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
130 changes: 108 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,120 @@ 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; a
feedback that never comes is answered 100 ms after the commit. 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 the daemon fits the period to the measured
vsync spacing over the last 32 vsyncs (`gndrm-daemon/src/vblank.rs`, `VsyncRing`). The
mode's integer rate never enters it: a "60 Hz" panel that latches at 59.973 Hz is
stamped at 59.973 Hz, and a live mode change is followed when the spacing changes.
- **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 *started* —
the latch that takes it, even when that is after Choreographer's deadline, and even
when the commit worker was preempted inside or after the apply and returned past the
latch (SurfaceFlinger already had the transaction). Claiming the next one would move
gamescope's next wakeup a whole period out and leave a latch with no new frame. The
event is still sent with the publish, not at the latch: the latch time
`SurfaceFlinger` reports comes with the completion a period or more later, and holding
the event for it would put that round trip into gamescope's present loop (`docs/12`).
A stamp in the past is what a KMS event always carries (it is read after the vblank);
gamescope arms its next wakeup at stamp + interval − draw time − red zone and steps
forward whole intervals if that has passed.
- **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).

## 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