diff --git a/docs/02-gamenative-drm.md b/docs/02-gamenative-drm.md index 3b20416..25bbfe4 100644 --- a/docs/02-gamenative-drm.md +++ b/docs/02-gamenative-drm.md @@ -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) | @@ -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`, @@ -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)`. diff --git a/docs/03-gamescope-build.md b/docs/03-gamescope-build.md index dd19883..8700be1 100644 --- a/docs/03-gamescope-build.md +++ b/docs/03-gamescope-build.md @@ -316,7 +316,9 @@ WLR_XWAYLAND=/bin/Xwayland # guest so winex11 dials XWayland. ``` -Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W -H -- /bin/wine explorer /desktop=gamescope, ` -(width/height = *output* res = Surface size; game res stays wine's window size). +Launch: `gamescope --backend drm --expose-wayland --xwayland-count 1 -W -H -w -h -- /bin/wine explorer /desktop=gamescope, ` +(`-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. diff --git a/docs/04-gamenative-app.md b/docs/04-gamenative-app.md index abd62dd..4866ad7 100644 --- a/docs/04-gamenative-app.md +++ b/docs/04-gamenative-app.md @@ -78,11 +78,19 @@ because those run without the gamescope lib env): ``` filesDir/gamescope/bin/gamescope --backend drm --expose-wayland --xwayland-count 1 \ - -W -H -- /bin/wine explorer /desktop=gamescope, + -W -H -w -h -- /bin/wine explorer /desktop=gamescope, ``` -- `-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. @@ -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). diff --git a/docs/10-fps-limit.md b/docs/10-fps-limit.md index b9fd280..ff82e48 100644 --- a/docs/10-fps-limit.md +++ b/docs/10-fps-limit.md @@ -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 ` / `--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 ` / `--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 — @@ -63,54 +65,58 @@ Method: deltas of gamescope's unconditional 1/s `drm: A23 flip commit ` 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 ` 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. diff --git a/docs/11-refresh-model.md b/docs/11-refresh-model.md index 177737e..832f1cc 100644 --- a/docs/11-refresh-model.md +++ b/docs/11-refresh-model.md @@ -2,63 +2,70 @@ ## Two variables, two jobs -**`g_nOutputRefresh`** (mHz, `main.cpp:305`) — the **panel mode's** rate, assigned by the -DRM backend from the selected mode (`Backends/DRMBackend.cpp:3695`). It is what the -whole stack *and the clients* are told: +**`g_nOutputRefresh`** (mHz, `main.cpp:305`) — the **mode's** rate, assigned by the DRM +backend from the mode it selected (`Backends/DRMBackend.cpp:3727-3728`, the +`GetModeRefresh` recompute at `:229-246`). It is what the whole stack *and the clients* +are told: -- the Xwayland server mode (`steamcompmgr.cpp:7356`, `:10382`), -- the `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` root property (`:9283-9288`), -- `gamescope_control.active_display_info`'s rate list (`wlserver.cpp:1538-1541`), -- mangoapp's snapshot (`mangoapp.cpp:205`), ReShade (`reshade_effect_manager.cpp:293`). +- the Xwayland server mode (`steamcompmgr.cpp:7458`, `:10496`), +- the `_GAMESCOPE_DISPLAY_REFRESH_RATE_FEEDBACK` root property (`:9384-9390`), +- `gamescope_control.active_display_info`'s rate list (`wlserver.cpp:1483-1487`), +- mangoapp's snapshot (`mangoapp.cpp:221`), ReShade (`reshade_effect_manager.cpp:293`). **`g_nNestedRefresh`** (mHz, `main.cpp:299`) — `-r/--nested-refresh`; **0 means fall back -to the output refresh** (`main.cpp:749` rejects only negatives). In the nested backends it -*becomes* the output refresh (`SDLBackend.cpp:230`, `WaylandBackend.cpp:1989`, +to the output refresh** (`main.cpp:740-746` rejects only negatives). In the nested backends +it *becomes* the output refresh (`SDLBackend.cpp:230`, `WaylandBackend.cpp:1989`, `HeadlessBackend.cpp:112`). In the **DRM** backend — ours — it is passed to -`init_drm( &g_DRM, w, h, g_nNestedRefresh )` (`Backends/DRMBackend.cpp:3910`) and stored as -`drm->preferred_refresh` (`:1503`). +`init_drm( &g_DRM, w, h, g_nNestedRefresh )` (`Backends/DRMBackend.cpp:3943`) and stored as +`drm->preferred_refresh` (`:1545`). -## A preferred refresh selects the mode +## In the DRM backend they are separate -`find_mode( connector, w, h, preferred_refresh_hz )` (`DRMBackend.cpp:1385`) matches -`mode->vrefresh` **exactly** (`:1029`), and where a preference exists `g_nOutputRefresh` is -taken from it (`:3628`, `:3647`). +`find_mode( connector, w, h, vrefresh )` (`DRMBackend.cpp:1042-1069`) matches `vrefresh` +**exactly** when one is given, so a grid that names no exposed mode falls through — +which is why A47 re-runs the lookup by size with the refresh unconstrained +(`:1417-1426`). `drm_unset_mode` then declares the preferred refresh +(`:3680`), and `drm_set_mode` overwrites it with the selected mode's own rate (`:3728`). -So in the DRM backend **`-r N` is a declaration that the display is N Hz** — it reaches -mode selection, the client-visible refresh and the grid together. It is not a cap layered -on top of a 120 Hz grid. +So in the DRM backend **`-r` is the session's grid** — the compositor's paint tick and +the app-refresh cycle — while the **mode's rate is what clients are told**. A cap is a +grid divisor, or the shim's acquire floor (doc 10); it never re-times the mode. ## What the grid and the pacing read - **Grid:** `CVBlankTimer::GetRefresh()` = `g_nNestedRefresh ?: g_nOutputRefresh` - (`vblankmanager.cpp:77`); the interval comes from `mHzToRefreshCycle(GetRefresh())` + (`vblankmanager.cpp:75-77`); the interval comes from `mHzToRefreshCycle(GetRefresh())` in `GetNextVBlank()`, and the same expression times the timer's idle sleep (`:405`). - **App pacing:** `g_SteamCompMgrAppRefreshCycle = mHzToRefreshCycle( g_nNestedRefresh ?: - g_nOutputRefresh )` (`steamcompmgr.cpp:10492`), consumed per window at `:8246-8253` + g_nOutputRefresh )` (`steamcompmgr.cpp:10606-10607`), consumed per window at `:8355` (that is what throttles the guest's frame callbacks). -- **Limiter divisor base:** `nRefreshHz` = the same nested-expression (`:6911`); the +- **Limiter divisor base:** `nRefreshHz` = the same nested-expression (`:7013`); the divisor only applies while `g_nSteamCompMgrTargetFPS != 0`. Both the grid and the app pacing come from **one** expression, so there is no way to pace -the app at N while keeping the grid at the mode rate — they move together. +the app at N while keeping the grid at the mode's rate — they move together. -## Consequences on the session's own rate +## What gndrm does with it -gndrm advertises **one** preferred mode — `1280x960@1200`, from its own -`SESSION_REFRESH_MHZ` constant (`gndrm-daemon/src/objects.rs`, -`vrefresh: SESSION_REFRESH_MHZ.div_ceil(1000)`), not the panel's rate — so a -preferred refresh with no matching exposed mode has nothing to select, and the -grid, declared output refresh and client-visible refresh move together with `-r`. +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. -- **Uncapped:** `-r 0` falls back to the output refresh (1200 Hz); the loop - reaches its measured ceiling (~259 presents/s). -- **`-r N`:** declares N Hz everywhere — the guest is told N, the grid runs at N — - while the single 1200 Hz DRM mode is kept. -- **Capping at 30/40/60 is exact arithmetic** — all three divide 1200, and a - divisor cap lands on its target exactly (`-r 60` reads 60.0 presents/s). +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: + +- **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. +- **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 -- The `active_display_info` refresh list carries the connector's mode list, not - `g_nOutputRefresh`; the client-visible refresh for a low `-r` was not measured - directly on device. +- `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. diff --git a/docs/15-fps-limiter-pacing-and-vsync.md b/docs/15-fps-limiter-pacing-and-vsync.md index e5de4ef..ad959e2 100644 --- a/docs/15-fps-limiter-pacing-and-vsync.md +++ b/docs/15-fps-limiter-pacing-and-vsync.md @@ -5,11 +5,11 @@ Scope: an audit of where frame pacing and the fps cap are decided in SurfaceFlinger`, and a design for a correct cap with correct pacing and an honest refresh model. -Nothing in this document is implemented. The pacing-arithmetic patch remains in -`gamescope/android/patches/disabled/0032-A32-fps-cap-lower-bound-delay.patch` -and is **not** applied to `gamescope/src/`. Line references are against -`gamescope` HEAD `f3c3b96`, `gamenative-drm` `master`, and GameNative -`feat/gamescope-display`. +The refresh split described in §1 is implemented: the mode advertises the +panel's rate and the 1200 Hz constant is the grid alone. The pacing arithmetic +below is not — `steamcompmgr.cpp:10621` still applies the cap as a refresh +divisor, whose patch sits in `gamescope/android/patches/disabled/`. Line +references are against the working trees at the A51 patch train. ## 1. One variable does two jobs @@ -23,39 +23,43 @@ g_nNestedRefresh ? g_nNestedRefresh : g_nOutputRefresh - the grid — `CVBlankTimer::GetRefresh()`, `gamescope/src/vblankmanager.cpp:75-78`, consumed at `:87` (`GetNextVBlank`) and `:127` (`CalcNextWakeupTime`); - app pacing — `g_SteamCompMgrAppRefreshCycle = mHzToRefreshCycle(nRealRefreshmHz)`, - `gamescope/src/steamcompmgr.cpp:10528-10529`; -- the limiter's divisor base — `nRefreshHz`, `gamescope/src/steamcompmgr.cpp:6938`. + `gamescope/src/steamcompmgr.cpp:10606-10607`; +- the limiter's divisor base — `nRefreshHz`, `gamescope/src/steamcompmgr.cpp:7013`. -The launcher passes no `-r` -(`GameNative/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java:591-594`), -so `g_nNestedRefresh` is 0 and all three read `g_nOutputRefresh`. +The launcher passes the constant `-r 1200` +(`GameNative/.../GamescopeState.kt:44`, `:64` → `gridArg()`, appended at +`BionicProgramLauncherComponent.java:640`), so `g_nNestedRefresh` wins for all +three and the grid is 1200 Hz — the app-refresh cycle and the divisor base +included. The DRM backend sets `g_nOutputRefresh` from the mode it selected, recomputed rather than read from the label: `drm_set_mode` → `g_nOutputRefresh = gamescope::GetModeRefresh( mode )` -(`gamescope/src/Backends/DRMBackend.cpp:3674-3685`), and `GetModeRefresh` is -`(clock*1e6/htotal + vtotal/2)/vtotal` (`:229-243`). The daemon exposes one -synthetic mode whose `clock`/`htotal`/`vtotal` encode -`SESSION_REFRESH_MHZ = 1_200_000` -(`gamenative-drm/modules/gndrm-daemon/src/objects.rs:63`, `:91-125`), so the -recomputed rate is 1200 Hz. The mode's `vrefresh` field (`objects.rs:120`) only -has to agree with those three; it is a label, not the rate. +(`gamescope/src/Backends/DRMBackend.cpp:3727-3728`), and `GetModeRefresh` is +`(clock*1e6/htotal + vtotal/2)/vtotal` (`:229-246`). The daemon's one mode carries +its own rate in `clock`/`htotal`/`vtotal`/`vrefresh` alike +(`gamenative-drm/modules/gndrm-daemon/src/objects.rs:121-160`): the panel's rate as +the app reported it through `nativeSetPanelRefreshHz`, raised to the +frame-generation output rate when that is higher. The recomputed rate is therefore +the panel's, and the 1200 Hz constant +(`SESSION_REFRESH_MHZ`, `objects.rs:68`) is the grid alone. Consequences, all structural: - the grid interval is `mHzToRefreshCycle(1200 Hz)` ≈ 833 µs — three orders of magnitude below the panel's frame time, so the composite is never the ceiling and a guest can present faster than the panel at all; -- the refresh pushed to clients is a fabricated 1200 Hz; -- every reachable capped rate is `1200/N`, which is what the limiter's parity - between a requested target and a landed rate is measured against. +- the refresh pushed to clients is the panel's (or the generated rate), never the grid; +- every capped rate reachable through gamescope is `1200/N`, which is what the + limiter's parity between a requested target and a landed rate is measured + against there; the shim's own floor is exact. The daemon's flip-event timebase is built from the same constant — `VblankTimeline::interval_ns = 1e12 / SESSION_REFRESH_MHZ` -(`gamenative-drm/modules/gndrm-daemon/src/lib.rs:1834`) — so the timeline and -the grid agree by construction. `objects.rs:55-62` states the intent: the -advertised rate is a deliberate decoupling from the panel, so that the only cap -on a guest is the user's own fps limit, and the panel downstream coalesces. +(`gamenative-drm/modules/gndrm-daemon/src/lib.rs:2091`) — so the timeline and +the grid agree by construction. `objects.rs:53-68` states the intent: the grid is a +deliberately high timer so the only cap on a guest is the user's own fps limit, +while the mode's rate stays truthful to the panel. ## 2. The current limiter path @@ -155,7 +159,7 @@ Which stage binds is per title, not per build. A title whose lap through the shim and the compositor exceeds the panel's frame time is bound by its own lap (stage 1+2). A fast title is bound by how often gamescope releases its buffers, which is per grid tick (stage 3). Nothing bounds it at the panel rate: that is -the intent of the fabricated 1200 Hz grid (`objects.rs:55-62`). +the intent of the 1200 Hz grid (`objects.rs:53-68`). **A frame presented early** — the guest obtained a slot and committed before the panel's next edge. The daemon publishes it, and `VblankTimeline::claim` stamps it @@ -514,9 +518,10 @@ Three candidates, and which one bounds: repaint (`steamcompmgr.cpp:10762`, `:10817`) — i.e. when a client committed. The project's own figure for that pipeline is ~259 presents/s (`objects.rs:60-61`, from an earlier session and not re-measured here). -- **The panel's real rate.** Nothing. `g_nOutputRefresh` is the fabricated 1200 - and `PanelDesc` carries no rate (`objects.rs:69-75`); the panel reaches the - stack only through SurfaceFlinger, downstream of everything we own. +- **The panel's real rate.** Nothing on the loop: `g_nOutputRefresh` is the + panel's rate, but the grid is the daemon's 1200 Hz constant, so the + compositor's tick is not tied to the panel; the panel reaches the stack only + through SurfaceFlinger, downstream of everything we own. So the loop is bounded by the **guest's present rate** for the per-frame path (shim present, daemon commit, presenter publish), and by the **1200 Hz grid** for diff --git a/icd_shim/src/scanout.rs b/icd_shim/src/scanout.rs index 7c63071..234f9f8 100644 --- a/icd_shim/src/scanout.rs +++ b/icd_shim/src/scanout.rs @@ -126,28 +126,35 @@ pub(crate) fn period_ns(target_fps: u32) -> u64 { /// /// The cap is applied here rather than to a release: a frame that may not start /// yet is the only point where waiting costs neither rendered work nor display -/// latency. `max(prev, now) + period` holds the interval at the target when the -/// guest is faster and lets it be its own rate when it is slower — never two -/// frames closer than one period, and no debt carried out of a stall. +/// latency. `prev + period` holds the interval at the target while the guest +/// keeps up: the wait for `prev` returns a hair late, and that hair must not +/// become the cadence (it did, as `max(prev, now) + period`: a 60 cap ran at +/// 59.4). Once the guest is a whole period behind it is its own rate again, +/// `now + period` — never two frames closer than one period, and no debt +/// carried out of a stall. #[must_use] pub(crate) fn next_acquire_after(prev_ns: u64, now_ns: u64, period_ns: u64) -> u64 { if period_ns == 0 { return prev_ns; } - prev_ns.max(now_ns).saturating_add(period_ns) + if prev_ns != 0 && now_ns < prev_ns.saturating_add(period_ns) { + prev_ns + period_ns + } else { + now_ns.saturating_add(period_ns) + } } /// Adopt one frame-cap note: the target it carries. fn apply_note(note: FrameCapNote) { - // 0 while frame generation paces the session: log what is actually in force, - // not what the note carried. + NOTES_N.fetch_add(1, Ordering::Relaxed); + CAP_FPS.store(note.fps, Ordering::Relaxed); + // 0 while frame generation paces the session: log what is actually in force + // now that the note is adopted, not what it carried. crate::logf!(gndrm_obf::obf!( "scanout frame cap: fps={} effective={}", note.fps, target_fps() )); - NOTES_N.fetch_add(1, Ordering::Relaxed); - CAP_FPS.store(note.fps, Ordering::Relaxed); } /// The target in force, [`FPS_UNCAPPED`] when none has: read by the shim's @@ -916,6 +923,19 @@ mod tests { /// The acquire schedule: one period after the previous start, never closer, /// and never later than the guest's own next frame when it is slower. + #[test] + fn the_cadence_does_not_carry_the_wakeup_overshoot() { + let period = 16_666_666; + let first = next_acquire_after(0, 1_000_000_000, period); + assert_eq!(first, 1_016_666_666); + // The wait returned 150 us late: the next deadline is still one period on. + let second = next_acquire_after(first, first + 150_000, period); + assert_eq!(second, first + period); + // A whole period late: no debt. + let third = next_acquire_after(second, second + period + 5_000_000, period); + assert_eq!(third, second + period + 5_000_000 + period); + } + #[test] fn the_acquire_schedule_spaces_frame_starts() { let period = period_ns(40); diff --git a/modules/gndrm-android/src/asc.rs b/modules/gndrm-android/src/asc.rs index 03adc10..a19cf5d 100644 --- a/modules/gndrm-android/src/asc.rs +++ b/modules/gndrm-android/src/asc.rs @@ -806,9 +806,9 @@ impl Drop for ForeignSlot { } /// The layer-rect probe's change key: the published source and destination -/// rects plus the panel the destination is bounded by. A plain tuple, so the -/// probe has a `Copy + PartialEq` value to compare against the last one and -/// prints on a change instead of every frame. +/// rects plus the mode and window the destination is bounded by. A plain tuple, +/// so the probe has a `Copy + PartialEq` value to compare against the last one +/// and prints on a change instead of every frame. type RectLogKey = ( c_int, c_int, @@ -820,6 +820,8 @@ type RectLogKey = ( c_int, c_int, c_int, + c_int, + c_int, ); /// The last buffer actually put on the layer, with the geometry it went out @@ -848,8 +850,15 @@ pub struct AscState { /// [`AscState::publish`] for why that matters and why the SurfaceFlinger /// completion callback does not depend on it. looper: Option, - panel_w: c_int, - panel_h: c_int, + /// The session's mode: the scan-out size the connector advertises and the + /// pool's buffers are allocated at. + mode_w: c_int, + mode_h: c_int, + /// The app's window — the display's own pixels — which the layer is + /// composited in. Equal to the mode unless the app asked for a smaller + /// scan-out size, in which case the window's compositor does the upscale. + win_w: c_int, + win_h: c_int, /// The scan-out buffer pool, on its own lock (see [`ScanoutPool`]). The /// lock is tracked (see `gndrm_daemon::diag`): a request that stalls on it /// must be able to name the thread holding it. @@ -896,11 +905,18 @@ pub struct AscState { unsafe impl Send for AscState {} impl AscState { - /// Create the child layer under `window`. Never fails: a missing publish - /// surface or a null layer leaves the presenter disabled, which drops - /// frames instead of failing the session. + /// Create the child layer under `window`, presenting the session's `mode_w`x`mode_h` + /// scan-out into the `win_w`x`win_h` window. Never fails: a missing publish + /// surface or a null layer leaves the presenter disabled, which drops frames + /// instead of failing the session. #[must_use] - pub fn new(window: *mut ffi::ANativeWindow, panel_w: i32, panel_h: i32) -> Self { + pub fn new( + window: *mut ffi::ANativeWindow, + mode_w: i32, + mode_h: i32, + win_w: i32, + win_h: i32, + ) -> Self { let loaded = ffi::ScApi::load(); // SAFETY: `window` is a live ANativeWindow owned by the caller for the // whole session; createFromWindow only reads it. @@ -925,7 +941,9 @@ impl AscState { } else { log_info!( "{}", - gndrm_obf::obf!("asc: SurfaceControl layer created ({panel_w}x{panel_h} panel)") + gndrm_obf::obf!( + "asc: SurfaceControl layer created (mode {mode_w}x{mode_h} in a {win_w}x{win_h} window)" + ) ); } // The `ALooper` is deliberately *not* prepared here. This function runs @@ -939,8 +957,10 @@ impl AscState { api, sc, looper: None, - panel_w, - panel_h, + mode_w, + mode_h, + win_w, + win_h, pool: Arc::new(TrackedMutex::new( diag::diag(), LOCK_SCANOUT_POOL, @@ -967,14 +987,14 @@ impl AscState { // allocator pool, which is empty at this point, so there is normally // nothing to put up yet — re-publishing is a no-op here and only does // work on a path that re-creates the layer over an existing table. - // The panel mode is a geometry the daemon can serve from a bare length, - // so a mode-sized scan-out allocation resolves by length even before a + // The mode is a geometry the daemon can serve from a bare length, so a + // mode-sized scan-out allocation resolves by length even before a // framebuffer of that size is committed. - if panel_w > 0 && panel_h > 0 { + if mode_w > 0 && mode_h > 0 { state.pool.lock(Role::Commit).note_framebuffer( - panel_w as u32, - panel_h as u32, - panel_w as u64 * panel_h as u64 * 4, + mode_w as u32, + mode_h as u32, + mode_w as u64 * mode_h as u64 * 4, ); } state.republish_last(); @@ -1047,24 +1067,32 @@ impl AscState { return; }; - // KMS geometry: the FB's SRC rect (16.16 fixed point) is scaled into - // the CRTC rect, and the output is black outside it. The layer is - // sized to the CRTC rect only, so the app's window shows through - // around it, and the layer's background covers transparent regions - // inside it (gamescope letterboxes with transparent bars). + // The FB's SRC rect (16.16 fixed point) is the crop inside the buffer, + // which the mode's coordinates then scale into the window. let src = ffi::ARect { left: (plane.src_x >> 16) as c_int, top: (plane.src_y >> 16) as c_int, right: ((plane.src_x + plane.src_w) >> 16) as c_int, bottom: ((plane.src_y + plane.src_h) >> 16) as c_int, }; - // KMS clips the plane to the mode: the destination rect is the CRTC - // rect bounded by the panel. + // KMS clips the plane to the mode, and the layer is composited in the + // window: the CRTC rect (mode pixels) is fitted into the window, so a + // scan-out size below the display's is upscaled by the display's own + // compositor rather than by gamescope's composite, which stops at the + // mode. The output is black outside the rect, and the layer is sized to + // it only, so whatever lies outside is the app's window showing through; + // the layer's background covers transparent regions inside it + // (gamescope letterboxes with transparent bars). + let (dst_left, dst_top, dst_right, dst_bottom) = gndrm_daemon::scanout::layer_rect( + (plane.crtc_x, plane.crtc_y, plane.crtc_w, plane.crtc_h), + (self.mode_w as u32, self.mode_h as u32), + (self.win_w as u32, self.win_h as u32), + ); let dst = ffi::ARect { - left: plane.crtc_x.max(0), - top: plane.crtc_y.max(0), - right: (plane.crtc_x + plane.crtc_w).min(self.panel_w), - bottom: (plane.crtc_y + plane.crtc_h).min(self.panel_h), + left: dst_left, + top: dst_top, + right: dst_right, + bottom: dst_bottom, }; let drawable = src.right > src.left && src.bottom > src.top @@ -1073,11 +1101,12 @@ impl AscState { let keep = self.last_publish.map(|lp| lp.ino); // UNGATED, change-latched: the rect this commit publishes the layer - // with, against the panel it is bounded by. The layer is a child of the - // app's window and is sized to the CRTC rect only, so whatever lies - // inside the panel but outside that rect is the app's own window + // with, against the window it is composited in. The layer is a child of + // the app's window and is sized to the CRTC rect only, so whatever lies + // inside the window but outside that rect is the app's own window // showing through — the bands this probe exists to measure. Prints on a - // rect or panel change and once at session start, never per frame. + // rect, mode or window change and once at session start, never per + // frame. let key: RectLogKey = ( src.left, src.top, @@ -1087,14 +1116,16 @@ impl AscState { dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h, + self.mode_w, + self.mode_h, + self.win_w, + self.win_h, ); if self.last_rect_log != Some(key) { self.last_rect_log = Some(key); asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer rect src=({},{})-({},{}) dst=({},{})-({},{}) panel={}x{} band left={} top={} right={} bottom={} drawable={}",key.0, + gndrm_obf::obf!("gndrm-daemon: asc layer rect src=({},{})-({},{}) dst=({},{})-({},{}) mode={}x{} window={}x{} band left={} top={} right={} bottom={} drawable={}",key.0, key.1, key.2, key.3, @@ -1104,10 +1135,12 @@ impl AscState { key.7, key.8, key.9, + key.10, + key.11, dst.left, dst.top, - self.panel_w - dst.right, - self.panel_h - dst.bottom, + self.win_w - dst.right, + self.win_h - dst.bottom, drawable,) ); } @@ -1374,13 +1407,15 @@ impl AscState { self.hidden = false; asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer shown again fb={} dst=({},{})-({},{}) panel={}x{}",fb.id, + gndrm_obf::obf!("gndrm-daemon: asc layer shown again fb={} dst=({},{})-({},{}) mode={}x{} window={}x{}",fb.id, dst.left, dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); } match build_show(&api, self.sc, ahb, ctx, rect, fence) { @@ -1425,7 +1460,7 @@ impl AscState { self.hidden = true; asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc layer HIDDEN (plane not drawable) fb={} src=({},{})-({},{}) dst=({},{})-({},{}) panel={}x{}",fb.id, + gndrm_obf::obf!("gndrm-daemon: asc layer HIDDEN (plane not drawable) fb={} src=({},{})-({},{}) dst=({},{})-({},{}) mode={}x{} window={}x{}",fb.id, src.left, src.top, src.right, @@ -1434,8 +1469,10 @@ impl AscState { dst.top, dst.right, dst.bottom, - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); } if let Some(tx) = build_hide(&api, self.sc) { @@ -1724,10 +1761,12 @@ impl AscState { asc_log!( "{}", gndrm_obf::obf!( - "gndrm-daemon: asc re-published retained ino {} onto a {}x{} layer (frames={})", + "gndrm-daemon: asc re-published retained ino {} onto a mode {}x{} in a {}x{} window (frames={})", lp.ino, - self.panel_w, - self.panel_h, + self.mode_w, + self.mode_h, + self.win_w, + self.win_h, self.frames ) ); @@ -1739,14 +1778,14 @@ impl AscState { /// retained last frame put back on it, so a display power cycle never /// leaves the panel with a bufferless layer. /// + /// The session's mode does not move: it is what gamescope is compositing at + /// and what the connector advertises, and neither can be re-moded from here. + /// A new window of another size is therefore reached by the same fit the + /// first window used. + /// /// Returns `false` when publishing is disabled or the new layer cannot be /// created, in which case the caller keeps the current window and layer. - pub fn retarget( - &mut self, - window: *mut ffi::ANativeWindow, - panel_w: i32, - panel_h: i32, - ) -> bool { + pub fn retarget(&mut self, window: *mut ffi::ANativeWindow, win_w: i32, win_h: i32) -> bool { let Some(api) = self.api else { return false; }; @@ -1770,8 +1809,8 @@ impl AscState { // layer handle, so no completion callback can reach the old layer this // call is about to drop. let old_sc = core::mem::replace(&mut self.sc, new_sc); - self.panel_w = panel_w; - self.panel_h = panel_h; + self.win_w = win_w; + self.win_h = win_h; // SAFETY: the old layer was created by this API, is no longer // referenced by the presenter or any mailbox, and is released exactly // once here. The window it was created from is released by the caller @@ -1781,7 +1820,7 @@ impl AscState { } log_info!( "{}", - gndrm_obf::obf!("asc: retargeted to the new window ({panel_w}x{panel_h})") + gndrm_obf::obf!("asc: retargeted to the new window ({win_w}x{win_h})") ); // A layer with no buffer set composites nothing (the app's background // shows through as a blank panel), so the retained frame goes straight @@ -1790,12 +1829,12 @@ impl AscState { true } - /// Adopt a panel geometry reported by a new window without rebuilding the - /// layer (a resize on the same window: the geometry only bounds the - /// published rect). - pub fn set_panel(&mut self, panel_w: i32, panel_h: i32) { - self.panel_w = panel_w; - self.panel_h = panel_h; + /// Adopt a window geometry reported by the same window without rebuilding the + /// layer (a resize on the same window: the geometry only bounds the published + /// rect). + pub fn set_window(&mut self, win_w: i32, win_h: i32) { + self.win_w = win_w; + self.win_h = win_h; } } @@ -1808,15 +1847,17 @@ impl Drop for AscState { // and this is the last line the presenter can print. asc_log!( "{}", - gndrm_obf::obf!("gndrm-daemon: asc teardown frames_published={} hidden={} last_publish_ino={} last_dst=({},{})-({},{}) panel={}x{}",self.frames, + gndrm_obf::obf!("gndrm-daemon: asc teardown frames_published={} hidden={} last_publish_ino={} last_dst=({},{})-({},{}) mode={}x{} window={}x{}",self.frames, self.hidden, self.last_publish.map_or(0, |lp| lp.ino), self.last_publish.map_or(0, |lp| lp.dst.left), self.last_publish.map_or(0, |lp| lp.dst.top), self.last_publish.map_or(0, |lp| lp.dst.right), self.last_publish.map_or(0, |lp| lp.dst.bottom), - self.panel_w, - self.panel_h) + self.mode_w, + self.mode_h, + self.win_w, + self.win_h) ); // Return every buffer before the layer goes: the presenter owns the // daemon's reference on each, and SurfaceFlinger may still be reading diff --git a/modules/gndrm-android/src/lib.rs b/modules/gndrm-android/src/lib.rs index b1f7934..d81c40a 100644 --- a/modules/gndrm-android/src/lib.rs +++ b/modules/gndrm-android/src/lib.rs @@ -26,6 +26,13 @@ //! mode's rate — the refresh advertised to clients — while the grid stays the //! daemon's constant. //! +//! The mode's *size* is the other session-level input. `nativeStart` takes it +//! from the window unless the app named one through `nativeSetScanoutGeometry` +//! first, and a size below the window's is what keeps a session off the panel's +//! pixel count: gamescope composites at the mode, the presenter scales the layer +//! up into the window, and the display's compositor does the upscale. A size +//! that does not fit the window is refused and the window's is the mode. +//! //! On non-Android targets this crate is an empty stub so host-side //! workspace gates (fmt/clippy/test) stay green; the real gates for this //! module are `tools/build-android.sh` and on-device runs. @@ -187,6 +194,48 @@ pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetLsfgCo /// still gets it for every later session. static PANEL_REFRESH_MHZ: AtomicU32 = AtomicU32::new(0); +/// The scan-out size the app asked the session to present at, supplied through +/// [`Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry`] before +/// the session starts; `None` until then, and then the window's size is the mode. +/// +/// It is the emulated connector's mode — what gamescope composites at, and what +/// the scan-out pool allocates — not the window's size. Below the window's it is +/// what keeps a session off the panel's pixel count: gamescope composites at this +/// size and the presenter scales the layer up into the window, so the display's +/// own compositor does the upscale. A size that does not fit the window is +/// refused at `nativeStart`, whose window is then the mode (see +/// [`scanout_geometry`]). +/// +/// Session-level and read once, like the panel rate: the mode is built when +/// `nativeStart` assembles the [`PanelDesc`], so a value set afterwards does not +/// reach the running session. Process-lifetime, not reset per session. +static SCANOUT_GEOMETRY: Mutex> = Mutex::new(None); + +/// The mode geometry for a window of `win_w`x`win_h`: the size the app asked for +/// when it fits, else the window's own. +/// +/// A mode larger than the window would composite more pixels than the display +/// can show and then have them scaled back down, so it is refused rather than +/// clamped to an aspect nobody asked for: the connector then advertises the +/// window's size, which is the pre-setting behaviour, and gamescope's `-W/-H` +/// (the requested size) matches no mode and falls back to it. +fn scanout_geometry(win_w: u16, win_h: u16) -> (u16, u16) { + let Some((want_w, want_h)) = *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) else { + return (win_w, win_h); + }; + if want_w >= 64 && want_h >= 64 && want_w <= u32::from(win_w) && want_h <= u32::from(win_h) { + (want_w as u16, want_h as u16) + } else { + log_info!( + "{}", + gndrm_obf::obf!( + "scan-out geometry {want_w}x{want_h} does not fit the {win_w}x{win_h} window; the mode is the window" + ) + ); + (win_w, win_h) + } +} + /// The app's `JavaVM`, cached from the first JNI entry point (`nativeStart`). /// Needed by the presenter and scan-out threads, which are Rust-spawned threads /// with no `JNIEnv` of their own: they attach with @@ -436,11 +485,12 @@ fn server_thread( listener: RawFd, window: WindowPtr, panel: PanelDesc, + window_size: (u16, u16), fds: Arc>, ready: mpsc::Sender>, String>>, ) { let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - server_thread_inner(listener, window, panel, fds, ready); + server_thread_inner(listener, window, panel, window_size, fds, ready); })); if let Err(e) = caught { report_panic("gndrm server", e.as_ref()); @@ -451,6 +501,7 @@ fn server_thread_inner( listener: RawFd, window: WindowPtr, panel: PanelDesc, + window_size: (u16, u16), fds: Arc>, ready: mpsc::Sender>, String>>, ) { @@ -461,6 +512,8 @@ fn server_thread_inner( window.0, i32::from(panel.width), i32::from(panel.height), + i32::from(window_size.0), + i32::from(window_size.1), ))); let presenter: Box = Box::new(asc::AscPresenter::new(Arc::clone(&asc_state))); @@ -881,11 +934,15 @@ fn start( ffi::ANativeWindow_getHeight(window.0), ) }; - let (panel_w, panel_h) = if win_w > 0 && win_h > 0 { + let (win_w, win_h) = if win_w > 0 && win_h > 0 { (win_w as u16, win_h as u16) } else { (width as u16, height as u16) }; + // The mode is the scan-out size, which the app may have asked to be below the + // window's so the display's compositor upscales instead of the session + // compositing at the panel's pixels (see `SCANOUT_GEOMETRY`). + let (panel_w, panel_h) = scanout_geometry(win_w, win_h); let panel = PanelDesc { width: panel_w, height: panel_h, @@ -945,9 +1002,10 @@ fn start( }; let (tx, rx) = mpsc::channel::>, String>>(); let thread_fds = Arc::clone(&fds); + let window_size = (win_w, win_h); let handle = thread::Builder::new() .name("gndrm".into()) - .spawn(move || server_thread(listener, window, panel, thread_fds, tx)); + .spawn(move || server_thread(listener, window, panel, window_size, thread_fds, tx)); let handle = match handle { Ok(h) => h, Err(e) => { @@ -996,7 +1054,7 @@ fn start( log_info!( "{}", - gndrm_obf::obf!("started: socket={name} {panel_w}x{panel_h}") + gndrm_obf::obf!("started: socket={name} {panel_w}x{panel_h} into {win_w}x{win_h}") ); log_info!( "{}", @@ -1129,7 +1187,9 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j ffi::ANativeWindow_getHeight(raw), ) }; - let (panel_w, panel_h) = if win_w > 0 && win_h > 0 { + // The window's size, not the session's mode: retargeting rebuilds the publish + // layer around a new window and never re-modes what gamescope is compositing at. + let (win_w, win_h) = if win_w > 0 && win_h > 0 { (win_w, win_h) } else { (width, height) @@ -1142,7 +1202,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .set_panel(panel_w, panel_h); + .set_window(win_w, win_h); // SAFETY: `raw` is the extra reference this call acquired. unsafe { ffi::ANativeWindow_release(raw) }; return 0; @@ -1150,7 +1210,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j let rebuilt = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .retarget(raw, panel_w, panel_h); + .retarget(raw, win_w, win_h); if !rebuilt { log_error!( "{}", @@ -1173,7 +1233,7 @@ fn retarget(env: &mut JNIEnv, surface: &JObject, width: jint, height: jint) -> j let _ = injector::gamescope_command("debug_force_repaint"); log_info!( "{}", - gndrm_obf::obf!("retarget: adopted the new window {panel_w}x{panel_h}") + gndrm_obf::obf!("retarget: adopted the new window {win_w}x{win_h}") ); 0 } @@ -1285,6 +1345,57 @@ pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetPanelR } } +/// `GameNativeDrm.nativeSetScanoutGeometry(width, height)` — tell the daemon the +/// scan-out size the session's mode is emulated at, before `nativeStart`. +/// +/// It is the size gamescope composites at and the connector advertises, so it is +/// also the size the scan-out pool allocates and the size the guest's X screen +/// and windows see. Given a size below the window's, the session stays off the +/// panel's pixel count and the presenter scales the layer up into the window. +/// A size that does not fit the window, or one below `64x64`, is refused at +/// `nativeStart` (the window's size is then the mode) — see [`scanout_geometry`]. +/// Safe to call with no session running; a value set after `nativeStart` does not +/// reach that session. +#[no_mangle] +pub extern "system" fn Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry( + _env: JNIEnv, + _class: JClass, + width: jint, + height: jint, +) { + if let Err(e) = std::panic::catch_unwind(|| set_scanout_geometry(width, height)) { + report_panic("gndrm-mode", e.as_ref()); + } +} + +/// Store the scan-out geometry from +/// [`Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry`], or +/// clear it for a zero axis. +/// +/// Only the obviously-unusable is dropped here — a negative or sub-`64` axis on +/// its own, which would make the mode's timings nonsense. Whether the size fits +/// the window is [`scanout_geometry`]'s question, because only `nativeStart` +/// knows the window. Clearing matters because the value is process-lifetime: a +/// session whose resolution cannot be read must not inherit the previous +/// session's size. +fn set_scanout_geometry(width: jint, height: jint) { + if width == 0 || height == 0 { + eprintln!("gndrm-daemon: scan-out geometry cleared"); + *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = None; + return; + } + if width < 64 || height < 64 { + log_warn!( + "{}", + gndrm_obf::obf!("scan-out geometry {width}x{height} below 64x64; ignored") + ); + return; + } + eprintln!("gndrm-daemon: scan-out geometry {}x{}", width, height); + *SCANOUT_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = + Some((width as u32, height as u32)); +} + /// Validate and store the panel refresh rate from /// [`Java_app_gamenative_display_GameNativeDrm_nativeSetPanelRefreshHz`]. /// diff --git a/modules/gndrm-daemon/src/edid.rs b/modules/gndrm-daemon/src/edid.rs index dac93ae..381c9f7 100644 --- a/modules/gndrm-daemon/src/edid.rs +++ b/modules/gndrm-daemon/src/edid.rs @@ -210,12 +210,12 @@ fn write_chromaticity(edid: &mut [u8; 128], primaries: [(f32, f32); 3], white: ( /// One 18-byte detailed timing descriptor matching the connector's mode. /// -/// The advertised mode's refresh is the daemon's 1200 Hz vblank grid -/// (`SESSION_REFRESH_MHZ`), which no DTD can express (the pixel-clock field -/// tops out at 655.35 MHz). gamescope reads the mode from the connector, not -/// from EDID timings, so the descriptor carries the advertised geometry and -/// porches exactly and a nominal 60 Hz pixel clock — a legal, self-consistent -/// value rather than a truncated one. +/// The pixel clock is the mode's own (`PanelDesc::modeinfo` builds it from the +/// mode's rate), so the descriptor and the connector describe one timing. The +/// 16-bit 10 kHz field tops out at 655.35 MHz, which only a frame-generated rate +/// above 4K reaches; such a mode falls back to a nominal 60 Hz. gamescope reads +/// the mode from the connector and the EDID only for colorimetry and HDR +/// metadata, so these timings are for consistency, not for mode selection. fn detailed_timing(mode: &DrmModeModeinfo, panel: &PanelDesc) -> [u8; 18] { let hact = u32::from(mode.hdisplay); let hblank = u32::from(mode.htotal).saturating_sub(hact); @@ -226,7 +226,11 @@ fn detailed_timing(mode: &DrmModeModeinfo, panel: &PanelDesc) -> [u8; 18] { let vfront = u32::from(mode.vsync_start).saturating_sub(vact); let vsync = u32::from(mode.vsync_end).saturating_sub(u32::from(mode.vsync_start)); let mut d = [0u8; 18]; - let clock_khz = u32::from(mode.htotal) * u32::from(mode.vtotal) * 60 / 1000; + let clock_khz = if (10..=655_350).contains(&mode.clock) { + mode.clock + } else { + u32::from(mode.htotal) * u32::from(mode.vtotal) * 60 / 1000 + }; let pc = (clock_khz / 10) & 0xFFFF; // pixel clock in 10 kHz units d[0] = (pc & 0xFF) as u8; d[1] = (pc >> 8) as u8; @@ -278,6 +282,42 @@ mod tests { PanelDesc::default().modeinfo() } + #[test] + fn the_detailed_timing_carries_the_modes_own_rate() { + let panel = PanelDesc { + width: 1280, + height: 960, + mm_width: 0, + mm_height: 0, + refresh_mhz: 120_000, + }; + let mode = panel.modeinfo(); + let edid = build(None, &mode, &panel); + let pc = u32::from(edid[54]) | (u32::from(edid[55]) << 8); + // The descriptor's timings imply the connector's rate, not a nominal one. + let hz = u64::from(pc) * 10_000 / (u64::from(mode.htotal) * u64::from(mode.vtotal)); + assert!((119..=120).contains(&hz), "descriptor implies {hz} Hz"); + } + + #[test] + fn a_clock_the_field_cannot_hold_falls_back_to_60_hz() { + let panel = PanelDesc { + width: 1920, + height: 1080, + mm_width: 0, + mm_height: 0, + refresh_mhz: 500_000, + }; + let mode = panel.modeinfo(); + assert!( + mode.clock > 655_350, + "the mode must exceed the 10 kHz field" + ); + let edid = build(None, &mode, &panel); + let pc = u32::from(edid[54]) | (u32::from(edid[55]) << 8); + assert_eq!(pc * 10, 2000 * 1100 * 60 / 1000); + } + #[test] fn sdr_edid_has_no_cta_extension() { let edid = build(None, &mode(), &PanelDesc::default()); diff --git a/modules/gndrm-daemon/src/scanout.rs b/modules/gndrm-daemon/src/scanout.rs index 09ccc31..6985914 100644 --- a/modules/gndrm-daemon/src/scanout.rs +++ b/modules/gndrm-daemon/src/scanout.rs @@ -46,6 +46,55 @@ pub fn serves_len(buffer_len: u64, requested_len: u64) -> bool { buffer_len >= requested_len } +/// Where a scan-out buffer lands on the session's window: a KMS plane rect in +/// *mode* pixels, mapped into the app's window (the display's own pixels). +/// +/// The session's mode is its scan-out size, which the app may ask for below the +/// window's (the Upscaling Resolution), so the two are not the same box. The +/// layer is composited in window pixels: a smaller mode is scaled up uniformly +/// and centred — a mode of another aspect is letterboxed, never stretched — and +/// that upscale belongs to the display compositor rather than to gamescope, +/// whose composite stops at the mode. Mode equal to window is the identity, so +/// a 1:1 present is unchanged. +/// +/// `crtc` is `(x, y, width, height)` in mode pixels, as the plane committed it; +/// the result is `(left, top, right, bottom)` in window pixels, clipped to the +/// mode (a plane rect can exceed it) and therefore inside the window. +#[must_use] +pub fn layer_rect( + crtc: (i32, i32, i32, i32), + mode: (u32, u32), + window: (u32, u32), +) -> (i32, i32, i32, i32) { + let mw = i64::from(mode.0.max(1)); + let mh = i64::from(mode.1.max(1)); + let ww = i64::from(window.0.max(1)); + let wh = i64::from(window.1.max(1)); + // The mode's box fitted into the window: width-limited or height-limited. + let (fit_w, fit_h) = if ww * mh <= wh * mw { + (ww, mh * ww / mw) + } else { + (mw * wh / mh, wh) + }; + let off_x = (ww - fit_w) / 2; + let off_y = (wh - fit_h) / 2; + // One axis: clamp to the mode's span, scale, offset, round to nearest. + let place = + |v: i64, span: i64, fit: i64, off: i64| off + (v.clamp(0, span) * fit + span / 2) / span; + let (x, y, w, h) = ( + i64::from(crtc.0), + i64::from(crtc.1), + i64::from(crtc.2), + i64::from(crtc.3), + ); + ( + place(x, mw, fit_w, off_x) as i32, + place(y, mh, fit_h, off_y) as i32, + place(x + w, mw, fit_w, off_x) as i32, + place(y + h, mh, fit_h, off_y) as i32, + ) +} + /// Geometry a pooled buffer can serve. A free buffer only matches a request of /// the exact same width, height and format. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] @@ -1038,4 +1087,55 @@ mod tests { Reservation::Full => "full", } } + + /// The mode filling the window is the identity, so a session whose scan-out size + /// is the display's presents exactly as before this mapping existed — including a + /// plane rect that is letterboxed inside the mode. + #[test] + fn layer_rect_is_the_identity_for_a_mode_that_fills_the_window() { + assert_eq!( + layer_rect((0, 0, 1280, 720), (1280, 720), (1280, 720)), + (0, 0, 1280, 720) + ); + assert_eq!( + layer_rect((0, 27, 1280, 904), (1280, 960), (1280, 960)), + (0, 27, 1280, 931), + "a window letterboxed inside the mode keeps its rect" + ); + } + + /// A mode below the window is upscaled by the display compositor: one uniform + /// 1.5x here, covering the window. + #[test] + fn layer_rect_upscales_a_mode_below_the_window() { + assert_eq!( + layer_rect((0, 0, 1280, 720), (1280, 720), (1920, 1080)), + (0, 0, 1920, 1080) + ); + assert_eq!( + layer_rect((320, 180, 640, 360), (1280, 720), (1920, 1080)), + (480, 270, 1440, 810), + "a window inside the mode is scaled with it" + ); + } + + /// A mode of another aspect is letterboxed, never stretched. + #[test] + fn layer_rect_letterboxes_a_mode_of_another_aspect() { + assert_eq!( + layer_rect((0, 0, 1024, 768), (1024, 768), (1920, 1080)), + (240, 0, 1680, 1080), + "4:3 in a 16:9 window: pillarboxed" + ); + } + + /// A plane rect that runs past the mode is clipped to it, so the layer never + /// leaves the window. + #[test] + fn layer_rect_clips_to_the_mode() { + assert_eq!( + layer_rect((-40, -40, 1280 + 80, 720 + 80), (1280, 720), (1920, 1080)), + (0, 0, 1920, 1080) + ); + } } diff --git a/modules/gndrm-daemon/tests/golden.rs b/modules/gndrm-daemon/tests/golden.rs index 38e044d..2c0cc52 100644 --- a/modules/gndrm-daemon/tests/golden.rs +++ b/modules/gndrm-daemon/tests/golden.rs @@ -2671,3 +2671,58 @@ fn presenter_gets_the_committed_buffer_as_a_source() { // SAFETY: the test's own reference. unsafe { libc::close(fd) }; } + +// --------------------------------------------------------------------------- +// A50: the mode is the session's scan-out size, not the window's +// --------------------------------------------------------------------------- + +/// The connector advertises the size the app asked the session to present at, +/// so gamescope's `-W/-H` finds a mode and composites at it. This is what makes +/// an Upscaling Resolution below the panel a scan-out size rather than only a +/// scaling hint. +#[test] +fn connector_advertises_the_session_scanout_size() { + let _layout = with_layout(ScanoutLayout::Bgra8888); + let mut d = Device::new( + PanelDesc { + width: 1920, + height: 1080, + ..PanelDesc::default() + }, + Box::::default(), + ); + d.set_clock(Box::new(|| (1000, 500))); + + let r = call( + &mut d, + DRM_IOCTL_MODE_GETCONNECTOR, + &pod(&DrmModeGetConnector { + connector_id: CONNECTOR_ID, + modes_ptr: 1, + count_modes: 1, + ..Default::default() + }), + ); + ok(&r); + let off = core::mem::size_of::(); + let m: DrmModeModeinfo = pod_read(&r.body[off..]).unwrap(); + assert_eq!( + (m.hdisplay, m.vdisplay), + (1920, 1080), + "the mode is the scan-out size the panel was built with" + ); + + // GETCRTC carries the same mode (its `mode_valid` follows a committed + // MODE_ID, so it is 0 on a fresh device and says nothing here). + let r = call( + &mut d, + DRM_IOCTL_MODE_GETCRTC, + &pod(&DrmModeCrtc { + crtc_id: CRTC_ID, + ..Default::default() + }), + ); + ok(&r); + let c: DrmModeCrtc = pod_read(&r.body).unwrap(); + assert_eq!((c.mode.hdisplay, c.mode.vdisplay), (1920, 1080)); +} diff --git a/modules/gndrm-shim/gndrm_shim.c b/modules/gndrm-shim/gndrm_shim.c index 52df54b..5cc4361 100644 --- a/modules/gndrm-shim/gndrm_shim.c +++ b/modules/gndrm-shim/gndrm_shim.c @@ -1313,6 +1313,106 @@ _Static_assert(sizeof(struct gndrm_dmaheap_alloc) == 24, static uint32_t g_scan_modes[GNDRM_MAX_MODES][2]; static int g_scan_nmodes; +/* + * The row stride the daemon's pool buffer has for a scan-out geometry. gralloc + * pads rows for widths it does not like (1024 px -> 5120 B on Adreno) while a + * LINEAR Vulkan image is laid out at width * 4, so a client that binds the + * buffer at its own pitch writes rows at one stride into a buffer the + * compositor reads at another. A client that asks here before creating its + * image can lay that image out at the buffer's stride instead; see the + * gamescope A51 patch. + * + * Keyed by geometry because that is what such a client knows: the output size + * it is about to create an image for. + */ +#define GNDRM_MAX_STRIDES 8 + +struct gndrm_stride_note { + uint32_t w; + uint32_t h; + uint32_t stride; + uint64_t len; +}; + +static struct gndrm_stride_note g_scan_strides[GNDRM_MAX_STRIDES]; +static int g_scan_nstrides; + +static int scanout_request(uint32_t w, uint32_t h, uint64_t len, int *out_fd, + uint64_t *out_ino, uint32_t *out_stride, + int *out_attempts, long *out_elapsed_ms); + +/* Remember the stride the daemon reported for a pool buffer of `len` bytes, + * allocated for `w`x`h` when the shim matched a mode, else for whichever + * geometry the daemon resolved. */ +static void scanout_note_stride(uint64_t len, uint32_t w, uint32_t h, + uint32_t stride) +{ + if (!len || !stride) + return; + for (int i = 0; i < g_scan_nstrides; i++) + if ((w && g_scan_strides[i].w == w && g_scan_strides[i].h == h) || + g_scan_strides[i].len == len) { + g_scan_strides[i].w = w; + g_scan_strides[i].h = h; + g_scan_strides[i].len = len; + g_scan_strides[i].stride = stride; + return; + } + if (g_scan_nstrides >= GNDRM_MAX_STRIDES) + return; + g_scan_strides[g_scan_nstrides].w = w; + g_scan_strides[g_scan_nstrides].h = h; + g_scan_strides[g_scan_nstrides].len = len; + g_scan_strides[g_scan_nstrides].stride = stride; + g_scan_nstrides++; +} + +/* The noted stride for `w`x`h` or, failing that, for the tight length of that + * geometry (a length-only hand-out knew the geometry only from its length). */ +static uint32_t scanout_noted_pitch(uint32_t w, uint32_t h) +{ + uint64_t tight = (uint64_t)w * (uint64_t)h * 4u; + for (int i = 0; i < g_scan_nstrides; i++) + if (g_scan_strides[i].w == w && g_scan_strides[i].h == h) + return g_scan_strides[i].stride; + for (int i = 0; i < g_scan_nstrides; i++) + if (g_scan_strides[i].len == tight) + return g_scan_strides[i].stride; + return 0; +} + +/* + * The pool buffer's row stride for a `w`x`h` scan-out image, or 0 when the + * shim cannot find out — no daemon channel, so the allocation has no pool + * buffer behind it and the driver's own pitch is the right one. + * + * gralloc's stride is not derivable from the geometry, so an unrecorded + * geometry is measured by asking the daemon for one buffer of it and keeping + * the stride. The buffer is not kept: its fd is closed, the daemon releases it + * at the next commit and its geometry (and so its padded length) stays in the + * pool's index, which is what the client's real allocation then resolves + * against. + */ +uint32_t gamenative_drm_scanout_pitch(uint32_t w, uint32_t h); +uint32_t gamenative_drm_scanout_pitch(uint32_t w, uint32_t h) +{ + if (!w || !h) + return 0; + uint32_t pitch = scanout_noted_pitch(w, h); + if (pitch) + return pitch; + + int fd = -1; + uint64_t ino = 0; + uint32_t stride = 0; + uint64_t len = (uint64_t)w * (uint64_t)h * 4u; + if (scanout_request(w, h, len, &fd, &ino, &stride, NULL, NULL) != 0) + return 0; + syscall(SYS_close, fd); + scanout_note_stride(len, w, h, stride); + return stride; +} + /* ------------------------------------------------------------------ */ /* scan-out allocator channel */ /* ------------------------------------------------------------------ */ @@ -1719,6 +1819,10 @@ static int scanout_hand_out(struct gndrm_dmaheap_alloc *a, syscall(SYS_fcntl, fd, F_SETFD, FD_CLOEXEC); a->fd = (uint32_t)fd; a->heap_flags = 0; + /* The client that owns this allocation is about to bind it at a pitch of + * its own choosing; recording the daemon's lets it ask before it does + * (A51). */ + scanout_note_stride(len, w, h, stride); if (w && h) LOG("scanout: heap alloc %llu B -> daemon buffer %ux%u fd=%d ino=%llu stride=%u\n", (unsigned long long)len, w, h, fd, (unsigned long long)ino, stride); diff --git a/tools/exports/libgamenative_drm.map b/tools/exports/libgamenative_drm.map index c6532ab..f924b98 100644 --- a/tools/exports/libgamenative_drm.map +++ b/tools/exports/libgamenative_drm.map @@ -22,6 +22,7 @@ Java_app_gamenative_display_GameNativeDrm_nativeReshadeSetEffect; Java_app_gamenative_display_GameNativeDrm_nativeReshadeDisable; Java_app_gamenative_display_GameNativeDrm_nativeSetPanelRefreshHz; + Java_app_gamenative_display_GameNativeDrm_nativeSetScanoutGeometry; Java_app_gamenative_display_GameNativeDrm_nativeSetLsfgConfig; Java_app_gamenative_display_GameNativeDrm_nativeStart; Java_app_gamenative_display_GameNativeDrm_nativeStop; diff --git a/tools/exports/libgndrm_shim.map b/tools/exports/libgndrm_shim.map index 9bdac94..791bccc 100644 --- a/tools/exports/libgndrm_shim.map +++ b/tools/exports/libgndrm_shim.map @@ -8,6 +8,7 @@ { global: gamenative_drm_connect; + gamenative_drm_scanout_pitch; open; openat; open64;