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
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:
+34
-21
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user