feat(windows-host): SessionPlan — resolve capture/topology/encoder once per session (Goal-1 stage 3)

New src/session_plan.rs: a Copy `SessionPlan { capture, topology, encoder, bit_depth, hdr }`
resolved ONCE from HostConfig (+ the negotiated bit_depth) at the top of `virtual_stream`,
logged, and threaded through build_pipeline_with_retry/build_pipeline. The three scattered
Windows dispatch points now read this one typed artifact instead of re-deriving from config
(plan §2.4, the "capture and encode disagree on the backend" hazard):

  * capture: capture::capture_virtual_output takes a CaptureBackend IN (was re-reading
    config().idd_push / capture_backend / no_wgc internally). CaptureBackend::resolve() is the
    single resolver, shared with the GameStream + spike call sites.
  * topology: virtual_stream reads plan.topology; should_use_helper is deleted (its body is
    session_plan::resolve_topology, verbatim). The IDD-push reconnect-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 capture->windows_resolved_backend()
    back-reference recomputed in dxgi.rs) is stage 5.

Behavior-preserving by construction: each resolved decision is provably equivalent to the
pre-stage-3 reads (same config() + the same cached running_as_system()/GPU-vendor probes), so
old==new. SessionPlan is platform-neutral so it threads the shared virtual_stream/build_pipeline
signatures; on Linux it resolves to the single portal/single-process path.

Also fixes a pre-existing mod-ordering fmt drift in main.rs (mod config; / mod capture;).

Verified: Linux cargo check + clippy (-D warnings) + fmt clean on the touched files. Box build
(Windows compile) + on-glass (NVENC + IDD-push + mode switch) pending on the RTX box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 17:47:48 +00:00
parent e5057f6cc1
commit 0a63154293
7 changed files with 218 additions and 50 deletions
+19 -33
View File
@@ -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<Pipeline> {
// ~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<Pipeline> {
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")?;