fix(capture/kwin): a hidden cursor leaves the stream — KWin's id-0 meta is the hide

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 21:54:09 +02:00
co-authored by Claude Fable 5
parent c2a6d30d7b
commit 5174a59832
10 changed files with 125 additions and 14 deletions
+6 -1
View File
@@ -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<Box<dyn Capturer>> {
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<dyn Capturer>)
}
+14 -1
View File
@@ -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<OwnedFd>,
@@ -328,6 +338,7 @@ impl PortalCapturer {
want_hdr: bool,
policy: ZeroCopyPolicy,
expect_exact_dims: bool,
cursor_id0_hides: bool,
) -> Result<PortalCapturer> {
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,
)?
+2 -1
View File
@@ -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 {
+75 -8
View File
@@ -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]
+10
View File
@@ -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<Box<dyn Capturer>> {
// 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<Box<dyn Capturer>> {
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<Box<dyn Capturer>> {
anyhow::bail!("virtual-output capture requires Linux or Windows")
}
+1
View File
@@ -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);
@@ -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);
+13 -3
View File
@@ -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.
+1
View File
@@ -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")?
}
+1
View File
@@ -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);