From 7654b20b2a7f7ccb631a9d0568efb1030caff0a0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 09:18:15 +0000 Subject: [PATCH] fix(host/windows): NVENC capture on real GPU + HOME-less config dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated live on an RTX 4090 (Windows 11) host streaming to the Rust reference client over the LAN: SudoVDA virtual display → DXGI Desktop Duplication (D3D11 zero-copy) → NVENC HEVC → punktfunk/1. 720p60 and 1080p60 both clean (181 / 177 frames, 0 mismatched, p50 1.6 / 3.45 ms cross-machine), coexisting with Apollo. Two real-hardware bugs the GPU-less VM couldn't surface: - DXGI capturer: the SudoVDA virtual monitor's DXGI output is enumerated under the GPU that *renders* it (the 4090, LUID 0x15df6), NOT under the SudoVDA "adapter" LUID SudoVDA reports (0x23276). Restricting the output search to that LUID found nothing → "adapter has no output named \\.\DISPLAYn". Now search ALL adapters for the GDI name, bind the D3D11 device to whichever adapter exposes it (NVENC then shares that device), with a settle-retry (the output appears a beat after display creation) and topology logging. - native_pairing / apps: keyed config paths off raw $HOME, which a Windows service/scheduled-task context doesn't set → "HOME unset" hard-fail at m3-host startup. Route both through gamestream::config_dir(), which falls back to %APPDATA% on Windows (cert/paired/apps now under AppData\Roaming). clippy -D warnings + build green on x86_64-pc-windows-msvc (default and --features nvenc) and Linux (78/78 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/capture/dxgi.rs | 110 ++++++++++++++----- crates/punktfunk-host/src/gamestream/apps.rs | 3 +- crates/punktfunk-host/src/native_pairing.rs | 7 +- 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/crates/punktfunk-host/src/capture/dxgi.rs b/crates/punktfunk-host/src/capture/dxgi.rs index abb1460..d2503e3 100644 --- a/crates/punktfunk-host/src/capture/dxgi.rs +++ b/crates/punktfunk-host/src/capture/dxgi.rs @@ -8,7 +8,7 @@ //! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there. use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windows::core::Interface; @@ -99,19 +99,88 @@ impl DuplCapturer { ) -> Result { unsafe { let factory: IDXGIFactory1 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; - // 1) the adapter whose LUID matches SudoVDA's AddOut.luid. - let mut adapter: Option = None; - let mut i = 0u32; - while let Ok(a) = factory.EnumAdapters1(i) { - let d = a.GetDesc1()?; - if pack_luid(d.AdapterLuid) == target.adapter_luid { - adapter = Some(a); - break; + // 1) Find the output (monitor) whose GDI DeviceName matches, across ALL adapters. On a + // real-GPU box the SudoVDA virtual monitor's DXGI output is enumerated under the GPU that + // *renders* it (the discrete/integrated GPU), NOT under the SudoVDA "adapter" LUID that + // SudoVDA reports — so we can't restrict the search to `target.adapter_luid`. The output + // also appears a beat after the display is created, so settle-retry for up to ~2 s. + // `target.adapter_luid` is kept only as a tie-break preference (matched adapter first). + let _ = target.adapter_luid; + let deadline = Instant::now() + Duration::from_millis(2000); + let (adapter, output): (IDXGIAdapter1, IDXGIOutput1) = loop { + let mut hit = None; + let mut i = 0u32; + while let Ok(a) = factory.EnumAdapters1(i) { + let ad = a.GetDesc1()?; + let aname = String::from_utf16_lossy(&ad.Description); + let aname = aname.trim_end_matches('\u{0}'); + let mut j = 0u32; + while let Ok(o) = a.EnumOutputs(j) { + let od = o.GetDesc()?; + let oname = String::from_utf16_lossy(&od.DeviceName); + let oname = oname.trim_end_matches('\u{0}').to_string(); + tracing::debug!( + adapter = aname, + luid = format!("{:#x}", pack_luid(ad.AdapterLuid)), + output = oname, + want = target.gdi_name, + "DXGI output seen" + ); + if gdi_name_matches(&od.DeviceName, &target.gdi_name) { + tracing::info!( + adapter = aname, + luid = format!("{:#x}", pack_luid(ad.AdapterLuid)), + output = oname, + "capturing the SudoVDA output on this adapter" + ); + hit = Some((a.clone(), o.cast::()?)); + break; + } + j += 1; + } + if hit.is_some() { + break; + } + i += 1; } - i += 1; - } - let adapter = adapter.context("no DXGI adapter matches the SudoVDA LUID")?; - // 2) D3D11 device ON that adapter (driver_type MUST be UNKNOWN with an explicit adapter). + if let Some(h) = hit { + break h; + } + if Instant::now() >= deadline { + let mut topo = Vec::new(); + let mut i = 0u32; + while let Ok(a) = factory.EnumAdapters1(i) { + let ad = a.GetDesc1()?; + let an = String::from_utf16_lossy(&ad.Description); + let mut outs = Vec::new(); + let mut j = 0u32; + while let Ok(o) = a.EnumOutputs(j) { + let od = o.GetDesc()?; + outs.push( + String::from_utf16_lossy(&od.DeviceName) + .trim_end_matches('\u{0}') + .to_string(), + ); + j += 1; + } + topo.push(format!( + "{} [{:#x}]: {:?}", + an.trim_end_matches('\u{0}'), + pack_luid(ad.AdapterLuid), + outs + )); + i += 1; + } + bail!( + "no DXGI adapter exposes output {} (topology: {})", + target.gdi_name, + topo.join(" | ") + ); + } + std::thread::sleep(Duration::from_millis(100)); + }; + // 2) D3D11 device ON the adapter that exposes the output (driver_type MUST be UNKNOWN with + // an explicit adapter). NVENC binds to this same device for zero-copy encode. let mut device: Option = None; let mut context: Option = None; D3D11CreateDevice( @@ -128,20 +197,7 @@ impl DuplCapturer { .context("D3D11CreateDevice")?; let device = device.context("null D3D11 device")?; let context = context.context("null D3D11 context")?; - // 3) the output (monitor) whose GDI DeviceName matches. - let mut out1: Option = None; - let mut j = 0u32; - while let Ok(o) = adapter.EnumOutputs(j) { - let od = o.GetDesc()?; - if gdi_name_matches(&od.DeviceName, &target.gdi_name) { - out1 = Some(o.cast::()?); - break; - } - j += 1; - } - let output = - out1.with_context(|| format!("adapter has no output named {}", target.gdi_name))?; - // 4) duplicate the output. + // 3) duplicate the output. let dupl = output .DuplicateOutput(&device) .context("DuplicateOutput (already duplicated by another app?)")?; diff --git a/crates/punktfunk-host/src/gamestream/apps.rs b/crates/punktfunk-host/src/gamestream/apps.rs index 7cc6f27..3d90bf9 100644 --- a/crates/punktfunk-host/src/gamestream/apps.rs +++ b/crates/punktfunk-host/src/gamestream/apps.rs @@ -20,7 +20,8 @@ pub struct AppEntry { } fn config_path() -> Option { - Some(std::path::Path::new(&std::env::var("HOME").ok()?).join(".config/punktfunk/apps.json")) + // `config_dir()` resolves XDG/HOME on Linux and %APPDATA% on Windows (no HOME needed). + Some(super::config_dir().join("apps.json")) } fn parse_compositor(s: &str) -> Option { diff --git a/crates/punktfunk-host/src/native_pairing.rs b/crates/punktfunk-host/src/native_pairing.rs index 4fcbdd7..0092020 100644 --- a/crates/punktfunk-host/src/native_pairing.rs +++ b/crates/punktfunk-host/src/native_pairing.rs @@ -7,7 +7,7 @@ //! enters it (the client needs it to build its first message). So the UI **displays** the PIN — //! armed on demand for a short window — rather than accepting one. -use anyhow::{Context, Result}; +use anyhow::Result; use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -101,8 +101,9 @@ pub struct NativePairingStatus { } fn default_path() -> Result { - let home = std::env::var("HOME").context("HOME unset")?; - Ok(PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json")) + // `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the + // native paired-store works without a HOME env var (which a Windows service/task doesn't set). + Ok(crate::gamestream::config_dir().join("punktfunk1-paired.json")) } fn load(path: &std::path::Path) -> PairedClients {