From 4e04c2bbf8f608593b82b8e504a0727150150cec Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 17:03:25 +0200 Subject: [PATCH] feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `xbox_windows` into the per-pad router so an Xbox-family pad can be built as a real HID device instead of the XUSB companion, and adds the knob that selects between them. Opt-in rather than the new default, deliberately. XUSB is what classic-XInput games read today; the HID pad buys the Steam / WGI / GameInput / DirectInput visibility XUSB can never have, but whether Windows promotes it into an Xbox-profile device that XInput and WGI Gamepad accept is still the open question. Flipping the default before that is settled would trade a known-working path for an unproven one. The two backends are mutually exclusive per pad by construction — one match arm or the other — because presenting both hands a game two controllers for one pair of hands. Verified on .173: cargo check -p punktfunk-host exit 0, clippy -D warnings clean, `cargo test -p punktfunk-host gamepad` 8/8 green, fmt clean. --- crates/punktfunk-host/src/native/gamepad.rs | 24 +++++++++++++++++++++ crates/punktfunk-host/src/native/input.rs | 22 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index ba8b16ad..12c9ec15 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -221,6 +221,30 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { chosen } +/// Whether an Xbox-family pad should be built as a real **HID** device +/// ([`crate::inject::xbox_windows`]) instead of the **XUSB** companion +/// ([`crate::inject::gamepad`]). Windows only; `PUNKTFUNK_XBOX_BACKEND=hid` opts in. +/// +/// **Why this is a knob and not simply the new default.** The XUSB companion registers only +/// `GUID_DEVINTERFACE_XUSB` and exposes no HID collection, so Steam's hidapi enumeration, +/// DirectInput, `joy.cpl` and WGI/GameInput cannot see it at all — only classic `XInputGetState` +/// via xinput1_4's interface walk does. That is what left a reporter with a dead controller for two +/// weeks (2026-08-09) until they switched the client to DualSense, a real HID pad. +/// +/// But the converse is not yet proven: classic-XInput games DO read the XUSB pad today, and whether +/// Windows promotes our HID pad into an Xbox-profile device that XInput and WGI `Gamepad` accept is +/// exactly the open question. Until that is settled on glass, flipping the default would trade a +/// known-working path for an unproven one. Opt in, measure, then decide. +/// +/// The two backends are mutually exclusive per pad by construction (one match arm or the other) — +/// presenting both would hand a game two controllers for one pair of hands. +#[cfg(target_os = "windows")] +pub(super) fn windows_xbox_hid() -> bool { + std::env::var("PUNKTFUNK_XBOX_BACKEND") + .map(|v| v.trim().eq_ignore_ascii_case("hid")) + .unwrap_or(false) +} + /// Resolve the client's gamepad-backend preference (the env/logging shell around /// [`pick_gamepad`]). Always concrete — the `Welcome` reports what the session will drive. pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index 377d5037..3a1a56bd 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -123,6 +123,11 @@ struct Pads { steamctrl2_puck: Option, #[cfg(target_os = "windows")] dualsense_win: Option, + /// The HID-visible Xbox pad ([`crate::inject::xbox_windows`]) — used INSTEAD of `xbox360`'s + /// XUSB companion when [`super::gamepad::windows_xbox_hid`] says so. Never both at once: two + /// devices for one wire pad is the "the game sees two controllers" bug. + #[cfg(target_os = "windows")] + xbox_hid: Option, #[cfg(target_os = "windows")] dualsense_edge_win: Option, #[cfg(target_os = "windows")] @@ -165,6 +170,8 @@ impl Pads { #[cfg(target_os = "windows")] dualsense_win: None, #[cfg(target_os = "windows")] + xbox_hid: None, + #[cfg(target_os = "windows")] dualsense_edge_win: None, #[cfg(target_os = "windows")] dualshock4_win: None, @@ -291,6 +298,16 @@ impl Pads { .steamdeck_win .get_or_insert_with(crate::inject::steam_deck_windows::SteamDeckWindowsManager::new) .handle(ev), + // The Xbox pad, as a real HID device rather than the XUSB companion. Opt-in for now + // (see `windows_xbox_hid`): XUSB is what classic-XInput games read today, and this + // trades that for the Steam / WGI / GameInput / DirectInput visibility XUSB can never + // have — a swap that has to be proven on glass before it becomes the default. + #[cfg(target_os = "windows")] + GamepadPref::Xbox360 | GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => { + self.xbox_hid + .get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new) + .handle(ev) + } _ => self .xbox360 .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) @@ -451,6 +468,11 @@ impl Pads { } #[cfg(target_os = "windows")] { + if let Some(m) = &mut self.xbox_hid { + // Rumble only — an Xbox pad has no rich-feedback plane (no lightbar / adaptive + // triggers), same as its XUSB sibling above. + m.pump(&mut rumble, &mut hidout); + } if let Some(m) = &mut self.dualsense_win { m.pump(&mut rumble, &mut hidout); }