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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user