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:
2026-08-09 20:07:34 +02:00
parent 13438b1287
commit f9fe496dbc
3 changed files with 247 additions and 18 deletions
+150 -3
View File
@@ -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);
}
}
}