diff --git a/crates/pf-capture/src/linux/xfixes_cursor.rs b/crates/pf-capture/src/linux/xfixes_cursor.rs index f1f1e1db..4ec2629d 100644 --- a/crates/pf-capture/src/linux/xfixes_cursor.rs +++ b/crates/pf-capture/src/linux/xfixes_cursor.rs @@ -10,20 +10,31 @@ //! //! **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. +//! inactive display's pointer is frozen. So the source connects to ALL of them and publishes from +//! the one gamescope is actually drawing the pointer on; 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): +//! Three X sources per display, split by cost (Sunshine's split, plus gamescope's own verdict): //! * **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). +//! bitmap), so it can out-pace the stream fps and keep the composited pointer smooth. +//! * **Shape / hotspot** — `XFixesGetCursorImage`, refreshed only after an XFixes `CursorNotify` +//! (a real cursor change). A fully-transparent image reads as hidden. +//! * **Visibility + focus** — [`GAMESCOPE_CURSOR_VISIBLE_FEEDBACK`](GS_CURSOR_FEEDBACK) on the +//! root, read at connect and re-read on its `PropertyNotify`. +//! +//! **Why the feedback atom and not pointer motion.** gamescope hides its pointer by WARPING the X +//! pointer to the root's bottom-right corner pixel — it does NOT swap in a transparent X cursor, so +//! `XFixesGetCursorImage` keeps handing back the last opaque arrow. The original "follow whichever +//! display's pointer moved" heuristic therefore stuck to the parked display (a parked pointer never +//! moves again) and composited that arrow at `(w-1, h-1)`: a sliver of cursor welded to the corner +//! of the stream for the rest of the session, while the real pointer went undrawn. Reported +//! on-glass as "part of cursor shows up on bottom right … isn't where it really is", in every game +//! (a game grabs the pointer, so the hide is permanent). Measured on a live 1920x1080 Gaming Mode +//! session: real motion ⇒ pointer live + feedback `1`; 3 s idle (`--hide-cursor-delay 3000`) ⇒ +//! pointer `(1919, 1079)` + feedback `0`. The atom answers BOTH questions correctly for a static +//! pointer, which motion cannot: which Xwayland owns it, and whether to draw it at all — so +//! honouring it also gives the stream gamescope's own idle auto-hide, which this source never had. use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -35,7 +46,11 @@ 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::protocol::xproto::{ + Atom, AtomEnum, ChangeWindowAttributesAux, ConnectionExt as _, EventMask, QueryPointerReply, + Window, +}; +use x11rb::protocol::Event; use x11rb::rust_connection::RustConnection; /// Serializes the brief `XAUTHORITY` env swap around a connect (the var is process-global). Only @@ -47,6 +62,17 @@ static XAUTH_LOCK: Mutex<()> = Mutex::new(()); /// and must out-run a 240 fps session or the pointer stutters. const POLL: Duration = Duration::from_millis(4); +/// gamescope's own pointer verdict, published on EVERY nested Xwayland's root: `1` on the server +/// whose pointer gamescope is currently drawing, `0` on the others — and `0` on all of them once +/// the pointer is hidden (a game grabbed it, or `--hide-cursor-delay` fired). See the module docs +/// for why this, and not pointer motion, is the signal this source follows. +const GS_CURSOR_FEEDBACK: &str = "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK"; + +/// Self-heal cadence for the feedback re-read: `PropertyNotify` drives it, this only covers a +/// missed/coalesced event (and a gamescope that publishes the atom after we connected). One +/// `GetProperty` per display per interval is nothing next to the 250 Hz pointer poll. +const FEEDBACK_RESYNC: Duration = Duration::from_millis(250); + /// 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 { @@ -68,7 +94,9 @@ impl XFixesCursorSource { 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)), + Ok((conn, root, feedback)) => { + displays.push(XDisplay::new(dpy, conn, root, feedback)) + } Err(e) => tracing::warn!( dpy = %dpy, error = %e, @@ -84,9 +112,13 @@ impl XFixesCursorSource { return None; } let names: Vec<&str> = displays.iter().map(|d| d.name.as_str()).collect(); + let feedback = displays.iter().any(|d| d.gs_visible.is_some()); tracing::info!( displays = ?names, - "gamescope cursor: XFixes source live — following the focused Xwayland's pointer" + cursor_feedback = feedback, + "gamescope cursor: XFixes source live — following the Xwayland gamescope draws the \ + pointer on (cursor_feedback=false ⇒ this gamescope publishes no \ + GAMESCOPE_CURSOR_VISIBLE_FEEDBACK, degrading to the pointer-motion heuristic)" ); let stop = Arc::new(AtomicBool::new(false)); @@ -111,10 +143,15 @@ impl Drop for XFixesCursorSource { } } -/// 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> { +/// Open the X connection, negotiate XFixes, and select cursor-change + root-property events — +/// returning the connection, root window and this display's initial +/// [`GAMESCOPE_CURSOR_FEEDBACK`](GS_CURSOR_FEEDBACK) reading (`(atom, value)`; the value is `None` +/// when gamescope publishes no such property here). `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. +type Connected = (RustConnection, Window, (Atom, Option)); + +fn connect(dpy: &str, xauthority: Option<&str>) -> Result { let (conn, screen_num) = { let _g = XAUTH_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let prev = std::env::var_os("XAUTHORITY"); @@ -148,8 +185,41 @@ fn connect(dpy: &str, xauthority: Option<&str>) -> Result<(RustConnection, Windo .map_err(ReplyError::from) .and_then(|c| c.check()) .map_err(|e| format!("SelectCursorInput: {e}"))?; + + // …and whenever gamescope republishes its cursor verdict. Interned with `only_if_exists=false` + // so we hold a matchable atom id even on a gamescope that sets the property later; a failure to + // select PROPERTY_CHANGE is NOT fatal — the resync re-read still tracks it, just at 250 ms. + let feedback_atom = conn + .intern_atom(false, GS_CURSOR_FEEDBACK.as_bytes()) + .map_err(ReplyError::from) + .and_then(|c| c.reply()) + .map(|r| r.atom) + .unwrap_or(0); + if let Ok(c) = conn.change_window_attributes( + root, + &ChangeWindowAttributesAux::new().event_mask(EventMask::PROPERTY_CHANGE), + ) { + let _ = c.check(); + } let _ = conn.flush(); - Ok((conn, root)) + let feedback = read_cursor_feedback(&conn, root, feedback_atom); + Ok((conn, root, (feedback_atom, feedback))) +} + +/// Read `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` off `root`. `None` = the property is absent or +/// unreadable (not a gamescope that publishes it) — the caller then falls back to the pointer-motion +/// heuristic rather than blanking the cursor, so an older gamescope keeps today's behaviour. +fn read_cursor_feedback(conn: &RustConnection, root: Window, atom: Atom) -> Option { + if atom == 0 { + return None; + } + let reply = conn + .get_property(false, root, atom, AtomEnum::CARDINAL, 0, 1) + .ok()? + .reply() + .ok()?; + let value = reply.value32()?.next()?; + Some(value != 0) } /// One gamescope Xwayland the source tracks. @@ -163,12 +233,22 @@ struct XDisplay { shape: Shape, /// A `CursorNotify` (or first read) is pending — fetch the shape when this display is active. need_shape: bool, + /// Interned [`GS_CURSOR_FEEDBACK`] atom (`0` = intern failed — treated as absent). + feedback_atom: Atom, + /// gamescope's verdict for THIS display: `Some(true)` = it is drawing the pointer here, + /// `Some(false)` = it is not, `None` = this gamescope publishes no verdict at all. + gs_visible: Option, /// The X connection died (game/Xwayland exited) — skip it. dead: bool, } impl XDisplay { - fn new(name: String, conn: RustConnection, root: Window) -> Self { + fn new( + name: String, + conn: RustConnection, + root: Window, + (feedback_atom, gs_visible): (Atom, Option), + ) -> Self { XDisplay { name, conn, @@ -176,9 +256,20 @@ impl XDisplay { last_pos: None, shape: Shape::default(), need_shape: true, + feedback_atom, + gs_visible, dead: false, } } + + /// Re-read gamescope's verdict, keeping a previously-seen one if the read fails (a transient + /// failure must not look like "this gamescope has no feedback" and re-arm the motion heuristic). + fn resync_feedback(&mut self) { + let fresh = read_cursor_feedback(&self.conn, self.root, self.feedback_atom); + if fresh.is_some() || self.gs_visible.is_none() { + self.gs_visible = fresh; + } + } } /// Cached cursor shape for one display. @@ -209,20 +300,35 @@ fn run( let mut out_serial = 0u64; let mut last_key = (usize::MAX, u64::MAX); let mut warned_image = false; + let mut last_resync = std::time::Instant::now(); while !stop.load(Ordering::Relaxed) { - // 1) Poll every display's pointer; note which moved since last tick (the focus signal). + // A missed/coalesced PropertyNotify would otherwise strand the verdict — re-read on a slow + // cadence so the source always converges (also picks the atom up if gamescope adds it late). + let resync = last_resync.elapsed() >= FEEDBACK_RESYNC; + if resync { + last_resync = std::time::Instant::now(); + } + + // 1) Poll every display's pointer; note which moved since last tick (the fallback focus + // signal, used only when this gamescope publishes no cursor verdict). let mut active_moved = false; let mut other_moved: Option = 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. + // Drain pending events. Two kinds are selected: XFixes CursorNotify (the shape + // changed) and root PropertyNotify (gamescope republished its cursor verdict, among + // the many other properties it keeps on the root — hence the atom match). + let mut need_feedback = resync; loop { match d.conn.poll_for_event() { - Ok(Some(_)) => d.need_shape = true, + Ok(Some(Event::XfixesCursorNotify(_))) => d.need_shape = true, + Ok(Some(Event::PropertyNotify(ev))) => { + need_feedback |= d.feedback_atom != 0 && ev.atom == d.feedback_atom; + } + Ok(Some(_)) => {} Ok(None) => break, Err(_) => { d.dead = true; @@ -230,6 +336,9 @@ fn run( } } } + if need_feedback && !d.dead { + d.resync_feedback(); + } 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)); @@ -248,13 +357,11 @@ fn run( } } - // 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; - } - } + // 2) Pick the display to publish from, and decide whether a pointer should be drawn at all. + let states: Vec<(bool, Option)> = + displays.iter().map(|d| (d.dead, d.gs_visible)).collect(); + let hidden_by_gamescope; + (active, hidden_by_gamescope) = pick_active(&states, active, active_moved, other_moved); if displays.get(active).is_none_or(|d| d.dead) { match displays.iter().position(|d| !d.dead) { Some(k) => active = k, @@ -283,7 +390,11 @@ fn run( // 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). + // A pointer gamescope is not drawing is published `visible: false`, NOT dropped: the + // encode loop overwrites the frame's overlay from this slot and strips invisible ones, so + // a `None` here would leave the last visible overlay standing on repeat frames. let d = &displays[active]; + let drawn = d.shape.visible && !hidden_by_gamescope; let overlay = match (d.last_pos, d.shape.rgba.is_empty()) { (Some((px, py)), false) => { let key = (active, d.shape.serial); @@ -301,7 +412,7 @@ fn run( serial: out_serial, hot_x: d.shape.hot_x, hot_y: d.shape.hot_y, - visible: d.shape.visible, + visible: drawn, }) } _ => None, @@ -314,6 +425,39 @@ fn run( } } +/// Which display to publish from, and whether gamescope is drawing NO pointer right now — +/// `(active, hidden)`. `states` is one `(dead, gs_visible)` per display, in `displays` order. +/// +/// PREFERRED: gamescope's own verdict. It is authoritative for a STATIC pointer, which is exactly +/// the case motion cannot read — a game grabs the pointer, gamescope parks it in the bottom-right +/// corner, and "follow whichever moved" then never switches again (see the module docs). +/// +/// FALLBACK, only when NO live display publishes the verdict: the original motion heuristic — +/// sticky to the active display while its pointer moves (no flapping), else follow another that +/// moved. `hidden` is never asserted on this path, so an older gamescope keeps today's behaviour. +fn pick_active( + states: &[(bool, Option)], + active: usize, + active_moved: bool, + other_moved: Option, +) -> (usize, bool) { + let live = |&(dead, _): &(bool, Option)| !dead; + if states.iter().filter(|s| live(s)).any(|(_, v)| v.is_some()) { + return match states.iter().position(|s| live(s) && s.1 == Some(true)) { + // gamescope is drawing the pointer here — publish from it. + Some(i) => (i, false), + // Drawing none of them (a game grabbed it, or the idle auto-hide fired). Keep `active` + // so the shape cache and last position stay warm for the re-show; the caller publishes + // the overlay `visible: false` instead of dropping it. + None => (active, true), + }; + } + match other_moved { + Some(j) if !active_moved => (j, false), + _ => (active, false), + } +} + /// 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) { @@ -364,3 +508,60 @@ fn argb_premul_to_straight_rgba(argb: &[u32]) -> Vec { } out } + +#[cfg(test)] +mod tests { + use super::pick_active; + + /// Steam Gaming Mode shape: display 0 = Big Picture's Xwayland, 1 = the game's. + const BPM: usize = 0; + const GAME: usize = 1; + + #[test] + fn follows_the_display_gamescope_draws_on() { + // Verdict beats motion: gamescope says the game's Xwayland owns the pointer, so we publish + // from it even though only BPM's (parked) pointer looks like it moved. + let states = [(false, Some(false)), (false, Some(true))]; + assert_eq!(pick_active(&states, BPM, false, Some(BPM)), (GAME, false)); + } + + /// THE REGRESSION: gamescope hides the pointer by warping it to the root's bottom-right corner + /// and leaves the opaque arrow as the X cursor. Nothing moves ever again, so the motion + /// heuristic stayed on the parked display and composited that arrow at (w-1, h-1) — a sliver of + /// cursor welded to the corner of the stream for the whole session, in every game. + #[test] + fn a_pointer_gamescope_draws_nowhere_is_hidden_not_parked() { + let states = [(false, Some(false)), (false, Some(false))]; + // `active` is kept (shape cache + last position stay warm for the re-show) but hidden. + assert_eq!(pick_active(&states, GAME, false, None), (GAME, true)); + } + + #[test] + fn re_show_returns_to_the_drawing_display() { + let states = [(false, Some(true)), (false, Some(false))]; + assert_eq!(pick_active(&states, GAME, false, None), (BPM, false)); + } + + #[test] + fn a_dead_displays_verdict_is_ignored() { + // The game's Xwayland exited mid-session with a stale `Some(true)`; BPM is the live one. + let states = [(false, Some(true)), (true, Some(true))]; + assert_eq!(pick_active(&states, GAME, false, None), (BPM, false)); + // …and a dead display's `Some` must not count as "this gamescope publishes a verdict", + // which would blank the cursor forever on a gamescope that publishes none. + let states = [(false, None), (true, Some(false))]; + assert_eq!(pick_active(&states, BPM, false, Some(GAME)), (GAME, false)); + } + + #[test] + fn no_verdict_falls_back_to_the_motion_heuristic() { + let none = [(false, None), (false, None)]; + // Sticky while the active display's own pointer moves… + assert_eq!(pick_active(&none, BPM, true, Some(GAME)), (BPM, false)); + // …otherwise follow the one that moved… + assert_eq!(pick_active(&none, BPM, false, Some(GAME)), (GAME, false)); + // …and never assert `hidden` on this path (that would regress an older gamescope to a + // cursorless stream). + assert_eq!(pick_active(&none, BPM, false, None), (BPM, false)); + } +}