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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 0–6), 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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user