From d2a2bcc25d2bf99edd8300204c546fc8ecb9d85c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 17:29:10 +0200 Subject: [PATCH] feat(drivers/pf-xusb): answer the async input wait, and put xinputhid on the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two things this driver's README has always listed as the missing WGI/GameInput work, both user-mode, neither needing a bus driver: `IOCTL_XUSB_WAIT_FOR_INPUT` is now pended on a manual queue and completed by the periodic timer on a dwPacketNumber edge, answering with the same 29-byte GET_STATE payload the synchronous path serves. Declining it was enough for classic xinput1_4, which just falls back to sync GET_STATE polling — that is why the pad has always worked there. It is not enough for WGI/GameInput, which poll asynchronously: to them a decline is a refusal, not a fallback. Completion is edge-gated because releasing a waiter on an unchanged packet spins its caller at timer rate. WAIT_GUIDE_BUTTON stays declined — we have no state to signal on. The INF adds UpperFilters=xinputhid on the XUSB devnode. Note the earlier attempt put that filter on the HID child of the *other* backend, which was simply the wrong devnode: XInput does not read HID at all, it enumerates GUID_DEVINTERFACE_XUSB, which is what this driver registers. Verified on .173: build + sign + catalog exit 0; infverif "INF is VALID"; the devnode starts Status OK with UpperFilters=xinputhid readable back from its enum key; and XInput still sees the pad (slot 1 live alongside the box's real Elite in slot 0), so the async queue is no regression to the path that already worked. NOT yet measured: whether WGI/GameInput now admit the pad. `IG_` is the wrong probe for this driver — it is a HID-path artifact and pf-xusb is System-class with no HID child, so its absence says nothing either way. That needs a real WinRT/GameInput enumeration test. --- crates/punktfunk-host/src/devtest.rs | 12 +++ packaging/windows/drivers/pf-xusb/pf_xusb.inx | 11 +++ packaging/windows/drivers/pf-xusb/src/lib.rs | 86 ++++++++++++++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 2fff4e6c..47509241 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -364,6 +364,8 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { .unwrap_or(0); let ds4 = args.iter().any(|a| a == "--ds4"); let xbox = args.iter().any(|a| a == "--xbox"); + // `--xboxhid` drives the HID Xbox backend (device-type 4) instead of `--xbox`'s XUSB companion. + let xboxhid = args.iter().any(|a| a == "--xboxhid"); // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend @@ -463,6 +465,16 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { })); std::thread::sleep(Duration::from_millis(15)); } + } else if xboxhid { + // The HID Xbox pad (device-type 4) — the SHIPPING SwDeviceCreate identity, not a devgen + // node. That distinction is the whole point of this leg: a devgen devnode carries no USB + // hardware ids, so its HID child comes up `HID\VID_045E&UP:0001_U:0005` with no PID token, + // and the question this exists to answer — does Windows promote our pad to an Xbox-profile + // device (an `IG_` token, XInput, WGI `Gamepad`) — turns on exactly that PID being present. + drive!( + crate::inject::xbox_windows::XboxWindowsManager::new(), + "Xbox Wireless Controller (HID)" + ); } else if ds4 { drive!( crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), diff --git a/packaging/windows/drivers/pf-xusb/pf_xusb.inx b/packaging/windows/drivers/pf-xusb/pf_xusb.inx index 82300a1b..83ce2281 100644 --- a/packaging/windows/drivers/pf-xusb/pf_xusb.inx +++ b/packaging/windows/drivers/pf-xusb/pf_xusb.inx @@ -39,6 +39,17 @@ pf_xusb.dll [pfXusb.NT.HW] Include=WUDFRD.inf Needs=WUDFRD.NT.HW +AddReg=pfXusb_HW_AddReg + +; The WGI/GameInput admission tripwire. Classic `xinput1_4` needs nothing here — it finds us by the +; XUSB device-interface GUID and polls GET_STATE, which is why the pad has always worked there +; (verified on .173 2026-08-09: our pad takes XInput slot 1 with live state). WGI and GameInput +; instead expect the in-box `xinputhid` filter on the stack, and without it they never admit the +; device however correct its IOCTL surface is. Pairs with the async WAIT_FOR_INPUT pump in +; src/lib.rs — the filter and the async wait are the two halves this driver's README has always +; listed as the missing WGI work; neither needs kernel-mode code. +[pfXusb_HW_AddReg] +HKR,,"UpperFilters",0x00010000,"xinputhid" [pfXusb.NT.Services] Include=WUDFRD.inf diff --git a/packaging/windows/drivers/pf-xusb/src/lib.rs b/packaging/windows/drivers/pf-xusb/src/lib.rs index 5343a8fb..4a8ce4c0 100644 --- a/packaging/windows/drivers/pf-xusb/src/lib.rs +++ b/packaging/windows/drivers/pf-xusb/src/lib.rs @@ -25,7 +25,7 @@ #![deny(unsafe_op_in_unsafe_fn)] #![deny(clippy::undocumented_unsafe_blocks)] -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}; use pf_driver_proto::gamepad::XusbShm; use pf_umdf_util::channel::{ChannelClient, ChannelConfig}; use pf_umdf_util::nt_success; @@ -34,7 +34,7 @@ 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, WDF_OBJECT_ATTRIBUTES, - WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFREQUEST, WDFTIMER, + WDF_TIMER_CONFIG, WDFDEVICE, WDFDRIVER, WDFQUEUE, WDFQUEUE__, WDFREQUEST, WDFTIMER, call_unsafe_wdf_function_binding, windows::OutputDebugStringA, }; @@ -78,6 +78,15 @@ const XUSB_VERSION: u16 = 0x0103; // ---- WDF enum values ---- const WdfIoQueueDispatchParallel: i32 = 2; +const WdfIoQueueDispatchManual: i32 = 3; + +/// Manual queue holding pended [`IOCTL_XUSB_WAIT_FOR_INPUT`] requests; the periodic timer completes +/// them when the host publishes a new packet. See [`evt_timer`]. +static WAIT_QUEUE: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +/// The `dwPacketNumber` the last completed wait reported — the edge the timer compares against, so +/// a waiter is only released when the state actually MOVED (that is the contract of an async wait; +/// completing it unconditionally would spin the caller at timer rate). +static WAIT_LAST_PACKET: AtomicU32 = AtomicU32::new(0); const WdfUseDefault: i32 = 2; // WDF_TRI_STATE const WdfExecutionLevelInheritFromParent: i32 = 1; // WDF_EXECUTION_LEVEL const WdfSynchronizationScopeInheritFromParent: i32 = 1; // WDF_SYNCHRONIZATION_SCOPE @@ -272,6 +281,35 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI return st; } + // Manual queue for the ASYNC input wait (`IOCTL_XUSB_WAIT_FOR_INPUT`), completed by the timer. + // + // Declining that IOCTL is enough for CLASSIC XInput — `xinput1_4` just falls back to synchronous + // GET_STATE polling, which is why the pad has always worked there. It is NOT enough for + // WGI/GameInput: those poll asynchronously, so to them the decline is not a fallback but a + // refusal, and the device is never admitted. Measured 2026-08-09 on .173 — the pad reaches + // XInput slot 1 with live data while WGI/GameInput never see it at all. + // SAFETY: a zeroed WDF_IO_QUEUE_CONFIG is valid; we then set Size + the fields we use. + let mut wcfg: WDF_IO_QUEUE_CONFIG = unsafe { core::mem::zeroed() }; + wcfg.Size = core::mem::size_of::() as ULONG; + wcfg.DispatchType = WdfIoQueueDispatchManual; + wcfg.PowerManaged = WdfUseDefault; + let mut wait_queue: WDFQUEUE = core::ptr::null_mut(); + // SAFETY: `device` + `wcfg` are valid; attributes null; `wait_queue` receives the handle. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfIoQueueCreate, + device, + &mut wcfg, + WDF_NO_OBJECT_ATTRIBUTES, + &mut wait_queue + ) + }; + if !nt_success(st) { + dbglog!("[pf-xusb] wait WdfIoQueueCreate failed 0x{:08x}", st as u32); + return st; + } + WAIT_QUEUE.store(wait_queue, Ordering::SeqCst); + // 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; @@ -333,6 +371,28 @@ extern "C" fn evt_device_add(_driver: WDFDRIVER, mut device_init: PWDFDEVICE_INI extern "C" fn evt_timer(_timer: WDFTIMER) { let live = CHANNEL.pump(&channel_cfg()).is_some(); HOST_LIVE.store(live, Ordering::Relaxed); + + // Release one pended `WAIT_FOR_INPUT` per tick, but only on a real edge — the host bumps + // `dwPacketNumber` whenever it publishes new state, so an unchanged packet means nothing moved + // and a waiter that is completed anyway would just spin its caller at timer rate. + let data = CHANNEL.data(); + let (packet, ..) = read_state(data); + if packet == WAIT_LAST_PACKET.load(Ordering::Relaxed) { + return; + } + let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst); + if wq.is_null() { + return; + } + // SAFETY: `wq` is the live manual queue created in EvtDeviceAdd — the contract + // `retrieve_next_request` requires. `None` simply means nobody is waiting. + if let Some(request) = unsafe { wdf::retrieve_next_request(wq) } { + WAIT_LAST_PACKET.store(packet, Ordering::Relaxed); + // Answer with the same 29-byte GET_STATE payload the synchronous path serves, so a caller + // that waits and a caller that polls observe byte-identical state. + let st = request.copy_to_output(&build_get_state(data)); + request.complete(st); + } } /// The current controller state from the attached DATA section (zeros / neutral when unattached). @@ -504,8 +564,26 @@ extern "C" fn evt_io_device_control( 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, + // The async input wait is PENDED on the manual queue and completed by the timer when the + // packet number moves (see `evt_timer`) — WGI/GameInput poll this way and will not admit a + // device that refuses it. Classic `xinput1_4` never issues it (it polls GET_STATE), so this + // costs the working path nothing. A forward failure completes the request with its error. + IOCTL_XUSB_WAIT_FOR_INPUT => { + let wq: WDFQUEUE = WAIT_QUEUE.load(Ordering::SeqCst); + if wq.is_null() { + STATUS_INVALID_DEVICE_REQUEST + } else { + // SAFETY: `wq` is the live manual queue created in EvtDeviceAdd; `request` is this + // dispatch's request and is CONSUMED by the forward (hence the early return). + match unsafe { request.forward_to_queue(wq) } { + Ok(()) => return, + Err((req, st)) => req.complete(st), + } + return; + } + } + // Still declined: the guide-button wait has no state of ours to signal on. + IOCTL_XUSB_WAIT_GUIDE_BUTTON => STATUS_INVALID_DEVICE_REQUEST, other => { dbglog!("[pf-xusb] unhandled IOCTL 0x{other:08x} in={input_len} out={output_len}"); STATUS_INVALID_DEVICE_REQUEST