feat(host/windows): SudoVDA virtual-display backend (control path)
Windows VirtualDisplay backend driving SudoVDA (the Apollo IDD) via its DeviceIoControl IOCTL protocol: open by interface GUID, ADD at the client's exact WxH@Hz (mode baked into the IOCTL, no EDID seeding), mandatory watchdog ping thread, QueryDisplayConfig name resolution, RAII Drop -> REMOVE. Wired behind the existing VirtualDisplay trait (open()/probe() Windows arms). Validated live on the GPU-less VM (standalone + via the trait, env-gated test): version 0.2.1, ADD 1920x1080@60 -> target, watchdog hold, REMOVE. Monitor activation into a WDDM path (-> capturable \\.\DisplayN) needs a real GPU and is deferred with capture/NVENC. docs/windows-host.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+105
@@ -2559,6 +2559,7 @@ dependencies = [
|
|||||||
"wayland-protocols-misc",
|
"wayland-protocols-misc",
|
||||||
"wayland-protocols-wlr",
|
"wayland-protocols-wlr",
|
||||||
"wayland-scanner",
|
"wayland-scanner",
|
||||||
|
"windows",
|
||||||
"x509-parser",
|
"x509-parser",
|
||||||
"xkbcommon",
|
"xkbcommon",
|
||||||
]
|
]
|
||||||
@@ -4079,12 +4080,107 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
@@ -4169,6 +4265,15 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
|
|||||||
@@ -101,3 +101,16 @@ khronos-egl = { version = "6", features = ["dynamic"] }
|
|||||||
# GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the
|
# GPU-copy into an exportable allocation, export OPAQUE_FD → cuImportExternalMemory (the
|
||||||
# officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver).
|
# officially-supported CUDA pairing; raw dmabuf fds are rejected by the desktop driver).
|
||||||
ash = "0.38"
|
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",
|
||||||
|
] }
|
||||||
|
|||||||
@@ -456,10 +456,16 @@ pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
|||||||
Compositor::Wlroots => Ok(Box::new(wlroots::WlrootsDisplay::new()?)),
|
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;
|
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(()),
|
Compositor::Gamescope | Compositor::Mutter | Compositor::Wlroots => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
let _ = compositor;
|
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;
|
mod mutter;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod wlroots;
|
mod wlroots;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod sudovda;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -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<u32> {
|
||||||
|
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<String> {
|
||||||
|
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::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() 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<HANDLE> {
|
||||||
|
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::<SP_DEVICE_INTERFACE_DATA>() 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::<SP_DEVICE_INTERFACE_DETAIL_DATA_W>() 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<Self> {
|
||||||
|
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<VirtualOutput> {
|
||||||
|
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::<AddParams>()) };
|
||||||
|
let mut out = [0u8; size_of::<AddOut>()];
|
||||||
|
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<AtomicBool>,
|
||||||
|
pinger: Option<JoinHandle<()>>,
|
||||||
|
#[allow(dead_code)] // consumed by the Windows capture backend (not yet wired)
|
||||||
|
gdi_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<RemoveParams>()) };
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-4
@@ -158,8 +158,10 @@ glass-to-glass / throughput numbers (no perf claim transfers from Linux).
|
|||||||
## Phased plan (host-first)
|
## Phased plan (host-first)
|
||||||
|
|
||||||
0. **Compile on MSVC** (Step 0 above). GPU-less. ← *start here*
|
0. **Compile on MSVC** (Step 0 above). GPU-less. ← *start here*
|
||||||
1. **SudoVDA `VirtualDisplay` backend** — add/resolve-GDI-name/keepalive/remove + `SetDisplayConfig`
|
1. **SudoVDA `VirtualDisplay` backend** — ✅ *control path landed* (`vdisplay/sudovda.rs`:
|
||||||
mode-set; RAII teardown. *Spike first*: does `ADD` bring up a monitor + mode-set on the VM (WARP)?
|
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 →
|
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
|
openh264 → existing FEC/transport. First end-to-end Windows session, GPU-less, against the Linux
|
||||||
`punktfunk-client-rs` or the new Windows client.
|
`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
|
1. **`cargo build -p punktfunk-host` on the VM** — count + triage the real MSVC errors before
|
||||||
estimating Step 0. (GPU-less.)
|
estimating Step 0. (GPU-less.)
|
||||||
2. **SudoVDA `ADD` on the VM** — does a virtual monitor come up + mode-set via WARP with no GPU?
|
2. **SudoVDA `ADD` on the VM** — ✅ *done 2026-06-15.* The control path is fully validated on the
|
||||||
Confirms the whole Phase 1 backend is VM-developable. (GPU-less.)
|
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
|
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
|
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".
|
"native client resolution, no scaling".
|
||||||
|
|||||||
Reference in New Issue
Block a user