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:
@@ -30,5 +30,6 @@ windows = { version = "0.62", features = [
|
||||
"Win32_System_Com",
|
||||
"Gaming_Input",
|
||||
"Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Foundation_Collections",
|
||||
] }
|
||||
|
||||
@@ -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, ¶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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user