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
16 changes: 15 additions & 1 deletion docs/02-gamenative-drm.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ EINVAL (probing clients treat that as unsupported).

| ioctl | notes |
|---|---|
| `DRM_IOCTL_MODE_GETCONNECTOR` | 1 connector, `DRM_MODE_CONNECTED`, type `DRM_MODE_CONNECTOR_DSI`; modes: panel mode from the SurfaceView size; two-call count/array semantics; `count_props` exposes the connector props |
| `DRM_IOCTL_MODE_GETCONNECTOR` | 1 connector, `DRM_MODE_CONNECTED`, type `DRM_MODE_CONNECTOR_DSI`; one mode: the session's scan-out size (`nativeSetScanoutGeometry`, the app's Upscaling Resolution), or the window's size when the app named none or the size does not fit the window; two-call count/array semantics; `count_props` exposes the connector props |
| `DRM_IOCTL_MODE_GETENCODER` | 1 encoder, `possible_crtcs=0x1`, `possible_clones=0` |
| `DRM_IOCTL_MODE_GETCRTC` | current mode = panel mode |
| `DRM_IOCTL_MODE_SETCRTC` | never called on the atomic path; EINVAL (gamescope is atomic-only) |
Expand Down Expand Up @@ -262,6 +262,17 @@ documented in 06-presenter-latency.md; the shape is:
The shim requests a pool buffer by geometry and receives a dup'd dma-buf fd;
gamescope renders into it; the same `AHardwareBuffer` is published. No
import, no copy.
- **Row stride.** The pool buffer's rows are gralloc's, which pads them for
widths it does not like (1024 px → 5120 B, 1440 px → 5888 B on Adreno), while
a LINEAR Vulkan image is otherwise laid out at `width * 4`. A client that
binds the buffer at its own pitch writes rows the compositor reads one row
apart — a sheared frame. The shim answers the pitch (`gamenative_drm_scanout_pitch`,
measuring an unseen geometry with one throwaway pool allocation), and
gamescope's A51 patch creates the scan-out image at that stride through an
explicit LINEAR modifier, so the AddFB2 export reports the same stride the
compositor reads. The guest's own buffers are handled on the ICD side
(`icd_shim` `proxy`/`export`), which does the same thing for the images it
creates.
- **Layout per session.** BGRA (`XR24`/`AR24` byte order) is the default.
RGBA (`XB24`/`AB24`) is chosen at `nativeStart` on SDK 36+ when
SurfaceFlinger's render engine is Skia GLES (`debug.renderengine.backend`,
Expand Down Expand Up @@ -299,6 +310,9 @@ in the app's `jniLibs/arm64-v8a/` and is loaded via

