feat(capture): gamescope cursor via XFixes shape + QueryPointer position (Phase C)

gamescope excludes its pointer from the PipeWire node it feeds us and can't embed
one either (`set_hw_cursor` is inert), so every gamescope stream was cursorless.
Read the pointer from gamescope's nested Xwayland instead — XFixesGetCursorImage
for shape/hotspot/visibility, core QueryPointer for position — and publish a
CursorOverlay into the capturer's existing `cursor_live` slot, so the encoder
blend composites it into the video exactly like the SPA_META_Cursor path.

- pf-capture/src/linux/xfixes_cursor.rs (new): the XFixes reader. Connects to
  EVERY nested Xwayland (Gaming Mode runs one per --xwayland-count) and follows
  the focused one each tick (the display whose pointer moves), reading that
  display's own cursor shape. Un-premultiplies ARGB -> straight RGBA. Drop stops
  the thread.
- Capturer::attach_gamescope_cursor + the PortalCapturer override spawn it into
  the same `cursor_live` slot; pf_vdisplay::gamescope_xwayland_cursor_targets
  discovers the (DISPLAY, XAUTHORITY) pairs via the GAMESCOPE_WAYLAND_DISPLAY scan.
- host: SessionPlan.gamescope_cursor (set from the compositor at both resolve
  sites AND on a mid-stream Desktop->Gaming switch); the blend gate now builds the
  encoder blend for gamescope; a sibling composite arm attaches capturer.cursor()
  per tick for capture-mode clients (no channel needed). native NV12 is disabled
  for these sessions — that encode path can't blend the cursor (it assumes
  gamescope embeds the pointer), so we capture RGB and route to the proven CUDA
  VkSlotBlend / compute-CSC blend.
- the pipewire thread no longer clobbers `cursor_live` with None on a buffer that
  carries no SPA_META_Cursor (gamescope) — that raced the XFixes writer and
  strobed the composited pointer on/off.

