Files
punktfunk/crates/punktfunk-host/src/capture.rs
T
enricobuehler 9c6e06d3b9 feat(host): GameStream is now a cargo feature — WP19, compile-time isolation
A new 'gamestream' feature (default ON — every stock package is behaviorally
identical, and GameStream stays runtime-opt-in via --gamestream /
PUNKTFUNK_GAMESTREAM) gates the whole Moonlight-protocol surface: control
(the ENet plane), rtsp, nvhttp, pairing, serverinfo, the _nvstream mDNS
advert, the compat media path (stream/video/audio), pen/gamepad/input
decode, apps, crypto, cert (the RSA identity), and tls's
Moonlight-client-cert leniency. AppState keeps the shared vocabulary
unconditional and cfg-gates the Moonlight-only fields; the mgmt API's PIN
endpoints (routes, handlers, OpenAPI entries, lane classifications, tests)
exist only under the feature.

Building --no-default-features --features pyrowave yields the hardened
NATIVE-ONLY host: no rusty_enet (the c2rust-transpiled C ENet stack, 158
unsafe sites) and no rsa (the identity split's legacy fallback became a
pem-only read — rustls/ring serves an existing RSA cert without the crate —
so the accepted Marvin advisory no longer applies to native-only builds).
Both claims are ASSERTED, not assumed: a new CI leg keeps the native-only
flavor clippy-clean and fails if cargo tree finds either crate in its graph.
serve --gamestream (or the env knob) against such a binary refuses to start
with a clear error rather than serving less than the operator configured.

En route: the logs-paging test assumed a quiet process-global log ring
between its cursors and raced other tests' legitimate log lines (the
identity tests added new emitters) — it now asserts on its own markers
within the page.

Gates: Linux amd64 — BOTH flavors clippy --all-targets -D warnings clean;
default tests identity 3/3, mgmt 37/37, gamestream 59/59; native-only tests
identity 3/3, mgmt 35/35, residue 4/4; rusty_enet+rsa absent native-only,
present default. .133 Windows — both flavors clippy clean (clean-first,
sentinel-checked), tree claims hold, and the WP0 port-lifecycle functional
gate PASSES on the default build.
2026-08-11 22:05:30 +02:00