- `nativeStart(socketName: String, surface: Surface, width: Int, height: Int): Int`
- `nativeStop()`, `nativeRetargetSurface(surface, w, h)`
- session inputs, before `nativeStart`: `nativeSetPanelRefreshHz(hz)` (the mode's
rate) and `nativeSetScanoutGeometry(w, h)` (the mode's size — the app's
Upscaling Resolution; `0, 0` clears it).
- injector calls: `nativePointerAbsolute(x, y)`, `nativePointerMotion(dx, dy)`,
`nativePointerButton(btn, pressed)`, `nativePointerAxis(axis, value)`,
`nativeKey(evdevCode, pressed)`.
Expand Down
6 changes: 4 additions & 2 deletions docs/03-gamescope-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ WLR_XWAYLAND=<pkg>/bin/Xwayland
# guest so winex11 dials XWayland.
```

Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W <panelW> -H <panelH> -- <wine>/bin/wine explorer /desktop=gamescope,<WxH> <start command>`
(width/height = *output* res = Surface size; game res stays wine's window size).
Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W <scanoutW> -H <scanoutH> -w <desktopW> -h <desktopH> -- <wine>/bin/wine explorer /desktop=gamescope,<WxH> <start command>`
(`-W/-H` = the session's scan-out size, i.e. the container's Upscaling Resolution,
which the daemon emulates its connector mode at; `-w/-h` = the container's screen
size, the guest desktop. Game res stays wine's window size.)
`--backend drm` explicit: auto-select would also land on DRM when no parent
wayland/X11 exists, but never rely on detection.
20 changes: 16 additions & 4 deletions docs/04-gamenative-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,19 @@ because those run without the gamescope lib env):

```
filesDir/gamescope/bin/gamescope --backend drm --expose-wayland --xwayland-count 1 \
-W <surfaceW> -H <surfaceH> -- <wine>/bin/wine explorer /desktop=gamescope,<WxH> <start command>
-W <scanoutW> -H <scanoutH> -w <desktopW> -h <desktopH> -- <wine>/bin/wine explorer /desktop=gamescope,<WxH> <start command>
```

- `-W/-H` = **output** res = the Surface/mode size (03 §7); game res stays wine's
window size.
- `-W/-H` = **the session's scan-out size**: the container's Upscaling Resolution
(Graphics tab; `GamescopeSettings.outputResolution`), passed to the daemon as
`nativeSetScanoutGeometry` so the emulated connector advertises exactly that
mode. The composite, the scan-out pool and the KMS output are that size, and
the presenter fits them into the app's window, so a resolution below the panel
is upscaled by the display's compositor, not by gamescope's composite. A size
that does not fit the window is refused and the window's size is the mode —
gamescope's `-W/-H` then matches no mode and falls back to it.
- `-w/-h` = the container's screen size, i.e. the guest desktop; the game res
stays wine's window size.
- `--expose-wayland`: gamescope serves xdg-shell clients and sets
`WAYLAND_DISPLAY=gamescope-0` + `XDG_SESSION_TYPE=wayland` for its children
itself.
Expand Down Expand Up @@ -300,7 +308,11 @@ anyway).
4. `surfaceDestroyed` / screen exit → `nativeStop()` before the guest is
killed.
- The daemon presents per KMS semantics: black outside the plane's CRTC rect, the
FB's SRC rect (16.16 fixed point) scaled into it (02 §7).
FB's SRC rect (16.16 fixed point) scaled into it (02 §7). The CRTC rect is in
the session's mode pixels, so it is fitted into the window before the
transaction: a mode below the window's is scaled up uniformly and centred there
(another aspect is letterboxed, never stretched), which is where the Upscaling
Resolution's upscale happens.
- Logging: `GN_DRM` logtag (native side; Kotlin uses Timber), plus a build-stamp
line at daemon start — the only stale-.so detector on device (no root/run-as).

Expand Down
108 changes: 57 additions & 51 deletions docs/10-fps-limit.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,25 @@ gamescope, so our pacer would be pacing a path the frames no longer take.
(interned `steamcompmgr.cpp:9003`, read at `:7371`). Equivalent effect, but it
needs a client of *gamescope's* Xwayland — writable by the ICD shim (which
already has that connection) or by wine, not by the app process.
3. **`-r <N>` / `--nested-refresh`** — the launch knob GameNative uses to carry
the FPS limit value directly. `main.cpp:748` stores it as
`g_nNestedRefresh` in **mHz**; `0` is the unset sentinel (only negatives are
rejected) and every consumer falls back to `g_nOutputRefresh`. Verified
consumers:
- `vblankmanager.cpp:77` `GetVBlankInterval()` — the vblank grid itself.
- `vblankmanager.cpp:405` — a sleep computed from the same value.
- `steamcompmgr.cpp:10492-10494` — `nRealRefreshmHz` →
`g_SteamCompMgrAppRefreshCycle`, consumed at `:8252-8253` as the window's
refresh cycle (this is what paces the app's frame callbacks).
- `steamcompmgr.cpp:6911` — the limiter's divisor base `nRefreshHz` (only
used when a target FPS is also set; with `-r` alone `g_nSteamCompMgrTargetFPS`
stays 0, so the cap comes from the app refresh cycle, not the divisor).
- `Backends/DRMBackend.cpp:3910` — passed into `init_drm(..., g_nNestedRefresh)`
→ `drm->preferred_refresh` → mode selection (`find_mode`, exact `vrefresh`
match `:1029`) and the declared output refresh (`drm_unset_mode`, `:3646`
`if (bPreferredRefresh) g_nOutputRefresh = drm->preferred_refresh`).
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:
- `vblankmanager.cpp:75-77` `GetRefresh()` — the vblank grid itself, and
`:405` a sleep computed from the same value.
- `steamcompmgr.cpp:10606-10607` — `nRealRefreshmHz` →
`g_SteamCompMgrAppRefreshCycle`, the window's refresh cycle (what paces the
app's frame callbacks).
- `steamcompmgr.cpp:7013` — the limiter's divisor base `nRefreshHz`, used only
when a target FPS reached gamescope through
`set_app_target_refresh_cycle`.
- `Backends/DRMBackend.cpp:3943` — passed into
`init_drm(..., g_nNestedRefresh)` → `drm->preferred_refresh`, which A47
makes a size-only mode lookup because the grid matches no `vrefresh`;
`drm_set_mode` (`:3728`) then makes the mode's own rate the declared
output refresh.

`--framerate-limit` is a dead option in this tree — declared at
`main.cpp:97`/documented at `:221`, absent from main.cpp's long-option chain —
Expand All @@ -63,54 +65,58 @@ 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 only: `1280x960@1200` (gndrm-daemon `objects.rs`
`PanelDesc::modeinfo` builds a single `DrmModeModeinfo`; `vrefresh` comes from
`SESSION_REFRESH_MHZ`, the daemon's own 1200 Hz constant — not the panel's rate).
gndrm exposes **one** mode: the session's scan-out size at the panel's real 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.

The selected DRM mode is `1280x960@1200` for **every** `-r` value: the connector
exposes only that one mode, `find_mode(N)` finds no exact `vrefresh` match and
the single mode is kept. What `-r N` changes is the declared output refresh
`g_nOutputRefresh = N` and the vblank grid / app-refresh cycle = N — the grid is
re-timed while the mode stays 1200 Hz.
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.

| `-r` | presents/s |
|---|---|
| `0` (unset→output refresh; uncapped) | ~259 |
| `60` | 60.0 |

The 1200 Hz grid never binds: the uncapped ceiling is the client↔gamescope lap,
and a divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s).

### Pacing cost

The flip event is emitted with the publish (see 12), so a paced frame's present
does not carry the SurfaceFlinger round trip: `interval ≈ 1 / target`, and a
divisor cap reads its target exactly.

## Recommended wiring
## How the cap is applied

- **Runtime (mid-session) changes** stay on the existing request path: a
`nativeSetFpsLimit(fps)` JNI call on the `GameNativeDrm` surface issues
- **`nativeSetFpsLimit(fps)`** (`gndrm-android/src/lib.rs:1454`, `0` = uncapped)
fans out to both routes. `injector::set_fps_limit` issues
`gamescope_control.set_app_target_refresh_cycle(fps, internal_display)` and
re-issues it when the setting changes (`fps = 0` = uncapped). This is the only
route that can change the limit mid-session, because `-r` is a launch-time
argument.
- **Launch contract**: pass `-r <value>` where value = the QuickMenu fps limit —
`0` when the limiter is off (gamescope falls back to the output refresh =
uncapped) and the user's target when it is on. Sourced from the same container
extra (`fpsLimiterTarget`, off ⇒ 0) the runtime request reads, so the launch
value and a mid-session change cannot drift. The launch command builds no
`--framerate-limit`.
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.
- **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.
- **`-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.

Open points:
- **Divisor rounding only bites the request path.** With `-r` alone the cap comes
from the grid rate, not from `nVblankDivisor`, so the `nearest divisor`
behaviour applies to runtime changes made while `g_nSteamCompMgrTargetFPS != 0`.
- **`-r` also sets the declared output refresh `g_nOutputRefresh`** (and, since
the connector has a single mode, keeps the 1200 Hz DRM mode). Consequences of a
low `-r` on the client-visible refresh (`_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK`,
`active_display_info` rates) were not measured directly on device — the rates
`active_display_info` carries are the connector's mode list, not
`g_nOutputRefresh`, in this configuration.
- **Engagement feedback**: `gamescope_limiter.state` is the honest source for
"is a limit actually in force".
- **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.
- **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.
Loading