Skip to content

display: the mode is the display's rate, the cap is at acquire, and generation follows the multiplier - #41

Merged
utkarshdalal merged 3 commits into
masterfrom
fix/fps-limiter
Nov 1, 2026
Merged

utkarshdalal merged 3 commits into
masterfrom
fix/fps-limiter

Conversation

@joshuatam

@joshuatam joshuatam commented Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

The emulated display's mode is the rate the compositor runs at, so it is the display's own and nothing else: the fps cap and the frame-generation multiplier never enter it. The cap is enforced at acquire, before a frame is drawn, and a batch generates multiplier - 1 frames plus the source frame, so a 40 cap produces and counts 80 frames a second at x2, 120 at x3 and 160 at x4, on any display.

Measured on a Retroid Pocket Nova, Hades II, cap 40, changing only the multiplier in the QuickMenu, one session per display:

display x2 x3 x4 mode
120 Hz 80.0 120.0 160.0 120 Hz throughout, one selecting mode, no backend dirtles
60 Hz 79.8 120.0 160.0 60 Hz throughout, 40.3 acquires/s

What changed

  • The mode is served live. The connector's mode is built from process state (set_advertised_refresh_mhz) on every read instead of being snapshotted at session start, so a display change reaches a running session; the app applies one by dirtying gamescope's backend (backend_set_dirty, allowlisted in the injector).
  • The ring counts what was produced. FrameAcquired { count, generated } carries the shim's acquire and generation totals, and the daemon's deltas drive onFramePublished, so a session running above the display's rate reads as that rate rather than as what the display latched.
  • Pushes go to every subscriber. Cap and latch-grid notes are delivered to every connection that subscribed, not just the one holding the allocator (pushes.rs, new), so a superseded connection keeps receiving them.
  • A retired connection that asks to allocate yields the allocator back, so the shim reconnects and reclaims it instead of waiting out its exchange on a dead channel — this is the Vsync toggle freeze.
  • Generation follows the multiplier. panel_budget and source_pace are gone: nothing about the display reshapes the source rate or caps the output, and a batch is admitted once per acquired source frame rather than once per present, which is what had x4 producing 164 where 160 was asked for.
  • The latch grid is used only when a batch fits the source period; a batch that does not goes out spread evenly on the shim's clock, because on the grid the source frame lands a period late and the queue carries that lateness forever.
  • The daemon's vblank grid is the mode's rate. The flip timeline used to claim a fabricated 833 us tick from a constant of its own while the mode carried the display's rate; SESSION_REFRESH_MHZ is gone and an event's timestamp is the vblank the frame lands on, one display period after the previous one.

Docs 10, 11, 17, 19 and the index are updated to the current model, and docs/15 is rewritten: it was a 752-line audit of the fabricated grid, and is now the current state of where a rate is decided, the mode, where vsync is real, and what the daemon owns.

Verification

cargo test --workspace: 256 passed, 0 failed. Clippy clean on the host and aarch64-linux-android. Device runs are the table above, plus 6 consecutive swapchain recreations through a Vsync toggle with 0 slot init failed. The fabricated session grid is gone from the daemon, the docs and the gamescope patch train.

Paired changes

…eneration follows the multiplier

The emulated display's mode is the rate the compositor runs at, so it is the display's own
and nothing else: the cap and the frame-generation multiplier never enter it. The cap is
enforced at acquire, before a frame is drawn, and a batch generates `multiplier - 1` frames
and the source frame, so a 40 cap produces and counts 80 at x2, 120 at x3 and 160 at x4,
whatever the mode. Measured on the Retroid Pocket Nova, one session per display, changing
only the multiplier in the QuickMenu: 120 Hz display 80.0 / 120.0 / 160.0; 60 Hz display
160.0 at x4 with 40.3 acquires a second; the mode re-selected only when the display changed.

The rate is served live — the connector's mode is built from process state on every read —
so a display change reaches a running session, and the app applies one by dirtying
gamescope's backend (`backend_set_dirty`). Nothing else moves the mode.

The frame ring counts what the session produced: the shim reports its acquire and generation
totals, and the daemon's deltas drive `onFramePublished`, so a session running above the
display's rate reads as that rate rather than as what the display latched.

