From 1f59498c5c1593c429da73a4b3791d1af4672432 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 00:20:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(host/linux):=20a=20no-channel=20session=20c?= =?UTF-8?q?omposites=20the=20metadata=20cursor=20=E2=80=94=20Mutter=20neve?= =?UTF-8?q?r=20embeds=20on=20a=20virtual=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture-latched client (console.rs latched_mouse) never advertises CLIENT_CAP_CURSOR, so its session resolved cursor_blend=false and asked Mutter to EMBED the pointer. On a Mutter virtual stream that is a fiction: since Mutter 48 (7ff5334a, hw-cursor inhibition removed) the software cursor overlay is suppressed stage-globally whenever any physical head realizes a HW cursor — dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no re-record either (mutter#4939). Probed on-glass on Mutter 50.3: embedded + relative motion = frozen frame counter; SPA_META_Cursor positions kept flowing in the same setup. So the no-channel session now takes the path that was verified end to end: cursor-as-metadata + the host composites, permanently — the same arm a channel session lands in after its capture-model flip. Embedded remains only the can't-blend fallback (libav VAAPI/NVENC, software). - session_plan::cursor_blend_for grows the no-channel arm (codec + depth in, the same CUDA-payload prediction handshake makes); gamescope excluded so patch-2+ keeps its native-NV12 zero-copy shape - the encode loop's composite refresh + one-shot breadcrumbs now cover the no-channel session; the park schedule keeps retrying while its composite is starved (relative-only clients cannot park themselves) - the compositor retarget re-applies set_hw_cursor — the rebuilt display used to come up EMBEDDED even for channel sessions - the GameStream virtual source takes the same rule (it never has a channel); its stream_body blend flag mirrors the request - punktfunk-probe grows --cursor-nochannel (the latched-capture client, headless); cursor-probe grows --dump (PPM frames + a content-change counter, the pixel evidence the embedded A/B lacked) Co-Authored-By: Claude Fable 5 --- clients/probe/src/main.rs | 55 +++++--- .../pf-vdisplay/src/vdisplay/linux/mutter.rs | 18 ++- .../punktfunk-host/src/gamestream/stream.rs | 55 +++++--- crates/punktfunk-host/src/native/handshake.rs | 11 +- crates/punktfunk-host/src/native/stream.rs | 127 +++++++++++++----- crates/punktfunk-host/src/session_plan.rs | 40 +++++- tools/cursor-probe/Cargo.toml | 1 + tools/cursor-probe/src/main.rs | 59 +++++++- 8 files changed, 278 insertions(+), 88 deletions(-) diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 976f57b5..e0f60bbb 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -122,6 +122,13 @@ struct Args { /// pointer-lock client expecting the HOST to composite the cursor into the video. Decode the /// dump and look for the pointer — a cursorless dump is the bug this flag was built to catch. cursor_capture: bool, + /// `--cursor-nochannel` — the same relative wiggle WITHOUT the cursor channel: no + /// `CLIENT_CAP_CURSOR`, no render-mode flip. The headless reproduction of a client LATCHED + /// in capture mode at connect (`console.rs` `latched_mouse` — it never advertises the + /// channel), the shape of the 2026-07 "no cursor in Mutter capture mode" field report. The + /// host must composite the metadata cursor on its own; decode the dump and look for the + /// pointer. + cursor_nochannel: bool, /// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS` /// seconds (default 4), print what's found, and exit. No connection is made. discover: Option, @@ -301,6 +308,7 @@ fn parse_args() -> Args { .then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)), clock_resync: argv.iter().any(|a| a == "--clock-resync"), cursor_capture: argv.iter().any(|a| a == "--cursor-capture"), + cursor_nochannel: argv.iter().any(|a| a == "--cursor-nochannel"), } } @@ -845,32 +853,37 @@ async fn session(args: Args) -> Result<()> { "SPEED TEST complete", ); }); - } else if args.cursor_capture { - // Capture-model cursor repro: flip the negotiated cursor channel to "host composites" - // and drive RELATIVE pointer motion, exactly like a pointer-lock client. Owns BOTH - // control halves: the host may send CursorShape (0x50) messages while the channel is + } else if args.cursor_capture || args.cursor_nochannel { + // Capture-model cursor repro. `--cursor-capture`: flip the negotiated cursor channel to + // "host composites" and drive RELATIVE pointer motion, exactly like a pointer-lock + // client. `--cursor-nochannel`: the same motion with NO channel at all (a + // capture-latched client) — the host must composite unprompted. Owns BOTH control + // halves: the host may send CursorShape (0x50) messages while a negotiated channel is // still in its initial client-draws state, and dropping our recv half would fail those // writes host-side. + let flip_channel = args.cursor_capture; let mut cs = send; let mut cr = recv; tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - match io::write_msg( - &mut cs, - &CursorRenderMode { - client_draws: false, - } - .encode(), - ) - .await - { - Ok(()) => tracing::info!( - "cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the host \ - must now composite the pointer into the video" - ), - Err(e) => { - tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed"); - return; + if flip_channel { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + match io::write_msg( + &mut cs, + &CursorRenderMode { + client_draws: false, + } + .encode(), + ) + .await + { + Ok(()) => tracing::info!( + "cursor-capture: CursorRenderMode {{ client_draws: false }} sent — the \ + host must now composite the pointer into the video" + ), + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "cursor-capture: render-mode write failed"); + return; + } } } // Drain (and just log) whatever the host still sends on the control stream. diff --git a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs index 1f7c8f4e..9a0dde2c 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/mutter.rs @@ -50,13 +50,19 @@ const APPLY_TEMPORARY: u32 = 1; /// the frames. The capturer always negotiates the meta (pf-capture `meta_param`) and the encoder /// blend composites it for sessions where the client does not draw the cursor itself — while a /// cursor-forwarding session strips the overlay and sends shape/state over the cursor channel. -/// Embedded mode would leave BOTH paths blind: no metadata means nothing to forward AND nothing -/// to blend, and Mutter's own embedded painting is what the pre-channel path relied on. +/// This is the mode for EVERY session whose host plans a composite or forward (`set_hw_cursor` +/// on), channel or not: Mutter's embedded painting is a fiction on a virtual stream — since +/// Mutter 48 (commit `7ff5334a`, hw-cursor inhibition removed) the software cursor overlay is +/// suppressed STAGE-GLOBALLY whenever any physical head realizes a hardware cursor, so +/// dmabuf-recorded frames blit the view without a pointer, and cursor-only motion schedules no +/// re-record either (mutter#4939). Probed on-glass (Mutter 50.3): embedded + relative motion = +/// frozen frame counter; metadata positions kept flowing in the same setup. const CURSOR_METADATA: u32 = 2; -/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself — -/// zero host-side cursor work, the pre-channel path. Chosen for every session WITHOUT the -/// negotiated cursor channel (`set_hw_cursor` off — Phase B, the Windows no-regression gate -/// mirrored); metadata stays the cursor-channel sessions' mode (shapes forwarded / host blend). +/// `cursor-mode` embedded (mutter enum 1): Mutter composites the pointer into frames itself. +/// Kept only as the can't-blend fallback (`set_hw_cursor` off — the resolved encode backend +/// cannot composite a metadata cursor, so metadata would strand the pointer in meta nothing +/// draws). Know its limits (above): on a virtual stream it paints only into the MemFd/SHM +/// record path (`FORCE_CURSORS`) and only refreshes on unrelated damage. const CURSOR_EMBEDDED: u32 = 1; /// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index c7500bba..c3b3ed5c 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -389,12 +389,14 @@ fn run( return stream_body( &mut capturer, Some(&rebuild), - // The virtual-output source never selects cursor-as-metadata (`set_hw_cursor` is - // never called → the compositor EMBEDS the pointer where it can), so the encoder - // is handed nothing to composite. gamescope remains the pointerless residual — - // its capture carries no cursor either way (the native plane's XFixes source is - // not wired on this plane). - false, + // Mirrors the source's own `set_hw_cursor` request (`open_gs_virtual_source`): + // cursor-as-metadata + host blend wherever the backend composites — the + // compositor-EMBEDS fallback never paints on a Mutter virtual stream (the + // native plane's no-channel rule, `session_plan::cursor_blend_for`). gamescope + // remains the pointerless residual — its capture carries no cursor either way + // (the native plane's XFixes source is not wired on this plane). + compositor != crate::vdisplay::Compositor::Gamescope + && blend_capable_metadata_cursor(&cfg), &sock, cfg, running, @@ -415,18 +417,7 @@ fn run( // `frame.cursor` (the caps-aware negotiation — mirror of the native plane's); otherwise ask // the portal to EMBED the pointer so no backend × cursor-mode combination streams // cursorless. Synthetic frames carry no pointer either way. - let metadata_cursor = { - #[cfg(target_os = "linux")] - { - // Same CUDA-payload prediction SessionPlan/`handshake::cursor_forward` make: - // the NVIDIA resolution plus the zero-copy master switch. - let cuda_planned = - !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled(); - crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr) - } - #[cfg(not(target_os = "linux"))] - false - }; + let metadata_cursor = blend_capable_metadata_cursor(&cfg); // Which screen this stream must show. The host-wide pin (§5.3) applies to the compat plane too: // the portal chooser cannot name a head, so a pinned host MIRRORS it here the same way the // virtual source does via `vdisplay::open`. Without this a Moonlight client on a pinned host @@ -643,6 +634,24 @@ fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { /// entry can PIN a compositor (skips the live detect/retarget). Re-run on a mid-stream capture loss to /// FOLLOW a Desktop<->Game switch: it re-detects the now-live compositor and re-targets at it. Does NOT /// launch the app (that happens once at stream start; a rebuild must not re-spawn it). +/// Cursor-as-metadata for this plane (GameStream has no cursor channel): only where the encode +/// backend this session resolves to composites `frame.cursor` — the same CUDA-payload +/// prediction `SessionPlan`/`handshake::cursor_forward` make (the NVIDIA resolution plus the +/// zero-copy master switch). Shared by the monitor-mirror and virtual-output sources so their +/// `set_hw_cursor` request and `stream_body`'s blend flag cannot drift. +fn blend_capable_metadata_cursor(cfg: &StreamConfig) -> bool { + #[cfg(target_os = "linux")] + { + let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled(); + crate::encode::cursor_blend_capable(cfg.codec, cuda_planned, cfg.hdr) + } + #[cfg(not(target_os = "linux"))] + { + let _ = cfg; + false + } +} + fn open_gs_virtual_source( cfg: StreamConfig, app: Option<&super::apps::AppEntry>, @@ -701,6 +710,16 @@ fn open_gs_virtual_source( } }; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; + // Out-of-band cursor for the virtual source (the native plane's no-channel rule, mirrored): + // GameStream has no cursor channel, and the compositor-EMBEDS fallback never paints on a + // Mutter virtual stream (stage-global overlay suppression since Mutter 48 — see + // pf-vdisplay `mutter.rs`), so ask for cursor-as-metadata wherever the resolved backend + // composites `frame.cursor`; `stream_body`'s blend flag mirrors this request. gamescope + // stays off: its capture carries no metadata either way, and the request would cost the + // native-NV12 shape for nothing. + vd.set_hw_cursor( + compositor != crate::vdisplay::Compositor::Gamescope && blend_capable_metadata_cursor(&cfg), + ); // Carry the resolved launch command on the backend instance (per-session) rather than a // process-global env var, so concurrent sessions can't stomp each other's launch target. It is // the RESOLVED command, so gamescope's bare spawn nests a library title exactly like an diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index a7146ccb..7f32ad67 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -17,10 +17,13 @@ use super::*; /// (`encode::cursor_blend_capable`): the channel's capture-mouse flip (`CursorRenderMode`, /// `client_draws = false`) makes the HOST draw the pointer, and on Linux the encoder is that /// compositing stage — granting the channel over a backend that can't blend (libav -/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied, the -/// session keeps the pre-channel path: the compositor EMBEDS the pointer and the client never -/// draws — never cursorless, never doubled. THE single predicate: the Welcome's -/// `HOST_CAP_CURSOR` bit is computed from it, and the session wiring reads that bit back. +/// VAAPI/NVENC, software) shipped a cursorless stream on every capture-mode flip. Denied — or +/// never asked for (a capture-latched client, `console.rs` `latched_mouse`) — the session +/// composites host-side anyway wherever the backend can blend +/// (`session_plan::cursor_blend_for`'s no-channel arm; the compositor-EMBEDS fallback never +/// paints on a Mutter virtual stream), and only a can't-blend backend falls back to the +/// compositor EMBED. THE single predicate: the Welcome's `HOST_CAP_CURSOR` bit is computed +/// from it, and the session wiring reads that bit back. pub(super) fn cursor_forward( client_caps: u8, compositor: Option, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index e2c4450b..0ab6818e 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1076,20 +1076,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option= 10); - // Cursor-forward sessions ask the backend for an out-of-band hardware cursor - // (Windows pf-vdisplay / IddCx; no-op on Linux — the portal already separates it). - vd.set_hw_cursor(cursor_forward); + // Out-of-band cursor request: cursor-forward sessions (Windows pf-vdisplay / + // IddCx hardware cursor; Linux metadata mode) AND no-channel host-composite + // sessions (Linux only — `metadata_composite` is `plan.cursor_blend`-gated, so + // it is always false on Windows). The backend keeps the pointer out of the + // pixels; the host blend (or the client) puts it back. + vd.set_hw_cursor(cursor_forward || metadata_composite); // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the // backend mints — the retry-hold below AND the capturer's — carries the session's quit // flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the @@ -2378,6 +2399,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option tracing::warn!(error = %format!("{e2:#}"), "capture loss: opening the newly-detected compositor failed — retrying"), @@ -2560,15 +2593,41 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option