From 5174a5983269b0f814df14596ef98d7e9cca58d1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 10:04:56 +0200 Subject: [PATCH] =?UTF-8?q?fix(capture/kwin):=20a=20hidden=20cursor=20leav?= =?UTF-8?q?es=20the=20stream=20=E2=80=94=20KWin's=20id-0=20meta=20is=20the?= =?UTF-8?q?=20hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the 0.22.0 cursor work (the seat-pointer park + the metadata composite), a KWin capture-model stream always has a cursor — and it never went away again: not in game, not in Big Picture, not with a controller in hand (field report, 2026-08-01). The host blended the arrow forever because pf-capture deliberately ignores SPA_META_Cursor id 0, and once `visible` latched true nothing on Linux ever cleared it. Two producer contracts meet on id 0, and one flag now carries which one a stream follows. KWin rewrites the cursor meta on EVERY enqueued buffer and writes id 0 whenever Cursor::isOnOutput says the pointer is not in this stream — which covers both a globally hidden cursor and a client null-cursor surface (empty geometry intersects nothing). There id 0 IS the hide, and honoring it is what lets a game hide the pointer mid-stream. Mutter only rewrites a buffer's meta when the cursor changed, so recycled buffers carry stale id-0 regions between damage frames — honoring those flickered the cursor off between hovers (on-glass round 5), and that path keeps its last-known-state behavior. The flag rides from the backend that created the output (correct for registry-pooled reuse too — a kept display only ever matches its own backend) through capture_virtual_output into the parser's CursorState. The portal-monitor path stays on the stale-meta contract: the only thing routed through it today is Mutter's HDR mirror. Verified on .25: pf-capture 45/45, punktfunk-host 369/369, clippy -D warnings clean (pf-capture, punktfunk-host, cursor-probe), fmt clean. On-glass KDE validation still owed. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/lib.rs | 7 +- crates/pf-capture/src/linux/mod.rs | 15 +++- crates/pf-capture/src/linux/pipewire.rs | 3 +- crates/pf-capture/src/linux/pw_cursor.rs | 83 +++++++++++++++++-- crates/punktfunk-host/src/capture.rs | 10 +++ crates/punktfunk-host/src/devtest.rs | 1 + .../punktfunk-host/src/gamestream/stream.rs | 2 + crates/punktfunk-host/src/native/stream.rs | 16 +++- crates/punktfunk-host/src/spike.rs | 1 + tools/cursor-probe/src/main.rs | 1 + 10 files changed, 125 insertions(+), 14 deletions(-) diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 4298b2c6..afc9e7b6 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -612,7 +612,10 @@ pub fn open_portal_monitor( /// 10-bit PQ/BT.2020 formats instead of the SDR set — pass it only when the output was actually /// brought up HDR (a gamescope spawned with `--hdr-enabled` off our `pipewire-hdr` build); the /// host resolves that in `capture::capturer_supports_hdr_for` **before** the Welcome, because a -/// session that negotiated PQ cannot fall back to SDR afterwards. +/// session that negotiated PQ cannot fall back to SDR afterwards. `cursor_id0_hides` declares the +/// producer's cursor-meta contract — pass it for outputs whose compositor rewrites +/// `SPA_META_Cursor` on every buffer (KWin), where an `id == 0` meta is an authoritative +/// "pointer hidden" the composited/forwarded cursor must honor. #[cfg(target_os = "linux")] #[allow(clippy::too_many_arguments)] pub fn open_virtual_output( @@ -625,6 +628,7 @@ pub fn open_virtual_output( want_hdr: bool, policy: ZeroCopyPolicy, expect_exact_dims: bool, + cursor_id0_hides: bool, ) -> Result> { linux::PortalCapturer::from_virtual_output( remote_fd, @@ -636,6 +640,7 @@ pub fn open_virtual_output( want_hdr && !hdr_capture_failed(HdrSource::VirtualOutput), policy, expect_exact_dims, + cursor_id0_hides, ) .map(|c| Box::new(c) as Box) } diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 7dc129de..65e7c5b1 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -72,6 +72,11 @@ struct CaptureOpts { /// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and /// gamescope fixates its own — gating those would starve legitimate first frames). expect_exact_dims: bool, + /// The producer rewrites `SPA_META_Cursor` on EVERY buffer, so an `id == 0` meta is an + /// authoritative "pointer hidden / off this output" the blend must honor (KWin). `false` for + /// the stale-meta producers (Mutter recycles buffers without rewriting the region) — see + /// [`pw_cursor::CursorState::id0_hides`](pw_cursor) for the full contract. + cursor_id0_hides: bool, } /// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of @@ -301,6 +306,10 @@ impl PortalCapturer { want_444: false, want_hdr, expect_exact_dims: false, + // The portal-monitor path today is Mutter (the GNOME HDR mirror) — the stale-meta + // id-0 contract. A KDE portal capture would rewrite per buffer, but nothing routes + // one through here yet; the virtual-output path below carries the real flag. + cursor_id0_hides: false, }, policy, )? @@ -316,7 +325,8 @@ impl PortalCapturer { /// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the /// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB. /// `want_hdr` runs the 10-bit PQ/BT.2020 offer instead of the SDR set — see - /// [`crate::open_virtual_output`] for who is allowed to pass it. + /// [`crate::open_virtual_output`] for who is allowed to pass it. `cursor_id0_hides` declares + /// the producer's cursor-meta contract ([`CaptureOpts::cursor_id0_hides`]). #[allow(clippy::too_many_arguments)] pub fn from_virtual_output( remote_fd: Option, @@ -328,6 +338,7 @@ impl PortalCapturer { want_hdr: bool, policy: ZeroCopyPolicy, expect_exact_dims: bool, + cursor_id0_hides: bool, ) -> Result { tracing::info!( node_id, @@ -335,6 +346,7 @@ impl PortalCapturer { want_444, want_hdr, expect_exact_dims, + cursor_id0_hides, "connecting PipeWire to virtual output" ); // Most virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise @@ -350,6 +362,7 @@ impl PortalCapturer { want_444, want_hdr, expect_exact_dims, + cursor_id0_hides, }, policy, )? diff --git a/crates/pf-capture/src/linux/pipewire.rs b/crates/pf-capture/src/linux/pipewire.rs index 5b86f19e..37b17757 100644 --- a/crates/pf-capture/src/linux/pipewire.rs +++ b/crates/pf-capture/src/linux/pipewire.rs @@ -811,6 +811,7 @@ pub fn pipewire_thread( want_444, want_hdr, expect_exact_dims, + cursor_id0_hides, .. } = opts; crate::pwinit::ensure_init(); @@ -985,7 +986,7 @@ pub fn pipewire_thread( yuv444: want_444, linear_nv12_failed: false, dbg_log_n: 0, - cursor: CursorState::default(), + cursor: CursorState::new(cursor_id0_hides), expect_dims: if expect_exact_dims { preferred.map(|(w, h, _)| (w, h)) } else { diff --git a/crates/pf-capture/src/linux/pw_cursor.rs b/crates/pf-capture/src/linux/pw_cursor.rs index 0b45a951..dcdcddd0 100644 --- a/crates/pf-capture/src/linux/pw_cursor.rs +++ b/crates/pf-capture/src/linux/pw_cursor.rs @@ -39,9 +39,23 @@ pub(super) struct CursorState { /// negotiated). Per-stream deliberately — a host serves many sessions per process, and a /// process-wide latch made the second session's triage read as "no meta". seen_meta: bool, + /// This stream's producer rewrites the cursor meta on EVERY buffer, so an `id == 0` meta is + /// an authoritative "pointer hidden / off this output" rather than a stale recycled region. + /// True for KWin virtual outputs; false for the stale-meta producers (Mutter) — see + /// [`note_cursor_id`]. + id0_hides: bool, } impl CursorState { + /// The per-stream state, declaring which `id == 0` contract the producer follows + /// ([`Self::id0_hides`]). + pub(super) fn new(id0_hides: bool) -> CursorState { + CursorState { + id0_hides, + ..CursorState::default() + } + } + /// A shareable overlay for the encode/forward paths, or `None` before the first bitmap /// arrived. A HIDDEN pointer still yields `Some` (with `visible: false`): the /// cursor-forward channel needs "known but hidden" — an app grabbed the pointer, the @@ -79,6 +93,31 @@ pub(super) fn decode_bitmap_pixel(vfmt: u32, s: &[u8]) -> (u8, u8, u8, u8) { } } +/// Apply one parsed `spa_meta_cursor.id` to the visibility state; returns whether the rest of the +/// meta region (position, bitmap) is worth parsing. +/// +/// Two producer contracts meet on `id == 0`. **KWin** rewrites the cursor meta on EVERY enqueued +/// buffer, and writes id 0 whenever `Cursor::isOnOutput` says the pointer is not in this stream — +/// which covers a globally hidden cursor AND a client null-cursor surface (empty cursor geometry +/// intersects nothing). There id 0 is the authoritative hide, and honoring it is what lets a game +/// or Big Picture hide the pointer mid-stream ([`CursorState::id0_hides`], set for KWin virtual +/// outputs; without it the composited arrow outlived every hide — the 0.22.0 field report). +/// **Mutter** only rewrites a buffer's meta region when the cursor changed, so recycled buffers +/// between damage frames carry a stale id-0 meta — treating that as hidden flickered the cursor +/// off between hovers (on-glass round 5). There the last-known state holds, and a pointer that +/// really left/hid simply stops producing updates (the M3 hidden hint has no Mutter signal — +/// Windows has its own CURSOR_SUPPRESSED source). +fn note_cursor_id(cursor: &mut CursorState, id: u32) -> bool { + if id == 0 { + if cursor.id0_hides { + cursor.visible = false; + } + return false; + } + cursor.visible = true; + true +} + /// Update `cursor` from the newest buffer's `SPA_META_Cursor` (no-op when the buffer carries no /// cursor meta — producer doesn't support it, or the portal isn't in Metadata cursor mode). /// Called for EVERY dequeued buffer, before the stale-frame skip, so pointer-only movements @@ -121,16 +160,9 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy (*cur).bitmap_offset, ) }; - if id == 0 { - // SPA contract: id 0 = "no cursor information", NOT "cursor hidden". Mutter only - // REWRITES a buffer's meta region when the cursor changed, so recycled buffers - // between damage frames carry a stale id-0 meta — treating that as hidden flickered - // the cursor off between hovers (on-glass round 5). Keep the last-known state; a - // pointer that really left/hid simply stops producing updates. (The M3 hidden hint - // loses its Mutter signal — Windows has its own CURSOR_SUPPRESSED source.) + if !note_cursor_id(cursor, id) { return; } - cursor.visible = true; cursor.x = pos_x - hot_x; cursor.y = pos_y - hot_y; cursor.hot_x = hot_x; @@ -367,9 +399,44 @@ mod tests { hot_x: 0, hot_y: 0, seen_meta: true, + id0_hides: false, } } + // ---- note_cursor_id: the two producer id-0 contracts -------------------------------------- + + #[test] + fn id_zero_hides_only_on_a_rewriting_producer() { + // KWin contract (`id0_hides`): id 0 is written fresh on every buffer, so it IS the hide — + // a game or Big Picture hiding the pointer must reach the stream. + let mut kwin = cursor(10, 10, 8, 8, (255, 255, 255), 255); + kwin.id0_hides = true; + assert!(!note_cursor_id(&mut kwin, 0), "id 0 parses no further"); + let o = kwin.overlay().expect("bitmap stays cached across a hide"); + assert!(!o.visible, "KWin id 0 must hide the overlay"); + // The pointer coming back re-shows the SAME cached bitmap. + assert!(note_cursor_id(&mut kwin, 1)); + assert!(kwin.overlay().expect("still cached").visible); + + // Mutter contract: recycled buffers carry stale id-0 metas — the last-known state holds + // (honoring them flickered the cursor off between hovers, on-glass round 5). + let mut mutter = cursor(10, 10, 8, 8, (255, 255, 255), 255); + assert!(!note_cursor_id(&mut mutter, 0)); + assert!( + mutter.overlay().expect("cached").visible, + "a stale-meta producer's id 0 must NOT hide" + ); + } + + #[test] + fn id_zero_before_any_bitmap_yields_no_overlay() { + // A KWin stream whose pointer was never on the output: hides arrive before any bitmap — + // `overlay()` must stay `None` (nothing to blend), not a phantom empty cursor. + let mut c = CursorState::new(true); + assert!(!note_cursor_id(&mut c, 0)); + assert!(c.overlay().is_none()); + } + // ---- bitmap_extent: the guard whose absence SIGSEGVs uncatchably ------------------------- #[test] diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index bef9b31e..d4226972 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -110,6 +110,11 @@ pub fn capture_virtual_output( vout: crate::vdisplay::VirtualOutput, want: OutputFormat, _capture: crate::session_plan::CaptureBackend, + // The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta + // is an authoritative "pointer hidden" — the caller derives it from the backend that created + // `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own + // backend). See `pf_capture`'s `cursor_id0_hides` contract. + cursor_id0_hides: bool, ) -> Result> { // The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the // capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch) @@ -132,6 +137,7 @@ pub fn capture_virtual_output( want.hdr, zero_copy_policy(want.pyrowave, want.nv12_native), vout.expect_exact_dims, + cursor_id0_hides, ) } @@ -171,6 +177,9 @@ pub fn capture_virtual_output( vout: crate::vdisplay::VirtualOutput, want: OutputFormat, _capture: crate::session_plan::CaptureBackend, + // Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no + // `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source. + _cursor_id0_hides: bool, ) -> Result> { let target = vout.win_capture.clone().ok_or_else(|| { anyhow::anyhow!( @@ -285,6 +294,7 @@ pub fn capture_virtual_output( _vout: crate::vdisplay::VirtualOutput, _want: OutputFormat, _capture: crate::session_plan::CaptureBackend, + _cursor_id0_hides: bool, ) -> Result> { anyhow::bail!("virtual-output capture requires Linux or Windows") } diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index d6ee6df8..0a8b646a 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -559,6 +559,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> { vout, fmt, crate::session_plan::CaptureBackend::resolve(), + compositor == crate::vdisplay::Compositor::Kwin, ) .context("attach a capturer to the mirrored monitor")?; cap.set_active(true); diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index ff40e1d7..37c7476f 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -570,6 +570,7 @@ fn open_gs_mirror_source( vout, pf_frame::OutputFormat::resolve(cfg.hdr, crate::zerocopy::enabled()), crate::session_plan::CaptureBackend::resolve(), + compositor == crate::vdisplay::Compositor::Kwin, ) .context("attach a capturer to the mirrored monitor") } @@ -782,6 +783,7 @@ fn open_gs_virtual_source( vout, capture::OutputFormat::resolve(cfg.hdr, crate::encode::resolved_backend_is_gpu()), crate::session_plan::CaptureBackend::resolve(), + compositor == crate::vdisplay::Compositor::Kwin, ) .context("capture virtual output")?; capturer.set_active(true); diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 5905efd6..39feb264 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -4122,9 +4122,19 @@ fn build_pipeline( // VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2; // otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends, // which auto-detect HDR from the monitor state.) - let mut capturer = - crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) - .context("capture virtual output")?; + // + // KWin rewrites `SPA_META_Cursor` on every buffer, so its id-0 metas are an authoritative + // "pointer hidden" the cursor blend/forward must honor — without this, the composited arrow + // outlives every in-game/Big Picture hide (0.22.0 field report). Derived from the backend + // (correct for pooled reuse too — a kept display only matches its own backend). + let cursor_id0_hides = vd.name() == pf_vdisplay::Compositor::Kwin.id(); + let mut capturer = crate::capture::capture_virtual_output( + vout, + plan.output_format(), + plan.capture, + cursor_id0_hides, + ) + .context("capture virtual output")?; // gamescope (Phase C): gamescope paints no `SPA_META_Cursor`, so hand the capturer a way to // reach gamescope's nested Xwaylands — it reads the pointer over X11 (XFixes shape + // QueryPointer position) and feeds `cursor()`, which the encode loop composites. diff --git a/crates/punktfunk-host/src/spike.rs b/crates/punktfunk-host/src/spike.rs index bfb69a20..33b1169f 100644 --- a/crates/punktfunk-host/src/spike.rs +++ b/crates/punktfunk-host/src/spike.rs @@ -118,6 +118,7 @@ pub fn run(opts: Options) -> Result<()> { vout, capture::OutputFormat::resolve(false, crate::encode::resolved_backend_is_gpu()), crate::session_plan::CaptureBackend::resolve(), + compositor == crate::vdisplay::Compositor::Kwin, ) .context("capture virtual output")? } diff --git a/tools/cursor-probe/src/main.rs b/tools/cursor-probe/src/main.rs index 8e35dade..275c9c30 100644 --- a/tools/cursor-probe/src/main.rs +++ b/tools/cursor-probe/src/main.rs @@ -88,6 +88,7 @@ mod linux { false, policy, vout.expect_exact_dims, + compositor == pf_vdisplay::Compositor::Kwin, ) .context("attach the PipeWire capturer")?; cap.set_active(true);