daemon: present the cursor on a layer of its own (GNCL cursor plane)

Merged
UT
10d ago
+1708-22

daemon: present the cursor on a layer of its own (GNCL)

Base: master (now at 53df34a, with #37). No longer stacked on #36: this PR stands alone and carries no latch-grid code (no GNLG cap, no latch.rs, no frozen LSFG lead, no gn_panel_refresh). Commits: 260d894 (GNCL cursor layer), 36e5ee1 (switchable presentation, experimental), c69d8f7 (staged presentation as the default, rotation derived from the window, experimental modes removed).

Problem

On Android gamescope cannot use a cursor plane: the daemon's cursor plane was capped at 64x64 and never presented, and drm_cursor_plane stays off because liftoff could otherwise park the game on it (black screen). So every cursor move is a full composite plus a scan-out flip. Measured on the Retroid Pocket 6 (120 Hz, Dave the Diver, cap 60, cursor moving): 70-76 presents/s on the scan-out layer (cursor-only composites), gamescope +2.8 % CPU, +500 wakeups/s; with LSFG x2 the cursor composites wreck pacing (719 doubled gaps per 1500 frames vs 160 hidden).

Change

  • New private client cap GNDRM_CLIENT_CAP_CURSOR_LAYER = 0x474E434C ("GNCL"), card node only. With it set the cursor plane reports 256x256 and is presented; without it (old gamescope) the 64x64 rule and today's behaviour are unchanged. An old daemon answers EINVAL and gamescope keeps the cursor in the composite.
  • With GNCL the cursor plane rejects (TEST_ONLY and real commits, EINVAL) any rect or framebuffer larger than 256, so liftoff can never place the game there.
  • Presenter (asc.rs): a sibling gndrm-cursor SurfaceFlinger layer above the scan-out layer, premultiplied alpha, geometry-only transactions for moves, FB 0 hides, rebuilt on retarget. The cursor image is copied once through the foreign-buffer path and follows the session layout rule (RGBA on sdk>=36 GLES).
  • HWC finding on the RP6 (portrait panel at ROTATION_90): a cursor layer with transform 90 plus 1.5x scaling falls back to GPU composition (the inline rotator takes UBWC only and the single IWE path is used by the scan-out layer). The presenter therefore copies the cursor pre-rotated by the display rotation and pre-scaled to the output scale and presents it with the inverse buffer transform, so HWC sees transform 0 and no scaling: DEVICE composition, correct size and orientation (verified on the RP6 as mode 7: buffer 24x18, dst 18x24, transform 7). This staged presentation is now the default (asc cursor staged rot=.. (derived) transform=.. in logcat). The geometry lives in gndrm_daemon::cursor (host-tested).
  • Rotation: no NDK call reports the display rotation, so it is derived from the window (derive_rotation): 90 when the window's orientation differs from the panel's assumed natural one (landscape when ro.build.characteristics contains tablet, portrait otherwise: phones and handhelds), else 0. Limitation: 90 and 270 cannot be told apart, and a panel of the other natural orientation gets the wrong value. The picture is right either way (pre-rotation and the inverse buffer transform cancel in window space); only the composer then sees a residual rotation (180, or 90/270) and may compose the cursor on the GPU, as before.
  • A 10-bit cursor or a failed staging allocation falls back to the padded buffer (composer-scaled, transform 0).
  • Debug overrides, read live: debug.gndrm.cursor_mode=0 shows the padded buffer as KMS describes it; debug.gndrm.cursor_rot=0|90|180|270 replaces the derived rotation. The other experimental modes are removed.
  • A cursor-only commit never republishes the scan-out layer, so LSFG gets no extra source frames.

Results (RP6, cap 60, same scene)

cursor hiddencursor moving
before (fix 1 stack): scan-out presents/s46.9 (pre shim fix)70.78, full composite per move
this PR, GPU-composited cursor layer (mode 0)60.0054.95, cursor CLIENT
this PR, pre-rotated/pre-scaled (default)59.9854.85, both layers DEVICE, picture intact

The game's own rate still drops ~5 fps with the cursor moving on this device; that is the per-motion wineserver work on the game's main thread (10 round trips per motion event under Xwayland, made expensive by libredirect's per-read readlink), not composition. Separate follow-up.

Tests

Golden: cap raises the limit and a render node is refused; anything larger than a cursor is rejected; cursor motion updates only the cursor layer; no presentation without the cap. Unit tests for the clip/crop geometry, the derived rotation, the staged geometry (the measured RP6 case), identity in window space for all four rotations, hotspot placement after scaling and edge clipping. cargo test -p gndrm-daemon -p gndrm-android (rust:1-bookworm, 129 tests) and clippy (aarch64-linux-android) clean after the rebase onto master.

Risks

HWC handling of a small translucent layer on other SoCs (it falls back to GPU, never to a wrong picture); one 256 KB copy per cursor image change; HDR sessions tag the cursor sRGB; the rotation is derived from the window, not read from the display (limits above), and an external display is not considered.