Skip to content

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

Closed
utkarshdalal wants to merge 5 commits into
masterfrom
flip-events-on-the-real-vsync-pr
Closed

utkarshdalal wants to merge 5 commits into
masterfrom
flip-events-on-the-real-vsync-pr

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Base master (efd50ab), head flip-events-on-the-real-vsync-pr (f5ac0bc), four commits plus a one-line docs fix: two in the daemon, two in the shim.

Problem

Measured on a Retroid Pocket 6 (Android 13, Adreno 740, 1080x1920 panel in its 60 Hz mode), gamescope session, cap 60.

Two free-running 60.000 Hz clocks against a 59.97-59.99 Hz panel.

  • The daemon's flip events were stamped on a walk of its own: 1e12 / mode_refresh_mhz() from CLOCK_MONOTONIC, exactly 16 666 666 ns on a "60 Hz" mode. gamescope re-arms its composite timer from every flip event (DRMBackend.cpp page_flip_handler → MarkVBlank; vblankmanager.cpp: wakeup = stamp + interval − draw time − red zone). So the stamps decide where in the display's cycle gamescope commits.
    • The panel latches at 59.97-59.99 Hz, so the walk slid across SurfaceFlinger's latch about every half minute.
    • While gamescope's commit sat on the latch edge, about 1 ms of post jitter put two buffers into one latch window, and SurfaceFlinger dropped the older one. The HUD read 58.7-59.3 fps and SurfaceFlinger 54-57.
  • The shim's acquire gate released frames on a third clock, exactly 1e9 / N ns. It also beat against the panel (57-59 fps at cap 60). And because the container's MESA_VK_WSI_PRESENT_MODE=mailbox decided the mode declared to gamescope, every commit was mailbox, which gamescope's limiter never holds. A frame released by the gate had one period to reach gamescope's latch, with no queue behind it to absorb a late frame.

35 fps with VSync on. Making the shim declare FIFO (commit 3) exposed a second problem. A guest that waits on every frame (DXVK with in-game VSync on: FIFO, vkWaitForPresentKHR, frame latency 1) ran at 35.3 fps at cap 60, with 364 of 526 intervals two vblanks long. The shim answered the present wait from wl_buffer.release. Under FIFO that comes when the next frame is latched, a vblank after this one was shown, so every frame started a vblank late.

What the native stacks do

  • A KMS driver stamps the page-flip event with the hardware vblank the flip took. That vblank is both the point a flip must be queued by and the start of scan-out. Nothing on a PC drifts against the display, because the display is the only clock.
  • gamescope's WSI layer (VkLayer_FROG_gamescope_wsi.cpp) always creates the driver swapchain as MAILBOX and implements FIFO itself. Per present, it declares the app's own mode through gamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter is engaged. gamescope latches one FIFO commit per limited vblank (steamcompmgr.cpp handle_done_commits_xwayland). The client is paced only by wl_buffer.release. MESA_VK_WSI_PRESENT_MODE acts on Mesa below the layer and never changes what is declared.
  • Mesa's Wayland WSI answers vkWaitForPresentKHR from wp_presentation. Each present that carries an id requests a wp_presentation_feedback, and presented or discarded signals that id. gamescope offers wp_presentation (version 1, CLOCK_MONOTONIC). It sends presented for the commit it latched, with the refresh it targets, and discarded for a commit it replaced or never showed.

The shim already plays the WSI layer's part on gamescope's override route: its own wl_surface, wrapped in a gamescope_swapchain, with slots reused only on release. This PR makes the daemon act like the KMS driver and makes the shim declare and wait the way the layer and Mesa do.

Commits

