Repository navigation
display: the mode is the display's rate, the cap is at acquire, and generation follows the multiplier - #41
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 - 1frames 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:
selecting mode, no backend dirtlesWhat changed
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).FrameAcquired { count, generated }carries the shim's acquire and generation totals, and the daemon's deltas driveonFramePublished, so a session running above the display's rate reads as that rate rather than as what the display latched.pushes.rs, new), so a superseded connection keeps receiving them.panel_budgetandsource_paceare 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.SESSION_REFRESH_MHZis 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/15is 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 andaarch64-linux-android. Device runs are the table above, plus 6 consecutive swapchain recreations through a Vsync toggle with 0slot init failed. The fabricated session grid is gone from the daemon, the docs and the gamescope patch train.Paired changes
change through
backend_set_dirty, and ships this build as payload20261101.1. It is thecaller of everything above: without it the connector's rate is never reported and the mode is
the launch-time default.