test(tools): a GameInput probe — and it cannot see our promoted Xbox pad

`win-input-matrix` covered four of the five rows and said so; GameInput was the gap, because it has
no binding in the `windows` crate and needs hand-written COM. This adds it: `--gameinput` reports
whether GameInput has a reading, and `--gi-rumble l,h,lt,rt [--gi-pid PID]` drives
`SetRumbleState`. Every vtable slot is taken from the SDK header, not guessed — a COM vtable is
positional, so a wrong slot calls a different method with the wrong signature.

WHY RUMBLE AND NOT JUST ENUMERATION. `XINPUT_VIBRATION` has two members, so classic XInput can never
exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has four
(`lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger`), which makes GameInput the only API
that can settle `design/trigger-rumble-plane.md` §2.1's open question — the `enable`-mask bit
assignment for the two trigger actuators, where bits 2/3 (the handles) are measured and bits 0/1
(the triggers) are inferred from field order and nothing else.

TWO THINGS MEASURED ON .173, 2026-08-09:

1.  GameInput's device enumeration is ASYNCHRONOUS, and the first `GetCurrentReading` reliably
   returns nothing even with pads actively reporting. This is the GameInput analogue of `wake_wgi`:
   the API looks like a query and is really a cache someone else fills. A bounded poll fixes it.
   ⚠️ Focus is NOT the cause, and the header rules it out rather than my guessing:
   `GameInputDefaultFocusPolicy` is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION, so the
   default already admits background input. Do not "fix" this with `SetFocusPolicy`.

2. 🛑 **GameInput never sees our pad.** Hunting by product id for six seconds with the pad live and
   sweeping, it enumerated `054C:0CE6` (DualSense) and `3434:D031` (8BitDo) — both plain HID pads —
   and never `045E:02FD`, ours, while classic XInput was reading ours live in the same moment.

⇒ THE TRIGGER ENABLE BITS REMAIN CONJECTURE, but for a better reason than before: it is not that
nobody has tried, it is that on this box NOTHING CAN DELIVER a four-motor rumble to our pad. XInput
structurally cannot; GameInput can but does not see it.

⚠️ The obvious suspicion is that `xinputhid` claiming the HID collection exclusively is what hides
the pad from GameInput — which would mean promotion costs us the API most Game-Pass-era titles use,
a trade we have shipped by default. **That is NOT established here.** The decisive control is cheap
and has not been run: power on the REAL Xbox Elite, which Microsoft's own driver promotes the same
way, and see whether GameInput enumerates it. If a real promoted Xbox pad is also absent, this is a
property of GameInput in a non-interactive session and not our defect — the same shape as the WGI
`ts=0` row, which a real Elite reproduced.

VERIFIED
  * `cargo fmt --check` clean; `cargo clippy --target x86_64-pc-windows-msvc --all-targets
    -- -D warnings` clean (cross-checked from macOS).
  * Builds and runs on .173; `GameInputCreate` succeeds, readings arrive after the poll, and
    `SetRumbleState` is accepted.
  * The runtime is loaded by name, so a box without GameInput reports "unavailable" rather than
    failing to link or crashing.

NOT VERIFIED
  * That `SetRumbleState` reaches ANY pad's motors — it was accepted for the DualSense but nothing
    observable was checked on that device, and it never reached ours.
  * `GameInputDeviceInfo` is read only for `vendorId`/`productId` (offsets 4 and 6). The rest of the
    struct has variable-size members whose layout would have to be mirrored exactly; nothing here
    needs them. `supportedRumbleMotors` is in there and would answer "does GameInput think this pad
    has trigger motors" — worth adding if this line of enquiry continues.
