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:
Generated
+28
@@ -1459,6 +1459,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2829,6 +2839,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5886,6 +5897,23 @@ version = "0.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"rustix",
|
||||||
|
"x11rb-protocol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "x11rb-protocol"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ pipewire = "0.9"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
# ashpd 0.13 uses the tokio runtime for the one-time portal handshake (control plane).
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||||
|
# XFixes cursor source for gamescope (remote-desktop-sweep Phase C): gamescope paints no
|
||||||
|
# `SPA_META_Cursor`, so the pointer never reaches the PipeWire node. We read the shape/hotspot/
|
||||||
|
# visibility from gamescope's nested Xwayland via XFixes instead and feed the existing cursor slot.
|
||||||
|
# `RustConnection` is the pure-Rust default (no libxcb link → no new C dependency on the host); the
|
||||||
|
# `xfixes` feature (auto-pulls `render` + `shape`) is what exposes GetCursorImage/SelectCursorInput.
|
||||||
|
x11rb = { version = "0.13", default-features = false, features = ["xfixes"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
# The host<->driver wire contract for the sealed frame channel (control IOCTL structs + frame header).
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ pub trait Capturer: Send {
|
|||||||
/// the encode loop blends its overlay instead.
|
/// the encode loop blends its overlay instead.
|
||||||
fn set_cursor_forward(&mut self, _on: bool) {}
|
fn set_cursor_forward(&mut self, _on: bool) {}
|
||||||
|
|
||||||
|
/// Attach a gamescope cursor source (remote-desktop-sweep Phase C). gamescope paints no
|
||||||
|
/// `SPA_META_Cursor`, so [`cursor`](Self::cursor)'s slot stays empty — this hands the Linux
|
||||||
|
/// portal capturer gamescope's nested Xwayland `(DISPLAY, XAUTHORITY)` targets (it may run
|
||||||
|
/// several — one per `--xwayland-count`) so it reads the pointer shape/position over X11
|
||||||
|
/// (XFixes + QueryPointer), following whichever display is focused, and publishes it into that
|
||||||
|
/// same slot. Called once, after the capturer is built, only for gamescope sessions. Default
|
||||||
|
/// no-op: every non-gamescope capturer already has a cursor source.
|
||||||
|
fn attach_gamescope_cursor(&mut self, _targets: Vec<(String, Option<String>)>) {}
|
||||||
|
|
||||||
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
|
|
||||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
||||||
|
// gamescope cursor source (remote-desktop-sweep Phase C) — feeds `cursor_live` from XFixes when
|
||||||
|
// the PipeWire node carries no `SPA_META_Cursor` (gamescope's does not).
|
||||||
|
mod xfixes_cursor;
|
||||||
use std::os::fd::OwnedFd;
|
use std::os::fd::OwnedFd;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
|
||||||
@@ -88,6 +92,12 @@ pub struct PortalCapturer {
|
|||||||
/// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the
|
/// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the
|
||||||
/// portal source (its session ends with the portal thread's zbus connection).
|
/// portal source (its session ends with the portal thread's zbus connection).
|
||||||
_keepalive: Option<Box<dyn Send>>,
|
_keepalive: Option<Box<dyn Send>>,
|
||||||
|
/// The gamescope XFixes cursor reader (remote-desktop-sweep Phase C), when this capturer
|
||||||
|
/// serves a gamescope node. `Some` after
|
||||||
|
/// [`attach_gamescope_cursor`](Capturer::attach_gamescope_cursor); its `Drop` stops the reader
|
||||||
|
/// thread, so it lives exactly as long as the capturer. `None` on the portal path (its cursor
|
||||||
|
/// comes from `SPA_META_Cursor`).
|
||||||
|
_gs_cursor: Option<xfixes_cursor::XFixesCursorSource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PortalCapturer {
|
impl PortalCapturer {
|
||||||
@@ -218,6 +228,7 @@ impl PwHandles {
|
|||||||
quit: Some(self.quit),
|
quit: Some(self.quit),
|
||||||
join: Some(self.join),
|
join: Some(self.join),
|
||||||
_keepalive: keepalive,
|
_keepalive: keepalive,
|
||||||
|
_gs_cursor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,9 +346,21 @@ impl Capturer for PortalCapturer {
|
|||||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||||
// The PipeWire thread's live cursor slot (fed by every buffer's meta, frames or not) —
|
// The PipeWire thread's live cursor slot (fed by every buffer's meta, frames or not) —
|
||||||
// lets the forwarder track pointer-only motion on a static desktop. See `cursor_live`.
|
// lets the forwarder track pointer-only motion on a static desktop. See `cursor_live`.
|
||||||
|
// On a gamescope node the meta never arrives; the XFixes source (attached below) fills
|
||||||
|
// the same slot instead.
|
||||||
self.cursor_live.lock().ok().and_then(|slot| slot.clone())
|
self.cursor_live.lock().ok().and_then(|slot| slot.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn attach_gamescope_cursor(&mut self, targets: Vec<(String, Option<String>)>) {
|
||||||
|
// gamescope paints no `SPA_META_Cursor`, so `cursor_live` would stay empty. Spawn the
|
||||||
|
// XFixes reader to publish gamescope's pointer into that SAME slot — `cursor()` above then
|
||||||
|
// serves it and the encode loop composites it, exactly like the portal path. It connects
|
||||||
|
// to every nested Xwayland and follows the focused one's pointer. A failure (no Xwayland /
|
||||||
|
// no XFixes) logs and leaves the slot empty = today's cursorless gamescope.
|
||||||
|
self._gs_cursor =
|
||||||
|
xfixes_cursor::XFixesCursorSource::spawn(targets, Arc::clone(&self.cursor_live));
|
||||||
|
}
|
||||||
|
|
||||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||||
self.frame_within(budget)
|
self.frame_within(budget)
|
||||||
}
|
}
|
||||||
@@ -2503,11 +2526,18 @@ mod pipewire {
|
|||||||
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
|
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
|
||||||
// frame, but their cursor meta is fresh and must still move our overlay.
|
// frame, but their cursor meta is fresh and must still move our overlay.
|
||||||
update_cursor_meta(&mut ud.cursor, spa_buf);
|
update_cursor_meta(&mut ud.cursor, spa_buf);
|
||||||
// Publish the LIVE overlay (frames or not): the encode loop's forwarder polls
|
// Publish the LIVE overlay (frames or not) so the encode loop's forwarder
|
||||||
// it per tick, so pointer-only motion tracks on a static desktop — the
|
// tracks pointer-only motion on a static desktop — the frame-attached overlay
|
||||||
// frame-attached overlay alone stales between damage frames.
|
// alone stales between damage frames. ONLY when we actually have one: a
|
||||||
|
// gamescope node carries no `SPA_META_Cursor`, so `overlay()` is always `None`
|
||||||
|
// here, and writing that would clobber — at frame rate — the `Some` the
|
||||||
|
// attached XFixes source publishes into this SAME slot, strobing the
|
||||||
|
// composited pointer on/off. Portal cursors are `None` only before the first
|
||||||
|
// bitmap (nothing to drop), and a HIDDEN pointer is still `Some(visible:false)`.
|
||||||
|
if let Some(overlay) = ud.cursor.overlay() {
|
||||||
if let Ok(mut slot) = ud.cursor_live.lock() {
|
if let Ok(mut slot) = ud.cursor_live.lock() {
|
||||||
*slot = ud.cursor.overlay();
|
*slot = Some(overlay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
// Inspect the newest buffer's header + first chunk for the diagnostic and the
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! XFixes cursor source for the gamescope capture path (remote-desktop-sweep Phase C).
|
||||||
|
//!
|
||||||
|
//! gamescope draws the pointer on a DRM hardware-cursor plane and its `paint_pipewire()`
|
||||||
|
//! deliberately excludes the cursor from the frame it feeds its built-in PipeWire node — so
|
||||||
|
//! `SPA_META_Cursor` never arrives and the ordinary [`cursor_live`](super::PortalCapturer) slot
|
||||||
|
//! stays empty (a KWin/GNOME session gets its cursor from that meta; gamescope can't embed one
|
||||||
|
//! either, its `set_hw_cursor` is inert). We instead read the pointer from gamescope's nested
|
||||||
|
//! Xwayland via X11 — the trick Sunshine uses — and publish a [`CursorOverlay`] into that same
|
||||||
|
//! slot, so the encoder blend composites the pointer into the video exactly like the portal path.
|
||||||
|
//!
|
||||||
|
//! **Multiple Xwaylands.** gamescope runs one Xwayland per `--xwayland-count` (Steam Gaming Mode
|
||||||
|
//! uses 2: one for Big Picture, one for the game). The pointer lives on whichever is FOCUSED — an
|
||||||
|
//! inactive display's pointer is frozen. So the source connects to ALL of them and each tick
|
||||||
|
//! follows the one whose pointer actually moved (gamescope routes input to the focused surface, so
|
||||||
|
//! exactly one moves at a time). It reads that display's shape too, since each Xwayland has its own
|
||||||
|
//! current cursor. This is why a single-display read froze the pointer the moment a game on the
|
||||||
|
//! OTHER Xwayland took focus.
|
||||||
|
//!
|
||||||
|
//! Two X sources per display, split by cost (Sunshine's split):
|
||||||
|
//! * **Position** — core `QueryPointer` on the root, polled fast. Cheap (a few-byte reply, no
|
||||||
|
//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. It also
|
||||||
|
//! doubles as the focus signal (the display whose pointer moves is the active one).
|
||||||
|
//! * **Shape / hotspot / visibility** — `XFixesGetCursorImage`, refreshed only after an XFixes
|
||||||
|
//! `CursorNotify` (a real cursor change). A game hiding the pointer IS a cursor change → the
|
||||||
|
//! image comes back fully transparent → `visible: false`, which the encode loop strips before
|
||||||
|
//! any blend path draws it (so a grabbed pointer shows nothing, matching native gamescope).
|
||||||
|
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pf_frame::CursorOverlay;
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
use x11rb::errors::ReplyError;
|
||||||
|
use x11rb::protocol::xfixes::{self, ConnectionExt as _, GetCursorImageReply};
|
||||||
|
use x11rb::protocol::xproto::{ConnectionExt as _, QueryPointerReply, Window};
|
||||||
|
use x11rb::rust_connection::RustConnection;
|
||||||
|
|
||||||
|
/// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only
|
||||||
|
/// ever contended if two gamescope sessions start at once — rare, and the swap is microseconds.
|
||||||
|
static XAUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// Position out-paces the stream fps (`POLL`); shape rides `CursorNotify` events drained each tick.
|
||||||
|
/// 4 ms ≈ 250 Hz matches the Windows GDI poller — the polled position IS the composited position
|
||||||
|
/// and must out-run a 240 fps session or the pointer stutters.
|
||||||
|
const POLL: Duration = Duration::from_millis(4);
|
||||||
|
|
||||||
|
/// A running XFixes cursor reader. Dropping it stops the worker thread and joins it, releasing the
|
||||||
|
/// X connections — so it lives exactly as long as the capturer that owns it.
|
||||||
|
pub(super) struct XFixesCursorSource {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
join: Option<std::thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XFixesCursorSource {
|
||||||
|
/// Connect to every gamescope nested Xwayland in `targets` (`(DISPLAY, XAUTHORITY)`) and start
|
||||||
|
/// publishing cursor overlays into `slot`, following the focused display's pointer. Returns
|
||||||
|
/// `None` — and logs — if NONE can be used (no X connection / no XFixes), so the caller
|
||||||
|
/// degrades to no gamescope cursor (today's behaviour) instead of failing the session.
|
||||||
|
pub(super) fn spawn(
|
||||||
|
targets: Vec<(String, Option<String>)>,
|
||||||
|
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||||
|
) -> Option<Self> {
|
||||||
|
// Connect on the caller's thread so failures degrade cleanly and the displays are validated
|
||||||
|
// before we commit a thread.
|
||||||
|
let mut displays = Vec::new();
|
||||||
|
for (dpy, xauth) in targets {
|
||||||
|
match connect(&dpy, xauth.as_deref()) {
|
||||||
|
Ok((conn, root)) => displays.push(XDisplay::new(dpy, conn, root)),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
dpy = %dpy,
|
||||||
|
error = %e,
|
||||||
|
"gamescope cursor: skipping a nested Xwayland we can't use"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if displays.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"gamescope cursor: no usable nested Xwayland — no in-video pointer this session \
|
||||||
|
(falls back to today's cursorless gamescope stream)"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect();
|
||||||
|
tracing::info!(
|
||||||
|
displays = ?names,
|
||||||
|
"gamescope cursor: XFixes source live — following the focused Xwayland's pointer"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_worker = Arc::clone(&stop);
|
||||||
|
let join = std::thread::Builder::new()
|
||||||
|
.name("pf-gs-cursor".into())
|
||||||
|
.spawn(move || run(displays, slot, stop_worker))
|
||||||
|
.ok()?;
|
||||||
|
Some(XFixesCursorSource {
|
||||||
|
stop,
|
||||||
|
join: Some(join),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for XFixesCursorSource {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(j) = self.join.take() {
|
||||||
|
let _ = j.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the X connection, negotiate XFixes, and select cursor-change events — returning the
|
||||||
|
/// connection + root window. `RustConnection` reads `XAUTHORITY` from the env at connect time only,
|
||||||
|
/// so set it under the lock (the host isn't a gamescope child), connect, then restore.
|
||||||
|
fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Window), String> {
|
||||||
|
let (conn, screen_num) = {
|
||||||
|
let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let prev = std::env::var_os("XAUTHORITY");
|
||||||
|
if let Some(x) = xauthority {
|
||||||
|
std::env::set_var("XAUTHORITY", x);
|
||||||
|
}
|
||||||
|
let out = RustConnection::connect(Some(dpy));
|
||||||
|
match (&prev, xauthority) {
|
||||||
|
(Some(p), _) => std::env::set_var("XAUTHORITY", p),
|
||||||
|
(None, Some(_)) => std::env::remove_var("XAUTHORITY"),
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
out.map_err(|e| format!("connect: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// XFixes ≥ 1 gives GetCursorImage / SelectCursorInput; ask for a modern minor, take what we get.
|
||||||
|
conn.xfixes_query_version(5, 0)
|
||||||
|
.map_err(ReplyError::from)
|
||||||
|
.and_then(|c| c.reply())
|
||||||
|
.map_err(|e| format!("XFixes unavailable: {e}"))?;
|
||||||
|
|
||||||
|
let root = conn
|
||||||
|
.setup()
|
||||||
|
.roots
|
||||||
|
.get(screen_num)
|
||||||
|
.ok_or_else(|| format!("no X screen {screen_num}"))?
|
||||||
|
.root;
|
||||||
|
|
||||||
|
// Wake the worker's event drain whenever the cursor shape changes (incl. hide/show).
|
||||||
|
conn.xfixes_select_cursor_input(root, xfixes::CursorNotifyMask::DISPLAY_CURSOR)
|
||||||
|
.map_err(ReplyError::from)
|
||||||
|
.and_then(|c| c.check())
|
||||||
|
.map_err(|e| format!("SelectCursorInput: {e}"))?;
|
||||||
|
let _ = conn.flush();
|
||||||
|
Ok((conn, root))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One gamescope Xwayland the source tracks.
|
||||||
|
struct XDisplay {
|
||||||
|
name: String,
|
||||||
|
conn: RustConnection,
|
||||||
|
root: Window,
|
||||||
|
/// Last polled pointer position — a change since the previous tick marks this display FOCUSED.
|
||||||
|
last_pos: Option<(i32, i32)>,
|
||||||
|
/// Cached cursor shape, refreshed only after this display's XFixes `CursorNotify`.
|
||||||
|
shape: Shape,
|
||||||
|
/// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active.
|
||||||
|
need_shape: bool,
|
||||||
|
/// The X connection died (game/Xwayland exited) — skip it.
|
||||||
|
dead: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XDisplay {
|
||||||
|
fn new(name: String, conn: RustConnection, root: Window) -> Self {
|
||||||
|
XDisplay {
|
||||||
|
name,
|
||||||
|
conn,
|
||||||
|
root,
|
||||||
|
last_pos: None,
|
||||||
|
shape: Shape::default(),
|
||||||
|
need_shape: true,
|
||||||
|
dead: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached cursor shape for one display.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Shape {
|
||||||
|
/// Straight-alpha RGBA (`w*h*4`, bytes R,G,B,A); empty before the first image arrives.
|
||||||
|
rgba: Arc<Vec<u8>>,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
hot_x: u32,
|
||||||
|
hot_y: u32,
|
||||||
|
/// XFixes' own per-display cursor serial — bumps on every shape change.
|
||||||
|
serial: u64,
|
||||||
|
/// A hidden pointer arrives as an all-transparent image; kept so a position-only tick preserves
|
||||||
|
/// the last known visibility.
|
||||||
|
visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
mut displays: Vec<XDisplay>,
|
||||||
|
slot: Arc<Mutex<Option<CursorOverlay>>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let mut active = 0usize;
|
||||||
|
// The overlay serial must bump whenever the DRAWN cursor changes — either the active display's
|
||||||
|
// shape OR which display is active (per-display XFixes serials aren't comparable across
|
||||||
|
// displays, so switching could reuse a number and the encoder would keep the old texture).
|
||||||
|
let mut out_serial = 0u64;
|
||||||
|
let mut last_key = (usize::MAX, u64::MAX);
|
||||||
|
let mut warned_image = false;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
// 1) Poll every display's pointer; note which moved since last tick (the focus signal).
|
||||||
|
let mut active_moved = false;
|
||||||
|
let mut other_moved: Option<usize> = None;
|
||||||
|
for (i, d) in displays.iter_mut().enumerate() {
|
||||||
|
if d.dead {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Drain pending events; the only ones selected are CursorNotify, so ANY event means
|
||||||
|
// "re-read this display's shape". poll_for_event never blocks.
|
||||||
|
loop {
|
||||||
|
match d.conn.poll_for_event() {
|
||||||
|
Ok(Some(_)) => d.need_shape = true,
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(_) => {
|
||||||
|
d.dead = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match fetch_pointer(&d.conn, d.root) {
|
||||||
|
Ok(p) if p.same_screen => {
|
||||||
|
let pos = (i32::from(p.root_x), i32::from(p.root_y));
|
||||||
|
let moved = d.last_pos.is_some_and(|lp| lp != pos);
|
||||||
|
d.last_pos = Some(pos);
|
||||||
|
if moved {
|
||||||
|
if i == active {
|
||||||
|
active_moved = true;
|
||||||
|
} else if other_moved.is_none() {
|
||||||
|
other_moved = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {} // pointer on another screen — keep the last position.
|
||||||
|
Err(_) => d.dead = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Switch focus: sticky to the active display while it moves (no flapping); otherwise
|
||||||
|
// follow another display that moved. If the active one died, fall to any live display.
|
||||||
|
if !active_moved {
|
||||||
|
if let Some(j) = other_moved {
|
||||||
|
active = j;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if displays.get(active).is_none_or(|d| d.dead) {
|
||||||
|
match displays.iter().position(|d| !d.dead) {
|
||||||
|
Some(k) => active = k,
|
||||||
|
None => {
|
||||||
|
std::thread::sleep(POLL); // all connections dead — idle until Drop.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fetch the active display's shape if a CursorNotify (or a focus switch) left it stale.
|
||||||
|
if displays[active].need_shape {
|
||||||
|
match fetch_cursor_image(&displays[active].conn) {
|
||||||
|
Ok(img) => {
|
||||||
|
update_shape(&mut displays[active].shape, &img);
|
||||||
|
displays[active].need_shape = false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if !warned_image {
|
||||||
|
warned_image = true;
|
||||||
|
tracing::warn!(error = %e, "gamescope cursor: GetCursorImage failed — retrying");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Publish the ACTIVE display's pointer + shape (or clear the slot when it has no cursor
|
||||||
|
// of its own — so a focus switch never leaves the other display's stale pointer showing).
|
||||||
|
let d = &displays[active];
|
||||||
|
let overlay = match (d.last_pos, d.shape.rgba.is_empty()) {
|
||||||
|
(Some((px, py)), false) => {
|
||||||
|
let key = (active, d.shape.serial);
|
||||||
|
if key != last_key {
|
||||||
|
out_serial += 1;
|
||||||
|
last_key = key;
|
||||||
|
}
|
||||||
|
Some(CursorOverlay {
|
||||||
|
// Top-left = pointer position − hotspot (the overlay contract).
|
||||||
|
x: px - d.shape.hot_x as i32,
|
||||||
|
y: py - d.shape.hot_y as i32,
|
||||||
|
w: d.shape.w,
|
||||||
|
h: d.shape.h,
|
||||||
|
rgba: Arc::clone(&d.shape.rgba),
|
||||||
|
serial: out_serial,
|
||||||
|
hot_x: d.shape.hot_x,
|
||||||
|
hot_y: d.shape.hot_y,
|
||||||
|
visible: d.shape.visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Ok(mut s) = slot.lock() {
|
||||||
|
*s = overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update `shape` from a fresh `GetCursorImage` reply. A hidden pointer (all-transparent) keeps the
|
||||||
|
/// last bitmap (instant re-show) but flips visibility; the serial still bumps so the change shows.
|
||||||
|
fn update_shape(shape: &mut Shape, img: &GetCursorImageReply) {
|
||||||
|
let visible =
|
||||||
|
img.width > 0 && img.height > 0 && img.cursor_image.iter().any(|&p| (p >> 24) & 0xff != 0);
|
||||||
|
if visible {
|
||||||
|
shape.rgba = Arc::new(argb_premul_to_straight_rgba(&img.cursor_image));
|
||||||
|
shape.w = u32::from(img.width);
|
||||||
|
shape.h = u32::from(img.height);
|
||||||
|
shape.hot_x = u32::from(img.xhot);
|
||||||
|
shape.hot_y = u32::from(img.yhot);
|
||||||
|
}
|
||||||
|
shape.visible = visible;
|
||||||
|
shape.serial = u64::from(img.cursor_serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One request+reply — x11rb splits errors (the request is `ConnectionError`, `reply()` is
|
||||||
|
/// `ReplyError` which is `From<ConnectionError>`), so `?` collapses both.
|
||||||
|
fn fetch_cursor_image(conn: &RustConnection) -> Result<GetCursorImageReply, ReplyError> {
|
||||||
|
Ok(conn.xfixes_get_cursor_image()?.reply()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_pointer(conn: &RustConnection, root: Window) -> Result<QueryPointerReply, ReplyError> {
|
||||||
|
Ok(conn.query_pointer(root)?.reply()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XFixes cursor pixels are packed `0xAARRGGBB` with **premultiplied** alpha (the Xrender / Xcursor
|
||||||
|
/// convention). The overlay + both blend paths want **straight** alpha RGBA (R,G,B,A bytes), like
|
||||||
|
/// the `SPA_META_Cursor` path — so un-premultiply here. (If on-glass shows over-bright fringes the
|
||||||
|
/// source wasn't premultiplied after all; drop the divide.)
|
||||||
|
fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(argb.len() * 4);
|
||||||
|
for &px in argb {
|
||||||
|
let a = (px >> 24) & 0xff;
|
||||||
|
let r = (px >> 16) & 0xff;
|
||||||
|
let g = (px >> 8) & 0xff;
|
||||||
|
let b = px & 0xff;
|
||||||
|
let (r, g, b) = match a {
|
||||||
|
0 => (0, 0, 0),
|
||||||
|
255 => (r, g, b),
|
||||||
|
a => (
|
||||||
|
((r * 255 + a / 2) / a).min(255),
|
||||||
|
((g * 255 + a / 2) / a).min(255),
|
||||||
|
((b * 255 + a / 2) / a).min(255),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
out.extend_from_slice(&[r as u8, g as u8, b as u8, a as u8]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -77,8 +77,9 @@ pub use routing::{
|
|||||||
};
|
};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub use routing::{
|
pub use routing::{
|
||||||
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
|
cancel_pending_tv_restore, dedicated_game_exited, gamescope_xwayland_cursor_targets,
|
||||||
launch_is_nested, steam_appid_from_launch, watch_steam_game_exit,
|
launch_into_gamescope_session, launch_is_nested, steam_appid_from_launch,
|
||||||
|
watch_steam_game_exit,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Compositors punktfunk knows how to drive (plan §6).
|
/// Compositors punktfunk knows how to drive (plan §6).
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ pub fn launch_into_session(cmd: &str) -> Result<std::process::Child> {
|
|||||||
let mut c = Command::new("sh");
|
let mut c = Command::new("sh");
|
||||||
c.arg("-c").arg(cmd);
|
c.arg("-c").arg(cmd);
|
||||||
match discover_session_display_env() {
|
match discover_session_display_env() {
|
||||||
Some((x11, wayland)) => {
|
Some((x11, wayland, _xauth)) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
command = %cmd,
|
command = %cmd,
|
||||||
x11_display = x11.as_deref().unwrap_or("-"),
|
x11_display = x11.as_deref().unwrap_or("-"),
|
||||||
@@ -533,11 +533,71 @@ pub fn launch_into_session(cmd: &str) -> Result<std::process::Child> {
|
|||||||
.context("spawn launch command into gamescope session")
|
.context("spawn launch command into gamescope session")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` by scanning same-uid processes
|
/// EVERY nested Xwayland the running gamescope session exposes, as `(DISPLAY, XAUTHORITY)` pairs
|
||||||
/// for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for everything
|
/// for the XFixes cursor source (remote-desktop-sweep Phase C). gamescope can run several
|
||||||
/// it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that gamescope
|
/// (`--xwayland-count N` — Steam Gaming Mode uses 2: one for Big Picture, one for the game), and
|
||||||
/// socket; `DISPLAY` is the nested Xwayland. Either can be individually absent.
|
/// the pointer lives on whichever is FOCUSED — so the source connects to all and follows the one
|
||||||
fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
|
/// whose pointer moves. The host is not a gamescope child, so gamescope's auth cookie rides along
|
||||||
|
/// when a process exposes it. Empty when no gamescope session is running / none exposes a `DISPLAY`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) fn xwayland_cursor_targets() -> Vec<(String, Option<String>)> {
|
||||||
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||||
|
let uid = unsafe { libc::getuid() };
|
||||||
|
let mut out: Vec<(String, Option<String>)> = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for e in entries.flatten() {
|
||||||
|
let name = e.file_name();
|
||||||
|
let Some(pid_str) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !pid_str.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(md) = std::fs::metadata(e.path()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if md.uid() != uid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(raw) = std::fs::read(e.path().join("environ")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (mut display, mut is_gamescope, mut xauth) = (None, false, None);
|
||||||
|
for kv in raw.split(|&b| b == 0) {
|
||||||
|
let kv = String::from_utf8_lossy(kv);
|
||||||
|
if kv.starts_with("GAMESCOPE_WAYLAND_DISPLAY=") {
|
||||||
|
is_gamescope = true;
|
||||||
|
} else if let Some(v) = kv.strip_prefix("DISPLAY=") {
|
||||||
|
if !v.is_empty() {
|
||||||
|
display = Some(v.to_string());
|
||||||
|
}
|
||||||
|
} else if let Some(v) = kv.strip_prefix("XAUTHORITY=") {
|
||||||
|
if !v.is_empty() {
|
||||||
|
xauth = Some(v.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (true, Some(d)) = (is_gamescope, display) {
|
||||||
|
// Distinct DISPLAY only; prefer the first non-empty XAUTHORITY seen for it.
|
||||||
|
match out.iter_mut().find(|(dd, _)| *dd == d) {
|
||||||
|
Some((_, xa)) if xa.is_none() => *xa = xauth,
|
||||||
|
Some(_) => {}
|
||||||
|
None => out.push((d, xauth)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY, XAUTHORITY)` by scanning same-uid
|
||||||
|
/// processes for one whose environment carries `GAMESCOPE_WAYLAND_DISPLAY` (gamescope sets it for
|
||||||
|
/// everything it runs — Steam, the game, our own nested `sh`). The Wayland value returned is that
|
||||||
|
/// gamescope socket; `DISPLAY` is the nested Xwayland; `XAUTHORITY` is its auth file (for X
|
||||||
|
/// clients that aren't gamescope children). Any one can be individually absent.
|
||||||
|
fn discover_session_display_env() -> Option<(Option<String>, Option<String>, Option<String>)> {
|
||||||
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
// SAFETY: `getuid()` is a parameterless POSIX call that always succeeds and touches no memory.
|
||||||
let uid = unsafe { libc::getuid() };
|
let uid = unsafe { libc::getuid() };
|
||||||
for e in std::fs::read_dir("/proc").ok()?.flatten() {
|
for e in std::fs::read_dir("/proc").ok()?.flatten() {
|
||||||
@@ -560,6 +620,7 @@ fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
|
|||||||
};
|
};
|
||||||
let mut display = None;
|
let mut display = None;
|
||||||
let mut gs_wayland = None;
|
let mut gs_wayland = None;
|
||||||
|
let mut xauth = None;
|
||||||
for kv in raw.split(|&b| b == 0) {
|
for kv in raw.split(|&b| b == 0) {
|
||||||
let kv = String::from_utf8_lossy(kv);
|
let kv = String::from_utf8_lossy(kv);
|
||||||
if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") {
|
if let Some(v) = kv.strip_prefix("GAMESCOPE_WAYLAND_DISPLAY=") {
|
||||||
@@ -570,11 +631,15 @@ fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
|
|||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
display = Some(v.to_string());
|
display = Some(v.to_string());
|
||||||
}
|
}
|
||||||
|
} else if let Some(v) = kv.strip_prefix("XAUTHORITY=") {
|
||||||
|
if !v.is_empty() {
|
||||||
|
xauth = Some(v.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only a process INSIDE a gamescope session (it has the marker var) is a valid source.
|
// Only a process INSIDE a gamescope session (it has the marker var) is a valid source.
|
||||||
if gs_wayland.is_some() {
|
if gs_wayland.is_some() {
|
||||||
return Some((display, gs_wayland));
|
return Some((display, gs_wayland, xauth));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
|||||||
gamescope::launch_into_session(cmd)
|
gamescope::launch_into_session(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every nested Xwayland `(DISPLAY, XAUTHORITY)` of the running gamescope session for the XFixes
|
||||||
|
/// cursor source (remote-desktop-sweep Phase C) — gamescope can run several, and the pointer is on
|
||||||
|
/// whichever is focused. Empty when no gamescope session is running / it exposes no Xwayland (the
|
||||||
|
/// host then leaves gamescope cursorless, today's behaviour).
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn gamescope_xwayland_cursor_targets() -> Vec<(String, Option<String>)> {
|
||||||
|
gamescope::xwayland_cursor_targets()
|
||||||
|
}
|
||||||
|
|
||||||
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
|
||||||
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
|
||||||
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
|
||||||
|
|||||||
@@ -1000,18 +1000,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
ctx.bit_depth,
|
ctx.bit_depth,
|
||||||
ctx.chroma,
|
ctx.chroma,
|
||||||
ctx.codec,
|
ctx.codec,
|
||||||
// Blend CAPABILITY only for cursor-FORWARD sessions (Phase B, the Windows gate
|
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
|
||||||
// mirrored): their client can flip to the capture mouse model mid-stream
|
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
|
||||||
// (`CursorRenderMode`), and the composite side of that flip must not need an encoder
|
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
|
||||||
// rebuild — WHETHER a frame's pointer is drawn stays per-tick (the encode loop strips
|
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
|
||||||
// `frame.cursor` while the client draws locally, see the forwarder tick). Every OTHER
|
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
|
||||||
// session's output is created with the pointer compositor-EMBEDDED
|
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||||
// (`vd.set_hw_cursor(false)` → no cursor metadata ever arrives, nothing to blend), so
|
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||||
// it keeps the zero-cost pre-channel path — and gamescope never has a pointer either
|
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||||
// way.
|
// the blend must be built for every gamescope session.
|
||||||
ctx.compositor != pf_vdisplay::Compositor::Gamescope && ctx.cursor_forward,
|
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
|
||||||
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
|
// 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.
|
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
||||||
if ctx.codec == crate::encode::Codec::PyroWave {
|
if ctx.codec == crate::encode::Codec::PyroWave {
|
||||||
@@ -1070,6 +1073,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
if cursor_forward {
|
if cursor_forward {
|
||||||
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
|
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 {
|
if streamed_wire {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
"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");
|
"capture loss: active session switched compositor — retargeting");
|
||||||
vd = v;
|
vd = v;
|
||||||
compositor = c;
|
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:#}"),
|
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
|
||||||
"capture loss: opening the newly-detected compositor failed — retrying"),
|
"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
|
// The overlay surfaces hidden pointers too (for the hint above) — strip them
|
||||||
// HERE, after forwarding, so no blend path ever draws an invisible cursor.
|
// HERE, after forwarding, so no blend path ever draws an invisible cursor.
|
||||||
@@ -2792,13 +2831,14 @@ pub(super) fn prepare_display(
|
|||||||
bit_depth,
|
bit_depth,
|
||||||
chroma,
|
chroma,
|
||||||
codec,
|
codec,
|
||||||
// Blend capability only for cursor-forward sessions — must MATCH virtual_stream's
|
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
|
||||||
// resolve (Phase B: non-channel sessions get the pointer compositor-EMBEDDED, nothing
|
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||||
// to blend; the mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per
|
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||||
// tick for channel sessions).
|
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||||
compositor != pf_vdisplay::Compositor::Gamescope && cursor_forward,
|
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
|
||||||
cursor_forward,
|
cursor_forward,
|
||||||
);
|
);
|
||||||
|
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
||||||
if codec == crate::encode::Codec::PyroWave {
|
if codec == crate::encode::Codec::PyroWave {
|
||||||
plan.wire_chunk = Some(shard_payload as usize);
|
plan.wire_chunk = Some(shard_payload as usize);
|
||||||
}
|
}
|
||||||
@@ -3056,6 +3096,21 @@ fn build_pipeline(
|
|||||||
let mut capturer =
|
let mut capturer =
|
||||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||||
.context("capture virtual output")?;
|
.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 {
|
if let Some(t) = trace {
|
||||||
t.mark("capture_attached");
|
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
|
/// 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).
|
/// hardware cursor up via [`OutputFormat::hw_cursor`](pf_frame::OutputFormat).
|
||||||
pub cursor_forward: bool,
|
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 {
|
impl SessionPlan {
|
||||||
@@ -135,6 +141,9 @@ impl SessionPlan {
|
|||||||
wire_chunk: None,
|
wire_chunk: None,
|
||||||
cursor_blend,
|
cursor_blend,
|
||||||
cursor_forward,
|
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,
|
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
// 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
|
// 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")]
|
#[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"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
nv12_native: false,
|
nv12_native: false,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user