diff --git a/docs/windows-dualsense-scoping.md b/docs/windows-dualsense-scoping.md index 93ba387..e4f48d1 100644 --- a/docs/windows-dualsense-scoping.md +++ b/docs/windows-dualsense-scoping.md @@ -1,35 +1,64 @@ # Windows host — virtual DualSense scoping -**Status:** scoping (2026-06-20). Decision pending the web-research pass (see *Open questions* — web -search was unavailable when this was written, so the VHF API/signing specifics and the -"existing-driver-to-vendor" survey are marked TO-CONFIRM). +**Status:** **M0 feasibility gate PASSED (2026-06-21)** — a self-authored **Rust** UMDF virtual DualSense +loads self-signed under Secure Boot, is recognized as a genuine DualSense by Steam, and receives `0x02` +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 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 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 -DualSense** and therefore drives them; a DS4 virtual pad means the game uses its DS4 code path and +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 takes its DS4 code path and never emits those commands, so the client's adaptive-trigger rendering is never exercised. ViGEm DS4 structurally **cannot** deliver adaptive triggers. -The right path is the Windows analog of what the Linux host already does: present a **real virtual -DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report descriptor). On Windows -that means a kernel-mode virtual-HID device via the **Virtual HID Framework (VHF)** — the UHID analog — -which is a SudoVDA-class driver effort (vendored + signed, installed by the existing Inno installer). +The right path is the Windows analog of what the Linux host already does over `/dev/uhid`: present a +**real virtual DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report +descriptor punktfunk already ships). On Windows that is a **UMDF2 (user-mode) HID minidriver** — +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 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 `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 -Windows side. So: +calibration, DS4 touchpad packing. There is **zero** virtual-HID / DualSense code on Apollo's Windows +side. So: - 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 - on Windows to vendor from. This would be novel work for the streaming-host space. +- There is **no in-ecosystem upstream** (Sunshine/Apollo/Wolf) that already solved a virtual + *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 @@ -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 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 | |---|---|---| -| Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | 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` | -| Backend dispatch (`enum PadBackend`) | done; `DualSense` arm is `#[cfg(target_os="linux")]` | `punktfunk1.rs:1229` | -| Clients (capture + adaptive-trigger/lightbar/haptic rendering) | done, all platforms | `clients/*` | -| C-ABI (`next_hidout` / `send_rich_input`) | done | `abi.rs` | +| Protocol planes (rich input `0xCC`, rumble `0xCA`, HID-output `0xCD`) | ✅ 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` (~1577) | +| Backend dispatch (`enum PadBackend`) | ✅ done; `DualSense`/`DualShock4` arms are `#[cfg(target_os="linux")]` | `punktfunk1.rs` (PadBackend ~1181–1272) | +| Clients (capture + adaptive-trigger/lightbar/haptic/touchpad/motion rendering) | ✅ done, all platforms | `clients/*` | +| C-ABI (`next_hidout` / `send_rich_input`) | ✅ done | `abi.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 -virtual DualSense HID device, translate our pad state → HID input report `0x01`, and surface the game's -HID output report `0x02` as the same `HidOutput` events the Linux path already emits. That is a -well-bounded host-side addition (driver + a `DualSenseManager`-shaped userspace bridge + a -`PadBackend::DualSense` Windows arm). +So a Windows DualSense backend needs **no protocol, client, or C-ABI change**. The whole DualSense +**HID contract already exists as pure, transport-independent Rust + const data**, kernel-verified +byte-for-byte against `hid-playstation.c` / inputtino / SDL, in `inject/dualsense.rs`: -## 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 -requires a kernel component. The Microsoft-sanctioned one is the **Virtual HID Framework (VHF)**: a -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`. +needs a driver component. The decisive correction over the prior draft: -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 - PadState ----------- input report 0x01 -----------> VhfReadReportSubmit - HidOutput <-- output report 0x02 (write callback) --- EvtVhf*WriteReport +host process (Rust) <-- SwDeviceCreate + IOCTL channel --> UMDF2 HID minidriver <-- HID --> game / Steam / GameInput + PadState -------------- input report 0x01 -------------> inverted READ_REPORT queue + HidOutput <----- output report 0x02 (WriteReport cb) ----- EvtIoDeviceControl ``` -- **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship for - Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games recognize it - as a DualSense. -- **Userspace bridge:** a `DualSenseManager`-shaped struct mirroring the Linux one (same `RichInput` → - report `0x01` packing, same `HidOutput` parsing from report `0x02`), talking to the driver over an - IOCTL/pipe instead of `/dev/uhid`. -- **Packaging:** vendor + sign the `.sys`/`.inf`/`.cat` and install via the existing - `packaging/windows/sudovda` machinery (`nefconc.exe` + an `install-*.ps1`, bundled in the Inno - `setup.exe`). The precedent is already in the repo. +- **Descriptor reuse:** the exact inputtino PS5 descriptor + feature-report replies we already ship + for Linux (`dualsense.rs` `DS_*` constants) — same bytes, same VID/PID, so Windows + games + recognize it as a DualSense. +- **Host-side device creation:** `windows::Win32::Devices::Enumeration::Pnp::SwDeviceCreate` → + `Result` (pure Win32, in the `windows` crate, **no WDK needed**), enumerating a + root device whose hardware IDs match the pre-staged INF. Requires Administrator. **The device + exists only while the `HSWDEVICE` handle (i.e. the host process) is open** — `SwDeviceClose` + removes it — so the pad is created/destroyed with the session, exactly like the Linux UHID fd. + 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 | -|---|---|---| -| KMDF + VHF virtual-HID driver | large | KMDF (kernel) is a higher bar than SudoVDA's UMDF/IddCx; bulk of the work | -| 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 | +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 +*low-risk* M2. Honest ranking of the three options: -**Top risks:** (1) a KMDF/VHF driver is real kernel work + signing logistics; (2) whether VHF's -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 -same as a physical one (descriptor + VID/PID should suffice, but unverified on Windows). +### Option R — fully self-authored Rust driver (preferred; viable, but pioneering) + +- **What's real today:** `microsoft/windows-drivers-rs` (`wdk`, `wdk-sys`, `wdk-build`, + `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 | 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 | -| **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 | -| **D. Defer** | — | — | if a higher-ROI item (#9 launch, #7/#18 audio) wins the slot | +| **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 | +| **C. Hybrid** | A for DS5 clients, B/Xbox 360 fallback | A + small | belt-and-suspenders once A exists | +| **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); - exact API (`VhfCreate`/`VhfStart`/`VhfReadReportSubmit`/the output-report `EvtVhf…WriteReport` - callback); KMDF-only or UMDF-capable; minimum Windows version; the MS `vhidmini`/VHF sample. -2. **Existing driver to vendor:** is there a maintained virtual-HID / virtual-DualSense Windows driver - (Nefarius/community) we can vendor like SudoVDA, instead of writing a KMDF driver from scratch? -3. **Recognition:** does a VHF device with VID `054C`/PID `0CE6` + the DualSense descriptor get - recognized as a DualSense by Windows.Gaming.Input / GameInput / Steam Input / native-DS5 games — - including adaptive triggers via the `0x02` output report? -4. **Signing/distribution:** attestation vs. WHQL for a KMDF driver; can we test-sign for dev and ship - an attestation-signed driver via the Inno installer like SudoVDA? -5. **HidHide:** needed at all on a (usually headless) host, or only when a physical pad is present? +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| 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 | +| `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 | +| 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** | +| EV cert + Partner Center attestation lead time / friction | medium | schedule | Start procurement at M0; lean on the SudoVDA UMDF submission precedent | +| EV key non-exportable → can't sign in CI | high | low | Accept a manual offline sign+submit step; vendor the Microsoft-resigned `.cat` | +| `SwDeviceCreate` device lifetime tied to the host process handle | known | low | Hold `HSWDEVICE` for the session lifetime (matches Linux UHID fd semantics) | +| `windows-drivers-rs` transient toolchain breaks (e.g. LLVM-22 bindgen, Disc. #591) | low | low | Pin LLVM 21.1.2; not a fundamental defect | +| `DS_FEATURE_CALIBRATION` 42-byte blob rejected by strict Windows consumers | low | low | Trim to 41 bytes in M1 | -## Recommended plan +## Open questions -1. **Web-research pass** (when search is back) to close the five questions above — especially #2 - (vendor vs. build) and #1 (VHF feasibility + output-report support), which gate the whole effort. -2. If VHF (or a vendorable driver) is confirmed feasible: build **Option A** — driver + Windows - `DualSenseManager` + un-gate `PadBackend::DualSense`, reusing the inputtino descriptor and the - existing `HidOutput` plane (no protocol/client/ABI change), packaged via the SudoVDA path. -3. Keep **Xbox 360** as-is and treat **ViGEm DS4** only as an optional fallback (Option C), never as - the DualSense answer. +1. **Driver channel design** (unknown): punktfunk's own driver↔host protocol — simplest is a private + control device with an inverted-call IOCTL for input + IOCTLs for output/feature, vs HIDMaestro's + shared-memory section. `vhidmini2` has *no* service channel (it self-generates via a timer), so this + must be designed fresh (or read out of HIDMaestro/DsHidMini source). **Resolve in M3.** +2. **Rust UMDF symbol coverage** (unknown — the M0(a) gate): are all needed WDF symbols + (`WdfIoQueueCreate`, `WdfFdoInitSetFilter`, `WdfRequestRetrieveOutputMemory`, manual-queue APIs, + `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. diff --git a/packaging/windows/dualsense-driver/Cargo.toml b/packaging/windows/dualsense-driver/Cargo.toml new file mode 100644 index 0000000..c30e8d1 --- /dev/null +++ b/packaging/windows/dualsense-driver/Cargo.toml @@ -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] diff --git a/packaging/windows/dualsense-driver/Makefile.toml b/packaging/windows/dualsense-driver/Makefile.toml new file mode 100644 index 0000000..df1e841 --- /dev/null +++ b/packaging/windows/dualsense-driver/Makefile.toml @@ -0,0 +1,4 @@ +extend = [ + { path = "../../crates/wdk-build/rust-driver-makefile.toml" }, + { path = "../../crates/wdk-build/rust-driver-sample-makefile.toml" }, +] diff --git a/packaging/windows/dualsense-driver/README.md b/packaging/windows/dualsense-driver/README.md new file mode 100644 index 0000000..89bb73d --- /dev/null +++ b/packaging/windows/dualsense-driver/README.md @@ -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 $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 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`. diff --git a/packaging/windows/dualsense-driver/build.rs b/packaging/windows/dualsense-driver/build.rs new file mode 100644 index 0000000..180a6c9 --- /dev/null +++ b/packaging/windows/dualsense-driver/build.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() +} diff --git a/packaging/windows/dualsense-driver/pf_dualsense.inx b/packaging/windows/dualsense-driver/pf_dualsense.inx new file mode 100644 index 0000000..c979082 --- /dev/null +++ b/packaging/windows/dualsense-driver/pf_dualsense.inx @@ -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" diff --git a/packaging/windows/dualsense-driver/src/lib.rs b/packaging/windows/dualsense-driver/src/lib.rs new file mode 100644 index 0000000..ee0496f --- /dev/null +++ b/packaging/windows/dualsense-driver/src/lib.rs @@ -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 = 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 = 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::() 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::() + ) + } +} + +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::() 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::() 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::() 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::() 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::() 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::() 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: F) { + let name: Vec = "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 +}