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
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:
@@ -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-<index>`, 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
|
||||
|
||||
Generated
-32
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -1167,7 +1167,8 @@ fn mic_service_thread(rx: std::sync::mpsc::Receiver<Vec<u8>>) {
|
||||
/// 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);
|
||||
|
||||
@@ -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<idx>}` ("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`)
|
||||
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -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<WDFQUEUE__> = 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-<index>` 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<u16> = 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: FnOnce(*mut u8)>(f: F) {
|
||||
let name: Vec<u16> = "Global\\pfds-shm-0"
|
||||
let name: Vec<u16> = 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: FnOnce(*mut u8)>(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: FnOnce(*mut u8)>(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| {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)"
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}"; \
|
||||
|
||||
@@ -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]
|
||||
@@ -0,0 +1,4 @@
|
||||
extend = [
|
||||
{ path = "../../crates/wdk-build/rust-driver-makefile.toml" },
|
||||
{ path = "../../crates/wdk-build/rust-driver-sample-makefile.toml" },
|
||||
]
|
||||
@@ -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:<pkg> /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-<index>`, 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-<index>`. `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.)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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-<index>` 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::<WDF_DRIVER_CONFIG>() 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::<WDFDRIVER>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::<WDF_IO_QUEUE_CONFIG>() 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: FnOnce(*mut u8)>(f: F) {
|
||||
let name: Vec<u16> = 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
|
||||
}
|
||||
Reference in New Issue
Block a user