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
+23 -6
View File
@@ -584,7 +584,11 @@ pub struct PunktfunkHidOutput {
#[cfg(feature = "quic")]
impl PunktfunkHidOutput {
fn from_hid(h: &crate::quic::HidOutput) -> PunktfunkHidOutput {
/// `None` for a [`HidOutput::HidRaw`](crate::quic::HidOutput) — a raw passthrough report
/// (up to 64 bytes) doesn't fit this struct's 11-byte `effect` buffer, and no C-ABI embedder
/// declares the as-is SC2 kind that would receive one; the pull site skips it rather than
/// truncating it into an unreplayable stub.
fn from_hid(h: &crate::quic::HidOutput) -> Option<PunktfunkHidOutput> {
use crate::quic::HidOutput;
let mut out = PunktfunkHidOutput {
kind: 0,
@@ -635,8 +639,9 @@ impl PunktfunkHidOutput {
out.effect[4..6].copy_from_slice(&count.to_le_bytes());
out.effect_len = 6;
}
HidOutput::HidRaw { .. } => return None,
}
out
Some(out)
}
}
@@ -895,6 +900,12 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSEEDGE: u32 = 7;
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo`): Nintendo glyphs +
/// positional layout, gyro/accel, HD rumble. Folds to `XBOX360` until its backend lands.
pub const PUNKTFUNK_GAMEPAD_SWITCHPRO: u32 = 8;
/// New Steam Controller (2026, Valve `28DE:1302`) passed through AS-IS: the host mirrors the
/// client's raw Triton input reports out of a virtual SC2 with the real identity, and Steam's
/// hidraw writes (lizard mode, IMU enable, rumble/haptics) come back raw for the physical pad.
/// 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;
/// 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
@@ -958,6 +969,7 @@ const _: () = {
assert!(PUNKTFUNK_GAMEPAD_STEAMDECK == GamepadPref::SteamDeck.to_u8() as u32);
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);
// 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);
@@ -2023,10 +2035,15 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
.inner
.next_hidout(std::time::Duration::from_millis(timeout_ms as u64))
{
Ok(h) => {
unsafe { *out = PunktfunkHidOutput::from_hid(&h) };
PunktfunkStatus::Ok
}
Ok(h) => match PunktfunkHidOutput::from_hid(&h) {
Some(v) => {
unsafe { *out = v };
PunktfunkStatus::Ok
}
// A raw as-is passthrough report (no C representation) — report "nothing this
// poll" and let the embedder's poll loop continue; see `from_hid`.
None => PunktfunkStatus::NoFrame,
},
Err(e) => e.status(),
}
})
+21 -4
View File
@@ -138,8 +138,9 @@ impl CompositorPref {
/// honored only if that backend is available on the host (DualSense / DualShock 4 need Linux UHID);
/// 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`), appended to
/// `Hello`/`Welcome` — older peers simply omit/ignore it (an unknown byte degrades to `Auto`).
/// `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`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum GamepadPref {
/// Let the host pick (its `PUNKTFUNK_GAMEPAD` env var, else X-Box 360).
@@ -172,11 +173,20 @@ pub enum GamepadPref {
/// Nintendo Switch Pro Controller (Nintendo `057E:2009`, kernel `hid-nintendo` ≥ 5.16) —
/// correct Nintendo glyphs + positional layout, gyro/accel, HD rumble back. Needs Linux UHID.
SwitchPro,
/// New Steam Controller (2026, Valve "Ibex"/SDL "Triton", wired `28DE:1302`) passed through
/// AS-IS: the host presents a virtual SC2 with the real identity and mirrors the client's raw
/// Triton input reports ([`RichInput::HidReport`](crate::quic::RichInput)); Steam on the host
/// drives it over hidraw exactly like the physical pad (its feature/output writes — lizard
/// mode, IMU enable, rumble/haptics — come back raw on the HID-output plane and land on the
/// 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,
}
impl GamepadPref {
/// Wire byte. `0 = Auto`, `1 = Xbox360`, `2 = DualSense`, `3 = XboxOne`, `4 = DualShock4`,
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`.
/// `5 = SteamController`, `6 = SteamDeck`, `7 = DualSenseEdge`, `8 = SwitchPro`,
/// `9 = SteamController2`.
pub const fn to_u8(self) -> u8 {
match self {
GamepadPref::Auto => 0,
@@ -188,6 +198,7 @@ impl GamepadPref {
GamepadPref::SteamDeck => 6,
GamepadPref::DualSenseEdge => 7,
GamepadPref::SwitchPro => 8,
GamepadPref::SteamController2 => 9,
}
}
@@ -203,6 +214,7 @@ impl GamepadPref {
6 => GamepadPref::SteamDeck,
7 => GamepadPref::DualSenseEdge,
8 => GamepadPref::SwitchPro,
9 => GamepadPref::SteamController2,
_ => GamepadPref::Auto,
}
}
@@ -224,12 +236,16 @@ impl GamepadPref {
"switchpro" | "switch-pro" | "switch" | "procontroller" | "pro-controller" => {
GamepadPref::SwitchPro
}
"steamcontroller2" | "steam-controller-2" | "steamcon2" | "sc2" | "ibex" => {
GamepadPref::SteamController2
}
_ => return None,
})
}
/// Canonical lowercase identifier (`"auto"`, `"xbox360"`, `"dualsense"`, `"xboxone"`,
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`).
/// `"dualshock4"`, `"steamcontroller"`, `"steamdeck"`, `"dualsenseedge"`, `"switchpro"`,
/// `"steamcontroller2"`).
pub fn as_str(self) -> &'static str {
match self {
GamepadPref::Auto => "auto",
@@ -241,6 +257,7 @@ impl GamepadPref {
GamepadPref::SteamDeck => "steamdeck",
GamepadPref::DualSenseEdge => "dualsenseedge",
GamepadPref::SwitchPro => "switchpro",
GamepadPref::SteamController2 => "steamcontroller2",
}
}
}
@@ -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
/// 4654 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,
}
}
+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();