diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index aa6b4ad7..78200f10 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -276,6 +276,9 @@ impl PadInfo { GamepadPref::DualSenseEdge => "DualSense Edge", GamepadPref::DualShock4 => "DualShock 4", GamepadPref::XboxOne => "Xbox One", + // Unreachable from `pref_for_type` today — SDL has no Elite `GamepadType` — but a + // pinned setting can carry it, and an empty label there reads as a plain Xbox pad. + GamepadPref::XboxElite => "Xbox Elite Series 2", GamepadPref::SteamDeck => "Steam Deck", GamepadPref::SteamController => "Steam Controller", GamepadPref::SteamController2 => "Steam Controller 2", diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 4e2c8cc9..48d3813b 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -804,6 +804,24 @@ pub mod gamepad { /// `XBOX_INPUT_REPORT_LEN` (16). The driver serves per-identity report lengths because /// hidclass sizes its buffer from the descriptor and refuses an over-long source. pub const DEVTYPE_XBOX: u8 = 4; + /// `device_type` = Xbox One S controller over Bluetooth (`VID_045E&PID_02FD`). + /// + /// ⭐ **Shares [`DEVTYPE_XBOX`]'s report descriptor, byte for byte.** All three Xbox identities + /// are the same pad in HID terms — same axes, same trigger pair, same hat, same 15 buttons, + /// same rumble output report — and differ ONLY in VID/PID, product string and INF model line. + /// The descriptor is the report SHAPE; the identity is what the OS keys mappings off. Giving + /// each identity its own hand-written descriptor would triple a debt that has already cost + /// three separate bugs (see the `XBOX_RDESC` provenance block in the driver). + pub const DEVTYPE_XBOX_ONE_S: u8 = 5; + /// `device_type` = Xbox Elite Wireless Controller Series 2 (`VID_045E&PID_0B22`) — the + /// hardware `tools/hid-descriptor-dump` captured on `.173`. + /// + /// ⚠️ The four paddles are NOT in this identity's report yet. See [`DEVTYPE_XBOX_ONE_S`] for + /// why the descriptor is shared, and `design/xbox-pad-windows-handoff.md` §4 WP-C for the + /// unresolved tension: once the pad is promoted, `xinputhid` claims the HID collection + /// exclusively, so extra buttons declared here may be invisible to every consumer anyway. + /// That needs measuring before it is built. + pub const DEVTYPE_XBOX_ELITE: u8 = 6; /// The value a gamepad driver writes into its section's `driver_proto` field once it attaches — /// the host's positive "driver is alive on this section" signal (health check + version audit). diff --git a/crates/pf-inject/src/inject/windows/dualsense_windows.rs b/crates/pf-inject/src/inject/windows/dualsense_windows.rs index 7ae6e69c..340c1f5a 100644 --- a/crates/pf-inject/src/inject/windows/dualsense_windows.rs +++ b/crates/pf-inject/src/inject/windows/dualsense_windows.rs @@ -1031,8 +1031,15 @@ mod drain_tests { WinDsIdentity::dualsense_edge().hwid, super::super::dualshock4_windows::DS4_HWID, super::super::steam_deck_windows::DECK_HWID, - super::super::xbox_windows::XBOX_HWID, - ] { + ] + .into_iter() + // Every Xbox identity, not just the first — a new one added to the table without its INF + // model line is exactly the "pad exists, never starts, never answers a proof" failure. + .chain( + super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| i.hwid), + ) { let want = hwid.to_ascii_lowercase(); let rooted = format!("root\\{want}"); assert!( @@ -1046,7 +1053,7 @@ mod drain_tests { } } - /// The Xbox identity must install its OWN section, and the PlayStation/Deck identities must + /// EVERY Xbox identity must install its OWN section, and the PlayStation/Deck identities must /// not install that one. /// /// `pfGamepadXbox` attaches Microsoft's `xinputhid` as an upper filter and sets @@ -1057,7 +1064,10 @@ mod drain_tests { /// exclusively and would take a working pad away from Steam and SDL. /// /// Merging the two sections back together is a one-line edit that looks like tidying and is - /// not, so assert the split rather than trusting a comment to survive. + /// not, so assert the split rather than trusting a comment to survive. Both directions matter, + /// and so does the count: a new Xbox identity whose model line was pasted from a PlayStation + /// one installs `pfGamepad`, enumerates perfectly, and is simply never promoted — a silent + /// half-failure that reads on glass as "XInput doesn't see it", the original field symptom. #[test] fn only_the_xbox_identity_installs_the_xinputhid_section() { let inx = concat!( @@ -1065,9 +1075,12 @@ mod drain_tests { "/../../packaging/windows/drivers/pf-gamepad/pf_gamepad.inx" ); let inf = std::fs::read_to_string(inx).expect("read pf_gamepad.inx"); - let xbox = super::super::xbox_windows::XBOX_HWID.to_ascii_lowercase(); + let xbox: Vec = super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| i.hwid.to_ascii_lowercase()) + .collect(); - let mut saw_xbox_model = false; + let mut seen: Vec<&str> = Vec::new(); for line in inf.lines().map(str::trim).filter(|l| !l.starts_with(';')) { let Some((_, rhs)) = line.split_once('=') else { continue; @@ -1083,28 +1096,36 @@ mod drain_tests { .split(',') .map(|i| i.trim().to_ascii_lowercase()) .collect(); - let mentions_xbox = ids.iter().any(|i| i.contains(&xbox)); - if mentions_xbox { - saw_xbox_model = true; - assert_ne!( - section, "pfGamepad", - "the Xbox model line installs the SHARED section, so the xinputhid filter \ - would be attached to every PlayStation and Deck pad too" - ); - } else { + // `contains`, not `==`: the model lines carry both the bare id and its `root\` twin. + let matched: Vec<&str> = xbox + .iter() + .filter(|x| ids.iter().any(|i| i.contains(x.as_str()))) + .map(String::as_str) + .collect(); + if matched.is_empty() { assert_eq!( section, "pfGamepad", "a non-Xbox model line ({ids:?}) installs {section:?}; if that section carries \ the xinputhid filter, this pad is about to be handed to Microsoft's Xbox \ translator" ); + } else { + seen.extend(matched); + assert_ne!( + section, "pfGamepad", + "an Xbox model line ({ids:?}) installs the SHARED section, so either the \ + xinputhid filter would be attached to every PlayStation and Deck pad too, or \ + this Xbox pad silently never gets promoted" + ); } } - assert!( - saw_xbox_model, - "no [Models] line mentions {xbox:?} — the parse went vacuous; fix it rather than \ - deleting the assert" - ); + for want in &xbox { + assert!( + seen.contains(&want.as_str()), + "no [Models] line mentions {want:?} — either the identity has no INF line at all, \ + or the parse went vacuous; fix that rather than deleting the assert" + ); + } } /// The driver reads its HID identity back off the same hardware id — that mapping is what @@ -1146,7 +1167,7 @@ mod drain_tests { .collect(); assert_eq!( entries.len(), - 5, + 7, "parsed {entries:?} out of the driver's table — the shape changed and this test went \ vacuous; fix the parse rather than deleting the assert" ); @@ -1173,11 +1194,16 @@ mod drain_tests { super::super::steam_deck_windows::DECK_HWID, pf_driver_proto::gamepad::DEVTYPE_STEAMDECK, ), - ( - super::super::xbox_windows::XBOX_HWID, - pf_driver_proto::gamepad::DEVTYPE_XBOX, - ), - ] { + ] + .into_iter() + // All three Xbox identities: they share a report descriptor, so a hwid→devtype slip does + // NOT show up as a mangled report the way the Deck's did — it shows up as the wrong PID and + // the wrong product string, i.e. an Elite that Steam maps as a Series X|S pad. + .chain( + super::super::xbox_windows::XBOX_IDENTITIES + .iter() + .map(|i| (i.hwid, i.devtype)), + ) { let want = hwid.to_ascii_lowercase(); let got = entries.iter().find(|(id, _)| *id == want); assert_eq!( diff --git a/crates/pf-inject/src/inject/windows/xbox_windows.rs b/crates/pf-inject/src/inject/windows/xbox_windows.rs index ef347e48..8098d45c 100644 --- a/crates/pf-inject/src/inject/windows/xbox_windows.rs +++ b/crates/pf-inject/src/inject/windows/xbox_windows.rs @@ -1,5 +1,6 @@ -//! Virtual Xbox Wireless Controller on Windows via the UMDF HID minidriver (device-type 4) — the -//! HID-visible alternative to [`super::gamepad_windows`]'s XUSB companion. +//! Virtual Xbox pads on Windows via the UMDF HID minidriver — Xbox Wireless (device-type 4), +//! Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to +//! [`super::gamepad_windows`]'s XUSB companion. //! //! **Why this exists.** `pf-xusb` registers only `GUID_DEVINTERFACE_XUSB` and exposes no HID //! collection, so Steam's hidapi enumeration, DirectInput, `joy.cpl` and WGI/GameInput cannot see @@ -10,15 +11,16 @@ //! and install path the PlayStation pads already ship on. //! //! Transport is identical to the PS/Deck pads: a `SwDeviceCreate` devnode plus the sealed -//! shared-memory channel, with `device_type = 4` stamped before the magic so the driver resolves -//! the Xbox identity before hidclass asks it for descriptors. The codec is -//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte. +//! shared-memory channel, with the identity's `device_type` stamped before the magic so the driver +//! resolves it before hidclass asks for descriptors. The codec is +//! [`super::xbox_proto`]; the report it writes mirrors the driver's `XBOX_RDESC` byte for byte — +//! **one descriptor, all three identities** (see `WinXboxIdentity` below). //! -//! ⚠️ **The synthesized USB identity is a BLUETOOTH Xbox pad (`045E:0B13`) on purpose.** The wired -//! ids the rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are -//! vendor-class XUSB/GIP devices that expose no HID interface on real hardware — a HID child -//! claiming one is a device that has never existed, and Windows' inbox promotion would have nothing -//! to match. See `pf-gamepad`'s `XBOX_PID` for the alternate to try if `0B13` is not promoted. +//! ⚠️ **Every synthesized USB identity here is a BLUETOOTH Xbox pad on purpose.** The wired ids the +//! rest of the tree uses (`045E:028E` X-Box 360, `045E:02EA` Xbox One S USB) are vendor-class +//! XUSB/GIP devices that expose no HID interface on real hardware — a HID child claiming one is a +//! device that has never existed, and Windows' inbox promotion would have nothing to match. The +//! Bluetooth ids (`0B13` / `02FD` / `0B22`) are the Xbox pads that genuinely ARE HID. //! //! ⚠️ **No rich plane.** An Xbox pad has no touchpad, no lightbar, no adaptive triggers and no //! IMU in its HID contract, so `apply_rich` / `clear_rich` / `neutralize_gyro` are deliberately @@ -36,14 +38,106 @@ use anyhow::Result; use punktfunk_core::quic::RichInput; use std::time::Duration; -/// The hardware id this pad's devnode carries. Must be one `pf_gamepad.inx` declares — a package -/// rename must never touch it (`dualsense_windows::tests::hwid_matches_inf` enforces that). -pub(super) const XBOX_HWID: &str = "pf_xboxwireless"; +/// One of the Xbox identities this backend can present. Mirrors `WinDsIdentity` +/// (`super::dualsense_windows`) for the PlayStation family: the whole transport (section layout, +/// report codec, output parse, INF install section) is shared, and only the PnP identity plus the +/// `device_type` stamp differ. +/// +/// ⭐ **All three share ONE report descriptor in the driver** — `XBOX_RDESC`. In HID terms they are +/// the same pad: same stick pairs, same trigger pair, same hat, same 15 buttons, same rumble output +/// report. A descriptor is the report SHAPE; the identity is what SDL/Steam/Windows key their stock +/// mappings off, and that travels in the VID/PID below. See the `XBOX_RDESC` provenance block in +/// `packaging/windows/drivers/pf-gamepad/src/lib.rs` for why inventing two more hand-written +/// descriptors would be a net loss. +pub(super) struct WinXboxIdentity { + /// `device_type` stamped into the section — the driver picks its VID/PID and product string + /// off it, before hidclass asks anything. + pub devtype: u8, + /// PnP instance-id prefix — distinct namespaces per identity, so two Xbox models never reuse + /// the same devnode shell. + pub instance_prefix: &'static str, + /// The INF-matched hardware id. Must be one `pf_gamepad.inx` declares, on a model line that + /// installs `pfGamepadXbox` — a package rename must never touch it + /// (`dualsense_windows::tests::hwid_matches_inf` and + /// `only_the_xbox_identity_installs_the_xinputhid_section` enforce both halves). + pub hwid: &'static str, + /// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID + /// child ids (`HID\VID_045E&PID_xxxx`) — the identity SDL/RawInput/WGI read, and the one + /// Windows' own Xbox INFs match when they decide whether to promote a HID gamepad. + pub usb_vid_pid: &'static str, + /// Device Manager description. + pub description: &'static str, +} -/// The USB VID&PID token synthesized onto the devnode so hidclass derives the real-pad HID child -/// ids (`HID\VID_045E&PID_0B13`) — the identity SDL/RawInput/WGI read, and the one Windows' own -/// Xbox INFs match when they decide whether to promote a HID gamepad to an Xbox-profile pad. -const XBOX_USB_VID_PID: &str = "VID_045E&PID_0B13"; +impl WinXboxIdentity { + /// Xbox Wireless Controller (Series X|S) over Bluetooth, `045E:0B13` — the default. + /// + /// ⭐ Its PID is on Microsoft's `xinputhid.inf` allow-list twice (measured on `.173`, + /// 2026-08-09). That is not what promotes OUR pad — a software devnode matches no allow-list + /// entry, so `pfGamepadXbox`'s `AddReg` writes the two registry values those sections would + /// have written — but it is why this identity, not one of the two below, stays the default. + pub(super) const fn wireless() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX, + instance_prefix: "pf_xbox", + hwid: "pf_xboxwireless", + usb_vid_pid: "VID_045E&PID_0B13", + description: "Punktfunk Virtual Xbox Wireless Controller", + } + } + + /// Xbox One S controller over Bluetooth, `045E:02FD`. + /// + /// ⚠️ `02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has **no** + /// stage-2 `HID\…&IG_00` model line, unlike `0B13`. Promotion here rides entirely on our own + /// `AddReg`, so it should behave identically; but if a servicing update ever makes promotion + /// depend on Microsoft's list again, this is the identity that loses it first. UNVERIFIED on + /// glass. + pub(super) const fn one_s() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ONE_S, + instance_prefix: "pf_xbox_ones", + hwid: "pf_xboxones", + usb_vid_pid: "VID_045E&PID_02FD", + description: "Punktfunk Virtual Xbox One S Controller", + } + } + + /// Xbox Elite Wireless Controller Series 2, `045E:0B22` — the pad + /// `tools/hid-descriptor-dump` captured on `.173`, so the one identity here whose real hardware + /// has been measured directly. + /// + /// ⚠️ **No paddles yet.** `BTN_PADDLE1..4` still fold/drop for this identity exactly as for the + /// other two; the Elite is merely the first Xbox pad that *could* carry them natively. Adding + /// them is blocked on a measurement, not on effort — once `xinputhid` promotes the pad it + /// claims the HID collection exclusively, so extra buttons declared in the descriptor may be + /// invisible to every consumer anyway (handoff §3.6). Measure before building. + pub(super) const fn elite() -> WinXboxIdentity { + WinXboxIdentity { + devtype: pf_driver_proto::gamepad::DEVTYPE_XBOX_ELITE, + instance_prefix: "pf_xbox_elite", + hwid: "pf_xboxelite", + usb_vid_pid: "VID_045E&PID_0B22", + description: "Punktfunk Virtual Xbox Elite Wireless Controller Series 2", + } + } +} + +/// Every Xbox identity this backend can build, in wire order (`device_type` 4, 5, 6). +/// +/// A table rather than three loose constructors because the INF tests sweep it: each entry's +/// `hwid` must appear in `pf_gamepad.inx` **on a `pfGamepadXbox` model line**, and every non-Xbox +/// model line must NOT be on that section. A new identity added here without its INF line fails +/// those tests instead of failing on a user's box. +/// +/// `static`, not `const`, on purpose: [`XboxWinProto`] holds a `&'static WinXboxIdentity`, and a +/// `const` is inlined at each use site — `&CONST[i]` would depend on rvalue static promotion to +/// come out `'static` at all. +pub(super) static XBOX_IDENTITIES: [WinXboxIdentity; 3] = [ + WinXboxIdentity::wireless(), + WinXboxIdentity::one_s(), + WinXboxIdentity::elite(), +]; /// A single virtual Xbox pad: the `SwDeviceCreate`'d `pf_xbox_` devnode plus the sealed /// shared-memory channel. Dropping it removes the devnode and closes both sections. @@ -61,9 +155,9 @@ pub struct XboxWinPad { } impl XboxWinPad { - /// Create the sealed channel, stamp `device_type = Xbox` FIRST + the pad index + the neutral - /// report + the magic LAST, then spawn the devnode under the Bluetooth Xbox identity. - fn open(index: u8) -> Result { + /// Create the sealed channel, stamp `device_type` FIRST + the pad index + the neutral report + + /// the magic LAST, then spawn the devnode under `id`'s Bluetooth Xbox identity. + fn open(index: u8, id: &WinXboxIdentity) -> Result { let boot_name = pf_driver_proto::gamepad::pad_boot_name(index); let mut channel = PadChannel::create(boot_name.clone(), SHM_SIZE)?; let base = channel.data_base(); @@ -71,7 +165,7 @@ impl XboxWinPad { // device_type MUST land before the magic — the driver reads it the moment it attaches, and // a late stamp enumerates the pad with the default DualSense identity (the Deck's bug). unsafe { - *base.add(OFF_DEVTYPE) = pf_driver_proto::gamepad::DEVTYPE_XBOX; + *base.add(OFF_DEVTYPE) = id.devtype; std::ptr::write_unaligned(base.add(OFF_PAD_INDEX) as *mut u32, index as u32); // Ring capability `2` = "this host drains the v2.2 long ring" (see the DualSense open). std::ptr::write_unaligned(base.add(OFF_OUT_RING_VER) as *mut u32, 2); @@ -81,17 +175,20 @@ impl XboxWinPad { ); std::ptr::write_unaligned(base as *mut u32, SHM_MAGIC); } - let inst = format!("pf_xbox_{index}"); + let inst = format!("{}_{index}", id.instance_prefix); let (hsw, instance_id) = create_swdevice(&SwDeviceProfile { instance: &inst, + // Per-FAMILY tag, like "PFDS" for the whole PlayStation family: the three Xbox + // identities share it because only one of them can ever hold a given pad index (the + // router keeps a live device in its owning manager), so their containers never collide. container_tag: 0x5046_5842, // "PFXB" container_index: index, - hwid: XBOX_HWID, - usb_vid_pid: XBOX_USB_VID_PID, + hwid: id.hwid, + usb_vid_pid: id.usb_vid_pid, // A Bluetooth pad is not a USB composite device, so there is no interface number to // synthesize — unlike the Deck, whose Steam promotion gate needs `&MI_02`. usb_mi: None, - description: "Punktfunk Virtual Xbox Wireless Controller", + description: id.description, })?; // Propagate — swallowing latched the slot to a pad with no devnode (see the DS4 twin). channel.bind_devnode( index as u32, @@ -99,14 +196,14 @@ impl XboxWinPad { super::gamepad_raii::ProofTransport::HidFeatureReport, ); let _sw = Some(super::gamepad_raii::SwDevice::new(hsw)); - // Bounded eager delivery — the driver must read `device_type = 4` before hidclass asks it - // for descriptors, or the pad enumerates as a DualSense. + // Bounded eager delivery — the driver must read the `device_type` stamp before hidclass + // asks it for descriptors, or the pad enumerates as a DualSense. channel.deliver_eager(Duration::from_millis(1500)); Ok(XboxWinPad { _sw, channel, attach: super::gamepad_raii::DriverAttach::new( - "pf_xboxwireless", + id.hwid, "pf_gamepad.inf", // one driver package serves every identity "C:\\Windows\\ServiceProfiles\\LocalService\\AppData\\Local\\Temp\\pf_gamepad-driver.log", boot_name, @@ -190,8 +287,37 @@ fn parse_xbox_output(bytes: &[u8]) -> Option<(u16, u16, u16, u16)> { /// The Windows-Xbox half of the shared stateful manager (see [`PadProto`]). Lifecycle (slot table, /// unplug sweep, heartbeat, rumble dedup) lives in [`UhidManager`], exactly as for the PS pads. -#[derive(Default)] -pub struct XboxWinProto; +/// +/// The identity is a field rather than three separate proto types because nothing else about the +/// backend varies: same codec, same output parse, same rumble plane. `Default` is the Xbox Wireless +/// Controller, so `XboxWindowsManager::new()` keeps its previous meaning exactly. +pub struct XboxWinProto { + identity: &'static WinXboxIdentity, +} + +impl Default for XboxWinProto { + fn default() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[0], + } + } +} + +impl XboxWinProto { + /// The Xbox One S identity (`045E:02FD`) — `UhidManager::with_backend(XboxWinProto::one_s())`. + pub fn one_s() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[1], + } + } + + /// The Xbox Elite Series 2 identity (`045E:0B22`). + pub fn elite() -> XboxWinProto { + XboxWinProto { + identity: &XBOX_IDENTITIES[2], + } + } +} impl PadProto for XboxWinProto { type Pad = XboxWinPad; @@ -202,10 +328,12 @@ impl PadProto for XboxWinProto { " (install/repair: punktfunk-host.exe driver install --gamepad)"; fn open(&mut self, idx: u8) -> Result { - let p = XboxWinPad::open(idx)?; + let p = XboxWinPad::open(idx, self.identity)?; tracing::info!( index = idx, - "virtual Xbox Wireless Controller created (Windows UMDF HID identity 045E:0B13)" + identity = self.identity.usb_vid_pid, + description = self.identity.description, + "virtual Xbox pad created (Windows UMDF HID)" ); Ok(p) } diff --git a/crates/pf-inject/src/lib.rs b/crates/pf-inject/src/lib.rs index 01260fd0..0bdfdd28 100644 --- a/crates/pf-inject/src/lib.rs +++ b/crates/pf-inject/src/lib.rs @@ -474,8 +474,9 @@ pub mod uhid_abi; #[cfg(any(target_os = "linux", target_os = "windows"))] #[path = "inject/uhid_manager.rs"] pub mod uhid_manager; -/// Transport-independent Xbox Wireless Controller HID codec — the report the `pf-gamepad` UMDF -/// driver serves under device-type 4, giving an Xbox pad the HID footing `pf-xusb` never had +/// Transport-independent Xbox HID codec — the report the `pf-gamepad` UMDF driver serves under +/// device-types 4, 5 and 6 (Xbox Wireless / One S / Elite Series 2, which share one descriptor and +/// differ only in VID/PID), giving an Xbox pad the HID footing `pf-xusb` never had /// (Steam / WGI / GameInput / DirectInput cannot see an XUSB-interface-only device). /// /// Deliberately NOT cfg-gated to linux/windows like its siblings: it is pure byte-packing with no @@ -484,9 +485,11 @@ pub mod uhid_manager; /// has until a Windows box is reachable. #[path = "inject/proto/xbox_proto.rs"] pub mod xbox_proto; -/// Windows: virtual Xbox Wireless Controller via the same UMDF minidriver (device-type 4) — the -/// HID-visible alternative to [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / -/// DirectInput cannot enumerate at all because it registers only the XUSB device interface. +/// Windows: virtual Xbox pads via the same UMDF minidriver — Xbox Wireless (device-type 4), +/// Xbox One S (5) and Xbox Elite Series 2 (6), the HID-visible alternative to +/// [`gamepad_windows`]'s XUSB companion, which Steam / WGI / GameInput / DirectInput cannot +/// enumerate at all because it registers only the XUSB device interface. The three identities +/// share one report descriptor and differ only in VID/PID, product string and INF model line. #[cfg(target_os = "windows")] #[path = "inject/windows/xbox_windows.rs"] pub mod xbox_windows; diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 82a6b008..f9c67603 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1219,6 +1219,11 @@ pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9; /// topology and four controller slots. Used by capture clients that own the physical Puck; /// ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK: u32 = 10; +/// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity +/// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360 +/// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box +/// classes (`DUALSENSEEDGE` is the pad with native back-button slots). +pub const PUNKTFUNK_GAMEPAD_XBOXELITE: u32 = 11; /// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips /// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's @@ -1344,6 +1349,7 @@ const _: () = { assert!( PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.to_u8() as u32 ); + assert!(PUNKTFUNK_GAMEPAD_XBOXELITE == GamepadPref::XboxElite.to_u8() as u32); // Extended button bits mirror the wire `input::gamepad` constants. assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE1 == g::BTN_PADDLE1); assert!(PUNKTFUNK_GAMEPAD_BTN_PADDLE2 == g::BTN_PADDLE2); diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index 118bf00f..1fd11577 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -140,8 +140,8 @@ impl CompositorPref { /// otherwise the host falls back and reports the real choice in `Welcome`. The wire form is a single /// byte (`0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, -/// `9 = SteamController2`, `10 = SteamController2Puck`), appended to `Hello`/`Welcome` — older -/// peers simply omit/ignore it (an unknown byte degrades to `Auto`). +/// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`), appended to +/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum GamepadPref { /// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360). @@ -151,9 +151,11 @@ pub enum GamepadPref { Xbox360, /// UHID DualSense (kernel `hid-playstation`) — adaptive triggers, lightbar, touchpad, motion. DualSense, - /// uinput X-Box One / Series pad — the X-Box 360 backend with the One/Series USB identity - /// (VID/PID/name), so games show One/Series glyphs. XInput-identical otherwise (impulse-trigger - /// rumble is unreachable through any virtual pad, so there's no game-visible gain over `Xbox360`). + /// X-Box One / Series pad. On Linux, the X-Box 360 uinput backend with the One/Series USB + /// identity (VID/PID/name), so games show One/Series glyphs — XInput-identical otherwise. On + /// Windows it is a distinct HID identity (`045E:02FD`, Bluetooth Xbox One S) through the UMDF + /// minidriver; it used to fold to `Xbox360` there, because the only Windows Xbox backend was + /// the XUSB companion, which presents one fixed 360 identity and cannot vary it. XboxOne, /// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like /// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host. @@ -186,6 +188,21 @@ pub enum GamepadPref { /// native seven-interface Puck topology (CDC pair, four controller slots, management HID) /// rather than relabelling its reports as a wired `1302`. SteamController2Puck, + /// Xbox Elite Wireless Controller Series 2 (Microsoft `045E:0B22`, Bluetooth) — a Windows-only + /// HID identity through the UMDF minidriver, so glyphs and the Device Manager name read Elite. + /// + /// ⚠️ **Glyphs and identity only, today.** The four paddles (`BTN_PADDLE1..4`) still fold or + /// drop exactly as on the other Xbox classes; the Elite is merely the first Xbox identity that + /// *could* carry them natively. Wiring them up is blocked on a measurement, not on effort — + /// once Windows promotes the pad, `xinputhid` claims its HID collection exclusively, so extra + /// buttons declared in the report descriptor may reach no consumer at all + /// (`design/xbox-pad-windows-handoff.md` §3.6). Do not advertise paddle support off this + /// variant until that is measured; `DualSenseEdge` stays the only virtual pad with native + /// back-button slots. + /// + /// Folds to `Xbox360` everywhere but Windows: there is no Linux uinput Elite identity + /// (`PadIdentity` has 360 and One S only). + XboxElite, } impl GamepadPref { @@ -211,7 +228,8 @@ impl GamepadPref { pub const fn has_motion(self) -> bool { match self { GamepadPref::Auto => true, // unknown; assume it can, see above - GamepadPref::Xbox360 | GamepadPref::XboxOne => false, + // No Xbox pad has a gyro in its HID contract — Elite Series 2 included. + GamepadPref::Xbox360 | GamepadPref::XboxOne | GamepadPref::XboxElite => false, GamepadPref::DualSense | GamepadPref::DualShock4 | GamepadPref::DualSenseEdge @@ -225,7 +243,7 @@ impl GamepadPref { /// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`, /// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`, - /// `9 = SteamController2`, `10 = SteamController2Puck`. + /// `9 = SteamController2`, `10 = SteamController2Puck`, `11 = XboxElite`. pub const fn to_u8(self) -> u8 { match self { GamepadPref::Auto => 0, @@ -239,6 +257,7 @@ impl GamepadPref { GamepadPref::SwitchPro => 8, GamepadPref::SteamController2 => 9, GamepadPref::SteamController2Puck => 10, + GamepadPref::XboxElite => 11, } } @@ -256,6 +275,7 @@ impl GamepadPref { 8 => GamepadPref::SwitchPro, 9 => GamepadPref::SteamController2, 10 => GamepadPref::SteamController2Puck, + 11 => GamepadPref::XboxElite, _ => GamepadPref::Auto, } } @@ -270,6 +290,10 @@ impl GamepadPref { "xboxone" | "xbox-one" | "xone" | "xbox1" | "series" | "xboxseries" => { GamepadPref::XboxOne } + // "elite" is unambiguous here — the DualSense Edge answers to "edge", never "elite". + "xboxelite" | "xbox-elite" | "elite" | "xboxelite2" | "elite2" => { + GamepadPref::XboxElite + } "dualshock4" | "dualshock" | "ds4" | "ps4" => GamepadPref::DualShock4, "steamdeck" | "steam-deck" | "deck" => GamepadPref::SteamDeck, "steamcontroller" | "steam-controller" | "steamcon" => GamepadPref::SteamController, @@ -289,7 +313,7 @@ impl GamepadPref { /// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`, /// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`, - /// `"steamcontroller2"`, `"steamcontroller2puck"`). + /// `"steamcontroller2"`, `"steamcontroller2puck"`, `"xboxelite"`). pub fn as_str(self) -> &'static str { match self { GamepadPref::Auto => "auto", @@ -303,6 +327,7 @@ impl GamepadPref { GamepadPref::SwitchPro => "switchpro", GamepadPref::SteamController2 => "steamcontroller2", GamepadPref::SteamController2Puck => "steamcontroller2puck", + GamepadPref::XboxElite => "xboxelite", } } } @@ -833,7 +858,11 @@ mod tests { /// into a host that drops every one. #[test] fn only_the_xbox_classes_lack_a_motion_plane() { - for p in [GamepadPref::Xbox360, GamepadPref::XboxOne] { + for p in [ + GamepadPref::Xbox360, + GamepadPref::XboxOne, + GamepadPref::XboxElite, + ] { assert!( !p.has_motion(), "{} should have no motion plane", @@ -910,11 +939,12 @@ mod tests { GamepadPref::SwitchPro, GamepadPref::SteamController2, GamepadPref::SteamController2Puck, + GamepadPref::XboxElite, ] { assert_eq!(GamepadPref::from_u8(p.to_u8()), p); assert_eq!(GamepadPref::from_name(p.as_str()), Some(p)); } - // Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers + // Every wire byte 0..=11 is assigned, distinct, and pinned (forward-compat with peers // that only know a prefix of the range). for (v, p) in [ (0, GamepadPref::Auto), @@ -928,12 +958,13 @@ mod tests { (8, GamepadPref::SwitchPro), (9, GamepadPref::SteamController2), (10, GamepadPref::SteamController2Puck), + (11, GamepadPref::XboxElite), ] { assert_eq!(p.to_u8(), v); assert_eq!(GamepadPref::from_u8(v), p); } // The next unassigned byte degrades to Auto today; assigning it later must update this. - assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto); + assert_eq!(GamepadPref::from_u8(12), GamepadPref::Auto); // Aliases + unknowns. assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense)); assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360)); @@ -964,6 +995,24 @@ mod tests { Some(GamepadPref::XboxOne) ); assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne)); + // The Elite's aliases, and the one that could plausibly have been stolen: "edge" is the + // DualSense Edge and must stay so — the two are different pads on different vendors. + assert_eq!( + GamepadPref::from_name("Elite"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("xbox-elite"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("elite2"), + Some(GamepadPref::XboxElite) + ); + assert_eq!( + GamepadPref::from_name("edge"), + Some(GamepadPref::DualSenseEdge) + ); assert_eq!(GamepadPref::from_name("nope"), None); // Unknown wire byte degrades to Auto (forward-compatible). assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto); diff --git a/crates/punktfunk-host/src/devtest.rs b/crates/punktfunk-host/src/devtest.rs index 269c76ea..b719cbab 100644 --- a/crates/punktfunk-host/src/devtest.rs +++ b/crates/punktfunk-host/src/devtest.rs @@ -368,6 +368,14 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { let xbox = args.iter().any(|a| a == "--xbox"); // `--xboxhid` drives the HID Xbox backend (device-type 4) instead of `--xbox`'s XUSB companion. let xboxhid = args.iter().any(|a| a == "--xboxhid"); + // The other two HID Xbox identities (device-types 5 and 6). Same backend, same report + // descriptor — only VID/PID, product string and hardware id differ — so these legs exist for + // exactly one question each: does Windows PROMOTE that PID the way it promotes `0B13`? + // `02FD` in particular has no stage-2 `HID\…&IG_00` line in Microsoft's `xinputhid.inf`, so + // it is the one worth watching. Check for the `IG_00` token, the XUSB interface, an XInput + // slot and rumble, exactly as the `--xboxhid` run did. + let xboxones = args.iter().any(|a| a == "--xboxones"); + let xboxelite = args.iter().any(|a| a == "--xboxelite"); // `--edge` drives the DualSense Edge backend (device_type 2) and additionally holds // the R4/L4 paddles on the pressed beats, so a HID read shows the Edge bits in // report byte 10 (0x80|0x40) next to Cross. `--deck` drives the Steam Deck backend @@ -481,6 +489,20 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> { crate::inject::xbox_windows::XboxWindowsManager::new(), "Xbox Wireless Controller (HID)" ); + } else if xboxones { + drive!( + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::one_s() + ), + "Xbox One S Controller (HID, 045E:02FD)" + ); + } else if xboxelite { + drive!( + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::elite() + ), + "Xbox Elite Wireless Controller Series 2 (HID, 045E:0B22)" + ); } else if ds4 { drive!( crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), diff --git a/crates/punktfunk-host/src/native/gamepad.rs b/crates/punktfunk-host/src/native/gamepad.rs index b990a507..bdc72b2a 100644 --- a/crates/punktfunk-host/src/native/gamepad.rs +++ b/crates/punktfunk-host/src/native/gamepad.rs @@ -41,7 +41,7 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { cfg!(target_os = "linux"), cfg!(target_os = "windows"), ); - degrade_steam_on_conflict(degrade_if_no_uhid(chosen)) + degrade_xbox_identity(degrade_steam_on_conflict(degrade_if_no_uhid(chosen))) } /// Pure selection of the session's virtual-gamepad backend: the client's explicit `pref` wins, @@ -49,9 +49,18 @@ pub(super) fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { /// /// `linux`/`windows` flag the host platform. DualSense and DualShock 4 each have both a Linux (UHID /// hid-playstation) and a Windows (UMDF minidriver) backend; on any other platform such a wish degrades -/// to X-Box 360 (never an error: a session without rich pads still streams). X-Box One/Series is a -/// distinct uinput *identity* on Linux, but XInput-identical to the 360 pad on Windows (the XUSB -/// companion presents a 360 identity), so it degrades to `Xbox360` there. +/// to X-Box 360 (never an error: a session without rich pads still streams). +/// +/// The X-Box identities are now distinct on BOTH platforms: a uinput identity on Linux (360 / +/// One S), and a UMDF HID identity on Windows (360 → `045E:0B13`, One → `045E:02FD`, Elite → +/// `045E:0B22`). The Windows fold of One/Series into the 360 pad is gone with the reason for it — +/// it existed because the only Windows X-Box backend was the XUSB companion, which presents one +/// fixed 360 identity and cannot vary it. The Elite has no Linux identity (`PadIdentity` stops at +/// One S), so it folds there. +/// +/// ⚠️ **This is compile-time only.** `PUNKTFUNK_XBOX_BACKEND=xusb` puts Windows back on the +/// companion at RUNTIME, which un-varies the identity again — that is [`degrade_xbox_identity`]'s +/// job, not this function's. fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool) -> GamepadPref { let want = match pref { GamepadPref::Auto => env @@ -63,9 +72,12 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool // DualSense / DualShock 4: Linux UHID hid-playstation, or the Windows UMDF minidriver backend. GamepadPref::DualSense if linux || windows => GamepadPref::DualSense, GamepadPref::DualShock4 if linux || windows => GamepadPref::DualShock4, - // One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on - // Windows (XInput can't tell them apart anyway). - GamepadPref::XboxOne if linux => GamepadPref::XboxOne, + // One/Series: a real, distinct uinput identity on Linux, and — since the HID X-Box backend + // became the default — a distinct UMDF HID identity (`045E:02FD`) on Windows too. + GamepadPref::XboxOne if linux || windows => GamepadPref::XboxOne, + // Elite Series 2: Windows-only (UMDF device-type 6, `045E:0B22`). There is no Linux uinput + // Elite identity to fold onto, so it takes the `_` arm and lands on the 360 pad there. + GamepadPref::XboxElite if windows => GamepadPref::XboxElite, // Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices // are the N4 spike). GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck, @@ -221,6 +233,36 @@ fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref { chosen } +/// Runtime degrade for the two non-default Windows X-Box identities (One S / Elite Series 2): with +/// `PUNKTFUNK_XBOX_BACKEND=xusb` the session runs the XUSB companion, which presents ONE fixed +/// X-Box 360 identity and has no way to vary VID/PID — so the pad a player gets is a 360 pad no +/// matter what was asked for. Fold here so the `Welcome` echo says so. +/// +/// This is a runtime check and [`pick_gamepad`] is a compile-time one, which is exactly the split +/// [`degrade_if_no_uhid`] already draws. Without it, asking for an Elite under the escape hatch +/// resolves to `xboxelite`, echoes `xboxelite`, and builds a 360 pad — the class of silent lie +/// `pad_motion_reaches` and the fold-logging in [`resolve_gamepad`] exist to prevent. +/// +/// A no-op on every non-Windows host: `XboxElite` never survives `pick_gamepad` there, and +/// `XboxOne` is a genuine uinput identity on Linux. +#[cfg(target_os = "windows")] +fn degrade_xbox_identity(chosen: GamepadPref) -> GamepadPref { + if matches!(chosen, GamepadPref::XboxOne | GamepadPref::XboxElite) && !windows_xbox_hid() { + tracing::warn!( + wanted = chosen.as_str(), + "PUNKTFUNK_XBOX_BACKEND=xusb selects the XUSB companion, which has one fixed X-Box 360 \ + identity — falling back to the 360 pad" + ); + return GamepadPref::Xbox360; + } + chosen +} + +#[cfg(not(target_os = "windows"))] +fn degrade_xbox_identity(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. **HID is the default**; set @@ -278,6 +320,9 @@ pub(super) fn resolve_gamepad(pref: GamepadPref) -> GamepadPref { // Steam controller — its own Steam Input would then manage two Decks (confirmed conflict-prone on // a Deck-as-host). `PUNKTFUNK_STEAM_FORCE=1` overrides. let chosen = degrade_steam_on_conflict(chosen); + // The XUSB escape hatch can only present a 360 identity, so the One S / Elite wishes fold when + // `PUNKTFUNK_XBOX_BACKEND=xusb` is set. + let chosen = degrade_xbox_identity(chosen); match pref { GamepadPref::Auto => { // The operator's env knob deserves a diagnostic when it didn't drive the @@ -374,10 +419,21 @@ mod tests { assert_eq!(pick_gamepad(Auto, Some("ps4"), true, false), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, true), DualShock4); assert_eq!(pick_gamepad(DualShock4, None, false, false), Xbox360); - // X-Box One: a distinct uinput identity on Linux, folded into the 360 pad on Windows. + // X-Box One: a distinct uinput identity on Linux AND a distinct UMDF HID identity + // (`045E:02FD`) on Windows. The old Windows fold to Xbox360 is deliberately gone — it + // existed only because the XUSB companion has one fixed 360 identity, and the HID backend + // is the default now. `degrade_xbox_identity` puts the fold back when the escape hatch + // `PUNKTFUNK_XBOX_BACKEND=xusb` is set; that is a runtime check this pure one can't make. assert_eq!(pick_gamepad(XboxOne, None, true, false), XboxOne); assert_eq!(pick_gamepad(Auto, Some("series"), true, false), XboxOne); - assert_eq!(pick_gamepad(XboxOne, None, false, true), Xbox360); + assert_eq!(pick_gamepad(XboxOne, None, false, true), XboxOne); + assert_eq!(pick_gamepad(XboxOne, None, false, false), Xbox360); + // X-Box Elite Series 2: Windows-only (UMDF device-type 6). No Linux uinput Elite identity + // exists, so it folds to the 360 pad there rather than pretending. + assert_eq!(pick_gamepad(XboxElite, None, false, true), XboxElite); + assert_eq!(pick_gamepad(Auto, Some("elite"), false, true), XboxElite); + assert_eq!(pick_gamepad(XboxElite, None, true, false), Xbox360); + assert_eq!(pick_gamepad(XboxElite, None, false, false), Xbox360); // Steam Deck: native on Linux (UHID/usbip/gadget) AND Windows (UMDF device-type 3, // Steam-Input-promoted via MI_02 — gamepad-new-types N4); Xbox360 elsewhere. diff --git a/crates/punktfunk-host/src/native/input.rs b/crates/punktfunk-host/src/native/input.rs index bf0d4fc7..179881e3 100644 --- a/crates/punktfunk-host/src/native/input.rs +++ b/crates/punktfunk-host/src/native/input.rs @@ -126,9 +126,19 @@ struct Pads { /// 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. + /// + /// Three managers because the HID backend now has three IDENTITIES (Xbox Wireless `045E:0B13`, + /// Xbox One S `045E:02FD`, Elite Series 2 `045E:0B22`) and a manager is bound to one at + /// construction. They are otherwise the same backend — same codec, same report descriptor, + /// same rumble plane — so the split is purely so a mixed session can present, say, a Series + /// pad on slot 0 and an Elite on slot 1. #[cfg(target_os = "windows")] xbox_hid: Option, #[cfg(target_os = "windows")] + xbox_one_hid: Option, + #[cfg(target_os = "windows")] + xbox_elite_hid: Option, + #[cfg(target_os = "windows")] dualsense_edge_win: Option, #[cfg(target_os = "windows")] dualshock4_win: Option, @@ -172,6 +182,10 @@ impl Pads { #[cfg(target_os = "windows")] xbox_hid: None, #[cfg(target_os = "windows")] + xbox_one_hid: None, + #[cfg(target_os = "windows")] + xbox_elite_hid: None, + #[cfg(target_os = "windows")] dualsense_edge_win: None, #[cfg(target_os = "windows")] dualshock4_win: None, @@ -298,17 +312,38 @@ 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. This is now the + // The Xbox pads, as real HID devices rather than the XUSB companion. This is now the // DEFAULT (see `windows_xbox_hid`; `PUNKTFUNK_XBOX_BACKEND=xusb` reverts it). It is no // longer a trade: with the `xinputhid` bus filter the INF attaches, the HID pad keeps // classic XInput AND gains everything XUSB never had — Steam, SDL, RawInput, // DirectInput, `joy.cpl`, WGI — plus rumble, which the XUSB path could not source. + // + // Three arms, one per identity. The `windows_xbox_hid()` guard stays on each: with the + // escape hatch set, `degrade_xbox_identity` has already folded One/Elite to Xbox360, so + // only Xbox360 can reach here and it must fall through to the XUSB companion below. #[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) - } + GamepadPref::Xbox360 if super::gamepad::windows_xbox_hid() => self + .xbox_hid + .get_or_insert_with(crate::inject::xbox_windows::XboxWindowsManager::new) + .handle(ev), + #[cfg(target_os = "windows")] + GamepadPref::XboxOne if super::gamepad::windows_xbox_hid() => self + .xbox_one_hid + .get_or_insert_with(|| { + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::one_s(), + ) + }) + .handle(ev), + #[cfg(target_os = "windows")] + GamepadPref::XboxElite if super::gamepad::windows_xbox_hid() => self + .xbox_elite_hid + .get_or_insert_with(|| { + crate::inject::xbox_windows::XboxWindowsManager::with_backend( + crate::inject::xbox_windows::XboxWinProto::elite(), + ) + }) + .handle(ev), _ => self .xbox360 .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) @@ -431,7 +466,8 @@ impl Pads { /// player LEDs / adaptive triggers) for the UHID/UMDF pads. The `&mut` closure re-borrows /// satisfy `FnMut` for each backend. /// - /// Only the Windows HID Xbox backend (`xbox_hid`) can ever report non-zero trigger levels — no + /// Only the Windows HID Xbox backends (`xbox_hid` and its two identity siblings) can ever + /// report non-zero trigger levels — no /// other backend's source packet has a field for them (see `PadFeedback::rumble`), so they pass /// zeros and the v3 datagram they produce is a v2 datagram with a zero tail. fn pump( @@ -474,9 +510,17 @@ 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. + // All three HID Xbox identities. Rumble only — an Xbox pad has no rich-feedback plane + // (no lightbar / adaptive triggers), same as its XUSB sibling above. Missing one of + // these is silent: the pad works and simply never rumbles. + for m in [ + &mut self.xbox_hid, + &mut self.xbox_one_hid, + &mut self.xbox_elite_hid, + ] + .into_iter() + .flatten() + { m.pump(&mut rumble, &mut hidout); } if let Some(m) = &mut self.dualsense_win { diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 8aea2f81..d7a44760 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -236,6 +236,12 @@ // ordinary wired/BLE SC2 capture remains `STEAMCONTROLLER2`. #define PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK 10 +// Xbox Elite Wireless Controller Series 2 (`045E:0B22`, Bluetooth): a Windows-only HID identity +// through the UMDF minidriver, so glyphs and the device name read Elite. Folds to X-Box 360 +// elsewhere. ⚠️ Identity only — the four paddles still fold/drop exactly as on the other X-Box +// classes (`DUALSENSEEDGE` is the pad with native back-button slots). +#define PUNKTFUNK_GAMEPAD_XBOXELITE 11 + // Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips // (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1–P4) + the misc/capture button, in Moonlight's // `buttonFlags2 << 16` namespace. Mirror `input::gamepad::BTN_PADDLE1..4` / `BTN_MISC1`. diff --git a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx index a7cd26be..001431d9 100644 --- a/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx +++ b/packaging/windows/drivers/pf-gamepad/pf_gamepad.inx @@ -1,7 +1,8 @@ ;/*++ ; punktfunk virtual gamepads — UMDF2 HID minidriver INF. -; One package, four hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck — which is why -; the package is called pf_gamepad and not pf_dualsense (it never was one identity). +; One package, seven hardware ids: DualSense, DualShock 4, DualSense Edge, Steam Deck, and three +; Xbox pads (Wireless / One S / Elite Series 2) — which is why the package is called pf_gamepad and +; not pf_dualsense (it never was one identity). ; ; ⚠️ The HARDWARE IDS below deliberately keep their old names (`pf_dualsense`, `pf_dualshock4`, ; `pf_dualsenseedge`, `pf_steamdeck`). They are the binding contract with every devnode the host @@ -34,10 +35,12 @@ pf_gamepad.dll=1 [pf.NT$ARCH$.10.0...22000] ; Hardware ids: `root\pf_dualsense` for a root-enumerated devnode (devgen/devcon tests); `pf_dualsense` ; for the host's SwDeviceCreate'd DualSense (the `root\` prefix is reserved for root enumeration, so -; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / `pf_steamdeck` -; for the host's other virtual pads — ONE driver binds all of them (every model line below installs -; the same `pfGamepad` section) and serves the matching HID identity per the device_type byte the -; host stamps into shared memory. +; SwDeviceCreate rejects it with E_INVALIDARG); `pf_dualshock4` / `pf_dualsenseedge` / +; `pf_steamdeck` / `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` for the host's other virtual +; pads — ONE driver binds all of them and serves the matching HID identity per the device_type byte +; the host stamps into shared memory. TWO install sections, though: the PlayStation/Deck ids share +; `pfGamepad`, and the three Xbox ids install `pfGamepadXbox`, which additionally attaches the +; `xinputhid` bus filter (see the ⚠️ below the Deck line). ; ; Each id carries its OWN description: Device Manager reads this string, and a single shared ; "Virtual DualSense" made an emulated DualShock 4 look like the controller-type setting had been @@ -47,11 +50,19 @@ pf_gamepad.dll=1 %DeviceDescDS4%=pfGamepad, pf_dualshock4 %DeviceDescEdge%=pfGamepad, pf_dualsenseedge %DeviceDescDeck%=pfGamepad, pf_steamdeck -; ⚠️ The Xbox line installs its OWN section, `pfGamepadXbox`, and must keep doing so. Every other -; identity shares `pfGamepad`; the Xbox one additionally attaches the `xinputhid` bus filter, and +; ⚠️ The Xbox lines install their OWN section, `pfGamepadXbox`, and must keep doing so. Every other +; identity shares `pfGamepad`; the Xbox ones additionally attach the `xinputhid` bus filter, and ; putting that on a DualSense / DualShock 4 / Edge / Steam Deck would hand a PlayStation pad to ; Microsoft's Xbox translator. The two sections are otherwise identical — keep them in step. +; (`only_the_xbox_identity_installs_the_xinputhid_section`, in pf-inject, asserts both directions.) +; +; The three Xbox identities differ ONLY in hardware id, Device Manager description and the VID/PID +; + product string the driver serves off the resulting device_type — they share one report +; descriptor and one install section, because in HID terms they are the same pad. See the +; `XBOX_RDESC` header in src/lib.rs for why that sharing is deliberate. %DeviceDescXbox%=pfGamepadXbox, root\pf_xboxwireless, pf_xboxwireless +%DeviceDescXboxOneS%=pfGamepadXbox, root\pf_xboxones, pf_xboxones +%DeviceDescXboxElite%=pfGamepadXbox, root\pf_xboxelite, pf_xboxelite [pfGamepad.NT] CopyFiles=UMDriverCopy @@ -177,3 +188,9 @@ DeviceDescDS4 ="Punktfunk Virtual DualShock 4" DeviceDescEdge ="Punktfunk Virtual DualSense Edge" DeviceDescDeck ="Punktfunk Virtual Steam Deck Controller" DeviceDescXbox ="Punktfunk Virtual Xbox Wireless Controller" +; ⚠️ This one deliberately does NOT match the product string the driver serves for device_type 5. +; A real Xbox One S pad reports "Xbox Wireless Controller" over Bluetooth, exactly like the Series +; X|S pad above — the PID is the only thing that separates them on the wire. Device Manager, +; however, has to let a human tell our two virtual pads apart, and this string is ours to choose. +DeviceDescXboxOneS ="Punktfunk Virtual Xbox One S Controller" +DeviceDescXboxElite="Punktfunk Virtual Xbox Elite Wireless Controller Series 2" diff --git a/packaging/windows/drivers/pf-gamepad/src/lib.rs b/packaging/windows/drivers/pf-gamepad/src/lib.rs index da93f94b..3411826d 100644 --- a/packaging/windows/drivers/pf-gamepad/src/lib.rs +++ b/packaging/windows/drivers/pf-gamepad/src/lib.rs @@ -2,7 +2,9 @@ // // A Rust port of the WDK `vhidmini2` UMDF2 sample, reconfigured to present a Sony DualSense // (VID 054C / PID 0CE6), DualShock 4 (device_type=1), DualSense Edge (device_type=2), Steam Deck -// (device_type=3) or Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13) using the +// (device_type=3), Xbox Wireless Controller (device_type=4, VID 045E / PID 0B13), Xbox One S +// (device_type=5, 045E / 02FD) or Xbox Elite Wireless Controller Series 2 (device_type=6, +// 045E / 0B22) using the // report descriptors + feature blobs punktfunk already ships in `inject/`. Games see a genuine // HID PS controller; the host streams input in / reads output (rumble/lightbar/triggers) back. // @@ -73,7 +75,7 @@ const DS_EDGE_PID: u16 = 0x0DF2; const DECK_VID: u16 = 0x28DE; const DECK_PID: u16 = 0x1205; -// ---- Xbox Wireless Controller identity (device_type=4) ---- +// ---- Xbox identities (device_type = 4 Wireless / 5 One S / 6 Elite Series 2) ---- // // WHY THIS EXISTS (field 2026-08-09, `punktfunk-field-windows-pad-dead-0260`): the OTHER Windows // Xbox backend — `pf-xusb` — registers ONLY `GUID_DEVINTERFACE_XUSB` and has no HID collection at @@ -89,17 +91,37 @@ const DECK_PID: u16 = 0x1205; // never existed and inbox promotion has nothing to match. The Xbox pads that genuinely ARE HID are // the Bluetooth ones, which Windows binds through HIDCLASS. const XBOX_VID: u16 = 0x045E; -/// Xbox Wireless Controller (Series X|S), Bluetooth. Chosen over the Xbox One S BT id `0x02FD` -/// because the host's OS floor is Windows 11 22H2, where this is the current-generation identity -/// (so glyphs read "Xbox Series") and SDL's mapping database covers it. +/// Xbox Wireless Controller (Series X|S), Bluetooth — `device_type = 4`, the default Xbox identity. +/// Chosen over the Xbox One S BT id `0x02FD` because the host's OS floor is Windows 11 22H2, where +/// this is the current-generation identity (so glyphs read "Xbox Series") and SDL's mapping +/// database covers it. /// -/// ⚠️ **If on-glass shows Windows does not promote this to an Xbox-profile pad, try `0x02FD` -/// (Xbox One S BT) — it has the broadest inbox coverage.** Deliberately one named constant so that -/// experiment is a one-line change. +/// ⭐ It is also the PID Microsoft's own `xinputhid.inf` allow-lists **twice** (once as a +/// `BTHLEDevice` stage-1 id, once as a plain `HID\…&IG_00` stage-2 id) — measured off `.173`, +/// 2026-08-09. That is not what promotes OUR pad (a software devnode matches no allow-list entry; +/// `pfGamepadXbox`'s `AddReg` writes what the matching sections would have written), but it is why +/// this stays the default of the three. const XBOX_PID: u16 = 0x0B13; -/// Alternate identity for the promotion experiment above — Xbox One S controller over Bluetooth. -#[allow(dead_code)] +/// Xbox One S controller over Bluetooth — `device_type = 5`. +/// +/// ⚠️ **`02FD` appears in `xinputhid.inf` only as a `BTHENUM` (classic-BT bus) id — it has NO +/// stage-2 `HID\…&IG_00` model line.** That killed it as a "try another PID" lever for the +/// promotion work (handoff §4 B1). It does not block it as an IDENTITY, because our promotion +/// comes from the INF's own `AddReg` rather than from matching Microsoft's list — but if a future +/// Windows servicing update makes promotion depend on the allow-list again, this identity is the +/// one that loses it first. Worth re-measuring on glass before recommending it to anyone. const XBOX_PID_ONE_S: u16 = 0x02FD; +/// Xbox Elite Wireless Controller Series 2 — `device_type = 6`. This is the pad +/// `tools/hid-descriptor-dump` captured on `.173` (`BTHLE\DEV_686CE647F191`, `REV_0521`), so it is +/// the one identity here whose real hardware we have measured directly. +const XBOX_PID_ELITE2: u16 = 0x0B22; +/// bcdDevice for every Xbox identity. +/// +/// Deliberately ONE value rather than per-identity: the real Elite reports `REV_0521` (measured on +/// `.173`) but `create_swdevice` synthesizes the devnode's USB ids with a hardcoded `&REV_0100` +/// regardless, and SDL folds the version into its joystick GUID — so a version that disagrees with +/// the devnode buys nothing and risks missing a stock mapping. Revisit only with a measurement +/// that shows a consumer keying on it. const XBOX_VER: u16 = 0x0407; // Sony DualSense USB HID report descriptor (273 bytes), verbatim from inputtino (== inject/dualsense.rs). @@ -271,7 +293,23 @@ static DECK_RDESC: [u8; 38] = [ 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0, ]; -// ---- Xbox Wireless Controller assets (served when the host stamps device_type=4) ---- +// ---- Xbox assets (served when the host stamps device_type = 4, 5 or 6) ---- +// +// ⭐⭐ **ONE DESCRIPTOR SERVES ALL THREE XBOX IDENTITIES, DELIBERATELY.** Xbox Wireless (4), +// Xbox One S (5) and Xbox Elite Series 2 (6) differ ONLY in VID/PID, product string and INF model +// line — in HID terms they are the same pad: same two 16-bit stick pairs, same trigger pair, same +// hat, same 15 buttons, same rumble output report. A report descriptor is the report SHAPE, not +// the identity; the identity is what SDL/Steam/Windows key their stock mappings off, and that +// travels in `hid_attrs`. +// +// This is load-bearing, not laziness. The ⚠️ block below is the record of what ONE hand-written +// descriptor has already cost: three separate bugs (no Feature report ⇒ the sealed channel never +// opened and the pad served neutral forever; no OUTPUT item ⇒ no rumble of any kind and dead +// host-side code; a layout that provably disagrees with the captured hardware). Two more +// hand-written descriptors would multiply that debt by three for no measured gain, and each would +// need its own capture, its own `wReportLength`, its own `xbox_proto` layout tests and its own +// on-glass verification. When a Linux-hidraw capture settles the real layout (handoff §3.3), it +// lands here ONCE and all three identities get it. // // A standards-clean Game Pad collection matching the Bluetooth Xbox layout: two 16-bit stick pairs, // two 10-bit triggers on the Simulation page, a null-state hat, and 15 buttons. Report `0x01`, @@ -474,6 +512,7 @@ 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 +// Serves device_type 4, 5 AND 6 — one descriptor, three identities (see the XBOX_RDESC header). 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 @@ -492,13 +531,21 @@ 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. +// `devtype` selects the identity: PS family (same Sony VID/version), the N4-spike Deck, or one of +// the three Xbox pads (same Microsoft VID/version — only the PID differs, which is the entire +// difference between them; they share a report descriptor). +// +// ⚠️ THIS is where an Xbox identity is actually decided. Everything else in the Xbox path — +// descriptor, HID descriptor, report length, neutral report — is shared, so a new Xbox model is a +// PID here, a product string in `on_get_string`, an INF model line and nothing else. fn hid_attrs(devtype: u8) -> [u8; 32] { let (vid, pid, ver) = match devtype { 1 => (DS_VID, DS4_PID, DS_VER), 2 => (DS_VID, DS_EDGE_PID, DS_VER), 3 => (DECK_VID, DECK_PID, DS_VER), 4 => (XBOX_VID, XBOX_PID, XBOX_VER), + 5 => (XBOX_VID, XBOX_PID_ONE_S, XBOX_VER), + 6 => (XBOX_VID, XBOX_PID_ELITE2, XBOX_VER), _ => (DS_VID, DS_PID, DS_VER), }; let mut a = [0u8; 32]; @@ -518,10 +565,11 @@ fn hid_attrs(devtype: u8) -> [u8; 32] { /// the caller's buffer rather than truncating — so handing hidclass 64 bytes for a 16-byte report /// fails every single read and the pad looks dead. /// -/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. +/// Returns 64 for every pre-existing identity, so this is provably a no-op for them. All three +/// Xbox identities share one descriptor, hence one report length. fn input_report_len(devtype: u8) -> usize { match devtype { - 4 => XBOX_INPUT_REPORT_LEN, + 4 | 5 | 6 => XBOX_INPUT_REPORT_LEN, _ => 64, } } @@ -578,7 +626,8 @@ fn neutral_report(devtype: u8) -> [u8; 64] { match devtype { 1 => DS4_NEUTRAL_REPORT, 3 => DECK_NEUTRAL_REPORT, - 4 => XBOX_NEUTRAL_REPORT, + // Wireless / One S / Elite Series 2 — one report shape, three identities. + 4 | 5 | 6 => XBOX_NEUTRAL_REPORT, _ => NEUTRAL_REPORT, // DualSense and Edge share the report 0x01 shape } } @@ -731,8 +780,8 @@ static RING_PUBLISH: std::sync::Mutex<()> = std::sync::Mutex::new(()); /// this static is per-pad). The handshake/adoption/validation state machine lives in `pf_umdf_util`. static CHANNEL: ChannelClient = ChannelClient::new(); /// The last observed `device_type` (0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, -/// 3 = Steam Deck) — the neutral-report shape when the channel detaches, and the fallback identity -/// while unattached. +/// 3 = Steam Deck, 4 = Xbox Wireless, 5 = Xbox One S, 6 = Xbox Elite Series 2) — the +/// neutral-report shape when the channel detaches, and the fallback identity while unattached. static LAST_DEVTYPE: AtomicU32 = AtomicU32::new(0); /// The identity resolved from the devnode's PnP hardware ids at `EvtDeviceAdd` ([`devtype_from_hwids`]); /// `u32::MAX` = not resolved. See [`device_type`] for why this exists. @@ -748,9 +797,14 @@ static TICK: AtomicU32 = AtomicU32::new(0); /// can never disagree. /// /// Order matters: `pf_dualsense` is a prefix of `pf_dualsenseedge`, so the Edge is tested first. +/// (No Xbox token is a prefix of another — `pf_xboxwireless` / `pf_xboxones` / `pf_xboxelite` +/// diverge at the 8th character — but `hwid_devtype_table_matches_the_driver` re-checks that for +/// every pair rather than trusting this note.) fn devtype_from_hwids(ids: &str) -> Option { for (token, devtype) in [ ("pf_xboxwireless", 4u8), + ("pf_xboxones", 5), + ("pf_xboxelite", 6), ("pf_steamdeck", 3), ("pf_dualsenseedge", 2), ("pf_dualshock4", 1), @@ -1039,15 +1093,17 @@ extern "C" fn evt_io_device_control( 1 => &DS4_HID_DESC, 2 => &EDGE_HID_DESC, 3 => &DECK_HID_DESC, - 4 => &XBOX_HID_DESC, + 4 | 5 | 6 => &XBOX_HID_DESC, _ => &HID_DESC, }), IOCTL_HID_GET_DEVICE_ATTRIBUTES => request.copy_to_output(&hid_attrs(device_type())), + // The three Xbox identities share ONE report descriptor on purpose — see the XBOX_RDESC + // header. Only `hid_attrs` (VID/PID) and `on_get_string` (product string) tell them apart. IOCTL_HID_GET_REPORT_DESCRIPTOR => request.copy_to_output(match device_type() { 1 => &DS4_RDESC[..], 2 => &DS_EDGE_RDESC[..], 3 => &DECK_RDESC[..], - 4 => &XBOX_RDESC[..], + 4 | 5 | 6 => &XBOX_RDESC[..], _ => &DUALSENSE_RDESC[..], }), IOCTL_HID_WRITE_REPORT | IOCTL_UMDF_HID_SET_OUTPUT_REPORT => { @@ -1309,7 +1365,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { 0 | 0x000e => match devtype { 1 => "Sony Computer Entertainment".into(), 3 => "Valve Software".into(), - 4 => "Microsoft".into(), + 4 | 5 | 6 => "Microsoft".into(), _ => "Sony Interactive Entertainment".into(), }, // Per-pad serials (see `pad_index`): SDL reads this via HidD_GetSerialNumberString and @@ -1322,15 +1378,29 @@ fn on_get_string(request: &Request) -> NTSTATUS { 2 => format!("35533AD6E7{:02X}", 0x75u8.wrapping_add(pad_index())), 3 => format!("FVPF{:08X}", 0x5046_0000u32 | pad_index() as u32), // Xbox pads report a Bluetooth MAC-shaped serial; the low octet carries the pad index - // so Steam dedups multiple forwarded pads, exactly like the PS identities above. + // so Steam dedups multiple forwarded pads, exactly like the PS identities above. Each + // Xbox identity gets its OWN base octet (0x10 / 0x30 / 0x50) rather than sharing one: + // a mixed session can present a Wireless pad and an Elite at once, and two identities + // whose serials differ only by pad index are one off-by-one away from colliding — the + // failure being Steam silently treating two live pads as one device. 4 => format!("F4B0FC2A6C{:02X}", 0x10u8.wrapping_add(pad_index())), + 5 => format!("F4B0FC2A6C{:02X}", 0x30u8.wrapping_add(pad_index())), + 6 => format!("F4B0FC2A6C{:02X}", 0x50u8.wrapping_add(pad_index())), _ => format!("35533AD6E7{:02X}", 0x74u8.wrapping_add(pad_index())), }, _ => match devtype { 1 => "Wireless Controller".into(), 2 => "DualSense Edge Wireless Controller".into(), 3 => "Steam Deck Controller".into(), - 4 => "Xbox Wireless Controller".into(), + // ⚠️ 4 and 5 share a product string ON PURPOSE — a real Xbox Wireless Controller + // (Series X|S, `0B13`) and a real Xbox One S pad (`02FD`) BOTH report exactly + // "Xbox Wireless Controller" over Bluetooth. The PID is what tells them apart, and + // that is what SDL/Steam/Windows key their stock mappings off. Do not "fix" this by + // inventing a distinguishing string; it would make the One S identity a device that + // has never existed. (The INF's Device Manager descriptions DO differ — that string + // is ours, not the pad's.) + 4 | 5 => "Xbox Wireless Controller".into(), + 6 => "Xbox Elite Wireless Controller Series 2".into(), _ => "DualSense Wireless Controller".into(), }, }; @@ -1343,7 +1413,7 @@ fn on_get_string(request: &Request) -> NTSTATUS { } /// The device-type selector: 0 = DualSense, 1 = DualShock 4, 2 = DualSense Edge, 3 = Steam Deck, -/// 4 = Xbox Wireless Controller. +/// 4 = Xbox Wireless Controller, 5 = Xbox One S, 6 = Xbox Elite Wireless Controller Series 2. /// Read fresh on each enumeration query — cheap. /// /// ⚠️ **The sealed section cannot answer the enumeration queries.** hidclass asks for