d01a8fd17a
windows-host / package (push) Failing after 4m16s
ci / rust (push) Failing after 4m56s
ci / web (push) Failing after 22s
ci / docs-site (push) Successful in 1m7s
android / android (push) Successful in 9m19s
ci / bench (push) Successful in 4m47s
decky / build-publish (push) Successful in 11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Failing after 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / deploy-docs (push) Has been skipped
deb / build-publish (push) Failing after 6m29s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 7m4s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m17s
apple / swift (push) Successful in 1m13s
apple / screenshots (push) Successful in 5m27s
NVIDIA/AMD Vulkan ICDs refuse to *advertise* an HDR color space for a surface on an
IddCx indirect/virtual display, so Vulkan games (Doom: The Dark Ages, id Tech, Indiana
Jones, …) report "device does not support HDR" — even though Windows HDR, DWM compose,
and the client PQ stream all work, and the ICD happily *accepts + presents* a forced HDR
swapchain there. The whole gap is enumeration; the community (Apollo/Sunshine/VDD) wrote
this off as kernel-side / unfixable.
Add VK_LAYER_PUNKTFUNK_hdr_inject (packaging/windows/pf-vkhdr-layer/): a standalone
cdylib Vulkan implicit layer that appends {A2B10G10R10, HDR10_ST2084} + {RGBA16F, scRGB}
to vkGetPhysicalDeviceSurfaceFormats[2]KHR (no need to hook vkCreateSwapchainKHR — the
ICD doesn't validate the color space there). Self-gated on the surface monitor's actual
advanced-color state (DisplayConfig GET_ADVANCED_COLOR_INFO), so it is a complete no-op
on SDR sessions and real monitors (dedup). Always-on (registry-discovered) so it works
regardless of how a game is launched — env-scoping silently fails for already-running
Steam. Escape hatches: DISABLE_PF_VKHDR, PF_VKHDR_EXCLUDE, and a built-in kernel-anti-
cheat denylist.
The installer builds/signs/stages it and registers it under
HKLM64\SOFTWARE\Khronos\Vulkan\ImplicitLayers (opt-out "Install the HDR Vulkan layer"
task); windows-host CI fmt+clippy-gates it (msvc-only FFI).
Live-validated on the RTX box: Doom: The Dark Ages enables HDR over the pf-vdisplay
virtual display.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
417 lines
32 KiB
Markdown
417 lines
32 KiB
Markdown
# Windows host — virtual DualSense scoping
|
||
|
||
**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) 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 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** 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 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
|
||
|
||
The Linux host (`crates/punktfunk-host/src/inject/dualsense.rs`) creates a **UHID** device presenting
|
||
the genuine DualSense descriptor, so the kernel `hid-playstation` driver binds it and games see a real
|
||
DualSense — gamepad + motion + touchpad + lightbar/player-LEDs + adaptive triggers. It writes HID
|
||
**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**
|
||
(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` (~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**. 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`:
|
||
|
||
- `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
|
||
needs a driver component. The decisive correction over the prior 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. (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) <-- 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.
|
||
- **Host-side device creation:** `windows::Win32::Devices::Enumeration::Pnp::SwDeviceCreate` →
|
||
`Result<HSWDEVICE>` (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.
|
||
|
||
## Driver language — recommendation
|
||
|
||
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:
|
||
|
||
### 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.**
|
||
|
||
**Host integration status (2026-06-21): M1/M3/M4 landed; data plane runtime-proven.** The Linux
|
||
DualSense logic is shared via `inject/dualsense_proto.rs`; the Windows backend
|
||
`inject/dualsense_windows.rs` (`DualSenseWindowsManager`) drives the driver over the
|
||
`Global\pfds-shm-<idx>` section, and the `PadBackend`/`pick_gamepad` seam now resolves DualSense on
|
||
Windows. Live-verified on the RTX box: the manager creates the section + pushes report `0x01` and a
|
||
devnode serves it to a HID read (manager data plane works). **Open item — `SwDeviceCreate`
|
||
per-session devnode:** two `E_INVALIDARG` causes found — (1) an underscore in the enumerator name
|
||
(`pf_dualsense` → use `punktfunk`), (2) passing the completion callback is still rejected (cause
|
||
unresolved; needs a known-good C reference). So per-session auto-creation is **best-effort/non-fatal**:
|
||
the host falls back to an out-of-band `pf_dualsense` devnode (the INF lists both `root\pf_dualsense`
|
||
for devgen and `pf_dualsense` for SwDevice; the installer would create it, as SudoVDA does). Remaining:
|
||
fix the SwDeviceCreate callback E_INVALIDARG, then the M5 on-glass game test.
|
||
|
||
## 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. 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, 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.
|
||
|
||
## Risk register
|
||
|
||
| 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 |
|
||
|
||
## Open questions
|
||
|
||
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.
|