diff --git a/tools/win-input-matrix/Cargo.toml b/tools/win-input-matrix/Cargo.toml index ad719ba8..7d94386e 100644 --- a/tools/win-input-matrix/Cargo.toml +++ b/tools/win-input-matrix/Cargo.toml @@ -30,5 +30,6 @@ windows = { version = "0.62", features = [ "Win32_System_Com", "Gaming_Input", "Foundation", + "Win32_System_LibraryLoader", "Foundation_Collections", ] } diff --git a/tools/win-input-matrix/src/gameinput.rs b/tools/win-input-matrix/src/gameinput.rs new file mode 100644 index 00000000..21955a19 --- /dev/null +++ b/tools/win-input-matrix/src/gameinput.rs @@ -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 { + let name: Vec = "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, + ) -> 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 + } +} diff --git a/tools/win-input-matrix/src/main.rs b/tools/win-input-matrix/src/main.rs index 78ba28a7..545ff65c 100644 --- a/tools/win-input-matrix/src/main.rs +++ b/tools/win-input-matrix/src/main.rs @@ -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 = std::env::args().skip(1).collect(); let mut rounds = 0usize; let mut rumble_slot: Option = None; + let mut gameinput_report = false; + let mut gi_rumble: Option = None; + let mut gi_pid: Option = 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 = 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); + } + } + } + } } }