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:
@@ -315,8 +315,10 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
|||||||
pub fn capture_virtual_output(
|
pub fn capture_virtual_output(
|
||||||
vout: crate::vdisplay::VirtualOutput,
|
vout: crate::vdisplay::VirtualOutput,
|
||||||
_want_hdr: bool,
|
_want_hdr: bool,
|
||||||
|
_capture: crate::session_plan::CaptureBackend,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
// 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<dyn Capturer>)
|
linux::PortalCapturer::from_virtual_output(vout).map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +336,9 @@ pub(crate) fn wgc_disabled() -> bool {
|
|||||||
pub fn capture_virtual_output(
|
pub fn capture_virtual_output(
|
||||||
vout: crate::vdisplay::VirtualOutput,
|
vout: crate::vdisplay::VirtualOutput,
|
||||||
want_hdr: bool,
|
want_hdr: bool,
|
||||||
|
capture: crate::session_plan::CaptureBackend,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
|
use crate::session_plan::CaptureBackend;
|
||||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
"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 pref = vout.preferred_mode;
|
||||||
let keep = vout.keepalive;
|
let keep = vout.keepalive;
|
||||||
// P2 direct frame push (kill DDA): consume frames straight from the pf-vdisplay driver's shared
|
// 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;
|
// ring — no Desktop Duplication, no win32u reparenting hook. Resolved once in the `SessionPlan`
|
||||||
// `idd_push` takes the keepalive (owns the virtual display) so there's no fall-through.
|
// (was re-derived from `config().idd_push` here); `IddPush` takes the keepalive (owns the virtual
|
||||||
if crate::config::config().idd_push {
|
// 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
|
// 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
|
// 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
|
// 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),
|
// 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
|
// 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
|
// 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.
|
// chosen backend (it owns the SudoVDA keepalive), so there's no open-time auto-fallback. The
|
||||||
let backend = crate::config::config().capture_backend.as_str();
|
// backend choice (`dda`/`dxgi`/`PUNKTFUNK_NO_WGC` → DDA, else WGC) is now resolved once in the plan.
|
||||||
if backend == "dda" || backend == "dxgi" || wgc_disabled() {
|
if capture == CaptureBackend::Dda {
|
||||||
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
return dxgi::DuplCapturer::open(target, pref, keep, false)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
.map(|c| Box::new(c) as Box<dyn Capturer>);
|
||||||
}
|
}
|
||||||
@@ -418,6 +423,7 @@ pub fn capture_virtual_output(
|
|||||||
pub fn capture_virtual_output(
|
pub fn capture_virtual_output(
|
||||||
_vout: crate::vdisplay::VirtualOutput,
|
_vout: crate::vdisplay::VirtualOutput,
|
||||||
_want_hdr: bool,
|
_want_hdr: bool,
|
||||||
|
_capture: crate::session_plan::CaptureBackend,
|
||||||
) -> Result<Box<dyn Capturer>> {
|
) -> Result<Box<dyn Capturer>> {
|
||||||
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
anyhow::bail!("virtual-output capture requires Linux or Windows")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,8 +134,12 @@ fn run(
|
|||||||
// IDD-push bypasses WGC.) Acceptable for the experimental IDD-push A/B path; HDR over IDD-push
|
// 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
|
// 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.
|
// from a GameStream HDR flag once StreamConfig carries one.
|
||||||
let mut capturer =
|
let mut capturer = capture::capture_virtual_output(
|
||||||
capture::capture_virtual_output(vout, false).context("capture virtual output")?;
|
vout,
|
||||||
|
false,
|
||||||
|
crate::session_plan::CaptureBackend::resolve(),
|
||||||
|
)
|
||||||
|
.context("capture virtual output")?;
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
return stream_body(&mut *capturer, &sock, cfg, running, force_idr, rfi_range);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
mod config;
|
|
||||||
mod capture;
|
mod capture;
|
||||||
|
mod config;
|
||||||
mod discovery;
|
mod discovery;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
@@ -35,6 +35,7 @@ mod punktfunk1;
|
|||||||
mod pwinit;
|
mod pwinit;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod service;
|
mod service;
|
||||||
|
mod session_plan;
|
||||||
mod session_tuning;
|
mod session_tuning;
|
||||||
mod spike;
|
mod spike;
|
||||||
mod vdisplay;
|
mod vdisplay;
|
||||||
|
|||||||
@@ -2184,12 +2184,18 @@ fn virtual_stream(
|
|||||||
// This thread runs the capture+encode loop (single-process: Linux / synthetic / NO_WGC DDA) — or
|
// 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.
|
// tail-calls the relay below. Elevate it so a CPU-heavy game can't deschedule our GPU submission.
|
||||||
boost_thread_priority(true);
|
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
|
// 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
|
// 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
|
// 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.
|
// user, and stays the path on Linux.) See docs/windows-secure-desktop.md.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
if should_use_helper() {
|
if plan.topology == crate::session_plan::SessionTopology::TwoProcessRelay {
|
||||||
return virtual_stream_relay(
|
return virtual_stream_relay(
|
||||||
session,
|
session,
|
||||||
mode,
|
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
|
// driver-churning teardown of a monitor under a still-live session. Register THIS session's stop so
|
||||||
// the next reconnect preempts it.
|
// the next reconnect preempts it.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let idd_setup_guard = crate::config::config()
|
let idd_push_session = plan.capture == crate::session_plan::CaptureBackend::IddPush;
|
||||||
.idd_push
|
|
||||||
.then(|| IDD_SETUP_LOCK.lock().unwrap());
|
|
||||||
#[cfg(target_os = "windows")]
|
#[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());
|
let prev = IDD_SESSION_STOP.lock().unwrap().replace(stop.clone());
|
||||||
if let Some(prev_stop) = prev {
|
if let Some(prev_stop) = prev {
|
||||||
prev_stop.store(true, Ordering::SeqCst);
|
prev_stop.store(true, Ordering::SeqCst);
|
||||||
@@ -2233,7 +2239,7 @@ fn virtual_stream(
|
|||||||
}
|
}
|
||||||
let mut vd = crate::vdisplay::open(compositor)?;
|
let mut vd = crate::vdisplay::open(compositor)?;
|
||||||
let (mut capturer, mut enc, mut frame, mut interval) =
|
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).
|
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
drop(idd_setup_guard);
|
drop(idd_setup_guard);
|
||||||
@@ -2362,6 +2368,7 @@ fn virtual_stream(
|
|||||||
cur_mode,
|
cur_mode,
|
||||||
bitrate_kbps,
|
bitrate_kbps,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
|
plan,
|
||||||
)?;
|
)?;
|
||||||
Ok((new_vd, pipe))
|
Ok((new_vd, pipe))
|
||||||
})();
|
})();
|
||||||
@@ -2405,7 +2412,7 @@ fn virtual_stream(
|
|||||||
// Build the new pipeline BEFORE dropping the old one: the host already acked
|
// 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
|
// the switch as accepted, so a rebuild failure must not kill an otherwise
|
||||||
// healthy session — keep streaming the current mode and log instead.
|
// 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) => {
|
Ok(next_pipe) => {
|
||||||
(capturer, enc, frame, interval) = next_pipe;
|
(capturer, enc, frame, interval) = next_pipe;
|
||||||
cur_mode = new_mode;
|
cur_mode = new_mode;
|
||||||
@@ -2450,7 +2457,7 @@ fn virtual_stream(
|
|||||||
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
|
tracing::warn!(error = %format!("{e:#}"), rebuild = capture_rebuilds,
|
||||||
"capture lost — rebuilding pipeline in place");
|
"capture lost — rebuilding pipeline in place");
|
||||||
let (new_cap, new_enc, new_frame, new_interval) =
|
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")?;
|
.context("rebuild after capture loss")?;
|
||||||
capturer = new_cap;
|
capturer = new_cap;
|
||||||
enc = new_enc;
|
enc = new_enc;
|
||||||
@@ -2569,29 +2576,6 @@ fn virtual_stream(
|
|||||||
Ok(())
|
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
|
/// 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
|
/// 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
|
/// 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,
|
mode: punktfunk_core::Mode,
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
|
plan: crate::session_plan::SessionPlan,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
// ~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
|
// 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;
|
const MAX_ATTEMPTS: u32 = 8;
|
||||||
let mut backoff = std::time::Duration::from_millis(500);
|
let mut backoff = std::time::Duration::from_millis(500);
|
||||||
for attempt in 1..=MAX_ATTEMPTS {
|
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) => {
|
Ok(pipe) => {
|
||||||
if attempt > 1 {
|
if attempt > 1 {
|
||||||
tracing::info!(attempt, "pipeline up after retry");
|
tracing::info!(attempt, "pipeline up after retry");
|
||||||
@@ -3109,6 +3094,7 @@ fn build_pipeline(
|
|||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
bitrate_kbps: u32,
|
bitrate_kbps: u32,
|
||||||
bit_depth: u8,
|
bit_depth: u8,
|
||||||
|
plan: crate::session_plan::SessionPlan,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
let vout = vd.create(mode).context("create virtual output")?;
|
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
|
// 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;
|
// 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,
|
// 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.)
|
// 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")?;
|
.context("capture virtual output")?;
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
let frame = capturer.next_frame().context("first frame")?;
|
let frame = capturer.next_frame().context("first frame")?;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -76,7 +76,12 @@ pub fn run(opts: Options) -> Result<()> {
|
|||||||
refresh_hz: opts.fps,
|
refresh_hz: opts.fps,
|
||||||
})
|
})
|
||||||
.context("create virtual output")?;
|
.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")?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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).
|
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).**
|
**Stage 3 — `SessionPlan` (the single biggest clarity lever, plan §2.4). ✅ IMPLEMENTED (this commit; on-glass pending).**
|
||||||
Resolve `display/capture/topology/encoder/format/hdr/bit_depth` ONCE from `HostConfig` into a typed
|
New `src/session_plan.rs`: a `Copy` `SessionPlan { capture, topology, encoder, bit_depth, hdr }` resolved
|
||||||
`SessionPlan`; replace the 3-place dispatch (`capture_virtual_output`, `should_use_helper`/`virtual_stream`,
|
**once** from `HostConfig` (+ the negotiated `bit_depth`) in `virtual_stream`, logged, and threaded through
|
||||||
`windows_resolved_backend`) with reads off the plan. Fixes the capture/encode backend-disagreement bug
|
`build_pipeline_with_retry`/`build_pipeline`. The three dispatch points now read it:
|
||||||
class. Risk: medium-high (rewires the deployed decision). Verify: box build + on-glass re-test (NVENC +
|
- **capture** — `capture::capture_virtual_output` takes a `CaptureBackend` IN (was re-deriving from
|
||||||
IDD-push + a mode switch).
|
`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`.**
|
**Stage 4 — `SessionContext` + `SessionFactory`/`Session`.**
|
||||||
Bundle the 12–13-arg `#[allow(too_many_arguments)]` signatures into `SessionContext`; `SessionFactory.build()`
|
Bundle the 12–13-arg `#[allow(too_many_arguments)]` signatures into `SessionContext`; `SessionFactory.build()`
|
||||||
|
|||||||
Reference in New Issue
Block a user