feat(core,host,android): Steam Controller 2 as-is passthrough to Linux hosts
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m2s
ci / rust (push) Failing after 5m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
ci / bench (push) Successful in 6m3s
docker / deploy-docs (push) Successful in 25s
android / android (push) Successful in 14m2s
arch / build-publish (push) Successful in 11m57s
deb / build-publish (push) Successful in 11m49s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
flatpak / build-publish (push) Failing after 8m3s
windows-host / package (push) Failing after 8m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m18s
apple / swift (push) Successful in 5m4s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 5m14s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m43s
release / apple (push) Successful in 26m11s
apple / screenshots (push) Has been cancelled

The 2026 Steam Controller (Valve "Ibex" / SDL "Triton") captured on an
Android client is passed through AS-IS: the host presents a virtual pad
with the real wired identity (28DE:1302) and mirrors the physical pad's
raw HID reports, so Steam on the host drives it over hidraw exactly like
the real thing — trackpads, gyro, paddles, and its rumble/settings writes
flow back onto the physical controller. Protocol ground truth: SDL's
Valve-maintained SDL_hidapi_steam_triton.c + steam/controller_structs.h.

Core:
- GamepadPref::SteamController2 (wire byte 9; names steamcontroller2/
  sc2/ibex) + PUNKTFUNK_GAMEPAD_STEAMCONTROLLER2 in the C ABI.
- Raw HID planes: RichInput::HidReport (0xCC/0x04, client→host input
  reports verbatim, Copy fixed-64 body) and HidOutput::HidRaw (0xCD/0x05,
  host→client feature/output writes for replay). Best-effort is sound by
  the device protocol's own design (rumble re-sent every ~40 ms, settings
  every ~3 s — losses self-heal); HidRaw bypasses hidout dedup for
  exactly that reason.

Host (Linux):
- triton_proto.rs + steam_controller2.rs: Triton2Manager UHID backend —
  no kernel driver binds the PID (hidraw only; Steam Input is the
  consumer), raw mirroring with a typed-fallback 0x42 synthesizer until
  the first raw report, SET_REPORT ack + raw forward, canned GET_REPORT
  serial reply, rumble also parsed onto the universal 0xCA plane (phone
  mirror). Rides the uhid + 28DE-conflict degrades; UHID promotion by
  Steam is flagged in the creation log (usbip transport is the known
  follow-up if Steam ignores Interface:-1 devices for Triton too).

Android:
- Sc2UsbLink (wired/Puck: vendor-interface claim detaches the OS driver,
  interrupt read loop, lizard-off on the watchdog cadence, raw replay via
  interrupt-OUT / SET_REPORT with hidapi report-id framing) and Sc2BleLink
  (Valve vendor GATT service, notify subscribe machine, 0x45 re-framing,
  HIGH connection priority).
- Sc2Capture orchestrator: raw plane + typed mirror (exit chord + host
  degrade paths keep working) on a GamepadRouter external slot; raw
  return path via GamepadFeedback.onHidRaw.
- nativeSendPadHidReport JNI (direct ByteBuffer, no per-report copy),
  hidout raw decode, usb-host/BLUETOOTH_CONNECT manifest bits, opt-out
  settings toggle, StreamScreen engagement incl. the USB permission flow.

Verified: core 149 + host 312 tests green on Linux (.21), on-box uhid
smoke creates/mirrors/tears down the virtual 28DE:1302, C ABI harness
round-trips, Android compileDebugKotlin green. On-glass with the real
controller owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:22:16 +02:00
parent 705a8baddf
commit 2621b6e6b1
29 changed files with 1977 additions and 40 deletions
+56 -2
View File
@@ -342,11 +342,12 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::SteamDeck,
GamepadPref::DualSenseEdge,
GamepadPref::SwitchPro,
GamepadPref::SteamController2,
] {
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
}
// Every wire byte 0..=8 is assigned, distinct, and pinned (forward-compat with peers
// Every wire byte 0..=9 is assigned, distinct, and pinned (forward-compat with peers
// that only know a prefix of the range).
for (v, p) in [
(0, GamepadPref::Auto),
@@ -358,12 +359,13 @@ fn gamepad_pref_wire_and_names() {
(6, GamepadPref::SteamDeck),
(7, GamepadPref::DualSenseEdge),
(8, GamepadPref::SwitchPro),
(9, GamepadPref::SteamController2),
] {
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(9), GamepadPref::Auto);
assert_eq!(GamepadPref::from_u8(10), GamepadPref::Auto);
// Aliases + unknowns.
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
@@ -377,6 +379,14 @@ fn gamepad_pref_wire_and_names() {
GamepadPref::from_name("Switch-Pro"),
Some(GamepadPref::SwitchPro)
);
assert_eq!(
GamepadPref::from_name("ibex"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("sc2"),
Some(GamepadPref::SteamController2)
);
assert_eq!(
GamepadPref::from_name("xbox-one"),
Some(GamepadPref::XboxOne)
@@ -1197,6 +1207,33 @@ fn rich_input_roundtrip() {
assert_eq!(d[0], RICH_INPUT_MAGIC);
assert_eq!(RichInput::decode(&d), Some(ev));
}
// A raw Triton state report rides the plane verbatim (as-is SC2 passthrough).
let mut data = [0u8; HID_REPORT_MAX];
data[0] = 0x42; // ID_TRITON_CONTROLLER_STATE
for (i, b) in data.iter_mut().enumerate().take(46).skip(1) {
*b = i as u8;
}
let raw = RichInput::HidReport {
pad: 3,
len: 46,
data,
};
let d = raw.encode();
assert_eq!(d.len(), 4 + 46); // tag + kind + pad + len + body — no fixed-array padding
assert_eq!(RichInput::decode(&d), Some(raw));
// A torn HidReport truncates to what arrived rather than over-reading (len clamps).
assert_eq!(
RichInput::decode(&d[..20]),
Some(RichInput::HidReport {
pad: 3,
len: 16,
data: {
let mut t = [0u8; HID_REPORT_MAX];
t[..16].copy_from_slice(&data[..16]);
t
},
})
);
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
@@ -1230,6 +1267,23 @@ fn hid_output_roundtrip() {
period: 0x5678,
count: 9,
},
// A raw Triton rumble output report (as-is SC2 passthrough, host→client).
HidOutput::HidRaw {
pad: 1,
kind: HID_RAW_OUTPUT,
data: vec![0x80, 0, 0, 0, 0x34, 0x12, 0, 0x78, 0x56, 0],
},
// A raw 64-byte feature report (lizard-off / IMU-enable settings write).
HidOutput::HidRaw {
pad: 0,
kind: HID_RAW_FEATURE,
data: {
let mut f = vec![0u8; HID_REPORT_MAX];
f[0] = 1; // Triton feature reports ride report id 1
f[1] = 0x87; // ID_SET_SETTINGS_VALUES
f
},
},
];
for ev in &cases {
let d = ev.encode();