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",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -2829,6 +2839,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5886,6 +5897,23 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "x509-parser"
|
||||
version = "0.16.0"
|
||||
|
||||
@@ -30,6 +30,12 @@ pipewire = "0.9"
|
||||
libc = "0.2"
|
||||
# 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"] }
|
||||
# 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]
|
||||
# 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.
|
||||
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> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
|
||||
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
||||
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::sync::atomic::{AtomicBool, Ordering};
|
||||
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
|
||||
/// portal source (its session ends with the portal thread's zbus connection).
|
||||
_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 {
|
||||
@@ -218,6 +228,7 @@ impl PwHandles {
|
||||
quit: Some(self.quit),
|
||||
join: Some(self.join),
|
||||
_keepalive: keepalive,
|
||||
_gs_cursor: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,9 +346,21 @@ impl Capturer for PortalCapturer {
|
||||
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
||||
// 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`.
|
||||
// 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())
|
||||
}
|
||||
|
||||
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> {
|
||||
self.frame_within(budget)
|
||||
}
|
||||
@@ -2503,11 +2526,18 @@ mod pipewire {
|
||||
// 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.
|
||||
update_cursor_meta(&mut ud.cursor, spa_buf);
|
||||
// Publish the LIVE overlay (frames or not): the encode loop's forwarder polls
|
||||
// it per tick, so pointer-only motion tracks on a static desktop — the
|
||||
// frame-attached overlay alone stales between damage frames.
|
||||
if let Ok(mut slot) = ud.cursor_live.lock() {
|
||||
*slot = ud.cursor.overlay();
|
||||
// Publish the LIVE overlay (frames or not) so the encode loop's forwarder
|
||||
// tracks pointer-only motion on a static desktop — the frame-attached overlay
|
||||
// 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() {
|
||||
*slot = Some(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// 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")]
|
||||
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
|
||||
|
||||
@@ -1000,18 +1000,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
ctx.bit_depth,
|
||||
ctx.chroma,
|
||||
ctx.codec,
|
||||
// Blend CAPABILITY only for cursor-FORWARD sessions (Phase B, the Windows gate
|
||||
// mirrored): their client can flip to the capture mouse model mid-stream
|
||||
// (`CursorRenderMode`), and the composite side of that flip must not need an encoder
|
||||
// rebuild — WHETHER a frame's pointer is drawn stays per-tick (the encode loop strips
|
||||
// `frame.cursor` while the client draws locally, see the forwarder tick). Every OTHER
|
||||
// session's output is created with the pointer compositor-EMBEDDED
|
||||
// (`vd.set_hw_cursor(false)` → no cursor metadata ever arrives, nothing to blend), so
|
||||
// it keeps the zero-cost pre-channel path — and gamescope never has a pointer either
|
||||
// way.
|
||||
ctx.compositor != pf_vdisplay::Compositor::Gamescope && ctx.cursor_forward,
|
||||
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
|
||||
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
|
||||
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
|
||||
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
|
||||
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
|
||||
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
|
||||
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
|
||||
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
|
||||
// the blend must be built for every gamescope session.
|
||||
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
|
||||
ctx.cursor_forward,
|
||||
);
|
||||
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
|
||||
// resolve so the flag is a pure function of the compositor.
|
||||
plan.gamescope_cursor = ctx.compositor == pf_vdisplay::Compositor::Gamescope;
|
||||
// PyroWave rides the datagram-aligned wire mode (§4.4): every encoder this session opens
|
||||
// packetizes at the negotiated shard payload, so a lost datagram costs blocks, not frames.
|
||||
if ctx.codec == crate::encode::Codec::PyroWave {
|
||||
@@ -1070,6 +1073,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
if cursor_forward {
|
||||
tracing::info!("cursor channel negotiated — forwarding shape/state, encoder blend off");
|
||||
}
|
||||
// gamescope (Phase C): no channel for a plain capture-mode client and no compositor-embedded
|
||||
// pointer, so the host ALWAYS composites the XFixes-sourced cursor into the video. Active only
|
||||
// when there's no cursor-forward channel (a future desktop-mode gamescope client takes the
|
||||
// `cursor_fwd` path instead). See `plan.gamescope_cursor`.
|
||||
// `mut`: a mid-stream Gaming↔Desktop switch (the capture-loss rebuild below) retargets the
|
||||
// compositor, so this is recomputed there against the live compositor.
|
||||
let mut gamescope_composite =
|
||||
compositor == pf_vdisplay::Compositor::Gamescope && cursor_fwd.is_none();
|
||||
if gamescope_composite {
|
||||
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
|
||||
}
|
||||
if streamed_wire {
|
||||
tracing::info!(
|
||||
"client accepts streamed AUs (VIDEO_CAP_STREAMED_AU) — chunked encoder output \
|
||||
@@ -1961,6 +1975,21 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
"capture loss: active session switched compositor — retargeting");
|
||||
vd = v;
|
||||
compositor = c;
|
||||
// remote-desktop-sweep Phase C: the cursor pipeline was
|
||||
// resolved for the OLD compositor (e.g. a Desktop session
|
||||
// that then launched a game). Re-gate against the LIVE one,
|
||||
// mirroring SessionPlan::resolve: a switch TO gamescope must
|
||||
// build the encoder blend + attach the XFixes source on the
|
||||
// rebuild below (gamescope can't embed a pointer or carry a
|
||||
// capture-mode channel); a switch AWAY restores the prior
|
||||
// gating. `plan` is `Copy` — this is the value the rebuild
|
||||
// (and its `build_pipeline` attach) reads.
|
||||
plan.cursor_blend = plan.cursor_forward
|
||||
|| c == crate::vdisplay::Compositor::Gamescope;
|
||||
plan.gamescope_cursor =
|
||||
c == crate::vdisplay::Compositor::Gamescope;
|
||||
gamescope_composite =
|
||||
plan.gamescope_cursor && cursor_fwd.is_none();
|
||||
}
|
||||
Err(e2) => tracing::warn!(error = %format!("{e2:#}"),
|
||||
"capture loss: opening the newly-detected compositor failed — retrying"),
|
||||
@@ -2110,6 +2139,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if gamescope_composite {
|
||||
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
|
||||
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
|
||||
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
|
||||
// freezing at the last damage frame (the same reason the composite arm above re-reads
|
||||
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(live) = capturer.cursor() {
|
||||
frame.cursor = Some(live);
|
||||
}
|
||||
}
|
||||
// The overlay surfaces hidden pointers too (for the hint above) — strip them
|
||||
// HERE, after forwarding, so no blend path ever draws an invisible cursor.
|
||||
@@ -2792,13 +2831,14 @@ pub(super) fn prepare_display(
|
||||
bit_depth,
|
||||
chroma,
|
||||
codec,
|
||||
// Blend capability only for cursor-forward sessions — must MATCH virtual_stream's
|
||||
// resolve (Phase B: non-channel sessions get the pointer compositor-EMBEDDED, nothing
|
||||
// to blend; the mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per
|
||||
// tick for channel sessions).
|
||||
compositor != pf_vdisplay::Compositor::Gamescope && cursor_forward,
|
||||
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
|
||||
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
|
||||
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
|
||||
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
|
||||
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
|
||||
cursor_forward,
|
||||
);
|
||||
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;
|
||||
if codec == crate::encode::Codec::PyroWave {
|
||||
plan.wire_chunk = Some(shard_payload as usize);
|
||||
}
|
||||
@@ -3056,6 +3096,21 @@ fn build_pipeline(
|
||||
let mut capturer =
|
||||
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
|
||||
.context("capture virtual output")?;
|
||||
// gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer gamescope's
|
||||
// nested Xwayland — it reads the pointer over X11 (XFixes shape + QueryPointer position) and
|
||||
// feeds `cursor()`, which the encode loop composites. A failed discovery/connect leaves the
|
||||
// stream cursorless (today's behaviour); non-gamescope plans skip this entirely.
|
||||
#[cfg(target_os = "linux")]
|
||||
if plan.gamescope_cursor {
|
||||
let targets = pf_vdisplay::gamescope_xwayland_cursor_targets();
|
||||
if targets.is_empty() {
|
||||
tracing::warn!(
|
||||
"gamescope cursor: no nested Xwayland discovered — no in-video pointer this session"
|
||||
);
|
||||
} else {
|
||||
capturer.attach_gamescope_cursor(targets);
|
||||
}
|
||||
}
|
||||
if let Some(t) = trace {
|
||||
t.mark("capture_attached");
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ pub struct SessionPlan {
|
||||
/// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's
|
||||
/// hardware cursor up via [`OutputFormat::hw_cursor`](pf_frame::OutputFormat).
|
||||
pub cursor_forward: bool,
|
||||
/// This is a gamescope session and its cursor comes from the XFixes source, NOT the
|
||||
/// (absent) `SPA_META_Cursor` (remote-desktop-sweep Phase C). Distinct from `cursor_forward`:
|
||||
/// gamescope can't embed the pointer OR carry the channel for a plain capture-mode client, so
|
||||
/// the host ALWAYS composites the XFixes-sourced cursor into the video (`cursor_blend` is set
|
||||
/// too). `build_pipeline` reads this to attach the XFixes reader to the capturer.
|
||||
pub gamescope_cursor: bool,
|
||||
}
|
||||
|
||||
impl SessionPlan {
|
||||
@@ -135,6 +141,9 @@ impl SessionPlan {
|
||||
wire_chunk: None,
|
||||
cursor_blend,
|
||||
cursor_forward,
|
||||
// Set by the resolve callers (they know the compositor); default off keeps every
|
||||
// non-gamescope plan unchanged.
|
||||
gamescope_cursor: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +197,14 @@ impl SessionPlan {
|
||||
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||
// into encode (the same one-way edge as `gpu` above).
|
||||
// into encode (the same one-way edge as `gpu` above). BUT the native-NV12 encode path
|
||||
// has no CSC stage to fold the cursor into (it assumes gamescope embeds its pointer,
|
||||
// which it does NOT into the PipeWire node) — so a gamescope-cursor session (Phase C)
|
||||
// must capture RGB instead, routing to the compute-CSC / VkSlotBlend blend that draws
|
||||
// `frame.cursor`. Costs the RGB→NV12 CSC we'd otherwise skip; the native-NV12 cursor
|
||||
// blend is the perf-preserving follow-up.
|
||||
#[cfg(target_os = "linux")]
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor,
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
nv12_native: false,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user