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>
445 lines
20 KiB
Rust
445 lines
20 KiB
Rust
// punktfunk virtual Xbox 360 XUSB companion — UMDF2 driver presenting the XUSB device interface so
|
|
// classic XInput (XInputGetState) reads the pad with no kernel bus driver (the HIDMaestro approach).
|
|
//
|
|
// xinput1_4.dll enumerates GUID_DEVINTERFACE_XUSB, opens the Nth instance (= player slot), and polls
|
|
// it with buffered IOCTLs. We register the interface and answer those IOCTLs from controller state the
|
|
// host publishes into a shared DATA section; a game's rumble (SET_STATE) is published back for the
|
|
// host to forward. Byte formats are the source-verified xusb22 wire layout (HIDMaestro
|
|
// driver/companion.c + nefarius/XInputHooker XUSB.h + ViGEm XUSB_REPORT).
|
|
//
|
|
// The host channel is the **sealed pad channel** (design/gamepad-channel-sealing.md, proto v2): the
|
|
// DATA section (`pf_driver_proto::gamepad::XusbShm`) is UNNAMED — we reach it only through a handle
|
|
// the SYSTEM host duplicated into this WUDFHost, bootstrapped over the named `Global\pfxusb-boot-<i>`
|
|
// mailbox. The whole handshake + all shared-memory access lives in `pf_umdf_util` (audited unsafe
|
|
// layer): this crate's channel/IOCTL/state logic is 100% SAFE Rust. The only `unsafe` here is the
|
|
// unavoidable WDF setup FFI in DriverEntry/EvtDeviceAdd, each with a `// SAFETY:` proof.
|
|
//
|
|
// We answer the WAIT_* IOCTLs with STATUS_INVALID_DEVICE_REQUEST, which makes xinput1_4 fall back to
|
|
// synchronous GET_STATE polling — so no manual queue / timer is needed for classic XInput.
|
|
|
|
#![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 pf_driver_proto::gamepad::XusbShm;
|
|
use pf_umdf_util::channel::{ChannelClient, ChannelConfig};
|
|
use pf_umdf_util::nt_success;
|
|
use pf_umdf_util::section::MappedView;
|
|
use pf_umdf_util::wdf::{self, Request};
|
|
use wdk_sys::{
|
|
GUID, NTSTATUS, PCUNICODE_STRING, PDRIVER_OBJECT, PWDFDEVICE_INIT, ULONG, WDF_DRIVER_CONFIG,
|
|
WDF_IO_QUEUE_CONFIG, WDF_NO_HANDLE, WDF_NO_OBJECT_ATTRIBUTES, WDFDEVICE, WDFDRIVER, WDFQUEUE,
|
|
WDFREQUEST, call_unsafe_wdf_function_binding, windows::OutputDebugStringA,
|
|
};
|
|
|
|
// ---- NTSTATUS ----
|
|
const STATUS_SUCCESS: NTSTATUS = 0;
|
|
const STATUS_INVALID_DEVICE_REQUEST: NTSTATUS = 0xC000_0010u32 as NTSTATUS;
|
|
|
|
// GUID_DEVINTERFACE_XUSB {EC87F1E3-C13B-4100-B5F7-8B84D54260CB} — what xinput1_4 enumerates + opens.
|
|
const GUID_DEVINTERFACE_XUSB: GUID = GUID {
|
|
Data1: 0xEC87_F1E3,
|
|
Data2: 0xC13B,
|
|
Data3: 0x4100,
|
|
Data4: [0xB5, 0xF7, 0x8B, 0x84, 0xD5, 0x42, 0x60, 0xCB],
|
|
};
|
|
|
|
// ---- XUSB IOCTLs (METHOD_BUFFERED) ----
|
|
const IOCTL_XUSB_GET_INFORMATION: u32 = 0x8000_6000;
|
|
const IOCTL_XUSB_GET_CAPABILITIES: u32 = 0x8000_E004;
|
|
const IOCTL_XUSB_GET_LED_STATE: u32 = 0x8000_E008;
|
|
const IOCTL_XUSB_GET_STATE: u32 = 0x8000_E00C;
|
|
const IOCTL_XUSB_SET_STATE: u32 = 0x8000_A010;
|
|
const IOCTL_XUSB_WAIT_GUIDE_BUTTON: u32 = 0x8000_E014;
|
|
const IOCTL_XUSB_GET_BATTERY_INFORMATION: u32 = 0x8000_E018;
|
|
const IOCTL_XUSB_POWER_DOWN: u32 = 0x8000_A01C;
|
|
const IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER: u32 = 0x8000_6380;
|
|
const IOCTL_XUSB_WAIT_FOR_INPUT: u32 = 0x8000_E3AC;
|
|
const IOCTL_XUSB_GET_INFORMATION_EX: u32 = 0x8000_E3FC;
|
|
|
|
/// Our own private IOCTL (NOT part of xusb22): answer the host's **channel proof** — which process
|
|
/// is serving this devnode — so the host learns its duplication target from the device stack instead
|
|
/// of from the LocalService-writable bootstrap mailbox (see `pf_driver_proto::gamepad::ChannelProof`
|
|
/// for why that distinction is the whole security property). Any local caller may ask; the answer is
|
|
/// a pid and two version numbers, none of them secret, and it is only worth anything to a process
|
|
/// that can already duplicate handles into us.
|
|
const IOCTL_PF_GET_CHANNEL_PROOF: u32 = pf_driver_proto::gamepad::IOCTL_PF_GET_CHANNEL_PROOF;
|
|
|
|
// Xbox 360 wired identity (what GET_INFORMATION reports). 0x0103 unblocks SET_STATE (vibration).
|
|
const XUSB_VID: u16 = 0x045E;
|
|
const XUSB_PID: u16 = 0x028E;
|
|
const XUSB_VERSION: u16 = 0x0103;
|
|
|
|
// ---- WDF enum values ----
|
|
const WdfIoQueueDispatchParallel: i32 = 2;
|
|
const WdfUseDefault: i32 = 2; // WDF_TRI_STATE
|
|
|
|
// ---- the sealed host channel: layouts + offsets from pf_driver_proto (drift = compile error) ----
|
|
const SHM_MAGIC: u32 = pf_driver_proto::gamepad::XUSB_MAGIC; // "PFXU"
|
|
const SHM_SIZE: usize = core::mem::size_of::<XusbShm>();
|
|
const GAMEPAD_PROTO_VERSION: u32 = pf_driver_proto::gamepad::GAMEPAD_PROTO_VERSION;
|
|
|
|
// XusbShm field offsets (host writes state, we answer XInput; we write rumble + health marks).
|
|
const OFF_PACKET: usize = core::mem::offset_of!(XusbShm, packet);
|
|
const OFF_BUTTONS: usize = core::mem::offset_of!(XusbShm, buttons);
|
|
const OFF_LT: usize = core::mem::offset_of!(XusbShm, left_trigger);
|
|
const OFF_RT: usize = core::mem::offset_of!(XusbShm, right_trigger);
|
|
const OFF_LX: usize = core::mem::offset_of!(XusbShm, thumb_lx);
|
|
const OFF_LY: usize = core::mem::offset_of!(XusbShm, thumb_ly);
|
|
const OFF_RX: usize = core::mem::offset_of!(XusbShm, thumb_rx);
|
|
const OFF_RY: usize = core::mem::offset_of!(XusbShm, thumb_ry);
|
|
const OFF_RUMBLE_SEQ: usize = core::mem::offset_of!(XusbShm, rumble_seq);
|
|
const OFF_RUMBLE_LARGE: usize = core::mem::offset_of!(XusbShm, rumble_large);
|
|
const OFF_RUMBLE_SMALL: usize = core::mem::offset_of!(XusbShm, rumble_small);
|
|
const OFF_DRIVER_PROTO: usize = core::mem::offset_of!(XusbShm, driver_proto);
|
|
const OFF_DRIVER_HEARTBEAT: usize = core::mem::offset_of!(XusbShm, driver_heartbeat);
|
|
const OFF_PAD_INDEX: usize = core::mem::offset_of!(XusbShm, pad_index);
|
|
|
|
/// The sealed-channel client (per-pad: `ProcessSharingDisabled` gives each pad its own WUDFHost, so
|
|
/// this static is per-pad). All shared-memory access + the bootstrap handshake live in `pf_umdf_util`.
|
|
static CHANNEL: ChannelClient = ChannelClient::new();
|
|
|
|
/// This pad's channel config (magic/size/pad_index offset + our logger).
|
|
fn channel_cfg() -> ChannelConfig {
|
|
ChannelConfig {
|
|
tag: "pf-xusb",
|
|
boot_name_prefix: "Global\\pfxusb-boot-",
|
|
data_magic: SHM_MAGIC,
|
|
data_size: SHM_SIZE,
|
|
min_data_size: SHM_SIZE, // layout never grew — no fallback size
|
|
pad_index_off: OFF_PAD_INDEX,
|
|
log,
|
|
}
|
|
}
|
|
|
|
/// Whether the world-writable bring-up file log is enabled (resolved once). OPT-IN — debug builds,
|
|
/// or the `PFXUSB_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-rumble
|
|
/// SET_STATE 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("PFXUSB_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;
|
|
}
|
|
// Write to the WUDFHost's own (LocalService) temp dir — NOT world-writable/readable
|
|
// `C:\Users\Public`, where the per-event SET_STATE/report hex dumps could leak pad
|
|
// identity to any local reader and a non-admin could pre-create/hold the file
|
|
// (security-review 2026-07-17). Opt-in/debug only.
|
|
std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(std::env::temp_dir().join("pfxusb-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 NUL-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-xusb] DriverEntry");
|
|
// SAFETY: a 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: `driver`/`registry_path` are the loader-provided pointers; the config is valid.
|
|
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-xusb] EvtDeviceAdd");
|
|
|
|
let mut device: WDFDEVICE = core::ptr::null_mut();
|
|
// SAFETY: `device_init` is the framework-provided init; attributes null; `device` receives it.
|
|
let st = unsafe {
|
|
call_unsafe_wdf_function_binding!(
|
|
WdfDeviceCreate,
|
|
&mut device_init,
|
|
WDF_NO_OBJECT_ATTRIBUTES,
|
|
&mut device
|
|
)
|
|
};
|
|
if !nt_success(st) {
|
|
dbglog!("[pf-xusb] WdfDeviceCreate failed 0x{:08x}", st as u32);
|
|
return st;
|
|
}
|
|
|
|
// SAFETY: `device` is the live device just created — the exact contract `query_location_index`
|
|
// requires.
|
|
let idx = unsafe { wdf::query_location_index(device) };
|
|
CHANNEL.set_index(idx);
|
|
dbglog!("[pf-xusb] shm index = {idx}");
|
|
|
|
// Register the XUSB device interface (no reference string) — what xinput1_4 enumerates + opens.
|
|
// SAFETY: `device` is live; the GUID is a static; null reference string.
|
|
let st = unsafe {
|
|
call_unsafe_wdf_function_binding!(
|
|
WdfDeviceCreateDeviceInterface,
|
|
device,
|
|
&GUID_DEVINTERFACE_XUSB,
|
|
core::ptr::null()
|
|
)
|
|
};
|
|
if !nt_success(st) {
|
|
dbglog!(
|
|
"[pf-xusb] WdfDeviceCreateDeviceInterface failed 0x{:08x}",
|
|
st as u32
|
|
);
|
|
return st;
|
|
}
|
|
|
|
// Default parallel queue: all the XUSB IOCTLs land here.
|
|
// SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use.
|
|
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);
|
|
qcfg.Settings.Parallel.NumberOfPresentedRequests = u32::MAX;
|
|
let mut queue: WDFQUEUE = core::ptr::null_mut();
|
|
// SAFETY: `device` + `qcfg` are valid; attributes null; `queue` receives the handle.
|
|
let st = unsafe {
|
|
call_unsafe_wdf_function_binding!(
|
|
WdfIoQueueCreate,
|
|
device,
|
|
&mut qcfg,
|
|
WDF_NO_OBJECT_ATTRIBUTES,
|
|
&mut queue
|
|
)
|
|
};
|
|
if !nt_success(st) {
|
|
dbglog!("[pf-xusb] WdfIoQueueCreate failed 0x{:08x}", st as u32);
|
|
return st;
|
|
}
|
|
|
|
// Run the sealed-channel handshake on a worker (must NOT block EvtDeviceAdd): publish our pid in
|
|
// the bootstrap mailbox and poll for the host's delivered DATA handle, so the pad attaches (and
|
|
// the host's driver-attach health check goes green) even before any game polls XInput. Bounded;
|
|
// a later host (or a re-delivery) is still picked up by the per-IOCTL pump. This closure is 100%
|
|
// safe — the whole channel state machine lives in pf_umdf_util.
|
|
std::thread::spawn(|| {
|
|
let cfg = channel_cfg();
|
|
for _ in 0..500 {
|
|
if let Some(v) = CHANNEL.pump(&cfg) {
|
|
touch_driver_marks(v);
|
|
return;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
log(
|
|
"[pf-xusb] no sealed-channel delivery within 10s (host absent, or host/driver version mismatch — see above)",
|
|
);
|
|
});
|
|
|
|
log("[pf-xusb] device ready (XUSB interface registered)");
|
|
STATUS_SUCCESS
|
|
}
|
|
|
|
/// The current controller state from the attached DATA section (zeros / neutral when unattached).
|
|
/// Returns `(dwPacketNumber, wButtons, lt, rt, lx, ly, rx, ry)`.
|
|
fn read_state(data: Option<&MappedView>) -> (u32, u16, u8, u8, i16, i16, i16, i16) {
|
|
match data {
|
|
Some(v) => (
|
|
v.read_u32(OFF_PACKET),
|
|
v.read_u16(OFF_BUTTONS),
|
|
v.read_u8(OFF_LT),
|
|
v.read_u8(OFF_RT),
|
|
v.read_i16(OFF_LX),
|
|
v.read_i16(OFF_LY),
|
|
v.read_i16(OFF_RX),
|
|
v.read_i16(OFF_RY),
|
|
),
|
|
None => (0, 0, 0, 0, 0, 0, 0, 0),
|
|
}
|
|
}
|
|
|
|
/// Stamp the driver health marks the host watches: `driver_proto` (the attach signal, idempotent)
|
|
/// and `driver_heartbeat` (+1). Called once the channel attaches and on every serviced IOCTL, so the
|
|
/// host can tell "driver bound and alive" apart from "driver package missing/failed to bind" and see
|
|
/// the game-visible polling path advance.
|
|
fn touch_driver_marks(data: &MappedView) {
|
|
data.write_u32(OFF_DRIVER_PROTO, GAMEPAD_PROTO_VERSION);
|
|
let hb = data.read_u32(OFF_DRIVER_HEARTBEAT).wrapping_add(1);
|
|
data.write_u32(OFF_DRIVER_HEARTBEAT, hb);
|
|
}
|
|
|
|
/// Publish a game's rumble (from SET_STATE) into the DATA section for the host to forward.
|
|
fn publish_rumble(data: Option<&MappedView>, large: u8, small: u8) {
|
|
let Some(v) = data else { return };
|
|
v.write_u8(OFF_RUMBLE_LARGE, large);
|
|
v.write_u8(OFF_RUMBLE_SMALL, small);
|
|
let seq = v.read_u32(OFF_RUMBLE_SEQ).wrapping_add(1);
|
|
v.write_u32(OFF_RUMBLE_SEQ, seq);
|
|
}
|
|
|
|
// Build the 29-byte GET_STATE buffer (the layout xinput1_4 parses).
|
|
fn build_get_state(data: Option<&MappedView>) -> [u8; 29] {
|
|
let (packet, buttons, lt, rt, lx, ly, rx, ry) = read_state(data);
|
|
let mut s = [0u8; 29];
|
|
s[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
|
s[2] = 0x01; // device count
|
|
s[5..9].copy_from_slice(&packet.to_le_bytes());
|
|
s[0x0B..0x0D].copy_from_slice(&buttons.to_le_bytes());
|
|
s[0x0D] = lt;
|
|
s[0x0E] = rt;
|
|
s[0x0F..0x11].copy_from_slice(&lx.to_le_bytes());
|
|
s[0x11..0x13].copy_from_slice(&ly.to_le_bytes());
|
|
s[0x13..0x15].copy_from_slice(&rx.to_le_bytes());
|
|
s[0x15..0x17].copy_from_slice(&ry.to_le_bytes());
|
|
s
|
|
}
|
|
|
|
// GET_INFORMATION: 12 bytes — version, device count, VID/PID. Marks the slot connected.
|
|
fn build_information() -> [u8; 12] {
|
|
let mut info = [0u8; 12];
|
|
info[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
|
info[2] = 0x01; // one device/port
|
|
info[8..10].copy_from_slice(&XUSB_VID.to_le_bytes());
|
|
info[10..12].copy_from_slice(&XUSB_PID.to_le_bytes());
|
|
info
|
|
}
|
|
|
|
// GET_CAPABILITIES V1 (24 bytes): Type=0x03 SubType=0x01 (gamepad), button/stick masks, motor max
|
|
// = 0xFFFF (advertise rumble). The V2 (36-byte) form prepends a 16-byte header when WGI asks for 36.
|
|
#[rustfmt::skip]
|
|
const CAPS_V1: [u8; 24] = [
|
|
0x03, 0x01, 0x00, 0x01, 0xFF, 0xF7, 0xFF, 0xFF,
|
|
0xC0, 0xFF, 0xC0, 0xFF, 0xC0, 0xFF, 0xC0, 0xFF,
|
|
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF,
|
|
];
|
|
|
|
fn build_caps_v2() -> [u8; 36] {
|
|
let mut c = [0u8; 36];
|
|
c[0..6].copy_from_slice(&[0x03, 0x01, 0x01, 0x01, 0x0C, 0x00]);
|
|
c[6..8].copy_from_slice(&XUSB_VID.to_le_bytes());
|
|
c[8..10].copy_from_slice(&XUSB_PID.to_le_bytes());
|
|
c[10..16].copy_from_slice(&[0x10, 0x01, 0x00, 0xFA, 0x34, 0x22]);
|
|
c[16..36].copy_from_slice(&CAPS_V1[4..24]); // the XINPUT_CAPABILITIES struct body
|
|
c
|
|
}
|
|
|
|
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. From here everything is safe (the token owns completion).
|
|
let request = unsafe { Request::new(request) };
|
|
|
|
// Sealed-channel pump + health marks first: adopt a (late) delivery, detach when the host's
|
|
// mailbox is gone, and stamp the attach/heartbeat marks the host watches (also covers a host
|
|
// started after this device — the pump attaches on the next XInput poll).
|
|
let data = CHANNEL.pump(&channel_cfg());
|
|
if let Some(v) = data {
|
|
touch_driver_marks(v);
|
|
}
|
|
|
|
let status: NTSTATUS = match ioctl {
|
|
IOCTL_XUSB_GET_INFORMATION => request.copy_to_output(&build_information()),
|
|
IOCTL_XUSB_GET_INFORMATION_EX => {
|
|
let mut ex = [0u8; 64];
|
|
ex[0..2].copy_from_slice(&XUSB_VERSION.to_le_bytes());
|
|
ex[2] = 0x01;
|
|
ex[3] = 0x01;
|
|
ex[8..10].copy_from_slice(&XUSB_VID.to_le_bytes());
|
|
ex[10..12].copy_from_slice(&XUSB_PID.to_le_bytes());
|
|
let n = output_len.min(64);
|
|
request.copy_to_output(&ex[..n])
|
|
}
|
|
IOCTL_XUSB_GET_CAPABILITIES => {
|
|
if output_len >= 36 {
|
|
request.copy_to_output(&build_caps_v2())
|
|
} else {
|
|
request.copy_to_output(&CAPS_V1)
|
|
}
|
|
}
|
|
IOCTL_XUSB_GET_STATE => request.copy_to_output(&build_get_state(data)),
|
|
// The channel proof — deliberately answered from THIS process's own identity, never from
|
|
// anything a caller supplied, so the only way to make this devnode name a process is to BE
|
|
// the driver bound to it.
|
|
IOCTL_PF_GET_CHANNEL_PROOF => {
|
|
let proof =
|
|
pf_driver_proto::gamepad::ChannelProof::new(CHANNEL.index(), std::process::id());
|
|
request.copy_to_output(&proof.to_bytes())
|
|
}
|
|
IOCTL_XUSB_GET_LED_STATE => request.copy_to_output(&[0x00, 0x00, 0x06]),
|
|
IOCTL_XUSB_GET_BATTERY_INFORMATION => request.copy_to_output(&[0x00, 0x01, 0x03, 0x00]),
|
|
IOCTL_XUSB_SET_STATE => on_set_state(&request, data),
|
|
IOCTL_XUSB_POWER_DOWN | IOCTL_XUSB_GET_XINPUT_MANAGEMENT_DRIVER => STATUS_SUCCESS,
|
|
// Decline the async waits → xinput1_4 falls back to synchronous GET_STATE polling.
|
|
IOCTL_XUSB_WAIT_GUIDE_BUTTON | IOCTL_XUSB_WAIT_FOR_INPUT => STATUS_INVALID_DEVICE_REQUEST,
|
|
other => {
|
|
dbglog!("[pf-xusb] unhandled IOCTL 0x{other:08x} in={input_len} out={output_len}");
|
|
STATUS_INVALID_DEVICE_REQUEST
|
|
}
|
|
};
|
|
request.complete(status);
|
|
}
|
|
|
|
// SET_STATE: the rumble packet. Classic xusb22 layout is small; the motor bytes sit near the end.
|
|
// We publish a best-effort (large = byte 2, small = byte 3 for the 5-byte form) and log the raw bytes
|
|
// so the exact offsets can be confirmed against a real pad.
|
|
fn on_set_state(request: &Request, data: Option<&MappedView>) -> NTSTATUS {
|
|
if let Ok((bytes, len)) = request.input_bytes(8)
|
|
&& len >= 2
|
|
{
|
|
let mut hex = String::new();
|
|
for b in &bytes {
|
|
hex.push_str(&format!("{b:02x} "));
|
|
}
|
|
dbglog!("[pf-xusb] SET_STATE len={len} data: {hex}");
|
|
// Observed 5-byte form {00, led, largeMotor, smallMotor, subcmd}: subcmd 0x02 = rumble
|
|
// (large/low-freq at [2], small/high-freq at [3]); 0x01 = player-LED set (ignored).
|
|
// 4-byte = raw XINPUT_VIBRATION → the two motor hi bytes.
|
|
if len >= 5 && bytes[4] == 0x02 {
|
|
publish_rumble(data, bytes[2], bytes[3]);
|
|
} else if len == 4 {
|
|
publish_rumble(data, bytes[1], bytes[3]);
|
|
}
|
|
}
|
|
STATUS_SUCCESS
|
|
}
|