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:
@@ -77,8 +77,9 @@ pub use routing::{
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use routing::{
|
||||
cancel_pending_tv_restore, dedicated_game_exited, launch_into_gamescope_session,
|
||||
launch_is_nested, steam_appid_from_launch, watch_steam_game_exit,
|
||||
cancel_pending_tv_restore, dedicated_game_exited, gamescope_xwayland_cursor_targets,
|
||||
launch_into_gamescope_session, launch_is_nested, steam_appid_from_launch,
|
||||
watch_steam_game_exit,
|
||||
};
|
||||
|
||||
/// 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");
|
||||
c.arg("-c").arg(cmd);
|
||||
match discover_session_display_env() {
|
||||
Some((x11, wayland)) => {
|
||||
Some((x11, wayland, _xauth)) => {
|
||||
tracing::info!(
|
||||
command = %cmd,
|
||||
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")
|
||||
}
|
||||
|
||||
/// Find the live gamescope session's `(DISPLAY, WAYLAND_DISPLAY)` 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. Either can be individually absent.
|
||||
fn discover_session_display_env() -> Option<(Option<String>, Option<String>)> {
|
||||
/// EVERY nested Xwayland the running gamescope session exposes, as `(DISPLAY, XAUTHORITY)` pairs
|
||||
/// for the XFixes cursor source (remote-desktop-sweep Phase C). gamescope can run several
|
||||
/// (`--xwayland-count N` — Steam Gaming Mode uses 2: one for Big Picture, one for the game), and
|
||||
/// the pointer lives on whichever is FOCUSED — so the source connects to all and follows the one
|
||||
/// 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.
|
||||
let uid = unsafe { libc::getuid() };
|
||||
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 gs_wayland = None;
|
||||
let mut xauth = None;
|
||||
for kv in raw.split(|&b| b == 0) {
|
||||
let kv = String::from_utf8_lossy(kv);
|
||||
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() {
|
||||
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.
|
||||
if gs_wayland.is_some() {
|
||||
return Some((display, gs_wayland));
|
||||
return Some((display, gs_wayland, xauth));
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -162,6 +162,15 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
|
||||
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
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user