Cap and vsync notes go to every registered subscriber rather than to the connection holding
the allocator, so a superseded connection keeps receiving them; and a retired connection
that asks to allocate yields the allocator back, so the shim reconnects and reclaims it
instead of waiting out its exchange on a dead channel.

- `modules/gndrm-daemon/src/pushes.rs` (new): the process-wide subscriber registry
  (`add`/`forget`/`holds`/`wants`/`fds_except`/`push`/`close_all`,
  `set_served`/`clear_served`), non-blocking sends that keep a sink whose buffer is full,
  and seven tests serialized by one lock.
- `modules/gndrm-daemon/src/lib.rs`: the advertised rate as process state
  (`set_advertised_refresh_mhz`/`advertised_refresh_mhz`, unknown resolved to
  `UNKNOWN_PANEL_HZ`), `Device::mode()` built per connector read instead of stored at
  construction, and `pub mod pushes`.
- `modules/gndrm-daemon/src/objects.rs`: `PanelDesc` keeps the surface size only,
  `modeinfo(refresh_mhz)` takes the rate in force, and the docs and tests follow.
- `modules/gndrm-daemon/src/edid.rs`, `modules/gndrm-daemon/tests/golden.rs`: the mode is
  built with the rate passed in.
- `modules/gndrm-daemon/src/server.rs`: one allocator at a time, but pushes are per
  subscriber for the rest of the session.
- `modules/gndrm-android/src/asc.rs`: the registry for cap and vsync notes, the served
  descriptor guard, `note_produced` feeding the ring from `FrameAcquired` deltas (bounded
  per message), and `drain_retired` yielding a connection that asks to allocate.
- `modules/gndrm-android/src/lib.rs`: a superseded connection keeps its push subscription,
  the idle acceptor polls the note eventfds and retired descriptors, the ring is driven by
  production, and the display's rate is the daemon's.
- `modules/gndrm-android/src/vsync.rs`: subscription state and the measured lead.
- `modules/gndrm-android/src/injector.rs`: `backend_set_dirty` allowlisted, so the app can
  ask gamescope to re-read the connector.
- `modules/gndrm-uapi/src/wire.rs`: `FrameAcquired { count, generated }` with round-trip
  tests.
- `icd_shim/src/proxy.rs`: one generation batch per acquired source frame, no clamp by the
  panel, the latch grid used only when a batch fits the source period, and the source rate
  taken from the user's cap.
- `icd_shim/src/scanout.rs`: `send_frame_acquired(count, generated)`.
- `icd_shim/src/lsfg/mod.rs`: `source_cap` is the user's cap, unreshaped by the display.
- `modules/gndrm-lsfg/src/pacing.rs`: the panel's latch grid only; `panel_budget` and
  `source_pace` are gone with their tests.
- `docs/10-fps-limit.md`, `docs/11-refresh-model.md`, `docs/17-lsfg-frame-generation.md`,
  `docs/19-fps-limiter-acquire-rework.md`: the mode is the display's and is served live, the
  cap is at acquire, the ring counts production, generation follows the multiplier, and no
  display rate reshapes the source.
…id is gone

The daemon claimed flip events on a constant grid of its own while the mode carried the
display's rate, so the two disagreed: every event was stamped one 833 us tick after the
previous one whatever the display was doing, and gamescope was handed completion times on
a timebase nothing else in the session used. Nothing needs that grid any more — the
launcher passes no `-r`, so the mode's rate is the only rate in the session — and
`SESSION_REFRESH_MHZ` is deleted rather than left as a value with no caller.

The timeline now claims one display period, from the same rate the connector advertises,
so an event's timestamp is the vblank the frame lands on and the agreement the old
constant was there to guarantee comes from there being one rate at all.

- `modules/gndrm-daemon/src/objects.rs`: `SESSION_REFRESH_MHZ` and its rationale deleted;
  the two mode tests no longer compare `vrefresh` against it.
- `modules/gndrm-daemon/src/lib.rs`: `VblankTimeline::new` builds its interval from
  `mode_refresh_mhz()`, and the import drops the constant.
