fix(host/linux): a no-channel session composites the metadata cursor — Mutter never embeds on a virtual stream
ci / web (push) Successful in 3m26s
ci / docs-site (push) Successful in 3m32s
arch / build-publish (push) Failing after 4m29s
apple / swift (push) Successful in 4m38s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / rust-arm64 (push) Failing after 4m50s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish (push) Failing after 5m6s
ci / rust (push) Failing after 5m47s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 34s
android / android (push) Canceled after 8m16s
apple / screenshots (push) Canceled after 3m46s
deb / build-publish-host (push) Canceled after 4m15s
deb / build-publish-client-arm64 (push) Canceled after 4m7s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m58s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 8m21s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:35:25 +02:00
co-authored by Claude Fable 5
parent 9ed967cbaf
commit 1f59498c5c
8 changed files with 278 additions and 88 deletions
+34 -21
View File
@@ -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<u64>,
@@ -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.
@@ -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
+37 -18
View File
@@ -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<GsApp> {
/// 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
@@ -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<crate::vdisplay::Compositor>,
+95 -32
View File
@@ -1076,20 +1076,20 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
ctx.bit_depth,
ctx.chroma,
ctx.codec,
// Blend CAPABILITY for cursor-FORWARD sessions (Phase B, the Windows gate mirrored):
// their client can flip to the capture mouse model mid-stream (`CursorRenderMode`), and
// the composite side of that flip must not need an encoder rebuild — WHETHER a frame's
// pointer is drawn stays per-tick (the encode loop strips `frame.cursor` while the client
// draws locally, see the forwarder tick). Non-channel NON-gamescope sessions get the
// pointer compositor-EMBEDDED (`vd.set_hw_cursor(false)` → no cursor metadata, nothing to
// blend), keeping the zero-cost pre-channel path. gamescope is the exception (Phase C):
// it can't embed the pointer, so the host ALWAYS composites the XFixes-sourced cursor —
// the blend must be built for every gamescope session. (`cursor_forward` is already
// blend-gated: `handshake::cursor_forward` grants the channel only where
// `encode::cursor_blend_capable` says the resolved backend composites.)
// Blend CAPABILITY (the single rule in `cursor_blend_for`): cursor-FORWARD sessions
// need it for the mid-stream capture-mouse flip (`CursorRenderMode` — WHETHER a
// frame's pointer is drawn stays per-tick, the encode loop strips `frame.cursor`
// while the client draws locally); gamescope (Phase C) can't embed a pointer, so the
// host always composites the XFixes-sourced cursor; and a NO-channel session gets
// metadata + host blend too wherever the backend composites — the compositor-EMBEDS
// fallback streams cursorless on a Mutter virtual output (the overlay-visibility
// gate is stage-global since Mutter 48; see `cursor_blend_for`'s doc). Embedded
// remains only the can't-blend fallback.
crate::session_plan::cursor_blend_for(
ctx.cursor_forward,
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
ctx.codec,
ctx.bit_depth,
),
ctx.cursor_forward,
);
@@ -1187,6 +1187,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
if gamescope_composite {
tracing::info!("gamescope cursor: compositing the XFixes-sourced pointer into the video");
}
// No-channel metadata composite: the client never draws the pointer (it did not advertise
// the cursor channel — e.g. a capture-latched client, `console.rs` `latched_mouse`), and
// the compositor-EMBEDS fallback is a fiction on a Mutter virtual stream (the software
// cursor overlay is suppressed stage-globally whenever any physical head realizes a HW
// cursor, Mutter 48+ — dmabuf frames blit the view WITHOUT it, and cursor-only motion
// schedules no update either, mutter#4939). So the plan asked the backend for
// cursor-as-metadata and the HOST composites, permanently — the same arm a channel
// session lands in after its capture-model flip, minus the channel.
// `mut`: recomputed with `gamescope_composite` on a mid-stream compositor retarget.
let mut metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& compositor != pf_vdisplay::Compositor::Gamescope;
if metadata_composite {
tracing::info!(
"no cursor channel — compositing the metadata cursor into the video (embedded \
fallback is unreliable on virtual streams)"
);
}
if streamed_wire {
// Client capability only — whether AUs actually stream per-slice depends on the encoder
// backend's `supports_chunked_poll()` (today: Linux direct-NVENC only), which doesn't
@@ -1231,9 +1249,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// it with the HDR flags so nested games get HDR surfaces at all. Decided in the
// Welcome (`capture::capturer_supports_hdr_for`), so it cannot change under us.
vd.set_hdr(bit_depth >= 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<PreparedDispl
plan.cursor_blend = crate::session_plan::cursor_blend_for(
plan.cursor_forward,
c == crate::vdisplay::Compositor::Gamescope,
plan.codec,
plan.bit_depth,
);
plan.gamescope_cursor =
crate::session_plan::gamescope_cursor_for(
@@ -2385,6 +2408,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
);
gamescope_composite =
plan.gamescope_cursor && cursor_fwd.is_none();
metadata_composite = cursor_fwd.is_none()
&& plan.cursor_blend
&& c != crate::vdisplay::Compositor::Gamescope;
// The retargeted backend starts with `hw_cursor`
// unset — without re-applying the session's
// out-of-band cursor request, the rebuilt display
// would come up EMBEDDED: double-drawn for a
// desktop-model channel client, cursorless for
// every host-composite session.
vd.set_hw_cursor(plan.cursor_forward || metadata_composite);
}
Err(e2) => 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<PreparedDispl
}
}
}
} else if gamescope_composite {
// gamescope (Phase C): no channel, host always composites. Refresh the (repeat or new)
// frame's overlay from the capturer's LIVE cursor — the XFixes source publishes there
// — so pointer-only motion on a static gamescope UI re-blends at tick rate instead of
// freezing at the last damage frame (the same reason the composite arm above re-reads
// it). A grabbed/hidden pointer arrives `visible: false` and is stripped just below.
} else if gamescope_composite || metadata_composite {
// No channel, host always composites: gamescope (Phase C — the XFixes source
// publishes on `capturer.cursor()`) and the metadata-composite session (the portal
// `SPA_META_Cursor` live overlay publishes there too). Refresh the (repeat or new)
// frame's overlay from the capturer's LIVE cursor so pointer-only motion on a
// static desktop re-blends at tick rate instead of freezing at the last damage
// frame (the same reason the channel's composite arm above re-reads it). A
// grabbed/hidden pointer arrives `visible: false` and is stripped just below.
#[cfg(not(target_os = "windows"))]
if let Some(live) = capturer.cursor() {
frame.cursor = Some(live);
match capturer.cursor() {
Some(live) => {
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"
);
}
}
}
}
// The overlay surfaces hidden pointers too (for the hint above) — strip them
@@ -2579,10 +2638,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// The seat-pointer park schedule (state + rationale at the declarations above; armed by
// the first frame of every (re)built display and by the capture-model flip). The first
// two attempts run unconditionally — attempt 1 can be swallowed by a cold EIS
// connection. Past those, only a cursor-channel session in the capture model that STILL
// has no live overlay keeps trying: that combination means the pointer has not reached
// the streamed output (the compositor reports cursor metadata only while it is over the
// recorded view), and a relative-only client cannot get it there on its own.
// connection. Past those, only a host-composite session that STILL has no live overlay
// keeps trying — a channel session in the capture model, or a no-channel
// metadata-composite session (both relative-only): no overlay there means the pointer
// has not reached the streamed output (the compositor reports cursor metadata only
// while it is over the recorded view), and a relative-only client cannot get it there
// on its own.
// Armed from the loop's first tick — a static desktop may never deliver a fresh frame
// (`parked_display` is only bookkeeping for rebuild re-arming), and the pointer must be
// parked regardless.
@@ -2591,8 +2652,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
&& park_attempts < PARK_ATTEMPTS_MAX
&& std::time::Instant::now() >= next_park_at
{
let composite_starved = cursor_fwd.is_some()
&& !cursor_client_draws.load(Ordering::Relaxed)
let composite_starved = ((cursor_fwd.is_some()
&& !cursor_client_draws.load(Ordering::Relaxed))
|| metadata_composite)
&& capturer.cursor().is_none();
if park_attempts < 2 || composite_starved {
park_pointer(&input_tx, frame.width, frame.height);
@@ -3406,13 +3468,14 @@ pub(super) fn prepare_display(
bit_depth,
chroma,
codec,
// Blend capability — must MATCH virtual_stream's resolve (Phase B: non-channel
// non-gamescope sessions get the pointer compositor-EMBEDDED, nothing to blend; the
// mid-stream `CursorRenderMode` flip strips/keeps `frame.cursor` per tick for channel
// sessions). gamescope (Phase C) can't embed → always composites the XFixes cursor.
// Blend capability — must MATCH virtual_stream's resolve. Windows-only path, where
// the rule is a constant `false` (the IDD capturer composites itself); passed through
// the shared rule anyway so the two resolves cannot drift.
crate::session_plan::cursor_blend_for(
cursor_forward,
compositor == pf_vdisplay::Compositor::Gamescope,
codec,
bit_depth,
),
cursor_forward,
);
+34 -6
View File
@@ -108,7 +108,9 @@ pub struct SessionPlan {
pub wire_chunk: Option<usize>,
/// The session may hand the encoder cursor bitmaps to composite (cursor-as-metadata
/// captures). Set via [`cursor_blend_for`] — the single platform rule — so it is `true` only
/// where the ENCODER is the compositing stage (Linux cursor-forward and gamescope sessions);
/// where the ENCODER is the compositing stage (Linux: cursor-forward sessions, gamescope,
/// AND no-channel sessions on a blend-capable backend — the compositor-EMBEDS fallback is
/// broken on Mutter virtual streams, see [`cursor_blend_for`]);
/// Windows is always `false` (the IDD capturer composites the pointer itself). Encoders
/// whose fast path cannot blend (the Vulkan EFC RGB-direct source, native NV12) stay off
/// those shapes when this is set — see [`Self::output_format`] and
@@ -234,20 +236,46 @@ pub(crate) fn resolve_topology() -> SessionTopology {
/// THE rule for [`SessionPlan::cursor_blend`], shared by every resolve caller (initial plan and
/// the mid-stream compositor re-gate) so they can't drift:
/// * **Linux**: the encoder is the compositing stage — blend for a cursor-forward session (the
/// capture-mouse flip needs the host composite on demand) and for gamescope (its capture
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video).
/// capture-mouse flip needs the host composite on demand), for gamescope (its capture
/// carries no pointer at all; the XFixes-sourced cursor must be drawn into the video), AND
/// for a no-channel session whenever the resolved backend can composite. The pre-channel
/// "compositor EMBEDS the pointer" fallback is a fiction on a Mutter virtual stream:
/// cursor-only motion never re-records the stream (probed on-glass, Mutter 50.3 — frames
/// froze the instant motion went relative while `SPA_META_Cursor` kept updating), so a
/// capture-latched client (which never advertises `CLIENT_CAP_CURSOR`, `console.rs`
/// `latched_mouse`) streamed cursorless. Metadata + host blend is the path that was
/// verified end-to-end; embedded remains only the can't-blend fallback (libav
/// VAAPI/NVENC, software).
/// * **Windows**: never — the IDD capturer composites the pointer itself (`cursor_blend.rs` /
/// DWM), and no Windows encode backend reads `frame.cursor`. Asking the encoder anyway made
/// `open_video`'s blends-cursor backstop fire spuriously on every cursor-channel session.
pub(crate) fn cursor_blend_for(cursor_forward: bool, gamescope: bool) -> bool {
pub(crate) fn cursor_blend_for(
cursor_forward: bool,
gamescope: bool,
codec: crate::encode::Codec,
bit_depth: u8,
) -> bool {
#[cfg(target_os = "windows")]
{
let _ = (cursor_forward, gamescope);
let _ = (cursor_forward, gamescope, codec, bit_depth);
false
}
#[cfg(not(target_os = "windows"))]
{
cursor_forward || gamescope_needs_host_cursor(gamescope)
if gamescope {
// gamescope's capture carries no SPA_META_Cursor; the blend-capable term below
// must not apply, or a patch-2+ gamescope (composites its own pointer) would lose
// its native-NV12 zero-copy shape for a blend that can never receive an overlay.
return gamescope_needs_host_cursor(true);
}
if cursor_forward {
return true;
}
// No cursor channel: the same CUDA-payload prediction `handshake::cursor_forward` and
// the GameStream monitor mirror make — the NVIDIA resolution plus the zero-copy master
// switch — deciding direct-SDK NVENC (blends) vs libav NVENC (doesn't).
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
}
}
+1
View File
@@ -16,6 +16,7 @@ repository.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
pf-vdisplay = { path = "../../crates/pf-vdisplay" }
pf-capture = { path = "../../crates/pf-capture" }
pf-frame = { path = "../../crates/pf-frame" }
pf-inject = { path = "../../crates/pf-inject" }
punktfunk-core = { path = "../../crates/punktfunk-core", features = ["quic"] }
anyhow = "1"
+58 -1
View File
@@ -153,6 +153,19 @@ mod linux {
}
});
let dump = args.iter().any(|a| a == "--dump");
let dump_dir = format!(
"/tmp/pf-probe-frames-{}",
if embedded { "embedded" } else { "metadata" }
);
if dump {
let _ = std::fs::remove_dir_all(&dump_dir);
let _ = std::fs::create_dir_all(&dump_dir);
}
let mut prev: Vec<u8> = Vec::new();
let mut changed = 0u64;
let mut dumped = 0u32;
let deadline = Instant::now() + Duration::from_secs(secs);
let (mut frames, mut with_overlay) = (0u64, 0u64);
let mut last_report = Instant::now();
@@ -161,6 +174,24 @@ mod linux {
// Damage-driven source: timeouts (gaps) are normal, hence the missing error arm.
if let Ok(f) = cap.next_frame_within(Duration::from_millis(500)) {
frames += 1;
if let pf_frame::FramePayload::Cpu(data) = &f.payload {
if !prev.is_empty()
&& prev.len() == data.len()
&& prev.as_slice() != data.as_slice()
{
changed += 1;
}
prev.clear();
prev.extend_from_slice(data);
if dump && frames % 15 == 1 && dumped < 16 {
dumped += 1;
let path = format!("{dump_dir}/frame-{frames:05}.ppm");
match dump_ppm(&path, f.width, f.height, f.format, data) {
Ok(()) => println!("cursor-probe: dumped {path}"),
Err(e) => eprintln!("cursor-probe: dump failed: {e:#}"),
}
}
}
if let Some(c) = &f.cursor {
with_overlay += 1;
if with_overlay == 1 {
@@ -195,9 +226,13 @@ mod linux {
let _ = injector.join();
println!("---");
println!(
"cursor-probe: content-changed frames: {changed}/{frames} (a moving embedded \
cursor over a static desktop should change nearly every frame)"
);
if embedded {
println!(
"cursor-probe: EMBEDDED run — no metadata expected; check the stream visually. \
"cursor-probe: EMBEDDED run — no metadata expected; check the dumped frames. \
frames={frames}"
);
} else if live_seen || with_overlay > 0 {
@@ -214,4 +249,26 @@ mod linux {
}
Ok(())
}
/// Write a packed 4-bpp frame as a PPM, swapping B/R for the BGR layouts. Enough fidelity
/// to answer "is the pointer in the pixels".
fn dump_ppm(
path: &str,
w: u32,
h: u32,
format: pf_frame::PixelFormat,
data: &[u8],
) -> Result<()> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(path)?);
write!(out, "P6\n{w} {h}\n255\n")?;
let (ri, gi, bi) = match format {
pf_frame::PixelFormat::Rgbx | pf_frame::PixelFormat::Rgba => (0usize, 1usize, 2usize),
_ => (2usize, 1usize, 0usize),
};
for px in data.chunks_exact(4).take((w as usize) * (h as usize)) {
out.write_all(&[px[ri], px[gi], px[bi]])?;
}
Ok(())
}
}