feat(host/pads): route the Xbox pad to the HID backend behind PUNKTFUNK_XBOX_BACKEND=hid

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.
This commit is contained in:
2026-08-09 17:03:25 +02:00
parent d498ff4a60
commit 4e04c2bbf8
2 changed files with 46 additions and 0 deletions
@@ -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 {
+22
View File
@@ -123,6 +123,11 @@ struct Pads {
steamctrl2_puck: Option<crate::inject::steam_controller2::Triton2Manager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
/// 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<crate::inject::xbox_windows::XboxWindowsManager>,
#[cfg(target_os = "windows")]
dualsense_edge_win: Option<crate::inject::dualsense_edge_windows::DualSenseEdgeWindowsManager>,
#[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);
}