Merge origin/main into the pf-capture sweep
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
windows-host / package (push) Successful in 10m48s
ci / rust-arm64 (push) Successful in 13m38s
decky / build-publish (push) Successful in 34s
deb / build-publish (push) Successful in 12m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
android / android (push) Successful in 17m7s
deb / build-publish-host (push) Successful in 9m49s
arch / build-publish (push) Successful in 17m59s
ci / rust (push) Successful in 21m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-client-arm64 (push) Successful in 9m55s
apple / swift (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m24s
apple / screenshots (push) Successful in 24m29s

Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same
files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of
`linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same
conflict in up to nine commits; merging resolves it once with both sides in view. The repo already
carries merge commits (`land/sweep-all`).

Two conflicts, both resolved by keeping BOTH changes:

* `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the
  sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx,
  want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3
  moved out; the split wins and upstream's change is ported to where the code now lives:
  `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't
  blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken
  verbatim.
* `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key
  (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()`
  re-pool gate. Both guard the same slot against different failures: theirs against reusing a
  session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one.

Everything else auto-merged, including the `want_metadata_cursor` thread through
`host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`.

Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on
Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:44:22 +02:00
co-authored by Claude Opus 5
19 changed files with 1172 additions and 308 deletions
+28 -12
View File
@@ -12,31 +12,46 @@ use super::*;
/// the client asked ([`CLIENT_CAP_CURSOR`](punktfunk_core::quic::CLIENT_CAP_CURSOR)) AND the
/// capture path can deliver cursor metadata separately from the frame — the Linux portal
/// `SPA_META_Cursor` path (not gamescope, whose capture paints no cursor at all), or Windows
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c). THE single
/// predicate: the Welcome's `HOST_CAP_CURSOR` bit and the session's forwarding/blend-off
/// wiring both read it, so they can never disagree.
/// with a proto-v5 pf-vdisplay driver (the IddCx hardware-cursor channel, M2c) — AND, on
/// Linux, the encode backend this session resolves to can composite the pointer on demand
/// (`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.
pub(super) fn cursor_forward(
client_caps: u8,
compositor: Option<crate::vdisplay::Compositor>,
codec: crate::encode::Codec,
bit_depth: u8,
) -> bool {
if client_caps & punktfunk_core::quic::CLIENT_CAP_CURSOR == 0 {
return false;
}
#[cfg(target_os = "linux")]
{
// CUDA-payload prediction — the same one `SessionPlan` makes: the NVIDIA resolution
// plus the zero-copy master switch. It decides direct-SDK NVENC (blends) vs libav
// NVENC (doesn't) inside the capability mirror.
let cuda_planned = !crate::encode::linux_zero_copy_is_vaapi() && crate::zerocopy::enabled();
compositor.is_some_and(|c| c != crate::vdisplay::Compositor::Gamescope)
&& crate::encode::cursor_blend_capable(codec, cuda_planned, bit_depth == 10)
}
#[cfg(target_os = "windows")]
{
// Windows (M2c): the pf-vdisplay driver must speak the v5 hardware-cursor channel —
// DWM composites the pointer into the IDD frame otherwise, and forwarding a second
// copy would double it. The probe latches by opening the control device once.
let _ = compositor;
// copy would double it. The probe latches by opening the control device once. The
// encoder is deliberately NOT consulted: the IDD capturer itself composites on the
// capture-mouse flip (`set_cursor_forward`), so no Windows encode backend blends.
let _ = (compositor, codec, bit_depth);
crate::vdisplay::manager::hw_cursor_capable()
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
let _ = compositor;
let _ = (compositor, codec, bit_depth);
false
}
}
@@ -493,9 +508,10 @@ pub(super) async fn negotiate(
0
}
// Cursor channel granted (client asked + this capture path can deliver cursor
// metadata out of the frame) — the client turns its local renderer on ONLY when
// it sees this bit, and serve_session wires forwarding from the same predicate.
| if cursor_forward(hello.client_caps, compositor) {
// metadata out of the frame + the resolved encoder can composite on the
// capture-mouse flip) — the client turns its local renderer on ONLY when it sees
// this bit, and serve_session wires forwarding by reading the bit back.
| if cursor_forward(hello.client_caps, compositor, codec, bit_depth) {
punktfunk_core::quic::HOST_CAP_CURSOR
} else {
0
@@ -541,9 +557,9 @@ pub(super) async fn negotiate(
let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::<SessionContext>(1);
let client_identity = endpoint::peer_fingerprint(conn);
let client_hdr = hello.display_hdr;
// Same predicate the Welcome's HOST_CAP_CURSOR bit used — the prepared display and
// the session wiring must agree with what we just advertised.
let cursor_fw = cursor_forward(hello.client_caps, Some(comp));
// The bit the Welcome just advertised — read back rather than recomputed, so the
// prepared display and the session wiring cannot disagree with it.
let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0;
let (mode, shard_payload) = (hello.mode, welcome.shard_payload);
let trace = bringup.clone();
std::thread::Builder::new()
+15 -5
View File
@@ -1022,8 +1022,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// 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.
ctx.compositor == pf_vdisplay::Compositor::Gamescope || ctx.cursor_forward,
// 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.)
crate::session_plan::cursor_blend_for(
ctx.cursor_forward,
ctx.compositor == pf_vdisplay::Compositor::Gamescope,
),
ctx.cursor_forward,
);
// gamescope: the XFixes cursor source feeds the always-on composite (Phase C). Set after
@@ -2098,8 +2103,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// capture-mode channel); a switch AWAY restores the prior
// gating. `plan` is `Copy` — this is the value the rebuild
// (and its `build_pipeline` attach) reads.
plan.cursor_blend = plan.cursor_forward
|| c == crate::vdisplay::Compositor::Gamescope;
plan.cursor_blend = crate::session_plan::cursor_blend_for(
plan.cursor_forward,
c == crate::vdisplay::Compositor::Gamescope,
);
plan.gamescope_cursor =
c == crate::vdisplay::Compositor::Gamescope;
gamescope_composite =
@@ -2987,7 +2994,10 @@ pub(super) fn prepare_display(
// 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.
compositor == pf_vdisplay::Compositor::Gamescope || cursor_forward,
crate::session_plan::cursor_blend_for(
cursor_forward,
compositor == pf_vdisplay::Compositor::Gamescope,
),
cursor_forward,
);
plan.gamescope_cursor = compositor == pf_vdisplay::Compositor::Gamescope;