1. daemon: flip events carry the display's real vsync, read from Choreographer

  • The vsync source. A thread with an ALooper keeps one Choreographer callback posted per vsync while a session runs, and parks between sessions. It uses AChoreographer_postVsyncCallback on API 33+ and postFrameCallback64 on 29-32.
  • The period is fitted by least squares over the last 32 vsyncs. Each step is counted in whole periods, so a missed callback counts as two steps, not one long one. A live mode switch re-seeds the fit. The mode's integer rate never enters it: a "60 Hz" panel that latches at 59.973 Hz is stamped at 59.973 Hz.
  • Which instant is stamped. KMS has one instant per cycle. Android splits it into SurfaceFlinger's latch and the present one or more periods later. gamescope uses the stamp as the point its next commit must beat by its red zone, so the stamps sit on the latch grid:
    • the base is the preferred frame timeline's deadline;
    • it is moved by where SurfaceFlinger actually latched the daemon's publishes (latch-after-deadline, smoothed from ASurfaceTransactionStats_getLatchTime).
    • A present-time stamp would aim gamescope's margin at an instant whose phase to the latch is the compositor's work duration.
  • The lead. The stamp is that latch minus a measured lead. gamescope's 1.65 ms red zone was sized for a kernel commit. Here a commit still crosses the commit worker and a binder apply, with about 1 ms of spread.
    • Stamped on the deadline itself, the applies landed 1.0 / 2.1 / 4.5 ms (p10 / p50 / p90) ahead of the latch, and the tail crossed it: SurfaceFlinger showed 58.65 fps on a 59.967 Hz panel.
    • A quantile loop holds the 10th percentile of latch − apply at 3 ms (30 % of a period on faster panels). It starts at 2 ms and is capped at P/3.
    • GNDRM_GS_LATCH_LEAD_US in the app process's environment fixes the lead instead.
    • Cost: about 2 ms more between gamescope's commit and the latch.
  • The VsyncNote (frame generation's latch grid) now carries the measured period while Choreographer is locked. It is pushed on a period move above 1 µs.
  • Fallbacks. On API 29-32, the measured period is used with the latch grid's phase. With Choreographer silent for a second, the latch grid alone is followed at the mode's period. Before either source reports, the old walk runs.
  • Ordering. A claim is never earlier than the previous one and never repeats an instant.

2. daemon: stamp the flip from the apply's start; log where a held or late commit waited

  • The stamp. A KMS event reports the vblank the flip actually took. The daemon used to claim the first latch after the apply returned.
    • When the commit worker was preempted inside or after the apply, the event named the next latch, and gamescope's next wakeup moved a whole period out. In one SteamWorld Dig 2 trace, SurfaceFlinger had the frame 0.85 ms before its latch while the apply returned 6 ms after it; that case caused 3 of 20 doubled intervals in 30 s.
    • The claim now uses the instant the apply started. The event is still sent with the publish, not held for SurfaceFlinger's latch report, which comes a period or more later.
    • A stamp in the past is what a KMS event always carries: gamescope steps its next wakeup forward whole intervals.
  • Commit timing. Each commit carries stamps from the ioctl to the apply. A commit that was held more than 2 ms, or that missed its latch, logs one ungated line (at most one a second).
  • Thread names. The gndrm threads keep their names across JNI attach. Without this, ART renamed them Thread-N and they could not be told apart in ps/top.

3. shim: declare FIFO while a cap is set, as gamescope's WSI layer does; gamescope's limiter is the cap

On the override route, the mode declared per present is:

  • mailbox under frame generation, unchanged: generated commits carry no declaration, and a real frame declared FIFO would queue behind them;
  • FIFO while the daemon's FrameCapNote carries a cap, which is the same value the app hands gamescope's limiter;
  • otherwise the guest's own mode.

Other effects:

  • MESA_VK_WSI_PRESENT_MODE no longer decides this route. It still applies on the opt-in X11 Present route.
  • The extra mailbox slot follows the guest's request.
  • The acquire time gate now runs only while frame generation sets the source rate (the app disarms gamescope's limiter then) and on the X11 Present route.
  • One ungated line every 10 s reports the declared and requested modes and counts.

4. shim: answer the present wait from gamescope's presentation feedback

  • The request. wp_presentation is bound with the gamescope swapchain factory. A FIFO commit that carries a wait requests a feedback before wl_surface.commit, and the id is registered as pending first.
  • The answer. presented answers the wait at the reported time; discarded answers it at once. While a feedback is pending, neither the release nor the two-period grace answers. A feedback that never comes is answered 100 ms after the commit.
  • An id is answered once, whichever path gets there first.
  • Unchanged:
  • The present-mode line gains feedback= presented= discarded= fallback=.

Docs: docs/15 §1, §3 and §4, docs/10, docs/11 and docs/README change with the commits they describe.

Measured

Retroid Pocket 6, cap 60, 60 Hz mode, same scene per game, frames counted at SurfaceFlinger (dumpsys SurfaceFlinger --latency).

What each column ran.

  • Before: master daemon (60.000 Hz walk) and the shipped shim (acquire gate, mailbox declared from the env).
  • After: this branch's daemon (libgamenative_drm.so d48e27cd…) and shim (libadrenotools_gamescope.so b9aedd01…), plus two other changes:
    • gamescope with A57 (nice −20 through RLIMIT_NICE; GameNative/gamescope nice-via-rlimit-nice);
    • the app keeping the gamescope chain off SurfaceFlinger's cores (GameNative app PR, branch gamescope-real-vsync-payload-20261102-1).

The daemon and shim alone have not been measured without those two.

Game Before After
Dave the Diver (DXVK), in-game VSync on 35.3 fps (two-vblank cadence, 364 / 526 intervals doubled) 59.95 fps at a 59.99 Hz panel; 0 discarded and 0 fallback present-wait answers over 120 s (7195 presented)
Dave the Diver, VSync off 54-57 59.4 (now limited only by the game's own 59.55 Hz timer pacing)
SteamWorld Dig 2 (GL via Xwayland) 57.1 (periodic drop zones) 59.5, drop zones gone

The SteamWorld Dig 2 residual (about 1 % doubled intervals) is not fixed by this PR and is under separate investigation.

From the VSync-on run's daemon check lines: publishes 597-600 per 600 vsyncs, late=0 in every window, margin p10 held at +3.0 ms, latch-after-deadline +0.46 to +0.70 ms.

What does not change

  • Frame generation (LSFG).
    • It keeps mailbox declarations, the shim's time gate as the source-rate limiter, and its own latch-grid commits.
    • The VsyncNote keeps its wire layout. Its period is now the measured one, which is closer to the display.
    • Not re-measured on device in this round.
  • Mailbox and immediate guests still get their present wait answered at the commit (icd_shim: answer the present wait at commit under mailbox/immediate (47 -> 60 fps at cap 60) #37).
  • Caps that are not the display's rate over a whole number run at gamescope's divisor rate. gamescope's fixed-rate limiter passes every floor(refresh / target)-th vblank, so 40 or 45 on 60 Hz runs at 60, and 50 on 120 Hz runs at 60. That is upstream gamescope's behaviour and is left to it for now. Before this PR the shim's time gate held those caps.
  • The mode the connector advertises is still the display's integer rate.

Known limitations

  • Queue latency. Under FIFO with a cap, a guest faster than the cap queues up to minImageCount + 1 frames in gamescope, as on a PC. That can add up to about two limited periods of input latency against the old zero-depth gate. A guest that uses present wait (DXVK with VSync on) is bounded by its frame latency.
  • Uncapped FIFO guests are now vsynced at the refresh, as on a PC, instead of rendering uncapped into a mailbox.
  • The lead adds about 2 ms between gamescope's commit and the latch.
  • Choreographer's frame deadline exists on API 33+ only. API 29-32 uses the latch grid's phase, and that path has not been run on device in this round.
  • Pre-existing: the guest-Wayland route (unused by winex11) calls set_present_mode on a null gamescope_swapchain. It is untouched here.

Tests

  • cargo test --workspace --exclude gn-utils in rust:1-bookworm (with libwayland-dev libdrm-dev libvulkan-dev libxcb-{dri3,present,sync}-dev): all pass.
    • Covers gndrm-daemon (96 unit, 36 golden, 8 diag), gndrm-android (lib + dump/layout/log_policy/vsync) and gndrm-uapi (19 + 2 ABI ground truth, with /usr/include/drm → libdrm headers).
    • Shim crate: cargo test -p adrenotools_gamescope --lib, 70 passed.
  • New tests cover:
    • the period fit, rate changes, pauses, source selection and staleness;
    • the claim rules, the lead loop and the report;
    • the apply-start claim;
    • the live present-mode choice;
    • feedback presented / discarded / pending-blocks-release / safety timeout.
  • Reproducibility.
    • tools/build-android.sh-equivalent builds (NDK 27.3, rustc 1.98.1, daemon and shim as separate cargo build invocations) from this head give a daemon byte-identical to the device-tested one (d48e27cd…).
    • The shim differs from the device-tested b9aedd01… only in the 10 bytes of its embedded build stamp and git tag (GN_SHIM_BUILD / GN_SHIM_GIT). This head's tree is identical to the tree that was device-tested.

How to test

Use a gamescope session with a cap of 60 on a 60 Hz panel, with payload 20261102.1 and the app build that carries the daemon. In logcat, all of these lines are ungated:

  • Daemon lock line, once per lock:
    gndrm-daemon: vsync: flip events on the display's vsync: source=choreographer-deadline period=16676.286us (59.965 Hz, mode 60.000 Hz) latch-vs-grid=…
    The period should be the panel's real rate, not 60.000.
  • Daemon check line, every 10 s:
    gndrm-daemon: vsync: check source=choreographer-deadline period=… publishes=… vsyncs=600 late=0 pushed=0 margin p10/p50/p90=+3.0ms/… lead=… latch-after-deadline=…
    Expect publishes close to vsyncs, late 0-1, and margin p10 about +3.0 ms.
  • Shim present-mode line, every 10 s:
    gn-vkshim: present mode: declared=fifo requested=… route=gamescope cap=60 lsfg=0 … feedback=on presented=… discarded=0 fallback=0
    declared=fifo is the cap in force, and presented should climb with commits.
  • Commit timing line, only when a commit was held more than 2 ms or missed its latch:
    gndrm-daemon: commit timing (us from ioctl): queued=… pickup=… … apply_start=… apply_ret=… latch=… held=0|1 missed_latches=N
  • The rate: dumpsys SurfaceFlinger --latency <layer> over 30 s should read the panel's rate (about 59.97-59.99 on the RP6). Turning in-game VSync on in a DXVK game should no longer halve the rate.
  • Regression checks:
    • frame generation x2 at cap 40: declared=mailbox lsfg=1, 80 fps;
    • an uncapped session: cap= reads the no-cap sentinel (FPS_UNCAPPED), and declared= is the guest's own mode.

Utkarsh Dalal added 5 commits November 2, 2026 11:40
…rapher

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

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

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

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

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

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

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

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

A KMS flip event reports the vblank the flip actually took. The daemon stamped
the first latch after its apply *returned*. When the commit worker was preempted
inside or after the apply (SteamWorld Dig 2, RP6: at 18.42 s SurfaceFlinger had
the frame 0.85 ms before its latch and the apply returned 6 ms after it), the
event named the next latch and gamescope's next wakeup moved a whole period out:
3 of 20 doubled intervals in a 30 s trace.

The stamp now comes from the instant the apply started, which the presenter
reports (Presenter::take_apply_start_ns): SurfaceFlinger can take the
transaction from then on. The event is still sent with the publish. Stamping it
with the latch SurfaceFlinger reports would mean holding the event for the
completion, a period or more later, and putting that round trip back into
gamescope's present loop. A stamp in the past is what a KMS event always
carries: gamescope arms its next wakeup at stamp + interval - draw time - red
zone and steps forward whole intervals if that has passed. The claim is never
earlier than the previous one, as before. The clock stays the fallback for a
commit that applied nothing (and for test presenters).

Four other doubles were commits the daemon held 4.6-6.6 ms with its threads
idle. Each commit now carries stamps from the ioctl through the worker to the
apply (Presenter::note_commit_timing, CommitTiming), copied into the buffer's
completion mailbox at the apply. A commit whose apply started more than 2 ms
after it was queued, or that missed the latch it was due for, logs one ungated
line from its completion, at most one a second:

  gndrm-daemon: commit timing (us from ioctl): queued=… pickup=… state_lock=…
  pool=… retired=… fence_wait=… apply_start=… apply_ret=… latch=…
  held=0|1 missed_latches=N

(times from the start of the atomic ioctl; fence_wait is the duration of the wait
on this buffer's previous release fence; missed_latches counts latches after the
first one following the queue.)

Reading those threads in ps/top needed their names: the commit worker and the
scan-out thread read as Thread-72 and Thread-74 in the app process. Both are
named (gndrm-commit, gndrm-scanout), but they attach to the VM for the
onFramePublished and clearBootingSplash upcalls, and ART names a thread it
attaches without a name Thread-N and renames the native thread to match.
call_app_static now reads the kernel name before a first attach and sets it back
after.

- modules/gndrm-daemon/src/lib.rs: CommitTiming, mono_ns, the two Presenter
  methods, CommitJob received/queued stamps, the worker's pickup stamp and the
  apply-start claim.
- modules/gndrm-daemon/src/commit.rs: the ioctl and queue stamps.
- modules/gndrm-daemon/src/vblank.rs: the claim's documentation; a test.
- modules/gndrm-android/src/asc.rs: the stamps through publish (state lock,
  pool, retire, fence wait, apply start) into the registration, the apply start
  for the worker, and log_commit_timing in the completion.
- modules/gndrm-android/src/lib.rs: call_app_static keeps the thread's name.
- docs/15-fps-limiter-pacing-and-vsync.md: the claim from the apply start, and
  why the event is not held for the latch.
…ayer does; gamescope's limiter is the cap

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

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

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

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

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

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

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

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

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

- icd_shim/src/wlproto.rs: wp_presentation / wp_presentation_feedback
  interfaces, presentation_feedback().
- icd_shim/src/proxy.rs: bind, clock and feedback listeners, the request in
  commit_real, census fields.
- icd_shim/src/present.rs: expect_feedback, feedback_presented /
  feedback_discarded, cancel_feedback, the pending rule for release and grace,
  counters; tests.
- docs/15-fps-limiter-pacing-and-vsync.md: the FIFO present-wait answer.
@utkarshdalal

Copy link
Copy Markdown
Contributor Author

superseded by #44

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant