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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user