feat(clients/steam): M4 — desktop SDL clients capture the rich Steam inputs
The Linux + Windows native clients (clients/{linux,windows}/src/gamepad.rs) now
capture and send the Steam Controller / Steam Deck rich inputs, so a real Deck
(off Steam Input) or a Steam Controller on a desktop client drives the host's
virtual hid-steam pad end-to-end:
- Set SDL's HIDAPI Steam hints (SDL_JOYSTICK_HIDAPI_STEAMDECK / _STEAM) before
init so SDL opens Valve devices directly (paddles + both trackpads + gyro as
first-class SDL gamepad inputs).
- Detect the Deck/SC by VID/PID (0x28DE + 0x1205 / 0x1102 / 0x1142) ->
GamepadPref::SteamDeck (there is no SDL gamepad type for it), so the host
builds the virtual Deck with the right identity.
- Map the SDL paddle + Misc1 buttons -> BTN_PADDLE1..4 / BTN_MISC1 (a free win
for Xbox Elite paddles too).
- Route a SECOND touchpad -> RichInput::TouchpadEx (SDL touchpad 0 = left ->
surface 1, 1 = right -> surface 2, signed coords); a single touchpad keeps the
legacy Touchpad. New forward_touch() helper centralizes the choice.
- Track held touchpad contacts per (surface, finger) and lift them on pad
switch/detach so a contact held at that moment can't stick.
- Sensor (gyro/accel) capture was already generic across pad types.
Linux client builds + clippy clean; the Windows client is a near-verbatim
mirror (windows CI compiles it). On a Deck in Game Mode, Steam Input still holds
the device — the user disables Steam Input for the client (the Decky UX, next);
on a desktop client (or a Deck with Steam Input off) the hints just work.
Remaining M4: Decky Disable-Steam-Input UX, Apple/Android parity, and the C-ABI
PunktfunkRichInputEx + send_rich_input2 (Apple/embedder send path). Not pushed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+108
-27
@@ -169,6 +169,13 @@ fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||||
Button::DPadLeft => wire::BTN_DPAD_LEFT,
|
||||
Button::DPadRight => wire::BTN_DPAD_RIGHT,
|
||||
Button::Touchpad => wire::BTN_TOUCHPAD,
|
||||
// Back grips / paddles (Steam Deck L4/L5/R4/R5, Xbox Elite P1–P4) + the misc/Share button.
|
||||
// PADDLE1/2/3/4 = R4/L4/R5/L5 (see the host `input::gamepad`).
|
||||
Button::RightPaddle1 => wire::BTN_PADDLE1,
|
||||
Button::LeftPaddle1 => wire::BTN_PADDLE2,
|
||||
Button::RightPaddle2 => wire::BTN_PADDLE3,
|
||||
Button::LeftPaddle2 => wire::BTN_PADDLE4,
|
||||
Button::Misc1 => wire::BTN_MISC1,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -240,6 +247,9 @@ struct Worker {
|
||||
/// Wire state of the active pad — zeroed on the wire at switch/detach.
|
||||
last_axis: [i32; 6],
|
||||
held_buttons: Vec<u32>,
|
||||
/// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad
|
||||
/// switch / detach. surface 0 = the legacy single touchpad, 1/2 = a Steam left/right pad.
|
||||
held_touches: std::collections::HashSet<(u8, u8)>,
|
||||
last_accel: [i16; 3],
|
||||
}
|
||||
|
||||
@@ -252,13 +262,21 @@ impl Worker {
|
||||
|
||||
fn pad_info(&self, id: u32) -> Option<PadInfo> {
|
||||
let pad = self.opened.get(&id)?;
|
||||
let mut pref = pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
);
|
||||
// No SDL type for the Steam Deck / Steam Controller — detect Valve by VID/PID (Deck 0x1205,
|
||||
// SC wired 0x1102, SC dongle 0x1142) so the host builds the virtual hid-steam pad.
|
||||
if pad.vendor_id() == Some(0x28DE)
|
||||
&& matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142))
|
||||
{
|
||||
pref = GamepadPref::SteamDeck;
|
||||
}
|
||||
Some(PadInfo {
|
||||
id,
|
||||
name: pad.name().unwrap_or_else(|| "Controller".into()),
|
||||
pref: pref_for_type(
|
||||
self.subsystem
|
||||
.type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)),
|
||||
),
|
||||
pref,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -274,9 +292,33 @@ impl Worker {
|
||||
}
|
||||
*v = i32::MIN;
|
||||
}
|
||||
for (surface, finger) in self.held_touches.drain() {
|
||||
let rich = if surface == 0 {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: false,
|
||||
click: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
pressure: 0,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
}
|
||||
} else {
|
||||
self.held_buttons.clear();
|
||||
self.last_axis = [i32::MIN; 6];
|
||||
self.held_touches.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +334,56 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward one touchpad contact on the rich-input plane. A multi-touchpad pad (Steam Deck / Steam
|
||||
/// Controller) sends `TouchpadEx` with the surface (SDL touchpad 0 = left → 1, 1 = right → 2) and
|
||||
/// signed coordinates; a single-touchpad pad (DualSense) keeps the legacy `Touchpad` (unsigned).
|
||||
fn forward_touch(
|
||||
&mut self,
|
||||
which: u32,
|
||||
touchpad: u32,
|
||||
finger: u8,
|
||||
x: f32,
|
||||
y: f32,
|
||||
active: bool,
|
||||
) {
|
||||
let Some(c) = self.attached.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let multi = self
|
||||
.opened
|
||||
.get(&which)
|
||||
.map(|p| p.touchpads_count() >= 2)
|
||||
.unwrap_or(false);
|
||||
let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0));
|
||||
let surface = if multi { (touchpad as u8) + 1 } else { 0 };
|
||||
let rich = if multi {
|
||||
RichInput::TouchpadEx {
|
||||
pad: 0,
|
||||
surface,
|
||||
finger,
|
||||
touch: active,
|
||||
click: false,
|
||||
x: (cx * 65535.0 - 32768.0) as i16,
|
||||
y: (cy * 65535.0 - 32768.0) as i16,
|
||||
pressure: 0,
|
||||
}
|
||||
} else {
|
||||
RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger,
|
||||
active,
|
||||
x: (cx * 65535.0) as u16,
|
||||
y: (cy * 65535.0) as u16,
|
||||
}
|
||||
};
|
||||
let _ = c.send_rich_input(rich);
|
||||
if active {
|
||||
self.held_touches.insert((surface, finger));
|
||||
} else {
|
||||
self.held_touches.remove(&(surface, finger));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -305,6 +397,10 @@ fn run(
|
||||
// thread.
|
||||
sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
|
||||
// Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the
|
||||
// paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs.
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1");
|
||||
sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1");
|
||||
let sdl = sdl3::init().map_err(|e| e.to_string())?;
|
||||
let subsystem = sdl.gamepad().map_err(|e| e.to_string())?;
|
||||
let mut pump = sdl.event_pump().map_err(|e| e.to_string())?;
|
||||
@@ -317,6 +413,7 @@ fn run(
|
||||
attached: None,
|
||||
last_axis: [i32::MIN; 6],
|
||||
held_buttons: Vec::new(),
|
||||
held_touches: std::collections::HashSet::new(),
|
||||
last_accel: [0; 3],
|
||||
};
|
||||
|
||||
@@ -426,9 +523,11 @@ fn run(
|
||||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||||
}
|
||||
}
|
||||
// DualSense touchpad → the rich-input plane, normalized 0..=65535.
|
||||
// Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy
|
||||
// `Touchpad`; two pads (Steam Deck / Steam Controller) send `TouchpadEx` per surface.
|
||||
Event::ControllerTouchpadDown {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
@@ -436,41 +535,23 @@ fn run(
|
||||
}
|
||||
| Event::ControllerTouchpadMotion {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: true,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, true);
|
||||
}
|
||||
Event::ControllerTouchpadUp {
|
||||
which,
|
||||
touchpad,
|
||||
finger,
|
||||
x,
|
||||
y,
|
||||
..
|
||||
} if active == Some(which) && w.attached.is_some() => {
|
||||
let _ = w
|
||||
.attached
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.send_rich_input(RichInput::Touchpad {
|
||||
pad: 0,
|
||||
finger: finger as u8,
|
||||
active: false,
|
||||
x: (x.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
y: (y.clamp(0.0, 1.0) * 65535.0) as u16,
|
||||
});
|
||||
w.forward_touch(which, touchpad as u32, finger as u8, x, y, false);
|
||||
}
|
||||
// Motion: accel events update the cache; each gyro event ships a sample (the
|
||||
// DualSense reports both at ~250 Hz). Scale convention shared with the other
|
||||
|
||||
Reference in New Issue
Block a user