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
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:
@@ -161,6 +161,12 @@ pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
|
||||
pub(super) const RICH_MOTION: u8 = 0x02;
|
||||
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||
pub(super) const RICH_HID_REPORT: u8 = 0x04;
|
||||
|
||||
/// Longest raw HID report a [`RichInput::HidReport`] / [`HidOutput::HidRaw`] can carry — the
|
||||
/// 64-byte interrupt/feature report size every Valve controller uses (Triton input reports are
|
||||
/// 46–54 bytes; feature and output reports are at most 64).
|
||||
pub const HID_REPORT_MAX: usize = 64;
|
||||
|
||||
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||
@@ -206,6 +212,19 @@ pub enum RichInput {
|
||||
y: i16,
|
||||
pressure: u16,
|
||||
},
|
||||
/// One raw HID input report from a client-captured controller, forwarded verbatim for a
|
||||
/// host backend that mirrors the physical device as-is (the Steam Controller 2 / Triton
|
||||
/// passthrough — [`GamepadPref::SteamController2`](crate::config::GamepadPref)). `data[..len]`
|
||||
/// is exactly what the device produced on its interrupt endpoint / GATT notify, report-id
|
||||
/// byte first (`0x42`/`0x45`/`0x47` state, `0x43` battery, …). Best-effort like the rest of
|
||||
/// the plane: state reports are idempotent snapshots at the device's own rate, so a lost
|
||||
/// datagram self-heals on the next one. Fixed-size body keeps the type `Copy` on a path that
|
||||
/// runs at the controller's report rate.
|
||||
HidReport {
|
||||
pad: u8,
|
||||
len: u8,
|
||||
data: [u8; HID_REPORT_MAX],
|
||||
},
|
||||
}
|
||||
|
||||
impl RichInput {
|
||||
@@ -245,6 +264,11 @@ impl RichInput {
|
||||
out.extend_from_slice(&y.to_le_bytes());
|
||||
out.extend_from_slice(&pressure.to_le_bytes());
|
||||
}
|
||||
RichInput::HidReport { pad, len, ref data } => {
|
||||
let len = (len as usize).min(HID_REPORT_MAX);
|
||||
out.extend_from_slice(&[RICH_HID_REPORT, pad, len as u8]);
|
||||
out.extend_from_slice(&data[..len]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -279,6 +303,18 @@ impl RichInput {
|
||||
y: i16::from_le_bytes([b[8], b[9]]),
|
||||
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||
}),
|
||||
RICH_HID_REPORT if b.len() >= 4 => {
|
||||
// Every byte read below is bounded: `len` is clamped to the fixed body size AND
|
||||
// to what the buffer actually holds (a torn datagram truncates, never over-reads).
|
||||
let len = (b[3] as usize).min(HID_REPORT_MAX).min(b.len() - 4);
|
||||
let mut data = [0u8; HID_REPORT_MAX];
|
||||
data[..len].copy_from_slice(&b[4..4 + len]);
|
||||
Some(RichInput::HidReport {
|
||||
pad: b[2],
|
||||
len: len as u8,
|
||||
data,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -288,6 +324,16 @@ const HIDOUT_LED: u8 = 0x01;
|
||||
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||
const HIDOUT_HID_RAW: u8 = 0x05;
|
||||
|
||||
/// [`HidOutput::HidRaw`] `kind`: an OUTPUT report — what the host's hidraw client wrote with
|
||||
/// `write()`/`SDL_hid_write` (Triton rumble `0x80`, haptic pulse `0x81`, …). The client replays
|
||||
/// it on the physical device's interrupt-OUT endpoint / GATT write.
|
||||
pub const HID_RAW_OUTPUT: u8 = 0;
|
||||
/// [`HidOutput::HidRaw`] `kind`: a FEATURE report — what the host's hidraw client sent with
|
||||
/// `SET_REPORT` (`SDL_hid_send_feature_report`: lizard mode, IMU enable, settings). The client
|
||||
/// replays it as a USB `SET_REPORT(Feature)` control transfer / GATT feature write.
|
||||
pub const HID_RAW_FEATURE: u8 = 1;
|
||||
|
||||
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||
@@ -311,6 +357,14 @@ pub enum HidOutput {
|
||||
period: u16,
|
||||
count: u16,
|
||||
},
|
||||
/// A raw report the host's hidraw consumer (Steam) wrote to an as-is passthrough pad
|
||||
/// ([`RichInput::HidReport`]'s reverse direction), for the client to replay verbatim on the
|
||||
/// physical device. `kind` is [`HID_RAW_OUTPUT`] or [`HID_RAW_FEATURE`]; `data` is the full
|
||||
/// report, id byte first, at most [`HID_REPORT_MAX`] bytes. Best-effort is sound here by the
|
||||
/// device protocol's own design: Triton rumble is re-sent every ~40 ms against a ~50 ms
|
||||
/// hardware safety timeout, and settings (lizard/IMU) are refreshed every ~3 s against the
|
||||
/// firmware watchdog — a lost datagram heals on the next refresh.
|
||||
HidRaw { pad: u8, kind: u8, data: Vec<u8> },
|
||||
}
|
||||
|
||||
impl HidOutput {
|
||||
@@ -339,6 +393,10 @@ impl HidOutput {
|
||||
out.extend_from_slice(&period.to_le_bytes());
|
||||
out.extend_from_slice(&count.to_le_bytes());
|
||||
}
|
||||
HidOutput::HidRaw { pad, kind, data } => {
|
||||
out.extend_from_slice(&[HIDOUT_HID_RAW, *pad, *kind]);
|
||||
out.extend_from_slice(&data[..data.len().min(HID_REPORT_MAX)]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -370,6 +428,12 @@ impl HidOutput {
|
||||
period: u16::from_le_bytes([b[6], b[7]]),
|
||||
count: u16::from_le_bytes([b[8], b[9]]),
|
||||
}),
|
||||
HIDOUT_HID_RAW if b.len() >= 5 => Some(HidOutput::HidRaw {
|
||||
pad: b[2],
|
||||
kind: b[3],
|
||||
// Bounded: at most HID_REPORT_MAX bytes are kept from the (attacker-sized) tail.
|
||||
data: b[4..b.len().min(4 + HID_REPORT_MAX)].to_vec(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user