On-glass (home-bazzite-2, RTX 5070 Ti, Gaming Mode): cursor visible + steady in
Big Picture and CS2 menus, follows focus between the two Xwaylands, hidden in-game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:53 +02:00
co-authored by Claude Opus 4.8
parent 925130d1f9
commit d5ae8dcc3e
10 changed files with 614 additions and 31 deletions
+70 -15
View File
@@ -1000,18 +1000,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
ctx.bit_depth,
ctx.chroma,
ctx.codec,
// Blend CAPABILITY only for cursor-FORWARD sessions (Phase B, the Windows gate
// mirrored): their client can flip to the capture mouse model mid-stream
// (`CursorRenderMode`), and the composite side of that flip must not need an encoder
// rebuild — WHETHER a frame's pointer is drawn stays per-tick (the encode loop strips
// `frame.cursor` while the client draws locally, see the forwarder tick). Every OTHER
// session's output is created with the pointer compositor-EMBEDDED
// (`vd.set_hw_cursor(false)` → no cursor metadata ever arrives, nothing to blend), so
// it keeps the zero-cost pre-channel path — and gamescope never has a pointer either
// way.
ctx.compositor != pf_vdisplay::Compositor::Gamescope && ctx.cursor_forward,
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
// the blend must be built for every gamescope session.
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
ctx.cursor_forward,
);
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
// resolve so the flag is a pure function of the compositor.
plan.gamescope_cursor = ctx.compositor == pf_vdisplay::Compositor::Gamescope;
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
if ctx.codec == crate::encode::Codec::PyroWave {
@@ -1070,6 +1073,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if cursor_forward {
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
}
// gamescope (Phase C): no channel for a plain capture-mode client and no compositor-embedded
// pointer, so the host ALWAYS composites the XFixes-sourced cursor into the video. Active only
// when there's no cursor-forward channel (a future desktop-mode gamescope client takes the
// `cursor_fwd` path instead). See `plan.gamescope_cursor`.
// `mut`: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below) retargets the
// compositor, so this is recomputed there against the live compositor.
let mut gamescope_composite =
compositor == pf_vdisplay::Compositor::Gamescope && cursor_fwd.is_none();
if gamescope_composite {
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
if streamed_wire {
tracing::info!(
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
@@ -1961,6 +1975,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
"capture loss: active session switched compositor — retargeting");
vd = v;
compositor = c;
// remote-desktop-sweep Phase C: the cursor pipeline was
// resolved for the OLD compositor (e.g. a Desktop session
// that then launched a game). Re-gate against the LIVE one,
// mirroring SessionPlan::resolve: a switch TO gamescope must
// build the encoder blend + attach the XFixes source on the
// rebuild below (gamescope can't embed a pointer or carry a
// capture-mode channel); a switch AWAY restores the prior
// gating. `plan` is `Copy` — this is the value the rebuild
// (and its `build_pipeline` attach) reads.
plan.cursor_blend = plan.cursor_forward
|| c == crate::vdisplay::Compositor::Gamescope;
plan.gamescope_cursor =
c == crate::vdisplay::Compositor::Gamescope;
gamescope_composite =
plan.gamescope_cursor && cursor_fwd.is_none();
}
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
"capture loss: opening the newly-detected compositor failed — retrying"),
@@ -2110,6 +2139,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
}
}
}
} else if gamescope_composite {
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
// freezing at the last damage frame (the same reason the composite arm above re-reads
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
#[cfg(not(target_os = "windows"))]
if let Some(live) = capturer.cursor() {
frame.cursor = Some(live);
}
}
// The overlay surfaces hidden pointers too (for the hint above) — strip them
// HERE, after forwarding, so no blend path ever draws an invisible cursor.
@@ -2792,13 +2831,14 @@ pub(super) fn prepare_display(
bit_depth,
chroma,
codec,
// Blend capability only for cursor-forward sessions — must MATCH virtual_stream's
// resolve (Phase B: non-channel sessions get the pointer compositor-EMBEDDED, nothing
// to blend; the mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per
// tick for channel sessions).
compositor != pf_vdisplay::Compositor::Gamescope && cursor_forward,
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
cursor_forward,
);
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
if codec == crate::encode::Codec::PyroWave {
plan.wire_chunk = Some(shard_payload as usize);
}
@@ -3056,6 +3096,21 @@ fn build_pipeline(
let mut capturer =
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?;
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
#[cfg(target_os = "linux")]
if plan.gamescope_cursor {
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
if targets.is_empty() {
tracing::warn!(
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
);
} else {
capturer.attach_gamescope_cursor(targets);
}
}
if let Some(t) = trace {
t.mark("capture_attached");
}
+16 -2
View File
@@ -112,6 +112,12 @@ pub struct SessionPlan {
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
/// hardware cursor up via [`OutputFormat::hw_cursor`](pf_frame::OutputFormat).
pub cursor_forward: bool,
/// This is a gamescope session and its cursor comes from the XFixes source, NOT the
/// (absent) `SPA_META_Cursor` (remote-desktop-sweep Phase C). Distinct from `cursor_forward`:
/// gamescope can't embed the pointer OR carry the channel for a plain capture-mode client, so
/// the host ALWAYS composites the XFixes-sourced cursor into the video (`cursor_blend` is set
/// too). `build_pipeline` reads this to attach the XFixes reader to the capturer.
pub gamescope_cursor: bool,
}
impl SessionPlan {
@@ -135,6 +141,9 @@ impl SessionPlan {
wire_chunk: None,
cursor_blend,
cursor_forward,
// Set by the resolve callers (they know the compositor); default off keeps every
// non-gamescope plan unchanged.
gamescope_cursor: false,
}
}
@@ -188,9 +197,14 @@ impl SessionPlan {
pyrowave: self.codec == crate::encode::Codec::PyroWave,
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
// backend — resolved HERE from the plan's codec so the capturer never reaches back
// into encode (the same one-way edge as `gpu` above).
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
// blend is the perf-preserving follow-up.
#[cfg(target_os = "linux")]
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
#[cfg(not(target_os = "linux"))]
nv12_native: false,
}