feat(gamepad): SC2 Puck-dongle passthrough with the native 28DE:1304 topology

Community-contributed round 5 of the Steam Controller 2 passthrough,
reviewed + verified. A Puck-captured pad now presents the dongle's real
seven-interface identity (CDC pair, four controller HID slots, management
HID) instead of relabelling its reports as a wired 1302 — Steam's Puck
feature dances (wireless_transport / esb/bond / 0xB4 slot status) get
capture-shaped answers, and the wired identity's canned replies are
corrected to the real captures (attribute count, string-attr framing,
0xF2 firmware info, bcdDevice nibble encoding).

- new wire pref 10 = SteamController2Puck (Hello/Welcome byte; older
  peers degrade to Auto), selected by the Android capture link when the
  transport is a dongle, or by VID/PID in the degraded InputDevice path
- TRITON_RDESC is now the captured numbered descriptor (mouse/keyboard
  lizard collections + per-id vendor reports); unnumbered framing made
  hidraw mangle feature report 2 and Steam eventually closed the device
- interrupt-IN now queues sparse reports (battery/RSSI/wireless edges)
  instead of keeping latest-only, so a 250 Hz state packet can no longer
  erase them before the USB/IP poll observes them; EP0 SET_REPORT is
  split by wValue report type (OUTPUT parsed for rumble vs FEATURE)
- vendored usbip-sim: config attributes/max-power, IAD prefix + BOS
  descriptor support, correct BCD minor.patch encoding (Deck's 0x0300/
  0x0200 values are nibble-zero, so its bytes are unchanged), and
  full-speed interrupt pacing in ms (was 8 kHz from the HS formula)
- Triton feedback is serviced at 1 kHz while an SC2 backend exists so
  Steam's trackpad haptic writes reach the client unbatched

Verified: clippy -D warnings + 319 host tests green on Linux, core wire
tests green, Android kit/app compile + unit tests green. On-glass Puck
retest owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 20:47:52 +02:00
parent b50b698078
commit 01266ff18d
15 changed files with 971 additions and 170 deletions
+7
View File
@@ -906,6 +906,10 @@ pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// Steam Input is the consumer (no kernel driver binds the PID). Honored on Linux (UHID);
/// else folds to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2: u32 = 9;
/// Steam Controller Puck dongle (`28DE:1304`) passed through with its native seven-interface
/// 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;
/// Extended `InputEvent` gamepad button bits for embedders building raw events: the four back grips
/// (Steam L4/L5/R4/R5 ≙ Xbox-Elite P1P4) + the misc/capture button, in Moonlight's
@@ -970,6 +974,9 @@ const _: () = {
assert!(PUNKTFUNK_GAMEPAD_DUALSENSEEDGE == GamepadPref::DualSenseEdge.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_SWITCHPRO == GamepadPref::SwitchPro.to_u8() as u32);
assert!(PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 == GamepadPref::SteamController2.to_u8() as u32);
assert!(
PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2_PUCK == GamepadPref::SteamController2Puck.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);
+14 -4
View File
@@ -139,8 +139,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`), appended to `Hello`/`Welcome` — older peers simply omit/ignore it (an
/// unknown byte degrades to `Auto`).
/// `9 = SteamController2`, `10 = SteamController2Puck`), 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).
@@ -181,12 +181,16 @@ pub enum GamepadPref {
/// real controller). No kernel driver binds the PID (mainline `hid-steam` stops at the Deck),
/// so Steam Input is the consumer. Needs Linux UHID.
SteamController2,
/// Steam Controller Puck dongle (`28DE:1304`) carrying a captured SC2. The host presents the
/// native seven-interface Puck topology (CDC pair, four controller slots, management HID)
/// rather than relabelling its reports as a wired `1302`.
SteamController2Puck,
}
impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`.
/// `9 = SteamController2`, `10 = SteamController2Puck`.
pub const fn to_u8(self) -> u8 {
match self {
GamepadPref::Auto => 0,
@@ -199,6 +203,7 @@ impl GamepadPref {
GamepadPref::DualSenseEdge => 7,
GamepadPref::SwitchPro => 8,
GamepadPref::SteamController2 => 9,
GamepadPref::SteamController2Puck => 10,
}
}
@@ -215,6 +220,7 @@ impl GamepadPref {
7 => GamepadPref::DualSenseEdge,
8 => GamepadPref::SwitchPro,
9 => GamepadPref::SteamController2,
10 => GamepadPref::SteamController2Puck,
_ => GamepadPref::Auto,
}
}
@@ -239,13 +245,16 @@ impl GamepadPref {
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
GamepadPref::SteamController2
}
"steamcontroller2puck" | "steam-controller-2-puck" | "sc2puck" | "ibexpuck" => {
GamepadPref::SteamController2Puck
}
_ => return None,
})
}
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
/// `"steamcontroller2"`).
/// `"steamcontroller2"`, `"steamcontroller2puck"`).
pub fn as_str(self) -> &'static str {
match self {
GamepadPref::Auto => "auto",
@@ -258,6 +267,7 @@ impl GamepadPref {
GamepadPref::DualSenseEdge => "dualsenseedge",
GamepadPref::SwitchPro => "switchpro",
GamepadPref::SteamController2 => "steamcontroller2",
GamepadPref::SteamController2Puck => "steamcontroller2puck",
}
}
}
+8 -2
View File
@@ -343,11 +343,12 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro,
GamepadPref::SteamController2,
GamepadPref::SteamController2Puck,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Every wire byte 0..=9 is assigned, distinct, and pinned (forward-compat with peers
// Every wire byte 0..=10 is assigned, distinct, and pinned (forward-compat with peers
// that only know a prefix of the range).
for (v, p) in [
(0, GamepadPref::Auto),
@@ -360,12 +361,13 @@ fn gamepad_pref_wire_and_names() {
(7, GamepadPref::DualSenseEdge),
(8, GamepadPref::SwitchPro),
(9, GamepadPref::SteamController2),
(10, GamepadPref::SteamController2Puck),
] {
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(10), GamepadPref::Auto);
assert_eq!(GamepadPref::from_u8(11), GamepadPref::Auto);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
@@ -387,6 +389,10 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::from_name("sc2"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("sc2puck"),
Some(GamepadPref::SteamController2Puck)
);
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)