feat(gamepad): classic Steam Controller backend — Linux UHID via hid-steam (N3)

The reserved GamepadPref::SteamController = 5 slot goes live: the same
hid-steam driver under the wired-SC identity (28DE:1102,
ID_CONTROLLER_STATE), UHID-only in v1 (no captured SC USB interface
layout, so no Steam-Input promotion — the pre-usbip Deck state;
acceptable for discontinued hardware).

Layout pinned against the kernel's ID_CONTROLLER_STATE table: 24-bit
buttons at 8..11 (low bits shared with the Deck; grips at 9.7/10.0 =
the Deck's L5/R5 positions; right-pad click 10.2; joystick click 10.6),
u8 triggers at 11/12, the joystick/left-pad MULTIPLEX at 16..20 (a
left-pad contact shadows the stick, like real hardware's lpad_touched
flag), right pad at 20..24. Mapping: wire left stick -> SC stick; wire
right stick -> right-pad coords + touched bit (the SC's camera surface —
the second-stick loss is inherent); PADDLE1/2 -> the two grips (natively,
masked out of the fold input); PADDLE3/4 + MISC1 -> the remap policy.
The SC parser has NO gamepad_mode gate, so no mode-entry pulse.

SteamDeckPad grew a SteamModel (open_model); ScProto/SteamCtrlManager;
pick_gamepad flips SteamController -> itself on Linux (replacing the
Xbox360 fold); SDL picker splits Valve PIDs (Deck 1205 stays SteamDeck,
SC 1102/1142 now declare SteamController).

Verified: .21 clippy -D warnings + 304/0 tests + on-box UHID smoke
(hid-steam binds 1102, BTN_A + right-pad ABS_RX land on evdev, no mode
pulse); .133 clippy -D warnings green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:15:54 +02:00
parent 70a74b0d7c
commit 8c854e0a19
6 changed files with 417 additions and 25 deletions
+7 -3
View File
@@ -268,6 +268,7 @@ impl PadInfo {
GamepadPref::DualShock4 => "DualShock 4",
GamepadPref::XboxOne => "Xbox One",
GamepadPref::SteamDeck => "Steam Deck",
GamepadPref::SteamController => "Steam Controller",
GamepadPref::SwitchPro => "Switch Pro",
_ => "",
}
@@ -783,11 +784,14 @@ impl Worker {
self.subsystem.product_for_id(jid).unwrap_or(0),
);
// There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by
// VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual
// hid-steam pad with the back grips + dual trackpads and the right glyph identity.
if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
// VID/PID — the host then builds the matching virtual hid-steam pad (grips + trackpads +
// the right glyph identity): Deck 0x1205; classic SC wired 0x1102 / dongle 0x1142.
if vid == 0x28DE && pid == 0x1205 {
pref = GamepadPref::SteamDeck;
}
if vid == 0x28DE && matches!(pid, 0x1102 | 0x1142) {
pref = GamepadPref::SteamController;
}
// The DualSense Edge has no distinct SDL gamepad type either (it reports PS5) — detect by
// VID/PID so the host builds the virtual Edge and this pad's back paddles land on native
// slots instead of the fold/drop policy.
+2 -2
View File
@@ -882,8 +882,8 @@ pub const PUNKTFUNK_GAMEPAD_XBOXONE: u32 = 3;
/// DualSense (minus adaptive triggers / player LEDs / mute). Honored only where available (Linux
/// hosts); otherwise the host falls back to X-Box 360.
pub const PUNKTFUNK_GAMEPAD_DUALSHOCK4: u32 = 4;
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): dual trackpads, gyro,
/// two grip paddles. Reserved — currently folds to `XBOX360` until its backend lands.
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`): one stick + dual
/// trackpads + two grip paddles. Honored only where available (Linux hosts); else Xbox 360.
pub const PUNKTFUNK_GAMEPAD_STEAMCONTROLLER: u32 = 5;
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`): full Deck gamepad incl. the
/// four back grips, a right trackpad, and the IMU; re-grabbed by Steam Input with native glyphs when
+3 -3
View File
@@ -156,9 +156,9 @@ pub enum GamepadPref {
/// UHID DualShock 4 (kernel `hid-playstation`, ≥ 6.2) — lightbar, touchpad, motion, rumble. Like
/// `DualSense` minus adaptive triggers / player LEDs / mute. Needs Linux UHID on the host.
DualShock4,
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — dual trackpads, gyro,
/// two grip paddles, trackpad-only haptics. Needs Linux UHID. *(Reserved; its backend is not yet
/// built — currently folds to `Xbox360`; the Deck identity below is the implemented one.)*
/// UHID classic Steam Controller (Valve `28DE:1102`, kernel `hid-steam`) — one stick + dual
/// trackpads + two grip paddles. The wire right stick drives the right pad; a left-pad contact
/// shadows the stick (hardware multiplex). Needs Linux UHID.
SteamController,
/// UHID Steam Deck controller (Valve `28DE:1205`, kernel `hid-steam`) — full Deck gamepad incl.
/// the four back grips (L4/L5/R4/R5), a right trackpad, and the IMU; re-grabbed by Steam Input
@@ -20,8 +20,8 @@
//! command (the DualSense backend only services GET_REPORT + OUTPUT).
use super::steam_proto::{
btn, parse_steam_output, serial_reply, serialize_deck_state, SteamState, STEAMDECK_PRODUCT,
STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
btn, parse_steam_output, sc_from_gamepad, serial_reply, serialize_deck_state,
serialize_sc_state, SteamModel, SteamState, STEAMDECK_RDESC, STEAM_REPORT_LEN, STEAM_VENDOR,
};
use crate::inject::uhid_manager::{PadFeedback, PadProto, UhidManager};
use anyhow::{Context, Result};
@@ -77,10 +77,12 @@ fn try_clear_lizard_mode() {
}
}
/// A virtual Steam Deck backed by `/dev/uhid`. Dropping it destroys the device (the kernel tears
/// down the bound `hid-steam` interface + both evdevs).
/// A virtual Steam Deck **or classic Steam Controller** backed by `/dev/uhid` (same driver, two
/// identities/report layouts — see [`SteamModel`]). Dropping it destroys the device (the kernel
/// tears down the bound `hid-steam` interface + its evdevs).
pub struct SteamDeckPad {
fd: File,
model: SteamModel,
seq: u32,
created: Instant,
/// When `b9.6` started being continuously held in our OUTPUT (anti-toggle guard); `None` = not.
@@ -89,7 +91,16 @@ pub struct SteamDeckPad {
impl SteamDeckPad {
pub fn open(index: u8) -> Result<SteamDeckPad> {
SteamDeckPad::open_model(index, SteamModel::Deck)
}
/// Open under a specific Steam identity. The classic Controller's `ID_CONTROLLER_STATE` path
/// has NO `gamepad_mode` gate in the kernel (only the Deck's parser early-returns under
/// lizard mode), so the SC skips the whole mode-entry machinery.
pub fn open_model(index: u8, model: SteamModel) -> Result<SteamDeckPad> {
if model == SteamModel::Deck {
try_clear_lizard_mode();
}
let fd = OpenOptions::new()
.read(true)
.write(true)
@@ -100,24 +111,29 @@ impl SteamDeckPad {
})?;
let mut pad = SteamDeckPad {
fd,
model,
seq: 0,
created: Instant::now(),
menu_hold_since: None,
};
pad.send_create2(index).context("UHID_CREATE2 Steam Deck")?;
pad.send_create2(index).context("UHID_CREATE2 Steam pad")?;
Ok(pad)
}
fn send_create2(&mut self, index: u8) -> Result<()> {
let (name, phys, uniq) = match self.model {
SteamModel::Deck => ("Steam Deck", "steam", "steam"),
SteamModel::Controller => ("Steam Controller", "steamctrl", "steamctrl"),
};
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_CREATE2.to_ne_bytes());
put_cstr(&mut ev, 4, 128, &format!("Punktfunk Steam Deck {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/steam/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-steam-{index}")); // uniq[64]
put_cstr(&mut ev, 4, 128, &format!("Punktfunk {name} {index}")); // name[128]
put_cstr(&mut ev, 132, 64, &format!("punktfunk/{phys}/{index}")); // phys[64]
put_cstr(&mut ev, 196, 64, &format!("punktfunk-{uniq}-{index}")); // uniq[64]
ev[260..262].copy_from_slice(&(STEAMDECK_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(&STEAM_VENDOR.to_ne_bytes());
ev[268..272].copy_from_slice(&STEAMDECK_PRODUCT.to_ne_bytes());
ev[268..272].copy_from_slice(&self.model.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 + STEAMDECK_RDESC.len()].copy_from_slice(STEAMDECK_RDESC);
@@ -125,13 +141,19 @@ impl SteamDeckPad {
Ok(())
}
/// Serialize `st` (with the gamepad-mode entry overlay + anti-toggle guard applied) and write it.
/// Serialize `st` under this pad's model (Deck reports get the gamepad-mode entry overlay +
/// anti-toggle guard applied) and write it.
pub fn write_state(&mut self, st: &SteamState) -> Result<()> {
self.seq = self.seq.wrapping_add(1);
let mut r = [0u8; STEAM_REPORT_LEN];
match self.model {
SteamModel::Deck => {
let mut s = *st;
s.buttons = self.effective_buttons(st.buttons);
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_deck_state(&mut r, &s, self.seq);
}
SteamModel::Controller => serialize_sc_state(&mut r, st, self.seq),
}
let mut ev = [0u8; UHID_EVENT_SIZE];
ev[0..4].copy_from_slice(&UHID_INPUT2.to_ne_bytes());
@@ -142,8 +164,9 @@ impl SteamDeckPad {
}
/// True while still pulsing the mode-switch at creation (the caller force-writes during this).
/// Deck-only — the SC's kernel parser has no mode gate.
fn in_mode_entry(&self) -> bool {
self.created.elapsed() < MODE_ENTER
self.model == SteamModel::Deck && self.created.elapsed() < MODE_ENTER
}
/// During mode entry, force `b9.6` held (override). Afterwards, pass the real buttons through but
@@ -438,6 +461,113 @@ impl PadProto for SteamProto {
/// the pad alive (and drives the mode-entry pulse) — all from the shared [`UhidManager`].
pub type SteamControllerManager = UhidManager<SteamProto>;
/// The **classic Steam Controller** half of the shared stateful manager: the same `hid-steam`
/// driver under the wired-SC identity (`28DE:1102`, `ID_CONTROLLER_STATE`), UHID-only in v1 —
/// the usbip/gadget transports present the Deck's captured 3-interface USB device, and the SC's
/// wired interface layout hasn't been captured, so there is no Steam-Input promotion (the same
/// degraded-but-working state the Deck had pre-usbip; acceptable for discontinued hardware).
///
/// Deltas vs the Deck (see [`sc_from_gamepad`]/[`serialize_sc_state`]): one stick + two pads +
/// two grips — the wire right stick drives the right pad, a left-pad contact shadows the left
/// stick, wire PADDLE1/2 land on the two grips (3/4 fold via the remap policy), and the kernel
/// registers neither FF rumble nor a sensors evdev for this model (feedback stays empty).
pub struct ScProto {
/// Fallback policy for the wire paddles beyond the SC's two grips (PADDLE3/4).
remap: crate::inject::steam_remap::RemapConfig,
}
impl Default for ScProto {
fn default() -> ScProto {
ScProto {
remap: crate::inject::steam_remap::RemapConfig::from_env(),
}
}
}
impl PadProto for ScProto {
type Pad = SteamDeckPad;
type State = SteamState;
const LABEL: &'static str = "Steam Controller";
const DEVICE: &'static str = "Steam Controller";
const CREATE_HINT: &'static str = "";
fn open(&mut self, idx: u8) -> Result<SteamDeckPad> {
let p = SteamDeckPad::open_model(idx, SteamModel::Controller)?;
tracing::info!(
index = idx,
"virtual Steam Controller created (UHID hid-steam)"
);
Ok(p)
}
fn neutral(&self) -> SteamState {
SteamState::neutral()
}
/// Merge buttons/sticks/triggers, preserving the rich-plane fields. PADDLE1/2 map natively to
/// the SC's two grips inside [`sc_from_gamepad`]; only 3/4 go through the fold policy — mask
/// the native pair out of the fold input so the policy can't double-fire them.
fn merge_frame(
&self,
prev: &SteamState,
f: &crate::gamestream::gamepad::GamepadFrame,
) -> SteamState {
use punktfunk_core::input::gamepad as gs;
let native = f.buttons & (gs::BTN_PADDLE1 | gs::BTN_PADDLE2);
let folded = crate::inject::steam_remap::fold_paddles(
f.buttons & !(gs::BTN_PADDLE1 | gs::BTN_PADDLE2),
self.remap.paddles,
);
let mut s = sc_from_gamepad(
folded | native,
f.ls_x,
f.ls_y,
f.rs_x,
f.rs_y,
f.left_trigger,
f.right_trigger,
);
s.lpad_x = prev.lpad_x;
s.lpad_y = prev.lpad_y;
s.gyro = prev.gyro;
s.accel = prev.accel;
s.buttons |= prev.buttons & btn::LPAD_TOUCH;
s.lpad_click = prev.lpad_click;
// The right pad carries the wire right stick each frame; a rich right-pad contact
// (TouchpadEx surface 2) overrides it only while the stick is centered — the stick is
// the primary camera surface on this mapping.
if f.rs_x == 0 && f.rs_y == 0 {
s.rpad_x = prev.rpad_x;
s.rpad_y = prev.rpad_y;
s.buttons |= prev.buttons & btn::RPAD_TOUCH;
s.rpad_click = prev.rpad_click;
}
s
}
fn apply_rich(&self, st: &mut SteamState, rich: RichInput) {
st.apply_rich(rich);
}
fn write_state(&self, pad: &mut SteamDeckPad, st: &SteamState) {
let _ = pad.write_state(st);
}
/// Answer the kernel handshake (serial GET_REPORT + settings SET_REPORTs). The kernel
/// registers no FF device for the classic SC, so rumble feedback can only arrive from a
/// hidraw client (`0xEB`) — surfaced if it ever does.
fn service(&self, pad: &mut SteamDeckPad, _idx: u8) -> PadFeedback {
PadFeedback {
rumble: pad.service(),
hidout: Vec::new(),
}
}
}
/// All virtual classic Steam Controllers of a session — `PUNKTFUNK_GAMEPAD=steamcontroller`, or
/// the per-pad kind a client declares for a physical SC.
pub type SteamCtrlManager = UhidManager<ScProto>;
#[cfg(test)]
mod tests {
use super::*;
@@ -547,4 +677,40 @@ mod tests {
"device not torn down on drop"
);
}
/// On-box smoke for the classic-SC identity: binds `hid-steam` as `28DE:1102`, input flows
/// with NO mode-entry pulse (the SC parser has no gamepad_mode gate), a held A + right-stick
/// deflection land on the evdev (BTN_A + ABS_RX — the right PAD surface), and a grip lands
/// on BTN_GRIPR (0x2c5? — kernel BTN_GRIPR = 0x2c5 on new kernels / check via bitmap).
#[test]
#[ignore = "creates a real /dev/uhid device; needs hid-steam + the input group"]
fn sc_backend_binds_and_input_flows() {
use punktfunk_core::input::gamepad as gs;
const BTN_A: u16 = 0x130;
const ABS_RX: u16 = 0x03;
let mut pad = SteamDeckPad::open_model(0, SteamModel::Controller)
.expect("open SC pad (/dev/uhid + input group?)");
let st = sc_from_gamepad(gs::BTN_A | gs::BTN_PADDLE1, 0, 0, 9000, 0, 0, 0);
let start = Instant::now();
while start.elapsed() < Duration::from_millis(900) {
let _ = pad.service();
pad.write_state(&st).expect("write_state");
std::thread::sleep(Duration::from_millis(4));
}
let devs = std::fs::read_to_string("/proc/bus/input/devices").unwrap_or_default();
assert!(
devs.contains("Steam Controller"),
"SC gamepad evdev not created"
);
let node = find_node("Steam Controller").expect("SC evdev node");
assert!(
key_is_down(&node, BTN_A),
"BTN_A not down — SC serialize failed (no mode gate should apply)"
);
assert_eq!(
abs_value(&node, ABS_RX),
Some(9000),
"wire right stick did not land on the right pad (ABS_RX)"
);
}
}
@@ -341,6 +341,129 @@ pub fn serialize_deck_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq
r[58..60].copy_from_slice(&st.rpad_pressure.to_le_bytes());
}
/// Map an `XInput`/GameStream pad frame into **classic Steam Controller** state. The SC's 24-bit
/// button field (report bytes 8..10) shares its low-bit layout with the Deck's (face/shoulder/
/// trigger-full byte 8; dpad/View/Steam/Menu byte 9 bits 06), so this reuses the [`btn`] masks —
/// with the SC-specific tail per the kernel's `ID_CONTROLLER_STATE` table:
/// - `9.7`/`10.0` are the SC's TWO grips (the bit positions the Deck calls L5/R5): wire
/// `BTN_PADDLE2`/`BTN_PADDLE1` (L4/R4, the primary pair) land there; fold PADDLE3/4 via
/// [`super::steam_remap`] BEFORE calling this.
/// - `10.2` = right-pad clicked (the SC has no right stick): wire `BTN_RS_CLICK` and the
/// DualSense `BTN_TOUCHPAD` click both land there.
/// - `10.6` = joystick clicked = wire `BTN_LS_CLICK` (the same bit the Deck calls L3).
/// - No QAM/misc slot — `BTN_MISC1` is dropped (fold it upstream if a policy wants it).
///
/// The wire right STICK drives the right-pad coordinates (`rpad_x/y` + the `10.4` touched bit
/// while deflected) — the SC's camera surface; the loss of a true second stick is inherent to
/// the hardware. The left stick rides the joystick fields; a left-pad `TouchpadEx` contact
/// (via [`SteamState::apply_rich`]) SHADOWS the joystick while touched (the report multiplexes
/// them at bytes 16..20, exactly like real hardware's `lpad_touched` flag).
pub fn sc_from_gamepad(
buttons: u32,
lx: i16,
ly: i16,
rx: i16,
ry: i16,
lt: u8,
rt: u8,
) -> SteamState {
let on = |bit: u32| buttons & bit != 0;
let mut s = SteamState {
lx,
ly,
rx: 0,
ry: 0,
lt: (lt as u16) * 128,
rt: (rt as u16) * 128,
// The wire right stick becomes a right-pad contact (see the doc above).
rpad_x: rx,
rpad_y: ry,
..SteamState::neutral()
};
let mut b = 0u64;
let set = |b: &mut u64, on: bool, m: u64| {
if on {
*b |= m;
}
};
set(&mut b, on(gs::BTN_A), btn::A);
set(&mut b, on(gs::BTN_B), btn::B);
set(&mut b, on(gs::BTN_X), btn::X);
set(&mut b, on(gs::BTN_Y), btn::Y);
set(&mut b, on(gs::BTN_LB), btn::LB);
set(&mut b, on(gs::BTN_RB), btn::RB);
set(&mut b, lt > 0, btn::LT_FULL);
set(&mut b, rt > 0, btn::RT_FULL);
set(&mut b, on(gs::BTN_BACK), btn::VIEW);
set(&mut b, on(gs::BTN_START), btn::MENU);
set(&mut b, on(gs::BTN_GUIDE), btn::STEAM);
set(&mut b, on(gs::BTN_DPAD_UP), btn::DPAD_UP);
set(&mut b, on(gs::BTN_DPAD_DOWN), btn::DPAD_DOWN);
set(&mut b, on(gs::BTN_DPAD_LEFT), btn::DPAD_LEFT);
set(&mut b, on(gs::BTN_DPAD_RIGHT), btn::DPAD_RIGHT);
// SC grips at the Deck's L5/R5 bit positions (9.7 / 10.0): the wire primary pair L4/R4.
set(&mut b, on(gs::BTN_PADDLE2), btn::L5); // left grip
set(&mut b, on(gs::BTN_PADDLE1), btn::R5); // right grip
// Joystick click (10.6 — the bit the Deck calls L3) + right-pad click (10.2).
set(&mut b, on(gs::BTN_LS_CLICK), btn::L3);
set(
&mut b,
on(gs::BTN_RS_CLICK) || on(gs::BTN_TOUCHPAD),
btn::RPAD_CLICK,
);
// Right-pad touched (10.4) while the wire stick is deflected — the coords are live then.
set(&mut b, rx != 0 || ry != 0, btn::RPAD_TOUCH);
s.buttons = b;
s
}
/// Serialize the classic Steam Controller input report (`ID_CONTROLLER_STATE`) into the 64-byte
/// unnumbered frame `steam_do_input_event` parses. Byte-exact against the kernel's message
/// table: 24-bit buttons at 8..11, **u8** triggers at 11/12 (the Deck uses u16 at 44/46),
/// the joystick/left-pad MULTIPLEX at 16..20 (left-pad coords shadow the joystick while the
/// `10.3` touched bit is set), the right pad at 20..24, and the (kernel-ignored, hidraw-visible)
/// accel/gyro at 28..39. The kernel negates both Y axes on top of these raw values.
pub fn serialize_sc_state(r: &mut [u8; STEAM_REPORT_LEN], st: &SteamState, seq: u32) {
r.fill(0);
r[0] = 0x01;
r[1] = 0x00;
r[2] = ID_CONTROLLER_STATE;
r[3] = 0x3C;
r[4..8].copy_from_slice(&seq.to_le_bytes());
// Rich-plane pad clicks merge like the Deck path: left-pad clicked = 10.1 (hidraw-only —
// the kernel maps no key to it), right-pad clicked = 10.2.
let mut buttons = st.buttons;
if st.lpad_click {
buttons |= btn::LPAD_CLICK;
}
if st.rpad_click {
buttons |= btn::RPAD_CLICK;
}
r[8] = (buttons & 0xFF) as u8;
r[9] = ((buttons >> 8) & 0xFF) as u8;
r[10] = ((buttons >> 16) & 0xFF) as u8;
r[11] = (st.lt >> 7).min(255) as u8; // left trigger, u8
r[12] = (st.rt >> 7).min(255) as u8; // right trigger, u8
// Bytes 16..20 carry EITHER the joystick OR the left pad, per the 10.3 touched bit.
let (x, y) = if buttons & btn::LPAD_TOUCH != 0 {
(st.lpad_x, st.lpad_y)
} else {
(st.lx, st.ly)
};
r[16..18].copy_from_slice(&x.to_le_bytes());
r[18..20].copy_from_slice(&y.to_le_bytes());
r[20..22].copy_from_slice(&st.rpad_x.to_le_bytes());
r[22..24].copy_from_slice(&st.rpad_y.to_le_bytes());
// IMU: present in the frame (28..39) for hidraw readers, but the kernel maps none of it
// ("accelerator/gyro is disabled by default" — no sensors evdev for the SC).
r[28..30].copy_from_slice(&st.accel[0].to_le_bytes());
r[30..32].copy_from_slice(&st.accel[1].to_le_bytes());
r[32..34].copy_from_slice(&st.accel[2].to_le_bytes());
r[34..36].copy_from_slice(&st.gyro[0].to_le_bytes());
r[36..38].copy_from_slice(&st.gyro[1].to_le_bytes());
r[38..40].copy_from_slice(&st.gyro[2].to_le_bytes());
}
/// Build the `steam_get_serial` GET_REPORT reply. The Steam feature path is report-id-0 with a
/// leading report-id byte the kernel strips (`steam_recv_report` does `memcpy(data, buf+1, …)`), so
/// the wire is `[0x00, 0xAE, len, 0x01, ascii…]`; the kernel then validates `reply[0]==0xAE`,
@@ -693,6 +816,72 @@ mod tests {
assert_ne!(serialized & btn::LPAD_CLICK, 0); // click lands in the report despite the rebuild
}
/// The classic-SC frame, byte-exact against the kernel's `ID_CONTROLLER_STATE` table: 24-bit
/// buttons at 8..11, u8 triggers at 11/12, the joystick/left-pad multiplex at 16..20, right
/// pad at 20..24 — and the SC-specific button tail (grips at 9.7/10.0, right-pad click at
/// 10.2, joystick click at 10.6).
#[test]
fn sc_serialize_and_mapping() {
// Full mapping: face + grips + clicks + a deflected right stick.
let s = sc_from_gamepad(
gs::BTN_A
| gs::BTN_PADDLE1
| gs::BTN_PADDLE2
| gs::BTN_LS_CLICK
| gs::BTN_RS_CLICK,
1000,
-2000,
3000,
-4000,
255,
0,
);
assert_ne!(s.buttons & btn::A, 0);
assert_ne!(s.buttons & btn::R5, 0); // PADDLE1 → right grip (10.0)
assert_ne!(s.buttons & btn::L5, 0); // PADDLE2 → left grip (9.7)
assert_ne!(s.buttons & btn::L3, 0); // LS click → joystick clicked (10.6)
assert_ne!(s.buttons & btn::RPAD_CLICK, 0); // RS click → right-pad clicked (10.2)
assert_ne!(s.buttons & btn::RPAD_TOUCH, 0); // deflected stick = touched pad (10.4)
assert_eq!((s.rpad_x, s.rpad_y), (3000, -4000)); // right stick rides the right pad
assert_eq!((s.rx, s.ry), (0, 0));
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0x0102_0304);
assert_eq!(&r[0..4], &[0x01, 0x00, 0x01, 0x3C]); // ID_CONTROLLER_STATE
assert_eq!(&r[4..8], &[0x04, 0x03, 0x02, 0x01]);
assert_eq!(r[8] & 0x80, 0x80); // A = 8.7
assert_eq!(r[9] & 0x80, 0x80); // left grip = 9.7
assert_eq!(r[10] & 0x01, 0x01); // right grip = 10.0
assert_eq!(r[10] & 0x04, 0x04); // right-pad clicked = 10.2
assert_eq!(r[10] & 0x40, 0x40); // joystick clicked = 10.6
assert_eq!(r[11], 255); // left trigger u8
assert_eq!(r[12], 0); // right trigger u8
assert_eq!(&r[16..18], &1000i16.to_le_bytes()); // joystick X (lpad untouched)
assert_eq!(&r[18..20], &(-2000i16).to_le_bytes());
assert_eq!(&r[20..22], &3000i16.to_le_bytes()); // right pad X
assert_eq!(&r[22..24], &(-4000i16).to_le_bytes());
// Left-pad multiplex: a TouchpadEx surface-1 contact shadows the joystick at 16..20
// and sets the 10.3 touched bit (+ the 10.1 click bit from the rich field).
let mut s = sc_from_gamepad(0, 1234, 0, 0, 0, 0, 0);
s.apply_rich(RichInput::TouchpadEx {
pad: 0,
surface: 1,
finger: 0,
touch: true,
click: true,
x: -5000,
y: 6000,
pressure: 0,
});
let mut r = [0u8; STEAM_REPORT_LEN];
serialize_sc_state(&mut r, &s, 0);
assert_eq!(r[10] & 0x08, 0x08); // left-pad touched = 10.3
assert_eq!(r[10] & 0x02, 0x02); // left-pad clicked = 10.1 (rich click merged)
assert_eq!(&r[16..18], &(-5000i16).to_le_bytes()); // lpad coords shadow the joystick
assert_eq!(&r[18..20], &(-6000i16).to_le_bytes()); // screen +down → raw +up (flip)
}
/// The serial reply carries the leading report-id byte the kernel strips, so the *stripped*
/// view (`reply[1..]`) is what `steam_get_serial` validates: `[0xAE, len, 0x01, ascii…]`.
#[test]
+35 -2
View File
@@ -1779,6 +1779,8 @@ struct Pads {
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "linux")]
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "linux")]
steamctrl: Option<crate::inject::steam_controller::SteamCtrlManager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
@@ -1812,6 +1814,8 @@ impl Pads {
steamdeck: None,
#[cfg(target_os = "linux")]
switchpro: None,
#[cfg(target_os = "linux")]
steamctrl: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
@@ -1888,6 +1892,11 @@ impl Pads {
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SteamController => self
.steamctrl
.get_or_insert_with(crate::inject::steam_controller::SteamCtrlManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self
.xboxone
.get_or_insert_with(|| {
@@ -1973,6 +1982,12 @@ impl Pads {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SteamController => {
if let Some(m) = &mut self.steamctrl {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win {
@@ -2027,6 +2042,9 @@ impl Pads {
if let Some(m) = &mut self.switchpro {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.steamctrl {
m.pump(&mut rumble, &mut hidout);
}
}
#[cfg(target_os = "windows")]
{
@@ -2065,6 +2083,9 @@ impl Pads {
if let Some(m) = &mut self.switchpro {
m.heartbeat(gap);
}
if let Some(m) = &mut self.steamctrl {
m.heartbeat(gap);
}
}
#[cfg(target_os = "windows")]
{
@@ -2758,9 +2779,10 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// One/Series: a real, distinct uinput identity on Linux; folded into the 360 backend on
// Windows (XInput can't tell them apart anyway).
GamepadPref::XboxOne if linux => GamepadPref::XboxOne,
// Steam Deck: Linux UHID hid-steam. The classic Steam Controller's backend isn't built yet,
// so it folds to Xbox360 for now (Windows Steam devices are M7).
// Steam Deck / classic Steam Controller: Linux UHID hid-steam (Windows Steam devices
// are the N4 spike).
GamepadPref::SteamDeck if linux => GamepadPref::SteamDeck,
GamepadPref::SteamController if linux => GamepadPref::SteamController,
// No virtual Deck on Windows (M7) — fold to DualSense, the closest rich pad: its
// backend keeps gyro + trackpads + pad-click alive (the Deck's dual pads split the
// DualSense touchpad left/right per DsState::apply_rich). Folding to Xbox360 dropped
@@ -2789,6 +2811,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
| GamepadPref::SteamController
| GamepadPref::SwitchPro
);
if needs_uhid
@@ -5356,6 +5379,16 @@ mod tests {
assert_eq!(pick_gamepad(SteamDeck, None, false, true), DualSense);
assert_eq!(pick_gamepad(Auto, Some("deck"), false, true), DualSense);
assert_eq!(pick_gamepad(SteamDeck, None, false, false), Xbox360);
// Classic Steam Controller: native on Linux (UHID hid-steam); Xbox360 elsewhere.
assert_eq!(
pick_gamepad(SteamController, None, true, false),
SteamController
);
assert_eq!(
pick_gamepad(Auto, Some("steamcontroller"), true, false),
SteamController
);
assert_eq!(pick_gamepad(SteamController, None, false, true), Xbox360);
// DualSense Edge: native on Linux (UHID) AND Windows (UMDF device-type 2); Xbox360
// elsewhere.