# Windows host — virtual DualSense scoping

> **Status:** SHIPPED — M0 feasibility gate PASSED (2026-06-21), M1–M4 landed. Driver:
> `packaging/windows/drivers/pf-dualsense/` (README there); host backend
> `crates/punktfunk-host/src/inject/dualsense_windows.rs` + shared contract
> `inject/dualsense_proto.rs`. Commits `aa159df` (Rust UMDF driver + shm channel),
> `4a73102` (host backend), `fde438a`/`6db3525` (SwDeviceCreate per-session devnode),
> `b0c8233` (pure-user-mode DS4/Xbox 360, ViGEm dropped). This doc is trimmed to design
> rationale + open items; implementation detail lives in the code and the driver README.

## Why UMDF2, and why a real virtual DualSense (the WHY)

Apollo's backlog "DS4 ViGEm target on Windows" is the **wrong target** for *actual DualSense*.
ViGEmBus emulates only **Xbox 360 (XUSB)** and **DualShock 4** — never a DualSense. Because this
is a *host-side* virtual pad, the DualSense-defining features (adaptive 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 makes the game take its DS4 code path and never emit those commands,
so the client's adaptive-trigger rendering is never exercised. **ViGEm DS4 structurally cannot
deliver adaptive triggers** — that ceiling is the whole reason not to copy Apollo here (and Apollo
itself does DualSense only on Linux via `inputtino`; its Windows path is ViGEm `XUSB`/`DS4_REPORT_EX`
only — zero virtual-HID/DualSense code to vendor).

The right path is the Windows analog of the Linux host's `/dev/uhid` device: present a **real virtual
DualSense HID device** (Sony VID `054C` / PID `0CE6`, the inputtino PS5 report descriptor we already
ship) so the game/Steam/GameInput bind it as genuine.

**Mechanism = a UMDF2 (user-mode) HID minidriver**, created/torn-down per session via
`SwDeviceCreate`, as a lower filter under the OS pass-through driver `mshidumdf.sys`. This is the
**same driver tier as SudoVDA** (UMDF, not kernel), so the existing vendor → sign → Inno-installer
machinery applies almost unchanged. Two corrections drove this conclusion over the 2026-06-20 draft:

- **VHF (Virtual HID Framework) supports a HID *source* driver only in kernel mode** — it is *not*
  the mechanism for a user-mode virtual pad. The user-mode mechanism is a UMDF2 HID minidriver built
  from the `vhidmini2` sample. So the earlier "KMDF, a higher bar than SudoVDA" framing was wrong:
  it is the *same* UMDF tier.
- **UMDF 2.0 is NOT COM-based** (COM/`IDriverEntry`/`IWDFDriver` are legacy UMDF 1.x). UMDF 2.0 uses
  the same **C-style WDF object model as KMDF** — a `DriverEntry` symbol + C function pointers, no
  vtable. This is precisely why a Rust FFI implementation is even conceivable.

Everything except the host backend was already platform-agnostic and DualSense-complete (protocol
planes `0xCC`/`0xCA`/`0xCD`, the `HidOutput` feedback abstraction, pad-type negotiation, clients, the
C-ABI). The DualSense HID contract (the 232-byte `DUALSENSE_RDESC`, `serialize_state` for input report
`0x01`, `parse_ds_output` for output report `0x02`, the `0x05`/`0x09`/`0x20` feature blobs, USB framing
no-CRC) was already pure transport-independent Rust — so the report bytes are identical to Linux and
only the device-framing layer is new.

## Why Rust ("Option R") despite zero precedent

The user's strong preference was a **self-authored Rust driver**, accepted as pioneering risk.
`microsoft/windows-drivers-rs` officially targets UMDF and ships a real (but *bare-stub*) UMDF sample;
because UMDF 2.0 is the C function-pointer model, the FFI maps cleanly. The honest gap going in: the
whole HID-minidriver layer (`WdfFdoInitSetFilter`, the manual inverted-call queue, `IOCTL_UMDF_HID_*`
dispatch, `HID_XFER_PACKET`) was hand-written `unsafe` FFI with no safe wrappers, and **every** other
shipping virtual-HID controller driver (`vhidmini2`, HIDMaestro, DsHidMini) is C — so symbol coverage
for the UMDF target was unproven. The de-risk plan was a C `vhidmini2` shim fallback (keeping all
DualSense logic in the Rust host either way), with forking HIDMaestro as the last resort (rejected for
real use because **HIDMaestro omits adaptive triggers** — it cannot prove the one thing that makes a
virtual DualSense worth building).

**Outcome: Option R confirmed.** The M0 spike answered both the build-symbol question and the on-glass
gate with a Rust driver — no C shim needed. The DualSense *logic* stays in Rust where it already lived.

## M0 feasibility gate — PASSED (2026-06-21), and the three bugs

The blocking gate (RTX box `192.168.1.173`; the dev VM is headless/WARP and cannot validate
game-facing HID recognition) asked two questions no prior art settled:

1. **Recognition** — is a virtual `054C:0CE6` UMDF2 device accepted as a *genuine DualSense* by
   `Windows.Gaming.Input` / GameInput / Steam? **YES** — Steam recognized it and drove its
   DualSense-specific LEDs.
2. **Adaptive-trigger fidelity** — does the game's output report `0x02` (the adaptive-trigger block)
   actually reach the driver's `WriteReport`/`SetOutputReport` callback? **YES** — captured two
   Steam-Input output reports (`validFlag1=0x14` = LIGHTBAR|PLAYER_INDICATOR). Adaptive-trigger bytes
   ride the same `0x02` path.

> **Three M0 bugs — reference for any future UMDF-in-Rust work:**
> 1. **PE FORCE_INTEGRITY blocks self-signed load.** `wdk-build`'s `/INTEGRITYCHECK` sets the PE
>    FORCE_INTEGRITY bit, which demands a Microsoft-trusted signature to load. Fix: **clear bit `0x80`
>    at offset PE+`0x5e` post-build and re-sign.** This was the load wall (earlier "Secure Boot blocks
>    self-signed UMDF" conclusions were wrong).
> 2. **Timer `ExecutionLevel` must be `InheritFromParent`, not zeroed.** A `mem::zeroed`
>    `WDF_TIMER_CONFIG` gives ExecutionLevel 0, which the framework rejects.
> 3. **Queue `NumberOfPresentedRequests` must be `u32::MAX`, not 0.** A zeroed parallel-queue config
>    caps in-flight requests at 0 → `EvtIoDeviceControl` never fires.

## Milestones

| # | Milestone | State | Commit(s) |
|---|---|---|---|
| **M0** | Feasibility spike (Rust UMDF build + on-glass recognition + `0x02` callback) | ✅ SHIPPED | driver `aa159df` |
| **M1** | Extract transport-independent contract into `inject/dualsense_proto.rs` (`DUALSENSE_RDESC`, `serialize_state`, `parse_ds_output`, feature blobs; calibration trimmed 42→41) | ✅ SHIPPED | `4a73102` |
| **M2** | UMDF2 HID minidriver + INF + signed `.cat` (authored in **Rust**) | ✅ SHIPPED | `aa159df` |
| **M3** | Rust host bridge `inject/dualsense_windows.rs` (`DualSenseWindowsManager` over `Global\pfds-shm-<idx>`; `SwDeviceCreate` per-session devnode) | ✅ SHIPPED | `4a73102`, `fde438a`, `6db3525` |
| **M4** | Un-gate the `PadBackend::DualSense` seam + `GamepadPref::DualSense` resolution on Windows; ViGEm dropped (pure user-mode DS4/Xbox 360 too) | ✅ SHIPPED | `b0c8233` |

A `SwDeviceCreate` gotcha surfaced during M3 and is worth keeping: two `E_INVALIDARG` causes were found
— (1) an **underscore in the enumerator name** (`pf_dualsense` → must be `punktfunk`), and (2) passing
the completion callback was rejected; the INF lists both `root\pf_dualsense` (devgen) and `pf_dualsense`
(SwDevice) and the host falls back to an out-of-band devnode when per-session create fails.

## Decision matrix (condensed)

| Option | Adaptive triggers / DS5 identity | Effort | When it's right |
|---|---|---|---|
| **A. UMDF2 virtual DualSense** (shipped) | ✅ full | medium — UMDF, same tier as SudoVDA | the goal — matches the Linux host |
| **B. ViGEm DS4** | ❌ never (DS4 ceiling) | small | quick PS-pad, no adaptive triggers — **rejected, ViGEm removed** |
| **C. Hybrid** | A for DS5, Xbox 360 fallback | A + small | belt-and-suspenders (Xbox 360/XInput still covers most games) |
| **D. Defer** | — | — | would have applied only if the M0 `0x02` gate had failed |

Xbox 360 (XInput) covers most Windows games regardless; Xbox One/Series fold into it on Windows.

## Risk register (condensed)

| Risk | Status |
|---|---|
| Output `0x02` never reaches the driver write callback (fatal to value prop) | **resolved** — M0 measured it directly, YES |
| `054C:0CE6` not accepted as a real DualSense | **resolved** — Steam recognizes it |
| Rust UMDF pioneering risk (no safe WDF/HID wrappers; symbol coverage) | **resolved** — Rust driver shipped, no C shim |
| `SwDeviceCreate` device lifetime tied to host process handle | accepted — hold `HSWDEVICE` for the session (matches Linux UHID fd semantics) |
| `windows-drivers-rs` transient toolchain breaks (LLVM-22 bindgen, Disc. #591) | low — pin LLVM 21.1.2 |
| EV cert + Partner Center attestation lead time / friction | **open** (see below) |

## Open items

1. **Public-distribution signing — EV cert + Microsoft attestation.** The fleet/self-signed recipe
   (bundled `.cer` → machine Root + TrustedPublisher via `certutil -addstore -f`, then `pnputil
   /add-driver /install`, cloned from `install-sudovda.ps1`) works for dev/internal boxes only —
   Microsoft is explicit it "should never be followed for any driver package distributed outside your
   organization." For arms-length public users the minimal correct path is **Microsoft attestation
   signing** via Partner Center (it re-signs the `.cat` with a Microsoft cert → silent PnP install, no
   publisher prompt, no Root-store import). A bare Authenticode/OV/EV signature is **not** sufficient:
   it installs but with the blocking "would you like to install this device software?" prompt
   (setupapi `0x800b0109` / `0xe0000242`). Attestation needs a registered Windows Hardware Developer
   Program (Partner Center) account **and an EV code-signing cert** (FIPS hardware token, ~USD
   250–560/yr, 1–7 day vetting) to register and to sign the submission CAB. UMDF is exempt from
   kernel-mode load enforcement so the `.dll` *loads* unsigned, but *installation* still needs a
   trusted catalog. The EV key is non-exportable → CAB signing + submission is a **manual offline
   step**, not a CI secret; vendor the Microsoft-resigned `.cat` like SudoVDA's. (Azure Trusted Signing
   cannot substitute — it signs only user-mode PE/`/INTEGRITYCHECK`/SmartScreen, not the driver `.cat`.)
   **Blocks public release; dev/fleet self-signed works today.**

2. **GameInput API detection reads VID/PID as `0x0000`.** The GameInput path does not pick up the
   `054C:0CE6` identity (reads `0x0000`); may require the KMDF USB-emulating bus driver rather than the
   root-enumerated UMDF HID device. Tracked in
   [`design/windows-dualsense-game-detection.md`](windows-dualsense-game-detection.md).

3. **HidHide integration** — unclear value on a usually-headless host; only relevant when a physical
   pad is also attached. Decide whether to bundle/integrate at all.

4. **Minimum-OS / `UMDFVERSION` targeting decision** — which `UmdfLibraryVersion` / WDK to target for
   the widest Win10/11 install base, consistent with punktfunk's existing host support matrix.

5. **Single multi-driver CAB** — can one Partner Center submission carry *both* SudoVDA and the
   DualSense driver? Multi-driver CABs are supported in general; unverified for this account.

6. **DsHidMini end-user signing tier** — self-signed vs attestation in its WixSharp MSI, useful as a
   second public-distribution data point.