316 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Frame capture facade (plan §7 / §W6). The capturers themselves live in the `pf-capture`
//! subsystem crate; this host module is the thin BRIDGE that (a) re-exports the shared frame
//! vocabulary + the capturer types so every `crate::capture::*` path is unchanged, and (b) keeps
//! the orchestration entry points — [`open_portal_monitor`] / [`capture_virtual_output`] — which
//! know about `crate::{vdisplay, session_plan, inject, encode}` and hand pf-capture the pre-resolved
//! facts it needs (the [`pf_capture::ZeroCopyPolicy`] and, on Windows, the
//! [`pf_capture::FrameChannelSender`]) so the capturer never reaches back into the orchestrator.
use anyhow::Result;
// The shared frame vocabulary lives in `pf-frame`; re-export the pieces host modules still name via
// `crate::capture::*` (the capture mechanics that used the rest moved into pf-capture).
pub use pf_frame::{CapturedFrame, OutputFormat};
// `PixelFormat` is named through `crate::capture::` only by the GameStream media path; the Linux
// pyrowave-modifier plumbing below uses it in-module. Off both (a native-only Windows build,
// WP19), the re-export would be dead and -D warnings rejects it.
#[cfg(any(target_os = "linux", feature = "gamestream"))]
pub use pf_frame::PixelFormat;
// The capturer types + trait + synthetics live in `pf-capture`; re-export them at the old paths.
// `capturer_supports_hdr` is deliberately NOT re-exported: on Linux it is only the platform floor,
// and a caller reaching for it by that name would silently miss the gamescope arm. The host's
// answer is [`capturer_supports_hdr_for`] below.
pub use pf_capture::{capturer_supports_444, Capturer, SyntheticCapturer};
// Only the GameStream compat media path uses the fast synthetic source (WP19).
#[cfg(feature = "gamestream")]
pub use pf_capture::FastSyntheticCapturer;
// `crate::capture::dxgi::{install_gpu_pref_hook, hdr_p010_selftest_at}` (main.rs subcommands) and
// `crate::capture::synthetic_nv12` resolve through pf-capture's Windows modules.
#[cfg(target_os = "windows")]
pub use pf_capture::{dxgi, synthetic_nv12};
/// Resolve the [`pf_capture::ZeroCopyPolicy`] for a Linux capture session from the encode backend —
/// the one reach into `crate::encode` the capturer must NOT make itself (it would recreate the
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
/// (plan §2.4 / §W6).
#[cfg(target_os = "linux")]
fn zero_copy_policy(
pyrowave_session: bool,
native_nv12_session: bool,
) -> pf_capture::ZeroCopyPolicy {
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
// global `PUNKTFUNK_ENCODER=pyrowave` lab lever (which also flips `backend_is_vaapi`).
#[cfg(feature = "pyrowave")]
let pyrowave_session =
pyrowave_session || pf_host_config::config().encoder_pref.as_str() == "pyrowave";
#[cfg(not(feature = "pyrowave"))]
let pyrowave_session = {
let _ = pyrowave_session;
false
};
#[cfg(feature = "pyrowave")]
let pyrowave_modifiers = if pyrowave_session {
// BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys
// on it). `drm_fourcc(Bgrx)` is always `Some`.
pf_frame::drm_fourcc(PixelFormat::Bgrx)
.map(crate::encode::pyrowave_capture_modifiers)
.unwrap_or_default()
} else {
Vec::new()
};
#[cfg(not(feature = "pyrowave"))]
let pyrowave_modifiers = Vec::new();
pf_capture::ZeroCopyPolicy {
backend_is_vaapi,
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
pyrowave_session,
pyrowave_modifiers,
native_nv12_session,
// Only the direct-SDK NVENC backend takes a packed 10-bit PQ CUDA payload; without it an
// HDR capture must stay on the CPU path (libav's HDR route swscales into a P010 hardware
// frame). Resolved here, in the facade, like every other encode fact capture is told.
hdr_cuda_ok: pf_encode::linux_hdr_cuda_ok(),
}
}
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal. `want_hdr`
/// offers the GNOME 50+ 10-bit PQ/BT.2020 formats (pass it only when the session negotiated HDR
/// AND the mirrored monitor is in HDR mode — see [`pf_capture::gnome_hdr_monitor_active`]).
/// `want_metadata_cursor` asks for cursor-as-metadata — pass it only when the session's encode
/// backend composites `CapturedFrame::cursor` (`encode::cursor_blend_capable`); otherwise the
/// portal embeds the pointer, so no backend × cursor-mode combination streams cursorless.
#[cfg(target_os = "linux")]
pub fn open_portal_monitor(
want_hdr: bool,
want_metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
// On RemoteDesktop-capable desktops (KWin/GNOME) anchor ScreenCast to a RemoteDesktop
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
// so use a plain ScreenCast session there.
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
pf_capture::open_portal_monitor(
anchored,
want_hdr,
want_metadata_cursor,
zero_copy_policy(false, false),
)
}
#[cfg(not(target_os = "linux"))]
pub fn open_portal_monitor(
_want_hdr: bool,
_want_metadata_cursor: bool,
) -> Result<Box<dyn Capturer>> {
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
}
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]).
/// Explodes the output into the primitives pf-capture needs (so the capturer never depends on the
/// vdisplay type); the capturer takes the keepalive, so dropping it releases the output.
#[cfg(target_os = "linux")]
pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput,
want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
// The output's compositor rewrites `SPA_META_Cursor` on every buffer (KWin), so an id-0 meta
// is an authoritative "pointer hidden" — the caller derives it from the backend that created
// `vout` (which also covers registry-pooled reuse: a kept display only ever matches its own
// backend). See `pf_capture`'s `cursor_id0_hides` contract.
cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
// The portal negotiates its own pixel format, so `want.gpu` gates GPU zero-copy capture (the
// capture backend is always the portal — the `CaptureBackend` arg is a Windows-only dispatch)
// and `want.chroma_444` selects the worker's planar-YUV444 GPU convert. `gpu = false` (4:4:4
// without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident RGB to swscale
// into YUV444P.
//
// `want.hdr` runs the 10-bit PQ/BT.2020 offer. It is only ever set for a gamescope output off
// our `pipewire-hdr` build — every other Linux virtual output is SDR-only upstream — and the
// handshake already resolved that through [`capturer_supports_hdr_for`] before the Welcome,
// so passing it through here is the whole of this arm's HDR logic. It used to be dropped on
// the floor, which is what kept the Linux native plane at 8 bits.
pf_capture::open_virtual_output(
vout.remote_fd,
vout.node_id,
vout.preferred_mode,
vout.keepalive,
want.gpu,
want.chroma_444,
want.hdr,
zero_copy_policy(want.pyrowave, want.nv12_native),
vout.expect_exact_dims,
cursor_id0_hides,
)
}
/// Can the NATIVE-plane capture source this session will drive deliver a 10-bit PQ/BT.2020 frame?
/// The capture-side half of the punktfunk/1 bit-depth gate (`native::handshake`), and the single
/// source-aware answer — `pf_capture::capturer_supports_hdr()` alone cannot answer it on Linux,
/// where it depends on which compositor is resolved and which gamescope binary is installed.
///
/// **Must be truthful, because the Welcome is irrevocable**: `bit_depth` is decided before the
/// display exists, and PQ frames handed to an 8-bit encoder are a deliberate hard error
/// (`pf-encode/src/enc/linux/mod.rs`). So every term here is a STATIC fact resolvable before the
/// spawn — never "spawn it and find out".
///
/// - **Windows**: the IDD-push capturer proactively enables advanced colour → the platform answer.
/// - **Linux + gamescope**: true when the host knob allows it, the resolved gamescope binary
/// offers 10-bit BT.2020/PQ capture formats (`packaging/gamescope`), the sub-mode is one we
/// SPAWN (an attach to a foreign gamescope tells us nothing about how it was started — §3.6
/// stretch), and no earlier virtual-output HDR negotiation on this host has latched a downgrade.
/// - **Linux, anything else**: false. Mutter/KWin/wlroots virtual outputs are 8-bit upstream. The
/// other Linux HDR path — the GNOME 50+ portal monitor mirror — belongs to the GameStream plane
/// and is gated by `gamestream::host_hdr_capable` + the live monitor colour-mode probe instead.
pub fn capturer_supports_hdr_for(compositor: Option<crate::vdisplay::Compositor>) -> bool {
#[cfg(target_os = "linux")]
{
if compositor == Some(crate::vdisplay::Compositor::Gamescope) {
return pf_host_config::config().gamescope_hdr
&& pf_vdisplay::gamescope_hdr_available()
&& !pf_capture::hdr_capture_failed(pf_capture::HdrSource::VirtualOutput);
}
}
let _ = compositor;
pf_capture::capturer_supports_hdr()
}
#[cfg(target_os = "windows")]
pub fn capture_virtual_output(
vout: crate::vdisplay::VirtualOutput,
want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
// Linux-only fact (the PipeWire cursor-meta contract); the IDD-push path has no
// `SPA_META_Cursor` and its own CURSOR_SUPPRESSED hide source.
_cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
let target = vout.win_capture.clone().ok_or_else(|| {
anyhow::anyhow!(
"pf-vdisplay target not yet an active display path (activation failed — see the \
virtual-display warnings above)"
)
})?;
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
// degraded to the keep-physicals fallback) — the pen-offset field bug.
crate::inject::set_stream_target(Some(target.target_id));
let pref = vout.preferred_mode;
let keep = vout.keepalive;
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE and wrap
// `send_frame_channel` in a `Send + Sync` closure the IDD-push capturer calls at ring attach.
// This is the ONE reach into `crate::vdisplay` the capturer would otherwise make; building it
// here keeps the capture→vdisplay dependency out of pf-capture (plan §W6).
let control = crate::vdisplay::manager::control_device_handle().ok_or_else(|| {
anyhow::anyhow!(
"pf-vdisplay control device not open (monitor not created via the manager?)"
)
})?;
// Each closure keeps its own `Arc<OwnedHandle>` clone (`Send + Sync`), so the handle is open
// for exactly as long as any delivery closure lives — and CLOSES once the manager retires it
// and the last session drops, which is what lets the wake-from-sleep recovery's PnP device
// cycle proceed (an open control handle vetoes it).
let control_frame = control.clone();
let sender: pf_capture::FrameChannelSender = std::sync::Arc::new(
move |req: &pf_driver_proto::control::SetFrameChannelRequest| {
// SAFETY: the captured `control_frame` Arc keeps the control handle open across this
// call — `send_frame_channel`'s precondition.
unsafe {
crate::vdisplay::driver::send_frame_channel(
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_frame),
),
req,
)
}
},
);
// IDD direct-push is the sole Windows capture path: consume frames straight from the pf-vdisplay
// driver's shared ring (in-process — no Desktop Duplication, no WGC helper). The host itself runs
// as SYSTEM in the active interactive console session (1+), spawned there by the session-0 SCM
// supervisor (`windows/service.rs`), which is what lets it capture the secure desktop too.
// A FRESH monitor + ring is created per session. `want.hdr`
// proactively enables advanced color and selects the per-frame conversion. There is NO fallback:
// if it can't open or the driver doesn't attach, the session fails cleanly and the client
// reconnects.
// Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure —
// its presence opts the session in (the capturer creates + delivers the CursorShm section,
// the driver declares the IddCx hardware cursor). Built exactly like `sender` above.
let control_cursor = control.clone();
let cursor_sender: Option<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
std::sync::Arc::new(
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
// SAFETY: the captured `control_cursor` Arc keeps the control handle open across
// this call (`send_cursor_channel`'s precondition).
unsafe {
crate::vdisplay::driver::send_cursor_channel(
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control_cursor),
),
req,
)
}
},
) as pf_capture::CursorChannelSender
});
// The secure-desktop guard's actuator (`IOCTL_SET_CURSOR_FORWARD`): the capturer flips the
// driver's hardware-cursor declare off while UAC/Winlogon is up (the secure desktop renders
// only through the OS's software-cursor path) and back on at dismissal. The stand-down needs
// the same-mode re-commit that actualises the software-cursor default — driven here because
// topology commits belong under the vdisplay manager's lock, which pf-capture cannot take.
// Built for EVERY session (not just `want.hw_cursor`): a channel-less session can reuse a
// driver monitor whose cursor worker (an earlier session's) is still live and re-declaring —
// the flip is the only way to stop it; on a never-declared target the driver answers
// NOT_FOUND, which the capturer logs and ignores.
let target_id = target.target_id;
let cursor_forward: Option<pf_capture::CursorForwardSender> = Some({
std::sync::Arc::new(move |enable: bool| {
let req = pf_driver_proto::control::SetCursorForwardRequest {
target_id,
enable: enable as u32,
};
// SAFETY: the captured `control` Arc keeps the control handle open across this call
// (`send_cursor_forward`'s precondition).
unsafe {
crate::vdisplay::driver::send_cursor_forward(
windows::Win32::Foundation::HANDLE(
std::os::windows::io::AsRawHandle::as_raw_handle(&*control),
),
&req,
)?;
}
if !enable {
crate::vdisplay::manager::force_recommit();
}
Ok(())
}) as pf_capture::CursorForwardSender
});
pf_capture::open_idd_push(
target,
pref,
want.hdr,
want.chroma_444,
want.pyrowave,
keep,
sender,
cursor_sender,
cursor_forward,
)
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
pub fn capture_virtual_output(
_vout: crate::vdisplay::VirtualOutput,
_want: OutputFormat,
_capture: crate::session_plan::CaptureBackend,
_cursor_id0_hides: bool,
) -> Result<Box<dyn Capturer>> {
anyhow::bail!("virtual-output capture requires Linux or Windows")
}