- `modules/gndrm-daemon/tests/golden.rs`: the flip event's second claim is one 60 Hz
  fallback period (17 166 us), not one grid tick.
- `modules/gndrm-android/src/lib.rs`: the module header says the grid is the mode's rate
  and the mode's rate is the display's, instead of promising a daemon constant.
- `docs/15-fps-limiter-pacing-and-vsync.md`: rewritten. It was a 752-line audit of the
  grid and a proposal to split the client rate from it, both now obsolete; it states where
  a rate is decided (the acquire gate, gamescope's divisor, the latch grid), the mode as
  the display's rate, where vsync is real, what the daemon owns, and the measurements.
- `docs/10-fps-limit.md`, `docs/11-refresh-model.md`, `docs/17-lsfg-frame-generation.md`,
  `docs/19-fps-limiter-acquire-rework.md`, `docs/README.md`: every `-r 1200` and
  `SESSION_REFRESH_MHZ` passage replaced with what happens now — no `-r`, so
  `g_nNestedRefresh` is 0 and the grid, the app-refresh cycle and the divisor base all
  fall back to the mode's rate.
…low (#42)

The frame ring is fed from the shim's producer reports so a cap above the
panel reads as what the session produced. A session whose frames never go
through the ICD shim (Xwayland GL, GDI, software rendering) sends no
reports, so with the publish path silent its fps read 0.0 (SteamWorld Dig 2
under gamescope). Count publishes whenever no report arrived within the
last second; once reports flow the publish path goes quiet. The first
report of a connecting counter no longer replays its backlog, since those
frames were counted by the publish path already.

Co-authored-by: Utkarsh Dalal <utkarsh.dalal@toptal.com>
@utkarshdalal
utkarshdalal merged commit efd50ab into master Nov 1, 2026
utkarshdalal pushed a commit to utkarshdalal/GameNative that referenced this pull request Nov 1, 2026
… cap and the multiplier (#2313)

* gamescope: advertise a cap that is below the panel, and the panel otherwise

The daemon reports the panel's own rate as the session's emulated mode. That is
right for everything at or above the panel — the cap is enforced at acquire, so
the compositor's grid should stay at the rate the display can latch. Below the
panel it is not: a 30 cap on a 120 Hz panel had the connector advertise 120, the
compositor paint at 120 and the divisor base be 120, so the session ran a display
three times the rate it was asked to produce.

`applyPanelRefreshForCap` decides one number per apply: the cap when it is below
the panel, the panel otherwise, and it is called from the gamescope arm of the
limiter whenever a target changes. `refreshFpsLimit` passes the current cap too,
so a display change (and session start, which already sets the rate before
`nativeStart`) re-evaluates rather than re-reporting the panel blindly. X11
sessions are untouched: they pace through the renderer and the present extension,
not through the emulated display.

The jniLibs daemon is rebuilt from gamenative-drm `5ecab43`, which is the code
this behaviour needs: the mode is the panel's rate, the frame ring counts what
was produced, and a scan-out subscription now outlives the allocator role in both
directions.

* gamescope: the mode follows the cap and the multiplier, live

The connector advertises the rate the session is asked to produce — the cap times
the frame-generation multiplier, clamped to the panel — and gamescope has to take
it mid-session: at a 40 cap, x2 must run an 80 Hz mode and show 80 fps, x3 120
and 120, x4 120 and 160, on the QuickMenu's change alone.

`applyPanelRefreshForCap` derives that rate from the multiplier as well as the
cap, reads the multiplier from the container rather than `isLsfgAvailable` (that
is a composition-time value, and the QuickMenu's switch changes the container
without recomposing this screen), reports it, and asks gamescope to re-read the
connector when it changes — only then, because a nudge forces a modeset.
`applyLsfgMultiplier` reports the rate before the LSFG settings, so the config
the shim opens the generator from carries the rate in force instead of the one
from before the change.

- `display/GamescopeConVar.kt`: `BACKEND_SET_DIRTY`, gamescope's own re-poll.
- `display/GamescopeCommandQueue.kt`: `nudgeDisplayMode()`, a one-shot action.
- `ui/screen/xserver/XServerScreen.kt`: the produced rate in
  `applyPanelRefreshForCap`, the change-only nudge, the multiplier documented,
  and the rate applied before the LSFG settings.
- `display/GamescopePayload.kt`: payload 20261031.6.
- `jniLibs/arm64-v8a/libgamenative_drm.so`: rebuilt from the daemon commit that
  serves the rate live (`69f03ef6`).

Measured on the Retroid Pocket Nova (120 Hz panel), Hades II, one session, cap
40, changing only the multiplier: 80 Hz mode / 80.2 fps at x2, 120 / 120.0 at
x3, 120 / 160.0 at x4, back to 80 / 80.0 at x2.

* gamescope: the mode is the display's, not the session's

`applyPanelRefresh` reports the display's own rate and nothing else. The cap and the
frame-generation multiplier never enter it: the mode is what the compositor runs at, so it
changes when the display changes and at no other time. At a 40 cap the mode stays at the
panel's rate while x2 produces 80 frames a second, x3 120 and x4 160, which is what the frame
counter reports.

The mode nudge stays, and is now asked for only by the display listener: a display change
reaches the running session through it (gamescope re-reads the connector and re-selects the
mode), while a QuickMenu change moves nothing. The first report does not nudge — it is the rate
the session starts on anyway.

- `ui/screen/xserver/XServerScreen.kt`: `applyPanelRefreshForCap` becomes `applyPanelRefresh`,
  reports the display's rate, and nudges only on a change after the first report.
- `display/GamescopePayload.kt`: payload 20261031.7, whose shim no longer halves the source
  rate on a slow panel.
- `jniLibs/arm64-v8a/libgamenative_drm.so`: rebuilt (`aa33a6a4`).

Measured on the Retroid Pocket Nova, Hades II, cap 40, one session per display: 120 Hz display
80.0 / 120.0 / 160.0 at x2 / x3 / x4 with a single `selecting mode 1280x960@120Hz` and no
nudges; 60 Hz display 160.0 at x4, 120.0 at x3, 79.8 at x2 with 40.3 acquires a second;
forcing the display to 60 Hz mid-session re-selected the mode once.

* gamescope: payload 20261101.1, the shim leaves the source rate at the cap

The payload carries the gamescope build with the A52 vblank time-grid limiter
patch disabled (GameNative/gamescope#26) and the shim built from the daemon
change in GameNative/gamenative-drm#41, where `source_cap` is the user's cap as
asked: the display no longer halves it on a slow panel, and generation runs at
`multiplier - 1` frames per source frame whatever the mode.

`GamescopePayload.VERSION` must name the archive that ships with the build, so
it moves with it.

* update drm library

* Gamescope payload 20261101.4: Xwayland shared-memory attach via the sysvshm server, on the fps-limiter stack

Payload = shipped 20261031.3 + bin/gamescope (gamescope#26, A52 disabled)
+ bin/Xwayland (GameNative/xserver#1 + #2) + lib/libadrenotools_gamescope.so
(gamenative-drm#41 shim, e0f9c23). Daemon in jniLibs is JT's from #2313.

* Gamescope payload 20261101.5: merged fps-limiter daemon and shim, Xwayland shared-memory attach

Payload = shipped 20261031.3 + bin/gamescope and bin/Xwayland from the
gamescope gamenative CI (A52 disabled, #26; Xwayland from GameNative/xserver
gamenative with #1 + #2) + lib/libadrenotools_gamescope.so from
gamenative-drm master (#41 + #42). Daemon in jniLibs from the same master.

* Gamescope payload 20261101.6: Xwayland linked against the payload's libxcvt.so

20261101.5's Xwayland was built by the gamenative workflow without the
unversioned-libxcvt fix, so it needed libxcvt.so.0 and never loaded
("Xwayland server 0 did not signal ready"). 20261101.6 is the same
payload with the Xwayland built from the identical xserver source
(bd9c2cdd4 = merged gamenative) on the fixed workflow.

---------

Co-authored-by: Utkarsh Dalal <utkarsh.dalal@toptal.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants