diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 3fc198d4..9bcf2ee7 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -447,15 +447,25 @@ pub mod synthetic_nv12; /// `want_hdr` offers the GNOME 50+ HDR formats (10-bit PQ/BT.2020 dmabufs) instead of the SDR /// set — pass it only when the mirrored monitor is actually in HDR mode (the host probes /// DisplayConfig) or the negotiation runs into its 10 s timeout and latches the SDR downgrade. -/// The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts (the one-way edge). +/// `want_metadata_cursor` asks for cursor-as-metadata (`SPA_META_Cursor`) — pass it only when +/// the session's encode path composites `CapturedFrame::cursor` (the host consults +/// `pf-encode`'s `cursor_blend_capable`); otherwise the portal EMBEDS the pointer so it is +/// never silently lost. The [`ZeroCopyPolicy`] carries the pre-resolved encode-backend facts +/// (the one-way edge). #[cfg(target_os = "linux")] pub fn open_portal_monitor( anchored: bool, want_hdr: bool, + want_metadata_cursor: bool, policy: ZeroCopyPolicy, ) -> Result> { - linux::PortalCapturer::open(anchored, want_hdr && !hdr_capture_failed(), policy) - .map(|c| Box::new(c) as Box) + linux::PortalCapturer::open( + anchored, + want_hdr && !hdr_capture_failed(), + want_metadata_cursor, + policy, + ) + .map(|c| Box::new(c) as Box) } /// Open the Linux portal capturer bound to an already-created virtual output's PipeWire node. The diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 8244c710..3dceded1 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -105,16 +105,21 @@ impl PortalCapturer { /// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain /// ScreenCast session (wlroots, which has no RemoteDesktop portal). `want_hdr` offers the /// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set. - pub fn open(anchored: bool, want_hdr: bool, policy: ZeroCopyPolicy) -> Result { + pub fn open( + anchored: bool, + want_hdr: bool, + want_metadata_cursor: bool, + policy: ZeroCopyPolicy, + ) -> Result { // Portal handshake (async) on its own thread; hands back the PW fd + node id. let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); thread::Builder::new() .name("punktfunk-portal".into()) .spawn(move || { if anchored { - portal_thread_remote_desktop(setup_tx) + portal_thread_remote_desktop(setup_tx, want_metadata_cursor) } else { - portal_thread(setup_tx) + portal_thread(setup_tx, want_metadata_cursor) } }) .context("spawn portal thread")?; @@ -647,19 +652,23 @@ pub fn gnome_hdr_monitor_active() -> bool { } } -/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`), -/// preferring **cursor-as-metadata**: the compositor keeps its cheap hardware cursor plane and -/// ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + an occasional bitmap), -/// which the consumer composites itself. That avoids forcing the producer to burn the cursor into -/// every frame — the `Embedded` mode — which on gamescope would defeat its HW cursor plane. Falls -/// back to `Embedded`, then `Hidden`, and (if the property query fails, e.g. an older portal) -/// keeps the prior `Embedded` behavior so the cursor is never silently lost. +/// Pick the ScreenCast cursor mode from what the backend advertises (`AvailableCursorModes`). +/// With `want_metadata` the ladder prefers **cursor-as-metadata**: the compositor keeps its cheap +/// hardware cursor plane and ships the pointer as PipeWire `SPA_META_Cursor` metadata (position + +/// an occasional bitmap), which the consumer composites itself — avoiding the producer burning the +/// cursor into every frame (`Embedded`), which on gamescope would defeat its HW cursor plane. +/// Without it — the session's encode path has no compositing stage for a metadata cursor +/// (`pf-encode`'s `cursor_blend_capable` said the resolved backend can't blend) — the ladder +/// prefers `Embedded`, so the pointer is in the pixels instead of in metadata nothing would draw. +/// Both ladders fall through to the other mode, then `Hidden`; a failed property query (an older +/// portal) keeps the prior `Embedded` behavior so the cursor is never silently lost. async fn choose_cursor_mode( proxy: &ashpd::desktop::screencast::Screencast, + want_metadata: bool, ) -> ashpd::desktop::screencast::CursorMode { use ashpd::desktop::screencast::CursorMode; match proxy.available_cursor_modes().await { - Ok(avail) if avail.contains(CursorMode::Metadata) => { + Ok(avail) if want_metadata && avail.contains(CursorMode::Metadata) => { tracing::info!( ?avail, "ScreenCast: requesting cursor-as-metadata (SPA_META_Cursor)" @@ -667,12 +676,30 @@ async fn choose_cursor_mode( CursorMode::Metadata } Ok(avail) if avail.contains(CursorMode::Embedded) => { - tracing::info!( - ?avail, - "ScreenCast: cursor metadata unavailable — requesting Embedded cursor" - ); + if want_metadata { + tracing::info!( + ?avail, + "ScreenCast: cursor metadata unavailable — requesting Embedded cursor" + ); + } else { + tracing::info!( + ?avail, + "ScreenCast: requesting Embedded cursor (this session's encoder does not \ + composite a metadata cursor)" + ); + } CursorMode::Embedded } + Ok(avail) if avail.contains(CursorMode::Metadata) => { + // Embedded wanted but not offered. Metadata still beats Hidden: the CPU capture + // path composites `SPA_META_Cursor` inline, so part of the matrix keeps a pointer. + tracing::warn!( + ?avail, + "ScreenCast: Embedded cursor not advertised — requesting cursor-as-metadata \ + (only CPU-path frames will composite it)" + ); + CursorMode::Metadata + } Ok(avail) => { tracing::warn!( ?avail, @@ -692,7 +719,10 @@ async fn choose_cursor_mode( /// The portal handshake: connect ScreenCast, select a single monitor, start, open the /// PipeWire remote, hand the fd + node id back, then keep the session alive. -fn portal_thread(setup_tx: std::sync::mpsc::Sender>) { +fn portal_thread( + setup_tx: std::sync::mpsc::Sender>, + want_metadata_cursor: bool, +) { use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType}; use ashpd::desktop::PersistMode; use ashpd::enumflags2::BitFlags; @@ -722,7 +752,7 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender>) { +fn portal_thread_remote_desktop( + setup_tx: std::sync::mpsc::Sender>, + want_metadata_cursor: bool, +) { use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions}; use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType}; use ashpd::desktop::PersistMode; @@ -826,7 +859,7 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender u32 { /// (design/vulkan-rgb-direct-encode.md): the captured RGB frame is the encode source and the /// VCN EFC front-end does the 709-narrow CSC inline — no compute CSC, no plane copies, one /// queue submit per frame (unaligned modes go through the padded-copy staging blit). B2 -/// default: ON wherever the probe passes, EXCEPT sessions that may need the CSC's cursor -/// blend (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it even on -/// cursor-blend sessions (the pointer will be missing from the stream); unset = the default. +/// default: ON wherever the probe passes, EXCEPT sessions that need the CSC's cursor blend +/// (see [`VulkanVideoEncoder::open`]). `=0` disables outright; `=1` forces it on non-cursor +/// sessions; unset = the default. A cursor-blend session IGNORES `=1` — EFC cannot composite +/// the pointer, and the caps-aware negotiation promised the client a composited one, so the +/// pointer outranks the lab pin (the open logs the override). /// /// Parses like every sibling knob (`matches!(v.trim(), …)`): anything unrecognised — including /// an empty value and a value that is only whitespace — falls back to the default rather than @@ -614,10 +616,6 @@ pub struct VulkanVideoEncoder { /// GPU reset (those paths keep their aligned-size sources/staging). native_nv12: bool, - /// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session - /// (neither has a compositing stage — the cursor will be missing from the stream until the - /// CSC path is used). - warned_cursor: bool, /// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video /// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it /// (mid-stream) or folds it into the first frame's RESET+RC install, then promotes it into @@ -671,7 +669,16 @@ impl VulkanVideoEncoder { cursor_blend: bool, ) -> Result { let native_nv12 = format == PixelFormat::Nv12; - let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend); + // A cursor-blend session must keep the compute-CSC path — the only arm with the cursor + // blend — so it outranks an explicit RGB-direct pin (EFC cannot composite; the + // negotiation promised the client a composited pointer). + if cursor_blend && rgb_request() == Some(true) { + tracing::info!( + "PUNKTFUNK_VULKAN_RGB_DIRECT=1 ignored for this session — it composites the \ + pointer, which the EFC front-end cannot; using the compute-CSC path" + ); + } + let want_rgb = !native_nv12 && !cursor_blend && rgb_request().unwrap_or(true); Self::open_opts_inner( codec, width, @@ -1448,7 +1455,6 @@ impl VulkanVideoEncoder { cpu_expand: Vec::new(), rgb: rgb_cfg, native_nv12, - warned_cursor: false, pending_bitrate: None, width: w, height: h, @@ -2621,17 +2627,10 @@ impl VulkanVideoEncoder { d.modifier ); } - // No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer - // in the produced pixels, but any other NV12 producer's metadata cursor would be lost — - // say so once instead of silently. - if frame.cursor.is_some() && !self.warned_cursor { - self.warned_cursor = true; - tracing::warn!( - "cursor bitmap on a native-NV12 session — nothing composites it; the cursor \ - will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \ - metadata-cursor captures)" - ); - } + // No compositing stage exists here (like RGB-direct/EFC) — and none is needed: the + // session plan negotiates native NV12 only for a non-cursor-blend session + // (`SessionPlan::output_format` gates `nv12_native` on `!cursor_blend`), so no cursor + // bitmap ever reaches this arm. Gamescope embeds its pointer in the produced pixels. let dev = self.device.clone(); let cmd = self.frames[slot].cmd; let fence = self.frames[slot].fence; @@ -2691,16 +2690,9 @@ impl VulkanVideoEncoder { let query_pool = self.frames[slot].query_pool; let bs_buf = self.frames[slot].bs_buf; let ts_pool = self.frames[slot].ts_pool; - // EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so - // once instead of silently losing the pointer (gamescope, the flagship, embeds it). - if frame.cursor.is_some() && !self.warned_cursor { - self.warned_cursor = true; - tracing::warn!( - "cursor bitmap on an RGB-direct session — EFC cannot composite it; the cursor \ - will be missing from the stream (unset PUNKTFUNK_VULKAN_RGB_DIRECT for \ - metadata-cursor captures)" - ); - } + // EFC cannot composite a cursor bitmap — and never has to: `open` refuses the RGB-direct + // shape for a cursor-blend session (the pin override above), so no cursor bitmap ever + // reaches this arm. Gamescope, the flagship, embeds its pointer in the produced pixels. let padded = self.rgb.as_ref().is_some_and(|r| r.padded); // Only the padded Dmabuf arm below records timestamps (via `record_pad_blit`); the // CPU-upload arm records its own command buffer and writes none. Default to "not written" diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index c053dcb4..a8e41c4e 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -203,14 +203,15 @@ pub fn open_video( } }; // The session asked for a composited pointer; say so loudly if the backend that actually opened - // cannot deliver one. `cursor_blend` was a REQUEST with no answer for most of this crate's life - // (`let _ = cursor_blend;` below), and the result was a stream with no mouse cursor and nothing - // in the logs — confirmed on the VAAPI dmabuf path and the libav-NVENC CUDA path. - // - // A warning is deliberately all this does. `open_video` cannot re-plan capture, so refusing here - // would only trade a missing pointer for a dead session; the host owns `plan.cursor_blend` and is - // the only layer that can fall back to capturer-side compositing. This makes the condition - // visible and queryable (`EncoderCaps::blends_cursor`) so that decision can be made upstream. + // cannot deliver one. Since the negotiation became caps-aware ([`cursor_blend_capable`] gates + // the cursor channel, and the session plan keeps cursor sessions off the native-NV12/RGB-direct + // shapes), no PLANNED path reaches this: capture negotiates embedded-cursor mode wherever the + // resolved backend can't blend. What remains reachable is the open-time divergence the plan + // cannot see — a Vulkan Video open failing back to VAAPI mid-`open_amd_intel`, and the + // gamescope residual (gamescope has no embedded mode, so a never-blending backend there — + // H.264→VAAPI, software — still streams cursorless). This is the backstop that keeps those + // honest in the logs; `open_video` cannot re-plan capture, so a warning is deliberately all + // it does. if cursor_blend && !inner.caps().blends_cursor { tracing::warn!( backend, @@ -989,6 +990,87 @@ pub fn linux_native_nv12_ok(codec: Codec) -> bool { } } +/// Whether the encode backend this session will resolve to composites [`CapturedFrame::cursor`] +/// ([`EncoderCaps::blends_cursor`]) — answered BEFORE capture opens, so the host plans cursor +/// delivery honestly instead of discovering a cursorless stream after the fact (the +/// `blends_cursor` audit finding): a blend-capable backend takes cursor-as-metadata capture +/// (pointer-free frames + host composite on demand — the cursor channel's contract); for +/// anything else the host must have the compositor EMBED the pointer. The sibling verdict of +/// [`linux_native_nv12_ok`], threaded into the same negotiation. +/// +/// `cuda_planned` is the caller's prediction of a CUDA capture payload (NVIDIA + zero-copy — the +/// prediction `SessionPlan` already makes); `ten_bit` the negotiated depth. Both shift the +/// dispatch: a CPU payload keeps NVIDIA on libav NVENC (no blend), and a 10-bit HDR session +/// skips Vulkan Video for libav VAAPI's P010/Main10 wiring (no blend). +#[cfg(target_os = "linux")] +pub fn cursor_blend_capable(codec: Codec, cuda_planned: bool, ten_bit: bool) -> bool { + // A negotiated PyroWave session routes to that backend before the pref is consulted + // (`open_video_backend_linux`), and its wavelet CSC composites the metadata cursor. + if codec == Codec::PyroWave { + return true; + } + let direct_nvenc = { + #[cfg(feature = "nvenc")] + { + nvenc_direct_enabled() + } + #[cfg(not(feature = "nvenc"))] + { + false + } + }; + let vulkan_csc = { + // The compute-CSC arm — the one that blends. Eligibility mirrors `open_amd_intel`; + // the device probe runs last (it opens a Vulkan instance, cached per GPU+codec). + #[cfg(feature = "vulkan-encode")] + { + matches!(codec, Codec::H265 | Codec::Av1) + && vulkan_encode_enabled() + && vulkan_encode_available(codec) + } + #[cfg(not(feature = "vulkan-encode"))] + { + false + } + }; + let backend = resolve_linux_backend( + pf_host_config::config().encoder_pref.as_str(), + linux_auto_is_vaapi, + cuda_planned, + ); + cursor_blend_capable_for(backend, cuda_planned, ten_bit, direct_nvenc, vulkan_csc) +} + +/// The dispatch-mirroring core of [`cursor_blend_capable`], device-free for the unit tests. +/// `direct_nvenc` = the direct-SDK NVENC path is compiled in and enabled; `vulkan_csc` = the +/// Vulkan Video compute-CSC arm (the one that blends) is compiled in, enabled, and +/// device-supported for the session's codec. +#[cfg(target_os = "linux")] +fn cursor_blend_capable_for( + backend: Option, + cuda_planned: bool, + ten_bit: bool, + direct_nvenc: bool, + vulkan_csc: bool, +) -> bool { + match backend { + // The wavelet CSC composites the metadata cursor (`linux/pyrowave.rs`). + Some(LinuxBackend::Pyrowave) => true, + // Only the direct-SDK arm blends (VkSlotBlend), and it only takes CUDA payloads — + // a CPU-payload session stays on libav NVENC, which cannot blend. + Some(LinuxBackend::Nvenc) => cuda_planned && direct_nvenc, + // The Vulkan Video compute-CSC path blends; a 10-bit HDR session skips it for libav + // VAAPI (no blend). The session plan keeps a cursor-blend session off the native-NV12 + // and RGB-direct shapes (`SessionPlan::output_format` / `VulkanVideoEncoder::open`), + // so CSC eligibility IS the answer. + Some(LinuxBackend::AmdIntel) | Some(LinuxBackend::Vulkan) => !ten_bit && vulkan_csc, + // CPU frames: the capturer composites the metadata cursor inline before the encoder + // runs, but the ENCODER blends nothing — the cursor channel's on-demand composite + // contract can't be honored. Report the encoder's truth. + Some(LinuxBackend::Software) | None => false, + } +} + /// Can this GPU + driver actually open a Vulkan Video **encode** session for `codec`? Cached per /// (selected GPU, codec) — the [`can_encode_10bit`] idiom, with the probe run outside the lock. /// @@ -1754,6 +1836,72 @@ mod tests { assert_eq!(none.wire_mask(), None); } + /// The cursor-blend capability mirror, arm by arm — the table the caps-aware negotiation + /// (cursor channel grant, metadata-vs-embedded capture) stands on. Each row names the arm's + /// blending stage or the reason there is none. + #[cfg(target_os = "linux")] + #[test] + fn cursor_blend_capability_mirrors_the_dispatch() { + use LinuxBackend::*; + // PyroWave: the wavelet CSC composites, always. + assert!(cursor_blend_capable_for( + Some(Pyrowave), + false, + false, + false, + false + )); + // NVIDIA: only the direct-SDK arm blends (VkSlotBlend), and only for CUDA payloads. + assert!(cursor_blend_capable_for( + Some(Nvenc), + true, + false, + true, + false + )); + assert!( + !cursor_blend_capable_for(Some(Nvenc), false, false, true, false), + "a CPU payload stays on libav NVENC, which cannot blend" + ); + assert!( + !cursor_blend_capable_for(Some(Nvenc), true, false, false, false), + "PUNKTFUNK_NVENC_DIRECT=0 (or a build without the feature) is the libav path" + ); + // AMD/Intel: the Vulkan Video compute-CSC arm blends; VAAPI never does. + assert!(cursor_blend_capable_for( + Some(AmdIntel), + false, + false, + false, + true + )); + assert!( + !cursor_blend_capable_for(Some(AmdIntel), false, false, false, false), + "no eligible Vulkan CSC arm (H.264, PUNKTFUNK_VULKAN_ENCODE=0, unsupported \ + device) resolves to libav VAAPI, which cannot blend" + ); + assert!( + !cursor_blend_capable_for(Some(AmdIntel), false, true, false, true), + "a 10-bit HDR session skips Vulkan Video for VAAPI's P010 wiring — no blend" + ); + assert!(cursor_blend_capable_for( + Some(Vulkan), + false, + false, + false, + true + )); + // Software / unknown pref: CPU frames; the encoder blends nothing. + assert!(!cursor_blend_capable_for( + Some(Software), + false, + false, + true, + true + )); + assert!(!cursor_blend_capable_for(None, false, false, true, true)); + } + /// WP7.7 guard (the cheap half): every `Encoder` trait method must be explicitly forwarded by /// `TrackedEncoder`. A defaulted trait method that isn't forwarded silently no-ops through the /// wrapper — the trap has bitten three times (`set_wire_chunking`'s §4.4 chunking probe, diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 11f884f2..740ea6d1 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -66,8 +66,14 @@ fn zero_copy_policy( /// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr` /// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR /// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]). +/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode +/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the +/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless. #[cfg(target_os = "linux")] -pub fn open_portal_monitor(want_hdr: bool) -> Result> { +pub fn open_portal_monitor( + want_hdr: bool, + want_metadata_cursor: bool, +) -> Result> { // On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop // session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal, // so use a plain ScreenCast session there. @@ -76,11 +82,19 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result> { // passthrough is virtual-output-only; the global encoder-pref lever still applies inside. // Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop // compositors it mirrors (GNOME/KWin) don't produce NV12 anyway. - pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false)) + pf_capture::open_portal_monitor( + anchored, + want_hdr, + want_metadata_cursor, + zero_copy_policy(false, false), + ) } #[cfg(not(target_os = "linux"))] -pub fn open_portal_monitor(_want_hdr: bool) -> Result> { +pub fn open_portal_monitor( + _want_hdr: bool, + _want_metadata_cursor: bool, +) -> Result> { anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)") } diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 5f69a743..93276fb3 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -33,10 +33,14 @@ pub struct StreamConfig { pub hdr: bool, } +/// A pooled capturer plus the two PipeWire-negotiation-time properties reuse must match on — +/// its HDR-ness and its metadata-cursor mode; a mismatch on either needs a fresh screencast +/// session (see `AppState::video_cap`). +pub type PooledCapturer = (Box, bool, bool); + /// Slot for the persistent screen capturer, shared with the control plane and reused across -/// streams so a reconnect doesn't open a second (conflicting) screencast session. The `bool` is -/// the pooled capturer's HDR-ness (see `AppState::video_cap`). -pub type CapturerSlot = Arc, bool)>>>; +/// streams so a reconnect doesn't open a second (conflicting) screencast session. +pub type CapturerSlot = Arc>>; /// A pending client reference-frame-invalidation range (lost `firstFrame..=lastFrame`), set by the /// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)). @@ -136,7 +140,7 @@ fn run( running: &Arc, force_idr: &AtomicBool, rfi_range: &std::sync::Mutex>, - video_cap: &std::sync::Mutex, bool)>>, + video_cap: &std::sync::Mutex>, // Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the // encode loop); per-frame sample emission is wired by a later pass. stats: &Arc, @@ -250,6 +254,12 @@ fn run( return stream_body( &mut capturer, Some(&rebuild), + // The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is + // never called → the compositor EMBEDS the pointer where it can), so the encoder + // is handed nothing to composite. gamescope remains the pointerless residual — + // its capture carries no cursor either way (the native plane's XFixes source is + // not wired on this plane). + false, &sock, cfg, running, @@ -266,13 +276,34 @@ fn run( // pooled capturer's HDR-ness matching this stream's negotiated `cfg.hdr` — the depth is a // PipeWire-negotiation-time property of the screencast session, so an HDR↔SDR change needs a // fresh session (same pattern as the audio capturer's channel-count gate). + // Cursor-as-metadata only where the encode backend this session resolves to composites + // `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask + // the portal to EMBED the pointer so no backend × cursor-mode combination streams + // cursorless. Synthetic frames carry no pointer either way. + let metadata_cursor = { + #[cfg(target_os = "linux")] + { + // Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make: + // the NVIDIA resolution plus the zero-copy master switch. + let cuda_planned = + !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled(); + crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr) + } + #[cfg(not(target_os = "linux"))] + false + }; let pooled = match video_cap.lock().unwrap().take() { - Some((c, was_hdr)) if was_hdr == cfg.hdr => Some(c), - Some((c, was_hdr)) => { + Some((c, was_hdr, was_meta)) if was_hdr == cfg.hdr && was_meta == metadata_cursor => { + Some(c) + } + Some((c, was_hdr, was_meta)) => { tracing::info!( was_hdr, want_hdr = cfg.hdr, - "video source: pooled capturer depth mismatch — opening a fresh screencast session" + was_metadata_cursor = was_meta, + want_metadata_cursor = metadata_cursor, + "video source: pooled capturer depth/cursor-mode mismatch — opening a fresh \ + screencast session" ); drop(c); None @@ -285,8 +316,13 @@ fn run( c } None if pf_host_config::config().video_source.as_deref() == Some("portal") => { - tracing::info!(hdr = cfg.hdr, "video source: portal desktop capture"); - capture::open_portal_monitor(cfg.hdr).context("open portal capturer")? + tracing::info!( + hdr = cfg.hdr, + metadata_cursor, + "video source: portal desktop capture" + ); + capture::open_portal_monitor(cfg.hdr, metadata_cursor) + .context("open portal capturer")? } None => { tracing::info!("video source: synthetic test pattern"); @@ -298,6 +334,7 @@ fn run( let result = stream_body( &mut capturer, None, + metadata_cursor, &sock, cfg, running, @@ -308,7 +345,7 @@ fn run( on_lost, ); capturer.set_active(false); - *video_cap.lock().unwrap() = Some((capturer, cfg.hdr)); + *video_cap.lock().unwrap() = Some((capturer, cfg.hdr, metadata_cursor)); result } @@ -646,6 +683,10 @@ fn stream_body( // Re-open the video source on capture loss (virtual-display path → follow a Desktop<->Game switch); // `None` for the portal/synthetic source, which has nothing to re-detect (propagate the error). rebuild: Option<&dyn Fn() -> Result>>, + // The capture hands the encoder cursor bitmaps to composite (cursor-as-metadata negotiated + // because the resolved backend blends — see the callers). `false` = the pointer is embedded + // in the pixels (or absent), so the encoder is asked to composite nothing. + cursor_blend: bool, sock: &UdpSocket, cfg: StreamConfig, running: &Arc, @@ -681,9 +722,9 @@ fn stream_body( // GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the // Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only. encode::ChromaFormat::Yuv420, - // Desktop monitor capture negotiates cursor-as-metadata where available — the encoder - // may be handed cursor bitmaps to composite. - true, + // True only when THIS session's capture negotiated cursor-as-metadata — which the + // callers grant only where the resolved backend composites (`cursor_blend_capable`). + cursor_blend, ) .context("open video encoder for stream")?; // Tell the encoder how deep the capturer lets it pipeline. Without this an in-place backend @@ -855,7 +896,7 @@ fn stream_body( frame.is_cuda(), gs_bit_depth(frame.format), encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 - true, // metadata-cursor capture — see the first open + cursor_blend, // same capture cursor mode — see the first open ) .context("reopen encoder after rebuild")?; // A rebuilt encoder starts unconfigured — same reason as the first open above. diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index a9bb54d2..8dd2aff3 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1012,9 +1012,10 @@ async fn serve_session( // just never fires then. let (cursor_shape_tx, cursor_shape_rx) = tokio::sync::mpsc::unbounded_channel::(); - // Negotiated cursor forwarding: MUST match the HOST_CAP_CURSOR bit the Welcome advertised - // (handshake::cursor_forward is the single predicate both read). - let cursor_forward = handshake::cursor_forward(hello.client_caps, compositor); + // Negotiated cursor forwarding: the HOST_CAP_CURSOR bit the Welcome advertised, read back + // rather than recomputed (`handshake::cursor_forward` computed it once, with the encoder + // blend-capability gate — re-running it here could drift, and would re-probe). + let cursor_forward = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0; // Who renders the pointer RIGHT NOW (client `CursorRenderMode`, flipped live by the mouse- // model chord): `true` = client draws (exclude + forward), `false` = host composites (the // capture model). Starts true — the pre-message behavior for cap sessions. Control task diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 65b9607d..4ccd15a5 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -12,31 +12,46 @@ use super::*; /// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the /// capture path can deliver cursor metadata separately from the frame — the Linux portal /// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows -/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single -/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off -/// wiring both read it, so they can never disagree. +/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on +/// Linux, the encode backend this session resolves to can composite the pointer on demand +/// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`, +/// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that +/// compositing stage — granting the channel over a backend that can't blend (libav +/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the +/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never +/// draws — never cursorless, never doubled. THE single predicate: the Welcome's +/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back. pub(super) fn cursor_forward( client_caps: u8, compositor: Option, + codec: crate::encode::Codec, + bit_depth: u8, ) -> bool { if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 { return false; } #[cfg(target_os = "linux")] { + // CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution + // plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav + // NVENC (doesn't) inside the capability mirror. + let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled(); compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope) + && crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10) } #[cfg(target_os = "windows")] { // Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel — // DWM composites the pointer into the IDD frame otherwise, and forwarding a second - // copy would double it. The probe latches by opening the control device once. - let _ = compositor; + // copy would double it. The probe latches by opening the control device once. The + // encoder is deliberately NOT consulted: the IDD capturer itself composites on the + // capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends. + let _ = (compositor, codec, bit_depth); crate::vdisplay::manager::hw_cursor_capable() } #[cfg(not(any(target_os = "linux", target_os = "windows")))] { - let _ = compositor; + let _ = (compositor, codec, bit_depth); false } } @@ -488,9 +503,10 @@ pub(super) async fn negotiate( 0 } // Cursor channel granted (client asked + this capture path can deliver cursor - // metadata out of the frame) — the client turns its local renderer on ONLY when - // it sees this bit, and serve_session wires forwarding from the same predicate. - | if cursor_forward(hello.client_caps, compositor) { + // metadata out of the frame + the resolved encoder can composite on the + // capture-mouse flip) — the client turns its local renderer on ONLY when it sees + // this bit, and serve_session wires forwarding by reading the bit back. + | if cursor_forward(hello.client_caps, compositor, codec, bit_depth) { punktfunk_core::quic::HOST_CAP_CURSOR } else { 0 @@ -536,9 +552,9 @@ pub(super) async fn negotiate( let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::(1); let client_identity = endpoint::peer_fingerprint(conn); let client_hdr = hello.display_hdr; - // Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and - // the session wiring must agree with what we just advertised. - let cursor_fw = cursor_forward(hello.client_caps, Some(comp)); + // The bit the Welcome just advertised — read back rather than recomputed, so the + // prepared display and the session wiring cannot disagree with it. + let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0; let (mode, shard_payload) = (hello.mode, welcome.shard_payload); let trace = bringup.clone(); std::thread::Builder::new() diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 07019a72..bd4f2bcd 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1022,8 +1022,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option, /// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata - /// captures — every non-gamescope compositor; gamescope embeds the pointer itself). - /// Encoders whose fast path cannot blend (the Vulkan EFC RGB-direct source) stay on their - /// blending path when this is set, so the pointer never silently vanishes from the stream. + /// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only + /// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions); + /// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders + /// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off + /// those shapes when this is set — see [`Self::output_format`] and + /// `encode::cursor_blend_capable`, the pre-open mirror that gates the cursor channel — so + /// the pointer never silently vanishes from the stream. pub cursor_blend: bool, /// The session negotiated the cursor-forward channel (M2/M2c): the client draws the pointer /// locally, so `cursor_blend` is off AND (on Windows) the capturer sets the driver's @@ -198,13 +202,15 @@ impl SessionPlan { // 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). 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. + // has no CSC stage to fold the cursor into — so ANY cursor-compositing session + // (gamescope Phase C, whose XFixes pointer is absent from the PipeWire node, AND a + // cursor-forward session, whose capture-mouse flip needs the host composite on + // demand) 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. (`cursor_blend` + // subsumes `gamescope_cursor` — see [`cursor_blend_for`].) #[cfg(target_os = "linux")] - nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.gamescope_cursor, + nv12_native: crate::encode::linux_native_nv12_ok(self.codec) && !self.cursor_blend, #[cfg(not(target_os = "linux"))] nv12_native: false, } @@ -217,6 +223,26 @@ pub(crate) fn resolve_topology() -> SessionTopology { SessionTopology::SingleProcess } +/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and +/// the mid-stream compositor re-gate) so they can't drift: +/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the +/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture +/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video). +/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` / +/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made +/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session. +pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool { + #[cfg(target_os = "windows")] + { + let _ = (cursor_forward, gamescope); + false + } + #[cfg(not(target_os = "windows"))] + { + cursor_forward || gamescope + } +} + #[cfg(target_os = "windows")] fn resolve_encoder() -> EncoderBackend { match crate::encode::windows_resolved_backend() { diff --git a/crates/punktfunk-host/src/spike.rs b/crates/punktfunk-host/src/spike.rs index 1fed9270..30fdc64e 100644 --- a/crates/punktfunk-host/src/spike.rs +++ b/crates/punktfunk-host/src/spike.rs @@ -94,7 +94,9 @@ pub fn run(opts: Options) -> Result<()> { want_hdr, "spike source: xdg ScreenCast portal (live monitor)" ); - capture::open_portal_monitor(want_hdr).context("open portal capturer")? + // Embedded cursor: the spike passes `cursor_blend = false` to its encoder open, so + // a metadata pointer would be composited by nothing. + capture::open_portal_monitor(want_hdr, false).context("open portal capturer")? } Source::KwinVirtual => { let compositor = crate::vdisplay::detect().unwrap_or(crate::vdisplay::Compositor::Kwin);