diff --git a/Cargo.lock b/Cargo.lock index 2592e7c..0d314e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2559,6 +2559,7 @@ dependencies = [ "wayland-protocols-misc", "wayland-protocols-wlr", "wayland-scanner", + "windows", "x509-parser", "xkbcommon", ] @@ -4079,12 +4080,107 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -4169,6 +4265,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index ac67f99..484f066 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -101,3 +101,16 @@ khronos-egl = { version = "6", features = ["dynamic"] } # GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the # officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver). ash = "0.38" + +[target.'cfg(target_os = "windows")'.dependencies] +# Windows host backends. `windows` covers the Win32/CCD APIs the SudoVDA virtual-display backend +# drives (SetupAPI device enumeration, DeviceIoControl IOCTLs, QueryDisplayConfig name resolution); +# capture/encode/input/audio backends extend the feature set as they land. +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Devices_DeviceAndDriverInstallation", + "Win32_Devices_Display", + "Win32_Storage_FileSystem", + "Win32_System_IO", +] } diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 933aaa0..52884fe 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -456,10 +456,16 @@ pub fn open(compositor: Compositor) -> Result> { Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)), } } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] + { + // Windows has a single virtual-display backend (SudoVDA); the compositor arg is moot. + let _ = compositor; + Ok(Box::new(sudovda::SudoVdaDisplay::new()?)) + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] { let _ = compositor; - anyhow::bail!("virtual displays require Linux (Wayland compositor)") + anyhow::bail!("virtual displays require Linux or Windows") } } @@ -480,10 +486,15 @@ pub fn probe(compositor: Compositor) -> Result<()> { Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()), } } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] { let _ = compositor; - anyhow::bail!("virtual displays require Linux (Wayland compositor)") + sudovda::probe() + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = compositor; + anyhow::bail!("virtual displays require Linux or Windows") } } @@ -527,6 +538,8 @@ mod kwin; mod mutter; #[cfg(target_os = "linux")] mod wlroots; +#[cfg(target_os = "windows")] +mod sudovda; #[cfg(test)] mod tests { diff --git a/crates/punktfunk-host/src/vdisplay/sudovda.rs b/crates/punktfunk-host/src/vdisplay/sudovda.rs new file mode 100644 index 0000000..93d7c6c --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/sudovda.rs @@ -0,0 +1,362 @@ +//! Windows virtual-display backend driving **SudoVDA** (the SudoMaker Virtual Display Adapter — +//! the Indirect Display Driver the Apollo Sunshine-fork ships). The Windows analogue of the +//! Linux per-compositor backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the +//! client's exact `WxH@Hz` (the mode is baked into the ADD IOCTL — no EDID seeding), starts the +//! mandatory watchdog ping, and the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII). +//! +//! Control surface (verified live against SudoVDA 0.2.1): a device-interface-GUID + `CreateFileW` +//! + `DeviceIoControl` IOCTL protocol. No DLL, no named pipe. See `docs/windows-host.md`. + +use std::ffi::c_void; +use std::mem::size_of; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use windows::core::{GUID, PCWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, + SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, + SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, +}; +use windows::Win32::Devices::Display::{ + DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QueryDisplayConfig, + DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, + DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ONLY_ACTIVE_PATHS, +}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows::Win32::System::IO::DeviceIoControl; + +use super::{Mode, VirtualDisplay, VirtualOutput}; + +// SudoVDA device-interface GUID (Common/Include/sudovda-ioctl.h). +const SUVDA_INTERFACE: GUID = GUID::from_u128(0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D); + +// CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, func, METHOD_BUFFERED=0, FILE_ANY_ACCESS=0). +const fn ctl(func: u32) -> u32 { + (0x22u32 << 16) | (func << 2) +} +const IOCTL_ADD: u32 = ctl(0x800); +const IOCTL_REMOVE: u32 = ctl(0x801); +const IOCTL_GET_WATCHDOG: u32 = ctl(0x803); +const IOCTL_DRIVER_PING: u32 = ctl(0x888); +const IOCTL_GET_VERSION: u32 = ctl(0x8FF); + +// A fixed monitor identity. One session at a time today; Windows persists this monitor's layout +// across sessions by GUID, and REMOVE keys off it. (TODO: derive per-client when concurrent +// sessions land.) +const MONITOR_GUID: GUID = GUID::from_u128(0x70756E6B_7466_756E_6B30_000000000001); + +#[repr(C)] +#[derive(Clone, Copy)] +struct AddParams { + width: u32, + height: u32, + refresh: u32, + guid: GUID, + device_name: [u8; 14], + serial: [u8; 14], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct AddOut { + luid: LUID, + target_id: u32, +} + +#[repr(C)] +struct RemoveParams { + guid: GUID, +} + +/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. +unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result { + let mut returned = 0u32; + let inp = (!input.is_empty()).then(|| input.as_ptr() as *const c_void); + let outp = (!output.is_empty()).then(|| output.as_mut_ptr() as *mut c_void); + DeviceIoControl( + h, + code, + inp, + input.len() as u32, + outp, + output.len() as u32, + Some(&mut returned), + None, + ) + .with_context(|| format!("DeviceIoControl(code={code:#x})"))?; + Ok(returned) +} + +/// Resolve the `\\.\DisplayN` GDI name for a SudoVDA target id via the CCD API. Returns `None` +/// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a +/// GPU-less box this stays `None` even though ADD succeeded). +unsafe fn resolve_gdi_name(target_id: u32) -> Option { + let mut np = 0u32; + let mut nm = 0u32; + if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { + return None; + } + let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize]; + let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize]; + if QueryDisplayConfig( + QDC_ONLY_ACTIVE_PATHS, + &mut np, + paths.as_mut_ptr(), + &mut nm, + modes.as_mut_ptr(), + None, + ) + .is_err() + { + return None; + } + for p in paths.iter().take(np as usize) { + if p.targetInfo.id == target_id { + let mut src = DISPLAYCONFIG_SOURCE_DEVICE_NAME::default(); + src.header.r#type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; + src.header.size = size_of::() as u32; + src.header.adapterId = p.sourceInfo.adapterId; + src.header.id = p.sourceInfo.id; + if DisplayConfigGetDeviceInfo(&mut src.header) == 0 { + let name = String::from_utf16_lossy(&src.viewGdiDeviceName); + return Some(name.trim_end_matches('\u{0}').to_string()); + } + } + } + None +} + +unsafe fn open_device() -> Result { + let hdev = SetupDiGetClassDevsW( + Some(&SUVDA_INTERFACE), + PCWSTR::null(), + None, + DIGCF_DEVICEINTERFACE | DIGCF_PRESENT, + ) + .context("SetupDiGetClassDevsW(SudoVDA) — is the SudoVDA driver installed?")?; + + let mut idata = SP_DEVICE_INTERFACE_DATA { + cbSize: size_of::() as u32, + ..Default::default() + }; + SetupDiEnumDeviceInterfaces(hdev, None, &SUVDA_INTERFACE, 0, &mut idata) + .context("SetupDiEnumDeviceInterfaces(SudoVDA)")?; + + let mut required = 0u32; + let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None); + let mut buf = vec![0u8; required as usize]; + let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W; + (*detail).cbSize = size_of::() as u32; + SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None) + .context("SetupDiGetDeviceInterfaceDetailW(SudoVDA)")?; + + let handle = CreateFileW( + PCWSTR((*detail).DevicePath.as_ptr()), + 0xC000_0000, // GENERIC_READ | GENERIC_WRITE + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + .context("CreateFileW(SudoVDA device)")?; + let _ = SetupDiDestroyDeviceInfoList(hdev); + Ok(handle) +} + +/// A live SudoVDA control handle. One per host; `create` adds/removes monitors on it. +pub struct SudoVdaDisplay { + device: HANDLE, + watchdog_s: u32, +} + +// The HANDLE is a kernel object usable from any thread; we only ever issue serialized IOCTLs. +unsafe impl Send for SudoVdaDisplay {} + +impl SudoVdaDisplay { + pub fn new() -> Result { + let device = unsafe { open_device()? }; + let mut ver = [0u8; 4]; + if unsafe { ioctl(device, IOCTL_GET_VERSION, &[], &mut ver) }.is_ok() { + tracing::info!( + "SudoVDA protocol {}.{}.{} (test={})", + ver[0], + ver[1], + ver[2], + ver[3] + ); + } + let mut wd = [0u8; 8]; + let watchdog_s = if unsafe { ioctl(device, IOCTL_GET_WATCHDOG, &[], &mut wd) }.is_ok() { + u32::from_le_bytes([wd[0], wd[1], wd[2], wd[3]]).max(1) + } else { + 3 + }; + tracing::info!("SudoVDA watchdog timeout {watchdog_s}s"); + Ok(Self { device, watchdog_s }) + } +} + +impl Drop for SudoVdaDisplay { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.device); + } + } +} + +impl VirtualDisplay for SudoVdaDisplay { + fn name(&self) -> &'static str { + "sudovda" + } + + fn create(&mut self, mode: Mode) -> Result { + let mut device_name = [0u8; 14]; + let nm = b"punktfunk"; + device_name[..nm.len()].copy_from_slice(nm); + let add = AddParams { + width: mode.width, + height: mode.height, + refresh: mode.refresh_hz, + guid: MONITOR_GUID, + device_name, + serial: [0u8; 14], + }; + let add_bytes = + unsafe { std::slice::from_raw_parts(&add as *const _ as *const u8, size_of::()) }; + let mut out = [0u8; size_of::()]; + unsafe { ioctl(self.device, IOCTL_ADD, add_bytes, &mut out) } + .with_context(|| format!("SudoVDA ADD {}x{}@{}", mode.width, mode.height, mode.refresh_hz))?; + let ao = unsafe { *(out.as_ptr() as *const AddOut) }; + tracing::info!( + "SudoVDA created {}x{}@{} (target_id={}, adapter_luid={:#x})", + mode.width, + mode.height, + mode.refresh_hz, + ao.target_id, + ao.luid.LowPart + ); + + // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. + let stop = Arc::new(AtomicBool::new(false)); + let device_raw = self.device.0 as isize; + let interval = Duration::from_millis(self.watchdog_s as u64 * 1000 / 3); + let stop_t = stop.clone(); + let pinger = thread::spawn(move || { + let h = HANDLE(device_raw as *mut c_void); + while !stop_t.load(Ordering::Relaxed) { + let mut none: [u8; 0] = []; + unsafe { + let _ = ioctl(h, IOCTL_DRIVER_PING, &[], &mut none); + } + thread::sleep(interval); + } + }); + + // Resolve the capture target. May be None on a GPU-less box (target added but not activated + // into a WDDM path); the Windows capture backend will re-resolve once a GPU is present. + let mut gdi_name = None; + for _ in 0..15 { + thread::sleep(Duration::from_millis(200)); + if let Some(n) = unsafe { resolve_gdi_name(ao.target_id) } { + gdi_name = Some(n); + break; + } + } + match &gdi_name { + Some(n) => tracing::info!("SudoVDA target {} -> {n}", ao.target_id), + None => tracing::warn!( + "SudoVDA target {} not yet an active display path (needs a WDDM GPU to activate)", + ao.target_id + ), + } + + Ok(VirtualOutput { + node_id: 0, // unused on Windows; the capture target is the GDI name below + preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + keepalive: Box::new(SudoVdaKeepalive { + device: device_raw, + guid: MONITOR_GUID, + stop, + pinger: Some(pinger), + gdi_name, + }), + }) + } +} + +/// RAII teardown: stop the ping thread, then REMOVE the monitor by its GUID. Does NOT close the +/// device handle — that belongs to [`SudoVdaDisplay`], which outlives the output. +struct SudoVdaKeepalive { + device: isize, + guid: GUID, + stop: Arc, + pinger: Option>, + #[allow(dead_code)] // consumed by the Windows capture backend (not yet wired) + gdi_name: Option, +} + +impl Drop for SudoVdaKeepalive { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(j) = self.pinger.take() { + let _ = j.join(); + } + let rp = RemoveParams { guid: self.guid }; + let rp_bytes = + unsafe { std::slice::from_raw_parts(&rp as *const _ as *const u8, size_of::()) }; + let mut none: [u8; 0] = []; + let h = HANDLE(self.device as *mut c_void); + if let Err(e) = unsafe { ioctl(h, IOCTL_REMOVE, rp_bytes, &mut none) } { + tracing::warn!("SudoVDA REMOVE failed: {e:#}"); + } else { + tracing::info!("SudoVDA monitor removed"); + } + } +} + +/// Readiness probe: can we open the SudoVDA control device? +pub fn probe() -> Result<()> { + let h = unsafe { open_device()? }; + unsafe { + let _ = CloseHandle(h); + } + Ok(()) +} + +/// Is the SudoVDA driver present (device interface enumerable)? +pub fn is_available() -> bool { + unsafe { open_device().map(|h| CloseHandle(h)).is_ok() } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Live hardware round trip — skipped unless `PUNKTFUNK_SUDOVDA_LIVE=1` (needs the SudoVDA + /// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE). + #[test] + fn live_create_drop() { + if std::env::var("PUNKTFUNK_SUDOVDA_LIVE").is_err() { + return; + } + let mut vd = SudoVdaDisplay::new().expect("open SudoVDA"); + let vout = vd + .create(Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }) + .expect("create virtual display"); + assert_eq!(vout.preferred_mode, Some((1920, 1080, 60))); + thread::sleep(Duration::from_secs(3)); + drop(vout); // triggers REMOVE + stops the pinger + } +} diff --git a/docs/windows-host.md b/docs/windows-host.md index daf10d2..b4deae3 100644 --- a/docs/windows-host.md +++ b/docs/windows-host.md @@ -158,8 +158,10 @@ glass-to-glass / throughput numbers (no perf claim transfers from Linux). ## Phased plan (host-first) 0. **Compile on MSVC** (Step 0 above). GPU-less. ← *start here* -1. **SudoVDA `VirtualDisplay` backend** — add/resolve-GDI-name/keepalive/remove + `SetDisplayConfig` - mode-set; RAII teardown. *Spike first*: does `ADD` bring up a monitor + mode-set on the VM (WARP)? +1. **SudoVDA `VirtualDisplay` backend** — ✅ *control path landed* (`vdisplay/sudovda.rs`: + add/keepalive/remove + GDI-name resolution + RAII teardown, behind the existing trait; `open()` + returns it on Windows). Compiles + live-tested on the VM. **Remaining:** monitor activation + + `\\.\DisplayN` resolution (needs a GPU), then `SetDisplayConfig` mid-stream `Reconfigure`. 2. **Capture + SW encode** — DXGI Desktop Duplication (or WGC) → `ID3D11Texture2D` → CPU staging → openh264 → existing FEC/transport. First end-to-end Windows session, GPU-less, against the Linux `punktfunk-client-rs` or the new Windows client. @@ -191,8 +193,13 @@ Structurally a sibling of `crates/punktfunk-client-linux` (GTK4) — same shape, 1. **`cargo build -p punktfunk-host` on the VM** — count + triage the real MSVC errors before estimating Step 0. (GPU-less.) -2. **SudoVDA `ADD` on the VM** — does a virtual monitor come up + mode-set via WARP with no GPU? - Confirms the whole Phase 1 backend is VM-developable. (GPU-less.) +2. **SudoVDA `ADD` on the VM** — ✅ *done 2026-06-15.* The control path is fully validated on the + GPU-less VM, both standalone and through the real `VirtualDisplay` trait (`vdisplay/sudovda.rs`): + device open by GUID, `GET_VERSION` (0.2.1), `GET_WATCHDOG` (3 s), `ADD 1920×1080@60` → returns + adapter LUID + `target_id`, watchdog ping holds it, RAII `Drop` → `REMOVE`. **Gap:** with no GPU the + target does NOT activate into a WDDM display path (`QueryDisplayConfig` active paths stay 0 → no + `\\.\DisplayN` to resolve/capture). So **activation + name-resolution + capture defer to a real + GPU** (passthrough on the Proxmox VM, or a GPU box) — consistent with capture/NVENC deferring anyway. 3. **IDD arbitrary-mode + `Reconfigure` on 24H2/25H2** — does 5120×1440@240 apply, and does a remove+re-add (or re-modeset) hit the ~90 ms budget without a Settings-UI toggle? Make-or-break for "native client resolution, no scaling".