diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 98b07e5..d043d98 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -315,8 +315,10 @@ pub fn open_portal_monitor() -> Result> { pub fn capture_virtual_output( vout: crate::vdisplay::VirtualOutput, _want_hdr: bool, + _capture: crate::session_plan::CaptureBackend, ) -> Result> { - // The Linux host stays 8-bit (HDR is blocked upstream), so `want_hdr` is unused here. + // The Linux host stays 8-bit (HDR is blocked upstream), so `want_hdr` is unused here; the capture + // backend is always the portal (the `CaptureBackend` arg is a Windows-only dispatch — ignored here). linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box) } @@ -334,7 +336,9 @@ pub(crate) fn wgc_disabled() -> bool { pub fn capture_virtual_output( vout: crate::vdisplay::VirtualOutput, want_hdr: bool, + capture: crate::session_plan::CaptureBackend, ) -> Result> { + use crate::session_plan::CaptureBackend; let target = vout.win_capture.clone().ok_or_else(|| { anyhow::anyhow!( "SudoVDA target not yet an active display (needs a WDDM GPU to activate it)" @@ -343,9 +347,10 @@ pub fn capture_virtual_output( let pref = vout.preferred_mode; let keep = vout.keepalive; // P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared - // ring — no Desktop Duplication, no win32u reparenting hook. Opt-in while it's A/B'd against DDA; - // `idd_push` takes the keepalive (owns the virtual display) so there's no fall-through. - if crate::config::config().idd_push { + // ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan` + // (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual + // display) so there's no fall-through. + if capture == CaptureBackend::IddPush { // Recreate the monitor + ring per session (fix-teardown): a FRESH monitor reliably gets a // working IddCx swap-chain, whereas a REUSED monitor's swap-chain dies after ~2 sessions and // the host can't revive it. The driver's recreate crash (target id resolved to 0) is fixed by @@ -370,9 +375,9 @@ pub fn capture_virtual_output( // overlay/independent-flip planes DXGI Desktop Duplication misses (the frozen-HDR-animation bug), // and has no ACCESS_LOST-on-overlay churn. DDA stays available via PUNKTFUNK_CAPTURE=dda and is // the secure-desktop (lock/UAC) fallback (WGC can't capture those). `keep` is moved into the - // chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. - let backend = crate::config::config().capture_backend.as_str(); - if backend == "dda" || backend == "dxgi" || wgc_disabled() { + // chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The + // backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan. + if capture == CaptureBackend::Dda { return dxgi::DuplCapturer::open(target, pref, keep, false) .map(|c| Box::new(c) as Box); } @@ -418,6 +423,7 @@ pub fn capture_virtual_output( pub fn capture_virtual_output( _vout: crate::vdisplay::VirtualOutput, _want_hdr: bool, + _capture: crate::session_plan::CaptureBackend, ) -> Result> { anyhow::bail!("virtual-output capture requires Linux or Windows") } diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index d761c73..55ba6db 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -134,8 +134,12 @@ fn run( // IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push // is wired only for punktfunk/1 (want_hdr = negotiated bit_depth >= 10). TODO: derive want_hdr // from a GameStream HDR flag once StreamConfig carries one. - let mut capturer = - capture::capture_virtual_output(vout, false).context("capture virtual output")?; + let mut capturer = capture::capture_virtual_output( + vout, + false, + crate::session_plan::CaptureBackend::resolve(), + ) + .context("capture virtual output")?; capturer.set_active(true); return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range); } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 9caf20b..44d2f92 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -15,8 +15,8 @@ #![allow(dead_code)] mod audio; -mod config; mod capture; +mod config; mod discovery; #[cfg(target_os = "linux")] mod dmabuf_fence; @@ -35,6 +35,7 @@ mod punktfunk1; mod pwinit; #[cfg(target_os = "windows")] mod service; +mod session_plan; mod session_tuning; mod spike; mod vdisplay; diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 2cbcd32..584c3a2 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -2184,12 +2184,18 @@ fn virtual_stream( // This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or // tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission. boost_thread_priority(true); + // Resolve the per-session capture / topology / encoder decision ONCE (Goal-1 stage 3): the deployed + // path now reads this typed `SessionPlan` instead of re-deriving from config at each dispatch site + // (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `bit_depth` is the + // only per-session input — capture/topology/encoder are otherwise pure functions of `HostConfig`. + let plan = crate::session_plan::SessionPlan::resolve(bit_depth); + tracing::info!(?plan, "resolved session plan"); // Windows two-process secure-desktop path: when the host runs as SYSTEM (required for the secure // desktop + SendInput), WGC can't activate in-process, so we capture the normal desktop via a // helper spawned in the user session and relay its AUs. (Single-process WGC/DDA is used as the // user, and stays the path on Linux.) See docs/windows-secure-desktop.md. #[cfg(target_os = "windows")] - if should_use_helper() { + if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay { return virtual_stream_relay( session, mode, @@ -2220,11 +2226,11 @@ fn virtual_stream( // driver-churning teardown of a monitor under a still-live session. Register THIS session's stop so // the next reconnect preempts it. #[cfg(target_os = "windows")] - let idd_setup_guard = crate::config::config() - .idd_push - .then(|| IDD_SETUP_LOCK.lock().unwrap()); + let idd_push_session = plan.capture == crate::session_plan::CaptureBackend::IddPush; #[cfg(target_os = "windows")] - if crate::config::config().idd_push { + let idd_setup_guard = idd_push_session.then(|| IDD_SETUP_LOCK.lock().unwrap()); + #[cfg(target_os = "windows")] + if idd_push_session { let prev = IDD_SESSION_STOP.lock().unwrap().replace(stop.clone()); if let Some(prev_stop) = prev { prev_stop.store(true, Ordering::SeqCst); @@ -2233,7 +2239,7 @@ fn virtual_stream( } let mut vd = crate::vdisplay::open(compositor)?; let (mut capturer, mut enc, mut frame, mut interval) = - build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth)?; + build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). #[cfg(target_os = "windows")] drop(idd_setup_guard); @@ -2362,6 +2368,7 @@ fn virtual_stream( cur_mode, bitrate_kbps, bit_depth, + plan, )?; Ok((new_vd, pipe)) })(); @@ -2405,7 +2412,7 @@ fn virtual_stream( // Build the new pipeline BEFORE dropping the old one: the host already acked // the switch as accepted, so a rebuild failure must not kill an otherwise // healthy session — keep streaming the current mode and log instead. - match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth) { + match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan) { Ok(next_pipe) => { (capturer, enc, frame, interval) = next_pipe; cur_mode = new_mode; @@ -2450,7 +2457,7 @@ fn virtual_stream( tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds, "capture lost — rebuilding pipeline in place"); let (new_cap, new_enc, new_frame, new_interval) = - build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth) + build_pipeline_with_retry(&mut vd, cur_mode, bitrate_kbps, bit_depth, plan) .context("rebuild after capture loss")?; capturer = new_cap; enc = new_enc; @@ -2569,29 +2576,6 @@ fn virtual_stream( Ok(()) } -/// Should this host take the two-process (SYSTEM host + user-session WGC helper) path? Yes when it's -/// running as SYSTEM — the only account that can capture the secure desktop + drive SendInput on it, -/// and the account under which in-process WGC won't activate. `PUNKTFUNK_FORCE_HELPER` forces it on -/// (for testing the relay as a normal user); `PUNKTFUNK_NO_HELPER` forces it off. `PUNKTFUNK_NO_WGC` -/// also forces it off — that mode runs pure single-process DDA (one capturer for the normal AND secure -/// desktop, Apollo-style), which has no WGC helper to relay. -#[cfg(target_os = "windows")] -fn should_use_helper() -> bool { - let cfg = crate::config::config(); - if cfg.no_helper || crate::capture::wgc_disabled() { - return false; - } - // IDD direct-push captures IN-PROCESS in Session 0: the pf-vdisplay driver delivers frames to the - // SYSTEM host's session via shared memory and NVENC is headless, so no user-session WGC helper is - // needed for VIDEO (and a Session-1 helper couldn't open the Session-0 shared textures anyway). - // NOTE: input injection (SendInput) from Session 0 can't reach the user's Session-1 desktop yet — - // a known follow-up; this path validates the video transport. See docs/windows-virtual-display-rust-port.md. - if cfg.idd_push { - return false; - } - cfg.force_helper || crate::capture::wgc_relay::running_as_system() -} - /// Windows two-process video stream: the SYSTEM host creates the SudoVDA virtual output (and holds /// its keepalive = the sole topology/isolation owner), spawns the WGC helper in the user session to /// capture+encode the NORMAL desktop, and relays the helper's AUs onto the QUIC data plane via the @@ -3041,6 +3025,7 @@ fn build_pipeline_with_retry( mode: punktfunk_core::Mode, bitrate_kbps: u32, bit_depth: u8, + plan: crate::session_plan::SessionPlan, ) -> Result { // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take @@ -3050,7 +3035,7 @@ fn build_pipeline_with_retry( const MAX_ATTEMPTS: u32 = 8; let mut backoff = std::time::Duration::from_millis(500); for attempt in 1..=MAX_ATTEMPTS { - match build_pipeline(vd, mode, bitrate_kbps, bit_depth) { + match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) { Ok(pipe) => { if attempt > 1 { tracing::info!(attempt, "pipeline up after retry"); @@ -3109,6 +3094,7 @@ fn build_pipeline( mode: punktfunk_core::Mode, bitrate_kbps: u32, bit_depth: u8, + plan: crate::session_plan::SessionPlan, ) -> Result { let vout = vd.create(mode).context("create virtual output")?; // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a @@ -3131,7 +3117,7 @@ fn build_pipeline( // VIDEO_CAP_10BIT + host opted in via PUNKTFUNK_10BIT) is our HDR path → BT.2020 PQ Rgb10a2; // otherwise the FP16 IDD frames are converted to 8-bit SDR. (Ignored by non-IDD-push backends, // which auto-detect HDR from the monitor state.) - let mut capturer = crate::capture::capture_virtual_output(vout, bit_depth >= 10) + let mut capturer = crate::capture::capture_virtual_output(vout, plan.hdr, plan.capture) .context("capture virtual output")?; capturer.set_active(true); let frame = capturer.next_frame().context("first frame")?; diff --git a/crates/punktfunk-host/src/session_plan.rs b/crates/punktfunk-host/src/session_plan.rs new file mode 100644 index 0000000..2c7fa32 --- /dev/null +++ b/crates/punktfunk-host/src/session_plan.rs @@ -0,0 +1,155 @@ +//! `SessionPlan` — the per-session capture / topology / encoder decision, resolved **once** from +//! [`HostConfig`](crate::config) (+ the handshake-negotiated bit depth) into a typed, logged value. +//! +//! **Goal-1 stage 3** (`docs/windows-host-goal1-plan.md`): before this, the Windows session decision was +//! re-derived at three call sites — the capture backend inside `capture::capture_virtual_output`, the +//! process topology in `punktfunk1::should_use_helper`, and the encode backend in +//! `encode::windows_resolved_backend` — each reading [`config`](crate::config) independently, with no +//! single owner (the latent "capture and encode disagree on the backend" hazard, plan §2.4). `SessionPlan` +//! resolves them together, once, so the deployed path reads one typed artifact. +//! +//! Stage 3 routes the **capture** and **topology** decisions through the plan (see +//! `capture::capture_virtual_output` taking [`CaptureBackend`] in, and `virtual_stream` reading +//! [`SessionTopology`]). The **encoder** is resolved by `encode::windows_resolved_backend` (config-backed +//! and GPU-vendor cached since stage 2, so already a single source) and *recorded* here as +//! [`EncoderBackend`]. Threading `encoder`/`input_format` into the encoder + capturer opens — which +//! removes the `capture → encode::windows_resolved_backend()` back-reference recomputed in `dxgi.rs` — +//! is **stage 5**. +//! +//! The type is platform-neutral so it threads through the shared `virtual_stream`/`build_pipeline` +//! signatures; on Linux it resolves to the single portal/single-process path (the 3-way dispatch is a +//! Windows-only concern). + +/// Where a session's frames come from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CaptureBackend { + /// Linux: the xdg ScreenCast portal → PipeWire (the only Linux capture path). + Portal, + /// Windows: IDD direct-push — frames pulled straight from the pf-vdisplay driver's shared ring + /// (in-process, Session 0; no Desktop Duplication, no WGC helper). + IddPush, + /// Windows: DXGI Desktop Duplication (`PUNKTFUNK_CAPTURE=dda|dxgi` or `PUNKTFUNK_NO_WGC`). + Dda, + /// Windows: Windows.Graphics.Capture (the composed-desktop default), with a DDA watchdog fallback. + Wgc, +} + +impl CaptureBackend { + /// Resolve the capture backend from [`config`](crate::config). This is the single resolver shared by + /// [`SessionPlan::resolve`] and the standalone callers (GameStream / spike), so they can't drift. + #[cfg(target_os = "linux")] + pub fn resolve() -> Self { + CaptureBackend::Portal + } + + /// Windows precedence (identical to the pre-stage-3 `capture_virtual_output` branch order): + /// IDD-push wins; else an explicit `dda`/`dxgi` request or `PUNKTFUNK_NO_WGC` selects DDA; else WGC. + #[cfg(target_os = "windows")] + pub fn resolve() -> Self { + let cfg = crate::config::config(); + if cfg.idd_push { + CaptureBackend::IddPush + } else if matches!(cfg.capture_backend.as_str(), "dda" | "dxgi") + || crate::capture::wgc_disabled() + { + CaptureBackend::Dda + } else { + CaptureBackend::Wgc + } + } + + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + pub fn resolve() -> Self { + CaptureBackend::Portal + } +} + +/// How a session is structured across processes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionTopology { + /// One process captures + encodes (Linux; Windows non-SYSTEM / IDD-push / `NO_WGC`). + SingleProcess, + /// SYSTEM host + a user-session WGC helper relay (the Windows normal-desktop path under SYSTEM, + /// where in-process WGC can't activate). See `virtual_stream_relay`. + TwoProcessRelay, +} + +/// The resolved encode backend (recorded for logging / stages 4–5; the per-session encoder open still +/// resolves via `encode::windows_resolved_backend`, which is config-backed + GPU-vendor cached). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EncoderBackend { + /// Linux: NVENC vs VAAPI is auto-detected inside `encode::open_video` (not modeled here). + PlatformAuto, + Nvenc, + Amf, + Qsv, + Software, +} + +/// The per-session decision, resolved once. `Copy` so it threads through the capture/encode chain +/// without ceremony (stage 4 folds it, with the rest of the arg soup, into a `SessionContext`). +#[derive(Clone, Copy, Debug)] +pub struct SessionPlan { + pub capture: CaptureBackend, + pub topology: SessionTopology, + pub encoder: EncoderBackend, + /// Handshake-negotiated encode bit depth (8, or 10 = HEVC Main10). + pub bit_depth: u8, + /// The IDD-push HDR hint (`bit_depth >= 10`) — the want-HDR flag the capturer was passed before. + /// Non-IDD-push Windows backends ignore it and auto-detect HDR from the monitor; Linux is 8-bit. + pub hdr: bool, +} + +impl SessionPlan { + /// Resolve the whole plan once from [`config`](crate::config) + the negotiated `bit_depth`. + pub fn resolve(bit_depth: u8) -> Self { + SessionPlan { + capture: CaptureBackend::resolve(), + topology: resolve_topology(), + encoder: resolve_encoder(), + bit_depth, + hdr: bit_depth >= 10, + } + } +} + +/// Process topology. On Windows this is the former `punktfunk1::should_use_helper` logic verbatim; on +/// every other platform the session is always single-process. +#[cfg(target_os = "windows")] +fn resolve_topology() -> SessionTopology { + let cfg = crate::config::config(); + // `NO_HELPER`/`NO_WGC` force single-process; IDD-push captures in-process in Session 0 (no helper); + // otherwise the helper runs when forced or when we're SYSTEM (in-process WGC can't activate there). + let helper = if cfg.no_helper || crate::capture::wgc_disabled() { + false + } else if cfg.idd_push { + false + } else { + cfg.force_helper || crate::capture::wgc_relay::running_as_system() + }; + if helper { + SessionTopology::TwoProcessRelay + } else { + SessionTopology::SingleProcess + } +} + +#[cfg(not(target_os = "windows"))] +fn resolve_topology() -> SessionTopology { + SessionTopology::SingleProcess +} + +#[cfg(target_os = "windows")] +fn resolve_encoder() -> EncoderBackend { + match crate::encode::windows_resolved_backend() { + crate::encode::WindowsBackend::Nvenc => EncoderBackend::Nvenc, + crate::encode::WindowsBackend::Amf => EncoderBackend::Amf, + crate::encode::WindowsBackend::Qsv => EncoderBackend::Qsv, + crate::encode::WindowsBackend::Software => EncoderBackend::Software, + } +} + +#[cfg(not(target_os = "windows"))] +fn resolve_encoder() -> EncoderBackend { + EncoderBackend::PlatformAuto +} diff --git a/crates/punktfunk-host/src/spike.rs b/crates/punktfunk-host/src/spike.rs index 9d1ba03..53bd1b2 100644 --- a/crates/punktfunk-host/src/spike.rs +++ b/crates/punktfunk-host/src/spike.rs @@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> { refresh_hz: opts.fps, }) .context("create virtual output")?; - capture::capture_virtual_output(vout, false).context("capture virtual output")? + capture::capture_virtual_output( + vout, + false, + crate::session_plan::CaptureBackend::resolve(), + ) + .context("capture virtual output")? } }; diff --git a/docs/windows-host-goal1-plan.md b/docs/windows-host-goal1-plan.md index 6d37e88..1bf29fd 100644 --- a/docs/windows-host-goal1-plan.md +++ b/docs/windows-host-goal1-plan.md @@ -44,12 +44,23 @@ classes of `env::var` read are deliberately **kept live** and documented in `con Risk: medium (semantics-preservation). Verify: Linux `cargo check`/`clippy`/`fmt` green (the Windows-only edits are 1:1 substitutions, compile-verified on the box as part of Stage 3's build). -**Stage 3 — `SessionPlan` (the single biggest clarity lever, plan §2.4).** -Resolve `display/capture/topology/encoder/format/hdr/bit_depth` ONCE from `HostConfig` into a typed -`SessionPlan`; replace the 3-place dispatch (`capture_virtual_output`, `should_use_helper`/`virtual_stream`, -`windows_resolved_backend`) with reads off the plan. Fixes the capture/encode backend-disagreement bug -class. Risk: medium-high (rewires the deployed decision). Verify: box build + on-glass re-test (NVENC + -IDD-push + a mode switch). +**Stage 3 — `SessionPlan` (the single biggest clarity lever, plan §2.4). ✅ IMPLEMENTED (this commit; on-glass pending).** +New `src/session_plan.rs`: a `Copy` `SessionPlan { capture, topology, encoder, bit_depth, hdr }` resolved +**once** from `HostConfig` (+ the negotiated `bit_depth`) in `virtual_stream`, logged, and threaded through +`build_pipeline_with_retry`/`build_pipeline`. The three dispatch points now read it: +- **capture** — `capture::capture_virtual_output` takes a `CaptureBackend` IN (was re-deriving from + `config().idd_push`/`capture_backend`/`no_wgc`); `CaptureBackend::resolve()` is the one resolver (also + used by the GameStream + spike call sites). +- **topology** — `virtual_stream` reads `plan.topology` (`should_use_helper` deleted; its logic is + `session_plan::resolve_topology`, verbatim). The IDD-preempt guard reads `plan.capture` too. +- **encoder** — recorded as `EncoderBackend` from `encode::windows_resolved_backend` (config-backed + + GPU-vendor cached since stage 2, already a single source). Threading `encoder`/`input_format` into the + encoder + capturer opens (which removes the `dxgi.rs` back-reference) is **stage 5**. + +Every decision is provably equivalent to the pre-stage-3 scattered reads (same `config()` + cached probes), +so it is behavior-preserving. Risk: medium-high (rewires the deployed decision). Verify: box build (Windows +compile, which also covers stage 2's Windows-only edits) + on-glass re-test (NVENC + IDD-push + a mode +switch) — **pending** (RTX box `192.168.1.173`). **Stage 4 — `SessionContext` + `SessionFactory`/`Session`.** Bundle the 12–13-arg `#[allow(too_many_arguments)]` signatures into `SessionContext`; `SessionFactory.build()`