`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.
332 lines
15 KiB
Rust
332 lines
15 KiB
Rust
//! 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, ¶ms);
|
|
}
|
|
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
|
|
}
|
|
}
|