diff --git a/CLAUDE.md b/CLAUDE.md index ee694e4..a4d0f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,23 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc capability; impulse-trigger rumble is unreachable through a virtual pad), and the UHID `hid-playstation` pads — **DualSense** (adaptive triggers, lightbar, touchpad, motion) and **DualShock 4** (lightbar, touchpad, motion, rumble; DualSense minus adaptive triggers / player - LEDs / mute). The UHID pads need a Linux host; off Linux they (and One/Series) fold into Xbox 360. - Clients auto-resolve the type from the physical controller (DS5→DualSense, DS4→DualShock 4, - Xbox One→Xbox One). Windows-host DualShock 4 (ViGEm) is not yet wired — Windows clients asking for - DS4 get Xbox 360 for now. + LEDs / mute). DualSense and DualShock 4 each have a Linux (UHID `hid-playstation`) **and a Windows + (UMDF minidriver)** backend — `inject/dualsense_windows.rs` + `inject/dualshock4_windows.rs`, one + driver serving either identity per a `device_type` byte the host stamps into shared memory (the DS4 + reuses the same SwDeviceCreate game-detection identity fix as the DualSense). One/Series stays + Linux-only and folds into Xbox 360 off it. Clients auto-resolve the type from the physical controller + (DS5→DualSense, DS4→DualShock 4, Xbox One→Xbox One). **Windows uses ZERO external gamepad + dependencies — ViGEmBus is gone.** Xbox 360 (XInput) runs on a UMDF2 **XUSB companion** driver + (`packaging/windows/xusb-driver/`, `inject/gamepad_windows.rs`) that registers `GUID_DEVINTERFACE_XUSB` + and answers the buffered XInput IOCTLs from a shared section, so classic `XInputGetState`/`SetState` + work with no kernel bus driver (validated live: slot connected, state + rumble round-trip; Xbox One + folds to this 360 path). All three UMDF drivers (DualSense/DS4 + XUSB) are bundled + pnputil-installed + by the Inno Setup installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`). + **Multi-pad ready**: the host stamps each pad's index into the device Location (`pszDeviceLocation`), + the driver reads it (`WdfDeviceAllocAndQueryProperty`) to map its own `*-shm-`, and + `UmdfHostProcessSharing=ProcessSharingDisabled` gives each pad its own host (per-pad statics) — + validated live with 2 distinct XInput slots + 2 DualSense pads. (Client-side multi-pad forwarding is + the remaining piece.) - **Windows host: implemented and shipping (all-vendor, x64-only).** `#[cfg(windows)]` backends behind the same traits as Linux — DXGI Desktop Duplication capture (`capture/dxgi.rs`), **SudoVDA** virtual display per session (`vdisplay/sudovda.rs`), GPU encode (NVENC `--features nvenc`; AMD/Intel diff --git a/Cargo.lock b/Cargo.lock index ca7153d..0469fd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2668,7 +2668,6 @@ dependencies = [ "utoipa", "utoipa-axum", "utoipa-scalar", - "vigem-client", "wasapi", "wayland-backend", "wayland-client", @@ -4072,15 +4071,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vigem-client" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52" -dependencies = [ - "winapi", -] - [[package]] name = "wait-timeout" version = "0.2.1" @@ -4334,22 +4324,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -4359,12 +4333,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.62.2" diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 8261b44..244e3e5 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -174,10 +174,8 @@ windows-service = "0.7" openh264 = "0.9" # WASAPI loopback audio capture (default render endpoint -> 48 kHz stereo f32 for the Opus path). wasapi = "0.23" -# Virtual Xbox 360 gamepad via ViGEmBus (the uinput-xpad analogue) — driver installed separately. -# `unstable_xtarget_notification` exposes the rumble/LED back-channel (the game's force-feedback → -# `request_notification`), the analogue of the Linux uinput EV_FF read path. -vigem-client = { version = "0.1", features = ["unstable_xtarget_notification"] } +# Virtual Xbox 360 gamepad: the in-tree XUSB companion UMDF driver (packaging/windows/xusb-driver), +# driven over shared memory from inject/gamepad_windows.rs — no ViGEmBus dependency. # NVENC hardware encoder (NVENC SDK, D3D11 input). The SDK pins `cudarc` with # `cuda-version-from-build-system` (a build-time CUDA-toolkit probe); its `ci-check` feature switches # cudarc to `dynamic-loading` (loads nvcuda.dll at runtime — nothing needed at build), which is how diff --git a/crates/punktfunk-host/src/inject.rs b/crates/punktfunk-host/src/inject.rs index 543fcad..e77e800 100644 --- a/crates/punktfunk-host/src/inject.rs +++ b/crates/punktfunk-host/src/inject.rs @@ -432,13 +432,20 @@ pub mod dualsense_proto; pub mod dualsense_windows; #[cfg(target_os = "linux")] pub mod dualshock4; +/// Transport-independent DualShock 4 HID codec used by the Windows UMDF-driver backend +/// ([`dualshock4_windows`]). (The Linux backend still carries its own copy — see the module FIXME.) +#[cfg(any(target_os = "linux", target_os = "windows"))] +pub mod dualshock4_proto; +/// Windows: virtual DualShock 4 via the same UMDF minidriver + shared-memory channel (device-type 1). +#[cfg(target_os = "windows")] +pub mod dualshock4_windows; #[cfg(target_os = "linux")] pub mod gamepad; -/// Windows: virtual Xbox 360 pads via ViGEmBus. +/// Windows: virtual Xbox 360 pads via the in-tree XUSB companion UMDF driver (classic XInput). #[cfg(target_os = "windows")] #[path = "inject/gamepad_windows.rs"] pub mod gamepad; -/// Stub — virtual gamepads need Linux uinput or Windows ViGEmBus; events are dropped elsewhere. +/// Stub — virtual gamepads need Linux uinput or the Windows UMDF drivers; events are dropped elsewhere. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub mod gamepad { #[derive(Default)] diff --git a/crates/punktfunk-host/src/inject/dualsense_windows.rs b/crates/punktfunk-host/src/inject/dualsense_windows.rs index eb3c15f..28b7ac0 100644 --- a/crates/punktfunk-host/src/inject/dualsense_windows.rs +++ b/crates/punktfunk-host/src/inject/dualsense_windows.rs @@ -25,7 +25,7 @@ use anyhow::{anyhow, Result}; use punktfunk_core::quic::{HidOutput, RichInput}; use std::ffi::c_void; use std::time::{Duration, Instant}; -use windows::core::{w, HRESULT, HSTRING, PCWSTR}; +use windows::core::{w, GUID, HRESULT, HSTRING, PCWSTR}; use windows::Win32::Devices::Enumeration::Pnp::{ SwDeviceClose, SwDeviceCreate, HSWDEVICE, SW_DEVICE_CREATE_INFO, }; @@ -40,12 +40,17 @@ use windows::Win32::System::Memory::{ }; 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; -const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" -const OFF_INPUT: usize = 8; -const OFF_OUT_SEQ: usize = 72; -const OFF_OUTPUT: usize = 76; +/// Shared-section layout — must match `packaging/windows/dualsense-driver/src/lib.rs`. `pub(super)` +/// so the sibling DualShock 4 backend ([`super::dualshock4_windows`]) reuses the exact offsets. +pub(super) const SHM_SIZE: usize = 256; +pub(super) const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" +pub(super) const OFF_INPUT: usize = 8; +pub(super) const OFF_OUT_SEQ: usize = 72; +pub(super) const OFF_OUTPUT: usize = 76; +/// Device-type selector the driver reads to choose which HID identity/descriptor it serves: 0 = +/// DualSense (the default — the section is zeroed), 1 = DualShock 4. +pub(super) const OFF_DEVTYPE: usize = 140; +pub(super) const DEVTYPE_DUALSHOCK4: u8 = 1; /// A single virtual DualSense: the SwDeviceCreate'd `pf_pad_` software devnode (the driver /// loads on it and the HID DualSense appears to games) plus the shared-memory section the driver maps. @@ -86,31 +91,103 @@ unsafe extern "system" fn sw_create_cb( } } -/// Spawn the per-session virtual DualSense devnode for pad `index` under enumerator `punktfunk` -/// (instance `pf_pad_`, hardware id `pf_dualsense` which the INF matches). The returned -/// `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the pad appears/disappears with the -/// session and nothing persists. +/// The PnP identity for a virtual controller devnode — varies by controller type so the same +/// [`create_swdevice`] builds a DualSense (`VID_054C&PID_0CE6`) or a DualShock 4 +/// (`VID_054C&PID_09CC`). The fields map onto the `SW_DEVICE_CREATE_INFO` identity discussed below. +pub(super) struct SwDeviceProfile<'a> { + /// PnP instance id — distinct namespaces per type (`pf_pad_` vs `pf_ds4_`) so the two + /// never reuse the same devnode shell. + pub instance: &'a str, + /// Index for the deterministic per-pad ContainerId. + pub container_index: u8, + /// The INF-matched hardware id (`pf_dualsense` / `pf_dualshock4`), listed FIRST so the INF binds. + pub hwid: &'a str, + /// The USB VID&PID token (`VID_054C&PID_0CE6`) used to synthesize the USB hardware/compatible ids. + pub usb_vid_pid: &'a str, + /// Device description shown in Device Manager. + pub description: &'a str, +} + +/// Spawn the per-session virtual controller devnode under enumerator `punktfunk` (instance +/// `profile.instance`). The returned `HSWDEVICE` owns it — `SwDeviceClose` removes it on drop, so the +/// pad appears/disappears with the session and nothing persists. +/// +/// **Game-detection identity** (see `docs/windows-dualsense-game-detection.md`). `HIDD_ATTRIBUTES` +/// alone (VID/PID via the IOCTL) satisfies SDL/HIDAPI/RawInput, but a native PS5 path (libScePad- +/// style raw HID) classifies the *connection type* by walking from the HID child to its parent +/// (`CM_Get_Parent`) and string-matching `"USB"`/`"BTHENUM"` in that parent's +/// `DEVPKEY_Device_CompatibleIds`; with no bus identity the pad reads as `UNKNOWN` and the native +/// path rejects it. So we set, via `SW_DEVICE_CREATE_INFO` (NOT `pProperties` — bus/identity info is +/// create-time-only and a `DEVPROPERTY` write of these keys is ignored): +/// - `pszzCompatibleIds` starting with a `USB\` token → the parent walk resolves `bus_type = USB`. +/// - `pszzHardwareIds` = `pf_dualsense` **first** (so the INF still binds our UMDF driver) followed +/// by `USB\VID_054C&PID_0CE6[&REV_0100]`, which makes hidclass derive the real-DualSense child +/// hardware ids `HID\VID_054C&PID_0CE6[&REV_0100]` (the set a genuine USB DS5 exposes). +/// - a deterministic, non-sentinel per-pad `pContainerId` (groups the pad's devnodes; avoids the +/// null-sentinel ContainerId that trips an `xinput1_4` slot-skip bug). +/// +/// (Validated live on `.173`: the INF still binds, the child gains the `HID\VID&PID` ids, and the +/// parent walk reports USB. Remaining gap: GameInput parses VID/PID from the child *instance path* +/// `HID\punktfunk\…`, which only a real USB-bus instance path — a bus driver — would change.) /// /// Two requirements each yield E_INVALIDARG if violated: the enumerator name must not contain `_` /// (hence `punktfunk`, not `pf_dualsense`), and the completion callback is mandatory (the docs mark /// `pCallback` as `[in]`, not optional — a NULL callback is rejected). The caller must be /// Administrator (the host service runs as LocalSystem). -fn create_swdevice(index: u8) -> Result { - let hwids: Vec = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect(); - let instid: Vec = format!("pf_pad_{index}") +pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result { + // Build a double-NUL-terminated UTF-16 multi-sz from a list of ids. + let multi_sz = |ids: &[&str]| -> Vec { + ids.iter() + .flat_map(|s| s.encode_utf16().chain(std::iter::once(0))) + .chain(std::iter::once(0)) + .collect() + }; + let usb_rev = format!("USB\\{}&REV_0100", p.usb_vid_pid); + let usb = format!("USB\\{}", p.usb_vid_pid); + let hwids = multi_sz(&[ + p.hwid, // FIRST → the INF binds our UMDF driver on this id + usb_rev.as_str(), + usb.as_str(), + ]); + let compat = multi_sz(&[ + usb.as_str(), // a `USB\` token → native bus-type detection resolves USB + "USB\\Class_03&SubClass_00&Prot_00", + "USB\\Class_03", + ]); + let instid: Vec = p + .instance .encode_utf16() .chain(std::iter::once(0)) .collect(); - let desc: Vec = "punktfunk Virtual DualSense" + let desc: Vec = p + .description .encode_utf16() .chain(std::iter::once(0)) .collect(); - // SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. + // The pad index, stamped into the device Location — the driver reads it to map `pfds-shm-` + // (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return). + let loc: Vec = format!("{}", p.container_index) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // Deterministic per-pad ContainerId {50464453-0000-0000-0000-0000000000} ("PFDS"). + let container = GUID::from_values( + 0x5046_4453, + 0x0000, + 0x0000, + [0, 0, 0, 0, 0, 0, 0, p.container_index], + ); + + // SAFETY: zeroed then the fields we use are set; cbSize identifies the struct version. The id + // buffers and `container` outlive the SwDeviceCreate call (we wait on the event before return). let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() }; info.cbSize = std::mem::size_of::() as u32; info.pszInstanceId = PCWSTR(instid.as_ptr()); info.pszzHardwareIds = PCWSTR(hwids.as_ptr()); + info.pszzCompatibleIds = PCWSTR(compat.as_ptr()); + info.pContainerId = &container; info.pszDeviceDescription = PCWSTR(desc.as_ptr()); + info.pszDeviceLocation = PCWSTR(loc.as_ptr()); info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable // SAFETY: a manual-reset, initially-unsignaled, unnamed event. @@ -157,56 +234,66 @@ fn create_swdevice(index: u8) -> Result { Ok(hsw) } +/// Create + map the named section `Global\pfds-shm-`, zeroed, with a permissive DACL so the +/// WUDFHost (whatever account it runs as) can open it. Returns `(section handle, mapped base)`; the +/// caller stamps the device-type + initial input report and finally the magic. Shared by both Windows +/// pad backends (DualSense + DualShock 4). +pub(super) fn create_shm_section(index: u8) -> Result<(HANDLE, *mut u8)> { + let name = HSTRING::from(format!("Global\\pfds-shm-{index}")); + + let mut psd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS when + // the process exits — acceptable for a host-lifetime object). + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + w!("D:(A;;GA;;;WD)"), + SDDL_REVISION_1, + &mut psd, + None, + )?; + } + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }; + + // SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above. + let map = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + Some(&sa), + PAGE_READWRITE, + 0, + SHM_SIZE as u32, + PCWSTR(name.as_ptr()), + )? + }; + // SAFETY: map is a valid section handle; map the whole thing. + let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) }; + if view.Value.is_null() { + // SAFETY: map is valid. + unsafe { + let _ = CloseHandle(map); + } + return Err(anyhow!("MapViewOfFile failed for {name}")); + } + let base = view.Value as *mut u8; + // SAFETY: base points at SHM_SIZE writable bytes. + unsafe { std::ptr::write_bytes(base, 0, SHM_SIZE) }; + Ok((map, base)) +} + impl DsWinPad { /// 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}")); - - // A permissive DACL so the WUDFHost (whatever account it runs as) can open the section. - let mut psd = PSECURITY_DESCRIPTOR::default(); - // SAFETY: the SDDL literal is valid; psd receives an allocated descriptor (freed by the OS - // when the process exits — acceptable for a host-lifetime object). - unsafe { - ConvertStringSecurityDescriptorToSecurityDescriptorW( - w!("D:(A;;GA;;;WD)"), - SDDL_REVISION_1, - &mut psd, - None, - )?; - } - let sa = SECURITY_ATTRIBUTES { - nLength: std::mem::size_of::() as u32, - lpSecurityDescriptor: psd.0, - bInheritHandle: false.into(), - }; - - // SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above. - let map = unsafe { - CreateFileMappingW( - INVALID_HANDLE_VALUE, - Some(&sa), - PAGE_READWRITE, - 0, - SHM_SIZE as u32, - PCWSTR(name.as_ptr()), - )? - }; - // SAFETY: map is a valid section handle; map the whole thing. - let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) }; - if view.Value.is_null() { - // SAFETY: map is valid. - unsafe { - let _ = CloseHandle(map); - } - return Err(anyhow!("MapViewOfFile failed for {name}")); - } - let base = view.Value as *mut u8; - // Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). + let (map, base) = create_shm_section(index)?; + // Stamp the neutral input report, then the magic LAST (the driver only accepts the section + // once magic is set). The device-type stays 0 (DualSense — the section is already zeroed). // SAFETY: base points at SHM_SIZE writable bytes. unsafe { - std::ptr::write_bytes(base, 0, SHM_SIZE); std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS_INPUT_REPORT_LEN], { let mut r = [0u8; DS_INPUT_REPORT_LEN]; serialize_state(&mut r, &DsState::neutral(), 0, 0); @@ -217,7 +304,14 @@ impl DsWinPad { // Spawn the per-session devnode via SwDeviceCreate; `SwDeviceClose` removes it on drop. On the // rare failure we keep the section + data plane and fall back to an out-of-band `pf_dualsense` // devnode (installer / dev-box devgen). - let hsw = match create_swdevice(index) { + let inst = format!("pf_pad_{index}"); + let hsw = match create_swdevice(&SwDeviceProfile { + instance: &inst, + container_index: index, + hwid: "pf_dualsense", + usb_vid_pid: "VID_054C&PID_0CE6", + description: "punktfunk Virtual DualSense", + }) { Ok(h) => Some(h), Err(e) => { tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; falling back to an out-of-band pf_dualsense devnode"); diff --git a/crates/punktfunk-host/src/inject/dualshock4_proto.rs b/crates/punktfunk-host/src/inject/dualshock4_proto.rs new file mode 100644 index 0000000..e3be66d --- /dev/null +++ b/crates/punktfunk-host/src/inject/dualshock4_proto.rs @@ -0,0 +1,180 @@ +//! Transport-independent DualShock 4 HID contract — the pure report codec used by the Windows +//! UMDF-driver backend ([`super::dualshock4_windows`]). +//! +//! FIXME(ds4-dedup): the Linux UHID backend ([`super::dualshock4`]) still carries its own byte- +//! identical copy of this codec (`serialize_state` / `parse_ds4_output` / `Ds4Feedback` / the touch +//! dims). Fold it onto this module once the Linux build can be re-validated (it is `cfg(linux)`, so +//! it can't be compile-checked from a Windows host). Keep the two in sync until then. +//! +//! The PS4 sibling of [`super::dualsense_proto`]: the pure report codec with no transport. The DS4 +//! reuses the DualSense [`DsState`] controller model + its `GameStream`/XInput mapper +//! ([`DsState::from_gamepad`]) — only the report *byte layout*, the touchpad resolution, and the +//! feedback report differ. The Linux backend writes report `0x01` to `/dev/uhid` and reads `0x05` via +//! `UHID_OUTPUT`; the Windows backend pushes `0x01` to the UMDF driver and pulls `0x05` back over its +//! shared-memory channel — both build/parse the exact same bytes here. +//! +//! Field offsets are the canonical real-DS4-USB layout the kernel `struct +//! dualshock4_input_report_usb` / `_output_report_common` parse. + +use super::dualsense_proto::{DsState, Touch}; +use punktfunk_core::quic::HidOutput; + +/// DualShock 4 v2 USB identity (Sony Interactive Entertainment / CUH-ZCT2). +pub const DS4_VENDOR: u16 = 0x054C; +pub const DS4_PRODUCT: u16 = 0x09CC; +/// USB input report `0x01` is 64 bytes total (report id + 63-byte body). +pub const DS4_INPUT_REPORT_LEN: usize = 64; +/// The DualShock 4 touchpad resolution the kernel advertises (ABS_MT 0..1919 / 0..941). Narrower +/// than the DualSense's 1920×1080. +pub const DS4_TOUCH_W: u16 = 1920; +pub const DS4_TOUCH_H: u16 = 942; + +/// Pack one touchpad contact into the DS4's 4-byte point (same bit layout as the DualSense's: +/// byte0 bit7 = NOT-active, bits0-6 = id; 12-bit X then 12-bit Y). +fn pack_touch(dst: &mut [u8], t: &Touch) { + dst[0] = (t.id & 0x7F) | if t.active { 0 } else { 0x80 }; + // Never emit the extent itself — the kernel advertises 0..=W-1 / 0..=H-1. + let (x, y) = (t.x.min(DS4_TOUCH_W - 1), t.y.min(DS4_TOUCH_H - 1)); + dst[1] = (x & 0xFF) as u8; + dst[2] = (((x >> 8) & 0x0F) as u8) | (((y & 0x0F) as u8) << 4); + dst[3] = ((y >> 4) & 0xFF) as u8; +} + +/// Serialize a full DS4 input report `0x01` (pure — unit-testable without a transport). Field offsets +/// per the kernel's `struct dualshock4_input_report_usb` { report_id; common; num_touch; touch[3]; +/// rsvd[3] } where `common` = { x,y,rx,ry; buttons[3]; z,rz; sensor_ts le16; temp; gyro[3] le16; +/// accel[3] le16; rsvd[5]; status[2]; rsvd }. The report id is byte 0, so a `common` field at struct +/// offset N sits at report byte N+1. +pub fn serialize_state(r: &mut [u8; DS4_INPUT_REPORT_LEN], st: &DsState, counter: u8, ts: u16) { + r[0] = 0x01; // report id + r[1] = st.lx; + r[2] = st.ly; + r[3] = st.rx; + r[4] = st.ry; + r[5] = (st.dpad & 0x0F) | (st.buttons[0] & 0xF0); // dpad hat (low) + face buttons (high) + r[6] = st.buttons[1]; // L1/R1, L2/R2 digital, Share/Options, L3/R3 + r[7] = (st.buttons[2] & 0x03) | ((counter & 0x3F) << 2); // PS + touchpad-click + report counter + r[8] = st.l2; // L2 analog (z) + r[9] = st.r2; // R2 analog (rz) + r[10..12].copy_from_slice(&ts.to_le_bytes()); // sensor_timestamp (struct off 9) + // r[12] temperature stays 0 + for (i, v) in st.gyro.iter().enumerate() { + r[13 + i * 2..15 + i * 2].copy_from_slice(&v.to_le_bytes()); // gyro at struct off 12 + } + for (i, v) in st.accel.iter().enumerate() { + r[19 + i * 2..21 + i * 2].copy_from_slice(&v.to_le_bytes()); // accel at struct off 18 + } + // r[25..30] reserved2. + // status[0] (struct off 29 → r[30]): bit4 = cable/wired, low nibble = battery capacity. Report + // wired + full (0x1B) so SteamOS / the kernel never warn "low battery" on a virtual pad. + r[30] = 0x10 | 0x0B; + // r[31] status[1] = 0 (no headphone/mic), r[32] reserved3 = 0. + r[33] = 1; // num_touch_reports: one frame carrying the two contacts (a real DS4 always sends one) + r[34] = ts as u8; // touch_reports[0].timestamp + pack_touch(&mut r[35..39], &st.touch[0]); // touch point 0 + pack_touch(&mut r[39..43], &st.touch[1]); // touch point 1 + // remaining touch frames (r[43..61]) + reserved (r[61..64]) stay zero +} + +/// What one feedback pass extracted from the device's HID output reports. Rumble rides the universal +/// 0xCA plane; the lightbar rides the HID-output 0xCD plane (DS4 has no player LEDs or adaptive +/// triggers, so those never appear). +#[derive(Default)] +pub struct Ds4Feedback { + pub hidout: Vec, + /// `(low, high)` motor levels (0..=0xFF00), if a report carried them. + pub rumble: Option<(u16, u16)>, + /// Lightbar RGB, if the report carried it (deduped by the manager). + pub led: Option<(u8, u8, u8)>, +} + +/// Parse a DualShock 4 USB output report (`0x05`) into a [`Ds4Feedback`]. Layout per the kernel +/// `struct dualshock4_output_report_common`: valid_flag0 (bit0 motor, bit1 LED, bit2 blink) at [1], +/// valid_flag1 [2], reserved [3], motor_right (weak/small) [4], motor_left (strong/large) [5], +/// lightbar R/G/B [6..9], blink on/off [9..11]. Gated on the valid-flags so a rumble-only write +/// doesn't masquerade as a lightbar change. +pub fn parse_ds4_output(data: &[u8], fb: &mut Ds4Feedback) { + if data.first() != Some(&0x05) || data.len() < 11 { + return; // not the USB output report (BT 0x11 is shifted) / too short + } + let flag0 = data[1]; + if flag0 & 0x01 != 0 { + // motor_left (strong/large/low-freq) at [5], motor_right (weak/small/high-freq) at [4]; + // scale 0..255 → 0..0xFF00, same (low, high) convention as the other backends. + let low = (data[5] as u16) << 8; + let high = (data[4] as u16) << 8; + fb.rumble = Some((low, high)); + } + if flag0 & 0x02 != 0 { + fb.led = Some((data[6], data[7], data[8])); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Report 0x01 places sticks/buttons/triggers/motion/touch at the kernel's DS4 offsets. + #[test] + fn serialize_offsets() { + use punktfunk_core::input::gamepad as gs; + let mut st = DsState::from_gamepad( + gs::BTN_A | gs::BTN_DPAD_UP | gs::BTN_LB, + 16384, // lx (right) + 0, + 0, + -32768, // ry (down) — inverted to 0xFF + 200, // L2 + 0, + ); + st.gyro = [0x0102, 0x0304, 0x0506]; + st.accel = [0x1112, 0x1314, 0x1516]; + st.touch[0] = Touch { + active: true, + id: 0, + x: 100, + y: 200, + }; + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + serialize_state(&mut r, &st, 0, 0); + assert_eq!(r[0], 0x01); // report id + assert_eq!(r[8], 200); // L2 analog at byte 8 (not the DualSense's byte 5) + assert_eq!(r[5] & 0x0F, 0); // dpad hat = N (up) + assert_eq!(r[5] & 0x20, 0x20); // Cross (A) face bit + assert_eq!(r[6] & 0x01, 0x01); // L1 + // gyro le16 at 13..19, accel le16 at 19..25. + assert_eq!(&r[13..19], &[0x02, 0x01, 0x04, 0x03, 0x06, 0x05]); + assert_eq!(&r[19..25], &[0x12, 0x11, 0x14, 0x13, 0x16, 0x15]); + assert_eq!(r[33], 1); // one touch frame + assert_eq!(r[35] & 0x80, 0); // contact 0 active (bit7 clear) + assert_eq!(r[35] & 0x7F, 0); // contact id 0 + assert_eq!(r[30] & 0x10, 0x10); // cable/wired bit set + } + + /// A DS4 USB output report (`0x05`) with motor + LED flags parses into rumble (0xCA) and a + /// lightbar `Led` (0xCD); a rumble-only report (no LED flag) leaves the lightbar untouched. + #[test] + fn parse_output_rumble_and_lightbar() { + let mut report = [0u8; 32]; + report[0] = 0x05; + report[1] = 0x01 | 0x02; // MOTOR | LED + report[4] = 0x40; // motor_right (weak/high) + report[5] = 0x80; // motor_left (strong/low) + report[6] = 0x11; // R + report[7] = 0x22; // G + report[8] = 0x33; // B + let mut fb = Ds4Feedback::default(); + parse_ds4_output(&report, &mut fb); + assert_eq!(fb.rumble, Some((0x8000, 0x4000))); // (low=strong, high=weak) + assert_eq!(fb.led, Some((0x11, 0x22, 0x33))); + + let mut motor_only = [0u8; 32]; + motor_only[0] = 0x05; + motor_only[1] = 0x01; // MOTOR only + motor_only[5] = 0x10; + let mut fb2 = Ds4Feedback::default(); + parse_ds4_output(&motor_only, &mut fb2); + assert!(fb2.rumble.is_some()); + assert_eq!(fb2.led, None); // lightbar not asserted → no spurious change + } +} diff --git a/crates/punktfunk-host/src/inject/dualshock4_windows.rs b/crates/punktfunk-host/src/inject/dualshock4_windows.rs new file mode 100644 index 0000000..918c59a --- /dev/null +++ b/crates/punktfunk-host/src/inject/dualshock4_windows.rs @@ -0,0 +1,300 @@ +//! Virtual Sony DualShock 4 on Windows via the UMDF minidriver — the PS4 sibling of +//! [`super::dualsense_windows`]. Same transport (a per-session `SwDeviceCreate` devnode + the +//! `Global\pfds-shm-` shared section the driver maps), same controller model ([`DsState`]); only +//! the PnP identity (`VID_054C&PID_09CC`, hardware id `pf_dualshock4`) and the report codec +//! ([`super::dualshock4_proto`]) differ. The host stamps `device_type = 1` (DualShock 4) into the +//! section so the one UMDF driver serves the DS4 descriptor / attributes / features instead of the +//! DualSense ones. Feedback is motor rumble (universal 0xCA plane) + the lightbar (0xCD `Led`); a DS4 +//! has no adaptive triggers / player LEDs. + +use super::dualsense_proto::DsState; +use super::dualsense_windows::{ + create_shm_section, create_swdevice, SwDeviceProfile, DEVTYPE_DUALSHOCK4, OFF_DEVTYPE, + OFF_INPUT, OFF_OUTPUT, OFF_OUT_SEQ, SHM_MAGIC, +}; +use super::dualshock4_proto::{ + parse_ds4_output, serialize_state, Ds4Feedback, DS4_INPUT_REPORT_LEN, DS4_TOUCH_H, DS4_TOUCH_W, +}; +use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use anyhow::Result; +use punktfunk_core::quic::{HidOutput, RichInput}; +use std::ffi::c_void; +use std::time::{Duration, Instant}; +use windows::Win32::Devices::Enumeration::Pnp::{SwDeviceClose, HSWDEVICE}; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Memory::{UnmapViewOfFile, MEMORY_MAPPED_VIEW_ADDRESS}; + +/// A single virtual DualShock 4: the `SwDeviceCreate`'d `pf_ds4_` devnode plus the mapped +/// shared section. Dropping it removes the devnode and unmaps + closes the section. +struct Ds4WinPad { + hsw: Option, + map: HANDLE, + view: *mut u8, + counter: u8, + ts: u16, + last_out_seq: u32, +} + +impl Ds4WinPad { + /// Create + map the section, stamp `device_type = DualShock 4` + a neutral report + the magic, + /// then spawn the `pf_ds4_` devnode (the driver loads on it and maps the section). + fn open(index: u8) -> Result { + let (map, base) = create_shm_section(index)?; + // device-type FIRST (so it's visible the moment magic is), neutral report, magic LAST. + // SAFETY: base points at SHM_SIZE writable bytes; OFF_DEVTYPE/OFF_INPUT are in range. + unsafe { + *base.add(OFF_DEVTYPE) = DEVTYPE_DUALSHOCK4; + std::ptr::write_unaligned(base.add(OFF_INPUT) as *mut [u8; DS4_INPUT_REPORT_LEN], { + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + serialize_state(&mut r, &DsState::neutral(), 0, 0); + r + }); + std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); + } + let inst = format!("pf_ds4_{index}"); + let hsw = match create_swdevice(&SwDeviceProfile { + instance: &inst, + container_index: index, + hwid: "pf_dualshock4", + usb_vid_pid: "VID_054C&PID_09CC", + description: "punktfunk Virtual DualShock 4", + }) { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; DualShock 4 devnode unavailable"); + None + } + }; + Ok(Ds4WinPad { + hsw, + map, + view: base, + counter: 0, + ts: 0, + last_out_seq: 0, + }) + } + + /// Serialize `st` into report `0x01` and publish it to the section's input slot. + fn write_state(&mut self, st: &DsState) { + self.counter = self.counter.wrapping_add(1); + self.ts = self.ts.wrapping_add(188); // ~1ms in the DS4's 5.33µs sensor-clock units + let mut r = [0u8; DS4_INPUT_REPORT_LEN]; + serialize_state(&mut r, st, self.counter, self.ts); + // SAFETY: view points at SHM_SIZE bytes; input slot is OFF_INPUT..OFF_INPUT+64. + unsafe { std::ptr::copy_nonoverlapping(r.as_ptr(), self.view.add(OFF_INPUT), r.len()) }; + } + + /// Poll the section's output slot; parse a new `0x05` report (rumble / lightbar) into a + /// [`Ds4Feedback`]. Returns empty feedback if the driver hasn't published anything new. + fn service(&mut self) -> Ds4Feedback { + let mut fb = Ds4Feedback::default(); + // SAFETY: view points at SHM_SIZE bytes. + let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_OUT_SEQ) as *const u32) }; + if seq != self.last_out_seq { + self.last_out_seq = seq; + let mut out = [0u8; 64]; + // SAFETY: output slot is OFF_OUTPUT..OFF_OUTPUT+64 within the section. + unsafe { + std::ptr::copy_nonoverlapping(self.view.add(OFF_OUTPUT), out.as_mut_ptr(), 64) + }; + parse_ds4_output(&out, &mut fb); + } + fb + } +} + +impl Drop for Ds4WinPad { + fn drop(&mut self) { + // 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, + }); + let _ = CloseHandle(self.map); + } + } +} + +/// All virtual DualShock 4 pads of a session — the Windows analogue of +/// [`DualShock4Manager`](super::dualshock4::DualShock4Manager), with the same method surface as the +/// Windows DualSense manager so the session input thread drives either backend identically. +pub struct DualShock4WindowsManager { + pads: Vec>, + state: Vec, + last_rumble: Vec<(u16, u16)>, + last_led: Vec>, + last_write: Vec, + broken: bool, +} + +impl Default for DualShock4WindowsManager { + fn default() -> DualShock4WindowsManager { + DualShock4WindowsManager::new() + } +} + +impl DualShock4WindowsManager { + pub fn new() -> DualShock4WindowsManager { + DualShock4WindowsManager { + pads: (0..MAX_PADS).map(|_| None).collect(), + state: vec![DsState::neutral(); MAX_PADS], + last_rumble: vec![(0, 0); MAX_PADS], + last_led: vec![None; MAX_PADS], + last_write: vec![Instant::now(); MAX_PADS], + broken: false, + } + } + + /// Handle one decoded controller event (create/destroy by mask, then merge button/stick state). + pub fn handle(&mut self, ev: &GamepadEvent) { + match ev { + GamepadEvent::Arrival { index, kind, .. } => { + tracing::info!(index, kind, "controller arrival (DualShock 4/Windows)"); + self.ensure(*index as usize); + } + GamepadEvent::State(f) => { + let idx = f.index as usize; + if idx >= MAX_PADS { + return; + } + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && f.active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged (DualShock 4/Windows)"); + *slot = None; + self.state[i] = DsState::neutral(); + self.last_rumble[i] = (0, 0); + self.last_led[i] = None; + } + } + if f.active_mask & (1 << idx) == 0 { + return; + } + self.ensure(idx); + let prev = self.state[idx]; + let mut s = DsState::from_gamepad( + f.buttons, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + f.left_trigger, + f.right_trigger, + ); + s.touch = prev.touch; + s.gyro = prev.gyro; + s.accel = prev.accel; + self.state[idx] = s; + self.write(idx); + } + } + } + + /// Apply one rich client→host event (touchpad contact / motion sample) to an existing pad. + pub fn apply_rich(&mut self, rich: RichInput) { + let idx = match rich { + RichInput::Touchpad { pad, .. } | RichInput::Motion { pad, .. } => pad as usize, + }; + if idx >= MAX_PADS || self.pads[idx].is_none() { + return; + } + match rich { + RichInput::Touchpad { + finger, + active, + x, + y, + .. + } => { + let slot = (finger as usize).min(1); + let t = &mut self.state[idx].touch[slot]; + t.active = active; + t.id = slot as u8; + t.x = ((x as u32 * (DS4_TOUCH_W - 1) as u32) / u16::MAX as u32) as u16; + t.y = ((y as u32 * (DS4_TOUCH_H - 1) as u32) / u16::MAX as u32) as u16; + } + RichInput::Motion { gyro, accel, .. } => { + self.state[idx].gyro = gyro; + self.state[idx].accel = accel; + } + } + self.write(idx); + } + + fn write(&mut self, idx: usize) { + let st = self.state[idx]; + if let Some(pad) = self.pads[idx].as_mut() { + pad.write_state(&st); + } + self.last_write[idx] = Instant::now(); + } + + /// Re-emit each live pad's current report if it's been silent for `max_gap` (parity with the + /// other backends' heartbeat — keeps the section fresh). + pub fn heartbeat(&mut self, max_gap: Duration) { + let now = Instant::now(); + for i in 0..self.pads.len() { + if self.pads[i].is_some() && now.duration_since(self.last_write[i]) >= max_gap { + self.write(i); + } + } + } + + fn ensure(&mut self, idx: usize) { + if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + return; + } + match Ds4WinPad::open(idx as u8) { + Ok(p) => { + tracing::info!( + index = idx, + "virtual DualShock 4 created (Windows UMDF shm channel)" + ); + self.pads[idx] = Some(p); + self.state[idx] = DsState::neutral(); + self.last_rumble[idx] = (0, 0); + self.last_led[idx] = None; + self.last_write[idx] = Instant::now(); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "virtual DualShock 4 creation failed — controller input disabled"); + self.broken = true; + } + } + } + + /// Service every pad: poll the section for a game's feedback. `rumble` fires `(index, low, high)` + /// only on change (universal 0xCA plane); `hidout` fires the lightbar (0xCD `Led`), deduped. + pub fn pump( + &mut self, + mut rumble: impl FnMut(u16, u16, u16), + mut hidout: impl FnMut(HidOutput), + ) { + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_mut() else { + continue; + }; + let fb = pad.service(); + if let Some(r) = fb.rumble { + if self.last_rumble[i] != r { + self.last_rumble[i] = r; + rumble(i as u16, r.0, r.1); + } + } + if let Some(rgb) = fb.led { + if self.last_led[i] != Some(rgb) { + self.last_led[i] = Some(rgb); + hidout(HidOutput::Led { + pad: i as u8, + r: rgb.0, + g: rgb.1, + b: rgb.2, + }); + } + } + } + } +} diff --git a/crates/punktfunk-host/src/inject/gamepad_windows.rs b/crates/punktfunk-host/src/inject/gamepad_windows.rs index 631e0a1..209fe92 100644 --- a/crates/punktfunk-host/src/inject/gamepad_windows.rs +++ b/crates/punktfunk-host/src/inject/gamepad_windows.rs @@ -1,137 +1,362 @@ -//! Windows virtual gamepad via ViGEmBus — the analogue of the Linux uinput Xbox-360 pad. -//! One virtual Xbox 360 controller per client pad index. GameStream/Moonlight already uses the -//! XInput button/stick/trigger conventions (low 16 button bits, sticks −32768..32767 +Y up, -//! triggers 0..255), so the mapping is ~1:1. +//! Windows virtual Xbox 360 gamepad via the punktfunk **XUSB companion** UMDF driver +//! (`packaging/windows/xusb-driver`) — the in-tree replacement for ViGEmBus. One virtual Xbox 360 +//! controller per client pad index, visible to classic **XInput** (`XInputGetState`) with no kernel +//! bus driver: each pad `SwDeviceCreate`s a `pf_xusb_` devnode (the driver loads on it and +//! registers `GUID_DEVINTERFACE_XUSB`) and the host pushes the XInput state into the shared section +//! `Global\pfxusb-shm-`. GameStream/Moonlight already speak the XInput conventions (low-16 +//! button bits, sticks −32768..32767 +Y up, triggers 0..255), so the state copy is ~1:1. //! -//! Needs the ViGEmBus driver installed (like SudoVDA for the display); absent → gamepad is disabled -//! and the session continues without it. Rumble flows back the *other* way: a game on the host writes -//! force-feedback to the virtual pad, ViGEm's notification API delivers it on a background thread, -//! and [`GamepadManager::pump_rumble`] relays level changes to the client (the universal 0xCA plane), -//! mirroring the Linux `EV_FF` read path. +//! Rumble flows back the other way: a game writes force-feedback via `XInputSetState`, the driver +//! parses the `SET_STATE` packet into the shared section, and [`GamepadManager::pump_rumble`] relays +//! level changes to the client (the universal 0xCA plane), mirroring the Linux `EV_FF` read path. +//! +//! NB: the driver currently maps `Global\pfxusb-shm-0` (hardcoded), so a single pad (index 0) is +//! fully correct; mixed multi-pad needs the driver to read its own index first (same limitation as +//! the DualSense backend). -use crate::gamestream::gamepad::GamepadEvent; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; -use std::thread::JoinHandle; -use vigem_client::{Client, TargetId, XButtons, XGamepad, Xbox360Wired}; +use crate::gamestream::gamepad::{GamepadEvent, MAX_PADS}; +use anyhow::{anyhow, Result}; +use std::ffi::c_void; +use windows::core::{w, GUID, 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, +}; +use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +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}; -/// A plugged virtual pad plus its rumble back-channel. The notification thread stores the latest -/// motor levels into `rumble` (packed `large << 8 | small`, both 0..255); [`GamepadManager::pump_rumble`] -/// reads it and emits level changes. Dropping `target` aborts the outstanding notification request, -/// so the thread's `poll` returns an error and it exits on its own — we detach it (per ViGEm's docs, -/// dropping the `JoinHandle` does not stop the thread, but the target-drop abort does). -struct PadEntry { - target: Xbox360Wired>, - rumble: Arc, - last_emitted: u32, - _notif_thread: Option>, +// Shared-section layout — must match `packaging/windows/xusb-driver/src/lib.rs`. +const SHM_SIZE: usize = 64; +const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" +const OFF_PACKET: usize = 4; +const OFF_BUTTONS: usize = 8; +const OFF_LT: usize = 10; +const OFF_RT: usize = 11; +const OFF_LX: usize = 12; +const OFF_LY: usize = 14; +const OFF_RX: usize = 16; +const OFF_RY: usize = 18; +const OFF_RUMBLE_SEQ: usize = 24; +const OFF_RUMBLE: usize = 28; // large @28, small @29 + +/// Context for the `SwDeviceCreate` completion callback: an event to signal + the HRESULT it reports. +#[repr(C)] +struct SwCreateCtx { + event: HANDLE, + result: HRESULT, } +/// `SwDeviceCreate` fires this once PnP has enumerated the device; stash the result + wake the creator. +unsafe extern "system" fn sw_create_cb( + _dev: HSWDEVICE, + result: HRESULT, + ctx: *const c_void, + _id: PCWSTR, +) { + if !ctx.is_null() { + // SAFETY: ctx is the &mut SwCreateCtx the creator passed; it outlives this callback. + unsafe { + let c = ctx as *mut SwCreateCtx; + (*c).result = result; + let _ = SetEvent((*c).event); + } + } +} + +/// Spawn the `pf_xusb_` companion devnode (hardware id `pf_xusb`, enumerator `punktfunk`). The +/// INF (System class) binds our UMDF driver, which registers the XUSB interface. Unlike the HID pads, +/// no USB compatible-ids are needed — XInput finds the device by the interface GUID, not VID/PID — but +/// we still pass a deterministic non-null `pContainerId` (the null-sentinel trips an `xinput1_4` +/// slot-skip bug). `SwDeviceClose` removes it on drop. +fn create_swdevice(index: u8) -> Result { + let hwids: Vec = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect(); + let instid: Vec = format!("pf_xusb_{index}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let desc: Vec = "punktfunk Virtual Xbox 360 (XUSB)" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // The pad index, stamped into the device Location — the driver reads it to map `pfxusb-shm-` + // (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event). + let loc: Vec = format!("{index}") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let container = GUID::from_values(0x5046_5855, 0x0000, 0x0000, [0, 0, 0, 0, 0, 0, 0, index]); + + // SAFETY: zeroed then the fields we use are set; the buffers + container outlive the call. + let mut info: SW_DEVICE_CREATE_INFO = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + info.pszInstanceId = PCWSTR(instid.as_ptr()); + info.pszzHardwareIds = PCWSTR(hwids.as_ptr()); + info.pContainerId = &container; + info.pszDeviceDescription = PCWSTR(desc.as_ptr()); + info.pszDeviceLocation = PCWSTR(loc.as_ptr()); + info.CapabilityFlags = 0x0000_000B; // DriverRequired | SilentInstall | Removable + + // 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 + buffers + ctx outlive the call (we wait on the event before returning). + 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(pf_xusb) failed: {e}")); + } + }; + // SAFETY: event valid; block until PnP finishes enumerating, then check the callback result. + 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(pf_xusb) enumeration failed: {:?}", + ctx.result + )); + } + Ok(hsw) +} + +/// A single virtual Xbox 360 pad: the `pf_xusb_` devnode plus the mapped shared section. +struct XusbWinPad { + hsw: Option, + map: HANDLE, + view: *mut u8, + packet: u32, + last_rumble_seq: u32, +} + +impl XusbWinPad { + /// Create + map `Global\pfxusb-shm-`, stamp the magic, then spawn the devnode. + fn open(index: u8) -> Result { + let name = HSTRING::from(format!("Global\\pfxusb-shm-{index}")); + + // Permissive DACL so the WUDFHost (whatever account) can open the section. + let mut psd = PSECURITY_DESCRIPTOR::default(); + // SAFETY: SDDL literal valid; psd receives an OS-freed descriptor (host-lifetime — fine). + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + w!("D:(A;;GA;;;WD)"), + SDDL_REVISION_1, + &mut psd, + None, + )?; + } + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }; + // SAFETY: anonymous (pagefile-backed) section of SHM_SIZE bytes with the SDDL above. + let map = unsafe { + CreateFileMappingW( + INVALID_HANDLE_VALUE, + Some(&sa), + PAGE_READWRITE, + 0, + SHM_SIZE as u32, + PCWSTR(name.as_ptr()), + )? + }; + // SAFETY: map is a valid section handle; map the whole thing. + let view = unsafe { MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, SHM_SIZE) }; + if view.Value.is_null() { + // SAFETY: map is valid. + unsafe { + let _ = CloseHandle(map); + } + return Err(anyhow!("MapViewOfFile failed for {name}")); + } + let base = view.Value as *mut u8; + // Zero the section then stamp the magic LAST (the driver only accepts it once magic is set). + // SAFETY: base points at SHM_SIZE writable bytes. + unsafe { + std::ptr::write_bytes(base, 0, SHM_SIZE); + std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); + } + let hsw = match create_swdevice(index) { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "SwDeviceCreate failed; XUSB devnode unavailable"); + None + } + }; + Ok(XusbWinPad { + hsw, + map, + view: base, + packet: 0, + last_rumble_seq: 0, + }) + } + + /// Publish the XInput state to the section and bump the packet number (XInput uses it to detect + /// change). `buttons` is the XINPUT_GAMEPAD_* bitmap; sticks are i16, triggers u8. + #[allow(clippy::too_many_arguments)] + fn write_state(&mut self, buttons: u16, lt: u8, rt: u8, lx: i16, ly: i16, rx: i16, ry: i16) { + self.packet = self.packet.wrapping_add(1); + // SAFETY: view points at SHM_SIZE bytes; all offsets are in range. + unsafe { + std::ptr::write_unaligned(self.view.add(OFF_BUTTONS) as *mut u16, buttons); + *self.view.add(OFF_LT) = lt; + *self.view.add(OFF_RT) = rt; + std::ptr::write_unaligned(self.view.add(OFF_LX) as *mut i16, lx); + std::ptr::write_unaligned(self.view.add(OFF_LY) as *mut i16, ly); + std::ptr::write_unaligned(self.view.add(OFF_RX) as *mut i16, rx); + std::ptr::write_unaligned(self.view.add(OFF_RY) as *mut i16, ry); + std::ptr::write_unaligned(self.view.add(OFF_PACKET) as *mut u32, self.packet); + } + } + + /// Poll the section for a game's rumble (the driver bumps `rumble_seq` on each SET_STATE). Returns + /// `(large, small)` motor levels (0..=255) when a new one arrived. + fn service(&mut self) -> Option<(u8, u8)> { + // SAFETY: view points at SHM_SIZE bytes. + let seq = unsafe { std::ptr::read_unaligned(self.view.add(OFF_RUMBLE_SEQ) as *const u32) }; + if seq == self.last_rumble_seq { + return None; + } + self.last_rumble_seq = seq; + // SAFETY: rumble bytes at OFF_RUMBLE / OFF_RUMBLE+1. + let (large, small) = + unsafe { (*self.view.add(OFF_RUMBLE), *self.view.add(OFF_RUMBLE + 1)) }; + Some((large, small)) + } +} + +impl Drop for XusbWinPad { + fn drop(&mut self) { + // 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, + }); + let _ = CloseHandle(self.map); + } + } +} + +/// All virtual Xbox 360 pads of a session — the Windows analogue of the Linux uinput-xpad manager, +/// now backed by the XUSB companion driver. Same method surface (`new`/`handle`/`pump_rumble`) the +/// session input thread already drives. pub struct GamepadManager { - client: Option>, - pads: HashMap, + pads: Vec>, + last_rumble: Vec<(u8, u8)>, + broken: bool, +} + +impl Default for GamepadManager { + fn default() -> GamepadManager { + GamepadManager::new() + } } impl GamepadManager { pub fn new() -> GamepadManager { - let client = match Client::connect() { - Ok(c) => { - tracing::info!("ViGEmBus connected (virtual Xbox 360 gamepads)"); - Some(Arc::new(c)) - } - Err(e) => { - tracing::warn!( - error = format!("{e:?}"), - "ViGEmBus unavailable — gamepad disabled (install ViGEmBus)" - ); - None - } - }; GamepadManager { - client, - pads: HashMap::new(), + pads: (0..MAX_PADS).map(|_| None).collect(), + last_rumble: vec![(0, 0); MAX_PADS], + broken: false, } } - /// Lazily plug pad `index` on its first event, arming the rumble notification thread. Returns - /// `None` if ViGEmBus is unavailable or the pad failed to plug. - fn ensure_pad(&mut self, index: u8) -> Option<&mut PadEntry> { - if !self.pads.contains_key(&index) { - let client = self.client.clone()?; - let mut target = Xbox360Wired::new(client, TargetId::XBOX360_WIRED); - if let Err(e) = target.plugin() { - tracing::warn!(error = format!("{e:?}"), "ViGEm pad plugin failed"); - return None; - } - let _ = target.wait_ready(); - // Arm the force-feedback back-channel: a background thread writes each notification's - // motor levels into the shared atomic; the input thread drains changes via pump_rumble. - let rumble = Arc::new(AtomicU32::new(0)); - let notif_thread = match target.request_notification() { - Ok(req) => { - let sink = rumble.clone(); - Some(req.spawn_thread(move |_req, n| { - sink.store( - ((n.large_motor as u32) << 8) | n.small_motor as u32, - Ordering::Relaxed, - ); - })) - } - Err(e) => { - tracing::warn!( - error = format!("{e:?}"), - "ViGEm rumble notification unavailable — pad runs without force feedback" - ); - None - } - }; - self.pads.insert( - index, - PadEntry { - target, - rumble, - last_emitted: 0, - _notif_thread: notif_thread, - }, - ); + fn ensure(&mut self, idx: usize) { + if idx >= MAX_PADS || self.pads[idx].is_some() || self.broken { + return; + } + match XusbWinPad::open(idx as u8) { + Ok(p) => { + tracing::info!( + index = idx, + "virtual Xbox 360 created (Windows XUSB companion)" + ); + self.pads[idx] = Some(p); + self.last_rumble[idx] = (0, 0); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)"); + self.broken = true; + } } - self.pads.get_mut(&index) } pub fn handle(&mut self, ev: &GamepadEvent) { let GamepadEvent::State(f) = ev else { return; // Arrival metadata — the pad is created lazily on the first State }; - let Some(entry) = self.ensure_pad(f.index.max(0) as u8) else { + let idx = f.index.max(0) as usize; + if idx >= MAX_PADS { return; - }; - let gp = XGamepad { - buttons: XButtons { - raw: (f.buttons & 0xffff) as u16, - }, - left_trigger: f.left_trigger, - right_trigger: f.right_trigger, - thumb_lx: f.ls_x, - thumb_ly: f.ls_y, - thumb_rx: f.rs_x, - thumb_ry: f.rs_y, - }; - let _ = entry.target.update(&gp); + } + // Unplugs: drop any allocated pad whose mask bit cleared. + for (i, slot) in self.pads.iter_mut().enumerate() { + if slot.is_some() && f.active_mask & (1 << i) == 0 { + tracing::info!(index = i, "controller unplugged (Xbox 360/Windows)"); + *slot = None; + self.last_rumble[i] = (0, 0); + } + } + if f.active_mask & (1 << idx) == 0 { + return; + } + self.ensure(idx); + if let Some(pad) = self.pads[idx].as_mut() { + pad.write_state( + (f.buttons & 0xffff) as u16, + f.left_trigger, + f.right_trigger, + f.ls_x, + f.ls_y, + f.rs_x, + f.rs_y, + ); + } } - /// Relay any changed rumble level to the client. The notification thread keeps `rumble` current; - /// we emit only on change (the input thread re-sends the steady state every 500 ms to heal drops). - /// ViGEm motors are 0..255; the wire carries 0..65535, so scale by 257 (255 → 65535). `large` - /// (low-frequency) maps to the universal datagram's `low`, `small` (high-frequency) to `high`. + /// Relay any changed rumble level to the client. XUSB motors are 0..255; the wire carries + /// 0..65535, so scale by 257. `large` (low-frequency) → the datagram's `low`, `small` + /// (high-frequency) → `high` — matching the other backends. pub fn pump_rumble(&mut self, mut send: impl FnMut(u16, u16, u16)) { - for (idx, entry) in self.pads.iter_mut() { - let packed = entry.rumble.load(Ordering::Relaxed); - if packed != entry.last_emitted { - entry.last_emitted = packed; - let large = ((packed >> 8) & 0xff) as u16; - let small = (packed & 0xff) as u16; - send(*idx as u16, large * 257, small * 257); + for i in 0..self.pads.len() { + let Some(pad) = self.pads[i].as_mut() else { + continue; + }; + if let Some((large, small)) = pad.service() { + if self.last_rumble[i] != (large, small) { + self.last_rumble[i] = (large, small); + send(i as u16, large as u16 * 257, small as u16 * 257); + } } } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index bd88883..4886019 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -209,7 +209,6 @@ fn real_main() -> Result<()> { #[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() @@ -217,38 +216,95 @@ fn real_main() -> Result<()> { .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, - }); - println!( - "virtual DualSense up — cycling Cross + sweeping the left stick for {secs}s. Watch it \ - in joy.cpl / Steam / a game; any rumble / lightbar / trigger the game sends prints below." - ); - let deadline = Instant::now() + Duration::from_secs(secs); - let (mut i, mut last) = (0i32, Instant::now()); - while Instant::now() < deadline { - // Surface a game's feedback: rumble (universal) + lightbar / player-LED / adaptive - // triggers (DualSense-only) coming back over the shared section. - mgr.pump( - |pad, lo, hi| println!(" rumble from game: pad={pad} low={lo} high={hi}"), - |o| println!(" hid output from game: {o:?}"), + // `--index N` creates pad `pf_pad_N` (default 0) — use a spare index (e.g. 1) to test + // alongside a running host that already holds pad 0. `--ds4` drives the DualShock 4 + // backend instead of the DualSense one. + let idx: u8 = args + .iter() + .skip_while(|a| *a != "--index") + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let ds4 = args.iter().any(|a| a == "--ds4"); + let xbox = args.iter().any(|a| a == "--xbox"); + // Same drive loop for either backend (identical method surface): Arrival creates the pad, + // State pushes a cycling report, pump surfaces a game's rumble/lightbar feedback. + macro_rules! drive { + ($mgr:expr, $label:expr) => {{ + let mut mgr = $mgr; + mgr.handle(&GamepadEvent::Arrival { + index: idx, + kind: 2, + capabilities: 0, + }); + println!( + "virtual {} up — cycling Cross + sweeping the left stick for {secs}s. Watch \ + it in joy.cpl / Steam / a game; any feedback the game sends prints below.", + $label + ); + let deadline = Instant::now() + Duration::from_secs(secs); + let (mut i, mut last) = (0i32, Instant::now()); + while Instant::now() < deadline { + mgr.pump( + |pad, lo, hi| { + println!(" rumble from game: pad={pad} low={lo} high={hi}") + }, + |o| println!(" hid output from game: {o:?}"), + ); + if last.elapsed() >= Duration::from_millis(400) { + last = Instant::now(); + i += 1; + let buttons = if i % 2 == 0 { + punktfunk_core::input::gamepad::BTN_A // Cross + } else { + 0 + }; + let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X + mgr.handle(&GamepadEvent::State(GamepadFrame { + index: idx as i16, + active_mask: 1 << idx, + buttons, + left_trigger: 0, + right_trigger: 0, + ls_x: lx, + ls_y: 0, + rs_x: 0, + rs_y: 0, + })); + } + std::thread::sleep(Duration::from_millis(15)); + } + }}; + } + if xbox { + // Xbox 360 via the XUSB companion: a different surface (handle + pump_rumble, no + // HID-output plane), so drive it inline rather than via the macro. + let mut mgr = inject::gamepad::GamepadManager::new(); + mgr.handle(&GamepadEvent::Arrival { + index: idx, + kind: 1, + capabilities: 0, + }); + println!( + "virtual Xbox 360 (XUSB) up — sweeping LS + toggling A for {secs}s. Check with \ + an XInput game or xinputtest.exe." ); - if last.elapsed() >= Duration::from_millis(400) { - last = Instant::now(); - i += 1; - let buttons = if i % 2 == 0 { - punktfunk_core::input::gamepad::BTN_A // Cross + let deadline = Instant::now() + Duration::from_secs(secs); + let mut t = 0i32; + while Instant::now() < deadline { + mgr.pump_rumble(|pad, lo, hi| { + println!(" rumble from game: pad={pad} low={lo} high={hi}") + }); + t += 1; + let lx = (((t % 200) - 100) * 327).clamp(-32768, 32767) as i16; // sweep ±32700 + let buttons = if (t / 67) % 2 == 0 { + punktfunk_core::input::gamepad::BTN_A } else { 0 }; - let lx = (((i % 64) - 32) * 1024) as i16; // sweep left stick X mgr.handle(&GamepadEvent::State(GamepadFrame { - index: 0, - active_mask: 1, + index: idx as i16, + active_mask: 1 << idx, buttons, left_trigger: 0, right_trigger: 0, @@ -257,8 +313,18 @@ fn real_main() -> Result<()> { rs_x: 0, rs_y: 0, })); + std::thread::sleep(Duration::from_millis(15)); } - std::thread::sleep(Duration::from_millis(15)); + } else if ds4 { + drive!( + inject::dualshock4_windows::DualShock4WindowsManager::new(), + "DualShock 4" + ); + } else { + drive!( + inject::dualsense_windows::DualSenseWindowsManager::new(), + "DualSense" + ); } println!("dualsense-windows-test: done (devnode removed)"); Ok(()) diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index a8c1990..8e18382 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1167,7 +1167,8 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver>) { /// The session's virtual-gamepad backend, resolved once per session (sessions run serially). /// /// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)), -/// ViGEm on Windows. Also the X-Box One/Series identity (`PUNKTFUNK_GAMEPAD=xboxone`): the same +/// the in-tree XUSB companion driver (classic XInput) on Windows. Also the X-Box One/Series identity +/// (`PUNKTFUNK_GAMEPAD=xboxone`): the same /// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical /// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity). /// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`, @@ -1187,6 +1188,8 @@ enum PadBackend { DualShock4(crate::inject::dualshock4::DualShock4Manager), #[cfg(target_os = "windows")] DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), + #[cfg(target_os = "windows")] + DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager), } impl PadBackend { @@ -1213,11 +1216,20 @@ impl PadBackend { _ => {} } #[cfg(target_os = "windows")] - if kind == GamepadPref::DualSense { - tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)"); - return PadBackend::DualSenseWindows( - crate::inject::dualsense_windows::DualSenseWindowsManager::new(), - ); + match kind { + GamepadPref::DualSense => { + tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)"); + return PadBackend::DualSenseWindows( + crate::inject::dualsense_windows::DualSenseWindowsManager::new(), + ); + } + GamepadPref::DualShock4 => { + tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)"); + return PadBackend::DualShock4Windows( + crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), + ); + } + _ => {} } let _ = kind; PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) @@ -1232,6 +1244,8 @@ impl PadBackend { PadBackend::DualShock4(m) => m.handle(ev), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.handle(ev), + #[cfg(target_os = "windows")] + PadBackend::DualShock4Windows(m) => m.handle(ev), } } @@ -1246,6 +1260,8 @@ impl PadBackend { PadBackend::DualShock4(m) => m.apply_rich(_rich), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.apply_rich(_rich), + #[cfg(target_os = "windows")] + PadBackend::DualShock4Windows(m) => m.apply_rich(_rich), } } @@ -1269,6 +1285,8 @@ impl PadBackend { PadBackend::DualShock4(m) => m.pump(rumble, hidout), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout), + #[cfg(target_os = "windows")] + PadBackend::DualShock4Windows(m) => m.pump(rumble, hidout), } } @@ -1286,6 +1304,8 @@ impl PadBackend { PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), #[cfg(target_os = "windows")] PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), + #[cfg(target_os = "windows")] + PadBackend::DualShock4Windows(m) => m.heartbeat(std::time::Duration::from_millis(8)), } } } @@ -1570,10 +1590,11 @@ fn synthetic_stream( /// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, /// then the host's `PUNKTFUNK_GAMEPAD` env var (under a client `Auto`), then X-Box 360. /// -/// `linux` is whether this is a Linux host (uinput + UHID). The rich UHID pads (DualSense, DualShock -/// 4) need it — off Linux any such wish degrades to X-Box 360 (never an error: a session without rich -/// pads still streams). X-Box One/Series is a distinct uinput *identity* on Linux, but XInput-identical -/// to the 360 pad on Windows (ViGEm has no One target), so it degrades to `Xbox360` there. +/// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID +/// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades +/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a +/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB +/// companion presents a 360 identity), so it degrades to `Xbox360` there. fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env @@ -1582,9 +1603,9 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool explicit => explicit, }; match want { - // DualSense: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. + // DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. GamepadPref::DualSense if linux || windows => GamepadPref::DualSense, - GamepadPref::DualShock4 if linux => GamepadPref::DualShock4, + GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4, // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on // Windows (XInput can't tell them apart anyway). GamepadPref::XboxOne if linux => GamepadPref::XboxOne, @@ -3092,10 +3113,11 @@ mod tests { ); assert_eq!(pick_gamepad(DualSense, None, false, false), Xbox360); assert_eq!(pick_gamepad(Auto, Some("dualsense"), false, false), Xbox360); - // DualShock 4: Linux-only (UHID); degrades to X-Box 360 off it (including Windows). + // DualShock 4: honored on Linux (UHID) AND Windows (UMDF minidriver); degrades elsewhere. assert_eq!(pick_gamepad(DualShock4, None, true, false), DualShock4); assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4); - assert_eq!(pick_gamepad(DualShock4, None, false, true), Xbox360); + assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4); + assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360); // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); diff --git a/docs/windows-dualsense-game-detection.md b/docs/windows-dualsense-game-detection.md index d42d4e6..f135879 100644 --- a/docs/windows-dualsense-game-detection.md +++ b/docs/windows-dualsense-game-detection.md @@ -19,17 +19,124 @@ one agent's memory). Run the experiments **on the Windows host** (`.173`, repo a the motors, host forwards `0xCA` — log: `rumble: forwarding to client (0xCA) low=16128 high=16128`). The break is the **client** (macOS) not rendering `0xCA` onto the physical pad. Separate task/agent. -## Root-cause hypothesis (the thing to confirm/fix) +## Root cause — CONFIRMED (2026-06-22, run live on the interactive desktop, console session 3) -The device is **software-enumerated**: `SWD\PUNKTFUNK\PF_PAD_0` → child `HID\VID_054C&PID_0CE6`. It is NOT -a real USB or Bluetooth device. SDL/HIDAPI enumerate any HID by VID/PID (incl. SWD) — so they see it. A -game's *native* DualSense path is pickier. Two likely causes: -1. **Windows.Gaming.Input (WGI) / GameInput exclude SWD (software) HID devices** that raw-HID - enumeration includes. Many modern titles use these. -2. **USB-vs-Bluetooth detection by device-path prefix.** Native DS5 code picks the report format (64-byte - USB report `0x01` vs 78-byte BT report `0x31`) from the connection type. If it keys off the device - path (`USB\…` vs `BTHENUM\…`) rather than the report length, our `SWD\…` path matches neither and it - mis-detects. (SDL keys off the *report length* = 64 → USB → works.) +The break is the device's **PnP identity / device-interface path**, not the HID descriptor or feature +reports. `hidclass` derives the HID child's path token and its `HID\VID_054C&PID_0CE6` hardware-ids from the +**parent bus device's hardware-id**. Our parent is the software (SWD) devnode `SWD\PUNKTFUNK\PF_PAD_0` whose +hardware-id is `pf_dualsense` (no VID/PID), so hidclass emits only the *VendorID+usage* fallback and **no +PID**. Measured on this box (one virtual pad live + one real 8BitDo present): + +HID-child hardware-ids (`DEVPKEY_Device_HardwareIds`, CompatibleIds empty): +`HID\pf_dualsense` · `HID\VID_054C&UP:0001_U:0005` · `HID_DEVICE_SYSTEM_GAME` · `HID_DEVICE_UP:0001_U:0005` +· `HID_DEVICE` — **note the absent `HID\VID_054C&PID_0CE6`.** `HIDD_ATTRIBUTES` itself is correct (VID 054C +/ PID 0CE6), which is why attribute-readers work. + +Device-interface paths (from `HKLM\SYSTEM\CurrentControlSet\Control\DeviceClasses\{4d1e55b2-…}`): + +| Device | HID interface path | +| --- | --- | +| **Ours (virtual)** | `\\?\HID#punktfunk#1&ca418da&0&0000#{…}` — **no `VID_/PID_` token** | +| Real DualShock 4 (USB, registry remnant) | `\\?\HID#VID_054C&PID_05C4&REV_0100#…` | +| Real DualSense (BT, registry remnant) | `\\?\HID#{00001124-…}_VID&0002054c_PID&0ce6#…` | + +**Cross-API enumeration (the decisive experiment — impossible over SSH, run live in the console session):** + +| API | Sees our virtual DS5? | Identity reported | Reads from | +| --- | --- | --- | --- | +| SDL3 / HIDAPI | ✅ | 054C:0CE6, type=PS5 | `HIDD_ATTRIBUTES` → Steam works | +| RawInput | ✅ | 054C:0CE6 | `HIDD_ATTRIBUTES` | +| WGI `RawGameController` | ✅ | 054C:0CE6 | `HIDD_ATTRIBUTES` | +| WGI `Gamepad` | ❌ empty | — | (empty for *all* pads on this box — no Xbox-profile pad; not DS-specific) | +| **MS GameInput** | ✅ enumerates it | **vid=0x0000 pid=0x0000** | **PnP path / hardware-ids** | +| Cyberpunk native PS5 | ❌ | — | needs the DS5 VID/PID identity | + +The GameInput result is the clincher: it **does** enumerate our pad — descriptor fingerprint matches exactly +(15 buttons, 6 axes, 1 hat, usage Game Pad 0x05) — but reports **vid/pid = 0**, while it reads the real +8BitDo's `vid=0x3434` correctly. So GameInput (and, by the same logic, a native PS5 path) takes VID/PID from +the **PnP device path / hardware-ids, NOT from `HIDD_ATTRIBUTES`**, and ours carry no `VID_054C&PID_0CE6`. +Everything that reads attributes directly (SDL / RawInput / WGI-raw) is fine; everything that keys off the +device *identity/path* (GameInput, native DualSense detection) sees a generic, unidentified gamepad → no +PS5 path. + +**⇒ The fix must put `VID_054C&PID_0CE6` into the device-interface path and the `HID\VID&PID` hardware-ids** +(give the device a real-USB-like PnP identity), not merely correct `HIDD_ATTRIBUTES`. See "Fix options". + +**Secondary driver gaps found (not the detection blocker, but fix while here):** +- `IOCTL_HID_GET_STRING` (id 4, ioctl `0x000b0013`) returns `STATUS_NOT_IMPLEMENTED` — a game polls it + repeatedly (seen live in `pfds-driver.log`). Implement manufacturer / product / serial strings + (`"DualSense Wireless Controller"`, a serial). Native PS5 code can read the serial to tell USB from BT. +- `DS_FEATURE_CALIBRATION` is **42** bytes but the report descriptor declares feature `0x05` as **41** + (`0x95 0x28` = 40 data + 1 id). Trim to 41 (motion-only; SDL accepts it regardless). + +## Fix — implemented & validated at the identity layer (2026-06-22) + +`create_swdevice` (`inject/dualsense_windows.rs`) now sets, via **`SW_DEVICE_CREATE_INFO` struct fields** +(NOT `pProperties` — empirically a `DEVPROPERTY` write of these PnP-owned identity keys is ignored; the +create-time struct fields are the supported lever, confirmed on `.173`): +- **`pszzCompatibleIds`** = `USB\VID_054C&PID_0CE6`, `USB\Class_03&SubClass_00&Prot_00`, `USB\Class_03` + (Windows appends `SWD\Generic`). HIDAPI/SDL/libScePad walk HID-child → `CM_Get_Parent` → this parent's + CompatibleIds and string-match `"USB"` → **`bus_type` now resolves to USB** (was UNKNOWN). +- **`pszzHardwareIds`** = `pf_dualsense` **first** (so the INF still binds our UMDF driver), then + `USB\VID_054C&PID_0CE6&REV_0100`, `USB\VID_054C&PID_0CE6`. hidclass then derives the real-DS5 child ids + **`HID\VID_054C&PID_0CE6[&REV_0100]`** (previously only `HID\VID_054C&UP:0001_U:0005`). +- **`pContainerId`** = a deterministic per-pad GUID `{50464453-0000-0000-0000-00000000000}` ("PFDS") + (avoids the null-sentinel-ContainerId `xinput1_4` slot-skip bug; groups the pad's devnodes). + +**Validated live** (real shipping path, `dualsense-windows-test --index 1` alongside the running service's +pad 0): INF still binds (`Service=MsHidUmdf`), parent CompatibleIds/HardwareIds + per-pad ContainerId set, +the HID child gains `HID\VID_054C&PID_0CE6`, and the HIDAPI parent-walk reports **bus_type=USB**. +SDL / RawInput / WGI `RawGameController` identity stays correct (054C:0CE6). + +**Remaining gap (NOT fixed by the above): GameInput VID/PID still reads 0.** GameInput parses VID/PID from +the HID child's **instance path** (`HID\punktfunk\1&…`), which carries no `VID_…&PID_…` token; neither +CompatibleIds nor HardwareIds change the instance path. Only a real USB-bus instance path +(`HID\VID_054C&PID_0CE6\…`) does — i.e. a **ViGEm-style KMDF USB-emulating bus driver** (the rank-3, last +resort). Pursue only if a target title uses GameInput AND the identity fix above doesn't satisfy it; prior +art (HIDMaestro) shows pure user-mode pads ARE accepted by WGI/GameInput, so other parity (descriptor / +strings / mapping) may matter more than a genuine USB bus. + +## Next steps + +> **Deployed to `.173` (2026-06-22):** the host identity fix is live in the `PunktfunkHost` service (release +> rebuilt + restarted) and the driver fixes are installed + signed (`oem74.inf`, `punktfunk-ds-test` cert). +> The box is ready for the decisive on-glass test. A rollback copy of the prior driver is at +> `C:\Users\Public\giprobe\driver-backup-oem74`. + +1. **Decisive on-glass test (only the user can run):** launch Cyberpunk 2077 with Steam Input OFF against a + virtual DS5 carrying the new identity; check the in-game glyphs/prompt switch to DualSense. Cleanest + single-pad test (frees the service's pad 0 so only the new-identity pad is present): + `sc stop PunktfunkHost` → `target\debug\punktfunk-host.exe dualsense-windows-test --index 0 --seconds 600` + (new identity + live cycling Cross/stick), launch the game; then deploy the release + restart with + `scripts\windows\deploy-host.ps1`. +2. **Driver-side correctness — DONE & installed (2026-06-22).** Rebuilt/resigned/reinstalled per the recipe + below; validated live (`hidstrings` probe + `pfds-driver.log`): + - `IOCTL_HID_GET_STRING` now implemented (was `STATUS_NOT_IMPLEMENTED`). **Discovery:** Windows polls + this device's string slots with low-word ids **`0x0E`/`0x0F`/`0x10`** (lang `0x0409`) cyclically — NOT + the `0/1/2` `HID_STRING_ID_*` constants. The handler maps them (+ `0/1/2` as fallbacks): + `0x0E`→manufacturer "Sony Interactive Entertainment", `0x0F`→product "DualSense Wireless Controller", + `0x10`→serial "35533AD6E774" (the `0x09` pairing-report MAC). Verified: `HidD_GetManufacturer/Product/ + SerialNumberString` now return those three distinct strings. + - `DS_FEATURE_CALIBRATION` trimmed 42 → 41 bytes (1 id + 40 data) to match the descriptor's feature + `0x05` (`0x95 0x28`). + - The repo source (`packaging/windows/dualsense-driver/src/lib.rs`) and the m0 build copy were diverged + by *formatting only*; they are now back in sync (the repo file was copied to m0 before building). +3. If a GameInput-only title needs the real VID/PID → the rank-3 KMDF USB-emulating bus driver. + +## On-box experiment tooling (built 2026-06-22, `C:\Users\Public\giprobe\`) +- `probe.cpp` (+`build.bat`) — GameInput enumeration/fingerprint via `LoadLibrary("GameInput.dll")` + + `GameInputCreate`/`RegisterDeviceCallback` (GDK header). Prints each device's vid/pid/usage/counts — + this is what proved GameInput reads our pad as vid=0. +- `swexp.cpp` (+`build-swexp.bat`) — standalone `SwDeviceCreate` identity experiment: variations for + `pszzCompatibleIds` (struct field) vs `DEVPKEY_Device_CompatibleIds` (pProperties — ignored), + `pszzHardwareIds` USB ids, `pContainerId`. Create at a spare instance id, hold, inspect. Built with the + VS18 MSVC toolchain via `vcvars64.bat`. +- WGI probe: Windows PowerShell **5.1** WinRT projection of `RawGameController`/`Gamepad` (pump the message + loop; subscribe `RawGameControllerAdded` to kick enumeration). +- Parent-walk bus check: from the HID child, `DEVPKEY_Device_Parent` → that node's + `DEVPKEY_Device_CompatibleIds`, match `^USB`/`^BTH` — mirrors HIDAPI's `hid_internal_detect_bus_type()`. +- NOTE: the agent shell's PowerShell tool chokes on inline `@'…'@` here-strings feeding `Add-Type` (throws + a spurious "Remove-Item on system path '/' is blocked"); write C#/scripts to a file and run them instead. ## How to reproduce / iterate (on `.173`) diff --git a/packaging/windows/dualsense-driver/pf_dualsense.inx b/packaging/windows/dualsense-driver/pf_dualsense.inx index 36615bc..10bb2a4 100644 --- a/packaging/windows/dualsense-driver/pf_dualsense.inx +++ b/packaging/windows/dualsense-driver/pf_dualsense.inx @@ -25,10 +25,12 @@ pf_dualsense.dll=1 %ManufacturerString%=pf, NT$ARCH$.10.0...22000 [pf.NT$ARCH$.10.0...22000] -; Two hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests) and -; `pf_dualsense` for the host's SwDeviceCreate'd software device (the `root\` prefix is reserved for -; root enumeration, so SwDeviceCreate rejects it with E_INVALIDARG). -%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense +; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` +; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so +; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the +; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host +; stamps into shared memory. +%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4 [pfDualSense.NT] CopyFiles=UMDriverCopy @@ -60,6 +62,9 @@ UmdfKernelModeClientPolicy=AllowKernelModeClients UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects UmdfMethodNeitherAction=Copy UmdfFsContextUsePolicy=CanUseFsContext2 +; Each pad gets its OWN WUDFHost so the driver's per-pad statics (incl. the shm index) don't collide +; across multiple simultaneous controllers (multi-pad). +UmdfHostProcessSharing=ProcessSharingDisabled [pf_dualsense_Install] UmdfLibraryVersion=$UMDFVERSION$ diff --git a/packaging/windows/dualsense-driver/src/lib.rs b/packaging/windows/dualsense-driver/src/lib.rs index 925401c..be0654b 100644 --- a/packaging/windows/dualsense-driver/src/lib.rs +++ b/packaging/windows/dualsense-driver/src/lib.rs @@ -11,7 +11,7 @@ #![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)] use core::ffi::c_void; -use core::sync::atomic::{AtomicPtr, Ordering}; +use core::sync::atomic::{AtomicPtr, AtomicU32, Ordering}; use wdk_sys::{ NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG, @@ -41,6 +41,7 @@ const IOCTL_HID_GET_REPORT_DESCRIPTOR: u32 = hid_ctl(1); const IOCTL_HID_READ_REPORT: u32 = hid_ctl(2); const IOCTL_HID_WRITE_REPORT: u32 = hid_ctl(3); const IOCTL_HID_GET_DEVICE_ATTRIBUTES: u32 = hid_ctl(9); +const IOCTL_HID_GET_STRING: u32 = hid_ctl(4); const IOCTL_UMDF_HID_SET_FEATURE: u32 = hid_ctl(20); const IOCTL_UMDF_HID_GET_FEATURE: u32 = hid_ctl(21); const IOCTL_UMDF_HID_SET_OUTPUT_REPORT: u32 = hid_ctl(22); @@ -57,6 +58,8 @@ const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_ const DS_VID: u16 = 0x054C; const DS_PID: u16 = 0x0CE6; const DS_VER: u16 = 0x0100; +/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1. +const DS4_PID: u16 = 0x09CC; // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). // NOTE: inject/dualsense.rs comments this as "232 bytes" — that comment is wrong; it is 273. @@ -84,10 +87,10 @@ static DUALSENSE_RDESC: [u8; 273] = [ // Feature reports hid-playstation / Steam read during init (each array's first byte is the report id). #[rustfmt::skip] -static DS_FEATURE_CALIBRATION: [u8; 42] = [ // 0x05 motion calibration +static DS_FEATURE_CALIBRATION: [u8; 41] = [ // 0x05 motion calibration: 1 id + 40 data (descriptor declares feature 0x05 as 0x95 0x28 = 40) 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0xF4, 0x01, 0xF4, 0x01, 0x10, 0x27, 0xF0, 0xD8, 0x10, 0x27, 0xF0, 0xD8, 0x10, - 0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x27, 0xF0, 0xD8, 0x0B, 0x00, 0x00, 0x00, 0x00, 0x00, ]; #[rustfmt::skip] static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7) @@ -102,16 +105,85 @@ static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info 0x14, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, -// then {reportType=0x22, wReportLength=273 (0x0111)}. +// ---- DualShock 4 v2 assets (served when the host stamps device_type=1) ---- +// Sony DualShock 4 v2 USB HID report descriptor (507 bytes), verbatim from inject/dualshock4.rs. +#[rustfmt::skip] +static DS4_RDESC: [u8; 507] = [ + 0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, + 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, + 0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, + 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00, + 0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, + 0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, + 0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, + 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, + 0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09, + 0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1, + 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09, + 0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1, + 0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06, + 0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22, + 0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95, + 0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06, + 0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81, + 0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05, + 0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84, + 0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06, + 0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87, + 0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90, + 0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03, + 0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93, + 0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1, + 0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4, + 0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, + 0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9, + 0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01, + 0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC, + 0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B, + 0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF, + 0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3, + 0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F, + 0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0, + 0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F, + 0xB1, 0x02, 0xC0, +]; +// DS4 feature reports games read during init (each array's first byte is the report id). +#[rustfmt::skip] +static DS4_FEATURE_PAIRING: [u8; 16] = [ // 0x12 pairing info (MAC at bytes 1..7) + 0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; +#[rustfmt::skip] +static DS4_FEATURE_CALIBRATION: [u8; 37] = [ // 0x02 IMU calibration + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xF0, 0xFF, 0x10, 0x00, 0xF0, 0xFF, 0x10, + 0x00, 0xF0, 0xFF, 0x20, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x20, 0x00, 0xE0, 0x00, + 0x20, 0x00, 0xE0, 0x00, 0x00, +]; +#[rustfmt::skip] +static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info + 0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, +]; + +// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then +// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB). static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01]; +static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01]; // HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11]. -fn hid_attrs() -> [u8; 32] { +// `ds4` selects the DualShock 4 product id (same VID/version). +fn hid_attrs(ds4: bool) -> [u8; 32] { let mut a = [0u8; 32]; a[0..4].copy_from_slice(&32u32.to_le_bytes()); a[4..6].copy_from_slice(&DS_VID.to_le_bytes()); - a[6..8].copy_from_slice(&DS_PID.to_le_bytes()); + a[6..8].copy_from_slice(&(if ds4 { DS4_PID } else { DS_PID }).to_le_bytes()); a[8..10].copy_from_slice(&DS_VER.to_le_bytes()); a } @@ -128,8 +200,24 @@ const NEUTRAL_REPORT: [u8; 64] = { r[8] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0) r }; -fn neutral_report() -> [u8; 64] { - NEUTRAL_REPORT +// Neutral DualShock 4 input report 0x01: sticks centered (0x80); the dpad hat is in byte 5 (low +// nibble), so a neutral hat (8) lands there instead of byte 8. +const DS4_NEUTRAL_REPORT: [u8; 64] = { + let mut r = [0u8; 64]; + r[0] = 0x01; // report id + r[1] = 0x80; // LX + r[2] = 0x80; // LY + r[3] = 0x80; // RX + r[4] = 0x80; // RY + r[5] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0) + r +}; +fn neutral_report(ds4: bool) -> [u8; 64] { + if ds4 { + DS4_NEUTRAL_REPORT + } else { + NEUTRAL_REPORT + } } static MANUAL_QUEUE: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); @@ -198,6 +286,59 @@ pub unsafe extern "system" fn driver_entry( } } +/// The pad index this device serves (which `pfds-shm-` section to map). The host stamps it into +/// the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With +/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this +/// static is per-pad — the basis for multi-pad. +static SHM_INDEX: AtomicU32 = AtomicU32::new(0); +/// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (not re-exported at the wdk_sys root). +const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; + +/// Read the pad index the host stamped into the device Location (a NUL-terminated UTF-16 decimal +/// string). Defaults to 0 (single-pad) if absent. +fn query_shm_index(device: WDFDEVICE) -> u32 { + let mut mem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceAllocAndQueryProperty, + device, + DEVICE_PROPERTY_LOCATION_INFORMATION, + 0, + WDF_NO_OBJECT_ATTRIBUTES, + &mut mem + ) + }; + if !nt_success(st) || mem.is_null() { + return 0; + } + let mut len: usize = 0; + // SAFETY: mem valid. + let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } + as *const u16; + if buf.is_null() { + return 0; + } + let mut idx: u32 = 0; + let mut any = false; + for i in 0..(len / 2).min(8) { + // SAFETY: buf valid for len bytes; i < len/2. + let c = unsafe { *buf.add(i) }; + if c == 0 { + break; + } + if (0x30..=0x39).contains(&c) { + idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32); + any = true; + } + } + if any { + idx + } else { + 0 + } +} + extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { log("[pf-ds] EvtDeviceAdd"); @@ -220,6 +361,10 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } + let shm_idx = query_shm_index(device); + SHM_INDEX.store(shm_idx, Ordering::Relaxed); + dbglog!("[pf-ds] shm index = {shm_idx}"); + // Default parallel queue handling all IOCTLs. // SAFETY: zeroed config then fields set; Size matches the struct. let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; @@ -317,9 +462,18 @@ extern "C" fn evt_io_device_control( dbglog!("[pf-ds] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}"); } let status: NTSTATUS = match ioctl { - IOCTL_HID_GET_DEVICE_DESCRIPTOR => copy_to_output(request, &HID_DESC), - IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs()), - IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output(request, &DUALSENSE_RDESC), + IOCTL_HID_GET_DEVICE_DESCRIPTOR => { + copy_to_output(request, if device_type() == 1 { &DS4_HID_DESC } else { &HID_DESC }) + } + IOCTL_HID_GET_DEVICE_ATTRIBUTES => copy_to_output(request, &hid_attrs(device_type() == 1)), + IOCTL_HID_GET_REPORT_DESCRIPTOR => copy_to_output( + request, + if device_type() == 1 { + &DS4_RDESC[..] + } else { + &DUALSENSE_RDESC[..] + }, + ), IOCTL_HID_READ_REPORT => { let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst); // SAFETY: request valid; mq is the manual queue created in EvtDeviceAdd. @@ -341,7 +495,10 @@ extern "C" fn evt_io_device_control( STATUS_SUCCESS } IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request), - IOCTL_UMDF_HID_GET_INPUT_REPORT => copy_to_output(request, &neutral_report()), + IOCTL_UMDF_HID_GET_INPUT_REPORT => { + copy_to_output(request, &neutral_report(device_type() == 1)) + } + IOCTL_HID_GET_STRING => on_get_string(request), _ => STATUS_NOT_IMPLEMENTED, }; @@ -479,11 +636,15 @@ fn on_get_feature(request: WDFREQUEST) -> NTSTATUS { } // SAFETY: inbuf valid for >=1 byte. let report_id = unsafe { *inbuf }; - let blob: &[u8] = match report_id { - 0x05 => &DS_FEATURE_CALIBRATION, - 0x09 => &DS_FEATURE_PAIRING, - 0x20 => &DS_FEATURE_FIRMWARE, - other => { + // DualSense uses feature ids 0x05/0x09/0x20; DualShock 4 uses 0x02/0x12/0xa3. + let blob: &[u8] = match (device_type() == 1, report_id) { + (false, 0x05) => &DS_FEATURE_CALIBRATION, + (false, 0x09) => &DS_FEATURE_PAIRING, + (false, 0x20) => &DS_FEATURE_FIRMWARE, + (true, 0x02) => &DS4_FEATURE_CALIBRATION, + (true, 0x12) => &DS4_FEATURE_PAIRING, + (true, 0xA3) => &DS4_FEATURE_FIRMWARE, + (_, other) => { dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}"); return STATUS_INVALID_PARAMETER; } @@ -491,12 +652,70 @@ fn on_get_feature(request: WDFREQUEST) -> NTSTATUS { copy_to_output(request, blob) } +// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is +// the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native +// PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one +// way they tell USB from BT); the old default returned STATUS_NOT_IMPLEMENTED, leaving them blank. +// Observed live on this device, Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409) cyclically — the +// manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; we map both forms. +fn on_get_string(request: WDFREQUEST) -> NTSTATUS { + let mut inmem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: request valid. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) + }; + if !nt_success(st) { + return st; + } + let mut inlen: usize = 0; + // SAFETY: inmem valid. + let inbuf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut inlen) } + as *const u8; + // SAFETY: inbuf is valid for inlen bytes; read the 4-byte id value when present. + let id_val: u32 = if !inbuf.is_null() && inlen >= 4 { + unsafe { core::ptr::read_unaligned(inbuf as *const u32) } + } else { + 0 + }; + let string_id = id_val & 0xFFFF; + let ds4 = device_type() == 1; + dbglog!("[pf-ds] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) ds4={ds4}"); + let s: &str = match string_id { + 0 | 0x000e => { + if ds4 { + "Sony Computer Entertainment" + } else { + "Sony Interactive Entertainment" + } + } + 2 | 0x0010 => { + if ds4 { + "DEADBEEF0001" + } else { + "35533AD6E774" + } + } + _ => { + if ds4 { + "Wireless Controller" + } else { + "DualSense Wireless Controller" + } + } + }; + let mut wide: Vec = s.encode_utf16().collect(); + wide.push(0); // NUL terminator + // SAFETY: reinterpret the UTF-16 buffer as bytes for the byte-oriented copy_to_output. + let bytes = unsafe { core::slice::from_raw_parts(wide.as_ptr() as *const u8, wide.len() * 2) }; + copy_to_output(request, bytes) +} + // Open + map the host's shared-memory section (Global\pfds-shm-0) and run `f` against the mapped base // if it exists with a valid magic, then unmap. NOT cached: re-mapped per access so the driver always // sees the current section (UMDF groups all devices in one WUDFHost, and the host may recreate the // section across restarts — a cached view would go stale). ~125 maps/s from the timer = negligible. fn with_shm(f: F) { - let name: Vec = "Global\\pfds-shm-0" + let name: Vec = format!("Global\\pfds-shm-{}", SHM_INDEX.load(Ordering::Relaxed)) .encode_utf16() .chain(std::iter::once(0)) .collect(); @@ -516,7 +735,10 @@ fn with_shm(f: F) { let magic = unsafe { core::ptr::read_unaligned(view as *const u32) }; if magic == SHM_MAGIC { if !LOGGED_SHM.swap(true, Ordering::Relaxed) { - dbglog!("[pf-ds] control: shared memory mapped (Global\\pfds-shm-0)"); + dbglog!( + "[pf-ds] control: shared memory mapped (Global\\pfds-shm-{})", + SHM_INDEX.load(Ordering::Relaxed) + ); } f(view); } @@ -524,6 +746,19 @@ fn with_shm(f: F) { unsafe { UnmapViewOfFile(view as *const c_void) }; } +/// The host's device-type selector from shared memory (`device_type` byte @140): 0 = DualSense +/// (default), 1 = DualShock 4. Read fresh on each enumeration query — cheap, and the host stamps the +/// section before `SwDeviceCreate`, so it's set by the time hidclass asks for the descriptor / +/// attributes. Defaults to DualSense if the section isn't mapped yet (magic absent). +fn device_type() -> u8 { + let mut t = 0u8; + with_shm(|view| { + // SAFETY: view points at a mapped 256-byte section; the device-type byte is at offset 140. + t = unsafe { *view.add(140) }; + }); + t +} + extern "C" fn evt_timer(timer: WDFTIMER) { // Pull the latest host input report from shared memory (if the host has connected). with_shm(|view| { diff --git a/packaging/windows/gamepad-drivers/pf_dualsense.cat b/packaging/windows/gamepad-drivers/pf_dualsense.cat new file mode 100644 index 0000000..cf3e613 Binary files /dev/null and b/packaging/windows/gamepad-drivers/pf_dualsense.cat differ diff --git a/packaging/windows/gamepad-drivers/pf_dualsense.dll b/packaging/windows/gamepad-drivers/pf_dualsense.dll new file mode 100644 index 0000000..674bac0 Binary files /dev/null and b/packaging/windows/gamepad-drivers/pf_dualsense.dll differ diff --git a/packaging/windows/gamepad-drivers/pf_dualsense.inf b/packaging/windows/gamepad-drivers/pf_dualsense.inf new file mode 100644 index 0000000..eb06379 --- /dev/null +++ b/packaging/windows/gamepad-drivers/pf_dualsense.inf @@ -0,0 +1,82 @@ +;/*++ +; punktfunk virtual DualSense — UMDF2 HID minidriver INF (M0 spike). +; Adapted from the WDK vhidmini2 UMDF2 sample (VhidminiUm.inx). +; Depends on MsHidUmdf.inf (build >= 22000). +; Install: devgen /add /hardwareid "root\pf_dualsense" (after pnputil /add-driver /install) +;--*/ +[Version] +Signature="$WINDOWS NT$" +Class=HIDClass +ClassGuid={745a17a0-74d3-11d0-b6fe-00a0c90f57da} +Provider=%ProviderString% +CatalogFile = pf_dualsense.cat +PnpLockdown=1 +DriverVer = 06/22/2026,16.23.43.887 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1=%Disk_Description%,,, + +[SourceDisksFiles] +pf_dualsense.dll=1 + +[Manufacturer] +%ManufacturerString%=pf, NTamd64.10.0...22000 + +[pf.NTamd64.10.0...22000] +; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` +; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so +; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` for the host's virtual DualShock 4 — the +; same driver binds both and serves the DualSense or DS4 identity per the device_type byte the host +; stamps into shared memory. +%DeviceDesc%=pfDualSense, root\pf_dualsense, pf_dualsense, pf_dualshock4 + +[pfDualSense.NT] +CopyFiles=UMDriverCopy +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT + +[pfDualSense.NT.hw] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.hw +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.hw + +[pfDualSense.NT.Services] +Include=MsHidUmdf.inf +Needs=MsHidUmdf.NT.Services +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Services + +[pfDualSense.NT.Filters] +Include=WUDFRD.inf +Needs=WUDFRD_LowerFilter.NT.Filters + +[pfDualSense.NT.Wdf] +UmdfService="pf_dualsense", pf_dualsense_Install +UmdfServiceOrder=pf_dualsense +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfMethodNeitherAction=Copy +UmdfFsContextUsePolicy=CanUseFsContext2 +; Each pad gets its OWN WUDFHost so the driver's per-pad statics (incl. the shm index) don't collide +; across multiple simultaneous controllers (multi-pad). +UmdfHostProcessSharing=ProcessSharingDisabled + +[pf_dualsense_Install] +UmdfLibraryVersion=2.31.0 +ServiceBinary="%13%\pf_dualsense.dll" + +[UMDriverCopy] +pf_dualsense.dll + +[Strings] +ProviderString ="punktfunk" +ManufacturerString ="punktfunk" +ClassName ="HID device" +Disk_Description ="punktfunk DualSense Installation Disk" +DeviceDesc ="punktfunk Virtual DualSense" diff --git a/packaging/windows/gamepad-drivers/pf_xusb.cat b/packaging/windows/gamepad-drivers/pf_xusb.cat new file mode 100644 index 0000000..ec19e3b Binary files /dev/null and b/packaging/windows/gamepad-drivers/pf_xusb.cat differ diff --git a/packaging/windows/gamepad-drivers/pf_xusb.dll b/packaging/windows/gamepad-drivers/pf_xusb.dll new file mode 100644 index 0000000..c94c3b4 Binary files /dev/null and b/packaging/windows/gamepad-drivers/pf_xusb.dll differ diff --git a/packaging/windows/gamepad-drivers/pf_xusb.inf b/packaging/windows/gamepad-drivers/pf_xusb.inf new file mode 100644 index 0000000..fc10984 --- /dev/null +++ b/packaging/windows/gamepad-drivers/pf_xusb.inf @@ -0,0 +1,66 @@ +;/*++ +; punktfunk virtual Xbox 360 XUSB companion — a non-HID UMDF2 driver that registers the XUSB +; device-interface GUID {EC87F1E3-...} and answers the buffered XInput IOCTLs, so classic +; XInputGetState() reads the pad without a kernel bus driver (the HIDMaestro approach). System class, +; hosted by the in-box WUDFRd reflector. Created per-session by the host via SwDeviceCreate +; (hardware id `pf_xusb`); `root\pf_xusb` is the devgen/devcon test id. +;--*/ +[Version] +Signature = "$WINDOWS NT$" +Class = System +ClassGuid = {4D36E97D-E325-11CE-BFC1-08002BE10318} +Provider = %ProviderString% +CatalogFile = pf_xusb.cat +PnpLockdown = 1 +DriverVer = 06/22/2026,16.17.56.696 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1 = %DiskId1%,,,"" + +[SourceDisksFiles] +pf_xusb.dll = 1,, + +[Manufacturer] +%StdMfg%=Standard, NTamd64.10.0...22000 + +[Standard.NTamd64.10.0...22000] +%DeviceDesc%=pfXusb, root\pf_xusb, pf_xusb + +[pfXusb.NT] +CopyFiles=Drivers_Dir +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[Drivers_Dir] +pf_xusb.dll + +[pfXusb.NT.HW] +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[pfXusb.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[pfXusb.NT.Wdf] +UmdfService=pf_xusb, pfXusb_Install +UmdfServiceOrder=pf_xusb +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfMethodNeitherAction=Copy +UmdfFsContextUsePolicy=CanUseFsContext2 +UmdfHostProcessSharing=ProcessSharingDisabled + +[pfXusb_Install] +UmdfLibraryVersion=2.31.0 +ServiceBinary=%13%\pf_xusb.dll + +[Strings] +ProviderString = "punktfunk" +StdMfg = "(Standard system devices)" +DiskId1 = "punktfunk XUSB Installation Disk" +DeviceDesc = "punktfunk Virtual Xbox 360 (XUSB)" + diff --git a/packaging/windows/gamepad-drivers/punktfunk-driver.cer b/packaging/windows/gamepad-drivers/punktfunk-driver.cer new file mode 100644 index 0000000..603933e Binary files /dev/null and b/packaging/windows/gamepad-drivers/punktfunk-driver.cer differ diff --git a/packaging/windows/install-gamepad-drivers.ps1 b/packaging/windows/install-gamepad-drivers.ps1 new file mode 100644 index 0000000..6815ba8 --- /dev/null +++ b/packaging/windows/install-gamepad-drivers.ps1 @@ -0,0 +1,50 @@ +<# +.SYNOPSIS + Install the bundled punktfunk virtual-gamepad UMDF drivers - pf_dualsense (DualSense + DualShock 4, + one type-aware HID driver) and pf_xusb (Xbox 360 XUSB companion for classic XInput). Runs ELEVATED + at setup time (invoked from the installer's [Run] section). Best-effort: warns and exits 0 on any + failure, so a driver hiccup never aborts the whole install (gamepad input degrades gracefully - a + session still streams without a pad). + +.DESCRIPTION + -Dir holds the staged payload: pf_dualsense.{inf,cat,dll}, pf_xusb.{inf,cat,dll}, and the signing + .cer. Steps: + 1. Trust the self-signed driver cert (machine Root + TrustedPublisher) so pnputil adds it silently. + 2. pnputil /add-driver each .inf - adds the package to the driver store. (No /install or device-node + creation: the host SwDeviceCreate's the per-session devnodes itself when a client forwards a pad, + so PnP binds the store driver on demand.) + + ASCII-only on purpose: this is run by the installer via Windows PowerShell 5.1, which mis-decodes a + BOM-less UTF-8 non-ASCII char (e.g. an em-dash) as a smart-quote and breaks parsing. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File install-gamepad-drivers.ps1 -Dir C:\path\to\gamepad +#> +[CmdletBinding()] +param([string]$Dir = $PSScriptRoot) +# Never abort the installer on a driver failure. +$ErrorActionPreference = 'Continue' +trap { Write-Warning "gamepad driver install error: $_"; exit 0 } + +# 1) Trust the self-signed driver cert (Root so the chain validates + TrustedPublisher so pnputil adds +# it without a prompt). +$cer = Get-ChildItem -Path $Dir -Filter *.cer -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($cer) { + Write-Host "==> importing $($cer.Name) to Root + TrustedPublisher" + certutil.exe -addstore -f Root "$($cer.FullName)" | Out-Null + certutil.exe -addstore -f TrustedPublisher "$($cer.FullName)" | Out-Null +} +else { Write-Warning "no .cer in $Dir; drivers may not install silently (untrusted publisher)" } + +# 2) Add each driver package to the store (idempotent; re-adding the same .inf is harmless). +$infs = Get-ChildItem -Path $Dir -Filter *.inf -ErrorAction SilentlyContinue +if (-not $infs) { Write-Warning "no driver .inf in $Dir; skipping gamepad driver install."; exit 0 } +foreach ($inf in $infs) { + Write-Host "==> pnputil /add-driver $($inf.Name)" + & pnputil.exe /add-driver "$($inf.FullName)" + $rc = $LASTEXITCODE + if ($rc -eq 3010) { Write-Host " added; a reboot is recommended." } + elseif ($rc -ne 0) { Write-Warning "pnputil /add-driver $($inf.Name) returned $rc" } +} + +exit 0 diff --git a/packaging/windows/pack-host-installer.ps1 b/packaging/windows/pack-host-installer.ps1 index a304cee..25ad115 100644 --- a/packaging/windows/pack-host-installer.ps1 +++ b/packaging/windows/pack-host-installer.ps1 @@ -147,6 +147,23 @@ if (-not $NoDriver) { } else { Write-Host "-NoDriver: building installer WITHOUT the bundled SudoVDA driver" } +# --- stage the punktfunk virtual-gamepad UMDF drivers (DualSense/DS4 + Xbox 360 XUSB) ---------- +# Vendored, pre-signed under packaging/windows/gamepad-drivers/ (like SudoVDA). Rebuild + re-vendor +# from packaging/windows/{dualsense,xusb}-driver/ when the driver source changes (see their READMEs). +if (-not $NoDriver) { + $gpVendor = Join-Path $here 'gamepad-drivers' + if (Test-Path (Join-Path $gpVendor 'pf_dualsense.inf')) { + $gpStage = Join-Path $OutDir 'gamepad' + if (Test-Path $gpStage) { Remove-Item -Recurse -Force $gpStage } + New-Item -ItemType Directory -Force -Path $gpStage | Out-Null + Copy-Item (Join-Path $gpVendor '*') $gpStage -Force + Copy-Item (Join-Path $here 'install-gamepad-drivers.ps1') (Join-Path $gpStage 'install-gamepad-drivers.ps1') -Force + $defines += "/DGamepadStageDir=$gpStage" + Write-Host "==> staged vendored gamepad UMDF drivers from $gpVendor" + } + else { Write-Warning "no vendored gamepad drivers under $gpVendor — installer built WITHOUT them" } +} + # --- stage the FFmpeg shared DLLs (AMD/Intel AMF/QSV build) ------------------------------------ # A host built with --features amf-qsv link-imports avcodec/avutil/swscale/... so the shared DLLs # MUST sit next to the exe (it won't start otherwise). Bundle them from $FfmpegDir\bin — the same diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index cd7b4e0..aabf102 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -32,6 +32,10 @@ #ifdef StageDir #define WithDriver #endif +; GamepadStageDir (the vendored UMDF gamepad drivers + install-gamepad-drivers.ps1) is optional. +#ifdef GamepadStageDir + #define WithGamepad +#endif ; FfmpegBin (a dir of FFmpeg shared DLLs) is optional — present when the host is built with ; --features amf-qsv (the AMD/Intel AMF/QSV encode backend link-imports the FFmpeg libs). #ifdef FfmpegBin @@ -67,6 +71,9 @@ Name: "english"; MessagesFile: "compiler:Default.isl" #ifdef WithDriver Name: "installdriver"; Description: "Install the SudoVDA virtual display driver (required for native-resolution streaming)" #endif +#ifdef WithGamepad +Name: "installgamepad"; Description: "Install the virtual gamepad drivers (DualSense / DualShock 4 / Xbox 360 — no ViGEmBus needed)" +#endif Name: "startservice"; Description: "Start the punktfunk host service now (also starts on every boot)" [Files] @@ -83,6 +90,10 @@ Source: "{#FfmpegBin}\*.dll"; DestDir: "{app}"; Flags: ignoreversion ; The driver payload + nefconc.exe + install-sudovda.ps1, extracted to {tmp} and removed after install. Source: "{#StageDir}\*"; DestDir: "{tmp}\sudovda"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installdriver #endif +#ifdef WithGamepad +; The vendored UMDF gamepad drivers + install-gamepad-drivers.ps1, extracted to {tmp}, removed after. +Source: "{#GamepadStageDir}\*"; DestDir: "{tmp}\gamepad"; Flags: deleteafterinstall recursesubdirs createallsubdirs; Tasks: installgamepad +#endif [Run] #ifdef WithDriver @@ -91,6 +102,12 @@ Filename: "powershell.exe"; \ StatusMsg: "Installing the SudoVDA virtual display driver..."; \ Flags: runhidden waituntilterminated; Tasks: installdriver #endif +#ifdef WithGamepad +Filename: "powershell.exe"; \ + Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{tmp}\gamepad\install-gamepad-drivers.ps1"" -Dir ""{tmp}\gamepad"""; \ + StatusMsg: "Installing the virtual gamepad drivers..."; \ + Flags: runhidden waituntilterminated; Tasks: installgamepad +#endif ; Register (or re-point, on upgrade - idempotent) the SYSTEM service from its FINAL {app} location: ; service install records current_exe() as the SCM binPath, so it must run from {app}, not {tmp}. Filename: "{app}\punktfunk-host.exe"; Parameters: "service install"; WorkingDir: "{app}"; \ diff --git a/packaging/windows/xusb-driver/Cargo.toml b/packaging/windows/xusb-driver/Cargo.toml new file mode 100644 index 0000000..9ff4ee1 --- /dev/null +++ b/packaging/windows/xusb-driver/Cargo.toml @@ -0,0 +1,35 @@ +[package] +edition = "2024" +name = "pf-xusb" +version = "0.1.0" +publish = false +license = "MIT OR Apache-2.0" +description = "punktfunk virtual Xbox 360 XUSB companion (UMDF2 — classic XInput)" + +[package.metadata.wdk.driver-model] +driver-type = "UMDF" +target-umdf-version-minor = 31 +umdf-version-major = 2 + +[lib] +crate-type = ["cdylib"] + +[build-dependencies] +wdk-build.path = "../../crates/wdk-build" + +[dependencies] +wdk.path = "../../crates/wdk" +wdk-sys.path = "../../crates/wdk-sys" + +[features] +default = [] +nightly = ["wdk-sys/nightly", "wdk/nightly"] + +[profile.dev] +lto = true + +[profile.release] +lto = true + +# Standalone package (not part of the windows-drivers-rs root workspace). +[workspace] diff --git a/packaging/windows/xusb-driver/Makefile.toml b/packaging/windows/xusb-driver/Makefile.toml new file mode 100644 index 0000000..be118bb --- /dev/null +++ b/packaging/windows/xusb-driver/Makefile.toml @@ -0,0 +1,4 @@ +extend = [ + { path = "../../crates/wdk-build/rust-driver-makefile.toml" }, + { path = "../../crates/wdk-build/rust-driver-sample-makefile.toml" }, +] diff --git a/packaging/windows/xusb-driver/README.md b/packaging/windows/xusb-driver/README.md new file mode 100644 index 0000000..cda75b3 --- /dev/null +++ b/packaging/windows/xusb-driver/README.md @@ -0,0 +1,79 @@ +# pf-xusb — virtual Xbox 360 XUSB companion (UMDF2, classic XInput) + +A **pure-user-mode** UMDF2 driver that makes a virtual Xbox 360 controller visible to classic +**`XInputGetState`** with **no kernel bus driver** (no ViGEmBus) — the HIDMaestro approach. It is the +Windows counterpart to ViGEm's X360 target, owned in-tree. + +## Why this is not the HID driver + +XInput does **not** use HID. `xinput1_4.dll` enumerates the **XUSB device-interface GUID** +`{EC87F1E3-C13B-4100-B5F7-8B84D54260CB}` (`SetupDiEnumDeviceInterfaces`), opens the Nth present +instance (= player slot 0–3) with `CreateFile`, and polls it with buffered IOCTLs. So this driver: + +- is **not** a HID minidriver (no `MsHidUmdf`) — it's a plain UMDF2 function driver under `WUDFRd`, + **System** setup class; +- registers the XUSB interface with `WdfDeviceCreateDeviceInterface(device, &XUSB_GUID, NULL)`; +- answers the XUSB IOCTLs (all `METHOD_BUFFERED`, delivered to user mode by the reflector) from + controller state the host publishes into a shared section `Global\pfxusb-shm-0`; a game's rumble + (`SET_STATE`) is published back for the host to forward to the client. + +The WAIT_* IOCTLs return `STATUS_INVALID_DEVICE_REQUEST`, which makes `xinput1_4` fall back to +synchronous `GET_STATE` polling — so no manual queue / timer is needed for classic XInput. (WGI/ +GameInput admission additionally needs a `xinputhid` `UpperFilters` registry tripwire + the async +`WAIT_FOR_INPUT` pump — not implemented; classic XInput does not need it.) + +## Verified wire formats (source: HIDMaestro `driver/companion.c`, nefarius/XInputHooker `XUSB.h`, ViGEm) + +| IOCTL | Code | Reply | +| --- | --- | --- | +| `GET_INFORMATION` | `0x80006000` | 12 B: `[0]`=ver `0x0103`, `[2]`=count `0x01`, `[8]`=VID `045E`, `[10]`=PID `028E` — marks the slot **connected** | +| `GET_CAPABILITIES` | `0x8000E004` | 24 B (or 36 B V2 if `outLen>=36`): Type `0x03`/SubType `0x01`, motor max `0xFFFF` (advertise rumble) | +| `GET_STATE` | `0x8000E00C` | **29 B**: `[0]`ver `[2]`count `[5]`u32 packet# `[0x0B]`u16 wButtons `[0x0D]`LT `[0x0E]`RT `[0x0F..0x16]`4×i16 sticks | +| `SET_STATE` | `0x8000A010` | input 5 B `{00, led, large, small, subcmd}`: `subcmd 0x02`=rumble (large `[2]`, small `[3]`), `0x01`=player-LED | +| `GET_LED_STATE` | `0x8000E008` | `{0,0,0x06}` | +| `GET_BATTERY_INFORMATION` | `0x8000E018` | `{0,0x01,0x03,0}` | +| `WAIT_GUIDE_BUTTON` / `WAIT_FOR_INPUT` | `0x8000E014` / `0x8000E3AC` | `STATUS_INVALID_DEVICE_REQUEST` → GET_STATE fallback | + +`wButtons` is the `XINPUT_GAMEPAD_*` bitmap (DPAD_UP `0x0001` … A `0x1000` B `0x2000` X `0x4000` +Y `0x8000`). `dwPacketNumber` (GET_STATE `[5]`) must increment whenever the payload changes. + +## Shared-memory layout `Global\pfxusb-shm-0` (64 B) — host writes state, driver writes rumble + +`magic u32 @0` (`"PFXU"` `0x55584650`) · `packet u32 @4` (host bumps → dwPacketNumber) · `wButtons u16 +@8` · `LT @10` · `RT @11` · `LX/LY/RX/RY i16 @12/@14/@16/@18` · `rumble_seq u32 @24` (driver bumps) · +`large @28` · `small @29`. + +## Validated live on `.173` (2026-06-22) + +`XInputGetState(0)` returns **CONNECTED** with the pushed buttons/sticks and an incrementing +`dwPacketNumber`; `XInputSetState(0xC000, 0x4000)` reaches the driver as `00 00 c0 40 02` → host sees +`large=192 small=64`. Test tools: `C:\Users\Public\giprobe\xusbtest.exe` (creates the `pf_xusb` +devnode + cycling state via shm) and `xinputtest.exe` (`XInputGetState`/`SetState` harness). + +## Build / sign / install (same recipe as the DualSense driver) + +Built from `C:\Users\Public\m0\windows-drivers-rs\examples\pf-xusb` (the `../../crates` paths resolve +there); these repo files are the canonical copies — keep them in sync. + +1. `cargo make` (env `LIBCLANG_PATH`, `Version_Number=10.0.26100.0`) → `target\debug\pf_xusb_package\`. +2. Clear the FORCE_INTEGRITY PE bit (bit `0x80` at `e_lfanew+0x5e` of `pf_xusb.dll`). +3. `signtool sign /fd SHA256 /sha1 6A52984E54376C45A1C236B1A2C8A746C5AB6131 pf_xusb.dll`. +4. `Inf2Cat /driver: /os:10_X64` → re-sign `pf_xusb.cat` with the same thumbprint. +5. `pnputil /add-driver pf_xusb.inf` (no `/install`; the host SwDeviceCreate's `pf_xusb` per session). + +## Host integration (done) + +`crates/punktfunk-host/src/inject/gamepad_windows.rs` is the Windows `GamepadManager` (used by +`PadBackend::Xbox360`): it SwDeviceCreate's the `pf_xusb` companion, maps `pfxusb-shm-`, writes +the XInput state from the client's gamepad frame (already XInput-convention) and forwards rumble. There +is **no ViGEmBus dependency** anymore. The driver is vendored + pnputil-installed by the Inno Setup +installer (`packaging/windows/gamepad-drivers/` + `install-gamepad-drivers.ps1`). + +## Multi-pad + +The host stamps each pad's index into the device Location (`pszDeviceLocation`); the driver reads it +via `WdfDeviceAllocAndQueryProperty(DevicePropertyLocationInformation)` in EvtDeviceAdd and maps its own +`pfxusb-shm-`. `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) gives each pad its own +WUDFHost, so the per-pad `SHM_INDEX` static doesn't collide. Validated live: two pads → two distinct +XInput slots. (XInput assigns the player slot 0-3 by interface-enumeration order, independent of this +index — which only routes shared memory.) diff --git a/packaging/windows/xusb-driver/build.rs b/packaging/windows/xusb-driver/build.rs new file mode 100644 index 0000000..bf0e777 --- /dev/null +++ b/packaging/windows/xusb-driver/build.rs @@ -0,0 +1,5 @@ +//! Build script for the `pf-xusb` UMDF driver — provides Cargo the WDK linker flags. + +fn main() -> Result<(), wdk_build::ConfigError> { + wdk_build::configure_wdk_binary_build() +} diff --git a/packaging/windows/xusb-driver/pf_xusb.inx b/packaging/windows/xusb-driver/pf_xusb.inx new file mode 100644 index 0000000..12febc8 --- /dev/null +++ b/packaging/windows/xusb-driver/pf_xusb.inx @@ -0,0 +1,64 @@ +;/*++ +; punktfunk virtual Xbox 360 XUSB companion — a non-HID UMDF2 driver that registers the XUSB +; device-interface GUID {EC87F1E3-...} and answers the buffered XInput IOCTLs, so classic +; XInputGetState() reads the pad without a kernel bus driver (the HIDMaestro approach). System class, +; hosted by the in-box WUDFRd reflector. Created per-session by the host via SwDeviceCreate +; (hardware id `pf_xusb`); `root\pf_xusb` is the devgen/devcon test id. +;--*/ +[Version] +Signature = "$WINDOWS NT$" +Class = System +ClassGuid = {4D36E97D-E325-11CE-BFC1-08002BE10318} +Provider = %ProviderString% +CatalogFile = pf_xusb.cat +PnpLockdown = 1 + +[DestinationDirs] +DefaultDestDir = 13 + +[SourceDisksNames] +1 = %DiskId1%,,,"" + +[SourceDisksFiles] +pf_xusb.dll = 1,, + +[Manufacturer] +%StdMfg%=Standard, NT$ARCH$.10.0...22000 + +[Standard.NT$ARCH$.10.0...22000] +%DeviceDesc%=pfXusb, root\pf_xusb, pf_xusb + +[pfXusb.NT] +CopyFiles=Drivers_Dir +Include=WUDFRD.inf +Needs=WUDFRD.NT + +[Drivers_Dir] +pf_xusb.dll + +[pfXusb.NT.HW] +Include=WUDFRD.inf +Needs=WUDFRD.NT.HW + +[pfXusb.NT.Services] +Include=WUDFRD.inf +Needs=WUDFRD.NT.Services + +[pfXusb.NT.Wdf] +UmdfService=pf_xusb, pfXusb_Install +UmdfServiceOrder=pf_xusb +UmdfKernelModeClientPolicy=AllowKernelModeClients +UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects +UmdfMethodNeitherAction=Copy +UmdfFsContextUsePolicy=CanUseFsContext2 +UmdfHostProcessSharing=ProcessSharingDisabled + +[pfXusb_Install] +UmdfLibraryVersion=$UMDFVERSION$ +ServiceBinary=%13%\pf_xusb.dll + +[Strings] +ProviderString = "punktfunk" +StdMfg = "(Standard system devices)" +DiskId1 = "punktfunk XUSB Installation Disk" +DeviceDesc = "punktfunk Virtual Xbox 360 (XUSB)" diff --git a/packaging/windows/xusb-driver/src/lib.rs b/packaging/windows/xusb-driver/src/lib.rs new file mode 100644 index 0000000..4c5416f --- /dev/null +++ b/packaging/windows/xusb-driver/src/lib.rs @@ -0,0 +1,462 @@ +// punktfunk virtual Xbox 360 XUSB companion — UMDF2 driver presenting the XUSB device interface so +// classic XInput (XInputGetState) reads the pad with no kernel bus driver (the HIDMaestro approach). +// +// xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls +// it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the +// host publishes into a shared-memory section (`Global\pfxusb-shm-0`); a game's rumble (SET_STATE) is +// published back for the host to forward. Byte formats are the source-verified xusb22 wire layout +// (HIDMaestro driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT). +// +// We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to +// synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput. + +#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)] + +use core::ffi::c_void; +use core::sync::atomic::{AtomicU32, Ordering}; +use wdk_sys::{ + call_unsafe_wdf_function_binding, windows::OutputDebugStringA, GUID, NTSTATUS, PCUNICODE_STRING, + PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFREQUEST, + WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, +}; + +// DEVICE_REGISTRY_PROPERTY: DevicePropertyLocationInformation (the const isn't re-exported at the +// wdk_sys root; the value is stable WDM). +const DEVICE_PROPERTY_LOCATION_INFORMATION: i32 = 10; + +/// The pad index this device serves (which `pfxusb-shm-` section to map). The host stamps it +/// into the device Location (`pszDeviceLocation`); the driver reads it in EvtDeviceAdd. With +/// `UmdfHostProcessSharing=ProcessSharingDisabled` (the INF) each pad gets its own WUDFHost, so this +/// static is per-pad — the basis for multi-pad. +static SHM_INDEX: AtomicU32 = AtomicU32::new(0); + +// ---- NTSTATUS ---- +const STATUS_SUCCESS: NTSTATUS = 0; +const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS; +const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS; + +#[inline] +fn nt_success(s: NTSTATUS) -> bool { + s >= 0 +} + +// GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens. +const GUID_DEVINTERFACE_XUSB: GUID = GUID { + Data1: 0xEC87_F1E3, + Data2: 0xC13B, + Data3: 0x4100, + Data4: [0xB5, 0xF7, 0x8B, 0x84, 0xD5, 0x42, 0x60, 0xCB], +}; + +// ---- XUSB IOCTLs (METHOD_BUFFERED) ---- +const IOCTL_XUSB_GET_INFORMATION: u32 = 0x8000_6000; +const IOCTL_XUSB_GET_CAPABILITIES: u32 = 0x8000_E004; +const IOCTL_XUSB_GET_LED_STATE: u32 = 0x8000_E008; +const IOCTL_XUSB_GET_STATE: u32 = 0x8000_E00C; +const IOCTL_XUSB_SET_STATE: u32 = 0x8000_A010; +const IOCTL_XUSB_WAIT_GUIDE_BUTTON: u32 = 0x8000_E014; +const IOCTL_XUSB_GET_BATTERY_INFORMATION: u32 = 0x8000_E018; +const IOCTL_XUSB_POWER_DOWN: u32 = 0x8000_A01C; +const IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER: u32 = 0x8000_6380; +const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC; +const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC; + +// Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration). +const XUSB_VID: u16 = 0x045E; +const XUSB_PID: u16 = 0x028E; +const XUSB_VERSION: u16 = 0x0103; + +// ---- WDF enum values ---- +const WdfIoQueueDispatchParallel: i32 = 2; +const WdfUseDefault: i32 = 2; // WDF_TRI_STATE + +// ---- shared-memory layout (host ↔ driver), must match the host's xbox_xusb_windows backend ---- +// magic u32 @0 ("PFXU"); packet u32 @4 (host bumps on state change → dwPacketNumber); the XUSB_REPORT +// payload @8: wButtons u16 @8, bLeftTrigger @10, bRightTrigger @11, sThumbLX i16 @12, LY @14, RX @16, +// RY @18; rumble_seq u32 @24 (driver bumps on SET_STATE); rumble large @28, small @29. +const FILE_MAP_RW: u32 = 0x0002 | 0x0004; +const SHM_MAGIC: u32 = 0x5558_4650; // "PFXU" little-endian +const SHM_SIZE: usize = 64; + +unsafe extern "system" { + fn OpenFileMappingW(access: u32, inherit: i32, name: *const u16) -> *mut c_void; + fn MapViewOfFile(h: *mut c_void, access: u32, hi: u32, lo: u32, len: usize) -> *mut c_void; + fn UnmapViewOfFile(addr: *const c_void) -> i32; + fn CloseHandle(h: *mut c_void) -> i32; +} + +fn log(s: &str) { + if let Ok(c) = std::ffi::CString::new(s) { + // SAFETY: c is a valid null-terminated string for the duration of the call. + unsafe { OutputDebugStringA(c.as_ptr().cast()) }; + } + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open("C:\\Users\\Public\\pfxusb-driver.log") + { + let _ = writeln!(f, "{s}"); + } +} +macro_rules! dbglog { ($($a:tt)*) => { log(&format!($($a)*)) } } + +#[unsafe(export_name = "DriverEntry")] +pub unsafe extern "system" fn driver_entry( + driver: PDRIVER_OBJECT, + registry_path: PCUNICODE_STRING, +) -> NTSTATUS { + log("[pf-xusb] DriverEntry"); + // SAFETY: zeroed config then Size + callback set. + let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() }; + config.Size = core::mem::size_of::() as ULONG; + config.EvtDriverDeviceAdd = Some(evt_device_add); + // SAFETY: all pointers valid; provided by the loader. + unsafe { + call_unsafe_wdf_function_binding!( + WdfDriverCreate, + driver, + registry_path, + WDF_NO_OBJECT_ATTRIBUTES, + &mut config, + WDF_NO_HANDLE.cast::() + ) + } +} + +/// Read the pad index the host stamped into the device Location (`pszDeviceLocation`), a NUL-terminated +/// UTF-16 decimal string. Defaults to 0 (single-pad) if absent. +fn query_shm_index(device: WDFDEVICE) -> u32 { + let mut mem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: device valid; property = LocationInformation; pool ignored in UMDF; mem receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceAllocAndQueryProperty, + device, + DEVICE_PROPERTY_LOCATION_INFORMATION, + 0, + WDF_NO_OBJECT_ATTRIBUTES, + &mut mem + ) + }; + if !nt_success(st) || mem.is_null() { + return 0; + } + let mut len: usize = 0; + // SAFETY: mem valid. + let buf = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut len) } + as *const u16; + if buf.is_null() { + return 0; + } + let mut idx: u32 = 0; + let mut any = false; + for i in 0..(len / 2).min(8) { + // SAFETY: buf valid for len bytes; i < len/2. + let c = unsafe { *buf.add(i) }; + if c == 0 { + break; + } + if (0x30..=0x39).contains(&c) { + idx = idx.wrapping_mul(10).wrapping_add((c - 0x30) as u32); + any = true; + } + } + if any { + idx + } else { + 0 + } +} + +extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS { + log("[pf-xusb] EvtDeviceAdd"); + + let mut device: WDFDEVICE = core::ptr::null_mut(); + // SAFETY: device_init valid; attributes null; device receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreate, + &mut device_init, + WDF_NO_OBJECT_ATTRIBUTES, + &mut device + ) + }; + if !nt_success(st) { + dbglog!("[pf-xusb] WdfDeviceCreate failed 0x{:08x}", st as u32); + return st; + } + + let idx = query_shm_index(device); + SHM_INDEX.store(idx, Ordering::Relaxed); + dbglog!("[pf-xusb] shm index = {idx}"); + + // Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens. + // SAFETY: device valid; GUID static; null reference string. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfDeviceCreateDeviceInterface, + device, + &GUID_DEVINTERFACE_XUSB, + core::ptr::null() + ) + }; + if !nt_success(st) { + dbglog!( + "[pf-xusb] WdfDeviceCreateDeviceInterface failed 0x{:08x}", + st as u32 + ); + return st; + } + + // Default parallel queue: all the XUSB IOCTLs land here. + // SAFETY: zeroed config then fields set; Size matches the struct. + let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; + qcfg.Size = core::mem::size_of::() as ULONG; + qcfg.DispatchType = WdfIoQueueDispatchParallel; + qcfg.PowerManaged = WdfUseDefault; + qcfg.DefaultQueue = 1; + qcfg.EvtIoDeviceControl = Some(evt_io_device_control); + qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX; + let mut queue: WDFQUEUE = core::ptr::null_mut(); + // SAFETY: device + config valid; attributes null; queue receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &mut qcfg, + WDF_NO_OBJECT_ATTRIBUTES, + &mut queue + ) + }; + if !nt_success(st) { + dbglog!("[pf-xusb] WdfIoQueueCreate failed 0x{:08x}", st as u32); + return st; + } + + log("[pf-xusb] device ready (XUSB interface registered)"); + STATUS_SUCCESS +} + +// Open + map the host's shared section and run `f` against the mapped base if magic is valid, then +// unmap. Re-mapped per access (the host may recreate the section across restarts). +fn with_shm(f: F) { + let name: Vec = format!("Global\\pfxusb-shm-{}", SHM_INDEX.load(Ordering::Relaxed)) + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + // SAFETY: name is a valid NUL-terminated UTF-16 string. + let h = unsafe { OpenFileMappingW(FILE_MAP_RW, 0, name.as_ptr()) }; + if h.is_null() { + return; + } + // SAFETY: h is a valid mapping handle; map the whole section; the view keeps it alive. + let view = unsafe { MapViewOfFile(h, FILE_MAP_RW, 0, 0, SHM_SIZE) } as *mut u8; + unsafe { CloseHandle(h) }; + if view.is_null() { + return; + } + // SAFETY: view points at >= 4 mapped bytes. + let magic = unsafe { core::ptr::read_unaligned(view as *const u32) }; + if magic == SHM_MAGIC { + f(view); + } + // SAFETY: view came from MapViewOfFile. + unsafe { UnmapViewOfFile(view as *const c_void) }; +} + +/// The current controller state from shared memory (zeros / neutral if the host hasn't connected). +/// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`. +fn read_state() -> (u32, u16, u8, u8, i16, i16, i16, i16) { + let mut out = (0u32, 0u16, 0u8, 0u8, 0i16, 0i16, 0i16, 0i16); + with_shm(|v| { + // SAFETY: v points at a mapped SHM_SIZE section with valid magic. + unsafe { + out.0 = core::ptr::read_unaligned(v.add(4) as *const u32); + out.1 = core::ptr::read_unaligned(v.add(8) as *const u16); + out.2 = *v.add(10); + out.3 = *v.add(11); + out.4 = core::ptr::read_unaligned(v.add(12) as *const i16); + out.5 = core::ptr::read_unaligned(v.add(14) as *const i16); + out.6 = core::ptr::read_unaligned(v.add(16) as *const i16); + out.7 = core::ptr::read_unaligned(v.add(18) as *const i16); + } + }); + out +} + +/// Publish a game's rumble (from SET_STATE) into shared memory for the host to forward. +fn publish_rumble(large: u8, small: u8) { + with_shm(|v| { + // SAFETY: v points at a mapped SHM_SIZE section; rumble_seq @24, large @28, small @29. + unsafe { + *v.add(28) = large; + *v.add(29) = small; + let seqp = v.add(24) as *mut u32; + let seq = core::ptr::read_unaligned(seqp).wrapping_add(1); + core::ptr::write_unaligned(seqp, seq); + } + }); +} + +// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses). +fn build_get_state() -> [u8; 29] { + let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(); + let mut s = [0u8; 29]; + s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); + s[2] = 0x01; // device count + s[5..9].copy_from_slice(&packet.to_le_bytes()); + s[0x0B..0x0D].copy_from_slice(&buttons.to_le_bytes()); + s[0x0D] = lt; + s[0x0E] = rt; + s[0x0F..0x11].copy_from_slice(&lx.to_le_bytes()); + s[0x11..0x13].copy_from_slice(&ly.to_le_bytes()); + s[0x13..0x15].copy_from_slice(&rx.to_le_bytes()); + s[0x15..0x17].copy_from_slice(&ry.to_le_bytes()); + s +} + +// GET_INFORMATION: 12 bytes — version, device count, VID/PID. Marks the slot connected. +fn build_information() -> [u8; 12] { + let mut info = [0u8; 12]; + info[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); + info[2] = 0x01; // one device/port + info[8..10].copy_from_slice(&XUSB_VID.to_le_bytes()); + info[10..12].copy_from_slice(&XUSB_PID.to_le_bytes()); + info +} + +// GET_CAPABILITIES V1 (24 bytes): Type=0x03 SubType=0x01 (gamepad), button/stick masks, motor max +// = 0xFFFF (advertise rumble). The V2 (36-byte) form prepends a 16-byte header when WGI asks for 36. +#[rustfmt::skip] +const CAPS_V1: [u8; 24] = [ + 0x03, 0x01, 0x00, 0x01, 0xFF, 0xF7, 0xFF, 0xFF, + 0xC0, 0xFF, 0xC0, 0xFF, 0xC0, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, +]; + +fn build_caps_v2() -> [u8; 36] { + let mut c = [0u8; 36]; + c[0..6].copy_from_slice(&[0x03, 0x01, 0x01, 0x01, 0x0C, 0x00]); + c[6..8].copy_from_slice(&XUSB_VID.to_le_bytes()); + c[8..10].copy_from_slice(&XUSB_PID.to_le_bytes()); + c[10..16].copy_from_slice(&[0x10, 0x01, 0x00, 0xFA, 0x34, 0x22]); + c[16..36].copy_from_slice(&CAPS_V1[4..24]); // the XINPUT_CAPABILITIES struct body + c +} + +extern "C" fn evt_io_device_control( + _queue: WDFQUEUE, + request: WDFREQUEST, + output_len: usize, + input_len: usize, + ioctl: ULONG, +) { + let status: NTSTATUS = match ioctl { + IOCTL_XUSB_GET_INFORMATION => copy_to_output(request, &build_information()), + IOCTL_XUSB_GET_INFORMATION_EX => { + let mut ex = [0u8; 64]; + ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes()); + ex[2] = 0x01; + ex[3] = 0x01; + ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes()); + ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes()); + let n = output_len.min(64); + copy_to_output(request, &ex[..n]) + } + IOCTL_XUSB_GET_CAPABILITIES => { + if output_len >= 36 { + copy_to_output(request, &build_caps_v2()) + } else { + copy_to_output(request, &CAPS_V1) + } + } + IOCTL_XUSB_GET_STATE => copy_to_output(request, &build_get_state()), + IOCTL_XUSB_GET_LED_STATE => copy_to_output(request, &[0x00, 0x00, 0x06]), + IOCTL_XUSB_GET_BATTERY_INFORMATION => { + copy_to_output(request, &[0x00, 0x01, 0x03, 0x00]) + } + IOCTL_XUSB_SET_STATE => on_set_state(request), + IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS, + // Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling. + IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST, + other => { + dbglog!("[pf-xusb] unhandled IOCTL 0x{other:08x} in={input_len} out={output_len}"); + STATUS_INVALID_DEVICE_REQUEST + } + }; + // SAFETY: request valid and not forwarded. + unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) }; +} + +// SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end. +// We publish a best-effort (large = byte 3, small = byte 4 for the 5-byte form) and log the raw bytes +// so the exact offsets can be confirmed against a real pad. +fn on_set_state(request: WDFREQUEST) -> NTSTATUS { + let mut inmem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: request valid. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveInputMemory, request, &mut inmem) + }; + if nt_success(st) { + let mut len: usize = 0; + // SAFETY: inmem valid. + let p = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, inmem, &mut len) } + as *const u8; + if !p.is_null() && len >= 2 { + let n = len.min(8); + // SAFETY: p valid for len bytes; read at most n. + let bytes = unsafe { core::slice::from_raw_parts(p, n) }; + let mut hex = String::new(); + for b in bytes { + hex.push_str(&format!("{b:02x} ")); + } + dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}"); + // Observed 5-byte form {00, led, largeMotor, smallMotor, subcmd}: subcmd 0x02 = rumble + // (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored). + // 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes. + if len >= 5 && bytes[4] == 0x02 { + publish_rumble(bytes[2], bytes[3]); + } else if len == 4 { + publish_rumble(bytes[1], bytes[3]); + } + } + } + STATUS_SUCCESS +} + +// Copy `src` into the request's (buffered) output buffer and set the completed byte count. +fn copy_to_output(request: WDFREQUEST, src: &[u8]) -> NTSTATUS { + let mut mem: WDFMEMORY = core::ptr::null_mut(); + // SAFETY: request valid; mem receives the memory handle. + let st = unsafe { + call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut mem) + }; + if !nt_success(st) { + return st; + } + let mut outlen: usize = 0; + // SAFETY: mem valid; outlen receives the buffer size. + let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, mem, &mut outlen) }; + if outlen < src.len() { + return STATUS_INVALID_BUFFER_SIZE; + } + // SAFETY: mem valid; src is a valid buffer of src.len() bytes. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfMemoryCopyFromBuffer, + mem, + 0usize, + src.as_ptr() as *mut c_void, + src.len() + ) + }; + if !nt_success(st) { + return st; + } + // SAFETY: request valid. + unsafe { + call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, src.len() as u64) + }; + STATUS_SUCCESS +}