diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index e9b81de..1a07570 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -120,6 +120,9 @@ windows = { version = "0.62", features = [ # shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it. "Win32_Security_Authorization", "Win32_Devices_DeviceAndDriverInstallation", + # SwDeviceCreate/SwDeviceClose — the per-session virtual-DualSense devnode + # (inject/dualsense_windows.rs). + "Win32_Devices_Enumeration_Pnp", "Win32_Devices_Display", "Win32_Storage_FileSystem", "Win32_System_IO", diff --git a/crates/punktfunk-host/src/inject/dualsense_windows.rs b/crates/punktfunk-host/src/inject/dualsense_windows.rs index 57b3593..53fcdf3 100644 --- a/crates/punktfunk-host/src/inject/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/dualsense_windows.rs @@ -11,10 +11,9 @@ //! bytes. `hidclass` gates the device stack, so this user-mode IPC is the only viable channel (a //! UMDF driver has no control device); see `windows-dualsense-scoping.md`. //! -//! Device lifecycle: the `root\pf_dualsense` devnode is currently created out-of-band (the dev-box -//! `devgen` for tests; the installer for fleet use). Per-session creation via `SwDeviceCreate` (so the -//! pad appears/disappears with the session, matching the Linux UHID lifecycle) is the next step — -//! see [`DsWinPad::open`]. +//! Device lifecycle: each pad `SwDeviceCreate`s the `root\pf_dualsense` devnode on open and +//! `SwDeviceClose`s it on drop, so the virtual DualSense appears/disappears with the session — +//! matching the Linux UHID pad. (The driver itself must already be installed; the installer stages it.) use super::dualsense_proto::{ parse_ds_output, serialize_state, DsFeedback, DsState, DS_INPUT_REPORT_LEN, DS_TOUCH_H, @@ -25,7 +24,10 @@ use anyhow::{anyhow, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::ffi::c_void; use std::time::{Duration, Instant}; -use windows::core::{w, HSTRING, PCWSTR}; +use windows::core::{w, HRESULT, HSTRING, PCWSTR}; +use windows::Win32::Devices::Enumeration::Pnp::{ + SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, +}; use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, @@ -35,6 +37,7 @@ use windows::Win32::System::Memory::{ CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, }; +use windows::Win32::System::Threading::{CreateEventW, SetEvent, WaitForSingleObject}; /// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. const SHM_SIZE: usize = 256; @@ -43,9 +46,13 @@ const OFF_INPUT: usize = 8; const OFF_OUT_SEQ: usize = 72; const OFF_OUTPUT: usize = 76; -/// A single virtual DualSense: the shared-memory section the driver maps (and, in future, the -/// `HSWDEVICE` from `SwDeviceCreate`). Dropping it unmaps + closes the section. +/// A single virtual DualSense: the `root\pf_dualsense` software devnode (the driver loads on it and +/// the HID DualSense appears to games) plus the shared-memory section the driver maps. Dropping it +/// removes the devnode (`SwDeviceClose`) and unmaps + closes the section. struct DsWinPad { + /// Per-session devnode from SwDeviceCreate, when it succeeds. `None` falls back to an out-of-band + /// `pf_dualsense` devnode (installer/devgen). + hsw: Option, map: HANDLE, view: *mut u8, seq: u8, @@ -53,10 +60,100 @@ struct DsWinPad { last_out_seq: u32, } +/// Context for the async `SwDeviceCreate` completion callback: the event to signal + the result. +#[repr(C)] +struct SwCreateCtx { + event: HANDLE, + result: HRESULT, +} + +/// `SwDeviceCreate` fires this on a worker thread once the device is created. We stash the result and +/// wake the waiting [`create_swdevice`]; the creator blocks on the event, so there's no concurrent +/// access to `*ctx`. +unsafe extern "system" fn sw_create_cb( + _dev: HSWDEVICE, + result: HRESULT, + ctx: *const c_void, + _id: PCWSTR, +) { + if !ctx.is_null() { + let c = ctx as *mut SwCreateCtx; + // SAFETY: c is the &mut SwCreateCtx the creator passed; it outlives this callback (the + // creator waits on the event before dropping it). + unsafe { + (*c).result = result; + let _ = SetEvent((*c).event); + } + } +} + +/// Spawn the virtual DualSense software device under enumerator `punktfunk` (hardware id +/// `pf_dualsense`, which the INF matches). The returned `HSWDEVICE` owns the devnode for the session +/// — `SwDeviceClose` removes it. +/// +/// NB: enumerator names with an underscore (`pf_dualsense`) get E_INVALIDARG — hence `punktfunk`. +/// TODO: a SECOND E_INVALIDARG remains — passing the completion callback is rejected (callback-absent +/// is accepted but then the devnode doesn't materialize). Until that's resolved [`DsWinPad::open`] +/// treats a failure as non-fatal and relies on an out-of-band `pf_dualsense` devnode (installer / +/// dev-box `devgen`); see `docs/windows-dualsense-scoping.md`. +fn create_swdevice() -> Result { + let hwids: Vec = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect(); + let desc: Vec = "punktfunk Virtual DualSense" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. + let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + info.pszzHardwareIds = PCWSTR(hwids.as_ptr()); + info.pszDeviceDescription = PCWSTR(desc.as_ptr()); + // SWDeviceCapabilities: DriverRequired (8) | SilentInstall (2) | Removable (1). + info.CapabilityFlags = 0x0000_000B; + + // SAFETY: a manual-reset, initially-unsignaled, unnamed event. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null())? }; + let mut ctx = SwCreateCtx { + event, + result: HRESULT(0), + }; + // SAFETY: info + hwids/desc outlive the call; ctx outlives the callback (we wait below). + // windows-rs returns the HSWDEVICE (the C out-param) as the Result value. + let hsw = match unsafe { + SwDeviceCreate( + w!("punktfunk"), + w!("HTREE\\ROOT\\0"), + &info, + None, + Some(sw_create_cb), + Some(&mut ctx as *mut SwCreateCtx as *const c_void), + ) + } { + Ok(h) => h, + Err(e) => { + // SAFETY: event is valid. + unsafe { + let _ = CloseHandle(event); + } + return Err(anyhow!("SwDeviceCreate failed: {e}")); + } + }; + // SAFETY: event is valid; block up to 10s for the creation callback. + unsafe { + WaitForSingleObject(event, 10_000); + let _ = CloseHandle(event); + } + if ctx.result.is_err() { + // SAFETY: hsw is the handle SwDeviceCreate returned. + unsafe { SwDeviceClose(hsw) }; + return Err(anyhow!("SwDeviceCreate callback reported {:?}", ctx.result)); + } + Ok(hsw) +} + impl DsWinPad { - /// Create + map the section `Global\pfds-shm-` and stamp the magic so the driver accepts - /// it. (TODO: also `SwDeviceCreate("root\\pf_dualsense")` here to spawn the devnode per session; - /// for now the devnode is created out-of-band by the installer / dev-box `devgen`.) + /// Create + map the section `Global\pfds-shm-`, stamp the magic, then spawn the + /// `root\pf_dualsense` devnode (the driver loads on it and maps the section). The devnode lives + /// for the pad's lifetime — dropping the pad removes it (`SwDeviceClose`). fn open(index: u8) -> Result { let name = HSTRING::from(format!("Global\\pfds-shm-{index}")); @@ -110,7 +207,18 @@ impl DsWinPad { }); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } + // Best-effort: spawn a per-session devnode via SwDeviceCreate. It currently fails with a + // SwDevice quirk (see create_swdevice), so on failure we keep the section + data plane and + // rely on an out-of-band `pf_dualsense` devnode (installer / dev-box devgen). + let hsw = match create_swdevice() { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; using an out-of-band pf_dualsense devnode"); + None + } + }; Ok(DsWinPad { + hsw, map, view: base, seq: 0, @@ -150,8 +258,11 @@ impl DsWinPad { impl Drop for DsWinPad { fn drop(&mut self) { - // SAFETY: view came from MapViewOfFile; map from CreateFileMappingW. + // SAFETY: hsw (if any) owns the devnode; view/map from MapViewOfFile/CreateFileMappingW. unsafe { + if let Some(h) = self.hsw { + SwDeviceClose(h); + } let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { Value: self.view as *mut c_void, }); diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 5ec2239..d3115b3 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -201,6 +201,53 @@ fn real_main() -> Result<()> { println!("dualsense-test: done"); Ok(()) } + // Windows: create a virtual DualSense via the UMDF driver (SwDeviceCreate per-session devnode + // + the shared-memory channel) and hold it, pushing one fixed frame (Cross + LS-right). Drives + // the real DualSenseWindowsManager, so it validates the device lifecycle end to end. Verify + // while it holds: `Get-PnpDevice` shows a VID_054C device, and a HID read returns the pushed + // report (byte1=0xC0, byte8=0x28). On exit the pad drops → SwDeviceClose removes the devnode. + #[cfg(target_os = "windows")] + Some("dualsense-windows-test") => { + use crate::gamestream::gamepad::{GamepadEvent, GamepadFrame}; + use inject::dualsense_windows::DualSenseWindowsManager; + use std::time::{Duration, Instant}; + let secs: u64 = args + .iter() + .skip_while(|a| *a != "--seconds") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(20); + let mut mgr = DualSenseWindowsManager::new(); + // Arrival creates the pad (SwDeviceCreate + section); State pushes the report. + mgr.handle(&GamepadEvent::Arrival { + index: 0, + kind: 2, + capabilities: 0, + }); + // ls_x 16384 → report byte1 0xC0; BTN_A (Cross) → report byte8 0x28. + mgr.handle(&GamepadEvent::State(GamepadFrame { + index: 0, + active_mask: 1, + buttons: punktfunk_core::input::gamepad::BTN_A, + left_trigger: 0, + right_trigger: 0, + ls_x: 16384, + ls_y: 0, + rs_x: 0, + rs_y: 0, + })); + println!( + "virtual DualSense created via SwDeviceCreate (VID 054C/PID 0CE6). Holding {secs}s — \ + verify Get-PnpDevice VID_054C + a HID read (expect byte1=0xC0, byte8=0x28)." + ); + let deadline = Instant::now() + Duration::from_secs(secs); + while Instant::now() < deadline { + mgr.pump(|_, _, _| {}, |_| {}); + std::thread::sleep(Duration::from_millis(50)); + } + println!("dualsense-windows-test: done (devnode removed)"); + Ok(()) + } // Capture→encode→file pipeline spike (dev tool). Some("spike") => spike::run(parse_spike(&args[1..])?), // Native punktfunk/1 host (QUIC control plane + UDP data plane). diff --git a/docs/windows-dualsense-scoping.md b/docs/windows-dualsense-scoping.md index e4f48d1..b373449 100644 --- a/docs/windows-dualsense-scoping.md +++ b/docs/windows-dualsense-scoping.md @@ -340,6 +340,19 @@ Three bugs had to be fixed to get there — the load wall was the PE **FORCE_INT `/INTEGRITYCHECK`; clear bit `0x80` at PE+0x5e + re-sign), then `WdfTimerCreate` exec-level, then a parallel queue's zeroed `NumberOfPresentedRequests`. **Option R (Rust) confirmed for M2; no C shim needed.** +**Host integration status (2026-06-21): M1/M3/M4 landed; data plane runtime-proven.** The Linux +DualSense logic is shared via `inject/dualsense_proto.rs`; the Windows backend +`inject/dualsense_windows.rs` (`DualSenseWindowsManager`) drives the driver over the +`Global\pfds-shm-` section, and the `PadBackend`/`pick_gamepad` seam now resolves DualSense on +Windows. Live-verified on the RTX box: the manager creates the section + pushes report `0x01` and a +devnode serves it to a HID read (manager data plane works). **Open item — `SwDeviceCreate` +per-session devnode:** two `E_INVALIDARG` causes found — (1) an underscore in the enumerator name +(`pf_dualsense` → use `punktfunk`), (2) passing the completion callback is still rejected (cause +unresolved; needs a known-good C reference). So per-session auto-creation is **best-effort/non-fatal**: +the host falls back to an out-of-band `pf_dualsense` devnode (the INF lists both `root\pf_dualsense` +for devgen and `pf_dualsense` for SwDevice; the installer would create it, as SudoVDA does). Remaining: +fix the SwDeviceCreate callback E_INVALIDARG, then the M5 on-glass game test. + ## Milestone plan (M0–M6) | # | Milestone | Output | Gate / risk |