fix(drivers): the pad channel asks the devnode who to trust, not the mailbox
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
ci / rust (push) Failing after 12s
windows-drivers / probe-and-proto (push) Successful in 48s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m6s
deb / build-publish-client-arm64 (push) Failing after 10s
decky / build-publish (push) Successful in 47s
windows-drivers / driver-build (push) Successful in 1m40s
apple / swift (push) Successful in 3m6s
ci / bench (push) Successful in 7m39s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
android / android (push) Successful in 12m28s
deb / build-publish (push) Successful in 12m13s
ci / rust-arm64 (push) Successful in 12m31s
arch / build-publish (push) Successful in 12m40s
deb / build-publish-host (push) Successful in 12m17s
windows-host / package (push) Successful in 18m26s
windows-host / winget-source (push) Skipped
apple / screenshots (push) Successful in 23m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m34s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m24s
A LocalService principal could take over a virtual pad's shared input section and
forge HID input into the interactive desktop.
The host duplicates each pad's unnamed DATA section into the driver's WUDFHost, and
through gamepad proto v2 it learned that process from `driver_pid` in the named
bootstrap mailbox. That mailbox has to be LocalService-writable — that is what the
driver's own WUDFHost runs as — and the delivery gate, verify_is_wudfhost, only checks
that the target's IMAGE is %SystemRoot%\System32\WUDFHost.exe. That image is
world-executable. So anything running as LocalService — notably the deliberately
de-privileged plugin runner — could spawn its own WUDFHost (CREATE_SUSPENDED parks it
indefinitely with the right image path), publish that pid, and be handed
SECTION_MAP_READ|WRITE on a live section. For pf-mouse that section drives a real
absolute pointer, so it was desktop control; for the pads it was forged gamepad input
plus a read of the remote user's controller state.
The module docs claimed mailbox tampering "yields at worst a gamepad DoS, never a read
or an injection". That was wrong, and the reasoning behind it — that a LocalService
token is DACL-denied OpenProcess on a UMDF WUDFHost — only covers the REAL host, not
one the attacker spawned itself.
The pid now comes from the device stack (ChannelProof, proto 2 -> 3). The host asks the
devnode it SwDeviceCreate'd who is serving it, looked up by the instance id PnP handed
back, so a planted look-alike devnode is not a candidate and the kernel — not anything
the attacker supplies — does the routing. Only the driver PnP actually bound to that
device can answer. `driver_pid` survives as a liveness hint; a tamperer can still deny a
pad, which squatting the name always allowed, but can no longer choose the recipient.
Two rules keep the state machine honest around it: a delivery stands until its target
process EXITS (judged on a retained SYNCHRONIZE handle, so a recycled pid cannot fake
it, and UMDF's restart-after-driver-crash still re-attaches), and a pad with no
SwDeviceCreate devnode refuses to deliver rather than fall back — unless an operator
sets PUNKTFUNK_PAD_CHANNEL_TRUST_MAILBOX, which says so loudly.
Three transports, because Windows carries different things to different driver shapes,
and the obvious two did not survive contact with hidclass. Measured on .173 (Win11
26200): HidD_GetIndexedString is NOT forwarded to a UMDF HID minidriver at all — it
failed for every index including ones the driver demonstrably serves through the named
wrappers; and a private device interface registers and enumerates but cannot be OPENED
(ERROR_GEN_FAILURE), because hidclass owns IRP_MJ_CREATE on a devnode it is the FDO for.
That is exactly why pf-xusb was never affected: it is not a HID minidriver, so nothing
sits above it. What works:
* pf-xusb — a private IOCTL on its own GUID_DEVINTERFACE_XUSB.
* pf-mouse — the HID serial string. Verified: PFCP:3:0:7296, and 7296 was a genuine
service-spawned WUDFHost.exe. Safe here alone: nothing reads the virtual
mouse's serial, whereas a pad's is SDL/Steam dedup material.
* pf-gamepad — a HID feature report, and it cost NO report-descriptor change. The
captured descriptors already declare far more Feature ids than the driver
ever served: 0x85 is declared on DualSense, DualShock 4 and Edge alike and
used to fail with STATUS_INVALID_PARAMETER, so hidclass lets it through and
nothing can have depended on the old failure. The Deck's one feature report
is unnumbered and Steam drives it command->response, so its proof rides that
existing contract via a private two-byte command. Verified: feature 0x85
returned magic "PFCP", proto 3, pad_index 0, wudf_pid 18456 — and 18456 was
a WUDFHost — with the product string still 'DualSense Wireless Controller'.
Also renamed pf-dualsense -> pf-gamepad. One driver has always served four identities, so
the old name read as if the other three lived elsewhere. ONLY the package identity moved
(crate, INF/CAT/DLL, UMDF service, build script, CI lines, log file, env var). The four
HARDWARE IDS are deliberately unchanged — they bind every devnode the host creates and
every installed system — as are the Global\pfds-boot-<i> mailbox and PAD_MAGIC, which are
wire contract. `driver install --gamepad` now retires the pre-rename store package first,
matched on pf_dualsense.dll because that string appears only in the OLD inf; matching on
the hardware ids would delete what we are about to install. On .173 that separated 14
stale packages from the 1 new one with 0 ambiguous, and the renamed package binds the old
hwid (devgen root\pf_dualsense -> oem143.inf = pf_gamepad.inf).
The repo's own pre-commit/pre-push rustfmt hooks named the old crate, so they caught the
rename before the commit did — they now check pf-gamepad, and pf-mouse alongside it, which
they had been missing relative to the CI line.
Host and drivers MUST ship together: v2<->v3 fails closed in both directions by design,
with the existing "update host + drivers together" diagnostic.
The rename moved files that also carry the security change, so splitting this into two
commits would mean reconstructing an intermediate state that was never gated. It is one
commit on purpose.
Gated on the windows-amd64 runner with cargo clean first (the box's clock lags, so stale
artifacts would read as a vacuous green): clippy -D warnings clean for pf-inject,
pf-capture and pf-driver-proto, drivers workspace build + the CI clippy line clean,
cargo check --release -p punktfunk-host clean, 19 + 58 tests green. Also fixes pf-mouse
still writing its debug log to world-writable C:\Users\Public, which the 2026-07-17
review moved for the other three drivers and missed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# pf-gamepad - punktfunk virtual gamepads (DualSense / DualShock 4 / DualSense Edge / Steam Deck),
|
||||
# ONE UMDF2 HID minidriver serving all four identities. Named for the role, not for one of them —
|
||||
# it was `pf-dualsense`, which read as if the other three were somewhere else.
|
||||
# A member of the in-tree drivers workspace (shares the vendored wdk-sys/wdk-build with the bindgen pin
|
||||
# + the crt-static .cargo/config), built from source per release like pf-vdisplay.
|
||||
[package]
|
||||
name = "pf-gamepad"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "punktfunk virtual DualSense / DualShock 4 UMDF2 HID minidriver"
|
||||
|
||||
[package.metadata.wdk.driver-model]
|
||||
driver-type = "UMDF"
|
||||
umdf-version-major = 2
|
||||
target-umdf-version-minor = 31
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[build-dependencies]
|
||||
wdk-build.workspace = true
|
||||
|
||||
[dependencies]
|
||||
wdk.workspace = true
|
||||
wdk-sys.workspace = true
|
||||
pf-driver-proto.workspace = true
|
||||
pf-umdf-util.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["hid"]
|
||||
hid = ["wdk-sys/hid"]
|
||||
nightly = ["wdk-sys/nightly", "wdk/nightly"]
|
||||
@@ -0,0 +1,100 @@
|
||||
# pf-gamepad — the virtual-gamepad UMDF2 HID minidriver
|
||||
|
||||
> Renamed from **pf-dualsense** (2026-07-28). One driver has always served four identities —
|
||||
> DualSense, DualShock 4, DualSense Edge and Steam Deck — so the old name read as if the other three
|
||||
> lived somewhere else. Only the PACKAGE identity moved (crate, INF, CAT, DLL, UMDF service); the
|
||||
> four **hardware ids** (`pf_dualsense`, `pf_dualshock4`, `pf_dualsenseedge`, `pf_steamdeck`) are
|
||||
> deliberately unchanged — they bind every devnode the host creates and every installed system.
|
||||
> `driver install --gamepad` retires the pre-rename store package so the two can't both claim them.
|
||||
|
||||
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. It's how the punktfunk Windows host gives a client's
|
||||
DualSense a near-native feel with **no external gamepad dependencies** (no ViGEmBus).
|
||||
|
||||
Shipping: the driver is one member of the in-tree driver workspace
|
||||
([`packaging/windows/drivers/`](../../README.md)), built from source in CI, and bundled +
|
||||
`pnputil`-installed by the Windows host [installer](../../README.md). The host feeds it over a shared
|
||||
memory channel from `crates/punktfunk-host/src/inject/windows/dualsense_windows.rs`. The same UMDF driver also
|
||||
serves the **DualShock 4** identity per a `device_type` byte the host stamps.
|
||||
|
||||
This README captures the driver-authoring lore — the bugs and the signing recipe that make a
|
||||
self-signed UMDF HID driver actually load. The authoritative build/sign/package flow (CI + Inno Setup)
|
||||
lives in the [Windows host packaging README](../../README.md).
|
||||
|
||||
## Build workspace
|
||||
|
||||
This crate builds as a member of the [`packaging/windows/drivers/`](../../drivers) workspace, which
|
||||
uses the published **crates.io `wdk`/`wdk-sys`/`wdk-build`** (0.4/0.5) — not the old dev-box
|
||||
`windows-drivers-rs` path-deps. It's a separate cargo workspace from the main tree because driver
|
||||
crates are cdylibs built with the WDK toolchain on Windows only; it path-deps the shared ABI crate
|
||||
[`crates/pf-driver-proto`](../../../../crates/pf-driver-proto/README.md).
|
||||
|
||||
## Build / sign / install recipe (the one that actually loads)
|
||||
|
||||
Prereqs on the Windows box: **WDK 26100**, **LLVM** (the current default; bindgen 0.72 builds on clang
|
||||
22), Rust MSVC. Built as a member of the `packaging/windows/drivers/` workspace (plain `cargo build`, no
|
||||
cargo-make). 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
|
||||
```
|
||||
|
||||
The shipping flow is `build-gamepad-drivers.ps1` (one level up): workspace `cargo build --release`
|
||||
plus the sign steps below, staged for the installer. The original manual dev-box recipe, kept as
|
||||
lore (paths reflect that era's cargo-make layout):
|
||||
|
||||
```powershell
|
||||
cargo make # -> target\debug\pf_gamepad_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 (third-party VDD prior art, not used by punktfunk) has
|
||||
# this bit OFF. Clear bit 0x80 at PE-header offset +0x5e:
|
||||
$f = 'target\debug\pf_gamepad_package\pf_gamepad.dll'
|
||||
$b = [IO.File]::ReadAllBytes($f); $pe = [BitConverter]::ToInt32($b,0x3c); $off = $pe + 0x5e
|
||||
$dc = [BitConverter]::ToUInt16($b,$off); $bb = [BitConverter]::GetBytes([uint16]($dc -band 0xFF7F))
|
||||
$b[$off]=$bb[0]; $b[$off+1]=$bb[1]; [IO.File]::WriteAllBytes($f,$b)
|
||||
|
||||
signtool sign /fd SHA256 /sha1 <cert-thumbprint> $f
|
||||
Remove-Item target\debug\pf_gamepad_package\pf_gamepad.cat
|
||||
Inf2Cat /driver:target\debug\pf_gamepad_package /os:10_x64
|
||||
signtool sign /fd SHA256 /sha1 <cert-thumbprint> target\debug\pf_gamepad_package\pf_gamepad.cat
|
||||
|
||||
pnputil /add-driver target\debug\pf_gamepad_package\pf_gamepad.inf /install
|
||||
devgen /add /hardwareid "root\pf_dualsense" # creates the (transient, SWD) device node
|
||||
```
|
||||
|
||||
`devgen` (under `Windows Kits\10\Tools\<ver>\x64\`) is only for manual testing — the shipping
|
||||
install is `punktfunk-host.exe driver install --gamepad`, and the host SwDeviceCreate's the device
|
||||
per session (no persistent devnode). SWD devgen devices clear on reboot. 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`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Multi-pad** works via `UmdfHostProcessSharing=ProcessSharingDisabled` — each pad gets its own
|
||||
WUDFHost (so the per-instance statics don't collide), and the driver reads its pad index from the
|
||||
device Location (`WdfDeviceAllocAndQueryProperty`) to poll its own `*-boot-<index>` bootstrap
|
||||
mailbox (the DATA section itself is unnamed — the sealed pad channel,
|
||||
punktfunk-planning: `gamepad-channel-sealing.md` — and its `pad_index` is validated against this
|
||||
index on attach).
|
||||
- Port of the WDK `vhidmini2` UMDF2 sample; the DualSense identity + 273-byte descriptor + feature
|
||||
blobs `0x05`/`0x09`/`0x20` come from `crates/punktfunk-host/src/inject/proto/dualsense_proto.rs`.
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// License: MIT OR Apache-2.0
|
||||
|
||||
//! Build script for the `sample-umdf-driver` crate.
|
||||
//!
|
||||
//! Based on the [`wdk_build::Config`] parsed from the build tree, this build
|
||||
//! script will provide `Cargo` with the necessary information to build the
|
||||
//! driver binary (ex. linker flags)
|
||||
|
||||
fn main() -> Result<(), wdk_build::ConfigError> {
|
||||
wdk_build::configure_wdk_binary_build()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
;/*++
|
||||
; punktfunk virtual gamepads — UMDF2 HID minidriver INF.
|
||||
; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why
|
||||
; the package is called pf_gamepad and not pf_dualsense (it never was one identity).
|
||||
;
|
||||
; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`,
|
||||
; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host
|
||||
; SwDeviceCreate's and with every already-installed system; renaming them would orphan existing
|
||||
; installs and buy nothing. Only the PACKAGE identity (INF/CAT/DLL/service) moved to pf_gamepad.
|
||||
; 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_gamepad.cat
|
||||
PnpLockdown=1
|
||||
|
||||
[DestinationDirs]
|
||||
DefaultDestDir = 13
|
||||
|
||||
[SourceDisksNames]
|
||||
1=%Disk_Description%,,,
|
||||
|
||||
[SourceDisksFiles]
|
||||
pf_gamepad.dll=1
|
||||
|
||||
[Manufacturer]
|
||||
%ManufacturerString%=pf, NT$ARCH$.10.0...22000
|
||||
|
||||
[pf.NT$ARCH$.10.0...22000]
|
||||
; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense`
|
||||
; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so
|
||||
; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck`
|
||||
; for the host's other virtual pads — ONE driver binds all of them (every model line below installs
|
||||
; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the
|
||||
; host stamps into shared memory.
|
||||
;
|
||||
; Each id carries its OWN description: Device Manager reads this string, and a single shared
|
||||
; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been
|
||||
; ignored. The HID layer (VID/PID, report descriptor, product string) was always per-type; this
|
||||
; makes the human-readable name agree with it.
|
||||
%DeviceDesc%=pfGamepad, root\pf_dualsense, pf_dualsense
|
||||
%DeviceDescDS4%=pfGamepad, pf_dualshock4
|
||||
%DeviceDescEdge%=pfGamepad, pf_dualsenseedge
|
||||
%DeviceDescDeck%=pfGamepad, pf_steamdeck
|
||||
|
||||
[pfGamepad.NT]
|
||||
CopyFiles=UMDriverCopy
|
||||
Include=MsHidUmdf.inf
|
||||
Needs=MsHidUmdf.NT
|
||||
Include=WUDFRD.inf
|
||||
Needs=WUDFRD_LowerFilter.NT
|
||||
|
||||
[pfGamepad.NT.hw]
|
||||
Include=MsHidUmdf.inf
|
||||
Needs=MsHidUmdf.NT.hw
|
||||
Include=WUDFRD.inf
|
||||
Needs=WUDFRD_LowerFilter.NT.hw
|
||||
|
||||
[pfGamepad.NT.Services]
|
||||
Include=MsHidUmdf.inf
|
||||
Needs=MsHidUmdf.NT.Services
|
||||
Include=WUDFRD.inf
|
||||
Needs=WUDFRD_LowerFilter.NT.Services
|
||||
|
||||
[pfGamepad.NT.Filters]
|
||||
Include=WUDFRD.inf
|
||||
Needs=WUDFRD_LowerFilter.NT.Filters
|
||||
|
||||
[pfGamepad.NT.Wdf]
|
||||
UmdfService="pf_gamepad", pf_gamepad_Install
|
||||
UmdfServiceOrder=pf_gamepad
|
||||
UmdfKernelModeClientPolicy=AllowKernelModeClients
|
||||
UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects
|
||||
UmdfMethodNeitherAction=Copy
|
||||
UmdfFsContextUsePolicy=CanUseFsContext2
|
||||
; Each pad gets its OWN WUDFHost so the driver's per-pad statics (incl. the shm index) don't collide
|
||||
; across multiple simultaneous controllers (multi-pad).
|
||||
UmdfHostProcessSharing=ProcessSharingDisabled
|
||||
|
||||
[pf_gamepad_Install]
|
||||
UmdfLibraryVersion=$UMDFVERSION$
|
||||
ServiceBinary="%13%\pf_gamepad.dll"
|
||||
|
||||
[UMDriverCopy]
|
||||
pf_gamepad.dll
|
||||
|
||||
[Strings]
|
||||
ProviderString ="punktfunk"
|
||||
ManufacturerString ="punktfunk"
|
||||
ClassName ="HID device"
|
||||
Disk_Description ="punktfunk Gamepad Installation Disk"
|
||||
; One per hardware id — these are what Device Manager shows. Keep them aligned with the product
|
||||
; strings the driver serves per device_type (src/lib.rs `on_get_string`).
|
||||
DeviceDesc ="punktfunk Virtual DualSense"
|
||||
DeviceDescDS4 ="punktfunk Virtual DualShock 4"
|
||||
DeviceDescEdge ="punktfunk Virtual DualSense Edge"
|
||||
DeviceDescDeck ="punktfunk Virtual Steam Deck Controller"
|
||||
@@ -0,0 +1,968 @@
|
||||
// punktfunk virtual DualSense / DualShock 4 / DualSense Edge — UMDF2 HID minidriver.
|
||||
//
|
||||
// A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense
|
||||
// (VID 054C / PID 0CE6), DualShock 4 (device_type=1) or DualSense Edge (device_type=2) using the
|
||||
// report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine
|
||||
// HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back.
|
||||
//
|
||||
// No WDF object contexts: this is a singleton virtual device, so per-device state lives in statics.
|
||||
// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
|
||||
// whole handshake + all shared-memory access lives in `pf_umdf_util` (the audited unsafe layer), so
|
||||
// this crate's channel/HID/IOCTL logic is 100% SAFE Rust. The only `unsafe` here is the unavoidable
|
||||
// WDF setup FFI in DriverEntry/EvtDeviceAdd/the timer, each with a `// SAFETY:` proof.
|
||||
|
||||
#![allow(non_snake_case, non_upper_case_globals, clippy::missing_safety_doc)]
|
||||
// Every remaining `unsafe {}` (all WDF setup FFI) must carry a `// SAFETY:` proof.
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering};
|
||||
|
||||
use pf_driver_proto::gamepad::PadShm;
|
||||
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
||||
use pf_umdf_util::wdf::{self, Request};
|
||||
use wdk_sys::{
|
||||
NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
||||
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDF_OBJECT_ATTRIBUTES,
|
||||
WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER,
|
||||
call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
||||
};
|
||||
|
||||
// ---- NTSTATUS values ----
|
||||
const STATUS_SUCCESS: NTSTATUS = 0;
|
||||
const STATUS_NOT_IMPLEMENTED: NTSTATUS = 0xC000_0002u32 as NTSTATUS;
|
||||
const STATUS_INVALID_PARAMETER: NTSTATUS = 0xC000_000Du32 as NTSTATUS;
|
||||
|
||||
use pf_umdf_util::nt_success;
|
||||
|
||||
// ---- 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_HID_GET_STRING: u32 = hid_ctl(4);
|
||||
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);
|
||||
|
||||
// ---- 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;
|
||||
/// DualShock 4 v2 product id — served (same VID/version) when the host stamps device_type=1.
|
||||
const DS4_PID: u16 = 0x09CC;
|
||||
/// DualSense Edge product id — served (same VID/version) when the host stamps device_type=2.
|
||||
const DS_EDGE_PID: u16 = 0x0DF2;
|
||||
/// **N4 spike** (gamepad-new-types §6): the Steam Deck controller identity (Valve 28DE:1205),
|
||||
/// served when the host stamps device_type=3. Exists ONLY to answer the go/no-go question "does
|
||||
/// Steam Input on Windows promote a software-devnode HID Deck?" — the host never stamps 3
|
||||
/// outside the `deck-windows-spike` subcommand.
|
||||
const DECK_VID: u16 = 0x28DE;
|
||||
const DECK_PID: u16 = 0x1205;
|
||||
|
||||
// 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; 41] = [ // 0x05 motion calibration: 1 id + 40 data (descriptor declares feature 0x05 as 0x95 0x28 = 40)
|
||||
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,
|
||||
];
|
||||
#[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,
|
||||
];
|
||||
|
||||
// ---- DualShock 4 v2 assets (served when the host stamps device_type=1) ----
|
||||
// Sony DualShock 4 v2 USB HID report descriptor (507 bytes), verbatim from inject/dualshock4.rs.
|
||||
#[rustfmt::skip]
|
||||
static DS4_RDESC: [u8; 507] = [
|
||||
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
|
||||
0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
|
||||
0x04, 0x81, 0x02, 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, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
|
||||
0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
|
||||
0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
|
||||
0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
|
||||
0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
|
||||
0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
|
||||
0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x08, 0x09,
|
||||
0x25, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x10, 0x09, 0x26, 0x95, 0x04, 0xB1,
|
||||
0x02, 0x85, 0x11, 0x09, 0x27, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x12, 0x06,
|
||||
0x02, 0xFF, 0x09, 0x21, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0x13, 0x09, 0x22,
|
||||
0x95, 0x16, 0xB1, 0x02, 0x85, 0x14, 0x06, 0x05, 0xFF, 0x09, 0x20, 0x95,
|
||||
0x10, 0xB1, 0x02, 0x85, 0x15, 0x09, 0x21, 0x95, 0x2C, 0xB1, 0x02, 0x06,
|
||||
0x80, 0xFF, 0x85, 0x80, 0x09, 0x20, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x81,
|
||||
0x09, 0x21, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x22, 0x95, 0x05,
|
||||
0xB1, 0x02, 0x85, 0x83, 0x09, 0x23, 0x95, 0x01, 0xB1, 0x02, 0x85, 0x84,
|
||||
0x09, 0x24, 0x95, 0x04, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x25, 0x95, 0x06,
|
||||
0xB1, 0x02, 0x85, 0x86, 0x09, 0x26, 0x95, 0x06, 0xB1, 0x02, 0x85, 0x87,
|
||||
0x09, 0x27, 0x95, 0x23, 0xB1, 0x02, 0x85, 0x88, 0x09, 0x28, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0x89, 0x09, 0x29, 0x95, 0x02, 0xB1, 0x02, 0x85, 0x90,
|
||||
0x09, 0x30, 0x95, 0x05, 0xB1, 0x02, 0x85, 0x91, 0x09, 0x31, 0x95, 0x03,
|
||||
0xB1, 0x02, 0x85, 0x92, 0x09, 0x32, 0x95, 0x03, 0xB1, 0x02, 0x85, 0x93,
|
||||
0x09, 0x33, 0x95, 0x0C, 0xB1, 0x02, 0x85, 0x94, 0x09, 0x34, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xA0, 0x09, 0x40, 0x95, 0x06, 0xB1, 0x02, 0x85, 0xA1,
|
||||
0x09, 0x41, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA2, 0x09, 0x42, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA3, 0x09, 0x43, 0x95, 0x30, 0xB1, 0x02, 0x85, 0xA4,
|
||||
0x09, 0x44, 0x95, 0x0D, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x47, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xF1, 0x09, 0x48, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2,
|
||||
0x09, 0x49, 0x95, 0x0F, 0xB1, 0x02, 0x85, 0xA7, 0x09, 0x4A, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xA8, 0x09, 0x4B, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xA9,
|
||||
0x09, 0x4C, 0x95, 0x08, 0xB1, 0x02, 0x85, 0xAA, 0x09, 0x4E, 0x95, 0x01,
|
||||
0xB1, 0x02, 0x85, 0xAB, 0x09, 0x4F, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAC,
|
||||
0x09, 0x50, 0x95, 0x39, 0xB1, 0x02, 0x85, 0xAD, 0x09, 0x51, 0x95, 0x0B,
|
||||
0xB1, 0x02, 0x85, 0xAE, 0x09, 0x52, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xAF,
|
||||
0x09, 0x53, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB0, 0x09, 0x54, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xE0, 0x09, 0x57, 0x95, 0x02, 0xB1, 0x02, 0x85, 0xB3,
|
||||
0x09, 0x55, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xB4, 0x09, 0x55, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0x85, 0xB5, 0x09, 0x56, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD0,
|
||||
0x09, 0x58, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xD4, 0x09, 0x59, 0x95, 0x3F,
|
||||
0xB1, 0x02, 0xC0,
|
||||
];
|
||||
// DS4 feature reports games read during init (each array's first byte is the report id).
|
||||
#[rustfmt::skip]
|
||||
static DS4_FEATURE_PAIRING: [u8; 16] = [ // 0x12 pairing info (MAC at bytes 1..7)
|
||||
0x12, 0x01, 0x00, 0xEF, 0xBE, 0xAD, 0xDE, 0x08, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
static DS4_FEATURE_CALIBRATION: [u8; 37] = [ // 0x02 IMU calibration
|
||||
0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xF0, 0xFF, 0x10, 0x00, 0xF0, 0xFF, 0x10,
|
||||
0x00, 0xF0, 0xFF, 0x20, 0x00, 0x20, 0x00, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x20, 0x00, 0xE0, 0x00,
|
||||
0x20, 0x00, 0xE0, 0x00, 0x00,
|
||||
];
|
||||
#[rustfmt::skip]
|
||||
static DS4_FEATURE_FIRMWARE: [u8; 49] = [ // 0xa3 firmware/build info
|
||||
0xA3, 0x41, 0x75, 0x67, 0x20, 0x20, 0x33, 0x20, 0x32, 0x30, 0x31, 0x33, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x30, 0x37, 0x3A, 0x30, 0x31, 0x3A, 0x31, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0xA0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
];
|
||||
|
||||
// ---- DualSense Edge assets (served when the host stamps device_type=2) ----
|
||||
// Sony DualSense Edge USB HID report descriptor (389 bytes), verbatim from
|
||||
// inject/proto/dualsense_proto.rs (a real-device capture; see the provenance note there). Input
|
||||
// report 0x01 is bit-identical to the plain DualSense — the Edge's Fn/back buttons ride reserved
|
||||
// bits of buttons[2]; output report 0x02 grows to 63 bytes and 19 profile feature reports are added.
|
||||
#[rustfmt::skip]
|
||||
static DS_EDGE_RDESC: [u8; 389] = [
|
||||
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, 0x3F, 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, 0x34, 0xB1, 0x02,
|
||||
0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02,
|
||||
0x85, 0x60, 0x09, 0x41, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x61, 0x09, 0x42, 0xB1, 0x02, 0x85, 0x62,
|
||||
0x09, 0x43, 0xB1, 0x02, 0x85, 0x63, 0x09, 0x44, 0xB1, 0x02, 0x85, 0x64, 0x09, 0x45, 0xB1, 0x02,
|
||||
0x85, 0x65, 0x09, 0x46, 0xB1, 0x02, 0x85, 0x68, 0x09, 0x47, 0xB1, 0x02, 0x85, 0x70, 0x09, 0x48,
|
||||
0xB1, 0x02, 0x85, 0x71, 0x09, 0x49, 0xB1, 0x02, 0x85, 0x72, 0x09, 0x4A, 0xB1, 0x02, 0x85, 0x73,
|
||||
0x09, 0x4B, 0xB1, 0x02, 0x85, 0x74, 0x09, 0x4C, 0xB1, 0x02, 0x85, 0x75, 0x09, 0x4D, 0xB1, 0x02,
|
||||
0x85, 0x76, 0x09, 0x4E, 0xB1, 0x02, 0x85, 0x77, 0x09, 0x4F, 0xB1, 0x02, 0x85, 0x78, 0x09, 0x50,
|
||||
0xB1, 0x02, 0x85, 0x79, 0x09, 0x51, 0xB1, 0x02, 0x85, 0x7A, 0x09, 0x52, 0xB1, 0x02, 0x85, 0x7B,
|
||||
0x09, 0x53, 0xB1, 0x02, 0xC0,
|
||||
];
|
||||
|
||||
// ---- N4-spike Steam Deck assets (served when the host stamps device_type=3) ----
|
||||
// The Deck's captured CONTROLLER-interface report descriptor (38 bytes, interface 2 of a real
|
||||
// 28DE:1205 — verbatim from inject/proto/steam_proto.rs RDESC_DECK_CTRL): one vendor-defined
|
||||
// (page 0xFFFF) collection with a 64-byte input + 64-byte feature report.
|
||||
#[rustfmt::skip]
|
||||
static DECK_RDESC: [u8; 38] = [
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00, 0x26, 0xff, 0x00,
|
||||
0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07, 0x15, 0x00, 0x26, 0xff, 0x00, 0x75,
|
||||
0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
];
|
||||
|
||||
// HID descriptor (9 bytes, packed): len, type=0x21, bcdHID=0x0100, country=0, numDesc=1, then
|
||||
// {reportType=0x22, wReportLength}. DualSense = 273 (0x0111); DualShock 4 = 507 (0x01FB);
|
||||
// DualSense Edge = 389 (0x0185).
|
||||
static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01];
|
||||
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
|
||||
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
|
||||
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
|
||||
|
||||
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||
// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
|
||||
fn hid_attrs(devtype: u8) -> [u8; 32] {
|
||||
let (vid, pid) = match devtype {
|
||||
1 => (DS_VID, DS4_PID),
|
||||
2 => (DS_VID, DS_EDGE_PID),
|
||||
3 => (DECK_VID, DECK_PID),
|
||||
_ => (DS_VID, DS_PID),
|
||||
};
|
||||
let mut a = [0u8; 32];
|
||||
a[0..4].copy_from_slice(&32u32.to_le_bytes());
|
||||
a[4..6].copy_from_slice(&vid.to_le_bytes());
|
||||
a[6..8].copy_from_slice(&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
|
||||
};
|
||||
// Neutral DualShock 4 input report 0x01: sticks centered (0x80); the dpad hat is in byte 5 (low
|
||||
// nibble), so a neutral hat (8) lands there instead of byte 8.
|
||||
const DS4_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] = 0x08; // buttons[0]: low nibble = dpad hat (8 = neutral), high nibble = face buttons (0)
|
||||
r
|
||||
};
|
||||
// Neutral Steam Deck input frame (unnumbered): header [0x01, 0x00, ID_CONTROLLER_DECK_STATE=0x09,
|
||||
// payload-len 0x3C], everything released.
|
||||
const DECK_NEUTRAL_REPORT: [u8; 64] = {
|
||||
let mut r = [0u8; 64];
|
||||
r[0] = 0x01;
|
||||
r[2] = 0x09;
|
||||
r[3] = 0x3C;
|
||||
r
|
||||
};
|
||||
fn neutral_report(devtype: u8) -> [u8; 64] {
|
||||
match devtype {
|
||||
1 => DS4_NEUTRAL_REPORT,
|
||||
3 => DECK_NEUTRAL_REPORT,
|
||||
_ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape
|
||||
}
|
||||
}
|
||||
|
||||
static MANUAL_QUEUE: AtomicPtr<WDFQUEUE__> = AtomicPtr::new(core::ptr::null_mut());
|
||||
/// The latest input report the host pushed (report `0x01`) via shared memory; the timer delivers 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);
|
||||
|
||||
// ---- the sealed pad channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
||||
// 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 DATA section (`PadShm`, 256 B — input report @8, output seq @72, output
|
||||
// report @76, device_type @140, health marks @144/@148, pad_index @152) is UNNAMED and reached only
|
||||
// through a handle the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named mailbox
|
||||
// `Global\pfds-boot-<index>`. The handshake + all shared-memory access live in `pf_umdf_util`.
|
||||
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::PAD_MAGIC; // "PFDS"
|
||||
const SHM_SIZE: usize = core::mem::size_of::<PadShm>();
|
||||
const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
|
||||
|
||||
// PadShm field offsets (the driver reads input + device_type, writes output + health marks).
|
||||
const OFF_INPUT: usize = core::mem::offset_of!(PadShm, input);
|
||||
const OFF_OUT_SEQ: usize = core::mem::offset_of!(PadShm, out_seq);
|
||||
const OFF_OUTPUT: usize = core::mem::offset_of!(PadShm, output);
|
||||
const OFF_DEVICE_TYPE: usize = core::mem::offset_of!(PadShm, device_type);
|
||||
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(PadShm, driver_proto);
|
||||
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(PadShm, driver_heartbeat);
|
||||
const OFF_PAD_INDEX: usize = core::mem::offset_of!(PadShm, pad_index);
|
||||
// v2.1 output-report ring (see PadShm docs in pf_driver_proto).
|
||||
const OFF_OUT_RING_VER: usize = core::mem::offset_of!(PadShm, out_ring_ver);
|
||||
const OFF_RING_HEAD: usize = core::mem::offset_of!(PadShm, ring_head);
|
||||
const OFF_OUT_RING: usize = core::mem::offset_of!(PadShm, out_ring);
|
||||
const OUT_SLOT_SIZE: usize = core::mem::size_of::<pf_driver_proto::gamepad::OutSlot>();
|
||||
const OUT_RING_LEN: u32 = pf_driver_proto::gamepad::OUT_RING_LEN;
|
||||
|
||||
/// Publish one game output report to the host: the legacy latest-report slot + `out_seq` bump
|
||||
/// (every host generation reads this), and — when the host stamped `out_ring_ver` (it created the
|
||||
/// ring region) and our view maps it — the lossless report ring: slot bytes first, `ring_head`
|
||||
/// bump last, the same publish-then-bump store order the host's Acquire load pairs with on the
|
||||
/// legacy `out_seq`. The ring is what stops a rumble-STOP report from being coalesced away by a
|
||||
/// following LED/trigger report inside one host poll window (the confirmed stuck-rumble path).
|
||||
fn publish_output(view: &pf_umdf_util::section::MappedView, bytes: &[u8]) {
|
||||
view.write_bytes(OFF_OUTPUT, bytes);
|
||||
let seq = view.read_u32(OFF_OUT_SEQ).wrapping_add(1);
|
||||
view.write_u32(OFF_OUT_SEQ, seq);
|
||||
if view.mapped_len() >= SHM_SIZE && view.read_u32(OFF_OUT_RING_VER) != 0 {
|
||||
let head = view.read_u32(OFF_RING_HEAD);
|
||||
let slot = OFF_OUT_RING + (head % OUT_RING_LEN) as usize * OUT_SLOT_SIZE;
|
||||
let n = bytes.len().min(64);
|
||||
view.write_u32(slot, n as u32);
|
||||
view.write_bytes(slot + 4, &bytes[..n]);
|
||||
view.write_u32(OFF_RING_HEAD, head.wrapping_add(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
||||
/// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`.
|
||||
static CHANNEL: ChannelClient = ChannelClient::new();
|
||||
/// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge) — the
|
||||
/// neutral-report shape when
|
||||
/// the channel detaches, and the fallback identity while unattached.
|
||||
static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0);
|
||||
/// device_type()'s bounded first-read wait fires at most once (see its docs).
|
||||
static DEVTYPE_WAITED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// This pad's channel config (magic/size/pad_index offset + our logger).
|
||||
fn channel_cfg() -> ChannelConfig {
|
||||
ChannelConfig {
|
||||
tag: "pf-gamepad",
|
||||
boot_name_prefix: "Global\\pfds-boot-",
|
||||
data_magic: SHM_MAGIC,
|
||||
data_size: SHM_SIZE,
|
||||
// The v2.1 layout grew by tail extension (the output-report ring); against an old host's
|
||||
// 256-byte section the full-size map still succeeds (sections are page-granular), but if
|
||||
// it is ever refused, mapping the legacy size keeps the pad alive with the ring disabled.
|
||||
min_data_size: pf_driver_proto::gamepad::PAD_SHM_LEGACY_SIZE,
|
||||
pad_index_off: OFF_PAD_INDEX,
|
||||
log,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire pad index the host stamped into the sealed section (0 while the channel hasn't
|
||||
/// attached yet). Keys every per-pad identity surface: the Deck unit id + serial, the PS
|
||||
/// identities' pairing MAC (feature 0x09/0x12) and USB serial string — SDL/Steam dedup
|
||||
/// controllers by serial, so two virtual pads must never share one (identical serials make a
|
||||
/// second pad read as the FIRST one re-appearing over another transport, and it is merged).
|
||||
fn pad_index() -> u8 {
|
||||
(CHANNEL
|
||||
.data()
|
||||
.map(|v| v.read_u32(OFF_PAD_INDEX))
|
||||
.unwrap_or(0)
|
||||
& 0xFF) as u8
|
||||
}
|
||||
|
||||
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
|
||||
/// or the `PFGAMEPAD_DEBUG_LOG` (system-wide) env var — the same treatment pf-vdisplay got in audit
|
||||
/// §4.4: a RELEASE driver never writes the Public file (info-leak/DoS surface), and the per-report
|
||||
/// OUTPUT hex dumps stop being a sustained disk-write path during gameplay. DebugView can't see the
|
||||
/// UMDF host across session 0, so the file stays the bring-up diagnostic when enabled.
|
||||
fn file_log_enabled() -> bool {
|
||||
use std::sync::OnceLock;
|
||||
static ON: OnceLock<bool> = OnceLock::new();
|
||||
*ON.get_or_init(|| cfg!(debug_assertions) || std::env::var_os("PFGAMEPAD_DEBUG_LOG").is_some())
|
||||
}
|
||||
|
||||
/// Process-lifetime append handle to the bring-up log, opened ONCE and shared via a `Mutex`
|
||||
/// (pf-vdisplay's pattern) — no per-line open/close.
|
||||
fn file_appender() -> Option<&'static std::sync::Mutex<std::fs::File>> {
|
||||
use std::sync::OnceLock;
|
||||
static APPENDER: OnceLock<Option<std::sync::Mutex<std::fs::File>>> = OnceLock::new();
|
||||
APPENDER
|
||||
.get_or_init(|| {
|
||||
if !file_log_enabled() {
|
||||
return None;
|
||||
}
|
||||
// WUDFHost's own (LocalService) temp dir — NOT world-writable/readable `C:\Users\Public`,
|
||||
// where the OUTPUT/feature-report hex dumps could leak per-pad identity/serial material to
|
||||
// any local reader (security-review 2026-07-17). Opt-in/debug only.
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(std::env::temp_dir().join("pf_gamepad-driver.log"))
|
||||
.ok()
|
||||
.map(std::sync::Mutex::new)
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
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()) };
|
||||
}
|
||||
use std::io::Write;
|
||||
if let Some(m) = file_appender()
|
||||
&& let Ok(mut f) = m.lock()
|
||||
{
|
||||
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-gamepad] DriverEntry");
|
||||
// SAFETY: zeroed WDF_DRIVER_CONFIG is a valid all-null config; we then set Size + the callback.
|
||||
let mut config: WDF_DRIVER_CONFIG = unsafe { core::mem::zeroed() };
|
||||
config.Size = core::mem::size_of::<WDF_DRIVER_CONFIG>() as ULONG;
|
||||
config.EvtDriverDeviceAdd = Some(evt_device_add);
|
||||
|
||||
// SAFETY: all pointers valid; driver/registry_path provided by the loader.
|
||||
unsafe {
|
||||
call_unsafe_wdf_function_binding!(
|
||||
WdfDriverCreate,
|
||||
driver,
|
||||
registry_path,
|
||||
WDF_NO_OBJECT_ATTRIBUTES,
|
||||
&mut config,
|
||||
WDF_NO_HANDLE.cast::<WDFDRIVER>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INIT) -> NTSTATUS {
|
||||
log("[pf-gamepad] 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-gamepad] WdfDeviceCreate failed 0x{:08x}", st as u32);
|
||||
return st;
|
||||
}
|
||||
|
||||
// SAFETY: `device` is the live device just created — the exact contract this fn requires.
|
||||
let shm_idx = unsafe { wdf::query_location_index(device) };
|
||||
CHANNEL.set_index(shm_idx);
|
||||
dbglog!("[pf-gamepad] shm index = {shm_idx}");
|
||||
|
||||
// Default parallel queue handling all IOCTLs.
|
||||
// SAFETY: zeroed config then fields set; Size matches the struct.
|
||||
let mut qcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||
qcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||
qcfg.DispatchType = WdfIoQueueDispatchParallel;
|
||||
qcfg.PowerManaged = WdfUseDefault;
|
||||
qcfg.DefaultQueue = 1;
|
||||
qcfg.EvtIoDeviceControl = Some(evt_io_device_control);
|
||||
// WDF_IO_QUEUE_CONFIG_INIT sets this to (ULONG)-1 (unlimited); mem::zeroed left it 0,
|
||||
// which on a parallel queue means present ZERO requests → EvtIoDeviceControl never fires.
|
||||
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
||||
let mut default_queue: WDFQUEUE = core::ptr::null_mut();
|
||||
// SAFETY: device + config valid; attributes null; queue receives the handle.
|
||||
let st = unsafe {
|
||||
call_unsafe_wdf_function_binding!(
|
||||
WdfIoQueueCreate,
|
||||
device,
|
||||
&mut qcfg,
|
||||
WDF_NO_OBJECT_ATTRIBUTES,
|
||||
&mut default_queue
|
||||
)
|
||||
};
|
||||
if !nt_success(st) {
|
||||
dbglog!(
|
||||
"[pf-gamepad] default WdfIoQueueCreate failed 0x{:08x}",
|
||||
st as u32
|
||||
);
|
||||
return st;
|
||||
}
|
||||
|
||||
// Manual queue: pended READ_REPORT requests are completed by the timer.
|
||||
// SAFETY: zeroed config then fields set.
|
||||
let mut mcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() };
|
||||
mcfg.Size = core::mem::size_of::<WDF_IO_QUEUE_CONFIG>() as ULONG;
|
||||
mcfg.DispatchType = WdfIoQueueDispatchManual;
|
||||
mcfg.PowerManaged = WdfUseDefault;
|
||||
let mut manual_queue: WDFQUEUE = core::ptr::null_mut();
|
||||
// SAFETY: device + config valid; attributes null; queue receives the handle.
|
||||
let st = unsafe {
|
||||
call_unsafe_wdf_function_binding!(
|
||||
WdfIoQueueCreate,
|
||||
device,
|
||||
&mut mcfg,
|
||||
WDF_NO_OBJECT_ATTRIBUTES,
|
||||
&mut manual_queue
|
||||
)
|
||||
};
|
||||
if !nt_success(st) {
|
||||
dbglog!(
|
||||
"[pf-gamepad] manual WdfIoQueueCreate failed 0x{:08x}",
|
||||
st as u32
|
||||
);
|
||||
return st;
|
||||
}
|
||||
MANUAL_QUEUE.store(manual_queue, Ordering::SeqCst);
|
||||
|
||||
// Periodic timer (parent = manual queue) completes pended reads with the neutral report.
|
||||
// SAFETY: zeroed config then fields set.
|
||||
let mut tcfg: WDF_TIMER_CONFIG = unsafe { core::mem::zeroed() };
|
||||
tcfg.Size = core::mem::size_of::<WDF_TIMER_CONFIG>() as ULONG;
|
||||
tcfg.EvtTimerFunc = Some(evt_timer);
|
||||
tcfg.Period = 8; // ms
|
||||
tcfg.AutomaticSerialization = 1; // TRUE — UMDF requires a serialized timer (vhidmini2 pattern)
|
||||
// SAFETY: a zeroed WDF_OBJECT_ATTRIBUTES is a valid all-null attributes struct; we set Size + the
|
||||
// fields we use below.
|
||||
let mut tattr: WDF_OBJECT_ATTRIBUTES = unsafe { core::mem::zeroed() };
|
||||
tattr.Size = core::mem::size_of::<WDF_OBJECT_ATTRIBUTES>() as ULONG;
|
||||
tattr.ParentObject = manual_queue.cast();
|
||||
// mem::zeroed leaves these at 0 (Invalid) → set them like WDF_OBJECT_ATTRIBUTES_INIT
|
||||
// (matches the working vhidmini2 UMDF timer setup; avoids 0xc0200209 / 0xc00000bb).
|
||||
tattr.ExecutionLevel = WdfExecutionLevelInheritFromParent;
|
||||
tattr.SynchronizationScope = WdfSynchronizationScopeInheritFromParent;
|
||||
let mut timer: WDFTIMER = core::ptr::null_mut();
|
||||
// SAFETY: config + attributes valid; timer receives the handle.
|
||||
let st = unsafe {
|
||||
call_unsafe_wdf_function_binding!(WdfTimerCreate, &mut tcfg, &mut tattr, &mut timer)
|
||||
};
|
||||
if !nt_success(st) {
|
||||
dbglog!("[pf-gamepad] 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) };
|
||||
|
||||
log("[pf-gamepad] 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,
|
||||
) {
|
||||
// SAFETY: `request` is the live request for THIS EvtIoDeviceControl invocation — exactly the
|
||||
// contract `Request::new` requires. Everything after is safe (the token owns completion).
|
||||
let request = unsafe { Request::new(request) };
|
||||
|
||||
// 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 ioctl != IOCTL_HID_READ_REPORT {
|
||||
dbglog!("[pf-gamepad] ioctl 0x{ioctl:08x} out={_output_len} in={_input_len}");
|
||||
}
|
||||
|
||||
// READ_REPORT forwards to the manual queue (the timer completes it) — this CONSUMES the request
|
||||
// token, so it's handled apart from the status-and-complete paths below.
|
||||
if ioctl == IOCTL_HID_READ_REPORT {
|
||||
let mq: WDFQUEUE = MANUAL_QUEUE.load(Ordering::SeqCst);
|
||||
// SAFETY: `mq` is the manual queue created in EvtDeviceAdd (a live WDFQUEUE of this device).
|
||||
match unsafe { request.forward_to_queue(mq) } {
|
||||
Ok(()) => {} // framework owns it now (completed by the timer)
|
||||
Err((req, st)) => req.complete(st), // forward failed → complete with the error
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let status: NTSTATUS = match ioctl {
|
||||
IOCTL_HID_GET_DEVICE_DESCRIPTOR => request.copy_to_output(match device_type() {
|
||||
1 => &DS4_HID_DESC,
|
||||
2 => &EDGE_HID_DESC,
|
||||
3 => &DECK_HID_DESC,
|
||||
_ => &HID_DESC,
|
||||
}),
|
||||
IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())),
|
||||
IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() {
|
||||
1 => &DS4_RDESC[..],
|
||||
2 => &DS_EDGE_RDESC[..],
|
||||
3 => &DECK_RDESC[..],
|
||||
_ => &DUALSENSE_RDESC[..],
|
||||
}),
|
||||
IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => {
|
||||
on_output_report(&request, ioctl)
|
||||
}
|
||||
IOCTL_UMDF_HID_SET_FEATURE => on_set_feature(&request),
|
||||
IOCTL_UMDF_HID_GET_FEATURE => on_get_feature(&request),
|
||||
IOCTL_UMDF_HID_GET_INPUT_REPORT => request.copy_to_output(&neutral_report(device_type())),
|
||||
IOCTL_HID_GET_STRING => on_get_string(&request),
|
||||
// The channel proof (see `pf_umdf_util::hid`): the host asks THIS devnode which process
|
||||
// serves it, and duplicates the DATA section into the answer — so it never has to trust the
|
||||
// LocalService-writable bootstrap mailbox to name its target.
|
||||
_ => STATUS_NOT_IMPLEMENTED,
|
||||
};
|
||||
|
||||
dbglog!(
|
||||
"[pf-gamepad] ioctl 0x{ioctl:08x} -> 0x{:08x}",
|
||||
status as u32
|
||||
);
|
||||
request.complete(status);
|
||||
}
|
||||
|
||||
// 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, then publish it to the DATA section for the host.
|
||||
fn on_output_report(request: &Request, ioctl: ULONG) -> NTSTATUS {
|
||||
let (bytes, inlen) = match request.input_bytes(64) {
|
||||
Ok(v) => v,
|
||||
Err(st) => return st,
|
||||
};
|
||||
let report_id = request.output_buffer_len() as u32; // report id, UMDF convention
|
||||
|
||||
let mut hex = String::new();
|
||||
for b in bytes.iter().take(48) {
|
||||
hex.push_str(&format!("{b:02x} "));
|
||||
}
|
||||
let kind = if ioctl == IOCTL_HID_WRITE_REPORT {
|
||||
"WRITE_REPORT"
|
||||
} else {
|
||||
"SET_OUTPUT_REPORT"
|
||||
};
|
||||
dbglog!("[pf-gamepad] *** OUTPUT {kind} reportId={report_id} len={inlen} data: {hex}");
|
||||
|
||||
// Publish the game's 0x02 output report to the sealed DATA section for the host (rumble /
|
||||
// lightbar / player-LEDs / adaptive triggers): legacy slot + seq, plus the v2.1 ring.
|
||||
if !bytes.is_empty()
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
publish_output(view, &bytes);
|
||||
}
|
||||
|
||||
request.set_information(inlen as u64);
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// Deck identity: the last SET_FEATURE payload (the Steam command byte + args, minus the
|
||||
/// report-id prefix). Steam's Deck contract is command-in-SET_FEATURE → answer-in-GET_FEATURE
|
||||
/// on the one unnumbered feature report; the PS identities ignore this (their SET_FEATUREs are
|
||||
/// fire-and-forget) — acking them is all they need.
|
||||
static LAST_SET_FEATURE: std::sync::Mutex<[u8; 64]> = std::sync::Mutex::new([0; 64]);
|
||||
|
||||
// SET_FEATURE: ack (the PS identities' contract), latch the payload for the Deck's GET_FEATURE
|
||||
// answer, and — the Deck feedback path — publish Steam's rumble/haptic commands to the host.
|
||||
// Per the UMDF marshalling convention the report data is the input buffer.
|
||||
fn on_set_feature(request: &Request) -> NTSTATUS {
|
||||
if let Ok((bytes, _)) = request.input_bytes(64) {
|
||||
// The wire carries [report-id 0, cmd, …] for the unnumbered Steam report; store the
|
||||
// command-first view. (PS set-features carry their own report id first — harmless.)
|
||||
let src: &[u8] = if bytes.first() == Some(&0x00) && bytes.len() > 1 {
|
||||
&bytes[1..]
|
||||
} else {
|
||||
&bytes
|
||||
};
|
||||
if let Ok(mut g) = LAST_SET_FEATURE.lock() {
|
||||
g.fill(0);
|
||||
let n = src.len().min(64);
|
||||
g[..n].copy_from_slice(&src[..n]);
|
||||
}
|
||||
// Deck feedback: Steam drives rumble (0xEB) and trackpad haptic pulses (0x8F) via
|
||||
// SET_FEATURE on the unnumbered report — the PS identities get theirs as OUTPUT
|
||||
// reports instead. Publish them to the host through the same output slot + seq the
|
||||
// output path uses, re-prefixed with the report-id 0 byte so the host's
|
||||
// `parse_steam_output` sees the exact wire shape the Linux UHID path delivers.
|
||||
if device_type() == 3
|
||||
&& matches!(src.first(), Some(&0xEB) | Some(&0x8F))
|
||||
&& let Some(view) = CHANNEL.data()
|
||||
{
|
||||
let mut out = [0u8; 64];
|
||||
let n = src.len().min(63);
|
||||
out[1..1 + n].copy_from_slice(&src[..n]);
|
||||
publish_output(view, &out);
|
||||
}
|
||||
}
|
||||
dbglog!("[pf-gamepad] SET_FEATURE (acked, latched for GET)");
|
||||
STATUS_SUCCESS
|
||||
}
|
||||
|
||||
/// Deck identity: build the GET_FEATURE reply from the latched SET_FEATURE command — the
|
||||
/// 0x83 GET_ATTRIBUTES 9-attribute blob (unit id keyed per pad) or the 0xAE unit serial, both
|
||||
/// captured from a physical Deck (see inject/proto/steam_proto.rs feature_reply, the source of
|
||||
/// truth this mirrors). Anything else echoes the latched command.
|
||||
fn deck_feature_reply() -> [u8; 64] {
|
||||
let last = LAST_SET_FEATURE.lock().map(|g| *g).unwrap_or([0u8; 64]);
|
||||
// Per-pad unit id "PF" + the pad index the host stamped into the section — matches
|
||||
// steam_proto::deck_unit_id / deck_serial, so two virtual Decks never collide in Steam's eyes.
|
||||
let unit_id: u32 = 0x5046_0000 | pad_index() as u32;
|
||||
// Steam validates the unit serial's PREFIX before accepting it: a "PF"-leading serial is
|
||||
// REJECTED ("Invalid or missing unit serial number …") and Steam then substitutes a hash and
|
||||
// MANGLES the displayed name ("Steam Deck Controllerggg"). An 'F'-leading serial passes, so we
|
||||
// keep our PunktFunk marker one slot in ("FVPF") — still distinct enough for the Linux side's
|
||||
// physical-Deck self-detection while satisfying Steam's format check. (This, not the build-time
|
||||
// attributes below, is what un-mangles the name — verified by A/B on .173.)
|
||||
let unit_serial = format!("FVPF{unit_id:08X}");
|
||||
let unit_serial = unit_serial.as_bytes();
|
||||
let mut r = [0u8; 64];
|
||||
// The CHANNEL PROOF, Deck flavour: the Deck's ONE feature report is unnumbered and Steam drives
|
||||
// it as command→response, so the proof rides that same contract instead of a new report id (no
|
||||
// descriptor change). Two command bytes, so a Steam command we haven't catalogued cannot collide.
|
||||
if last.starts_with(&pf_driver_proto::gamepad::DECK_PROOF_CMD) {
|
||||
let proof =
|
||||
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
|
||||
r[..2].copy_from_slice(&pf_driver_proto::gamepad::DECK_PROOF_CMD);
|
||||
r[2..18].copy_from_slice(&proof.to_bytes());
|
||||
return r;
|
||||
}
|
||||
match last[0] {
|
||||
0x83 => {
|
||||
// GET_ATTRIBUTES_VALUES: [0x83, 0x2d, then 9x (attr-id, value u32-LE)].
|
||||
r[0] = 0x83;
|
||||
r[1] = 0x2D;
|
||||
// Attribute semantics per SDL's controller_constants.h: 0x04 = FIRMWARE_BUILD_TIME
|
||||
// and 0x0A = BOOTLOADER_BUILD_TIME are unix timestamps that must look like real build
|
||||
// dates (the old unit-id-derived junk here was cosmetic; the name mangling was the
|
||||
// serial prefix). Uniqueness rides the serial.
|
||||
let attrs: [(u8, u32); 9] = [
|
||||
(0x01, 0x1205), // ATTRIB_PRODUCT_ID
|
||||
(0x02, 0), // ATTRIB_CAPABILITIES
|
||||
(0x0A, 0x6408_9000), // ATTRIB_BOOTLOADER_BUILD_TIME (2023-03-08)
|
||||
(0x04, 0x66A8_C000), // ATTRIB_FIRMWARE_BUILD_TIME (2024-07-30)
|
||||
(0x09, 0x2E), // ATTRIB_BOARD_REVISION (captured)
|
||||
(0x0B, 0x0FA0), // ATTRIB_CONNECTION_INTERVAL_IN_US (4 ms)
|
||||
(0x0D, 0),
|
||||
(0x0C, 0),
|
||||
(0x0E, 0),
|
||||
];
|
||||
let mut o = 2;
|
||||
for (id, val) in attrs {
|
||||
r[o] = id;
|
||||
r[o + 1..o + 5].copy_from_slice(&val.to_le_bytes());
|
||||
o += 5;
|
||||
}
|
||||
}
|
||||
0xAE => {
|
||||
// GET_STRING_ATTRIBUTE: [0xAE, len, attr, ascii…]. Steam requests two strings: attr
|
||||
// 0x00 = ATTRIB_STR_BOARD_SERIAL (the PCB serial) and 0x01 = ATTRIB_STR_UNIT_SERIAL.
|
||||
// Echo the exact attr requested (last[2]) — the unit serial is the one that matters:
|
||||
// getting its format right (FVPF…, see above) is what un-mangles the displayed name.
|
||||
// Steam ALSO validates the PCB serial against a Valve-internal format we don't have a
|
||||
// real capture of; it logs "Deck Controller PCB Serial# invalid" for ANY value we send
|
||||
// (including an empty one — verified on .173), but that line is BENIGN: unlike a bad
|
||||
// unit serial, it does not mangle the name, change the handle, or block promotion. So we
|
||||
// serve the unit serial for both attrs and accept the log.
|
||||
r[0] = 0xAE;
|
||||
r[1] = unit_serial.len() as u8;
|
||||
r[2] = last[2];
|
||||
r[3..3 + unit_serial.len()].copy_from_slice(unit_serial);
|
||||
}
|
||||
_ => r.copy_from_slice(&last),
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
// GET_FEATURE: report id from the input buffer; reply with the matching DualSense/DualShock 4 blob
|
||||
// (the Deck identity instead answers the latched Steam command — its one feature report is
|
||||
// unnumbered).
|
||||
fn on_get_feature(request: &Request) -> NTSTATUS {
|
||||
if device_type() == 3 {
|
||||
return request.copy_to_output(&deck_feature_reply());
|
||||
}
|
||||
let (bytes, _) = match request.input_bytes(1) {
|
||||
Ok(v) => v,
|
||||
Err(st) => return st,
|
||||
};
|
||||
let Some(&report_id) = bytes.first() else {
|
||||
return STATUS_INVALID_PARAMETER;
|
||||
};
|
||||
// The CHANNEL PROOF (security-review 2026-07-28): tell the host which process serves this
|
||||
// devnode, so it never has to trust the LocalService-writable bootstrap mailbox to name its
|
||||
// duplication target. `0x85` is already declared as a Feature report in all three captured
|
||||
// descriptors and was previously answered with STATUS_INVALID_PARAMETER, so this costs NO
|
||||
// report-descriptor change — the identity Steam and SDL fingerprint is untouched. Derived only
|
||||
// from our own devnode Location + pid: nothing a caller supplies feeds into it.
|
||||
if report_id == pf_driver_proto::gamepad::HID_FEATURE_REPORT_CHANNEL_PROOF {
|
||||
let len = request.output_buffer_len();
|
||||
return match pf_driver_proto::gamepad::ChannelProof::new(
|
||||
CHANNEL.index(),
|
||||
std::process::id(),
|
||||
)
|
||||
.to_feature_report(report_id, len)
|
||||
{
|
||||
Some(rep) => request.copy_to_output(&rep),
|
||||
None => STATUS_INVALID_PARAMETER, // caller's buffer can't hold id + proof
|
||||
};
|
||||
}
|
||||
// DualSense + Edge use feature ids 0x05/0x09/0x20 (same blobs — SDL forces enhanced-rumble
|
||||
// for the Edge PID regardless of the firmware version at 0x20[44..46]); DualShock 4 uses
|
||||
// 0x02/0x12/0xa3.
|
||||
// The pairing replies are per-pad: the MAC (bytes 1..7, LSB first) low octet carries the pad
|
||||
// index (see `pad_index` — SDL/Steam dedup controllers by this serial), agreeing with the
|
||||
// GET_STRING serial in `on_get_string`. The Edge lands on its GET_STRING base (0x75 = DS
|
||||
// base + 1) so its feature MAC and USB serial string agree too.
|
||||
let devtype = device_type();
|
||||
let mut ds_pairing = DS_FEATURE_PAIRING;
|
||||
ds_pairing[1] = ds_pairing[1]
|
||||
.wrapping_add(u8::from(devtype == 2))
|
||||
.wrapping_add(pad_index());
|
||||
let mut ds4_pairing = DS4_FEATURE_PAIRING;
|
||||
ds4_pairing[1] = ds4_pairing[1].wrapping_add(pad_index());
|
||||
let blob: &[u8] = match (devtype, report_id) {
|
||||
(0 | 2, 0x05) => &DS_FEATURE_CALIBRATION,
|
||||
(0 | 2, 0x09) => &ds_pairing,
|
||||
(0 | 2, 0x20) => &DS_FEATURE_FIRMWARE,
|
||||
(1, 0x02) => &DS4_FEATURE_CALIBRATION,
|
||||
(1, 0x12) => &ds4_pairing,
|
||||
(1, 0xA3) => &DS4_FEATURE_FIRMWARE,
|
||||
(_, other) => {
|
||||
dbglog!("[pf-gamepad] GET_FEATURE unknown report id 0x{other:02x}");
|
||||
return STATUS_INVALID_PARAMETER;
|
||||
}
|
||||
};
|
||||
request.copy_to_output(blob)
|
||||
}
|
||||
|
||||
// IOCTL_HID_GET_STRING: the input is a ULONG whose low word is the string id and whose high word is
|
||||
// the language id. Reply with the requested device string as a NUL-terminated UTF-16 buffer. Native
|
||||
// PS5 / Steam code reads these (HidD_GetProductString / HidD_GetSerialNumberString — the serial is one
|
||||
// way they tell USB from BT). Observed live: Windows polls ids 0x0E/0x0F/0x10 (lang 0x0409)
|
||||
// cyclically — the manufacturer/product/serial slots — NOT the 0/1/2 HID_STRING_ID_* constants; both.
|
||||
fn on_get_string(request: &Request) -> NTSTATUS {
|
||||
let (bytes, _) = match request.input_bytes(4) {
|
||||
Ok(v) => v,
|
||||
Err(st) => return st,
|
||||
};
|
||||
let id_val: u32 = if bytes.len() >= 4 {
|
||||
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let string_id = id_val & 0xFFFF;
|
||||
let devtype = device_type();
|
||||
dbglog!("[pf-gamepad] GET_STRING id=0x{string_id:04x} (raw 0x{id_val:08x}) devtype={devtype}");
|
||||
let s: String = match string_id {
|
||||
0 | 0x000e => match devtype {
|
||||
1 => "Sony Computer Entertainment".into(),
|
||||
3 => "Valve Software".into(),
|
||||
_ => "Sony Interactive Entertainment".into(),
|
||||
},
|
||||
// Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and
|
||||
// Steam dedups controllers by it. The PS strings are the pairing MAC MSB-first, so the
|
||||
// low octet — the LAST two hex chars — carries the pad index, agreeing with the patched
|
||||
// feature 0x09/0x12 replies in `on_get_feature`. The Deck serial must agree with
|
||||
// deck_feature_reply's 0xAE answer (Steam reads both).
|
||||
2 | 0x0010 => match devtype {
|
||||
1 => format!("DEADBEEF00{:02X}", 0x01u8.wrapping_add(pad_index())),
|
||||
2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())),
|
||||
3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32),
|
||||
_ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())),
|
||||
},
|
||||
_ => match devtype {
|
||||
1 => "Wireless Controller".into(),
|
||||
2 => "DualSense Edge Wireless Controller".into(),
|
||||
3 => "Steam Deck Controller".into(),
|
||||
_ => "DualSense Wireless Controller".into(),
|
||||
},
|
||||
};
|
||||
let mut wide: Vec<u8> = Vec::with_capacity(s.len() * 2 + 2);
|
||||
for u in s.encode_utf16() {
|
||||
wide.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
wide.extend_from_slice(&[0, 0]); // NUL terminator (UTF-16)
|
||||
request.copy_to_output(&wide)
|
||||
}
|
||||
|
||||
/// The host's device-type selector from the sealed DATA section (`device_type` @140): 0 = DualSense
|
||||
/// (default), 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck. Read fresh on each enumeration
|
||||
/// query — cheap. If
|
||||
/// the channel hasn't attached when hidclass first asks (the host stamps the section + eager-delivers
|
||||
/// before `SwDeviceCreate` returns, but the handshake can be a few ms behind), pump the channel
|
||||
/// briefly — ONCE — for the delivery: a DS4/Edge pad must not enumerate with the default DualSense
|
||||
/// identity because of a lost race. After that one bounded wait, fall back to the last observed type.
|
||||
fn device_type() -> u8 {
|
||||
if let Some(view) = CHANNEL.data() {
|
||||
let t = view.read_u8(OFF_DEVICE_TYPE);
|
||||
LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
|
||||
return t;
|
||||
}
|
||||
if !DEVTYPE_WAITED.swap(true, Ordering::SeqCst) {
|
||||
let cfg = channel_cfg();
|
||||
for _ in 0..100 {
|
||||
if let Some(view) = CHANNEL.pump(&cfg) {
|
||||
let t = view.read_u8(OFF_DEVICE_TYPE);
|
||||
LAST_DEVTYPE.store(t as u32, Ordering::Relaxed);
|
||||
return t;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
dbglog!(
|
||||
"[pf-gamepad] device_type: sealed channel not attached within 1s — defaulting to the last observed identity"
|
||||
);
|
||||
}
|
||||
LAST_DEVTYPE.load(Ordering::Relaxed) as u8
|
||||
}
|
||||
|
||||
extern "C" fn evt_timer(timer: WDFTIMER) {
|
||||
// One sealed-channel tick: publish our pid / adopt a delivery / detect host-gone, then pull the
|
||||
// latest host input report from the attached DATA section (all safe, via pf_umdf_util).
|
||||
match CHANNEL.pump(&channel_cfg()) {
|
||||
Some(view) => {
|
||||
let mut buf = [0u8; 64];
|
||||
view.read_bytes(OFF_INPUT, &mut buf);
|
||||
if buf[0] == 0x01
|
||||
&& let Ok(mut g) = INPUT_REPORT.lock()
|
||||
{
|
||||
*g = buf;
|
||||
}
|
||||
// Health marks the host watches: driver_proto (attach signal, idempotent) and
|
||||
// driver_heartbeat (+1 per ~8 ms tick = liveness). Lets the host tell "driver bound
|
||||
// and alive" apart from "driver package missing/failed to bind".
|
||||
view.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
||||
let hb = view.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
||||
view.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
||||
}
|
||||
None => {
|
||||
// Host gone (mailbox name vanished) or channel not attached yet: feed games the neutral
|
||||
// report instead of a frozen last state (matters for the persistent out-of-band devnode,
|
||||
// which outlives host sessions).
|
||||
if let Ok(mut g) = INPUT_REPORT.lock() {
|
||||
*g = neutral_report(LAST_DEVTYPE.load(Ordering::Relaxed) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Complete the next pended READ_REPORT with the current input report (safe queue/request API).
|
||||
// SAFETY: the timer's parent object is the manual queue (set in EvtDeviceAdd); the framework
|
||||
// guarantees a live handle here.
|
||||
let queue =
|
||||
unsafe { call_unsafe_wdf_function_binding!(WdfTimerGetParentObject, timer) } as WDFQUEUE;
|
||||
// SAFETY: `queue` is that live manual queue — the exact contract `retrieve_next_request` needs.
|
||||
if let Some(request) = unsafe { wdf::retrieve_next_request(queue) } {
|
||||
let report = INPUT_REPORT.lock().map(|g| *g).unwrap_or(NEUTRAL_REPORT);
|
||||
let st = request.copy_to_output(&report);
|
||||
request.complete(st);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user