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:
2026-06-21 20:36:39 +00:00
parent 983adc5347
commit aa159df33f
7 changed files with 1272 additions and 80 deletions
+349 -80
View File
@@ -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 ~11811272) |
| 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` ~11811272), flipping `pick_gamepad`/`resolve_gamepad`
(~15581606) from `#[cfg(target_os = "linux")]` to `#[cfg(any(target_os = "linux", target_os =
"windows"))]`, plus the `inject.rs` module gating (~424451). `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) | smallmedium | 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 250560/yr;
FIPS hardware token/HSM mandatory; 17 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 M1M6** 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 (M0M6)
| # | 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 | lowmed | 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
}