feat(drivers/pf-gamepad): declare the rumble output report, and the Xbox pad gets rumble at all
`XBOX_RDESC` declared no OUTPUT item — zero `0x91` bytes. hidclass routes an output report only if
the descriptor declares one, so `on_output_report` never fired, `publish_output` never wrote the
out-ring, and `parse_xbox_output` in `inject/windows/xbox_windows.rs` was unreachable code. The
entire host-side rumble plane was already built, wired and tested, and was simply never fed. The
HID Xbox pad therefore had NO rumble whatsoever, not merely no trigger rumble.
This appends the PID-page `Set Effect Report` collection, report id `0x03`, 8 payload bytes, sized
to exactly the layout `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already
specify. It is declared AFTER the final Input item and re-states every global it uses, so the
16-byte input layout `xbox_proto`'s tests pin is untouched.
⚠️ PROVENANCE: hand-written, and it could not be otherwise. The Elite capture taken for WP-A
reports `OUTPUT items: 0` — Windows exposes no literal descriptor bytes and the reconstruction
carries no output collection for that pad — so there was nothing to copy. The comment says so and
asks for a Linux hidraw capture to replace it.
Also adds a compile-time assert pairing every descriptor with its HID-descriptor `wReportLength`.
Those are two copies of one length, edited in different places, and a mismatch fails SILENTLY:
hidclass asks for `wReportLength` bytes, parses whatever it got, and the pad either enumerates
truncated or not at all with nothing naming the cause. It now cannot build out of step. This
caught nothing today because I updated both by hand, but it is exactly the trap this descriptor
has already sprung twice in other forms.
MEASURED ON .173 (Win11 26200), with the pad promoted via the WP-B0 xinputhid bus-filter config:
* `XInputSetState(0xFFFF, 0x8000)` produced, on the host side,
`rumble from game: pad=0 low=65535 high=32767`
`rumble from game: pad=0 low=0 high=0`
i.e. XInputSetState -> xinputhid -> HID output report 0x03 -> on_output_report -> out-ring ->
parse_xbox_output -> PadFeedback. First rumble this backend has ever delivered.
* The round-trip values confirm the descriptor's `Logical Maximum (100)` percent domain is
right: 0x8000 -> 50% -> 32767. A 0..255 domain would have produced different numbers.
* This also answers `trigger-rumble-plane.md`'s WP0 gate — YES, Windows writes output reports
to a synthesized 045E:0B13 — which was blocking the whole trigger plane.
* classic XInput reads the pad fully: packets advancing, `buttons=0x1000` (the devtest's A), and
`LX [-32768..31744]`, the complete sweep. LY/RX/RY frozen is correct; the devtest drives only
LS-X and A.
VERIFIED
* `cargo test -p pf-inject --lib xbox` 11/11 — the input layout is byte-identical, as intended.
* `hid-descriptor-dump --rust-source ... --symbol XBOX_RDESC` decodes it clean: input report
0x01 unchanged at 16 bytes and the same offsets, new output report 0x03 at 9 bytes on the
wire, feature 0x85 unchanged, `structure: OK`.
* Driver builds and signs on .173 with the WDK; the new const asserts compile, so all five
descriptor/wReportLength pairs agree.
* fmt clean on both tools; .173 fully reverted afterwards.
NOT VERIFIED
* The enable-mask bit assignments for the two TRIGGER actuators. `XINPUT_VIBRATION` has only two
members, so XInput can never drive them and this run could not exercise them. Still open, as
trigger-rumble-plane.md WP0 says.
* That this equals the real pad's output collection, byte for byte. Needs Linux hidraw.
* Nothing about the INF is changed: `pf_gamepad.inx` still has no AddReg, so none of the
promotion config ships. The rumble descriptor is inert until something drives it.
This commit is contained in:
@@ -323,7 +323,7 @@ static DECK_RDESC: [u8; 38] = [
|
||||
// report forever. `0x3F` payload bytes so `FeatureReportByteLength` lands on 64, the buffer size
|
||||
// `channel_proof::query` asks with; the proof itself needs 17.
|
||||
#[rustfmt::skip]
|
||||
static XBOX_RDESC: [u8; 150] = [
|
||||
static XBOX_RDESC: [u8; 223] = [
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x05, // Usage (Game Pad)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
@@ -386,6 +386,67 @@ static XBOX_RDESC: [u8; 150] = [
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x81, 0x03, // Input (Cnst,Var,Abs) — pad to a byte boundary
|
||||
// ---- Rumble OUTPUT report `0x03` (Physical Interface Device page) ----
|
||||
//
|
||||
// Without this the pad can receive NOTHING. hidclass routes an output report only if the
|
||||
// descriptor declares one, so with no `0x91` item `on_output_report` never fires,
|
||||
// `publish_output` never writes the ring, and `parse_xbox_output`
|
||||
// (`inject/windows/xbox_windows.rs`) is unreachable code — the whole host-side rumble plane is
|
||||
// already built and was simply never fed. That is why the HID Xbox pad had no rumble at all,
|
||||
// not merely no trigger rumble.
|
||||
//
|
||||
// ⚠️ PROVENANCE — HAND-WRITTEN, and it could not be otherwise. Every other output collection in
|
||||
// this file is a capture, and §3 of `design/xbox-pad-windows-handoff.md` insists on captures.
|
||||
// But the Elite capture taken for that work reports `OUTPUT items: 0` (Windows exposes no
|
||||
// literal report-descriptor bytes; hidapi reconstructs from `HidD_GetPreparsedData`, and that
|
||||
// reconstruction carries no output collection for this pad). So there was nothing to copy.
|
||||
// This block is the documented Xbox One S / Elite Bluetooth rumble report — PID-page
|
||||
// `Set Effect Report`, id `0x03`, 8 payload bytes — chosen because it is exactly the layout
|
||||
// `parse_xbox_output` and `design/trigger-rumble-plane.md` §2.1 already specify:
|
||||
// [0x03][enable][left_trigger][right_trigger][left][right][duration][delay][loop]
|
||||
// with magnitudes 0..100 (hence `Logical Maximum (100)`, not 255).
|
||||
// **Replace it with a Linux hidraw capture when one can be taken** — that is the only route to
|
||||
// byte-exact truth here, and the enable-bit assignments for the two TRIGGER actuators remain
|
||||
// unverified (see trigger-rumble-plane.md WP0).
|
||||
//
|
||||
// Declared AFTER the final Input item and re-stating every global it uses, so it cannot
|
||||
// retroactively alter the 16-byte input layout `xbox_proto`'s tests pin.
|
||||
0x05, 0x0F, // Usage Page (Physical Interface Device)
|
||||
0x09, 0x21, // Usage (Set Effect Report)
|
||||
0x85, 0x03, // Report ID (3)
|
||||
0xA1, 0x02, // Collection (Logical)
|
||||
0x09, 0x97, // Usage (DC Enable Actuators)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x01, // Logical Maximum (1)
|
||||
0x75, 0x04, // Report Size (4)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x91, 0x02, // Output (Data,Var,Abs) — the enable mask, low nibble
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x00, // Logical Maximum (0)
|
||||
0x75, 0x04, // Report Size (4)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x91, 0x03, // Output (Cnst,Var,Abs) — pad the enable byte
|
||||
0x09, 0x70, // Usage (Magnitude)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x25, 0x64, // Logical Maximum (100) — percent, NOT 255
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x95, 0x04, // Report Count (4) — LT, RT, left handle, right handle
|
||||
0x91, 0x02, // Output (Data,Var,Abs)
|
||||
0x09, 0x50, // Usage (Duration)
|
||||
0x66, 0x01, 0x10, // Unit (SI Linear: seconds)
|
||||
0x55, 0x0E, // Unit Exponent (-2) — centiseconds
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x91, 0x02, // Output (Data,Var,Abs)
|
||||
0x09, 0xA7, // Usage (Start Delay) — same unit and range as Duration
|
||||
0x91, 0x02, // Output (Data,Var,Abs)
|
||||
0x65, 0x00, // Unit (None)
|
||||
0x55, 0x00, // Unit Exponent (0)
|
||||
0x09, 0x7C, // Usage (Loop Count)
|
||||
0x91, 0x02, // Output (Data,Var,Abs)
|
||||
0xC0, // End Collection
|
||||
// The channel-proof feature report — see the ⚠️ above. Declared last so it cannot disturb the
|
||||
// INPUT layout `xbox_proto` packs against: every global item here (Report Size/Count, Logical
|
||||
// Min/Max) is re-stated after the final Input item, so nothing above is retroactively changed.
|
||||
@@ -413,7 +474,22 @@ static HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x11, 0x01
|
||||
static DS4_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xFB, 0x01];
|
||||
static EDGE_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x85, 0x01];
|
||||
static DECK_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x26, 0x00]; // 38 bytes
|
||||
static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0x96, 0x00]; // 150 bytes
|
||||
static XBOX_HID_DESC: [u8; 9] = [0x09, 0x21, 0x00, 0x01, 0x00, 0x01, 0x22, 0xDF, 0x00]; // 223 bytes
|
||||
|
||||
// Each `wReportLength` above is a SECOND copy of a length that already exists as its descriptor's
|
||||
// array size, and the two are edited in different places. Getting them out of step does not fail
|
||||
// loudly — hidclass asks for `wReportLength` bytes and then parses whatever it got, so the pad
|
||||
// either enumerates with a truncated descriptor or fails to enumerate at all, with nothing naming
|
||||
// the cause. Assert the pairing at compile time instead; adding an item to a descriptor now cannot
|
||||
// build until its length is updated too.
|
||||
const fn declared_len(hid_desc: &[u8; 9]) -> usize {
|
||||
(hid_desc[7] as usize) | ((hid_desc[8] as usize) << 8)
|
||||
}
|
||||
const _: () = assert!(declared_len(&HID_DESC) == DUALSENSE_RDESC.len());
|
||||
const _: () = assert!(declared_len(&DS4_HID_DESC) == DS4_RDESC.len());
|
||||
const _: () = assert!(declared_len(&EDGE_HID_DESC) == DS_EDGE_RDESC.len());
|
||||
const _: () = assert!(declared_len(&DECK_HID_DESC) == DECK_RDESC.len());
|
||||
const _: () = assert!(declared_len(&XBOX_HID_DESC) == XBOX_RDESC.len());
|
||||
|
||||
// HID_DEVICE_ATTRIBUTES (32 bytes): Size(u32)=32, VendorID, ProductID, VersionNumber, Reserved[11].
|
||||
// `devtype` selects the identity: PS family (same Sony VID/version) or the N4-spike Deck.
|
||||
|
||||
@@ -54,22 +54,28 @@ fn extract_rust_array(src: &str, symbol: &str) -> Result<Vec<u8>, String> {
|
||||
let at = src
|
||||
.find(&format!("static {symbol}:"))
|
||||
.ok_or_else(|| format!("no `static {symbol}:` in that file"))?;
|
||||
let open = src[at..]
|
||||
.find('[')
|
||||
.and_then(|i| src[at + i + 1..].find('[').map(|j| at + i + 1 + j + 1))
|
||||
.ok_or("could not find the array literal")?;
|
||||
let close = src[open..]
|
||||
.find(']')
|
||||
.ok_or("array literal is never closed")?
|
||||
+ open;
|
||||
// Strip trailing `// ...` comments LINE BY LINE before splitting on commas — the annotations in
|
||||
// these arrays contain commas themselves (`// Input (Data,Var,Abs)`), so comma-splitting first
|
||||
// scatters comment text into the byte stream.
|
||||
let body: String = src[open..close]
|
||||
|
||||
// 🛑 STRIP COMMENTS FIRST, then find the brackets — not the other way round. These arrays are
|
||||
// heavily annotated and the annotations contain both `,` and `]` (a comment documenting a wire
|
||||
// layout as `[0x03][enable][left]…` is real, and it appears inside `XBOX_RDESC`). Locating the
|
||||
// closing bracket on the raw text stops at the first `]` in a COMMENT and silently truncates
|
||||
// the array — which reads as a corrupt descriptor rather than a parse bug.
|
||||
let tail: String = src[at..]
|
||||
.lines()
|
||||
.map(|l| l.split("//").next().unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
.join("\n");
|
||||
|
||||
// First `[` is the type's `[u8; N]`; the next one opens the literal.
|
||||
let open = tail
|
||||
.find('[')
|
||||
.and_then(|i| tail[i + 1..].find('[').map(|j| i + 1 + j + 1))
|
||||
.ok_or("could not find the array literal")?;
|
||||
let close = tail[open..]
|
||||
.find(']')
|
||||
.ok_or("array literal is never closed")?
|
||||
+ open;
|
||||
let body = &tail[open..close];
|
||||
let mut out = Vec::new();
|
||||
for tok in body.split(',') {
|
||||
let tok = tok.trim();
|
||||
|
||||
@@ -41,7 +41,9 @@ mod imp {
|
||||
};
|
||||
use windows::Win32::Foundation::ERROR_NO_MORE_ITEMS;
|
||||
use windows::Win32::System::Com::CoIncrementMTAUsage;
|
||||
use windows::Win32::UI::Input::XboxController::{XINPUT_STATE, XInputGetState};
|
||||
use windows::Win32::UI::Input::XboxController::{
|
||||
XINPUT_STATE, XINPUT_VIBRATION, XInputGetState, XInputSetState,
|
||||
};
|
||||
// `Interface` brings `cast()` into scope, which is how a WinRT `Gamepad` is correlated to the
|
||||
// `RawGameController` that knows its name.
|
||||
use windows::core::{GUID, Interface};
|
||||
@@ -158,6 +160,45 @@ mod imp {
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
}
|
||||
|
||||
/// Drive rumble into an XInput slot and hold it, so the other end of the pipe can be watched.
|
||||
///
|
||||
/// This is the WP0 probe from `design/trigger-rumble-plane.md`: does anything Windows-side ever
|
||||
/// write an output report back to a synthesized `045E:0B13`? For the HID backend the chain
|
||||
/// under test is `XInputSetState` → `xinputhid` → a HID output report on our collection →
|
||||
/// `on_output_report` → the shm out-ring → `parse_xbox_output`, and the observable is the
|
||||
/// devtest printing `rumble from game`. Run this with the devtest live and watch its stdout.
|
||||
///
|
||||
/// ⚠️ `XINPUT_VIBRATION` has exactly TWO members, so this can only ever drive the two handle
|
||||
/// motors — it can never source TRIGGER rumble. That is a property of the API, not of our
|
||||
/// plumbing, and it is why the trigger plane needs its own transport.
|
||||
fn rumble(slot: u32, seconds: u64) {
|
||||
println!("== RUMBLE PROBE: XInputSetState(slot {slot}) for {seconds}s ==");
|
||||
let v = XINPUT_VIBRATION {
|
||||
wLeftMotorSpeed: 0xFFFF,
|
||||
wRightMotorSpeed: 0x8000,
|
||||
};
|
||||
// SAFETY: `v` is a valid, fully-initialised XINPUT_VIBRATION.
|
||||
let rc = unsafe { XInputSetState(slot, &v) };
|
||||
println!(
|
||||
" set low=0xFFFF high=0x8000 -> rc={rc}{}",
|
||||
if rc == 0 {
|
||||
" (accepted)"
|
||||
} else {
|
||||
" (REJECTED)"
|
||||
}
|
||||
);
|
||||
if rc != 0 {
|
||||
println!(" (slot not connected — nothing downstream can be concluded)");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(seconds));
|
||||
let off = XINPUT_VIBRATION::default();
|
||||
// SAFETY: as above.
|
||||
let rc2 = unsafe { XInputSetState(slot, &off) };
|
||||
println!(" clear low=0 high=0 -> rc={rc2}");
|
||||
println!(" ⇒ now check the devtest stdout for `rumble from game`.");
|
||||
}
|
||||
|
||||
fn xinput() {
|
||||
println!("== classic XInput (xinput1_4 walks GUID_DEVINTERFACE_XUSB) ==");
|
||||
for slot in 0..4u32 {
|
||||
@@ -281,6 +322,100 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample XInput over the whole watch window and report the RANGE each axis covered.
|
||||
///
|
||||
/// A single `XInputGetState` call cannot tell "translated correctly" from "stuck at zero" —
|
||||
/// a sweeping stick reads 0 every time it crosses centre. `dwPacketNumber` advancing proves
|
||||
/// the state is changing at all; the min/max spread proves the AXES specifically are, which is
|
||||
/// the half that can fail on its own while buttons work.
|
||||
struct XiTrack {
|
||||
first_packet: u32,
|
||||
last_packet: u32,
|
||||
lx: (i16, i16),
|
||||
ly: (i16, i16),
|
||||
rx: (i16, i16),
|
||||
ry: (i16, i16),
|
||||
buttons: u16,
|
||||
lt: (u8, u8),
|
||||
rt: (u8, u8),
|
||||
}
|
||||
|
||||
fn xinput_watch(rounds: usize) {
|
||||
println!("\n== XINPUT WATCH ({rounds} samples) — do PACKETS advance and AXES move? ==");
|
||||
for slot in 0..4u32 {
|
||||
let mut t: Option<XiTrack> = None;
|
||||
for _ in 0..rounds {
|
||||
let mut st = XINPUT_STATE::default();
|
||||
// SAFETY: `st` is a valid, fully-initialised XINPUT_STATE.
|
||||
if unsafe { XInputGetState(slot, &mut st) } != 0 {
|
||||
break;
|
||||
}
|
||||
let g = st.Gamepad;
|
||||
match &mut t {
|
||||
None => {
|
||||
t = Some(XiTrack {
|
||||
first_packet: st.dwPacketNumber,
|
||||
last_packet: st.dwPacketNumber,
|
||||
lx: (g.sThumbLX, g.sThumbLX),
|
||||
ly: (g.sThumbLY, g.sThumbLY),
|
||||
rx: (g.sThumbRX, g.sThumbRX),
|
||||
ry: (g.sThumbRY, g.sThumbRY),
|
||||
buttons: g.wButtons.0,
|
||||
lt: (g.bLeftTrigger, g.bLeftTrigger),
|
||||
rt: (g.bRightTrigger, g.bRightTrigger),
|
||||
});
|
||||
}
|
||||
Some(t) => {
|
||||
t.last_packet = st.dwPacketNumber;
|
||||
t.lx = (t.lx.0.min(g.sThumbLX), t.lx.1.max(g.sThumbLX));
|
||||
t.ly = (t.ly.0.min(g.sThumbLY), t.ly.1.max(g.sThumbLY));
|
||||
t.rx = (t.rx.0.min(g.sThumbRX), t.rx.1.max(g.sThumbRX));
|
||||
t.ry = (t.ry.0.min(g.sThumbRY), t.ry.1.max(g.sThumbRY));
|
||||
t.buttons |= g.wButtons.0;
|
||||
t.lt = (t.lt.0.min(g.bLeftTrigger), t.lt.1.max(g.bLeftTrigger));
|
||||
t.rt = (t.rt.0.min(g.bRightTrigger), t.rt.1.max(g.bRightTrigger));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
match t {
|
||||
None => println!(" slot {slot}: not connected"),
|
||||
Some(t) => {
|
||||
let moved = t.last_packet != t.first_packet;
|
||||
let axes_moved = t.lx.0 != t.lx.1
|
||||
|| t.ly.0 != t.ly.1
|
||||
|| t.rx.0 != t.rx.1
|
||||
|| t.ry.0 != t.ry.1
|
||||
|| t.lt.0 != t.lt.1
|
||||
|| t.rt.0 != t.rt.1;
|
||||
println!(
|
||||
" slot {slot}: packets {}..{} ({}), buttons seen 0x{:04X}",
|
||||
t.first_packet,
|
||||
t.last_packet,
|
||||
if moved { "ADVANCING" } else { "FROZEN" },
|
||||
t.buttons
|
||||
);
|
||||
println!(
|
||||
" LX [{}..{}] LY [{}..{}] RX [{}..{}] RY [{}..{}] LT [{}..{}] RT [{}..{}] -> axes {}",
|
||||
t.lx.0,
|
||||
t.lx.1,
|
||||
t.ly.0,
|
||||
t.ly.1,
|
||||
t.rx.0,
|
||||
t.rx.1,
|
||||
t.ry.0,
|
||||
t.ry.1,
|
||||
t.lt.0,
|
||||
t.lt.1,
|
||||
t.rt.0,
|
||||
t.rt.1,
|
||||
if axes_moved { "MOVING" } else { "STUCK" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag every device whose current reading differs from the baseline one. Once a device has
|
||||
/// moved it stays flagged — a pad that twitches once in twenty samples is still LIVE.
|
||||
fn mark_moved(base: &[Sample], now: &[Sample], moved: &mut [bool]) {
|
||||
@@ -350,6 +485,7 @@ mod imp {
|
||||
pub fn run() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut rounds = 0usize;
|
||||
let mut rumble_slot: Option<u32> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
@@ -357,10 +493,16 @@ mod imp {
|
||||
rounds = args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(20);
|
||||
i += 1;
|
||||
}
|
||||
"--rumble" => {
|
||||
rumble_slot = Some(args.get(i + 1).and_then(|v| v.parse().ok()).unwrap_or(0));
|
||||
i += 1;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"win-input-matrix [--watch N]\n\n \
|
||||
--watch N sample WGI N times and report LIVE vs MUTE per device\n\n\
|
||||
"win-input-matrix [--watch N] [--rumble SLOT]\n\n \
|
||||
--watch N sample WGI N times and report LIVE vs MUTE per device\n \
|
||||
--rumble SLOT drive XInputSetState into that slot for 3 s (WP0 probe:\n \
|
||||
does anything write an output report back to our pad?)\n\n\
|
||||
ALWAYS take a baseline with your virtual pad STOPPED and diff it: a real\n\
|
||||
pad on the box owns XInput slot 0 and shows up in WGI."
|
||||
);
|
||||
@@ -412,6 +554,11 @@ mod imp {
|
||||
|
||||
if rounds > 0 {
|
||||
watch(rounds);
|
||||
xinput_watch(rounds);
|
||||
}
|
||||
if let Some(slot) = rumble_slot {
|
||||
println!();
|
||||
rumble(slot, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user