diff --git a/crates/pf-capture/src/linux/pw_cursor.rs b/crates/pf-capture/src/linux/pw_cursor.rs index 9219ad61..0b45a951 100644 --- a/crates/pf-capture/src/linux/pw_cursor.rs +++ b/crates/pf-capture/src/linux/pw_cursor.rs @@ -35,6 +35,10 @@ pub(super) struct CursorState { /// channel (the blend path uses the pre-adjusted `x`/`y` and never reads it). hot_x: i32, hot_y: i32, + /// One-shot breadcrumb latch: this stream saw a `SPA_META_Cursor` region (the Meta param + /// 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, } impl CursorState { @@ -93,6 +97,13 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy if meta.is_null() { return; } + // One-shot breadcrumb (per stream): the producer DID attach a cursor-meta region (the Meta + // param negotiated). Field triage for a cursorless stream starts by grepping for this line + // — its absence means the negotiation dropped the meta, not that the pointer never moved. + if !cursor.seen_meta { + cursor.seen_meta = true; + tracing::info!("cursor meta: first SPA_META_Cursor region observed on this stream"); + } // SAFETY: `meta` is non-null and points into the held buffer's metadata array. let (region_size, data) = unsafe { ((*meta).size as usize, (*meta).data as *const u8) }; if data.is_null() || region_size < std::mem::size_of::() { @@ -180,6 +191,12 @@ pub(super) fn update_cursor_meta(cursor: &mut CursorState, spa_buf: *mut spa::sy cursor.bw = bw; cursor.bh = bh; cursor.serial = cursor.serial.wrapping_add(1); + // One-shot sibling of the region breadcrumb above (per stream, via the serial's 0→1 edge): + // the first BITMAP — before this line has fired, `overlay()` is `None` and every + // blend/forward path is cursorless by construction. + if cursor.serial == 1 { + tracing::info!(w = bw, h = bh, "cursor meta: first cursor bitmap received"); + } } /// The byte range inside the producer's cursor-meta region that a `bh`-row, `row`-wide, @@ -349,6 +366,7 @@ mod tests { serial: 1, hot_x: 0, hot_y: 0, + seen_meta: true, } } diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 0080f2a5..d3337699 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1151,6 +1151,10 @@ async fn serve_session( // whichever arrives. let (input_tx, input_rx) = std::sync::mpsc::channel::(); let rich_tx = input_tx.clone(); + // The stream loop's handle into the same pipeline: it parks the seat pointer on the + // streamed surface (stream.rs `park_pointer`) through exactly the path client input takes. + #[cfg(target_os = "linux")] + let input_tx_stream = input_tx.clone(); let input_handle = { let conn = conn.clone(); let gamepad = welcome.gamepad; @@ -1557,6 +1561,8 @@ async fn serve_session( client_hdr, bringup: bringup_dp, resize_ms: resize_ms_dp, + #[cfg(target_os = "linux")] + input_tx: input_tx_stream, }; match prep { // P1.1: the display prep started at Welcome on its own thread — hand it diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 081ffb93..c0d51dcd 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1019,6 +1019,45 @@ pub(super) struct SessionContext { /// Shared slot the latest completed mid-stream resize total (ms) lands in — registered with /// `session_status` so the Dashboard shows it. pub(super) resize_ms: Arc, + /// The session's input pipeline (the same channel client datagrams feed) — the stream loop + /// uses it to PARK the seat pointer on the streamed surface (see [`park_pointer`]). + #[cfg(target_os = "linux")] + pub(super) input_tx: std::sync::mpsc::Sender, +} + +/// Park the seat pointer at the centre of the streamed surface, through the SAME injection path +/// client input takes (capability routing, region ladder, anchor — everything). +/// +/// Why this exists (the GNOME capture-mode cursor bug, 2026-07): a Linux virtual output is +/// created fresh per session, and the seat pointer stays wherever it last was — usually on a +/// physical monitor. A capture-model (pointer-lock) client sends only RELATIVE deltas, so +/// nothing ever moves the pointer INTO the streamed output: its input lands on the wrong +/// monitor, and on compositors that only embed/report the cursor while it is over the recorded +/// view (Mutter suppresses `SPA_META_Cursor` entirely — `should_cursor_metadata_be_set` — and +/// its embedded mode paints nothing either) the stream has NO cursor at all, in both the +/// embedded and the cursor-channel composite models. Parking once per (re)built display — and +/// again on the mid-stream flip to the capture model, which heals a pointer that drifted off the +/// output's edge — pins the pointer to the surface the client actually sees. A desktop-model +/// client overrides it with its first absolute move, so the jump is invisible in practice. +#[cfg(target_os = "linux")] +fn park_pointer(input_tx: &std::sync::mpsc::Sender, w: u32, h: u32) { + let ev = punktfunk_core::input::InputEvent { + kind: punktfunk_core::input::InputKind::MouseMoveAbs, + _pad: [0; 3], + code: 0, + x: (w / 2) as i32, + y: (h / 2) as i32, + // MouseMoveAbs packs its reference extent into `flags` — the injector's region ladder + // matches the streamed output by exactly these dims. + flags: (w << 16) | (h & 0xffff), + }; + if input_tx.send(super::input::ClientInput::Event(ev)).is_ok() { + tracing::info!( + w, + h, + "parked the seat pointer at the streamed surface's centre" + ); + } } pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option) -> Result<()> { @@ -1106,6 +1145,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option { diag_repeat += 1; // no new frame (static desktop / mid-rebuild) — repeat the last @@ -2420,6 +2491,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option { + if !composite_saw_overlay { + composite_saw_overlay = true; + tracing::info!( + x = live.x, + y = live.y, + w = live.w, + h = live.h, + visible = live.visible, + "host-composite: first live cursor overlay handed to the \ + encoder blend" + ); + } + frame.cursor = Some(live); + } + None => { + if !composite_saw_none { + composite_saw_none = true; + tracing::info!( + "host-composite active but the capture has no live cursor \ + overlay yet (no SPA_META_Cursor bitmap) — the stream is \ + cursorless until one arrives" + ); + } + } + } } } } else if gamescope_composite { @@ -2461,6 +2571,34 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option= next_park_at + { + let composite_starved = cursor_fwd.is_some() + && !cursor_client_draws.load(Ordering::Relaxed) + && capturer.cursor().is_none(); + if park_attempts < 2 || composite_starved { + park_pointer(&input_tx, frame.width, frame.height); + park_attempts += 1; + next_park_at = std::time::Instant::now() + std::time::Duration::from_secs(1); + } else { + // Settled (overlay flowing, or the client draws): stop scheduling until a + // rebuild or a capture-model flip re-arms it. + park_attempts = PARK_ATTEMPTS_MAX; + } + } if perf && diag_at.elapsed() >= std::time::Duration::from_secs(2) { let secs = diag_at.elapsed().as_secs_f64(); tracing::info!(