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:
@@ -527,6 +527,11 @@ pub mod pad_slots;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_controller.rs"]
|
||||
pub mod steam_controller;
|
||||
/// Linux: virtual Steam Controller 2 (Triton, `28DE:1302`) via UHID — as-is raw passthrough of a
|
||||
/// client-captured physical pad; Steam Input drives the hidraw node (no kernel driver binds it).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/linux/steam_controller2.rs"]
|
||||
pub mod steam_controller2;
|
||||
/// Windows: virtual Steam Deck via the same UMDF minidriver + shared-memory channel
|
||||
/// (device-type 3) — promoted by Steam Input thanks to the `&MI_02` hardware-id synthesis.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -565,6 +570,12 @@ pub mod switch_pro;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/switch_proto.rs"]
|
||||
pub mod switch_proto;
|
||||
/// Transport-independent Steam Controller 2 (Triton) contract: descriptor, SDL-documented report
|
||||
/// layout, the typed fallback serializer, and the rumble-output parser. Linux-only consumer today
|
||||
/// ([`steam_controller2`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "inject/proto/triton_proto.rs"]
|
||||
pub mod triton_proto;
|
||||
/// The generic stateful virtual-pad manager ([`uhid_manager::UhidManager`]) — event routing, frame
|
||||
/// merge, heartbeat, and feedback pump shared by the five UHID/UMDF backends; each supplies only
|
||||
/// its per-controller protocol via [`uhid_manager::PadProto`] (G12).
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Virtual **Steam Controller 2** (Triton) via UHID — the as-is passthrough backend
|
||||
//! ([`GamepadPref::SteamController2`](punktfunk_core::config::GamepadPref)). The
|
||||
//! transport-independent contract (descriptor, report ids, the typed fallback serializer, the
|
||||
//! rumble parser) lives in [`super::triton_proto`]; this module is the `/dev/uhid` plumbing.
|
||||
//!
|
||||
//! Deltas vs the Deck backend ([`super::steam_controller`]):
|
||||
//!
|
||||
//! 1. **No kernel driver.** Mainline `hid-steam` doesn't bind `28DE:1302`, so the device gets
|
||||
//! `hid-generic` + a hidraw node and NO evdev — Steam Input (hidapi over hidraw) is the only
|
||||
//! consumer, exactly as it is for the physical pad. No `gamepad_mode` machinery applies.
|
||||
//! 2. **Raw mirroring.** Input reports arrive verbatim from the client
|
||||
//! ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)) and are written unchanged;
|
||||
//! everything Steam writes back (SET_REPORT features, OUTPUT haptics) is acked and forwarded
|
||||
//! raw for replay on the physical controller.
|
||||
//! 3. **UHID-only for now.** Steam Input historically ignores UHID devices for *promotion*
|
||||
//! (`Interface: -1`; the Deck path grew usbip/gadget transports for this). Whether Steam's
|
||||
//! Triton support accepts a UHID hidraw is unverified on-glass — the creation log flags it,
|
||||
//! and a usbip transport (needs the physical pad's captured USB descriptors) is the known
|
||||
//! follow-up if it doesn't.
|
||||
|
||||
use super::triton_proto::{
|
||||
parse_triton_rumble, serialize_triton_state, strip_report_prefix, TritonState, TRITON_RDESC,
|
||||
TRITON_STATE_LEN, TRITON_VENDOR, TRITON_WIRED_PRODUCT,
|
||||
};
|
||||
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
|
||||
use anyhow::{Context, Result};
|
||||
use punktfunk_core::quic::{HidOutput, RichInput, HID_RAW_FEATURE, HID_RAW_OUTPUT};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
// /dev/uhid event ABI — same layout as the Deck/DualSense backends.
|
||||
const UHID_PATH: &str = "/dev/uhid";
|
||||
const UHID_DESTROY: u32 = 1;
|
||||
const UHID_OUTPUT: u32 = 6;
|
||||
const UHID_GET_REPORT: u32 = 9;
|
||||
const UHID_GET_REPORT_REPLY: u32 = 10;
|
||||
const UHID_CREATE2: u32 = 11;
|
||||
const UHID_INPUT2: u32 = 12;
|
||||
const UHID_SET_REPORT: u32 = 13;
|
||||
const UHID_SET_REPORT_REPLY: u32 = 14;
|
||||
const HID_MAX_DESCRIPTOR_SIZE: usize = 4096;
|
||||
const UHID_EVENT_SIZE: usize = 4 + 4372;
|
||||
const BUS_USB: u16 = 0x03;
|
||||
|
||||
fn put_cstr(ev: &mut [u8], off: usize, cap: usize, s: &str) {
|
||||
let n = s.len().min(cap - 1);
|
||||
ev[off..off + n].copy_from_slice(&s.as_bytes()[..n]);
|
||||
}
|
||||
|
||||
/// A virtual Steam Controller 2 backed by `/dev/uhid`. Dropping it destroys the device.
|
||||
pub struct TritonPad {
|
||||
fd: File,
|
||||
/// Synth-mode sequence counter (the raw path carries the physical pad's own seq).
|
||||
seq: u8,
|
||||
/// Raw reports Steam wrote since the last service pass, kind-tagged for the 0xCD plane.
|
||||
pending_raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl TritonPad {
|
||||
pub fn open(index: u8) -> Result<TritonPad> {
|
||||
let fd = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.custom_flags(libc::O_NONBLOCK)
|
||||
.open(UHID_PATH)
|
||||
.with_context(|| {
|
||||
format!("open {UHID_PATH} (is the uhid udev rule installed + are you in 'input'?)")
|
||||
})?;
|
||||
let mut pad = TritonPad {
|
||||
fd,
|
||||
seq: 0,
|
||||
pending_raw: Vec::new(),
|
||||
};
|
||||
pad.send_create2(index).context("UHID_CREATE2 Triton pad")?;
|
||||
Ok(pad)
|
||||
}
|
||||
|
||||
fn send_create2(&mut self, index: u8) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
|
||||
// The physical pad's USB product string is "Steam Controller"; keep the punktfunk prefix
|
||||
// convention every virtual pad uses (Steam matches on VID/PID, not the name).
|
||||
put_cstr(
|
||||
&mut ev,
|
||||
4,
|
||||
128,
|
||||
&format!("Punktfunk Steam Controller 2 {index}"),
|
||||
); // name[128]
|
||||
put_cstr(&mut ev, 132, 64, &format!("punktfunk/triton/{index}")); // phys[64]
|
||||
put_cstr(&mut ev, 196, 64, &format!("punktfunk-triton-{index}")); // uniq[64]
|
||||
ev[260..262].copy_from_slice(&(TRITON_RDESC.len() as u16).to_ne_bytes()); // rd_size
|
||||
ev[262..264].copy_from_slice(&BUS_USB.to_ne_bytes()); // bus
|
||||
ev[264..268].copy_from_slice(&TRITON_VENDOR.to_ne_bytes());
|
||||
ev[268..272].copy_from_slice(&TRITON_WIRED_PRODUCT.to_ne_bytes());
|
||||
ev[272..276].copy_from_slice(&0x0100u32.to_ne_bytes()); // version
|
||||
ev[276..280].copy_from_slice(&0u32.to_ne_bytes()); // country
|
||||
ev[280..280 + TRITON_RDESC.len()].copy_from_slice(TRITON_RDESC);
|
||||
self.fd.write_all(&ev).context("write UHID_CREATE2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mirror one report out: the client's raw bytes verbatim in as-is mode, else a synthesized
|
||||
/// minimal `0x42` state report from the typed fallback fields.
|
||||
pub fn write_state(&mut self, st: &TritonState) -> Result<()> {
|
||||
if st.raw_len > 0 {
|
||||
let len = (st.raw_len as usize).min(st.raw.len());
|
||||
return self.write_input(&st.raw[..len]);
|
||||
}
|
||||
self.seq = self.seq.wrapping_add(1);
|
||||
let mut r = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut r, st, self.seq);
|
||||
self.write_input(&r)
|
||||
}
|
||||
|
||||
fn write_input(&mut self, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
|
||||
ev[4..6].copy_from_slice(&(data.len() as u16).to_ne_bytes()); // input2.size
|
||||
ev[6..6 + data.len()].copy_from_slice(data); // input2.data
|
||||
self.fd.write_all(&ev).context("write UHID_INPUT2")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Service the device, non-blocking: ack SET_REPORTs (a stalled ack blocks the writer ~5 s),
|
||||
/// answer GET_REPORTs (best-effort canned reply — the query/answer feature dance can't
|
||||
/// round-trip to the physical pad synchronously), and queue every report Steam wrote for raw
|
||||
/// forwarding. Returns the rumble level if a `0x80` output report was seen this pass.
|
||||
pub fn service(&mut self) -> Option<(u16, u16)> {
|
||||
let mut rumble = None;
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
while let Ok(n) = self.fd.read(&mut ev) {
|
||||
if n < UHID_EVENT_SIZE {
|
||||
break;
|
||||
}
|
||||
match u32::from_ne_bytes([ev[0], ev[1], ev[2], ev[3]]) {
|
||||
UHID_OUTPUT => {
|
||||
let size = u16::from_ne_bytes([ev[4100], ev[4101]]) as usize;
|
||||
let end = 4 + size.min(HID_MAX_DESCRIPTOR_SIZE);
|
||||
let rep = strip_report_prefix(&ev[4..end]);
|
||||
if let Some(r) = parse_triton_rumble(rep) {
|
||||
rumble = Some(r);
|
||||
}
|
||||
self.queue_raw(HID_RAW_OUTPUT, rep);
|
||||
}
|
||||
UHID_SET_REPORT => {
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
// uhid_set_report: id u32, rnum u8, rtype u8, size u16, data — data at ev[12..].
|
||||
let size = u16::from_ne_bytes([ev[10], ev[11]]) as usize;
|
||||
let end = (12 + size.min(HID_MAX_DESCRIPTOR_SIZE)).min(UHID_EVENT_SIZE);
|
||||
let rep = strip_report_prefix(&ev[12..end]);
|
||||
if let Some(r) = parse_triton_rumble(rep) {
|
||||
rumble = Some(r); // some stacks send haptics on the feature path
|
||||
}
|
||||
self.queue_raw(HID_RAW_FEATURE, rep);
|
||||
let _ = self.reply_set_report(id);
|
||||
}
|
||||
UHID_GET_REPORT => {
|
||||
// Steam's attribute/serial reads can't reach the physical pad synchronously;
|
||||
// answer with a plausible Triton-shaped string-attribute reply (the same
|
||||
// canned-reply approach the virtual Deck ships). Logged for on-glass tuning.
|
||||
let id = u32::from_ne_bytes([ev[4], ev[5], ev[6], ev[7]]);
|
||||
tracing::debug!(
|
||||
rnum = ev[8],
|
||||
"virtual SC2: GET_REPORT — canned serial reply"
|
||||
);
|
||||
let _ = self.reply_get_report(id, &triton_serial_reply("PUNKTFUNK02"));
|
||||
}
|
||||
_ => {} // Start/Stop/Open/Close — ignore
|
||||
}
|
||||
}
|
||||
rumble
|
||||
}
|
||||
|
||||
/// Queue a raw report for the 0xCD plane, capped so a hidraw client gone haywire can't grow
|
||||
/// the queue unboundedly between pumps (newest wins — these are level-styled commands).
|
||||
fn queue_raw(&mut self, kind: u8, data: &[u8]) {
|
||||
if data.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.pending_raw.len() >= 32 {
|
||||
self.pending_raw.remove(0);
|
||||
}
|
||||
self.pending_raw.push((kind, data.to_vec()));
|
||||
}
|
||||
|
||||
fn reply_get_report(&mut self, id: u32, data: &[u8]) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_GET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0
|
||||
ev[10..12].copy_from_slice(&(data.len() as u16).to_ne_bytes());
|
||||
ev[12..12 + data.len()].copy_from_slice(data);
|
||||
self.fd.write_all(&ev).context("UHID_GET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reply_set_report(&mut self, id: u32) -> Result<()> {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_SET_REPORT_REPLY.to_ne_bytes());
|
||||
ev[4..8].copy_from_slice(&id.to_ne_bytes());
|
||||
ev[8..10].copy_from_slice(&0u16.to_ne_bytes()); // err 0 (ack)
|
||||
self.fd.write_all(&ev).context("UHID_SET_REPORT_REPLY")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The Valve feature GET reply shape (`[report-id 1][ID_GET_STRING_ATTRIBUTE][len][unit-serial]
|
||||
/// [ascii…]`), Triton-flavored: feature reports ride report id 1 on this device (SDL sends
|
||||
/// `buffer[0] = 1`), unlike the Deck's id-0 path.
|
||||
fn triton_serial_reply(serial: &str) -> [u8; 64] {
|
||||
const ID_GET_STRING_ATTRIBUTE: u8 = 0xAE;
|
||||
const ATTRIB_STR_UNIT_SERIAL: u8 = 0x01;
|
||||
let mut buf = [0u8; 64];
|
||||
let bytes = serial.as_bytes();
|
||||
let len = bytes.len().clamp(1, 21);
|
||||
buf[0] = 0x01; // feature report id
|
||||
buf[1] = ID_GET_STRING_ATTRIBUTE;
|
||||
buf[2] = len as u8;
|
||||
buf[3] = ATTRIB_STR_UNIT_SERIAL;
|
||||
buf[4..4 + len].copy_from_slice(&bytes[..len]);
|
||||
buf
|
||||
}
|
||||
|
||||
impl Drop for TritonPad {
|
||||
fn drop(&mut self) {
|
||||
let mut ev = [0u8; UHID_EVENT_SIZE];
|
||||
ev[0..4].copy_from_slice(&UHID_DESTROY.to_ne_bytes());
|
||||
let _ = self.fd.write_all(&ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Triton-specific half of the shared stateful manager (see [`PadProto`]): raw mirroring
|
||||
/// with the typed fallback, and the raw-forwarding service pass.
|
||||
#[derive(Default)]
|
||||
pub struct TritonProto;
|
||||
|
||||
impl PadProto for TritonProto {
|
||||
type Pad = TritonPad;
|
||||
type State = TritonState;
|
||||
const LABEL: &'static str = "Steam Controller 2";
|
||||
const DEVICE: &'static str = "Steam Controller 2";
|
||||
const CREATE_HINT: &'static str = "";
|
||||
|
||||
fn open(&mut self, idx: u8) -> Result<TritonPad> {
|
||||
let p = TritonPad::open(idx)?;
|
||||
tracing::info!(
|
||||
index = idx,
|
||||
"virtual Steam Controller 2 created (UHID 28DE:1302, as-is passthrough — hidraw \
|
||||
only, no kernel driver; if Steam doesn't list it, UHID promotion is the suspect \
|
||||
and a usbip transport is the follow-up)"
|
||||
);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn neutral(&self) -> TritonState {
|
||||
TritonState::neutral()
|
||||
}
|
||||
|
||||
/// Typed fallback merge. Once raw reports flow (`raw_len > 0`) the frame only refreshes the
|
||||
/// typed fields for diagnostics — `write_state` keeps mirroring the raw report.
|
||||
fn merge_frame(
|
||||
&self,
|
||||
prev: &TritonState,
|
||||
f: &crate::gamestream::gamepad::GamepadFrame,
|
||||
) -> TritonState {
|
||||
let mut s = TritonState::from_gamepad(
|
||||
f.buttons,
|
||||
f.ls_x,
|
||||
f.ls_y,
|
||||
f.rs_x,
|
||||
f.rs_y,
|
||||
f.left_trigger,
|
||||
f.right_trigger,
|
||||
);
|
||||
// As-is mode is sticky: a typed frame between two raw reports must not flap the pad back
|
||||
// to synth mode (the client sends BOTH planes — typed keeps the degrade paths alive).
|
||||
s.raw = prev.raw;
|
||||
s.raw_len = prev.raw_len;
|
||||
s
|
||||
}
|
||||
|
||||
fn apply_rich(&self, st: &mut TritonState, rich: RichInput) {
|
||||
if let RichInput::HidReport { len, data, .. } = rich {
|
||||
let len = (len as usize).min(data.len()).min(st.raw.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
st.raw[..len].copy_from_slice(&data[..len]);
|
||||
st.raw_len = len as u8;
|
||||
}
|
||||
// Touchpad/Motion/TouchpadEx: nothing to fold — the raw feed carries pads + IMU natively,
|
||||
// and the synth fallback has no surface for them.
|
||||
}
|
||||
|
||||
fn write_state(&self, pad: &mut TritonPad, st: &TritonState) {
|
||||
let _ = pad.write_state(st);
|
||||
}
|
||||
|
||||
/// Ack + queue Steam's writes, then hand them to the pump as raw 0xCD events; rumble ALSO
|
||||
/// rides the universal 0xCA plane (deduped) so the client's phone-mirror path keeps working.
|
||||
fn service(&self, pad: &mut TritonPad, idx: u8) -> PadFeedback {
|
||||
let rumble = pad.service();
|
||||
let hidout = std::mem::take(&mut pad.pending_raw)
|
||||
.into_iter()
|
||||
.map(|(kind, data)| HidOutput::HidRaw {
|
||||
pad: idx,
|
||||
kind,
|
||||
data,
|
||||
})
|
||||
.collect();
|
||||
PadFeedback { rumble, hidout }
|
||||
}
|
||||
}
|
||||
|
||||
/// All virtual Steam Controller 2 pads of a session — `PUNKTFUNK_GAMEPAD=steamcontroller2`
|
||||
/// (aliases `sc2`/`ibex`), or the per-pad kind an Android client declares for a captured
|
||||
/// physical pad.
|
||||
pub type Triton2Manager = UhidManager<TritonProto>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// On-box smoke: the virtual SC2 must create a hidraw node under `hid-generic` (no evdev —
|
||||
/// nothing binds the PID) carrying the Valve identity, mirror a raw state report verbatim,
|
||||
/// and tear down on drop. `#[ignore]`d in CI (touches `/dev/uhid`); run on a Linux box:
|
||||
/// `cargo test -p punktfunk-host -- --ignored triton`.
|
||||
#[test]
|
||||
#[ignore = "creates a real /dev/uhid device; needs the input group"]
|
||||
fn triton_backend_creates_hidraw_and_mirrors_raw() {
|
||||
let mut pad = TritonPad::open(0).expect("open TritonPad (/dev/uhid + input group?)");
|
||||
// Mirror one raw report (as the client would forward it).
|
||||
let mut st = TritonState::neutral();
|
||||
let raw: &[u8] = &[0x42, 1, 0x01, 0, 0, 0, 0xFF, 0x7F]; // A held, LT full — truncated is fine
|
||||
st.raw[..raw.len()].copy_from_slice(raw);
|
||||
st.raw_len = raw.len() as u8;
|
||||
for _ in 0..50 {
|
||||
let _ = pad.service();
|
||||
pad.write_state(&st).expect("write_state");
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
}
|
||||
// The device exists with the Valve identity (hidraw only; /proc/bus/input has no entry).
|
||||
let found = std::fs::read_dir("/sys/bus/hid/devices")
|
||||
.map(|d| {
|
||||
d.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().contains(":28DE:1302"))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
assert!(found, "virtual 28DE:1302 HID device not created");
|
||||
drop(pad);
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,8 @@ impl DsState {
|
||||
};
|
||||
self.touch_click[slot] = click;
|
||||
}
|
||||
// Raw as-is passthrough reports belong to the Triton backend, never a DS state.
|
||||
RichInput::HidReport { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +585,11 @@ impl HidoutDedup {
|
||||
}
|
||||
// One-shot haptic pulse (Steam voice-coil) — state-less, always fires.
|
||||
HidOutput::TrackpadHaptic { .. } => true,
|
||||
// Raw as-is passthrough reports must NEVER dedup: the physical device's firmware
|
||||
// watchdogs RELY on identical periodic refreshes (Triton rumble re-sent every ~40 ms
|
||||
// against a ~50 ms safety timeout, lizard-off every ~3 s) — dropping a repeat would
|
||||
// silence the motors / re-enable lizard mode on the real controller.
|
||||
HidOutput::HidRaw { .. } => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,8 @@ impl SteamState {
|
||||
self.rpad_y = flip_y(y);
|
||||
}
|
||||
}
|
||||
// Raw as-is passthrough reports belong to the Triton backend, never a Deck/SC state.
|
||||
RichInput::HidReport { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Transport-independent contract for the virtual **Steam Controller 2** (2026, Valve "Ibex" /
|
||||
//! SDL "Triton", wired `28DE:1302`) — the as-is passthrough sibling of [`super::steam_proto`].
|
||||
//!
|
||||
//! Unlike the Deck/classic-SC backends, this device is NOT re-synthesized from typed wire state:
|
||||
//! the client captures the physical controller (USB Puck / wired / BLE) and forwards its raw
|
||||
//! input reports verbatim ([`RichInput::HidReport`](punktfunk_core::quic::RichInput)); the host
|
||||
//! mirrors them out unchanged, and everything the host's hidraw consumer writes back (Steam's
|
||||
//! lizard-off / IMU-enable feature reports, `0x80` rumble output reports) is forwarded raw to the
|
||||
//! client for replay on the real controller ([`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)).
|
||||
//! Mainline `hid-steam` does not bind this PID, so no kernel evdev exists — **Steam Input is the
|
||||
//! consumer**, driving the hidraw node exactly as it drives the physical pad.
|
||||
//!
|
||||
//! Protocol ground truth: SDL's `SDL_hidapi_steam_triton.c` + `steam/controller_structs.h`
|
||||
//! (Valve-maintained). Input report ids `0x42`/`0x45` (`TritonMTUNoQuat_t`, 46 bytes with id),
|
||||
//! `0x47` (adds a trackpad timestamp), `0x43` battery, `0x46`/`0x79` wireless status. Feature
|
||||
//! reports are 64 bytes on report id `1`; haptics are OUTPUT reports `0x80..=0x85`.
|
||||
//!
|
||||
//! A typed **fallback synthesizer** is kept for the degraded case (a client that declared the
|
||||
//! kind but sends no raw feed): buttons/sticks/triggers from the ordinary gamepad plane are
|
||||
//! serialized into a minimal `0x42` state report. The first raw report permanently switches the
|
||||
//! pad to as-is mode.
|
||||
|
||||
use punktfunk_core::input::gamepad as gs;
|
||||
|
||||
/// Valve vendor id (same as [`super::steam_proto::STEAM_VENDOR`], repeated to keep this module
|
||||
/// self-contained).
|
||||
pub const TRITON_VENDOR: u32 = 0x28DE;
|
||||
/// The wired Steam Controller 2 identity the virtual pad presents. The BLE (`0x1303`) and Puck
|
||||
/// dongle (`0x1304`/`0x1305`) identities are client-side transports only — Steam treats the wired
|
||||
/// PID as the canonical controller.
|
||||
pub const TRITON_WIRED_PRODUCT: u32 = 0x1302;
|
||||
|
||||
/// Triton input-report ids (`ETritonReportIDTypes`, SDL `controller_structs.h`).
|
||||
pub const ID_TRITON_CONTROLLER_STATE: u8 = 0x42;
|
||||
pub const ID_TRITON_BATTERY_STATUS: u8 = 0x43;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_BLE: u8 = 0x45;
|
||||
pub const ID_TRITON_CONTROLLER_STATE_TIMESTAMP: u8 = 0x47;
|
||||
|
||||
/// Haptic OUTPUT report ids (`ID_OUT_REPORT_*`). Only rumble is parsed host-side (for the
|
||||
/// universal 0xCA plane); every output report is forwarded raw regardless.
|
||||
pub const ID_OUT_REPORT_HAPTIC_RUMBLE: u8 = 0x80;
|
||||
|
||||
/// Fixed report size: 64-byte feature reports, input reports at most 64 (state is 46/54).
|
||||
pub const TRITON_REPORT_LEN: usize = 64;
|
||||
|
||||
/// The `TritonMTUNoQuat_t` state payload (46 bytes with the leading report id).
|
||||
pub const TRITON_STATE_LEN: usize = 46;
|
||||
|
||||
/// Minimal vendor-defined HID report descriptor, mirroring [`super::steam_proto::STEAMDECK_RDESC`]
|
||||
/// with an added OUTPUT item: the Triton receives haptics as output reports (`SDL_hid_write`),
|
||||
/// not feature-only like the Deck, so hidapi consumers expect a writable interrupt-OUT-style
|
||||
/// report to exist. All items unnumbered 64-byte — the raw bytes we mirror carry the Valve
|
||||
/// report-type byte first, exactly like the physical device's stream.
|
||||
#[rustfmt::skip]
|
||||
pub const TRITON_RDESC: &[u8] = &[
|
||||
0x06, 0x00, 0xFF, // Usage Page (Vendor-Defined 0xFF00)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x00, // Logical Maximum (255)
|
||||
0x75, 0x08, // Report Size (8 bits)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) — the state/battery/wireless report stream
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0x91, 0x02, // Output (Data,Var,Abs) — haptic commands (0x80 rumble, 0x81 pulse, …)
|
||||
0x09, 0x01, // Usage (0x01)
|
||||
0x95, 0x40, // Report Count (64)
|
||||
0xB1, 0x02, // Feature (Data,Var,Abs) — settings/attributes (report id 1 on the wire)
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Triton button bits in the state report's `buttons` u32 — transcribed verbatim from SDL's
|
||||
/// `TritonButtons`. Only the bits the typed fallback synthesizes are named; the raw path carries
|
||||
/// whatever the physical pad set.
|
||||
pub mod tbtn {
|
||||
pub const A: u32 = 0x0000_0001;
|
||||
pub const B: u32 = 0x0000_0002;
|
||||
pub const X: u32 = 0x0000_0004;
|
||||
pub const Y: u32 = 0x0000_0008;
|
||||
pub const QAM: u32 = 0x0000_0010;
|
||||
pub const R3: u32 = 0x0000_0020;
|
||||
pub const VIEW: u32 = 0x0000_0040;
|
||||
pub const R4: u32 = 0x0000_0080;
|
||||
pub const R5: u32 = 0x0000_0100;
|
||||
pub const RB: u32 = 0x0000_0200;
|
||||
pub const DPAD_DOWN: u32 = 0x0000_0400;
|
||||
pub const DPAD_RIGHT: u32 = 0x0000_0800;
|
||||
pub const DPAD_LEFT: u32 = 0x0000_1000;
|
||||
pub const DPAD_UP: u32 = 0x0000_2000;
|
||||
pub const MENU: u32 = 0x0000_4000;
|
||||
pub const L3: u32 = 0x0000_8000;
|
||||
pub const STEAM: u32 = 0x0001_0000;
|
||||
pub const L4: u32 = 0x0002_0000;
|
||||
pub const L5: u32 = 0x0004_0000;
|
||||
pub const LB: u32 = 0x0008_0000;
|
||||
pub const RPAD_TOUCH: u32 = 0x0020_0000;
|
||||
pub const RPAD_CLICK: u32 = 0x0040_0000;
|
||||
pub const RT_CLICK: u32 = 0x0080_0000;
|
||||
pub const LPAD_TOUCH: u32 = 0x0200_0000;
|
||||
pub const LPAD_CLICK: u32 = 0x0400_0000;
|
||||
pub const LT_CLICK: u32 = 0x0800_0000;
|
||||
}
|
||||
|
||||
/// One virtual Triton pad's report state. In as-is mode (`raw_len > 0`) the raw report IS the
|
||||
/// state; the typed fields only feed the fallback synthesizer until the first raw report lands.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TritonState {
|
||||
/// The last raw report the client forwarded (report-id byte first); `raw_len == 0` until the
|
||||
/// first one arrives, after which the typed fields below stop mattering.
|
||||
pub raw: [u8; TRITON_REPORT_LEN],
|
||||
pub raw_len: u8,
|
||||
/// Typed fallback fields (Triton bit layout / raw axis units), from the ordinary wire plane.
|
||||
pub buttons: u32,
|
||||
pub lt: u16,
|
||||
pub rt: u16,
|
||||
pub lx: i16,
|
||||
pub ly: i16,
|
||||
pub rx: i16,
|
||||
pub ry: i16,
|
||||
}
|
||||
|
||||
impl TritonState {
|
||||
pub fn neutral() -> TritonState {
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: 0,
|
||||
lt: 0,
|
||||
rt: 0,
|
||||
lx: 0,
|
||||
ly: 0,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed fallback: fold one wire button/stick frame into Triton fields. Mapping follows the
|
||||
/// Deck backend's conventions (PADDLE1/2/3/4 = R4/L4/R5/L5, MISC1 = QAM, the DualSense
|
||||
/// touchpad-click wire bit = right-pad click); sticks are already the device convention
|
||||
/// (+y up), triggers scale 0..255 → 0..32767.
|
||||
pub fn from_gamepad(
|
||||
buttons: u32,
|
||||
lx: i16,
|
||||
ly: i16,
|
||||
rx: i16,
|
||||
ry: i16,
|
||||
lt: u8,
|
||||
rt: u8,
|
||||
) -> TritonState {
|
||||
let on = |bit: u32| buttons & bit != 0;
|
||||
let trig = |v: u8| ((v as u32 * 32767) / 255) as u16;
|
||||
let mut b = 0u32;
|
||||
let set = |b: &mut u32, on: bool, m: u32| {
|
||||
if on {
|
||||
*b |= m;
|
||||
}
|
||||
};
|
||||
set(&mut b, on(gs::BTN_A), tbtn::A);
|
||||
set(&mut b, on(gs::BTN_B), tbtn::B);
|
||||
set(&mut b, on(gs::BTN_X), tbtn::X);
|
||||
set(&mut b, on(gs::BTN_Y), tbtn::Y);
|
||||
set(&mut b, on(gs::BTN_LB), tbtn::LB);
|
||||
set(&mut b, on(gs::BTN_RB), tbtn::RB);
|
||||
set(&mut b, on(gs::BTN_BACK), tbtn::VIEW);
|
||||
set(&mut b, on(gs::BTN_START), tbtn::MENU);
|
||||
set(&mut b, on(gs::BTN_GUIDE), tbtn::STEAM);
|
||||
set(&mut b, on(gs::BTN_LS_CLICK), tbtn::L3);
|
||||
set(&mut b, on(gs::BTN_RS_CLICK), tbtn::R3);
|
||||
set(&mut b, on(gs::BTN_DPAD_UP), tbtn::DPAD_UP);
|
||||
set(&mut b, on(gs::BTN_DPAD_DOWN), tbtn::DPAD_DOWN);
|
||||
set(&mut b, on(gs::BTN_DPAD_LEFT), tbtn::DPAD_LEFT);
|
||||
set(&mut b, on(gs::BTN_DPAD_RIGHT), tbtn::DPAD_RIGHT);
|
||||
set(&mut b, on(gs::BTN_TOUCHPAD), tbtn::RPAD_CLICK);
|
||||
set(&mut b, on(gs::BTN_PADDLE1), tbtn::R4);
|
||||
set(&mut b, on(gs::BTN_PADDLE2), tbtn::L4);
|
||||
set(&mut b, on(gs::BTN_PADDLE3), tbtn::R5);
|
||||
set(&mut b, on(gs::BTN_PADDLE4), tbtn::L5);
|
||||
set(&mut b, on(gs::BTN_MISC1), tbtn::QAM);
|
||||
// "Fully pressed" digital shadow of the analog triggers (the physical pad's own
|
||||
// threshold is a hard pull, not first-contact).
|
||||
set(&mut b, lt >= 240, tbtn::LT_CLICK);
|
||||
set(&mut b, rt >= 240, tbtn::RT_CLICK);
|
||||
TritonState {
|
||||
raw: [0u8; TRITON_REPORT_LEN],
|
||||
raw_len: 0,
|
||||
buttons: b,
|
||||
lt: trig(lt),
|
||||
rt: trig(rt),
|
||||
lx,
|
||||
ly,
|
||||
rx,
|
||||
ry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the typed fallback state into a minimal `0x42` `TritonMTUNoQuat_t` report:
|
||||
/// `[0x42][seq u8][buttons u32][trigL i16][trigR i16][sticks i16×4][lpad x/y + pressure]
|
||||
/// [rpad x/y + pressure][imu ts u32 + accel i16×3 + gyro i16×3]` — pads and IMU stay zero
|
||||
/// (no raw feed = no trackpad/motion source; Steam only sees IMU data after enabling
|
||||
/// `SETTING_IMU_MODE` on a real feed anyway).
|
||||
pub fn serialize_triton_state(buf: &mut [u8; TRITON_STATE_LEN], st: &TritonState, seq: u8) {
|
||||
buf.fill(0);
|
||||
buf[0] = ID_TRITON_CONTROLLER_STATE;
|
||||
buf[1] = seq;
|
||||
buf[2..6].copy_from_slice(&st.buttons.to_le_bytes());
|
||||
buf[6..8].copy_from_slice(&(st.lt as i16).to_le_bytes());
|
||||
buf[8..10].copy_from_slice(&(st.rt as i16).to_le_bytes());
|
||||
buf[10..12].copy_from_slice(&st.lx.to_le_bytes());
|
||||
buf[12..14].copy_from_slice(&st.ly.to_le_bytes());
|
||||
buf[14..16].copy_from_slice(&st.rx.to_le_bytes());
|
||||
buf[16..18].copy_from_slice(&st.ry.to_le_bytes());
|
||||
// [18..30] left/right pad + pressures stay zero; [30..46] IMU stays zero.
|
||||
}
|
||||
|
||||
/// One service pass's extracted feedback: the raw reports to forward (kind-tagged for
|
||||
/// [`HidOutput::HidRaw`](punktfunk_core::quic::HidOutput)) plus the rumble level parsed out of a
|
||||
/// `0x80` report for the universal 0xCA plane (drives the phone-mirror path on clients whose
|
||||
/// physical pad already gets the raw report).
|
||||
#[derive(Default)]
|
||||
pub struct TritonFeedback {
|
||||
/// `(low, high)` — `left.speed`/`right.speed` of the last rumble output report seen.
|
||||
pub rumble: Option<(u16, u16)>,
|
||||
/// Raw reports to forward: `(kind, bytes)` with kind = `HID_RAW_OUTPUT`/`HID_RAW_FEATURE`.
|
||||
pub raw: Vec<(u8, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Parse a Triton haptic-rumble OUTPUT report (`MsgHapticRumble`, 10 bytes with id):
|
||||
/// `[0x80][type u8][intensity u16][left.speed u16][left.gain i8][right.speed u16][right.gain i8]`.
|
||||
/// Returns `(left_speed, right_speed)` as `(low, high)`.
|
||||
pub fn parse_triton_rumble(data: &[u8]) -> Option<(u16, u16)> {
|
||||
if data.len() < 10 || data[0] != ID_OUT_REPORT_HAPTIC_RUMBLE {
|
||||
return None;
|
||||
}
|
||||
let le = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
|
||||
Some((le(4), le(7)))
|
||||
}
|
||||
|
||||
/// Strip the hidraw unnumbered-report `0x00` prefix if present: Triton report/command ids are all
|
||||
/// non-zero (`0x42+` input, `0x80+` output, `1` feature), so a leading zero can only be the
|
||||
/// synthetic report-id byte hidraw prepends on this unnumbered virtual descriptor.
|
||||
pub fn strip_report_prefix(data: &[u8]) -> &[u8] {
|
||||
match data {
|
||||
[0, rest @ ..] if !rest.is_empty() => rest,
|
||||
d => d,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The typed fallback lands the canonical wire mapping on the SDL-documented bit positions
|
||||
/// and byte offsets.
|
||||
#[test]
|
||||
fn fallback_state_serializes_sdl_layout() {
|
||||
let st = TritonState::from_gamepad(
|
||||
gs::BTN_A | gs::BTN_START | gs::BTN_PADDLE1 | gs::BTN_MISC1,
|
||||
1000,
|
||||
-2000,
|
||||
3000,
|
||||
-32768,
|
||||
255,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
st.buttons,
|
||||
tbtn::A | tbtn::MENU | tbtn::R4 | tbtn::QAM | tbtn::LT_CLICK
|
||||
);
|
||||
assert_eq!(st.lt, 32767); // exact full-scale, not the *128 approximation
|
||||
let mut r = [0u8; TRITON_STATE_LEN];
|
||||
serialize_triton_state(&mut r, &st, 7);
|
||||
assert_eq!(r[0], ID_TRITON_CONTROLLER_STATE);
|
||||
assert_eq!(r[1], 7);
|
||||
assert_eq!(u32::from_le_bytes([r[2], r[3], r[4], r[5]]), st.buttons);
|
||||
assert_eq!(i16::from_le_bytes([r[6], r[7]]), 32767); // sTriggerLeft
|
||||
assert_eq!(i16::from_le_bytes([r[10], r[11]]), 1000); // sLeftStickX
|
||||
assert_eq!(i16::from_le_bytes([r[16], r[17]]), -32768); // sRightStickY
|
||||
assert!(r[18..].iter().all(|&b| b == 0)); // pads + IMU zero
|
||||
}
|
||||
|
||||
/// A rumble output report parses to `(left_speed, right_speed)`; other ids don't.
|
||||
#[test]
|
||||
fn rumble_output_report_parses() {
|
||||
// [0x80, type, intensity(2), left.speed(2), left.gain, right.speed(2), right.gain]
|
||||
let mut d = [0u8; 10];
|
||||
d[0] = ID_OUT_REPORT_HAPTIC_RUMBLE;
|
||||
d[4..6].copy_from_slice(&0x1234u16.to_le_bytes());
|
||||
d[7..9].copy_from_slice(&0x5678u16.to_le_bytes());
|
||||
assert_eq!(parse_triton_rumble(&d), Some((0x1234, 0x5678)));
|
||||
d[0] = 0x81; // haptic pulse — not rumble
|
||||
assert_eq!(parse_triton_rumble(&d), None);
|
||||
assert_eq!(parse_triton_rumble(&d[..8]), None); // short
|
||||
}
|
||||
|
||||
/// The hidraw `0x00` unnumbered prefix strips; genuine command bytes survive.
|
||||
#[test]
|
||||
fn report_prefix_strips_only_leading_zero() {
|
||||
assert_eq!(strip_report_prefix(&[0x00, 0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x80, 1, 2]), &[0x80, 1, 2]);
|
||||
assert_eq!(strip_report_prefix(&[0x01, 0x87]), &[0x01, 0x87]); // feature id 1 kept
|
||||
assert_eq!(strip_report_prefix(&[0x00]), &[0x00]); // lone zero: nothing to strip to
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,8 @@ impl<B: PadProto> UhidManager<B> {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
| RichInput::TouchpadEx { pad, .. }
|
||||
| RichInput::HidReport { pad, .. } => pad as usize,
|
||||
};
|
||||
if idx >= MAX_PADS || self.slots.get(idx).is_none() {
|
||||
return;
|
||||
|
||||
@@ -1869,6 +1869,8 @@ struct Pads {
|
||||
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: Option<crate::inject::steam_controller2::Triton2Manager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1906,6 +1908,8 @@ impl Pads {
|
||||
switchpro: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamctrl2: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1989,6 +1993,11 @@ impl Pads {
|
||||
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => self
|
||||
.steamctrl2
|
||||
.get_or_insert_with(crate::inject::steam_controller2::Triton2Manager::new)
|
||||
.handle(ev),
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::XboxOne => self
|
||||
.xboxone
|
||||
.get_or_insert_with(|| {
|
||||
@@ -2036,7 +2045,8 @@ impl Pads {
|
||||
let idx = match rich {
|
||||
RichInput::Touchpad { pad, .. }
|
||||
| RichInput::Motion { pad, .. }
|
||||
| RichInput::TouchpadEx { pad, .. } => pad as usize,
|
||||
| RichInput::TouchpadEx { pad, .. }
|
||||
| RichInput::HidReport { pad, .. } => pad as usize,
|
||||
};
|
||||
// Route to the manager that actually owns the device (falling back to the declared kind
|
||||
// before the first frame builds it), so a pad's touchpad/motion never lands on the wrong
|
||||
@@ -2085,6 +2095,12 @@ impl Pads {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GamepadPref::SteamController2 => {
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
@@ -2148,6 +2164,9 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.pump(&mut rumble, &mut hidout);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -2192,6 +2211,9 @@ impl Pads {
|
||||
if let Some(m) = &mut self.steamctrl {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
if let Some(m) = &mut self.steamctrl2 {
|
||||
m.heartbeat(gap);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -2903,6 +2925,11 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
|
||||
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
|
||||
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
|
||||
// New Steam Controller (2026, `28DE:1302`): passed through as-is on Linux — the Triton
|
||||
// UHID backend mirrors the client's raw reports under the real identity and Steam on
|
||||
// the host drives it over hidraw (no kernel driver binds the PID; Steam Input is the
|
||||
// consumer). No Windows backend; folds to Xbox360 there.
|
||||
GamepadPref::SteamController2 if linux => GamepadPref::SteamController2,
|
||||
_ => GamepadPref::Xbox360,
|
||||
}
|
||||
}
|
||||
@@ -2920,6 +2947,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
|
||||
| GamepadPref::DualShock4
|
||||
| GamepadPref::SteamDeck
|
||||
| GamepadPref::SteamController
|
||||
| GamepadPref::SteamController2
|
||||
| GamepadPref::SwitchPro
|
||||
);
|
||||
if needs_uhid
|
||||
@@ -2985,7 +3013,7 @@ fn physical_steam_controller_present() -> bool {
|
||||
fn degrade_steam_on_conflict(chosen: GamepadPref) -> GamepadPref {
|
||||
if !matches!(
|
||||
chosen,
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController
|
||||
GamepadPref::SteamDeck | GamepadPref::SteamController | GamepadPref::SteamController2
|
||||
) {
|
||||
return chosen;
|
||||
}
|
||||
@@ -5622,6 +5650,22 @@ mod tests {
|
||||
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
|
||||
// New Steam Controller (as-is Triton passthrough): native on Linux (UHID, Steam-driven);
|
||||
// Xbox360 on Windows and elsewhere.
|
||||
assert_eq!(
|
||||
pick_gamepad(SteamController2, None, true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("sc2"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(
|
||||
pick_gamepad(Auto, Some("ibex"), true, false),
|
||||
SteamController2
|
||||
);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, true), Xbox360);
|
||||
assert_eq!(pick_gamepad(SteamController2, None, false, false), Xbox360);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user