//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`). use super::pw_cursor::{composite_cursor, update_cursor_meta, CursorState}; use super::pw_pods::{ build_cursor_meta_param, build_default_format_obj, build_dmabuf_buffers, build_dmabuf_format, build_hdr_dmabuf_format, build_mappable_buffers, build_shm_only_buffers, serialize_pod, }; use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy}; use anyhow::{Context, Result}; use pipewire as pw; use pw::{properties::properties, spa}; use std::os::fd::{FromRawFd, OwnedFd}; use std::sync::atomic::Ordering; use std::sync::mpsc::SyncSender; use std::time::{SystemTime, UNIX_EPOCH}; use spa::param::video::{VideoFormat, VideoInfoRaw}; use spa::pod::Pod; /// Map a negotiated SPA video format to a layout the encoder can consume. Returns /// `None` for formats we don't handle (the frame is then skipped). fn map_format(f: VideoFormat) -> Option { Some(match f { VideoFormat::BGRx => PixelFormat::Bgrx, VideoFormat::RGBx => PixelFormat::Rgbx, VideoFormat::BGRA => PixelFormat::Bgra, VideoFormat::RGBA => PixelFormat::Rgba, VideoFormat::RGB => PixelFormat::Rgb, VideoFormat::BGR => PixelFormat::Bgr, VideoFormat::NV12 => PixelFormat::Nv12, // The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by // the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020). VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10, VideoFormat::xBGR_210LE => PixelFormat::X2Bgr10, _ => return None, }) } struct UserData { info: VideoInfoRaw, /// Negotiated layout (`None` until param_changed, or if unsupported). format: Option, /// Negotiated DRM format modifier (for dmabuf import); 0 = LINEAR. modifier: u64, /// The one-deep frame mailbox (see [`super::FrameSlot`]) + its wakeup sender. Written /// through [`UserData::publish`], never directly. slot: super::FrameSlot, wake: SyncSender<()>, /// Everything this thread publishes to the capturer — see [`super::CaptureSignals`], which also /// documents each flag's contract (it used to be restated here, and drifted). signals: super::CaptureSignals, /// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`]. import_fail_streak: u32, /// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer, /// normally via the isolated worker process (`pf_zerocopy::Importer::Remote`). importer: Option, /// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead /// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the /// encoder backend is VAAPI (AMD/Intel). vaapi_passthrough: bool, /// `PUNKTFUNK_NV12`: on the tiled EGL/GL zero-copy path, convert to NV12 on the GPU and feed /// NVENC native YUV (Tier 2A). Off ⇒ the BGRx path is unchanged. nv12: bool, /// 4:4:4 session: on the tiled EGL/GL zero-copy path, convert to planar YUV444 on the GPU /// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over /// `nv12` (a 4:4:4 session must never subsample). yuv444: bool, /// The LINEAR (gamescope) NV12 compute CSC failed once — RGB for the rest of the stream /// (T2.5b's per-stream fallback latch; cleared by the next stream's fresh `Ud`). linear_nv12_failed: bool, /// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`). dbg_log_n: u64, /// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`). cursor: CursorState, /// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a /// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`): /// `.process` skips whole buffers until the negotiated size matches, then clears this /// (self-disarming — later legitimate resizes are unaffected). `None` = no gating. expect_dims: Option<(u32, u32)>, /// Buffers skipped by the `expect_dims` gate (rate-limits its log). gate_skips: u64, /// When the gate first held a buffer — after [`GATE_DEADLINE`] with no renegotiation the /// gate disarms and accepts what the producer serves (degraded dims beat a session wedged /// into the first-frame-timeout retry loop; the promised renegotiation normally lands /// within a frame or two). gate_since: Option, } impl UserData { /// Hand `frame` to the consumer as THE latest, OVERWRITING any frame it has not taken yet /// (drop-oldest — see [`super::FrameSlot`]), then poke the wakeup edge. /// /// Never blocks the PipeWire loop, which is the hard constraint here: this runs inside /// `.process`, so blocking would stall the compositor's stream. Both operations are /// best-effort — a full wakeup channel means an edge is already pending (nothing lost, the /// slot is the truth), and a poisoned mutex is unreachable in practice (the only critical /// sections are a `take` and this store). fn publish(&self, frame: CapturedFrame) { if let Ok(mut slot) = self.slot.lock() { *slot = Some(frame); } let _ = self.wake.try_send(()); } } /// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time. /// Split out from [`pipewire_thread`]'s prologue so the decision is a pure function of these /// facts — testable, and consumable by both the thread and `spawn_pipewire` (see /// [`NegotiationPlan`]). #[derive(Debug, Clone, Copy)] pub(super) struct NegotiationInputs { /// `allow_zerocopy && pf_zerocopy::enabled()`. pub zerocopy: bool, /// `PUNKTFUNK_FORCE_SHM` — the race-free download path. pub force_shm: bool, /// This session offers the 10-bit PQ/BT.2020 formats. pub want_hdr: bool, /// This session negotiated full-chroma 4:4:4. pub want_444: bool, /// [`ZeroCopyPolicy::backend_is_vaapi`]. pub backend_is_vaapi: bool, /// [`ZeroCopyPolicy::pyrowave_session`]. pub pyrowave_session: bool, /// [`ZeroCopyPolicy::native_nv12_session`]. pub native_nv12_session: bool, /// `pf_zerocopy::raw_dmabuf_import_disabled()` — the scoped raw-passthrough latch. pub raw_dmabuf_import_disabled: bool, /// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths. pub gpu_import_disabled: bool, /// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference. pub native_nv12_env_on: bool, } /// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread /// AND by `spawn_pipewire`.** /// /// `spawn_pipewire` used to hand-mirror `vaapi_passthrough` so the capturer's /// negotiation-timeout branch could tell which offer had failed, and the copy drifted: it omitted /// `raw_dmabuf_import_disabled`, so after that latch fired the mirror still said "raw /// passthrough" while the thread had already fallen back — and the timeout branch then latched a /// downgrade for an offer it had not made. Deriving both consumers from this struct is what makes /// that class of drift unrepresentable rather than merely fixed (same shape as WP7.6's single /// Linux encode-backend resolver). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct NegotiationPlan { /// Build the EGL→CUDA importer for this capture. pub build_importer: bool, /// Hand raw dmabufs straight to the encoder instead of importing to CUDA. pub vaapi_passthrough: bool, /// Offer gamescope's producer-side NV12 pod ahead of packed RGB. pub prefer_native_nv12: bool, /// Carried through so [`want_dmabuf`](Self::want_dmabuf) needs no second copy of it. pub force_shm: bool, /// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is /// set (the encoder repeatedly failed to import, or a previous negotiation timed out). pub raw_dmabuf_latched: bool, /// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated /// import-worker deaths latched the GPU import off. pub gpu_import_latched: bool, } /// Resolve the negotiation plan. **Pure** — every environment read is already in `i`. /// /// The four invariants this encodes were previously prose-only comments spread across the /// prologue; `negotiation_plan_invariants` in the tests below pins each one: /// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth /// loss); the HDR consumers are the CPU mmap path and the raw passthrough. /// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled). /// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough /// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB). /// 4. The raw passthrough is off whenever its own latch has fired. pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan { // The frames' consumer imports raw dmabufs itself: the VAAPI backend (libva import + GPU // CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor). let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session; // Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or // worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by // invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack // must not crash-loop). let build_importer = i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled; // Note there is no `importer.is_none()` term, unlike the expression this replaces: it was // redundant (`build_importer` already excludes `raw_passthrough`, so the importer is // necessarily absent here) and it is what made the decision look impure — the reason // `spawn_pipewire` "had to" mirror it by hand. let vaapi_passthrough = i.zerocopy && !i.force_shm && raw_passthrough && !i.raw_dmabuf_import_disabled; let prefer_native_nv12 = i.native_nv12_env_on && i.native_nv12_session && i.backend_is_vaapi && vaapi_passthrough && !i.pyrowave_session && !i.want_444 && !i.want_hdr; NegotiationPlan { build_importer, vaapi_passthrough, prefer_native_nv12, force_shm: i.force_shm, raw_dmabuf_latched: i.zerocopy && !i.force_shm && raw_passthrough && i.raw_dmabuf_import_disabled, gpu_import_latched: i.zerocopy && !raw_passthrough && !i.want_hdr && i.gpu_import_disabled, } } impl NegotiationPlan { /// Whether to request dmabuf buffers. Not part of the plan proper: it depends on whether the /// importer actually CONSTRUCTED (`have_importer` — a GPU/driver fact) and on the modifier /// list that construction yielded. pub(super) fn want_dmabuf(&self, have_importer: bool, modifiers: &[u64]) -> bool { (have_importer || self.vaapi_passthrough) && !modifiers.is_empty() && !self.force_shm } } /// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before /// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the /// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a /// short streak of dropped frames the capturer fails loudly and the session renegotiates. const IMPORT_FAIL_POISON: u32 = 3; /// Log a frame-drop reason once per process (the process callback runs per frame; a stuck /// pipeline must say why without flooding). fn warn_once(msg: &'static str) { use std::sync::Mutex; static SEEN: Mutex> = Mutex::new(Vec::new()); let mut seen = SEEN.lock().unwrap(); if !seen.contains(&msg) { seen.push(msg); tracing::warn!("{msg}"); } } /// A read-only mmap of a dmabuf fd, unmapped on drop. Used when MAP_BUFFERS didn't map the /// buffer (producers don't always flag dmabufs mappable, e.g. gamescope's Vulkan exports). struct DmabufMap { ptr: *mut std::ffi::c_void, len: usize, } impl DmabufMap { fn new(fd: i32, len: usize) -> Option { // SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned // dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length. // `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and // returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned // region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying // object mapped independently of `fd` (which may be closed after this returns). let ptr = unsafe { libc::mmap( std::ptr::null_mut(), len, libc::PROT_READ, libc::MAP_SHARED, fd, 0, ) }; (ptr != libc::MAP_FAILED).then_some(DmabufMap { ptr, len }) } } impl Drop for DmabufMap { fn drop(&mut self) { // SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in // `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns // that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no // double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s // lifetime, so no borrow outlives the unmap. unsafe { libc::munmap(self.ptr, self.len); } } } /// De-pad / import a single PipeWire buffer and push it to the encoder. Called from the /// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced /// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data` /// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working. fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) { // No active stream: release the buffer without the (expensive at 5K) de-pad. if !ud.signals.active.load(Ordering::Relaxed) { return; } // Poisoned (GPU import lost): the capturer is already surfacing an error to the encode // loop; skip per-frame work until the rebuild tears this stream down. if ud.signals.broken.load(Ordering::Relaxed) { return; } // SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for // this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The // block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer // before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and // `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast // `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe // exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so // this `&mut` slice is the only reference to it (no aliasing/data race). let datas: &mut [pw::spa::buffer::Data] = unsafe { if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() { &mut [] } else { std::slice::from_raw_parts_mut( (*spa_buf).datas as *mut pw::spa::buffer::Data, (*spa_buf).n_datas as usize, ) } }; if datas.is_empty() { return; } let sz = ud.info.size(); let (w, h) = (sz.width as usize, sz.height as usize); if w == 0 || h == 0 { return; // format not negotiated yet } // Implicit-fence wait: Mutter renders into the dmabuf and hands it over at // GPU-submit time; with no producer explicit sync (Mutter+NVIDIA can't) we snapshot // the buffer's implicit fence and wait the producer's render before sampling — // closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that // attach no fence. Covers both the GPU import and the CPU mmap read below. if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) { Ok(waited) => { static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if F1.swap(false, Ordering::Relaxed) { tracing::info!( waited, "dmabuf implicit-fence sync active (waited=true → driver fences \ the render, race closed; false → no implicit fence, zero-copy \ may still show stale frames)" ); } } Err(e) => { static F2: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if F2.swap(false, Ordering::Relaxed) { tracing::warn!( error = %e, "dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \ zero-copy may show stale frames (no producer explicit sync)" ); } } } } // Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can // be consumed by the Vulkan Video encoder without another color conversion. if ud.vaapi_passthrough { if let Some(fmt) = ud.format { if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf { if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { let chunk = datas[0].chunk(); let offset = chunk.offset(); let stride = chunk.stride().max(0) as u32; // Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer // object; plane 1's chunk carries the REAL UV offset/stride (compositors // may align the Y plane before UV). Pass it through instead of assuming // contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is // by inode, not fd number; a genuinely two-BO frame cannot travel through // the single-fd import — drop it with a diagnosis instead of streaming // garbage chroma. let plane1 = if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 { // SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are // owned by the live PipeWire buffer for this callback, and `fstat` // only writes the out-param structs, whose fields are read only after // the `== 0` success checks. let same_bo = unsafe { let mut s0: libc::stat = std::mem::zeroed(); let mut s1: libc::stat = std::mem::zeroed(); libc::fstat(datas[0].fd() as i32, &mut s0) == 0 && libc::fstat(datas[1].fd() as i32, &mut s1) == 0 && (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino) }; if !same_bo { warn_once( "NV12 planes live in different buffer objects — frames \ dropped (single-fd import only)", ); return; } let c1 = datas[1].chunk(); Some((c1.offset(), c1.stride().max(0) as u32)) } else { None }; // dup the fd so it survives the SPA buffer recycle — the encode thread // imports it. Content stability across the brief import/encode window relies // on the compositor's buffer-pool depth, like any zero-copy capture. // SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid // for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd, // touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1). // The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0). let dup = unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) }; if dup >= 0 { let pts_ns = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); ud.publish(CapturedFrame { width: w as u32, height: h as u32, pts_ns, format: fmt, payload: FramePayload::Dmabuf(DmabufFrame { // SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned // (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole // ownership and closes it exactly once on drop — no alias, no // double-close. fd: unsafe { OwnedFd::from_raw_fd(dup) }, fourcc, modifier: ud.modifier, offset, stride, plane1, }), // Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope // embeds its pointer in the produced pixels, so native NV12 has none. cursor: ud.cursor.overlay(), }); static ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if ONCE.swap(false, Ordering::Relaxed) { tracing::info!( w, h, modifier = ud.modifier, fourcc = format_args!("{:#010x}", fourcc), source = if fmt == PixelFormat::Nv12 { "producer-native NV12" } else { "packed RGB (encoder GPU CSC)" }, "zero-copy: handing the raw DMA-BUF to the encoder" ); } return; } } } } // Not a dmabuf (or unmappable format) — fall through to the CPU de-pad path. } // Zero-copy path: if the buffer is a dmabuf and we have an importer, import it // into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall // through to the shm de-pad copy below. let mut gpu_import_broken = false; if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) { // Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its // de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the // importer, so this gate only matters if those invariants ever drift apart. if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() { let plane = pf_zerocopy::DmabufPlane { fd: datas[0].fd(), offset: datas[0].chunk().offset(), stride: datas[0].chunk().stride().max(0) as u32, }; // Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g. // gamescope) → direct CUDA external-memory import (NVIDIA EGL can't // sample LINEAR). let modifier = (ud.modifier != 0).then_some(ud.modifier); if let Some(fourcc) = pf_frame::drm_fourcc(fmt) { // GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled // EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never // subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL // blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan // T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on // the contended SM. A 4:4:4 session on LINEAR frames has no convert and // stays RGB, falling to the encoder's clear-error path (`want_444` with an // RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert // failure latches RGB for the stream (mid-frame fallback, no drop). let yuv444 = ud.yuv444 && modifier.is_some(); let mut nv12 = ud.nv12 && !ud.yuv444; let imported = if let Some(m) = modifier { if yuv444 { importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m)) } else if nv12 { importer.import_nv12(&plane, w as u32, h as u32, fourcc, Some(m)) } else { importer.import(&plane, w as u32, h as u32, fourcc, Some(m)) } } else if nv12 && !ud.linear_nv12_failed { match importer.import_linear_nv12(&plane, w as u32, h as u32) { Ok(buf) => Ok(buf), Err(e) => { ud.linear_nv12_failed = true; nv12 = false; tracing::warn!(error = %format!("{e:#}"), "LINEAR NV12 compute CSC failed — RGB for the rest of this \ stream (NVENC does the CSC internally)"); importer.import_linear(&plane, w as u32, h as u32) } } } else { nv12 = false; importer.import_linear(&plane, w as u32, h as u32) }; match imported { Ok(devbuf) => { ud.import_fail_streak = 0; pf_zerocopy::note_gpu_import_ok(); static ONCE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); if ONCE.swap(false, Ordering::Relaxed) { tracing::info!( w, h, modifier = ud.modifier, nv12, yuv444, "zero-copy: dmabuf imported to CUDA (no CPU copy)" ); } let pts_ns = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); ud.publish(CapturedFrame { width: w as u32, height: h as u32, pts_ns, format: if yuv444 { PixelFormat::Yuv444 } else if nv12 { PixelFormat::Nv12 } else { fmt }, payload: FramePayload::Cuda(devbuf), // Cursor-as-metadata: blended by the CUDA encoder into its owned // device surface. (RGB LINEAR-import case; YUV sessions blend planes.) cursor: ud.cursor.overlay(), }); return; } Err(e) => { let dead = importer.dead(); if dead { pf_zerocopy::note_gpu_import_death(); } if modifier.is_some() { // Tiled buffer: the CPU fallback below would mmap TILED bytes // and de-pad them as linear — a scrambled image, worse than no // frame. Drop the frame instead; on a dead worker (it absorbed a // driver fault) or a short failure streak, poison the stream so // the session's capture-loss rebuild renegotiates cleanly. ud.import_fail_streak += 1; if dead || ud.import_fail_streak >= IMPORT_FAIL_POISON { tracing::error!(error = %format!("{e:#}"), dead, "tiled GPU import lost — failing this capture for rebuild"); ud.signals.broken.store(true, Ordering::Relaxed); } else { tracing::warn!(error = %format!("{e:#}"), streak = ud.import_fail_streak, "tiled dmabuf GPU import failed — frame dropped"); } return; } // LINEAR dmabuf: CPU-mappable, so disable the importer and fall // through to the CPU mmap path — degraded, not dead. tracing::warn!(error = %format!("{e:#}"), "LINEAR dmabuf GPU import failed — falling back to the CPU copy path"); gpu_import_broken = true; } } } else { return; // format has no DRM fourcc mapping — skip the frame } } } if gpu_import_broken { ud.importer = None; } let d = &mut datas[0]; // CPU path may also receive LINEAR dmabufs (gamescope offers only those once its // modifier-bearing format pod wins); capture the fd before `data()` borrows `d`. let data_type = d.type_(); // fd-backed buffer (MemFd SHM, or DmaBuf)? Capture the fd before `data()` borrows `d`. let raw_fd = d.fd(); // `mapoffset` is where THIS spa_data's region begins inside the fd — non-zero for a pooled // producer that carves every buffer out of one shared fd. PipeWire's own MAP_BUFFERS slice // already starts there, but our self-mmap below maps the fd from 0, so that path must add it // (see `region_off`). Reading it without it made the self-mmap path index the WRONG buffer, // and the `needed > avail` guard could not catch that: `avail` came from the whole-fd // mapping, so it was large enough for any single buffer's span. let map_off = d.as_raw().mapoffset as usize; let (size, chunk_off, stride) = { let c = d.chunk(); ( c.size() as usize, c.offset() as usize, c.stride().max(0) as usize, ) }; let Some(fmt) = ud.format else { return }; // unsupported/not negotiated // The de-pad below assumes ONE packed plane of `bytes_per_pixel` bytes. `bytes_per_pixel`'s // catch-all answers 4 for NV12, so a producer-native NV12 buffer (stride ≈ w, two planes) // computed `row = 4w` and always tripped the `stride < row` guard below — blaming the // PRODUCER's stride for a host limitation. NV12 cannot be de-padded on this path at all // (the second plane is not even in `datas[0]`'s span), so say so honestly. It only arrives // here if a native-NV12 negotiation happens without the zero-copy passthrough that is // supposed to consume it, which is a host bug, not a producer one. if matches!(fmt, PixelFormat::Nv12) { warn_once( "negotiated producer-native NV12 but this capture fell back to the CPU de-pad path, \ which handles single-plane packed formats only — frames dropped (the NV12 offer is \ only valid under the raw-dmabuf passthrough that imports it directly)", ); return; } let bpp = fmt.bytes_per_pixel(); let row = w * bpp; let stride = if stride == 0 { row } else { stride }; if stride < row { warn_once("chunk stride < row — frames dropped"); return; } let needed = stride * (h - 1) + row; // dmabuf chunks commonly report size 0; fall back to the computed span. let size = if size == 0 { needed } else { size }; // For fd-backed buffers (MemFd SHM, DmaBuf) mmap the fd OURSELVES, sized to the fd's real // length (fstat), rather than trusting PipeWire's MAP_BUFFERS slice: xdg-desktop-portal-wlr // hands MemFd buffers whose reported `data.maxsize` exceeds the bytes actually mapped into // our process, so reading to maxsize segfaults (it also covers the original case — MAP_BUFFERS // not mapping Vulkan dmabufs, e.g. gamescope). The `needed > avail` guard below then drops // cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process — // trust `d.data()`. let fd_len = if raw_fd > 0 { // SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so // `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and // valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned, // correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only // after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it. unsafe { let mut st: libc::stat = std::mem::zeroed(); (libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0) .then_some(st.st_size as usize) } } else { None }; let _mapping; // keeps a manual mmap alive for the copy below // Prefer our own fstat-sized mmap of the fd; fall back to PipeWire's MAP_BUFFERS slice // (and finally drop) so an fd PipeWire could map but we can't never silently over-reads. // // `fd_len` is REQUIRED, not preferred: it used to fall back to // `offset + needed`, i.e. a length invented from producer-controlled geometry. // That defeated the whole point of the fstat (the "buffer smaller than the // frame span" guard compares against exactly the number the producer just // supplied) and could map — and then read — past the end of the object, which // is a SIGBUS, not an `Err`. Without a real length we decline to self-map and // let PipeWire's own slice serve, which is bounded by construction. let self_mapped: Option<&[u8]> = if raw_fd > 0 { match fd_len.and_then(|map_len| DmabufMap::new(raw_fd as i32, map_len)) { Some(m) => { _mapping = m; // SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a // successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned, // and the VMA is one allocated object of `len` bytes valid for reads. In the common // path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the // whole object; the de-pad copy below is further bounded by the `offset <= buf.len()` // and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end // of `consume_frame`, so the slice never outlives the mapping, and the memory is only // read here, so there is no aliasing/mutation. Some(unsafe { std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) }) } None => None, } } else { None }; // Which base `chunk.offset` is relative to differs by path: our self-mmap starts at fd // offset 0, so this spa_data's region begins at `mapoffset`; PipeWire's MAP_BUFFERS slice // already begins there. Checked add — both halves are producer-controlled. let (buf, region_off): (&[u8], usize) = if let Some(b) = self_mapped { match map_off.checked_add(chunk_off) { Some(off) => (b, off), None => { warn_once("mapoffset + chunk offset overflows — frames dropped"); return; } } } else if let Some(data) = d.data() { (data, chunk_off) } else { warn_once("buffer has no mappable data — frames dropped"); return; }; // Need stride*(h-1)+row valid bytes within [region_off, region_off+size). if region_off > buf.len() { return; } let avail = buf.len() - region_off; { // One-time geometry dump — makes a new compositor/GPU's buffer layout visible in the // logs (the kind of mismatch that crashed xdpw MemFd capture before the self-mmap fix). use std::sync::atomic::{AtomicBool, Ordering}; static ONCE: AtomicBool = AtomicBool::new(true); if ONCE.swap(false, Ordering::Relaxed) { tracing::info!( stride, size, chunk_off, map_off, region_off, buf_len = buf.len(), needed, data_type = ?data_type, fd_len = ?fd_len, self_mapped = self_mapped.is_some(), "capture CPU de-pad geometry (first frame)" ); } } if needed > avail || needed > size { warn_once("buffer smaller than frame span — frames dropped"); return; } let region = &buf[region_off..region_off + size.min(avail)]; // De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp). let mut tight = vec![0u8; row * h]; for y in 0..h { tight[y * row..y * row + row].copy_from_slice(®ion[y * stride..y * stride + row]); } // Cursor-as-metadata: blit the latched pointer into the frame (no-op when hidden or when // the layout isn't packed RGB). This is the CPU path's counterpart to the producer's // hardware cursor plane, which stays out of the captured buffer. composite_cursor(&mut tight, w, h, fmt, &ud.cursor); let pts_ns = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let frame = CapturedFrame { width: w as u32, height: h as u32, pts_ns, format: fmt, payload: FramePayload::Cpu(tight), // Already composited inline into `tight` above — nothing for the encoder to blend. cursor: None, }; // Overwrite whatever the consumer has not taken yet — never block the pipewire loop. ud.publish(frame); } #[allow(clippy::too_many_arguments)] pub fn pipewire_thread( fd: Option, node_id: u32, // The frame mailbox + its wakeup sender (see `super::FrameSlot`): publishing OVERWRITES, // so a stalled consumer costs the intermediate frames, never the freshest one. slot: super::FrameSlot, wake: SyncSender<()>, // Every flag/slot this thread PUBLISHES, in one struct (see `super::CaptureSignals`). It used to // be seven separate parameters here, seven `Arc` clones at the call site, and the same seven // fields restated on `UserData`. signals: super::CaptureSignals, // THE zero-copy negotiation decision, resolved once by `spawn_pipewire` (which consumes the // same struct for the capturer's timeout diagnosis) — never re-derived here. plan: NegotiationPlan, // The session's capture options (see `super::CaptureOpts`) — `want_444`/`want_hdr` pick the pod // family, `expect_exact_dims` arms the sacrificial-mode gate. `allow_zerocopy` is already folded // into `plan`. opts: super::CaptureOpts, preferred: Option<(u32, u32, u32)>, quit_rx: pw::channel::Receiver<()>, // Encode-backend facts resolved by the facade (never re-derived here) — the one-way // capture→encode edge (plan §W6). policy: ZeroCopyPolicy, ) -> Result<()> { let super::CaptureOpts { want_444, want_hdr, expect_exact_dims, .. } = opts; crate::pwinit::ensure_init(); let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; // A quit signal (capturer `Drop`) lands here on the loop thread and stops `run()` so the // thread unwinds instead of blocking to process exit. Hold the attachment for the loop's // life; the cloned loop handle is the one the callback quits. let quit_loop = mainloop.clone(); let _quit_attach = quit_rx.attach(mainloop.loop_(), move |()| { tracing::debug!("pipewire: quit signal received — stopping capture loop"); quit_loop.quit(); }); let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; // A portal source hands us an fd to a (sandboxed) PipeWire remote; the KWin // virtual-output source has no fd — its node lives on the user's default daemon. let core = match fd { Some(fd) => context .connect_fd_rc(fd, None) .context("pw connect_fd (portal remote)")?, None => context .connect_rc(None) .context("pw connect (default daemon)")?, }; // The negotiation decision arrives pre-resolved in `plan` (see `negotiation_plan`): what to // build, which offer to make, and whether to prefer producer NV12. Nothing here re-derives // any of it — that duplication is what F1/L3 was. let backend_is_vaapi = policy.backend_is_vaapi; let force_shm = plan.force_shm; let vaapi_passthrough = plan.vaapi_passthrough; let prefer_native_nv12 = plan.prefer_native_nv12; // Build the GPU importer — normally the ISOLATED worker process // (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf // kills the worker, not this host. If construction fails, log and fall back to the CPU path // (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try // at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched // the import off — see `negotiation_plan`). if plan.gpu_import_latched { tracing::warn!( "zero-copy GPU import disabled after repeated import-worker deaths — using CPU path" ); } let mut importer = if plan.build_importer { match pf_zerocopy::Importer::new_for_capture() { Ok(i) => Some(i), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path"); None } } } else { None }; if prefer_native_nv12 { tracing::info!( "zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \ RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)" ); } // Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR // (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via // CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only: // radeonsi/iHD import it and any compositor can allocate it. let mut modifiers = importer .as_mut() .map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap())) .unwrap_or_default(); if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) { modifiers.push(0); // DRM_FORMAT_MOD_LINEAR } // PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the // advertisement with every modifier its device samples from, so compositors that // never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers // were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when // the host's `pyrowave` feature is on AND the session (or the global encoder pref) is // PyroWave — so capture never calls back into `encode` and needs no feature gate of its // own (the emptiness check gates it). if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() { for &m in &policy.pyrowave_modifiers { if !modifiers.contains(&m) { modifiers.push(m); } } tracing::info!( count = modifiers.len(), "zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers" ); } // The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it // needs the modifier list the importer's construction actually yielded. let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers); if force_shm { tracing::info!( "capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)" ); } else if plan.raw_dmabuf_latched { tracing::warn!( "zero-copy raw-dmabuf passthrough disabled for this host process (repeated encoder \ import failures, or a previous dmabuf negotiation timeout) — capturing CPU frames \ instead" ); } else if !want_dmabuf && (plan.build_importer || plan.vaapi_passthrough) { tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path"); } else if vaapi_passthrough { // The raw-passthrough advertisement. Covers the PyroWave case too: its extra // Vulkan-importable modifiers were appended (and logged) just above, so this arm must // NOT be gated on `pyrowave_modifiers.is_empty()` — that gate is what dropped a fully // zero-copy PyroWave session through to the CPU-path warning below (L11). tracing::info!( native_nv12_preferred = prefer_native_nv12, modifier_count = modifiers.len(), pyrowave_extended = !policy.pyrowave_modifiers.is_empty(), "zero-copy: advertising DMA-BUF modifiers for direct encoder import (LINEAR \ always; native NV12 first when enabled, packed RGB fallback)" ); } else if want_dmabuf { tracing::info!( count = modifiers.len(), sample = ?&modifiers[..modifiers.len().min(6)], "zero-copy: advertising EGL-importable dmabuf modifiers" ); } else if backend_is_vaapi && policy.backend_is_gpu { // Reached only when no dmabuf is advertised at all (every arm above rules out a // zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays // three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) — // make the silent fallback visible. // The `raw_dmabuf_latched` arm above catches the latched downgrade, so by here zero-copy // is off at the source: the env var, or the session's own output format. tracing::warn!( "VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \ upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \ restore the dmabuf default", if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() { "PUNKTFUNK_ZEROCOPY is set falsy" } else { "this session's output format asked for CPU frames" } ); } if want_dmabuf && !vaapi_passthrough && want_444 { tracing::info!( "4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \ NVENC fed native full-chroma YUV, no CPU pixel path" ); } else if want_dmabuf && !vaapi_passthrough && pf_zerocopy::nv12_enabled() { tracing::info!( "PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \ fed native YUV (no internal RGB→YUV CSC)" ); } let data = UserData { info: VideoInfoRaw::default(), format: None, modifier: 0, slot, wake, signals, import_fail_streak: 0, importer, vaapi_passthrough, nv12: pf_zerocopy::nv12_enabled(), yuv444: want_444, linear_nv12_failed: false, dbg_log_n: 0, cursor: CursorState::default(), expect_dims: if expect_exact_dims { preferred.map(|(w, h, _)| (w, h)) } else { None }, gate_skips: 0, gate_since: None, }; let stream = pw::stream::StreamBox::new( &core, "punktfunk-screencast", properties! { *pw::keys::MEDIA_TYPE => "Video", *pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_ROLE => "Screen", // Never let the session manager re-target this stream to a different node when // its target goes away: an orphaned stream auto-linked to a fresh Video/Source // wedges that node — and a stuck link head-blocks the PipeWire daemon's shared // work queue, stalling ALL new link negotiation system-wide. "node.dont-reconnect" => "true", }, ) .context("pw Stream")?; let _listener = stream .add_local_listener_with_user_data(data) .state_changed(|_stream, ud, old, new| { tracing::info!(?old, ?new, "pipewire stream state"); // Track whether the node is actively producing. A live source sits in `Streaming` // (a static desktop just sends no buffers); anything else — `Paused`/`Unconnected`/ // `Error` — means the source went away (compositor died, virtual output removed on a // Gaming↔Desktop switch). `try_latest` turns a sustained non-Streaming state into a // capture-loss so the encode loop rebuilds instead of freezing on the last frame. ud.signals.streaming.store( matches!(new, pw::stream::StreamState::Streaming), Ordering::Relaxed, ); }) .param_changed(|_stream, ud, id, param| { let Some(param) = param else { return }; if id != pw::spa::param::ParamType::Format.as_raw() { return; } let Ok((media_type, media_subtype)) = pw::spa::param::format_utils::parse_format(param) else { return; }; if media_type != pw::spa::param::format::MediaType::Video || media_subtype != pw::spa::param::format::MediaSubtype::Raw { return; } if ud.info.parse(param).is_ok() { ud.signals.negotiated.store(true, Ordering::Relaxed); // A (re)negotiation replaces the buffer pool: every cached per-buffer import // (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on // buffers that no longer exist — and a recycled fd number/inode must never // resolve to a stale import. No-op on the first negotiation (empty caches). if let Some(imp) = ud.importer.as_mut() { imp.clear_cache(); } let sz = ud.info.size(); // Publish the negotiated size for the gamescope cursor source's root→frame // scaling (`xfixes_cursor::scale_to_frame`); a renegotiation updates it. ud.signals.frame_size.store( (u64::from(sz.width) << 32) | u64::from(sz.height), Ordering::Relaxed, ); ud.format = map_format(ud.info.format()); ud.modifier = ud.info.modifier(); // HDR: the 10-bit PQ formats are only ever offered with MANDATORY BT.2020/PQ // colorimetry props, so a 10-bit negotiation IS an HDR negotiation — but log // what the producer actually fixated for diagnosis. let hdr = ud.format.is_some_and(|f| f.is_hdr_rgb10()); ud.signals.hdr_negotiated.store(hdr, Ordering::Relaxed); tracing::info!( width = sz.width, height = sz.height, spa_format = ?ud.info.format(), mapped = ?ud.format, modifier = ud.modifier, hdr, transfer_function = ud.info.transfer_function(), color_primaries = ud.info.color_primaries(), "pipewire format negotiated" ); if ud.format.is_none() { tracing::error!( spa_format = ?ud.info.format(), "negotiated a pixel format the encoder cannot consume — frames will be skipped" ); } } }) .process(|stream, ud| { // Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its // pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue // the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the // `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is // requeued exactly once AFTER the panic-containing region. Previously the whole thing was // inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded // `newest` forever, permanently shrinking the stream's fixed pool until capture wedged. // SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the // loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null // (null-checked), single-threaded so no concurrent access. let mut newest = unsafe { stream.dequeue_raw_buffer() }; if newest.is_null() { return; } let mut drained = 1u32; loop { // SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null. let next = unsafe { stream.dequeue_raw_buffer() }; if next.is_null() { break; } // SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately // overwrite it, so the requeued pointer is never touched again. unsafe { stream.queue_raw_buffer(newest) }; newest = next; drained += 1; } // Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the // expected dims, every buffer — frame AND cursor meta, whose positions are in the // doomed mode's space — belongs to the birth mode; consuming one would build the // pipeline at the wrong size. Self-disarms on the first matching negotiation, or // after `GATE_DEADLINE` without one — degraded dims beat wedging the session into // the first-frame-timeout retry loop when the promised renegotiation never comes. if let Some((ew, eh)) = ud.expect_dims { /// The renegotiation normally lands within a frame or two of recording; well /// past that, the producer is not going to deliver it (the on-glass case: the /// real mode never actually applied) — stop starving the pipeline. const GATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(3); let sz = ud.info.size(); if sz.width == ew && sz.height == eh { tracing::info!( skipped = ud.gate_skips, width = ew, height = eh, "producer renegotiated to the expected mode — frames flow" ); ud.expect_dims = None; } else if ud .gate_since .get_or_insert_with(std::time::Instant::now) .elapsed() > GATE_DEADLINE { tracing::warn!( negotiated_w = sz.width, negotiated_h = sz.height, expected_w = ew, expected_h = eh, skipped = ud.gate_skips, "producer never renegotiated to the expected mode — accepting its \ dims (session runs degraded rather than wedged)" ); ud.expect_dims = None; } else { ud.gate_skips += 1; if ud.gate_skips == 1 || ud.gate_skips.is_power_of_two() { tracing::info!( negotiated_w = sz.width, negotiated_h = sz.height, expected_w = ew, expected_h = eh, n = ud.gate_skips, "holding frames until the producer renegotiates to the expected mode" ); } // SAFETY: `newest` was dequeued from this stream and not yet requeued; // requeued exactly once here, then never touched (mirrors the null path). unsafe { stream.queue_raw_buffer(newest) }; return; } } // PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI // boundary would abort the whole host. Contain the inspect/consume work — the only Rust // code here that can panic — and requeue `newest` unconditionally after it. let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { // SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued); // `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field // load through a valid pointer — no mutation or aliasing. let spa_buf = unsafe { (*newest).buffer }; // Refresh cursor-as-metadata BEFORE the stale-frame skip below: Mutter delivers // 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) 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.signals.cursor_live.lock() { *slot = Some(overlay); } } // Inspect the newest buffer's header + first chunk for the diagnostic and the // CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null. // SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold. // `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header` // of at least `size_of::()` bytes and returns a pointer into the held // buffer's metadata (or null). The size argument matches the struct the result is cast // to, and the pointer stays valid as long as the buffer is held (until requeue). Null is // handled below. let hdr = unsafe { spa::sys::spa_buffer_find_meta_data( spa_buf, spa::sys::SPA_META_Header, std::mem::size_of::(), ) as *const spa::sys::spa_meta_header }; let hdr_flags = if hdr.is_null() { 0u32 } else { // SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header` // inside the live buffer's metadata (returned for a size >= // `size_of::()`, so `.flags` is in bounds). A single field read // while the buffer is still held. unsafe { (*hdr).flags } }; // First data chunk's size + flags (used for the diagnostic + CORRUPTED check) // and its data type (a dmabuf legitimately reports chunk size 0, so the size-0 // stale skip only applies to mappable SHM buffers). // SAFETY: every dereference is guarded in order before any field read — `spa_buf` // non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first // element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c` // its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads // of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation. let (chunk_size, chunk_flags, is_dmabuf) = unsafe { if !spa_buf.is_null() && (*spa_buf).n_datas > 0 && !(*spa_buf).datas.is_null() && !(*(*spa_buf).datas).chunk.is_null() { let d0 = (*spa_buf).datas; let c = (*d0).chunk; let is_dmabuf = (*d0).type_ == spa::sys::SPA_DATA_DmaBuf; ((*c).size, (*c).flags, is_dmabuf) } else { (0u32, 0i32, false) } }; let corrupted = (hdr_flags & spa::sys::SPA_META_HEADER_FLAG_CORRUPTED) != 0 || (chunk_flags & spa::sys::SPA_CHUNK_FLAG_CORRUPTED as i32) != 0; // THE GNOME FLASH FIX: skip Mutter's CORRUPTED / size-0 cursor-update buffers. // When the pointer moves (e.g. dragging a window) Mutter sends metadata-only // buffers flagged CORRUPTED (chunk size 0) that still reference a RECYCLED old // frame; consuming them encodes "the window at its old position" — the flash. // Confirmed live on worker-3 (chunk_flags=CORRUPTED, size 0) for both the zero-copy // and SHM paths. The size-0 half is SHM-only (a real dmabuf legitimately reports // chunk size 0). `drained` is the latest-frame-only depth — a cheap extra defense // against bursty delivery, though here Mutter sends one buffer per callback. if corrupted || (chunk_size == 0 && !is_dmabuf) { ud.dbg_log_n += 1; if ud.dbg_log_n.is_power_of_two() { tracing::debug!( skipped = ud.dbg_log_n, drained, "capture: skipped a stale CORRUPTED/cursor buffer (GNOME)" ); } // Skip this stale/cursor buffer — `newest` is requeued unconditionally below. return; } consume_frame(ud, spa_buf); })); // Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip, // or a caught panic in the closure above. This single requeue is what keeps the fixed // buffer pool from draining. // SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed // inside the closure above; `newest` was dequeued from this stream and not yet requeued. unsafe { stream.queue_raw_buffer(newest) }; if outcome.is_err() { // In the per-frame `.process` callback: a deterministic panic (e.g. a bad // format) would fire this every frame, so power-of-two throttle it — enough to // surface the fault without evicting the whole log ring. static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let n = PANICS.fetch_add(1, Ordering::Relaxed) + 1; if n.is_power_of_two() { tracing::error!(count = n, "panic in pipewire process callback — frame dropped"); } } }) .register() .context("register stream listener")?; // Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect // negotiation failures against a producer's exact EnumFormat (e.g. gamescope). let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD") .ok() .and_then(|v| v.split_once('x').map(|(w, h)| (w.parse(), h.parse()))) .and_then(|(w, h)| Some((w.ok()?, h.ok()?))); // Request raw video in any encoder-mappable layout, any size/framerate. let obj = if let Some((fw, fh)) = fixed_pod { tracing::info!( fw, fh, "pipewire: offering a fixed BGRx format pod (PUNKTFUNK_PW_FIXED_POD)" ); pw::spa::pod::object!( pw::spa::utils::SpaTypes::ObjectParamFormat, pw::spa::param::ParamType::EnumFormat, pw::spa::pod::property!( pw::spa::param::format::FormatProperties::MediaType, Id, pw::spa::param::format::MediaType::Video ), pw::spa::pod::property!( pw::spa::param::format::FormatProperties::MediaSubtype, Id, pw::spa::param::format::MediaSubtype::Raw ), pw::spa::pod::property!( pw::spa::param::format::FormatProperties::VideoFormat, Id, VideoFormat::BGRx ), pw::spa::pod::property!( pw::spa::param::format::FormatProperties::VideoSize, Rectangle, pw::spa::utils::Rectangle { width: fw, height: fh } ), pw::spa::pod::property!( pw::spa::param::format::FormatProperties::VideoFramerate, Fraction, pw::spa::utils::Fraction { num: 0, denom: 1 } ), ) } else { build_default_format_obj(preferred) }; // gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission: // gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into // the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the // default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see // `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the // `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the // overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a // dedicated session) — that flips gamescope into the Remote-Play branch that deliberately // drops the overlay (and all host chrome) back out of the capture. The cursor, external // overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope // version and are composited host-side instead (see `xfixes_cursor.rs`). // // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers // (offering shm too makes the compositor pick shm). The modifier list goes out as a plain // MANDATORY `ChoiceEnum::Enum` and the producer fixates one of the alternatives directly — // this is NOT the two-step DONT_FIXATE handshake (libspa 0.9's `ChoiceFlags` cannot express // `SPA_POD_PROP_FLAG_DONT_FIXATE`, and `param_changed` only READS the fixated format, it // re-emits nothing). Worth revisiting if a multi-modifier offer is ever seen to fail // negotiation on a compositor that needs the allocator round-trip. Otherwise offer the // multi-format shm pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit // PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering // SDR alongside would make the producer pick its earlier-listed SDR format, and the // negotiation-timeout path latches the process-wide SDR downgrade if nothing matches. let format_pods: Vec> = if want_hdr { tracing::info!( "HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \ BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)" ); vec![ build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?, build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?, ] } else if want_dmabuf { let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 }); if prefer_native_nv12 { // First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning // BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry. pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?); } pods.push(build_dmabuf_format( VideoFormat::BGRx, &modifiers, preferred, )?); pods } else { vec![serialize_pod(obj)?] }; let buffers_values = if want_hdr || want_dmabuf { // Dmabuf-only. For HDR this is load-bearing beyond zero-copy: Mutter's SHM record // path paints 8-bit ARGB32 regardless of the negotiated format, so a MemFd buffer // under a 10-bit format would carry mislabeled bytes. Some(build_dmabuf_buffers()?) } else if force_shm { // True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render). Some(build_shm_only_buffers()?) } else { // CPU path still accepts mappable dmabufs (gamescope offers only those once its // modifier-bearing format pod wins the intersection). Some(build_mappable_buffers()?) }; // Ask for cursor-as-metadata on every path (harmless if the producer can't supply it): the // pointer rides as SPA_META_Cursor rather than being burned into the frame, so the // compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`). let cursor_meta = build_cursor_meta_param()?; let mut byte_slices: Vec<&[u8]> = Vec::new(); for pod in &format_pods { byte_slices.push(pod); } if let Some(b) = &buffers_values { byte_slices.push(b); } byte_slices.push(&cursor_meta); let mut params: Vec<&Pod> = byte_slices .iter() .map(|&b| Pod::from_bytes(b).context("pod from bytes")) .collect::>()?; stream .connect( spa::utils::Direction::Input, Some(node_id), pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS, &mut params, ) .context("pw stream connect")?; // Blocks this thread, pumping frame callbacks until the capturer's `Drop` fires the quit // channel attached above (`_quit_attach` → `quit_loop.quit()`), at which point `run()` // returns and the thread unwinds — releasing the importer / CUDA context deterministically. mainloop.run(); Ok(()) } #[cfg(test)] mod tests { use super::{negotiation_plan, NegotiationInputs}; /// A healthy NVENC session: zero-copy on, no latches, SDR 4:2:0, non-VAAPI backend. fn nvenc() -> NegotiationInputs { NegotiationInputs { zerocopy: true, force_shm: false, want_hdr: false, want_444: false, backend_is_vaapi: false, pyrowave_session: false, native_nv12_session: false, raw_dmabuf_import_disabled: false, gpu_import_disabled: false, native_nv12_env_on: true, } } /// A gamescope-style VAAPI session that CAN take producer-native NV12. fn vaapi_native_nv12() -> NegotiationInputs { NegotiationInputs { backend_is_vaapi: true, native_nv12_session: true, ..nvenc() } } /// The four invariants that were prose-only comments in `pipewire_thread`'s prologue. #[test] fn negotiation_plan_invariants() { // 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an // importer here would silently crush the 10-bit depth. for want_444 in [false, true] { let p = negotiation_plan(NegotiationInputs { want_hdr: true, want_444, ..nvenc() }); assert!(!p.build_importer, "HDR must not build the importer"); } // …on every backend, including the ones that take the raw passthrough. assert!( !negotiation_plan(NegotiationInputs { want_hdr: true, ..vaapi_native_nv12() }) .build_importer ); // 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled). let p = negotiation_plan(NegotiationInputs { want_444: true, ..vaapi_native_nv12() }); assert!(!p.prefer_native_nv12, "4:4:4 must not take NV12"); // Nor does HDR (no 10-bit NV12 path). assert!( !negotiation_plan(NegotiationInputs { want_hdr: true, ..vaapi_native_nv12() }) .prefer_native_nv12 ); // 3. Producer-native NV12 needs a `native_nv12_session` AND an active raw passthrough: // libav VAAPI would misread the two-plane buffer, and the CUDA importer expects // packed RGB. assert!(negotiation_plan(vaapi_native_nv12()).prefer_native_nv12); assert!( !negotiation_plan(NegotiationInputs { native_nv12_session: false, ..vaapi_native_nv12() }) .prefer_native_nv12, "a session whose encoder can't ingest NV12 must never be offered it" ); assert!( !negotiation_plan(NegotiationInputs { force_shm: true, ..vaapi_native_nv12() }) .prefer_native_nv12, "no passthrough (force_shm) ⇒ no native NV12" ); // A PyroWave session takes the passthrough but its CSC ingests packed RGB only. assert!( !negotiation_plan(NegotiationInputs { pyrowave_session: true, ..vaapi_native_nv12() }) .prefer_native_nv12 ); // 4. The pyrowave-modifier extension (and the passthrough generally) is off whenever // the raw-dmabuf latch has fired. let p = negotiation_plan(NegotiationInputs { raw_dmabuf_import_disabled: true, ..vaapi_native_nv12() }); assert!(!p.vaapi_passthrough, "latched ⇒ no raw passthrough"); assert!(!p.prefer_native_nv12); assert!(p.raw_dmabuf_latched, "…and the operator gets told why"); } /// The drift F1 was about: `spawn_pipewire` derived `vaapi_passthrough` by hand and its copy /// omitted the raw-dmabuf latch, so after that latch fired the capturer believed it had made /// the passthrough offer while the thread had already fallen back — and a timeout then /// latched a downgrade for an offer nobody made. With one resolver the two cannot disagree, /// so this pins the property that made the mirror wrong: the latch MUST move the decision. #[test] fn the_raw_dmabuf_latch_moves_the_passthrough_decision() { for pyrowave in [false, true] { let base = NegotiationInputs { backend_is_vaapi: !pyrowave, pyrowave_session: pyrowave, ..nvenc() }; assert!(negotiation_plan(base).vaapi_passthrough); assert!( !negotiation_plan(NegotiationInputs { raw_dmabuf_import_disabled: true, ..base }) .vaapi_passthrough ); } } /// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own /// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the /// EGL→CUDA importer — whose payloads only NVENC can consume. #[test] fn a_pyrowave_session_passes_through_without_a_cuda_importer() { let p = negotiation_plan(NegotiationInputs { pyrowave_session: true, ..nvenc() }); assert!(p.vaapi_passthrough); assert!(!p.build_importer); } /// `force_shm` is the race-free download path: no passthrough, and `want_dmabuf` stays false /// even with an importer and a full modifier list. #[test] fn force_shm_wins_over_every_dmabuf_path() { let p = negotiation_plan(NegotiationInputs { force_shm: true, ..vaapi_native_nv12() }); assert!(!p.vaapi_passthrough); assert!(!p.want_dmabuf(true, &[0, 1, 2])); // …and an SHM-forced NVENC session may still build the importer (it just won't be fed // dmabufs), which is why `want_dmabuf` — not `build_importer` — is the gate. let p = negotiation_plan(NegotiationInputs { force_shm: true, ..nvenc() }); assert!(p.build_importer); assert!(!p.want_dmabuf(true, &[0])); } /// `want_dmabuf` needs a real modifier list: an importer that constructed but advertised /// nothing importable falls back to the CPU path. #[test] fn want_dmabuf_needs_both_a_consumer_and_a_modifier() { let p = negotiation_plan(nvenc()); assert!(p.want_dmabuf(true, &[0])); assert!(!p.want_dmabuf(true, &[]), "no modifiers ⇒ CPU path"); assert!( !p.want_dmabuf(false, &[0]), "importer failed to construct and no passthrough ⇒ CPU path" ); // The passthrough needs no importer at all. let p = negotiation_plan(vaapi_native_nv12()); assert!(p.want_dmabuf(false, &[0])); } /// The GPU-import death latch stops the importer being rebuilt, and says so. #[test] fn the_gpu_import_death_latch_skips_the_importer() { let p = negotiation_plan(NegotiationInputs { gpu_import_disabled: true, ..nvenc() }); assert!(!p.build_importer); assert!(p.gpu_import_latched); // It is NOT reported for a capture that would never have built one anyway (HDR, or a // raw passthrough) — that would misdirect the operator. assert!( !negotiation_plan(NegotiationInputs { gpu_import_disabled: true, want_hdr: true, ..nvenc() }) .gpu_import_latched ); assert!( !negotiation_plan(NegotiationInputs { gpu_import_disabled: true, ..vaapi_native_nv12() }) .gpu_import_latched ); } /// Zero-copy off ⇒ nothing at all: no importer, no passthrough, no NV12 preference. #[test] fn zerocopy_off_disables_every_branch() { for i in [ NegotiationInputs { zerocopy: false, ..nvenc() }, NegotiationInputs { zerocopy: false, ..vaapi_native_nv12() }, ] { let p = negotiation_plan(i); assert!(!p.build_importer); assert!(!p.vaapi_passthrough); assert!(!p.prefer_native_nv12); assert!(!p.want_dmabuf(false, &[0])); } } }