This commit is contained in:
2026-08-09 22:55:17 +02:00
parent 2b1843ed1c
commit 94c2f62490
3 changed files with 371 additions and 0 deletions
+1
View File
@@ -30,5 +30,6 @@ windows = { version = "0.62", features = [
"Win32_System_Com",
"Gaming_Input",
"Foundation",
"Win32_System_LibraryLoader",
"Foundation_Collections",
] }
+331
View File
@@ -0,0 +1,331 @@
//! The GameInput row of the matrix, and the only API that can drive TRIGGER rumble.
//!
//! WHY THIS IS HAND-WRITTEN COM. GameInput has no binding in the `windows` crate, so the vtables
//! below are declared by hand. Every slot index is taken from the SDK header
//! `Windows Kits\10\Include\10.0.26100.0\um\GameInput.h`, not from guesswork — a COM vtable is
//! positional, so a wrong slot calls a different method with the wrong signature and corrupts the
//! stack. Only the slots up to the ones actually called are declared; anything past them is simply
//! absent from the struct, which is sound because a vtable is only ever read through the offsets we
//! name and we never call beyond the last declared entry.
//!
//! ⭐ WHY IT MATTERS BEYOND ENUMERATION. `XINPUT_VIBRATION` has exactly two members, so classic
//! XInput can never exercise an Xbox pad's two IMPULSE-TRIGGER motors. `GameInputRumbleParams` has
//! four — `lowFrequency`, `highFrequency`, `leftTrigger`, `rightTrigger` — which makes this the one
//! path that can settle the open question in `design/trigger-rumble-plane.md` §2.1: the `enable`-
//! mask bit assignment for the two trigger actuators in the pad's HID output report `0x03` is
//! CONJECTURE (bits 2/3 = the handles are measured; bits 0/1 = the triggers are inferred from field
//! order and nothing else).
//!
//! The experiment `--gi-rumble` exists for: drive four DISTINCT magnitudes, then read what the pad
//! actually decoded. Four distinct values make the mapping self-identifying — a channel that comes
//! back zero had its enable bit guessed wrong, and a channel that comes back holding another's
//! value is a swap.
//!
//! The runtime is loaded by name rather than linked, so this builds with no import library and
//! degrades to a clean "GameInput not present" on a box without it.
#![allow(non_snake_case)]
use std::ffi::c_void;
use windows::Win32::Foundation::{FreeLibrary, HMODULE};
use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
use windows::core::{HRESULT, PCSTR, PCWSTR};
/// `GameInputKind` values we use (GameInput.h).
const GAME_INPUT_KIND_GAMEPAD: u32 = 0x0004_0000;
const GAME_INPUT_KIND_CONTROLLER: u32 = 0x0000_000E;
/// The four rumble channels, 0.0..=1.0 each. Layout verbatim from GameInput.h.
#[repr(C)]
#[derive(Clone, Copy, Default, Debug)]
pub struct GameInputRumbleParams {
pub lowFrequency: f32,
pub highFrequency: f32,
pub leftTrigger: f32,
pub rightTrigger: f32,
}
/// `IGameInput`, declared only as far as `GetCurrentReading` (slot 4).
#[repr(C)]
struct IGameInputVtbl {
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
Release: unsafe extern "system" fn(*mut c_void) -> u32,
GetCurrentTimestamp: unsafe extern "system" fn(*mut c_void) -> u64,
GetCurrentReading:
unsafe extern "system" fn(*mut c_void, u32, *mut c_void, *mut *mut c_void) -> HRESULT,
}
/// `IGameInputReading`, declared only as far as `GetDevice` (slot 6).
#[repr(C)]
struct IGameInputReadingVtbl {
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
Release: unsafe extern "system" fn(*mut c_void) -> u32,
GetInputKind: unsafe extern "system" fn(*mut c_void) -> u32,
GetSequenceNumber: unsafe extern "system" fn(*mut c_void, u32) -> u64,
GetTimestamp: unsafe extern "system" fn(*mut c_void) -> u64,
GetDevice: unsafe extern "system" fn(*mut c_void, *mut *mut c_void),
}
/// `IGameInputDevice`, declared only as far as `SetRumbleState` (slot 10).
#[repr(C)]
struct IGameInputDeviceVtbl {
QueryInterface: unsafe extern "system" fn(*mut c_void, *const u8, *mut *mut c_void) -> HRESULT,
AddRef: unsafe extern "system" fn(*mut c_void) -> u32,
Release: unsafe extern "system" fn(*mut c_void) -> u32,
GetDeviceInfo: unsafe extern "system" fn(*mut c_void) -> *const c_void,
GetDeviceStatus: unsafe extern "system" fn(*mut c_void) -> u32,
GetBatteryState: unsafe extern "system" fn(*mut c_void, *mut c_void),
CreateForceFeedbackEffect:
unsafe extern "system" fn(*mut c_void, u32, *const c_void, *mut *mut c_void) -> HRESULT,
IsForceFeedbackMotorPoweredOn: unsafe extern "system" fn(*mut c_void, u32) -> i32,
SetForceFeedbackMotorGain: unsafe extern "system" fn(*mut c_void, u32, f32),
SetHapticMotorState: unsafe extern "system" fn(*mut c_void, u32, *const c_void),
SetRumbleState: unsafe extern "system" fn(*mut c_void, *const GameInputRumbleParams),
}
/// A loaded GameInput runtime plus the root object. Dropping it releases both.
pub struct GameInput {
module: HMODULE,
root: *mut c_void,
}
impl Drop for GameInput {
fn drop(&mut self) {
// SAFETY: `root` came from GameInputCreate and is released exactly once; `module` came from
// LoadLibraryW. Freeing the module after the object is the required order.
unsafe {
if !self.root.is_null() {
let vtbl = *(self.root as *mut *const IGameInputVtbl);
((*vtbl).Release)(self.root);
}
let _ = FreeLibrary(self.module);
}
}
}
impl GameInput {
/// Load `gameinput.dll` and create the root object. `Err` carries a human reason — a box
/// without the runtime is a legitimate outcome, not a crash.
pub fn create() -> Result<Self, String> {
let name: Vec<u16> = "gameinput.dll\0".encode_utf16().collect();
// SAFETY: `name` is a NUL-terminated wide string that outlives the call.
let module = unsafe { LoadLibraryW(PCWSTR(name.as_ptr())) }
.map_err(|e| format!("gameinput.dll not loadable: {e}"))?;
// SAFETY: `module` is live; the name is a NUL-terminated byte string.
let proc =
unsafe { GetProcAddress(module, PCSTR(c"GameInputCreate".as_ptr() as *const u8)) }
.ok_or_else(|| "gameinput.dll has no GameInputCreate export".to_string())?;
// SAFETY: the export's documented signature is
// `HRESULT GameInputCreate(IGameInput**)` — GameInput.h, `STDAPI GameInputCreate`.
let create: unsafe extern "system" fn(*mut *mut c_void) -> HRESULT =
unsafe { std::mem::transmute(proc) };
let mut root: *mut c_void = std::ptr::null_mut();
// SAFETY: `root` is a valid out-param slot.
let hr = unsafe { create(&mut root) };
if hr.is_err() || root.is_null() {
// SAFETY: nothing was created; drop the module by hand since we have no object yet.
unsafe {
let _ = FreeLibrary(module);
}
return Err(format!("GameInputCreate failed: {hr:?}"));
}
Ok(Self { module, root })
}
/// The current reading for `kind`, if any device is producing one.
fn reading(&self, kind: u32) -> Option<*mut c_void> {
let mut reading: *mut c_void = std::ptr::null_mut();
// SAFETY: `self.root` is a live IGameInput; slot 4 is GetCurrentReading with this exact
// signature (GameInput.h). A null `device` means "any device", which is what we want.
let hr = unsafe {
let vtbl = *(self.root as *mut *const IGameInputVtbl);
((*vtbl).GetCurrentReading)(self.root, kind, std::ptr::null_mut(), &mut reading)
};
(hr.is_ok() && !reading.is_null()).then_some(reading)
}
/// Poll for a reading, because GameInput's device enumeration is ASYNCHRONOUS.
///
/// A freshly created `IGameInput` has not finished enumerating yet, so the first
/// `GetCurrentReading` reliably returns nothing even with a pad actively reporting — measured
/// on `.173` 2026-08-09, where a sweeping devtest pad and a live DualSense both read "no
/// reading" on the first call. This is the GameInput analogue of `wake_wgi`: the API looks like
/// a query and is really a cache someone else fills.
///
/// ⚠️ Focus is NOT the cause and was ruled out from the header: `GameInputDefaultFocusPolicy`
/// is 0 and every `GameInputFocusPolicy` flag is a RESTRICTION
/// (`GameInputDisableBackgroundInput`, `GameInputExclusiveForegroundInput`, …), so the default
/// already admits background input. Do not "fix" this by calling `SetFocusPolicy`.
fn reading_wait(&self, kind: u32, timeout: std::time::Duration) -> Option<*mut c_void> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(r) = self.reading(kind) {
return Some(r);
}
if std::time::Instant::now() >= deadline {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
/// `(vendorId, productId)` for a device, read straight off `GameInputDeviceInfo`.
///
/// The struct begins `uint32_t infoSize; uint16_t vendorId; uint16_t productId; …`
/// (GameInput.h), so the two ids sit at byte offsets 4 and 6. Only those two are read — the
/// rest of the struct carries variable-size members whose layout we would have to mirror
/// exactly, and nothing here needs them.
fn device_ids(dev: *mut c_void) -> (u16, u16) {
// SAFETY: `dev` is a live IGameInputDevice; slot 3 is GetDeviceInfo, which returns a
// pointer to a struct owned by the runtime and valid for the device's lifetime.
unsafe {
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
let info = ((*vtbl).GetDeviceInfo)(dev) as *const u8;
if info.is_null() {
return (0, 0);
}
(
u16::from_le_bytes([*info.add(4), *info.add(5)]),
u16::from_le_bytes([*info.add(6), *info.add(7)]),
)
}
}
/// Take the device off a reading (AddRef'd), releasing the reading.
fn device_of(r: *mut c_void) -> *mut c_void {
let mut dev: *mut c_void = std::ptr::null_mut();
// SAFETY: `r` is a live IGameInputReading; slot 6 is GetDevice (returns void, hands back
// an AddRef'd device), slot 2 is Release.
unsafe {
let vtbl = *(r as *mut *const IGameInputReadingVtbl);
((*vtbl).GetDevice)(r, &mut dev);
((*vtbl).Release)(r);
}
dev
}
/// Hunt for a device with `pid`, polling because several devices take turns reporting and
/// `GetCurrentReading(kind, null, …)` hands back whichever one spoke most recently. A box with
/// a chatty pad on it (a DualSense streams continuously) will otherwise never yield ours.
fn find_device(&self, pid: u16, timeout: std::time::Duration) -> Option<*mut c_void> {
let deadline = std::time::Instant::now() + timeout;
let mut seen: Vec<(u16, u16)> = Vec::new();
loop {
for kind in [GAME_INPUT_KIND_GAMEPAD, GAME_INPUT_KIND_CONTROLLER] {
if let Some(r) = self.reading(kind) {
let dev = Self::device_of(r);
if !dev.is_null() {
let ids = Self::device_ids(dev);
if !seen.contains(&ids) {
seen.push(ids);
println!(" saw device {:04X}:{:04X}", ids.0, ids.1);
}
if ids.1 == pid {
return Some(dev);
}
// SAFETY: not our target; drop our reference.
unsafe {
let v = *(dev as *mut *const IGameInputDeviceVtbl);
((*v).Release)(dev);
}
}
}
}
if std::time::Instant::now() >= deadline {
return None;
}
std::thread::sleep(std::time::Duration::from_millis(60));
}
}
/// Does GameInput see a gamepad at all? This is the matrix row.
pub fn report(&self) {
for (kind, label) in [
(GAME_INPUT_KIND_GAMEPAD, "Gamepad"),
(GAME_INPUT_KIND_CONTROLLER, "Controller"),
] {
match self.reading_wait(kind, std::time::Duration::from_secs(3)) {
Some(r) => {
println!(" {label:<11}: reading available (a device is producing input)");
// SAFETY: `r` is a live IGameInputReading we own a reference to.
unsafe {
let vtbl = *(r as *mut *const IGameInputReadingVtbl);
((*vtbl).Release)(r);
}
}
None => println!(" {label:<11}: no reading"),
}
}
}
/// Drive `SetRumbleState` on whatever device is currently reporting.
///
/// Returns whether a device was found and driven. The values are deliberately the caller's to
/// choose: the whole point of the probe is sending four DISTINCT magnitudes so the pad's decoded
/// output identifies the channel mapping by itself.
pub fn rumble(
&self,
params: GameInputRumbleParams,
hold: std::time::Duration,
target_pid: Option<u16>,
) -> bool {
// A gamepad reading is the right one to hang this off: it is the kind an Xbox pad produces,
// and the device it names is the one a game would rumble.
let wait = std::time::Duration::from_secs(6);
let dev = match target_pid {
Some(pid) => {
println!(" hunting for PID {pid:04X}");
match self.find_device(pid, wait) {
Some(d) => d,
None => {
println!(" never saw PID {pid:04X} — nothing to rumble");
return false;
}
}
}
None => {
let Some(r) = self
.reading_wait(GAME_INPUT_KIND_GAMEPAD, wait)
.or_else(|| self.reading_wait(GAME_INPUT_KIND_CONTROLLER, wait))
else {
println!(" no GameInput reading — nothing to rumble");
return false;
};
Self::device_of(r)
}
};
if dev.is_null() {
println!(" reading had no device");
return false;
}
let ids = Self::device_ids(dev);
println!(" driving {:04X}:{:04X}", ids.0, ids.1);
println!(
" SetRumbleState(low={:.2} high={:.2} lt={:.2} rt={:.2}) for {:?}",
params.lowFrequency,
params.highFrequency,
params.leftTrigger,
params.rightTrigger,
hold
);
// SAFETY: `dev` is a live IGameInputDevice; slot 10 is SetRumbleState, which returns void
// and takes a const pointer to the four-float struct above.
unsafe {
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
((*vtbl).SetRumbleState)(dev, &params);
}
std::thread::sleep(hold);
let off = GameInputRumbleParams::default();
// SAFETY: as above; stopping is the same call with zeroes.
unsafe {
let vtbl = *(dev as *mut *const IGameInputDeviceVtbl);
((*vtbl).SetRumbleState)(dev, &off);
((*vtbl).Release)(dev);
}
println!(" cleared.");
true
}
}
+39
View File
@@ -28,6 +28,9 @@ fn main() {
std::process::exit(2);
}
#[cfg(windows)]
mod gameinput;
#[cfg(windows)]
mod imp {
use std::time::Duration;
@@ -486,6 +489,9 @@ mod imp {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut rounds = 0usize;
let mut rumble_slot: Option<u32> = None;
let mut gameinput_report = false;
let mut gi_rumble: Option<String> = None;
let mut gi_pid: Option<u16> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
@@ -493,6 +499,17 @@ mod imp {
rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20);
i += 1;
}
"--gameinput" => gameinput_report = true,
"--gi-pid" => {
gi_pid = args
.get(i + 1)
.and_then(|v| u16::from_str_radix(v.trim_start_matches("0x"), 16).ok());
i += 1;
}
"--gi-rumble" => {
gi_rumble = args.get(i + 1).cloned();
i += 1;
}
"--rumble" => {
rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0));
i += 1;
@@ -560,6 +577,28 @@ mod imp {
println!();
rumble(slot, 3);
}
if gameinput_report || gi_rumble.is_some() {
println!("\n== GameInput ==");
match crate::gameinput::GameInput::create() {
Err(e) => println!(" unavailable: {e}"),
Ok(gi) => {
gi.report();
if let Some(spec) = &gi_rumble {
let v: Vec<f32> = spec
.split(',')
.map(|p| p.trim().parse().unwrap_or(0.0))
.collect();
let p = crate::gameinput::GameInputRumbleParams {
lowFrequency: v.first().copied().unwrap_or(0.0),
highFrequency: v.get(1).copied().unwrap_or(0.0),
leftTrigger: v.get(2).copied().unwrap_or(0.0),
rightTrigger: v.get(3).copied().unwrap_or(0.0),
};
gi.rumble(p, std::time::Duration::from_secs(3), gi_pid);
}
}
}
}
}
}