Skip to content
Merged
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
53 changes: 37 additions & 16 deletions docs/10-fps-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ gamescope, so our pacer would be pacing a path the frames no longer take.
3. **`-r <N>` / `--nested-refresh`** — the session's vblank grid.
`main.cpp:740-741` stores it as `g_nNestedRefresh` in **mHz**; `0` is the
unset sentinel (only negatives are rejected) and every consumer falls back to
`g_nOutputRefresh`. GameNative launches with a constant 1200 Hz
(`GamescopeState.SESSION_GRID_HZ` → `gridArg()`), high enough that the grid
never binds the session. Verified consumers:
`g_nOutputRefresh`. GameNative passes no `-r`, so `g_nNestedRefresh` is 0 and
every consumer below falls back to the mode's rate, which is the display's.
Verified consumers:
- `vblankmanager.cpp:75-77` `GetRefresh()` — the vblank grid itself, and
`:405` a sleep computed from the same value.
- `steamcompmgr.cpp:10606-10607` — `nRealRefreshmHz` →
Expand All @@ -65,14 +65,12 @@ Method: deltas of gamescope's unconditional 1/s `drm: A23 flip commit <N>` over
≥23 s window after ~95 s settle, same scene each run. Device 97c32e85, container
`xuser-STEAM_1868140`.

gndrm exposes **one** mode: the session's scan-out size at the panel's real rate
gndrm exposes **one** mode: the session's scan-out size at the display's own rate
(`PanelDesc::modeinfo` in `gndrm-daemon/src/objects.rs`; the rate comes from
`nativeSetPanelRefreshHz`, or from the frame-generation output rate when that is
higher). The vblank grid is a separate value — the daemon's own
`SESSION_REFRESH_MHZ`, 1200 Hz — so `-r` matches no `vrefresh` by design: A47
re-runs the lookup by size, and `drm_set_mode` makes the mode's rate the
declared output refresh. `-r` is the grid (paint tick, app-refresh cycle) and
never the client-visible rate.
`nativeSetPanelRefreshHz`). The cap and the frame-generation multiplier never
enter it, so the mode is the rate the compositor paints at and the rate clients
are told. With no `-r` on the launch line, the paint tick, the app-refresh cycle
and the divisor base all derive from that one rate.

A divisor cap on the grid lands on its target exactly, and the uncapped ceiling
is the client↔gamescope lap — the grid itself never binds.
Expand All @@ -96,6 +94,16 @@ divisor cap reads its target exactly.
re-issues it when the value or the panel's screen type changes
(`injector.rs:717-722`); `set_frame_cap_target` (`lib.rs:322`) holds the target
so the scan-out channel pushes a `FrameCapNote` to a subscribed shim.
- **Every subscriber is pushed to, not the allocator.** The card listener hands
one client the allocator role at a time (`server::accept_newest`), and a session
has three that open it — the compositor, a wine child and the game — swapping it
for the first ~20 s. A `FrameCapSubscribe` therefore outlives that role:
`gndrm_daemon::pushes` keeps the registration and sends the cap, the panel's
grid and the frame-generation settings to every client that asked for them. A
live cap change reaches the guest that gates on it even after another client
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
Expand All @@ -105,18 +113,31 @@ divisor cap reads its target exactly.
(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.
- **`-r` is not part of it**: the grid is the constant 1200 Hz, and no launch
argument carries the limit. `--framerate-limit` is never built.
- **`-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.

Open points:
- **Divisor rounding bites the gamescope route only.** `nVblankDivisor =
nRefreshHz / nTargetFPS` (`steamcompmgr.cpp:7040`, and `:10621` for the window's
limited cycle) lands on the nearest divisor of the grid; the shim's floor is
exact (measured: 60.00 commits/s at a 60 cap).
- **The client-visible refresh is the mode's.** `-r` reaches `g_nOutputRefresh`
only transiently (`drm_unset_mode`, `:3680`), before `drm_set_mode` (`:3728`)
overwrites it with the mode's rate — `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK`
and `active_display_info` therefore carry the panel's rate, never the grid.
- **The client-visible refresh is the mode's, and the mode is the display's.**
`-r` reaches `g_nOutputRefresh` only transiently (`drm_unset_mode`, `:3680`),
before `drm_set_mode` (`:3728`) overwrites it with the mode's rate —
`_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` and `active_display_info` therefore
carry the mode's rate, never the grid. The cap and the frame-generation
multiplier never enter it: the connector advertises the display's own rate, so
the compositor paints at the rate the display latches whatever the session does
(a 40 cap at x4 runs a 120 Hz mode and produces 160 frames a second). The
daemon serves the rate live — the mode is built on every connector read — so a
display change is applied by dirtying gamescope's backend
(`backend_set_dirty`), which re-reads the connector and re-selects the mode.
The QuickMenu never moves it.
- **The frame ring counts produced frames**, not published ones: the shim reports
its acquire and generation totals (`FrameAcquired`), and the daemon turns the
deltas into `onFramePublished`. A cap above the panel therefore reads as more
frames than the panel shows — which is the point, and why the publish count is
kept only as the session's own statistic.
- **Engagement feedback**: `gamescope_limiter.state` covers the gamescope route;
the shim logs `scanout frame cap: fps=… effective=…` (`scanout.rs:148-158`),
`effective` being the target in force once the note is adopted, and reports what
Expand Down
28 changes: 15 additions & 13 deletions docs/11-refresh-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,27 @@ the app at N while keeping the grid at the mode's rate — they move together.

## What gndrm does with it

GameNative launches with `-r 1200` (`GamescopeState.SESSION_GRID_HZ` → `gridArg()`), the
grid the daemon's flip timeline is also built from (`SESSION_REFRESH_MHZ`,
`gndrm-daemon/src/objects.rs:68`; `lib.rs:2091`), so the grid never binds a session.

The **one** mode the connector exposes is the session's scan-out size at the panel's real
rate (`PanelDesc::modeinfo`, `objects.rs:121-160`: `mode_refresh_mhz()` is the rate the app
reported through `nativeSetPanelRefreshHz`, raised to the frame-generation output rate when
that is higher, and `clock`/`htotal`/`vtotal`/`vrefresh` all describe it). The rate clients
see is therefore the panel's:
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.

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;
`clock`/`htotal`/`vtotal`/`vrefresh` all describe it). That rate is the panel's, reported
through `nativeSetPanelRefreshHz`, and the daemon serves it live so a display change is
applied to the running session. Neither the cap nor the frame-generation multiplier
enters it — they move what the session produces, not what the compositor runs at. The
rate clients see is therefore the display's:

- **Uncapped:** the loop reaches its measured ceiling (~259 presents/s); the grid is three
orders of magnitude above the panel's frame time and never the limit.
- **Mode rate vs panel rate:** equal unless frame generation raises the output rate, in
which case the mode advertises that higher rate and the shim still paces on the panel's.
- **Mode rate vs panel rate:** equal, always (a 40 cap at x4 on a 120 Hz panel runs a
120 Hz mode and produces 160 frames a second).
- **Capping at 30/40/60 against the grid is exact arithmetic** — all three divide 1200, and
a divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s).

## Open — to measure, not assume

- `active_display_info`'s rate list is the single mode rate above (there are no dynamic
rates on this connector), so a client sees one entry — the panel's rate, or the
generated one.
rates on this connector), so a client sees one entry — the display's rate.
Loading