feat(windows): Rust UMDF virtual DualSense driver + shared-memory host channel
A self-authored UMDF2 HID minidriver (packaging/windows/dualsense-driver) that presents a virtual Sony DualSense (VID 054C/PID 0CE6) on Windows — adaptive triggers / lightbar / rumble that ViGEm structurally cannot deliver. Validated live on an RTX box (Win11 25H2, Secure Boot ON): the self-signed driver loads, Steam recognizes it as a genuine DualSense, and a game's 0x02 output report reaches the driver. The host<->driver channel is a named shared-memory section (Global\pfds-shm-<idx>) the host creates and the driver maps from its timer: input report 0x01 host->driver, output report 0x02 driver->host — input and output proven both directions live. This bypasses hidclass, which gates both a custom device interface and custom IOCTLs on the HID node, and UMDF has no control device. Built in Rust on microsoft/windows-drivers-rs. The load wall was the PE FORCE_INTEGRITY bit that wdk-build sets via /INTEGRITYCHECK (forces a CI-trusted page-hash signature a self-signed cert cannot satisfy) — cleared post-build. See packaging/windows/dualsense-driver/README.md for the build/sign/install recipe. Deferred: SwDeviceCreate per-session device lifecycle; removing the inert in-driver IOCTL-channel code; full on-glass session test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,35 +1,64 @@
|
|||||||
# Windows host — virtual DualSense scoping
|
# Windows host — virtual DualSense scoping
|
||||||
|
|
||||||
**Status:** scoping (2026-06-20). Decision pending the web-research pass (see *Open questions* — web
|
**Status:** **M0 feasibility gate PASSED (2026-06-21)** — a self-authored **Rust** UMDF virtual DualSense
|
||||||
search was unavailable when this was written, so the VHF API/signing specifics and the
|
loads self-signed under Secure Boot, is recognized as a genuine DualSense by Steam, and receives `0x02`
|
||||||
"existing-driver-to-vendor" survey are marked TO-CONFIRM).
|
output reports at its write callback. Driver source: `packaging/windows/dualsense-driver/`. (Earlier in
|
||||||
|
this doc's history the gate looked blocked by Secure Boot / driver code-integrity — that was wrong; the
|
||||||
|
real blocker was the PE FORCE_INTEGRITY bit that `wdk-build` sets via `/INTEGRITYCHECK`, cleared post-build.)
|
||||||
|
Web-research pass complete; the mechanism conclusion is **reversed**
|
||||||
|
from the 2026-06-20 draft. This doc **supersedes the 2026-06-20 VHF scoping** — VHF was the wrong
|
||||||
|
answer (it is kernel-only and cannot host a user-mode HID source), and the correct mechanism is a
|
||||||
|
**UMDF2 user-mode HID minidriver**, the same driver tier punktfunk already vendors/signs/installs for
|
||||||
|
SudoVDA. Two product decisions are now fixed and drive this plan: **(1)** the driver is for **public
|
||||||
|
end-user distribution** (so: EV cert + Microsoft attestation signing, not just the fleet self-signed
|
||||||
|
recipe), and **(2)** the strong preference is a **self-authored Rust driver**, with a thin C/C++ shim
|
||||||
|
as the realistic fallback and forking HIDMaestro as the last resort.
|
||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
|
|
||||||
Apollo's backlog item #23/#89 ("DS4 ViGEm target on Windows") is the **wrong target** if the goal is
|
Apollo's backlog item #23/#89 ("DS4 ViGEm target on Windows") is the **wrong target** if the goal is
|
||||||
*actual DualSense*. ViGEmBus emulates only **Xbox 360 (XUSB)** and **DualShock 4 (DS4)** — never a
|
*actual DualSense*. ViGEmBus emulates only **Xbox 360 (XUSB)** and **DualShock 4 (DS4)** — never a
|
||||||
DualSense. Because this is a *host-side* virtual pad, the DualSense-defining features (adaptive
|
DualSense. Because this is a *host-side* virtual pad, the DualSense-defining features (adaptive
|
||||||
triggers, the fine haptic actuators, DS5 identity) can only work end-to-end if the **game sees a real
|
triggers, the fine haptic actuators, DS5 identity) only work end-to-end if the **game sees a real
|
||||||
DualSense** and therefore drives them; a DS4 virtual pad means the game uses its DS4 code path and
|
DualSense** and therefore drives them; a DS4 virtual pad means the game takes its DS4 code path and
|
||||||
never emits those commands, so the client's adaptive-trigger rendering is never exercised. ViGEm DS4
|
never emits those commands, so the client's adaptive-trigger rendering is never exercised. ViGEm DS4
|
||||||
structurally **cannot** deliver adaptive triggers.
|
structurally **cannot** deliver adaptive triggers.
|
||||||
|
|
||||||
The right path is the Windows analog of what the Linux host already does: present a **real virtual
|
The right path is the Windows analog of what the Linux host already does over `/dev/uhid`: present a
|
||||||
DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report descriptor). On Windows
|
**real virtual DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report
|
||||||
that means a kernel-mode virtual-HID device via the **Virtual HID Framework (VHF)** — the UHID analog —
|
descriptor punktfunk already ships). On Windows that is a **UMDF2 (user-mode) HID minidriver** —
|
||||||
which is a SudoVDA-class driver effort (vendored + signed, installed by the existing Inno installer).
|
created/torn-down per session from the host via `SwDeviceCreate`, sitting as a lower filter under
|
||||||
|
the OS pass-through driver `mshidumdf.sys`. It is the **same driver tier as SudoVDA** (UMDF, not
|
||||||
|
kernel), so the existing vendor → sign → Inno-installer machinery applies almost unchanged.
|
||||||
|
|
||||||
|
> **Supersedes the 2026-06-20 VHF scoping.** That draft concluded "a kernel-mode virtual-HID device
|
||||||
|
> via the Virtual HID Framework (VHF) — a SudoVDA-class driver effort." The decisive correction:
|
||||||
|
> **VHF supports a HID *source* driver only in kernel mode** (Microsoft "Virtual HID Framework
|
||||||
|
> (VHF)"). A user-mode (UMDF) HID source is **not** a VHF use case — it is a UMDF2 HID minidriver
|
||||||
|
> built from the `vhidmini2` sample (or DMF's `Dmf_VirtualHidMini`). The earlier "KMDF is a higher
|
||||||
|
> bar than SudoVDA's UMDF/IddCx" framing is therefore wrong: the correct mechanism is **the same
|
||||||
|
> UMDF tier as SudoVDA**, not above it.
|
||||||
|
|
||||||
|
Everything except the host backend is already platform-agnostic and DualSense-complete (verified
|
||||||
|
against live code), so this is a well-bounded host-side addition. **The whole effort is gated by an
|
||||||
|
on-glass feasibility spike (M0)** that no prior art settles: whether a virtual `054C:0CE6` device is
|
||||||
|
accepted as a genuine DualSense by `Windows.Gaming.Input` / GameInput / Steam **and** whether the
|
||||||
|
game's output report `0x02` (the adaptive-trigger block) actually reaches the driver's write callback.
|
||||||
|
|
||||||
## Why this is the wrong place to copy Apollo
|
## Why this is the wrong place to copy Apollo
|
||||||
|
|
||||||
Apollo (and all of Sunshine's lineage) **does DualSense only on Linux** (`inputtino`,
|
Apollo (and all of Sunshine's lineage) **does DualSense only on Linux** (`inputtino`,
|
||||||
`DualSenseWired`). Its Windows input path (`src/platform/windows/input.cpp`) is ViGEm
|
`DualSenseWired`). Its Windows input path (`src/platform/windows/input.cpp`) is ViGEm
|
||||||
`XUSB_REPORT` + `DS4_REPORT_EX` only — `MPS2_TO_DS4_ACCEL` motion conversion, inverse-ViGEmBus gyro
|
`XUSB_REPORT` + `DS4_REPORT_EX` only — `MPS2_TO_DS4_ACCEL` motion conversion, inverse-ViGEmBus gyro
|
||||||
calibration, DS4 touchpad packing. There is **zero** VHF / virtual-HID / DualSense code on Apollo's
|
calibration, DS4 touchpad packing. There is **zero** virtual-HID / DualSense code on Apollo's Windows
|
||||||
Windows side. So:
|
side. So:
|
||||||
|
|
||||||
- Copying Apollo on Windows gets us a **DS4**, with the adaptive-trigger ceiling baked in.
|
- Copying Apollo on Windows gets us a **DS4**, with the adaptive-trigger ceiling baked in.
|
||||||
- There is **no in-ecosystem upstream** (Sunshine/Apollo/Wolf) that already solved virtual DualSense
|
- There is **no in-ecosystem upstream** (Sunshine/Apollo/Wolf) that already solved a virtual
|
||||||
on Windows to vendor from. This would be novel work for the streaming-host space.
|
*DualSense* on Windows to vendor from. The closest prior art is in the **virtual-HID-controller**
|
||||||
|
space, not the streaming-host space: HIDMaestro and Nefarius DsHidMini (see *Mechanism*).
|
||||||
|
|
||||||
|
This is unchanged from the 2026-06-20 draft and remains correct.
|
||||||
|
|
||||||
## The parity target — and what's *already* done
|
## The parity target — and what's *already* done
|
||||||
|
|
||||||
@@ -39,96 +68,336 @@ DualSense — gamepad + motion + touchpad + lightbar/player-LEDs + adaptive trig
|
|||||||
**input** report `0x01` (controller state) and reads HID **output** report `0x02` (the game's
|
**input** report `0x01` (controller state) and reads HID **output** report `0x02` (the game's
|
||||||
rumble/LED/trigger feedback), which it forwards to the client as `punktfunk_core::quic::HidOutput`.
|
rumble/LED/trigger feedback), which it forwards to the client as `punktfunk_core::quic::HidOutput`.
|
||||||
|
|
||||||
Crucially, **everything except the host backend is already platform-agnostic and DualSense-complete:**
|
Crucially, **everything except the host backend is already platform-agnostic and DualSense-complete**
|
||||||
|
(verified against live source):
|
||||||
|
|
||||||
| Layer | State | Where |
|
| Layer | State | Where |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | done | `punktfunk_core::quic` |
|
| Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | ✅ done | `punktfunk_core::quic` |
|
||||||
| Feedback abstraction (`HidOutput::{Led,PlayerLeds,Trigger,…}`) | done | `punktfunk_core::quic` |
|
| Feedback abstraction (`HidOutput::{Led,PlayerLeds,Trigger,…}`) | ✅ done | `punktfunk_core::quic` |
|
||||||
| Pad-type negotiation (client pref > env > default), `GamepadPref::DualSense` | done | `punktfunk1.rs::resolve_gamepad` |
|
| Pad-type negotiation (client pref > env > default), `GamepadPref::DualSense` | ✅ done | `punktfunk1.rs` `resolve_gamepad` (~1577) |
|
||||||
| Backend dispatch (`enum PadBackend`) | done; `DualSense` arm is `#[cfg(target_os="linux")]` | `punktfunk1.rs:1229` |
|
| Backend dispatch (`enum PadBackend`) | ✅ done; `DualSense`/`DualShock4` arms are `#[cfg(target_os="linux")]` | `punktfunk1.rs` (PadBackend ~1181–1272) |
|
||||||
| Clients (capture + adaptive-trigger/lightbar/haptic rendering) | done, all platforms | `clients/*` |
|
| Clients (capture + adaptive-trigger/lightbar/haptic/touchpad/motion rendering) | ✅ done, all platforms | `clients/*` |
|
||||||
| C-ABI (`next_hidout` / `send_rich_input`) | done | `abi.rs` |
|
| C-ABI (`next_hidout` / `send_rich_input`) | ✅ done | `abi.rs` |
|
||||||
| **Host virtual-DualSense backend** | **Linux only (UHID)** | `inject/dualsense.rs` |
|
| **Host virtual-DualSense backend** | **Linux only (UHID)** | `inject/dualsense.rs` |
|
||||||
|
|
||||||
So a Windows DualSense backend needs **no protocol, client, or C-ABI change**. It must only: create a
|
So a Windows DualSense backend needs **no protocol, client, or C-ABI change**. The whole DualSense
|
||||||
virtual DualSense HID device, translate our pad state → HID input report `0x01`, and surface the game's
|
**HID contract already exists as pure, transport-independent Rust + const data**, kernel-verified
|
||||||
HID output report `0x02` as the same `HidOutput` events the Linux path already emits. That is a
|
byte-for-byte against `hid-playstation.c` / inputtino / SDL, in `inject/dualsense.rs`:
|
||||||
well-bounded host-side addition (driver + a `DualSenseManager`-shaped userspace bridge + a
|
|
||||||
`PadBackend::DualSense` Windows arm).
|
|
||||||
|
|
||||||
## The Windows mechanism — VHF (primary candidate)
|
- `DUALSENSE_RDESC` — the 232-byte USB report descriptor.
|
||||||
|
- `serialize_state` — the input report `0x01` packer (controller state → bytes).
|
||||||
|
- `parse_ds_output` — the output report `0x02` parser (game's rumble/LED/trigger block → `HidOutput`),
|
||||||
|
valid-flag gated.
|
||||||
|
- Feature blobs `0x05` calibration, `0x09` pairing, `0x20` firmware. **USB framing (no CRC).**
|
||||||
|
|
||||||
|
**No hardware capture is needed** — the bytes are already correct and proven. The *only* Linux
|
||||||
|
coupling is the `/dev/uhid` event framing (`UHID_CREATE2`/`INPUT2`/`OUTPUT`/`GET_REPORT`) in
|
||||||
|
`DualSensePad::open`/`write_state`/`service`. A Windows backend swaps that framing for the
|
||||||
|
`SwDeviceCreate` + IOCTL channel to the UMDF driver; **the report bytes are identical.**
|
||||||
|
|
||||||
|
> **One in-repo bug to fix in passing:** `DS_FEATURE_CALIBRATION` (`0x05`) is currently **42 bytes**;
|
||||||
|
> the spec is **41**. Trim it for strict Windows consumers as part of M1 (`42 → 41`).
|
||||||
|
|
||||||
|
`dualshock4.rs` (committed `3e6c9f6`) is a worked **second** example of the multi-pad-type
|
||||||
|
`PadBackend` pattern, reusing the DualSense state — a template for how the Windows arm slots in.
|
||||||
|
|
||||||
|
The host integration seam is small and already mapped: ~1 enum arm + 5 match arms in the
|
||||||
|
`PadBackend` block (`punktfunk1.rs` ~1181–1272), flipping `pick_gamepad`/`resolve_gamepad`
|
||||||
|
(~1558–1606) from `#[cfg(target_os = "linux")]` to `#[cfg(any(target_os = "linux", target_os =
|
||||||
|
"windows"))]`, plus the `inject.rs` module gating (~424–451). `gamepad_windows.rs` is today
|
||||||
|
ViGEm-Xbox360-only (138 LOC); the new `inject/dualsense_windows.rs` sits beside it, and ViGEm stays
|
||||||
|
for Xbox 360 / Xbox One.
|
||||||
|
|
||||||
|
## The Windows mechanism — UMDF2 HID minidriver (not VHF)
|
||||||
|
|
||||||
Windows has **no userspace HID-device creation** (unlike Linux UHID), so a real virtual DualSense
|
Windows has **no userspace HID-device creation** (unlike Linux UHID), so a real virtual DualSense
|
||||||
requires a kernel component. The Microsoft-sanctioned one is the **Virtual HID Framework (VHF)**: a
|
needs a driver component. The decisive correction over the prior draft:
|
||||||
small KMDF driver creates a virtual HID device from an arbitrary report descriptor, submits **input**
|
|
||||||
reports to the OS, and receives **output/feature** reports written by applications (our feedback hook).
|
|
||||||
This is the structural twin of `/dev/uhid`.
|
|
||||||
|
|
||||||
Sketch of the integration (TO-CONFIRM details in *Open questions*):
|
- **VHF (Virtual HID Framework) supports a HID *source* driver only in kernel mode.** It is not the
|
||||||
|
mechanism for a user-mode virtual pad. (Microsoft, "Virtual HID Framework (VHF)".)
|
||||||
|
- The user-mode mechanism is a **UMDF2 HID minidriver**: a small lower-filter driver under the
|
||||||
|
OS-supplied pass-through driver **`mshidumdf.sys`** (which calls `HidRegisterMinidriver` on the
|
||||||
|
minidriver's behalf). This is the **same UMDF tier as SudoVDA** — *below* kernel work, not above it.
|
||||||
|
|
||||||
|
A second prior-research correction that matters for the language choice: **UMDF 2.0 is NOT
|
||||||
|
COM-based.** COM / `IDriverEntry` / `IWDFDriver` belong to legacy **UMDF 1.x**. UMDF 2.0 uses the
|
||||||
|
same **C-style WDF object model as KMDF** — a `DriverEntry` symbol plus C function pointers
|
||||||
|
(`EvtDriverDeviceAdd`, `EvtIoDeviceControl`) stored in config structs. There is no vtable to
|
||||||
|
implement. (Microsoft, "Porting a Driver from UMDF 1 to UMDF 2", "Getting Started with UMDF v2".)
|
||||||
|
This is precisely why a Rust FFI implementation is even conceivable (see *Driver language*).
|
||||||
|
|
||||||
|
### What the driver actually does (small, well-bounded)
|
||||||
|
|
||||||
|
A UMDF2 HID minidriver holds **no device logic** — it shuttles bytes. Its entire job is one
|
||||||
|
`EvtIoDeviceControl` callback branching on ~10 HID IOCTLs (Microsoft, "Creating WDF HID
|
||||||
|
Minidrivers"; reference source `vhidmini2`):
|
||||||
|
|
||||||
|
- In `EvtDriverDeviceAdd`: call `WdfFdoInitSetFilter`, then create the I/O queue(s).
|
||||||
|
- **Descriptor IOCTLs** (`GET_DEVICE_DESCRIPTOR` / `GET_REPORT_DESCRIPTOR` / `GET_DEVICE_ATTRIBUTES`)
|
||||||
|
— trivial: `RequestCopyFromBuffer` a static blob. For punktfunk these blobs are the **existing
|
||||||
|
`DUALSENSE_RDESC` (232 B)** + a `HID_DEVICE_ATTRIBUTES` filled `054C`/`0CE6`.
|
||||||
|
- **Output / feature IOCTLs** (`WRITE_REPORT` / `SET_OUTPUT_REPORT` / `GET_FEATURE` / `SET_FEATURE`)
|
||||||
|
— pull the `HID_XFER_PACKET` (report id + buffer) and hand the bytes to the host. These carry the
|
||||||
|
game's `0x02` output report (rumble / lightbar / **adaptive-trigger** block) — exactly what
|
||||||
|
`parse_ds_output` already decodes.
|
||||||
|
- **Input path** (`READ_REPORT`, pad → game) — the only non-trivial mechanic, an **inverted call**:
|
||||||
|
each `READ_REPORT` request is pended into a manual `WDFQUEUE`
|
||||||
|
(`WdfIoQueueDispatchManual` + `WdfRequestForwardToIoQueue`) and later popped
|
||||||
|
(`WdfIoQueueRetrieveNextRequest`), filled, and completed (`WdfRequestComplete`) whenever the host
|
||||||
|
has a fresh `0x01` input report. `vhidmini2` drives this from a periodic timer; punktfunk drives
|
||||||
|
it from each new `0x01` report arriving over the host channel — **structurally identical to the
|
||||||
|
existing Linux `/dev/uhid` loop.**
|
||||||
|
|
||||||
|
Because UMDF can't marshal embedded pointers, `mshidumdf.sys` converts `IOCTL_HID_*` into
|
||||||
|
`IOCTL_UMDF_HID_*` (e.g. `IOCTL_UMDF_HID_GET_INPUT_REPORT`, `IOCTL_UMDF_HID_SET_FEATURE`), passing
|
||||||
|
`reportBuffer` / `reportId` as separate buffers — the driver branches on those.
|
||||||
|
|
||||||
|
### Integration sketch
|
||||||
|
|
||||||
```
|
```
|
||||||
host process (Rust) <--IOCTL/named-pipe--> punktfunk-ds5.sys (KMDF + VHF) <--HID--> game / Steam / GameInput
|
host process (Rust) <-- SwDeviceCreate + IOCTL channel --> UMDF2 HID minidriver <-- HID --> game / Steam / GameInput
|
||||||
PadState ----------- input report 0x01 -----------> VhfReadReportSubmit
|
PadState -------------- input report 0x01 -------------> inverted READ_REPORT queue
|
||||||
HidOutput <-- output report 0x02 (write callback) --- EvtVhf*WriteReport
|
HidOutput <----- output report 0x02 (WriteReport cb) ----- EvtIoDeviceControl
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship for
|
- **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship
|
||||||
Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games recognize it
|
for Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games
|
||||||
as a DualSense.
|
recognize it as a DualSense.
|
||||||
- **Userspace bridge:** a `DualSenseManager`-shaped struct mirroring the Linux one (same `RichInput` →
|
- **Host-side device creation:** `windows::Win32::Devices::Enumeration::Pnp::SwDeviceCreate` →
|
||||||
report `0x01` packing, same `HidOutput` parsing from report `0x02`), talking to the driver over an
|
`Result<HSWDEVICE>` (pure Win32, in the `windows` crate, **no WDK needed**), enumerating a
|
||||||
IOCTL/pipe instead of `/dev/uhid`.
|
root device whose hardware IDs match the pre-staged INF. Requires Administrator. **The device
|
||||||
- **Packaging:** vendor + sign the `.sys`/`.inf`/`.cat` and install via the existing
|
exists only while the `HSWDEVICE` handle (i.e. the host process) is open** — `SwDeviceClose`
|
||||||
`packaging/windows/sudovda` machinery (`nefconc.exe` + an `install-*.ps1`, bundled in the Inno
|
removes it — so the pad is created/destroyed with the session, exactly like the Linux UHID fd.
|
||||||
`setup.exe`). The precedent is already in the repo.
|
The INF is pre-staged once (`pnputil /add-driver`).
|
||||||
|
- **Userspace bridge:** a `DualSenseManager`-shaped struct mirroring the Linux one (same `RichInput`
|
||||||
|
→ report `0x01` packing via `serialize_state`, same `HidOutput` parsing via `parse_ds_output`),
|
||||||
|
talking to the driver over an IOCTL channel instead of `/dev/uhid`.
|
||||||
|
- **Packaging:** vendor + sign the `.dll`/`.inf`/`.cat` and install via the existing
|
||||||
|
`packaging/windows` machinery (`pnputil` + an `install-*.ps1`, bundled in the Inno `setup.exe`).
|
||||||
|
The precedent — SudoVDA, a UMDF/IddCx driver — is already in the repo.
|
||||||
|
|
||||||
## Effort & risk
|
## Driver language — recommendation
|
||||||
|
|
||||||
| Piece | Rough size | Notes / risk |
|
The user strongly prefers a **self-authored Rust driver**. Verified verdict: **a Rust UMDF2 HID
|
||||||
|---|---|---|
|
minidriver is technically viable but unproven and pioneering** — it does not clear the bar for a
|
||||||
| KMDF + VHF virtual-HID driver | large | KMDF (kernel) is a higher bar than SudoVDA's UMDF/IddCx; bulk of the work |
|
*low-risk* M2. Honest ranking of the three options:
|
||||||
| Driver signing + distribution | medium | EV cert + Microsoft attestation for production; test-signing for dev; SudoVDA precedent but it's pre-signed/vendored, not built here |
|
|
||||||
| Userspace `DualSenseManager` (Windows) | small–medium | Mostly a port of the Linux report packing/parsing; reuses descriptors |
|
|
||||||
| `PadBackend::DualSense` Windows arm + negotiation | small | Un-gate the existing dispatch for Windows |
|
|
||||||
| HidHide-style hiding of a physical pad | small (maybe unneeded) | Headless host usually has no physical pad; only matters if one is attached |
|
|
||||||
|
|
||||||
**Top risks:** (1) a KMDF/VHF driver is real kernel work + signing logistics; (2) whether VHF's
|
### Option R — fully self-authored Rust driver (preferred; viable, but pioneering)
|
||||||
output-report callback cleanly surfaces the DualSense `0x02` effect report we need for adaptive
|
|
||||||
triggers; (3) whether games/Steam/`Windows.Gaming.Input`/GameInput accept a VHF-sourced DualSense the
|
- **What's real today:** `microsoft/windows-drivers-rs` (`wdk`, `wdk-sys`, `wdk-build`,
|
||||||
same as a physical one (descriptor + VID/PID should suffice, but unverified on Windows).
|
`wdk-macros`) officially targets WDM + KMDF + **UMDF** (tested UMDF 2.33). It ships a *real* Rust
|
||||||
|
UMDF sample, `examples/sample-umdf-driver/src/lib.rs`, that `#[unsafe(export_name = "DriverEntry")]`,
|
||||||
|
builds a `WDF_DRIVER_CONFIG` with `EvtDriverDeviceAdd: Some(...)`, and calls `WdfDriverCreate` +
|
||||||
|
`WdfDeviceCreate` via `call_unsafe_wdf_function_binding!` over raw `wdk-sys` FFI. Because UMDF 2.0
|
||||||
|
is the C function-pointer model (no COM vtable), the FFI maps cleanly.
|
||||||
|
- **The gap:** that sample is a **bare stub** — no I/O queue, no IOCTL dispatch, no HID. The entire
|
||||||
|
HID-minidriver layer (`WdfFdoInitSetFilter`, the manual inverted-call queue, `IOCTL_UMDF_HID_*`
|
||||||
|
dispatch, `HID_XFER_PACKET`, `METHOD_NEITHER`) would be **hand-written `unsafe` FFI with no safe
|
||||||
|
wrappers**, against `vhidmini2`/GazeHid-scale glue (a few hundred lines). The heavy domain logic is
|
||||||
|
*not* in the driver — it already exists in `dualsense.rs`.
|
||||||
|
- **The honest blockers:** **zero precedent** — every shipping virtual-HID controller driver
|
||||||
|
(`vhidmini2`, HIDMaestro, DsHidMini, EmuController, GazeHid) is **C**. Microsoft labels
|
||||||
|
`windows-drivers-rs` "not yet recommended for production use" (Sept 2025) and has **not settled the
|
||||||
|
WHCP/attestation submission path for Rust drivers** — directly relevant given the public-distribution
|
||||||
|
requirement (though attestation re-signs the `.cat` and treats the `.dll` opaquely, so signing
|
||||||
|
*should* be language-agnostic — unverified). Whether all needed WDF symbols (`WdfIoQueueCreate`,
|
||||||
|
`WdfFdoInitSetFilter`, `WdfRequestRetrieveOutputMemory`, manual-queue APIs,
|
||||||
|
`WDF_IO_QUEUE_CONFIG_INIT`) are generated/usable for the UMDF target is **unverified against the
|
||||||
|
bindings — this is exactly what the M0 build spike must answer.** Note the Dec 2025
|
||||||
|
`windows-drivers-rs` build break (Discussion #591) is a transient LLVM-22-tip bindgen issue, fixed
|
||||||
|
by pinning LLVM 21.1.2 — not a fundamental defect.
|
||||||
|
|
||||||
|
Do **not** C-FFI-bind DMF's `Dmf_VirtualHidMini` from Rust (large, awkward C surface) — reimplement
|
||||||
|
the modest `vhidmini2` queue/IOCTL glue directly.
|
||||||
|
|
||||||
|
### Option C — thin C/C++ UMDF2 shim + all logic in the Rust host (realistic fallback / lowest-risk M2)
|
||||||
|
|
||||||
|
Clone `vhidmini2` (`WdfFdoInitSetFilter` + `EvtIoDeviceControl` + manual inverted-call queue, a few
|
||||||
|
hundred LOC of generic byte-shuttling); keep **all** DualSense logic in the existing Rust host
|
||||||
|
(`dualsense.rs` descriptors/packers/parsers fed over the IOCTL channel); the `SwDeviceCreate` host
|
||||||
|
bridge stays pure Rust in the `windows` crate (no WDK). This **mirrors HIDMaestro's split** (generic
|
||||||
|
C/C++ UMDF2 HID minidriver under `mshidumdf.sys`, all profile/DualSense logic in the user-mode
|
||||||
|
service) **and punktfunk's own Linux design.** It is the user's pre-ranked middle option and the
|
||||||
|
fastest way to reach the M0 on-glass gate.
|
||||||
|
|
||||||
|
### Option H — fork/reuse HIDMaestro (last resort)
|
||||||
|
|
||||||
|
HIDMaestro is a proven, pure-UMDF2 virtual controller (self-signed, no EV/test-signing/reboot)
|
||||||
|
recognized by DirectInput/XInput/SDL3/WGI/GameInput/RawInput + Steam, with a **DualSense profile**
|
||||||
|
(byte-exact VID/PID + descriptor). Use only if even the C shim stalls **and** adaptive-trigger
|
||||||
|
fidelity is not required — **HIDMaestro omits adaptive triggers from its DS5 feature list**, so it
|
||||||
|
cannot prove the very thing that makes a virtual DualSense worth building. Its driver is C; its
|
||||||
|
service is C#.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Lead with Option R for the long-term codebase, but de-risk the on-glass gate with Option C in M2.**
|
||||||
|
Concretely: run the **M0 spike in two halves** — (a) a `windows-drivers-rs` UMDF *build* spike to
|
||||||
|
confirm the WDF queue/IOCTL symbols are usable from Rust at all, and (b) the on-glass recognition gate
|
||||||
|
using whichever driver is fastest to stand up (the C `vhidmini2` shim is the safe bet). If (a) passes
|
||||||
|
**and** the on-glass gate passes, author the M2 driver in **Rust** (it would be the first Rust UMDF
|
||||||
|
HID driver, accepted as pioneering risk per the user's explicit preference). If (a) is shaky, ship M2
|
||||||
|
as the **C shim** and migrate the driver to Rust later, once `windows-drivers-rs` ships safe WDF/HID
|
||||||
|
abstractions. Either way the DualSense *logic* stays in Rust where it already lives. Forking HIDMaestro
|
||||||
|
is the fallback-of-fallbacks and is acceptable only if adaptive triggers are dropped from scope.
|
||||||
|
|
||||||
|
## Signing
|
||||||
|
|
||||||
|
Two recipes coexist in the Inno installer, selected by the bundled payload — the same pattern already
|
||||||
|
proven for SudoVDA.
|
||||||
|
|
||||||
|
### Fleet / self-signed (dev + internal boxes)
|
||||||
|
|
||||||
|
The in-repo precedent is `packaging/windows/install-sudovda.ps1`: import the bundled `.cer` into the
|
||||||
|
machine **Root** *and* **TrustedPublisher** stores (`certutil -addstore -f`), then `pnputil
|
||||||
|
/add-driver /install`. This installs silently **only** because the publisher is pre-trusted on that
|
||||||
|
machine. Microsoft is explicit that this auto-import-into-Root practice "should never be followed for
|
||||||
|
any driver package distributed outside your organization" — so it is the **fleet** path, never the
|
||||||
|
public one.
|
||||||
|
|
||||||
|
### Public end-user distribution — EV cert + Microsoft attestation
|
||||||
|
|
||||||
|
For arms-length public users, the correct tier is **Microsoft attestation signing** via Partner
|
||||||
|
Center (verified: "Attestation signing supports Windows Desktop kernel mode **and user mode**
|
||||||
|
drivers"; processable types include `.cab`/`.dll`). Pipeline:
|
||||||
|
|
||||||
|
1. **Prerequisites:** a registered **Windows Hardware Developer Program** (Partner Center) account
|
||||||
|
(free to register; sign in with an Entra ID global-admin work account; accept the agreements,
|
||||||
|
provide org/D-U-N-S info, respond to the legal-contact verification email) and an **EV
|
||||||
|
code-signing certificate** (mandatory to register *and* to sign the submission CAB; ~USD 250–560/yr;
|
||||||
|
FIPS hardware token/HSM mandatory; 1–7 business-day identity vetting). Windows ADK (`MakeCab`).
|
||||||
|
2. **Build the submission:** `MakeCab` the `.dll` + `.inf` (+ `.pdb`/symbols) into per-driver
|
||||||
|
subfolders (folder names < 40 chars, no special chars, no UNC); `SignTool sign` the CAB with the
|
||||||
|
EV cert (`/fd sha256` + RFC3161 timestamp `/tr … /td sha256`).
|
||||||
|
3. **Submit:** Partner Center → *Submit new hardware*, **leave test-signing unchecked**, request the
|
||||||
|
desired signatures.
|
||||||
|
4. **Microsoft re-signs:** it appends a Microsoft SHA-2 signature and **regenerates + signs a new
|
||||||
|
`.cat` with a Microsoft cert** (your `.cat` is replaced). Because the catalog signer is then
|
||||||
|
Microsoft (already trusted), **PnP installs silently — no publisher prompt, no test-signing, no
|
||||||
|
reboot, and no shipping our cert into users' Root store.** Validation: `devcon`/`pnputil` install
|
||||||
|
must not show "Windows can't verify the publisher of this driver software."
|
||||||
|
|
||||||
|
**Important nuance — is attestation even *required* for UMDF?** UMDF is user-mode, so it is **exempt
|
||||||
|
from kernel-mode code-integrity *load* enforcement** — the driver `.dll` will *load* without a
|
||||||
|
Microsoft signature. But **PnP *installation* still requires a signed catalog whose publisher is
|
||||||
|
trusted.** A driver signed only with a plain publicly-trusted (OV/EV) Authenticode cert that is *not*
|
||||||
|
already in TrustedPublisher will **install, but with the blocking "Windows Security / would you like
|
||||||
|
to install this device software?" prompt** (setupapi warning `0x800b0109`, error `0xe0000242`
|
||||||
|
"publisher … not yet established as trusted"). So a bare Authenticode signature is **not** sufficient
|
||||||
|
for a prompt-free public install — **attestation is the minimal correct public path.** The April 2026
|
||||||
|
kernel-trust change (removing trust for legacy cross-signed *kernel* drivers) **does not affect**
|
||||||
|
attestation/WHQL or user-mode UMDF drivers.
|
||||||
|
|
||||||
|
What attestation does **not** do: attestation-signed drivers are **not** distributed via Windows
|
||||||
|
Update — irrelevant here, since punktfunk bundles the driver in its Inno installer exactly like
|
||||||
|
SudoVDA. (Azure Trusted Signing is **not** an option for the driver `.cat` at all — it signs only
|
||||||
|
user-mode PE / `/INTEGRITYCHECK` / SmartScreen, and cannot substitute for the EV cert in Partner
|
||||||
|
Center; it could only improve SmartScreen reputation on the installer `.exe`.) Note attestation does
|
||||||
|
**not** require HLK/WHQL testing. The heavier fallback, only if attestation's "testing scenarios"
|
||||||
|
positioning ever hardens into a block, is full **WHQL/HLK** submission (also yields a Microsoft-signed
|
||||||
|
catalog, plus Windows Update eligibility).
|
||||||
|
|
||||||
|
### Coexistence in the Inno installer
|
||||||
|
|
||||||
|
`packaging/windows/punktfunk-host.iss` already gates the SudoVDA driver payload behind
|
||||||
|
`#ifdef WithDriver` + the `installdriver` task + a `[Run]` call to `install-sudovda.ps1`. Add an
|
||||||
|
analogous gated payload + `install-dualsense.ps1` for the virtual DualSense driver, switching the
|
||||||
|
bundled `.cat` per build:
|
||||||
|
|
||||||
|
- **fleet build** → self-signed `.cat` + `install-dualsense.ps1` keeps the
|
||||||
|
`certutil -addstore Root/TrustedPublisher` step (cloned from `install-sudovda.ps1`).
|
||||||
|
- **public build** → Microsoft-attestation-re-signed `.cat`, and `install-dualsense.ps1`
|
||||||
|
**drops** the `certutil` import (just `pnputil /add-driver /install`).
|
||||||
|
|
||||||
|
Operationally, the EV key lives on a non-exportable FIPS token, so the **CAB signing + Partner Center
|
||||||
|
submission is a manual offline step**, not a CI secret (cloud-HSM/Azure Key Vault EV options exist but
|
||||||
|
need per-CA confirmation). The Microsoft-resigned `.cat` is then committed as the vendored public
|
||||||
|
payload, the way SudoVDA's signed driver is vendored in `packaging/windows/sudovda/`.
|
||||||
|
|
||||||
|
## Feasibility gate (BLOCKING — M0, on-glass only)
|
||||||
|
|
||||||
|
No prior art settles the two questions that decide whether this whole effort is worth building. **This
|
||||||
|
gate blocks M1–M6** and can only be answered on the **RTX box (`192.168.1.173`)** — the dev VM is
|
||||||
|
headless/WARP and cannot validate game-facing HID recognition:
|
||||||
|
|
||||||
|
1. **Recognition:** is a virtual `054C:0CE6` UMDF2 device accepted as a *genuine DualSense* by
|
||||||
|
`Windows.Gaming.Input` / GameInput / Steam (and native-DS5 games)? HIDMaestro proves DualSense
|
||||||
|
*recognition* is possible, but…
|
||||||
|
2. **Adaptive-trigger fidelity:** does the game's output report `0x02` (the adaptive-trigger block)
|
||||||
|
actually reach the driver's `WriteReport`/`SetOutputReport` callback? **HIDMaestro omits adaptive
|
||||||
|
triggers**, so no prior art proves this — it must be **measured on glass**.
|
||||||
|
|
||||||
|
If (2) fails, the realistic product is a DualSense *identity* without adaptive triggers — at which
|
||||||
|
point the value over ViGEm DS4 collapses and the project should likely **defer** rather than ship.
|
||||||
|
|
||||||
|
**M0 RESULT (2026-06-21): GATE PASSED.** Both answered YES on the RTX box with a self-authored **Rust**
|
||||||
|
UMDF minidriver (`packaging/windows/dualsense-driver/`). (1) **Recognition:** Steam recognized the virtual
|
||||||
|
`054C:0CE6` device as a genuine DualSense and drove its DualSense-specific LEDs. (2) **`0x02` reaches the
|
||||||
|
write callback:** captured two Steam-Input output reports (`validFlag1=0x14` = LIGHTBAR|PLAYER_INDICATOR).
|
||||||
|
Adaptive-trigger-specific bytes ride the same `0x02` path (Cyberpunk confirmation is gravy, not a gate).
|
||||||
|
Three bugs had to be fixed to get there — the load wall was the PE **FORCE_INTEGRITY** bit (`wdk-build`'s
|
||||||
|
`/INTEGRITYCHECK`; clear bit `0x80` at PE+0x5e + re-sign), then `WdfTimerCreate` exec-level, then a parallel
|
||||||
|
queue's zeroed `NumberOfPresentedRequests`. **Option R (Rust) confirmed for M2; no C shim needed.**
|
||||||
|
|
||||||
|
## Milestone plan (M0–M6)
|
||||||
|
|
||||||
|
| # | Milestone | Output | Gate / risk |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **M0 ✅ DONE** | **Feasibility spike — PASSED (2026-06-21)** | (a) Rust `windows-drivers-rs` UMDF build spike — symbols usable, driver authored in Rust; (b) on-glass on the RTX box: self-signed Rust `054C:0CE6` UMDF minidriver loads under Secure Boot, Steam recognizes it as a DualSense, `0x02` output reaches the write callback. Source: `packaging/windows/dualsense-driver/` | **PASSED.** Option R (Rust) chosen for M2. Load needed clearing the PE FORCE_INTEGRITY bit |
|
||||||
|
| **M1** | Linux codec refactor | Extract the transport-independent contract from `dualsense.rs` into `inject/dualsense_proto.rs` (`DUALSENSE_RDESC`, `serialize_state`, `parse_ds_output`, feature blobs); **fix `DS_FEATURE_CALIBRATION` 42 → 41**; Linux backend keeps passing | Pure refactor; keep Linux loopback green |
|
||||||
|
| **M2** | UMDF2 driver | The HID minidriver + INF + signed `.cat` (test-signed for dev). **Language per M0(a):** Rust if the build spike is solid, else the `vhidmini2`-derived C shim. INF carries the required UMDF directives (`UmdfKernelModeClientPolicy=AllowKernelModeClients`, `UmdfMethodNeitherAction=Copy`, `UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects`, `UmdfFsContextUsePolicy=CanUseFsContext2`), root-enumerated `HIDClass`, filter under `mshidumdf.sys` | Pioneering if Rust; manual inverted-call queue is the hard part |
|
||||||
|
| **M3** | Rust host bridge | `inject/dualsense_windows.rs`: `SwDeviceCreate` per-session device (hold `HSWDEVICE` for the session) + the inverted-call IOCTL channel, feeding `0x01` and surfacing `0x02` as `HidOutput` — reusing `dualsense_proto.rs` | Channel design (single control device + inverted-call IOCTL vs shared-memory) |
|
||||||
|
| **M4** | Un-gate the seam + negotiation | New `PadBackend::DualSense` Windows arm; relax the `#[cfg(target_os="linux")]` guards on DualSense/DualShock4 in `pick_gamepad`/`resolve_gamepad` to `any(linux, windows)`; wire `GamepadPref::DualSense` resolution | Small; `dualshock4.rs` is the template |
|
||||||
|
| **M5** | On-glass E2E | Client → host → virtual DualSense → game, with adaptive triggers / lightbar / touchpad / motion / rumble round-tripping; latency check | RTX box; the real proof |
|
||||||
|
| **M6** | Packaging / installer | Vendor + sign the driver; `install-dualsense.ps1` (fleet vs public variant); gate the payload in `punktfunk-host.iss`; complete the **EV cert + attestation** submission for the public build | EV-cert procurement + Partner Center turnaround are lead-time items — start early |
|
||||||
|
|
||||||
## Decision matrix
|
## Decision matrix
|
||||||
|
|
||||||
| Option | Adaptive triggers / DS5 identity | Effort | When it's right |
|
| Option | Adaptive triggers / DS5 identity | Effort | When it's right |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **A. VHF virtual DualSense** (parity) | ✅ full | large (kernel driver) | the goal — matches the Linux host |
|
| **A. UMDF2 virtual DualSense** (parity) | ✅ full (pending the M0 gate) | medium — **UMDF, same tier as SudoVDA** (was mis-scoped as "kernel/large" in the 2026-06-20 draft) | the goal — matches the Linux host |
|
||||||
| **B. ViGEm DS4** (interim) | ❌ never (DS4 ceiling) | small | quick PS-pad-on-Windows w/ touchpad/motion/lightbar/rumble, no adaptive triggers |
|
| **B. ViGEm DS4** (interim) | ❌ never (DS4 ceiling) | small | quick PS-pad on Windows w/ touchpad/motion/lightbar/rumble, no adaptive triggers |
|
||||||
| **C. Hybrid** | A for DS5 clients, B/Xbox360 fallback | A + small | belt-and-suspenders once A exists |
|
| **C. Hybrid** | A for DS5 clients, B/Xbox 360 fallback | A + small | belt-and-suspenders once A exists |
|
||||||
| **D. Defer** | — | — | if a higher-ROI item (#9 launch, #7/#18 audio) wins the slot |
|
| **D. Defer** | — | — | if the M0 gate fails (esp. output `0x02` fidelity), or a higher-ROI item wins the slot |
|
||||||
|
|
||||||
Xbox 360 (XInput) is already implemented and covers most Windows games regardless.
|
Xbox 360 (XInput, via ViGEm) is already implemented and covers most Windows games regardless; Xbox
|
||||||
|
One/Series fold into it on Windows. Windows-host DualShock 4 (ViGEm) remains separately deferred.
|
||||||
|
|
||||||
## Open questions — REQUIRES the web-research pass (search was down)
|
## Risk register
|
||||||
|
|
||||||
1. **VHF specifics:** confirm VHF is the right/current mechanism (vs. a newer HID-injection API);
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
exact API (`VhfCreate`/`VhfStart`/`VhfReadReportSubmit`/the output-report `EvtVhf…WriteReport`
|
|---|---|---|---|
|
||||||
callback); KMDF-only or UMDF-capable; minimum Windows version; the MS `vhidmini`/VHF sample.
|
| Output report `0x02` (adaptive triggers) never reaches the driver write callback | medium | **fatal** to the value prop | M0(b) measures it directly; if it fails → Option D |
|
||||||
2. **Existing driver to vendor:** is there a maintained virtual-HID / virtual-DualSense Windows driver
|
| `054C:0CE6` UMDF2 device not accepted as a real DualSense by WGI/GameInput/Steam | low–med | fatal | M0(b); HIDMaestro suggests recognition works, but confirm |
|
||||||
(Nefarius/community) we can vendor like SudoVDA, instead of writing a KMDF driver from scratch?
|
| Rust UMDF driver pioneering risk (first of its kind; no safe WDF/HID wrappers; symbol coverage unproven) | medium | schedule | M0(a) build spike; **Option C (C shim) as the de-risked M2 fallback** |
|
||||||
3. **Recognition:** does a VHF device with VID `054C`/PID `0CE6` + the DualSense descriptor get
|
| EV cert + Partner Center attestation lead time / friction | medium | schedule | Start procurement at M0; lean on the SudoVDA UMDF submission precedent |
|
||||||
recognized as a DualSense by Windows.Gaming.Input / GameInput / Steam Input / native-DS5 games —
|
| EV key non-exportable → can't sign in CI | high | low | Accept a manual offline sign+submit step; vendor the Microsoft-resigned `.cat` |
|
||||||
including adaptive triggers via the `0x02` output report?
|
| `SwDeviceCreate` device lifetime tied to the host process handle | known | low | Hold `HSWDEVICE` for the session lifetime (matches Linux UHID fd semantics) |
|
||||||
4. **Signing/distribution:** attestation vs. WHQL for a KMDF driver; can we test-sign for dev and ship
|
| `windows-drivers-rs` transient toolchain breaks (e.g. LLVM-22 bindgen, Disc. #591) | low | low | Pin LLVM 21.1.2; not a fundamental defect |
|
||||||
an attestation-signed driver via the Inno installer like SudoVDA?
|
| `DS_FEATURE_CALIBRATION` 42-byte blob rejected by strict Windows consumers | low | low | Trim to 41 bytes in M1 |
|
||||||
5. **HidHide:** needed at all on a (usually headless) host, or only when a physical pad is present?
|
|
||||||
|
|
||||||
## Recommended plan
|
## Open questions
|
||||||
|
|
||||||
1. **Web-research pass** (when search is back) to close the five questions above — especially #2
|
1. **Driver channel design** (unknown): punktfunk's own driver↔host protocol — simplest is a private
|
||||||
(vendor vs. build) and #1 (VHF feasibility + output-report support), which gate the whole effort.
|
control device with an inverted-call IOCTL for input + IOCTLs for output/feature, vs HIDMaestro's
|
||||||
2. If VHF (or a vendorable driver) is confirmed feasible: build **Option A** — driver + Windows
|
shared-memory section. `vhidmini2` has *no* service channel (it self-generates via a timer), so this
|
||||||
`DualSenseManager` + un-gate `PadBackend::DualSense`, reusing the inputtino descriptor and the
|
must be designed fresh (or read out of HIDMaestro/DsHidMini source). **Resolve in M3.**
|
||||||
existing `HidOutput` plane (no protocol/client/ABI change), packaged via the SudoVDA path.
|
2. **Rust UMDF symbol coverage** (unknown — the M0(a) gate): are all needed WDF symbols
|
||||||
3. Keep **Xbox 360** as-is and treat **ViGEm DS4** only as an optional fallback (Option C), never as
|
(`WdfIoQueueCreate`, `WdfFdoInitSetFilter`, `WdfRequestRetrieveOutputMemory`, manual-queue APIs,
|
||||||
the DualSense answer.
|
`WDF_IO_QUEUE_CONFIG_INIT`) generated/usable from `wdk-sys` for the UMDF target?
|
||||||
|
3. **Attestation for a Rust-authored `.dll`** (likely fine, unverified): attestation re-signs the
|
||||||
|
`.cat` and treats the `.dll` opaquely (allowed type), so language *should* be irrelevant to
|
||||||
|
signing — but Microsoft has not explicitly settled the WHCP path for Rust drivers. Confirm via a
|
||||||
|
Partner Center dry-run.
|
||||||
|
4. **Single multi-driver CAB** (unknown, operationally useful): can one Partner Center submission carry
|
||||||
|
*both* the existing SudoVDA driver and the new DualSense driver? Multi-driver CABs are supported in
|
||||||
|
general; unverified for this account.
|
||||||
|
5. **EV cert + Partner Center mechanics** (unknown): exact cost/turnaround; whether a cloud-HSM EV
|
||||||
|
option lets CI sign, or whether it must be a manual offline step (most likely the latter).
|
||||||
|
6. **HidHide** (carried over): needed at all on a usually-headless host, or only when a physical pad is
|
||||||
|
attached?
|
||||||
|
7. **Min-OS / UMDFVERSION target** (unknown): which `UmdfLibraryVersion` / WDK to target for the widest
|
||||||
|
Win10/11 install base, consistent with punktfunk's existing host support matrix.
|
||||||
|
8. **DsHidMini end-user signing tier** (unknown): self-signed vs attestation in its WixSharp MSI —
|
||||||
|
useful as a second public-distribution data point.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
edition = "2024"
|
||||||
|
name = "pf-dualsense"
|
||||||
|
version = "0.1.0"
|
||||||
|
publish = false
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
description = "punktfunk virtual DualSense UMDF2 HID minidriver (M0 spike)"
|
||||||
|
|
||||||
|
[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 = ["hid"]
|
||||||
|
hid = ["wdk-sys/hid"]
|
||||||
|
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,83 @@
|
|||||||
|
# pf-dualsense — virtual DualSense UMDF2 HID minidriver (M0 spike)
|
||||||
|
|
||||||
|
A self-authored **Rust UMDF2 HID minidriver** that presents a virtual Sony **DualSense**
|
||||||
|
(VID `054C` / PID `0CE6`) to Windows, so games drive adaptive triggers / lightbar / rumble —
|
||||||
|
capabilities ViGEm structurally cannot deliver. This is the M0 feasibility spike for rich
|
||||||
|
controller support in the punktfunk Windows host.
|
||||||
|
|
||||||
|
## Status (2026-06-21)
|
||||||
|
|
||||||
|
**Load + recognition: DONE.** A self-signed build **loads under Secure Boot ON** and enumerates as a
|
||||||
|
genuine DualSense HID game controller (`Status: OK`, VID `054C`, 273-byte DualSense report descriptor,
|
||||||
|
PID `0CE6` via `GET_DEVICE_ATTRIBUTES`). Validated live on the RTX box (`192.168.1.173`, Win11 25H2).
|
||||||
|
|
||||||
|
**Remaining:** the real-game `0x02` adaptive-trigger gate (Cyberpunk 2077 on the interactive desktop →
|
||||||
|
confirm `[pf-ds] *** OUTPUT ...` in the driver log), then wire into the host (M1+).
|
||||||
|
|
||||||
|
## This is a reference snapshot
|
||||||
|
|
||||||
|
The crate's `Cargo.toml` uses path-deps into `microsoft/windows-drivers-rs`
|
||||||
|
(`../../crates/wdk{,-sys,-build}`), so it builds **inside a `windows-drivers-rs` checkout's
|
||||||
|
`examples/` dir**, not standalone in this repo. On the dev box it lives at
|
||||||
|
`C:\Users\Public\m0\windows-drivers-rs\examples\pf-dualsense`. These files are checked in for
|
||||||
|
version control / portability of the spike.
|
||||||
|
|
||||||
|
## Build / sign / install recipe (the one that actually loads)
|
||||||
|
|
||||||
|
Prereqs on the Windows box: **WDK 26100**, **LLVM 21.1.2** (pinned — newer bindgen breaks),
|
||||||
|
`cargo-make`, Rust MSVC. A self-signed CodeSigning cert in `CurrentUser\My` + `LocalMachine\Root` +
|
||||||
|
`TrustedPublisher`.
|
||||||
|
|
||||||
|
Every build needs:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:LIBCLANG_PATH = 'C:\Program Files\LLVM\bin'
|
||||||
|
$env:Version_Number = '10.0.26100.0' # else wdk-build picks 10.0.28000.0 (no km/crt) and bindgen fails
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in the example dir:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo make # -> target\debug\pf_dualsense_package\ (.inf/.cat/.dll)
|
||||||
|
|
||||||
|
# *** CRITICAL: clear the PE FORCE_INTEGRITY bit ***
|
||||||
|
# windows-drivers-rs links the DLL with /INTEGRITYCHECK, which forces a CI-trusted page-hash
|
||||||
|
# signature a self-signed cert cannot satisfy (CodeIntegrity 3004 "hash not found" /
|
||||||
|
# 3089 VerificationError 7). SudoVDA.dll has this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
|
||||||
|
$f = 'target\debug\pf_dualsense_package\pf_dualsense.dll'
|
||||||
|
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
|
||||||
|
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
|
||||||
|
$b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b)
|
||||||
|
|
||||||
|
signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f
|
||||||
|
Remove-Item target\debug\pf_dualsense_package\pf_dualsense.cat
|
||||||
|
Inf2Cat /driver:target\debug\pf_dualsense_package /os:10_x64
|
||||||
|
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_dualsense_package\pf_dualsense.cat
|
||||||
|
|
||||||
|
pnputil /add-driver target\debug\pf_dualsense_package\pf_dualsense.inf /install
|
||||||
|
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
|
||||||
|
```
|
||||||
|
|
||||||
|
`devgen` is at `...\Windows Kits\10\Tools\10.0.26100.0\x64\devgen.exe`. SWD devgen devices clear on
|
||||||
|
reboot (recreate after each boot). TODO: drop the post-build PE patch by stopping wdk-build emitting
|
||||||
|
`/INTEGRITYCHECK`.
|
||||||
|
|
||||||
|
## The three bugs that made it work (porting a WDK C sample to Rust)
|
||||||
|
|
||||||
|
`WDF_*_CONFIG_INIT` / `WDF_OBJECT_ATTRIBUTES_INIT` macros set **non-zero** defaults — `mem::zeroed()`
|
||||||
|
silently breaks them:
|
||||||
|
|
||||||
|
1. **FORCE_INTEGRITY** (above) — the load wall.
|
||||||
|
2. **Timer `ExecutionLevel`** — zeroed = Invalid → `WdfTimerCreate` 0xC0200209. Set
|
||||||
|
`ExecutionLevel/SynchronizationScope = InheritFromParent` + `AutomaticSerialization = TRUE`
|
||||||
|
(the working vhidmini2 shape).
|
||||||
|
3. **Queue `Settings.Parallel.NumberOfPresentedRequests`** — zeroed = 0 → a parallel queue presents
|
||||||
|
zero requests → `EvtIoDeviceControl` never fires → no HID handshake → ~5 s timeout →
|
||||||
|
`CM_PROB_FAILED_START`. Set to `u32::MAX`.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Uses **statics, not per-device WDF contexts** → only one device instance per WUDFHost works.
|
||||||
|
Multi-instance needs proper device contexts.
|
||||||
|
- Port of the WDK `vhidmini2` UMDF2 sample; DualSense identity + 273-byte descriptor + feature blobs
|
||||||
|
`0x05`/`0x09`/`0x20` from `crates/punktfunk-host/src/inject/dualsense.rs`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Copyright (c) Microsoft Corporation
|
||||||
|
// License: MIT OR Apache-2.0
|
||||||
|
|
||||||
|
//! Build script for the `sample-umdf-driver` crate.
|
||||||
|
//!
|
||||||
|
//! Based on the [`wdk_build::Config`] parsed from the build tree, this build
|
||||||
|
//! script will provide `Cargo` with the necessary information to build the
|
||||||
|
//! driver binary (ex. linker flags)
|
||||||
|
|
||||||
|
fn main() -> Result<(), wdk_build::ConfigError> {
|
||||||
|
wdk_build::configure_wdk_binary_build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
;/*++
|
||||||
|
; 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
|
||||||
|
|
||||||
|
[DestinationDirs]
|
||||||
|
DefaultDestDir = 13
|
||||||
|
|
||||||
|
[SourceDisksNames]
|
||||||
|
1=%Disk_Description%,,,
|
||||||
|
|
||||||
|
[SourceDisksFiles]
|
||||||
|
pf_dualsense.dll=1
|
||||||
|
|
||||||
|
[Manufacturer]
|
||||||
|
%ManufacturerString%=pf, NT$ARCH$.10.0...22000
|
||||||
|
|
||||||
|
[pf.NT$ARCH$.10.0...22000]
|
||||||
|
%DeviceDesc%=pfDualSense, root\pf_dualsense
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
|
[pf_dualsense_Install]
|
||||||
|
UmdfLibraryVersion=$UMDFVERSION$
|
||||||
|
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"
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
// punktfunk virtual DualSense — UMDF2 HID minidriver (M0 spike).
|
||||||
|
//
|
||||||
|
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
||||||
|
// (VID 054C / PID 0CE6) using the inputtino report descriptor + feature blobs punktfunk already
|
||||||
|
// ships in `inject/dualsense.rs`. Its purpose for M0(b) is to (1) enumerate as a genuine DualSense
|
||||||
|
// and (2) LOG every output report the game writes — the adaptive-trigger `0x02` gate.
|
||||||
|
//
|
||||||
|
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
||||||
|
// All WDF calls go through `call_unsafe_wdf_function_binding!`; HID/WDF structs are hand-built.
|
||||||
|
|
||||||
|
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use core::sync::atomic::{AtomicPtr, Ordering};
|
||||||
|
|
||||||
|
use wdk_sys::{
|
||||||
|
call_unsafe_wdf_function_binding, windows::OutputDebugStringA, GUID, NTSTATUS, PCUNICODE_STRING,
|
||||||
|
PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDFDEVICE, WDFDRIVER, WDFMEMORY, WDFQUEUE, WDFQUEUE__,
|
||||||
|
WDFREQUEST, WDFTIMER, WDF_DRIVER_CONFIG, WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES, WDF_TIMER_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- NTSTATUS values ----
|
||||||
|
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||||
|
const STATUS_UNSUCCESSFUL: NTSTATUS = 0xC000_0001u32 as NTSTATUS;
|
||||||
|
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||||
|
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
||||||
|
const STATUS_INVALID_BUFFER_SIZE: NTSTATUS = 0xC000_0206u32 as NTSTATUS;
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn nt_success(s: NTSTATUS) -> bool {
|
||||||
|
s >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HID minidriver IOCTLs: CTL_CODE(FILE_DEVICE_KEYBOARD=0x0b, id, METHOD_NEITHER=3, ANY) ----
|
||||||
|
const fn hid_ctl(id: u32) -> u32 {
|
||||||
|
(0x0000_000b << 16) | (id << 2) | 3
|
||||||
|
}
|
||||||
|
const IOCTL_HID_GET_DEVICE_DESCRIPTOR: u32 = hid_ctl(0);
|
||||||
|
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_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);
|
||||||
|
const IOCTL_UMDF_HID_GET_INPUT_REPORT: u32 = hid_ctl(23);
|
||||||
|
|
||||||
|
// ---- Host control channel: CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, fn, METHOD_BUFFERED=0, access) ----
|
||||||
|
const fn pfds_ctl(func: u32, access: u32) -> u32 {
|
||||||
|
(0x0000_0022 << 16) | (access << 14) | (func << 2)
|
||||||
|
}
|
||||||
|
/// Host → driver: push the 64-byte `0x01` input report (FILE_WRITE_ACCESS).
|
||||||
|
const IOCTL_PFDS_SET_INPUT: u32 = pfds_ctl(0x800, 2);
|
||||||
|
/// Driver → host inverted-call: completed with a game's raw `0x02` output report (FILE_READ_ACCESS).
|
||||||
|
const IOCTL_PFDS_GET_OUTPUT: u32 = pfds_ctl(0x801, 1);
|
||||||
|
|
||||||
|
// ---- WDF enum values ----
|
||||||
|
const WdfIoQueueDispatchParallel: i32 = 2;
|
||||||
|
const WdfIoQueueDispatchManual: i32 = 3;
|
||||||
|
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
|
||||||
|
const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL
|
||||||
|
const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE
|
||||||
|
|
||||||
|
// ---- DualSense identity ----
|
||||||
|
const DS_VID: u16 = 0x054C;
|
||||||
|
const DS_PID: u16 = 0x0CE6;
|
||||||
|
const DS_VER: u16 = 0x0100;
|
||||||
|
|
||||||
|
// {7B2F8E4A-9C3D-4E1F-A6B5-1234567890AB} — the host↔driver control interface the punktfunk host
|
||||||
|
// opens (on the SwDeviceCreate'd device) to push input reports + pull a game's output reports.
|
||||||
|
const PFDS_CONTROL_GUID: GUID = GUID {
|
||||||
|
Data1: 0x7b2f_8e4a,
|
||||||
|
Data2: 0x9c3d,
|
||||||
|
Data3: 0x4e1f,
|
||||||
|
Data4: [0xa6, 0xb5, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static DUALSENSE_RDESC: [u8; 273] = [
|
||||||
|
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x35,
|
||||||
|
0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 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, 0x0F, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06,
|
||||||
|
0x00, 0xFF, 0x09, 0x21, 0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
|
||||||
|
0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23, 0x95, 0x2F, 0x91, 0x02,
|
||||||
|
0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02, 0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02,
|
||||||
|
0x85, 0x09, 0x09, 0x24, 0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
|
||||||
|
0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27, 0x95, 0x04, 0xB1, 0x02,
|
||||||
|
0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x81, 0x09, 0x29, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
|
||||||
|
0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02,
|
||||||
|
0x85, 0xE0, 0x09, 0x2F, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
|
||||||
|
0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32, 0x95, 0x0F, 0xB1, 0x02,
|
||||||
|
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||||
|
0xC0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static DS_FEATURE_PAIRING: [u8; 20] = [ // 0x09 pairing info (MAC at 1..7)
|
||||||
|
0x09, 0x74, 0xE7, 0xD6, 0x3A, 0x53, 0x35, 0x08, 0x25, 0x00, 0x1E, 0x00, 0xEE, 0x74, 0xD0, 0xBC,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
#[rustfmt::skip]
|
||||||
|
static DS_FEATURE_FIRMWARE: [u8; 64] = [ // 0x20 firmware info
|
||||||
|
0x20, 0x4A, 0x75, 0x6E, 0x20, 0x31, 0x39, 0x20, 0x32, 0x30, 0x32, 0x33, 0x31, 0x34, 0x3A, 0x34,
|
||||||
|
0x37, 0x3A, 0x33, 0x34, 0x03, 0x00, 0x44, 0x00, 0x08, 0x02, 0x00, 0x01, 0x36, 0x00, 0x00, 0x01,
|
||||||
|
0xC1, 0xC8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x54, 0x01, 0x00, 0x00,
|
||||||
|
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)}.
|
||||||
|
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
|
||||||
|
|
||||||
|
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||||
|
fn hid_attrs() -> [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[8..10].copy_from_slice(&DS_VER.to_le_bytes());
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutral DualSense input report 0x01 (64 bytes): sticks centered (0x80), triggers 0, dpad neutral (8).
|
||||||
|
const 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]=L2, r[6]=R2 = 0; r[7] = seq counter = 0
|
||||||
|
r[8] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
|
||||||
|
r
|
||||||
|
};
|
||||||
|
fn neutral_report() -> [u8; 64] {
|
||||||
|
NEUTRAL_REPORT
|
||||||
|
}
|
||||||
|
|
||||||
|
static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
|
||||||
|
/// Manual queue of pended host `IOCTL_PFDS_GET_OUTPUT` requests (inverted-call); completed with a
|
||||||
|
/// game's `0x02` output report as it arrives.
|
||||||
|
static OUTPUT_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
|
||||||
|
/// The latest input report the host pushed (report `0x01`); the timer + each SET_INPUT deliver it to
|
||||||
|
/// pended game READ_REPORTs. Defaults to neutral until the host connects.
|
||||||
|
static INPUT_REPORT: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new(NEUTRAL_REPORT);
|
||||||
|
/// One-shot logs so the control channel's first traffic is visible without per-frame spam.
|
||||||
|
static LOGGED_SET_INPUT: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
|
||||||
|
static LOGGED_GET_OUTPUT: core::sync::atomic::AtomicBool =
|
||||||
|
core::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
// ---- user-mode shared-memory IPC with the punktfunk host ----
|
||||||
|
// UMDF runs in WUDFHost.exe (user-mode) and hidclass blocks a control channel on the device stack
|
||||||
|
// (custom interface CreateFile → err 31; custom IOCTL on the HID handle → err 1) and UMDF has no
|
||||||
|
// control device, so the host channel is a named section the (privileged) host CREATES and the driver
|
||||||
|
// OPENS. Layout (256 B): magic u32 @0 ("PFDS"), input_seq u32 @4, input_report[64] @8,
|
||||||
|
// output_seq u32 @72, output_report[64] @76.
|
||||||
|
const FILE_MAP_RW: u32 = 0x0002 | 0x0004; // FILE_MAP_WRITE | FILE_MAP_READ
|
||||||
|
const SHM_MAGIC: u32 = 0x5046_4453; // "PFDS" little-endian
|
||||||
|
const SHM_SIZE: usize = 256;
|
||||||
|
static LOGGED_SHM: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
// kernel32 file-mapping APIs (resolved via std's kernel32 import; UMDF permits file mapping).
|
||||||
|
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()) };
|
||||||
|
}
|
||||||
|
// Also append to a world-writable file — DebugView can't capture the UMDF host's output
|
||||||
|
// across session 0, so this is how we read driver-start diagnostics.
|
||||||
|
use std::io::Write;
|
||||||
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open("C:\\Users\\Public\\pfds-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-ds] DriverEntry");
|
||||||
|
// SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
|
||||||
|
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; driver/registry_path 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>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||||
|
log("[pf-ds] EvtDeviceAdd");
|
||||||
|
|
||||||
|
// Mark as a filter (HID minidriver sits below mshidumdf.sys).
|
||||||
|
// SAFETY: device_init is provided by the framework and non-null.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfFdoInitSetFilter, device_init) };
|
||||||
|
|
||||||
|
let mut device: WDFDEVICE = core::ptr::null_mut();
|
||||||
|
// SAFETY: device_init valid; attributes allowed 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-ds] WdfDeviceCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() };
|
||||||
|
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);
|
||||||
|
// WDF_IO_QUEUE_CONFIG_INIT sets this to (ULONG)-1 (unlimited); mem::zeroed left it 0,
|
||||||
|
// which on a parallel queue means present ZERO requests → EvtIoDeviceControl never fires.
|
||||||
|
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
||||||
|
let mut default_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 default_queue
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-ds] default WdfIoQueueCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual queue: pended READ_REPORT requests are completed by the timer.
|
||||||
|
// SAFETY: zeroed config then fields set.
|
||||||
|
let mut mcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
mcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||||
|
mcfg.DispatchType = WdfIoQueueDispatchManual;
|
||||||
|
mcfg.PowerManaged = WdfUseDefault;
|
||||||
|
let mut manual_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 mcfg,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut manual_queue
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-ds] manual WdfIoQueueCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Periodic timer (parent = manual queue) completes pended reads with the neutral report.
|
||||||
|
// SAFETY: zeroed config then fields set.
|
||||||
|
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
tcfg.Size = core::mem::size_of::<WDF_TIMER_CONFIG>() as ULONG;
|
||||||
|
tcfg.EvtTimerFunc = Some(evt_timer);
|
||||||
|
tcfg.Period = 8; // ms
|
||||||
|
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
|
||||||
|
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||||
|
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
|
||||||
|
tattr.ParentObject = manual_queue.cast();
|
||||||
|
// mem::zeroed leaves these at 0 (Invalid) → set them like WDF_OBJECT_ATTRIBUTES_INIT
|
||||||
|
// (matches the working vhidmini2 UMDF timer setup; avoids 0xc0200209 / 0xc00000bb).
|
||||||
|
tattr.ExecutionLevel = WdfExecutionLevelInheritFromParent;
|
||||||
|
tattr.SynchronizationScope = WdfSynchronizationScopeInheritFromParent;
|
||||||
|
let mut timer: WDFTIMER = core::ptr::null_mut();
|
||||||
|
// SAFETY: config + attributes valid; timer receives the handle.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-ds] WdfTimerCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
// SAFETY: timer valid; -80000 == 8ms relative due time (100ns units, negative = relative).
|
||||||
|
let _started = unsafe { call_unsafe_wdf_function_binding!(WdfTimerStart, timer, -80000i64) };
|
||||||
|
|
||||||
|
// Output queue: pended host GET_OUTPUT (inverted-call) requests, completed as games write 0x02.
|
||||||
|
// SAFETY: zeroed config then fields set.
|
||||||
|
let mut ocfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||||
|
ocfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||||
|
ocfg.DispatchType = WdfIoQueueDispatchManual;
|
||||||
|
ocfg.PowerManaged = WdfUseDefault;
|
||||||
|
let mut output_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 ocfg,
|
||||||
|
WDF_NO_OBJECT_ATTRIBUTES,
|
||||||
|
&mut output_queue
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!("[pf-ds] output WdfIoQueueCreate failed 0x{:08x}", st as u32);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
OUTPUT_QUEUE.store(output_queue, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Host↔driver control interface — the punktfunk host opens this to push input + pull output.
|
||||||
|
// Non-fatal if it fails: the HID device still works for direct-app use, just not the host plane.
|
||||||
|
// SAFETY: device valid; GUID is a valid static; the reference string is optional (null).
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(
|
||||||
|
WdfDeviceCreateDeviceInterface,
|
||||||
|
device,
|
||||||
|
&PFDS_CONTROL_GUID,
|
||||||
|
core::ptr::null::<c_void>() as PCUNICODE_STRING
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !nt_success(st) {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-ds] WdfDeviceCreateDeviceInterface failed 0x{:08x}",
|
||||||
|
st as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pf-ds] device ready (DualSense 054C:0CE6)");
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_io_device_control(
|
||||||
|
_queue: WDFQUEUE,
|
||||||
|
request: WDFREQUEST,
|
||||||
|
_output_len: usize,
|
||||||
|
_input_len: usize,
|
||||||
|
ioctl: ULONG,
|
||||||
|
) {
|
||||||
|
let mut complete = true;
|
||||||
|
// Skip the 8ms READ_REPORT cadence so the log stays readable during a game test;
|
||||||
|
// the 0x02 OUTPUT report (the gate) and the descriptor handshake still log.
|
||||||
|
if !matches!(
|
||||||
|
ioctl,
|
||||||
|
IOCTL_HID_READ_REPORT | IOCTL_PFDS_SET_INPUT | IOCTL_PFDS_GET_OUTPUT
|
||||||
|
) {
|
||||||
|
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_READ_REPORT => {
|
||||||
|
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
||||||
|
// SAFETY: request valid; mq is the manual queue created in EvtDeviceAdd.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, mq)
|
||||||
|
};
|
||||||
|
if nt_success(st) {
|
||||||
|
complete = false;
|
||||||
|
STATUS_SUCCESS
|
||||||
|
} else {
|
||||||
|
st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => on_output_report(request, ioctl),
|
||||||
|
IOCTL_UMDF_HID_SET_FEATURE => {
|
||||||
|
log("[pf-ds] SET_FEATURE (stub ok)");
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(request),
|
||||||
|
IOCTL_UMDF_HID_GET_INPUT_REPORT => copy_to_output(request, &neutral_report()),
|
||||||
|
// ---- host control channel ----
|
||||||
|
IOCTL_PFDS_SET_INPUT => on_set_input(request),
|
||||||
|
IOCTL_PFDS_GET_OUTPUT => {
|
||||||
|
let oq: WDFQUEUE = OUTPUT_QUEUE.load(Ordering::SeqCst);
|
||||||
|
// SAFETY: request valid; oq is the output manual queue created in EvtDeviceAdd.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestForwardToIoQueue, request, oq)
|
||||||
|
};
|
||||||
|
if !LOGGED_GET_OUTPUT.swap(true, Ordering::Relaxed) {
|
||||||
|
dbglog!(
|
||||||
|
"[pf-ds] control: first GET_OUTPUT posted (host pump up) st=0x{:08x}",
|
||||||
|
st as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if nt_success(st) {
|
||||||
|
complete = false;
|
||||||
|
STATUS_SUCCESS
|
||||||
|
} else {
|
||||||
|
st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => STATUS_NOT_IMPLEMENTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(
|
||||||
|
ioctl,
|
||||||
|
IOCTL_HID_READ_REPORT | IOCTL_PFDS_SET_INPUT | IOCTL_PFDS_GET_OUTPUT
|
||||||
|
) {
|
||||||
|
dbglog!("[pf-ds] ioctl 0x{ioctl:08x} -> 0x{:08x} complete={complete}", status as u32);
|
||||||
|
}
|
||||||
|
if complete {
|
||||||
|
// SAFETY: request valid and not forwarded.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, status) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy `src` into the request's output memory 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 0x02 gate: a game writing an output report (rumble / lightbar / ADAPTIVE TRIGGERS). Per the
|
||||||
|
// UMDF marshalling convention the report data is the *input* buffer and the report id is carried in
|
||||||
|
// the *output* buffer length. We log it.
|
||||||
|
fn on_output_report(request: WDFREQUEST, ioctl: ULONG) -> 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;
|
||||||
|
|
||||||
|
// report id from output-buffer length (UMDF convention).
|
||||||
|
let mut report_id: u32 = 0;
|
||||||
|
let mut outmem: WDFMEMORY = core::ptr::null_mut();
|
||||||
|
// SAFETY: request valid; output memory is optional here.
|
||||||
|
if nt_success(unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfRequestRetrieveOutputMemory, request, &mut outmem)
|
||||||
|
}) {
|
||||||
|
let mut outlen: usize = 0;
|
||||||
|
// SAFETY: outmem valid.
|
||||||
|
let _ = unsafe { call_unsafe_wdf_function_binding!(WdfMemoryGetBuffer, outmem, &mut outlen) };
|
||||||
|
report_id = outlen as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = inlen.min(48);
|
||||||
|
let mut hex = String::new();
|
||||||
|
if !inbuf.is_null() {
|
||||||
|
// SAFETY: inbuf valid for inlen bytes; we read at most n.
|
||||||
|
let bytes = unsafe { core::slice::from_raw_parts(inbuf, n) };
|
||||||
|
for b in bytes {
|
||||||
|
hex.push_str(&format!("{b:02x} "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
|
||||||
|
"WRITE_REPORT"
|
||||||
|
} else {
|
||||||
|
"SET_OUTPUT_REPORT"
|
||||||
|
};
|
||||||
|
dbglog!(
|
||||||
|
"[pf-ds] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forward the raw report to a pended host GET_OUTPUT request so the punktfunk host can relay
|
||||||
|
// rumble / lightbar / player-LEDs / adaptive-trigger feedback to the client.
|
||||||
|
if !inbuf.is_null() && inlen > 0 {
|
||||||
|
// SAFETY: inbuf valid for inlen bytes; cap the copy at 64.
|
||||||
|
let report = unsafe { core::slice::from_raw_parts(inbuf, inlen.min(64)) };
|
||||||
|
deliver_output(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to shared memory for the host — the real feedback channel (the IOCTL path above is
|
||||||
|
// inert under hidclass). output_report @76, output_seq @72.
|
||||||
|
if !inbuf.is_null() && inlen > 0 {
|
||||||
|
let n = inlen.min(64);
|
||||||
|
with_shm(|view| {
|
||||||
|
// SAFETY: view is a mapped 256-byte section; write the report then bump the host-polled seq.
|
||||||
|
unsafe {
|
||||||
|
core::ptr::copy_nonoverlapping(inbuf, view.add(76), n);
|
||||||
|
let seqp = view.add(72) as *mut u32;
|
||||||
|
let seq = core::ptr::read_unaligned(seqp).wrapping_add(1);
|
||||||
|
core::ptr::write_unaligned(seqp, seq);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: request valid.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestSetInformation, request, inlen as u64) };
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host → driver: store the pushed `0x01` input report and deliver it to a pending game READ_REPORT.
|
||||||
|
fn on_set_input(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;
|
||||||
|
if inbuf.is_null() || inlen == 0 {
|
||||||
|
return STATUS_INVALID_PARAMETER;
|
||||||
|
}
|
||||||
|
let n = inlen.min(64);
|
||||||
|
if let Ok(mut guard) = INPUT_REPORT.lock() {
|
||||||
|
// SAFETY: inbuf valid for inlen >= n bytes.
|
||||||
|
let src = unsafe { core::slice::from_raw_parts(inbuf, n) };
|
||||||
|
guard[..n].copy_from_slice(src);
|
||||||
|
}
|
||||||
|
if !LOGGED_SET_INPUT.swap(true, Ordering::Relaxed) {
|
||||||
|
dbglog!("[pf-ds] control: first SET_INPUT ({inlen} bytes) — host input plane up");
|
||||||
|
}
|
||||||
|
complete_one_read();
|
||||||
|
STATUS_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull one pended game READ_REPORT and complete it with the current input report.
|
||||||
|
fn complete_one_read() {
|
||||||
|
let queue: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
||||||
|
if queue.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut request: WDFREQUEST = core::ptr::null_mut();
|
||||||
|
// SAFETY: queue valid; request receives the next pended request if any.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
|
||||||
|
};
|
||||||
|
if nt_success(st) {
|
||||||
|
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||||
|
let s = copy_to_output(request, &report);
|
||||||
|
// SAFETY: request valid and dequeued.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver a game's raw `0x02` output report to a pended host GET_OUTPUT request (if one is posted).
|
||||||
|
fn deliver_output(data: &[u8]) {
|
||||||
|
let oq: WDFQUEUE = OUTPUT_QUEUE.load(Ordering::SeqCst);
|
||||||
|
if oq.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut request: WDFREQUEST = core::ptr::null_mut();
|
||||||
|
// SAFETY: oq valid; request receives the next pended request if any.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, oq, &mut request)
|
||||||
|
};
|
||||||
|
if nt_success(st) {
|
||||||
|
let s = copy_to_output(request, data);
|
||||||
|
// SAFETY: request valid and dequeued.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense feature blob.
|
||||||
|
fn on_get_feature(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;
|
||||||
|
if inbuf.is_null() || inlen < 1 {
|
||||||
|
return STATUS_INVALID_PARAMETER;
|
||||||
|
}
|
||||||
|
// 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 => {
|
||||||
|
dbglog!("[pf-ds] GET_FEATURE unknown report id 0x{other:02x}");
|
||||||
|
return STATUS_INVALID_PARAMETER;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
copy_to_output(request, blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
.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 the section alive,
|
||||||
|
// so the handle can be closed right away.
|
||||||
|
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 {
|
||||||
|
if !LOGGED_SHM.swap(true, Ordering::Relaxed) {
|
||||||
|
dbglog!("[pf-ds] control: shared memory mapped (Global\\pfds-shm-0)");
|
||||||
|
}
|
||||||
|
f(view);
|
||||||
|
}
|
||||||
|
// SAFETY: view came from MapViewOfFile.
|
||||||
|
unsafe { UnmapViewOfFile(view as *const c_void) };
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||||
|
// Pull the latest host input report from shared memory (if the host has connected).
|
||||||
|
with_shm(|view| {
|
||||||
|
let mut buf = [0u8; 64];
|
||||||
|
// SAFETY: view points at a mapped 256-byte section; input lives at offset 8..72.
|
||||||
|
unsafe { core::ptr::copy_nonoverlapping(view.add(8), buf.as_mut_ptr(), 64) };
|
||||||
|
if buf[0] == 0x01 {
|
||||||
|
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||||
|
*g = buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SAFETY: timer valid; parent is the manual queue.
|
||||||
|
let queue =
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
|
||||||
|
let mut request: WDFREQUEST = core::ptr::null_mut();
|
||||||
|
// SAFETY: queue valid; request receives the next pended request if any.
|
||||||
|
let st = unsafe {
|
||||||
|
call_unsafe_wdf_function_binding!(WdfIoQueueRetrieveNextRequest, queue, &mut request)
|
||||||
|
};
|
||||||
|
if nt_success(st) {
|
||||||
|
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||||
|
let s = copy_to_output(request, &report);
|
||||||
|
// SAFETY: request valid and dequeued.
|
||||||
|
unsafe { call_unsafe_wdf_function_binding!(WdfRequestComplete, request, s) };
|
||||||
|
}
|
||||||
|
let _ = STATUS_UNSUCCESSFUL; // keep the const referenced
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user