feat(gamepad): pure-user-mode Windows DualShock 4 + Xbox 360 (drop ViGEm) + installer + multi-pad
audit / cargo-audit (push) Successful in 17s
apple / swift (push) Successful in 57s
android / android (push) Successful in 4m36s
ci / web (push) Successful in 34s
ci / docs-site (push) Successful in 52s
release / apple (push) Successful in 7m31s
ci / rust (push) Successful in 8m37s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
deb / build-publish (push) Successful in 2m35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
flatpak / build-publish (push) Successful in 4m0s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m31s
docker / deploy-docs (push) Successful in 19s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m22s
windows-host / package (push) Successful in 2m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m13s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m3s

Windows virtual gamepads now have zero external dependencies - ViGEmBus is removed.

- DualShock 4: Windows UMDF backend (inject/dualshock4_windows.rs + dualshock4_proto.rs),
  reusing the DualSense SwDeviceCreate game-detection identity fix. The one UMDF driver serves
  the DS5 or DS4 identity/descriptor/features/strings per a device_type byte the host stamps into
  shared memory. Driver also gains IOCTL_HID_GET_STRING and a 41-byte calibration feature.
- Xbox 360: a new UMDF2 XUSB companion driver (packaging/windows/xusb-driver/) 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. inject/gamepad_windows.rs is rewritten
  to drive it and the vigem-client dependency is removed. Xbox One folds to the 360 XInput path.
- Installer: vendor + pnputil-install the three UMDF drivers (packaging/windows/gamepad-drivers/
  + install-gamepad-drivers.ps1, wired into pack-host-installer.ps1 + punktfunk-host.iss).
- Multi-pad: the host stamps each pad index into the device Location (pszDeviceLocation); the
  driver reads it via WdfDeviceAllocAndQueryProperty to map its own *-shm-<index>, with
  UmdfHostProcessSharing=ProcessSharingDisabled giving each pad its own host (per-pad statics).

Validated live on the Windows host: Cyberpunk native DualSense detection, DS4 identity + descriptor,
XInputGetState + rumble round-trip, two pads -> two distinct XInput slots, and a full installer build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:34:22 +02:00
parent f208f3d92e
commit b0c82333d2
29 changed files with 2389 additions and 288 deletions
@@ -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_<index>` 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_<index>`, 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_<idx>` vs `pf_ds4_<idx>`) 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<HSWDEVICE> {
let hwids: Vec<u16> = "pf_dualsense".encode_utf16().chain([0u16, 0u16]).collect();
let instid: Vec<u16> = format!("pf_pad_{index}")
pub(super) fn create_swdevice(p: &SwDeviceProfile) -> Result<HSWDEVICE> {
// Build a double-NUL-terminated UTF-16 multi-sz from a list of ids.
let multi_sz = |ids: &[&str]| -> Vec<u16> {
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<u16> = p
.instance
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let desc: Vec<u16> = "punktfunk Virtual DualSense"
let desc: Vec<u16> = 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-<index>`
// (multi-pad). The buffer outlives the SwDeviceCreate call (we wait on the event before return).
let loc: Vec<u16> = format!("{}", p.container_index)
.encode_utf16()
.chain(std::iter::once(0))
.collect();
// Deterministic per-pad ContainerId {50464453-0000-0000-0000-0000000000<idx>} ("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::<SW_DEVICE_CREATE_INFO>() 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<HSWDEVICE> {
Ok(hsw)
}
/// Create + map the named section `Global\pfds-shm-<index>`, 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::<SECURITY_ATTRIBUTES>() 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-<index>`, 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<DsWinPad> {
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::<SECURITY_ATTRIBUTES>() 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");
@@ -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<HidOutput>,
/// `(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
}
}
@@ -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-<idx>` 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_<index>` devnode plus the mapped
/// shared section. Dropping it removes the devnode and unmaps + closes the section.
struct Ds4WinPad {
hsw: Option<HSWDEVICE>,
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_<index>` devnode (the driver loads on it and maps the section).
fn open(index: u8) -> Result<Ds4WinPad> {
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<Option<Ds4WinPad>>,
state: Vec<DsState>,
last_rumble: Vec<(u16, u16)>,
last_led: Vec<Option<(u8, u8, u8)>>,
last_write: Vec<Instant>,
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,
});
}
}
}
}
}
@@ -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_<index>` 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-<index>`. 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<Arc<Client>>,
rumble: Arc<AtomicU32>,
last_emitted: u32,
_notif_thread: Option<JoinHandle<()>>,
// 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_<index>` 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<HSWDEVICE> {
let hwids: Vec<u16> = "pf_xusb".encode_utf16().chain([0u16, 0u16]).collect();
let instid: Vec<u16> = format!("pf_xusb_{index}")
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let desc: Vec<u16> = "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-<index>`
// (multi-pad). The buffer must outlive the SwDeviceCreate call (it does; we wait on the event).
let loc: Vec<u16> = 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::<SW_DEVICE_CREATE_INFO>() 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_<index>` devnode plus the mapped shared section.
struct XusbWinPad {
hsw: Option<HSWDEVICE>,
map: HANDLE,
view: *mut u8,
packet: u32,
last_rumble_seq: u32,
}
impl XusbWinPad {
/// Create + map `Global\pfxusb-shm-<index>`, stamp the magic, then spawn the devnode.
fn open(index: u8) -> Result<XusbWinPad> {
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::<SECURITY_ATTRIBUTES>() 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<Arc<Client>>,
pads: HashMap<u8, PadEntry>,
pads: Vec<Option<XusbWinPad>>,
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);
}
}
}
}