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); }