fix(host/windows): NVENC capture on real GPU + HOME-less config dir
apple / swift (push) Successful in 54s
android / android (push) Failing after 1m44s
ci / rust (push) Successful in 1m18s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m50s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
flatpak / build-publish (push) Failing after 2s
deb / build-publish (push) Failing after 2m56s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m48s
docker / deploy-docs (push) Successful in 17s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:18:15 +00:00
parent bf65d264fd
commit 7654b20b2a
3 changed files with 89 additions and 31 deletions
+83 -27
View File
@@ -8,7 +8,7 @@
//! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there. //! WDDM output). Compiles on the GPU-less VM; the pure helpers are unit-tested there.
use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; 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::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::Interface; use windows::core::Interface;
@@ -99,19 +99,88 @@ impl DuplCapturer {
) -> Result<Self> { ) -> Result<Self> {
unsafe { unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("CreateDXGIFactory1")?; let factory: IDXGIFactory1 = CreateDXGIFactory1().context("CreateDXGIFactory1")?;
// 1) the adapter whose LUID matches SudoVDA's AddOut.luid. // 1) Find the output (monitor) whose GDI DeviceName matches, across ALL adapters. On a
let mut adapter: Option<IDXGIAdapter1> = None; // real-GPU box the SudoVDA virtual monitor's DXGI output is enumerated under the GPU that
let mut i = 0u32; // *renders* it (the discrete/integrated GPU), NOT under the SudoVDA "adapter" LUID that
while let Ok(a) = factory.EnumAdapters1(i) { // SudoVDA reports — so we can't restrict the search to `target.adapter_luid`. The output
let d = a.GetDesc1()?; // also appears a beat after the display is created, so settle-retry for up to ~2 s.
if pack_luid(d.AdapterLuid) == target.adapter_luid { // `target.adapter_luid` is kept only as a tie-break preference (matched adapter first).
adapter = Some(a); let _ = target.adapter_luid;
break; 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::<IDXGIOutput1>()?));
break;
}
j += 1;
}
if hit.is_some() {
break;
}
i += 1;
} }
i += 1; if let Some(h) = hit {
} break h;
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 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<ID3D11Device> = None; let mut device: Option<ID3D11Device> = None;
let mut context: Option<ID3D11DeviceContext> = None; let mut context: Option<ID3D11DeviceContext> = None;
D3D11CreateDevice( D3D11CreateDevice(
@@ -128,20 +197,7 @@ impl DuplCapturer {
.context("D3D11CreateDevice")?; .context("D3D11CreateDevice")?;
let device = device.context("null D3D11 device")?; let device = device.context("null D3D11 device")?;
let context = context.context("null D3D11 context")?; let context = context.context("null D3D11 context")?;
// 3) the output (monitor) whose GDI DeviceName matches. // 3) duplicate the output.
let mut out1: Option<IDXGIOutput1> = 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::<IDXGIOutput1>()?);
break;
}
j += 1;
}
let output =
out1.with_context(|| format!("adapter has no output named {}", target.gdi_name))?;
// 4) duplicate the output.
let dupl = output let dupl = output
.DuplicateOutput(&device) .DuplicateOutput(&device)
.context("DuplicateOutput (already duplicated by another app?)")?; .context("DuplicateOutput (already duplicated by another app?)")?;
+2 -1
View File
@@ -20,7 +20,8 @@ pub struct AppEntry {
} }
fn config_path() -> Option<std::path::PathBuf> { fn config_path() -> Option<std::path::PathBuf> {
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<crate::vdisplay::Compositor> { fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
+4 -3
View File
@@ -7,7 +7,7 @@
//! enters it (the client needs it to build its first message). So the UI **displays** the PIN — //! 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. //! armed on demand for a short window — rather than accepting one.
use anyhow::{Context, Result}; use anyhow::Result;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -101,8 +101,9 @@ pub struct NativePairingStatus {
} }
fn default_path() -> Result<PathBuf> { fn default_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME unset")?; // `config_dir()` resolves XDG/HOME on Linux and falls back to %APPDATA% on Windows — so the
Ok(PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json")) // 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 { fn load(path: &std::path::Path) -> PairedClients {