A declared IddCx hardware cursor is IRREVOCABLE for its OS target's life (§8.6), and the sticky exclusion survives monitor REMOVE→ADD because each client gets a STABLE target id — so once any desktop-mode session declared, every later pure-capture session on that target streamed a cursor-less desktop: DWM excluded the pointer, no channel forwarded it, no blend drew it (the exact no-regression gap §8.6's per-session cap gate cannot see). Driver: track every successful SetupHardwareCursor per target (DECLARED_TARGETS — scoped to the WUDFHost's life, exactly the sticky state's scope) and report it in a new AddReply::cursor_excluded tail field (dual-size discipline, both skews degrade cleanly; no proto bump). Host: the flag rides AddedMonitor → Monitor → WinCaptureTarget; a session WITHOUT the cursor channel on a flagged target forces composite mode in the IDD-push capturer — GDI poller + blend for the session's life, pinned on (set_cursor_forward cannot clear it: with no client drawing, un-compositing would erase the pointer entirely). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
367 lines
15 KiB
Rust
367 lines
15 KiB
Rust
//! 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 the request `?` converts into the reply error.
|
||
fn fetch_cursor_image(conn: &RustConnection) -> Result<GetCursorImageReply, ReplyError> {
|
||
conn.xfixes_get_cursor_image()?.reply()
|
||
}
|
||
|
||
fn fetch_pointer(conn: &RustConnection, root: Window) -> Result<QueryPointerReply, ReplyError> {
|
||
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
|
||
}
|