9822fc3b1c
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m0s
ci / bench (push) Successful in 5m49s
android-screenshots / screenshots (push) Successful in 3m17s
ci / rust (push) Successful in 17m50s
decky / build-publish (push) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 59s
apple / swift (push) Successful in 4m25s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m22s
android / android (push) Successful in 14m55s
arch / build-publish (push) Successful in 13m12s
deb / build-publish (push) Successful in 11m29s
web-screenshots / screenshots (push) Successful in 2m42s
docker / deploy-docs (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
linux-client-screenshots / screenshots (push) Successful in 7m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m36s
windows-host / package (push) Successful in 15m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m10s
release / apple (push) Successful in 22m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m59s
flatpak / build-publish (push) Successful in 5m41s
apple / screenshots (push) Successful in 19m29s
Removing the builtin stream path (ef580825) left GamepadService's attach/detach/
active/auto_pref + the Ctl::Attach/Detach variants with no callers — the spawned
punktfunk-session binary owns pad forwarding now. The client still compiled, but
clippy -D warnings tripped on the dead code. Drop the forwarding hooks + the
active-pad mirror; the service keeps pads() (Settings list) and set_pinned()
(persist the forwarded-pad selection the session child reads).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
630 lines
25 KiB
Rust
630 lines
25 KiB
Rust
//! App-lifetime gamepad service over SDL3 (mirrors the Swift/GTK clients' `GamepadManager` +
|
||
//! capture/feedback). Ported near-verbatim from the GTK Linux client — SDL3 is cross-platform,
|
||
//! so the only Windows change is the build (`sdl3` is compiled from source via the bundled
|
||
//! CMake, since there is no system SDL3).
|
||
//!
|
||
//! One worker thread owns SDL for the process lifetime: it tracks connected pads, selects the
|
||
//! ONE controller forwarded as pad 0 (user pin, else the most recently connected), and — while
|
||
//! a session is attached — forwards buttons/axes, DualSense touchpad contacts and motion
|
||
//! samples (0xCC), and renders feedback: rumble on every pad, lightbar via SDL, and on a real
|
||
//! DualSense the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs).
|
||
//! Held state is zeroed on the wire when the active pad switches or the session detaches, so
|
||
//! nothing sticks down.
|
||
//!
|
||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||
|
||
use punktfunk_core::client::NativeClient;
|
||
use punktfunk_core::config::GamepadPref;
|
||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||
use punktfunk_core::quic::{HidOutput, RichInput};
|
||
use std::collections::HashMap;
|
||
use std::sync::mpsc::{Receiver, Sender};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Duration;
|
||
|
||
/// Motion scale constants, shared convention with the other clients (`GamepadWire`): derived
|
||
/// from hid-playstation's math over the host's fixed calibration blob. SDL hands us gyro in
|
||
/// rad/s and accel in m/s²; the DualSense report wants raw LSBs.
|
||
const GYRO_LSB_PER_RAD_S: f32 = 20.0 * 180.0 / std::f32::consts::PI;
|
||
const ACCEL_LSB_PER_G: f32 = 10_000.0;
|
||
const G: f32 = 9.80665;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct PadInfo {
|
||
/// Stable identity (`vid:pid:name`, the same format as `pf-client-core`'s `PadInfo::key`)
|
||
/// — persisted as `Settings::forward_pad` so the pin survives restarts AND reaches the
|
||
/// spawned session binary, whose own gamepad service applies the same key.
|
||
pub key: String,
|
||
pub name: String,
|
||
/// The virtual pad "Automatic" resolves to for this physical controller (DualSense → DualSense,
|
||
/// DS4 → DualShock 4, Xbox One/Series → Xbox One, else → Xbox 360).
|
||
pub pref: GamepadPref,
|
||
}
|
||
|
||
impl PadInfo {
|
||
/// True for a real DualSense — the only pad whose lightbar / player-LED / adaptive-trigger
|
||
/// feedback we replay as raw DS5 HID effect packets (a DS4 uses SDL's generic `set_led`).
|
||
fn is_dualsense(&self) -> bool {
|
||
self.pref == GamepadPref::DualSense
|
||
}
|
||
|
||
/// A short human label for the detected pad family, shown next to the name in the settings
|
||
/// GUI's controller list ("" for a generic pad the name already describes).
|
||
pub fn kind_label(&self) -> &'static str {
|
||
match self.pref {
|
||
GamepadPref::DualSense => "DualSense",
|
||
GamepadPref::DualShock4 => "DualShock 4",
|
||
GamepadPref::XboxOne => "Xbox One",
|
||
GamepadPref::SteamDeck => "Steam Deck",
|
||
GamepadPref::SteamController => "Steam Controller",
|
||
_ => "",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Map the SDL-reported controller type to the virtual pad we'd ask the host to create.
|
||
fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref {
|
||
use sdl3::gamepad::GamepadType as T;
|
||
match t {
|
||
T::PS5 => GamepadPref::DualSense,
|
||
T::PS4 => GamepadPref::DualShock4,
|
||
T::XboxOne => GamepadPref::XboxOne,
|
||
_ => GamepadPref::Xbox360,
|
||
}
|
||
}
|
||
|
||
enum Ctl {
|
||
Pin(Option<String>),
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub struct GamepadService {
|
||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||
// `Arc<Mutex<…>>` (not a bare `Sender`, which is `!Sync`) so the service is `Sync` — the
|
||
// WinUI app shares it across the UI thread and the settings-pin path.
|
||
ctl: Arc<Mutex<Sender<Ctl>>>,
|
||
}
|
||
|
||
impl GamepadService {
|
||
pub fn start() -> GamepadService {
|
||
let pads = Arc::new(Mutex::new(Vec::new()));
|
||
let (ctl, ctl_rx) = std::sync::mpsc::channel();
|
||
let p = pads.clone();
|
||
if let Err(e) = std::thread::Builder::new()
|
||
.name("punktfunk-gamepad".into())
|
||
.spawn(move || {
|
||
if let Err(e) = run(&p, &ctl_rx) {
|
||
tracing::warn!(error = %e, "gamepad service ended — pads disabled");
|
||
}
|
||
})
|
||
{
|
||
tracing::warn!(error = %e, "gamepad service failed to start");
|
||
}
|
||
GamepadService {
|
||
pads,
|
||
ctl: Arc::new(Mutex::new(ctl)),
|
||
}
|
||
}
|
||
|
||
/// Connected controllers, most recently attached first (the settings GUI's list order).
|
||
pub fn pads(&self) -> Vec<PadInfo> {
|
||
self.pads.lock().unwrap().clone()
|
||
}
|
||
|
||
/// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic.
|
||
/// The pin survives the pad disconnecting: it re-applies the moment a matching
|
||
/// controller shows up again (same semantics as `pf-client-core`'s service). The spawned
|
||
/// `punktfunk-session` binary owns the actual forwarding; this persists the selection.
|
||
pub fn set_pinned(&self, key: Option<String>) {
|
||
let _ = self.ctl.lock().unwrap().send(Ctl::Pin(key));
|
||
}
|
||
}
|
||
|
||
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) {
|
||
let _ = connector.send_input(&InputEvent {
|
||
kind,
|
||
_pad: [0; 3],
|
||
code,
|
||
x,
|
||
y: 0,
|
||
flags: 0, // pad index 0 — single-pad model
|
||
});
|
||
}
|
||
|
||
fn button_bit(b: sdl3::gamepad::Button) -> Option<u32> {
|
||
use sdl3::gamepad::Button;
|
||
Some(match b {
|
||
Button::South => wire::BTN_A,
|
||
Button::East => wire::BTN_B,
|
||
Button::West => wire::BTN_X,
|
||
Button::North => wire::BTN_Y,
|
||
Button::Back => wire::BTN_BACK,
|
||
Button::Start => wire::BTN_START,
|
||
Button::Guide => wire::BTN_GUIDE,
|
||
Button::LeftStick => wire::BTN_LS_CLICK,
|
||
Button::RightStick => wire::BTN_RS_CLICK,
|
||
Button::LeftShoulder => wire::BTN_LB,
|
||
Button::RightShoulder => wire::BTN_RB,
|
||
Button::DPadUp => wire::BTN_DPAD_UP,
|
||
Button::DPadDown => wire::BTN_DPAD_DOWN,
|
||
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,
|
||
})
|
||
}
|
||
|
||
/// SDL axis → (wire axis id, wire value). SDL sticks are +y = down; the wire (XInput
|
||
/// convention) is +y = up. SDL triggers span 0..32767; the wire wants 0..255.
|
||
fn axis_value(axis: sdl3::gamepad::Axis, v: i16) -> (u32, i32) {
|
||
use sdl3::gamepad::Axis;
|
||
match axis {
|
||
Axis::LeftX => (wire::AXIS_LS_X, v as i32),
|
||
Axis::LeftY => (wire::AXIS_LS_Y, -(v as i32).max(-32767)),
|
||
Axis::RightX => (wire::AXIS_RS_X, v as i32),
|
||
Axis::RightY => (wire::AXIS_RS_Y, -(v as i32).max(-32767)),
|
||
Axis::TriggerLeft => (wire::AXIS_LT, (v as i32).clamp(0, 32767) >> 7),
|
||
Axis::TriggerRight => (wire::AXIS_RT, (v as i32).clamp(0, 32767) >> 7),
|
||
}
|
||
}
|
||
|
||
/// The DualSense effects packet (SDL `DS5EffectsState_t`, 47 bytes) — the same layout the host
|
||
/// parses off its virtual pad; the wire's 11-byte trigger blocks drop in verbatim. Enable bits
|
||
/// select only the fields each update touches, so rumble (driven separately through SDL) and
|
||
/// untouched fields keep their state.
|
||
#[derive(Default)]
|
||
struct Ds5Feedback;
|
||
|
||
impl Ds5Feedback {
|
||
const RIGHT_TRIGGER: usize = 10;
|
||
const LEFT_TRIGGER: usize = 21;
|
||
const PAD_LIGHTS: usize = 43;
|
||
const LED_RGB: usize = 44;
|
||
|
||
fn trigger_packet(which: u8, effect: &[u8]) -> [u8; 47] {
|
||
let mut p = [0u8; 47];
|
||
let (flag, off) = if which == 1 {
|
||
(0x04, Self::RIGHT_TRIGGER)
|
||
} else {
|
||
(0x08, Self::LEFT_TRIGGER)
|
||
};
|
||
p[0] = flag;
|
||
let n = effect.len().min(11);
|
||
p[off..off + n].copy_from_slice(&effect[..n]);
|
||
p
|
||
}
|
||
|
||
fn lightbar_packet(r: u8, g: u8, b: u8) -> [u8; 47] {
|
||
let mut p = [0u8; 47];
|
||
p[1] = 0x04; // lightbar enable
|
||
p[Self::LED_RGB] = r;
|
||
p[Self::LED_RGB + 1] = g;
|
||
p[Self::LED_RGB + 2] = b;
|
||
p
|
||
}
|
||
|
||
fn player_packet(bits: u8) -> [u8; 47] {
|
||
let mut p = [0u8; 47];
|
||
p[1] = 0x10; // player-LED enable
|
||
p[Self::PAD_LIGHTS] = bits & 0x1F;
|
||
p
|
||
}
|
||
}
|
||
|
||
struct Worker {
|
||
subsystem: sdl3::GamepadSubsystem,
|
||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||
/// Connection order; the most recently connected is the auto selection.
|
||
order: Vec<u32>,
|
||
/// The user pin by stable key (`PadInfo::key`); resolved to an instance id per lookup
|
||
/// so it re-applies whenever a matching pad (re)connects.
|
||
pinned: Option<String>,
|
||
attached: Option<Arc<NativeClient>>,
|
||
/// 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],
|
||
}
|
||
|
||
impl Worker {
|
||
fn active_id(&self) -> Option<u32> {
|
||
self.pinned
|
||
.as_deref()
|
||
.and_then(|key| {
|
||
self.order
|
||
.iter()
|
||
.rev() // prefer the most recently connected pad with this identity
|
||
.find(|&&id| self.pad_info(id).is_some_and(|p| p.key == key))
|
||
.copied()
|
||
})
|
||
.or_else(|| self.order.last().copied())
|
||
}
|
||
|
||
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)),
|
||
);
|
||
let (vid, pid) = (pad.vendor_id().unwrap_or(0), pad.product_id().unwrap_or(0));
|
||
// 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 vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) {
|
||
pref = GamepadPref::SteamDeck;
|
||
}
|
||
let name = pad.name().unwrap_or_else(|| "Controller".into());
|
||
Some(PadInfo {
|
||
// Must match pf-client-core's `PadInfo::key` byte-for-byte — the persisted
|
||
// `forward_pad` is applied by BOTH services (this one and the session's).
|
||
key: format!("{vid:04x}:{pid:04x}:{name}"),
|
||
name,
|
||
pref,
|
||
})
|
||
}
|
||
|
||
/// Zero everything the host believes is held — on pad switch and detach.
|
||
fn flush_held(&mut self) {
|
||
if let Some(c) = &self.attached {
|
||
for b in self.held_buttons.drain(..) {
|
||
send(c, InputKind::GamepadButton, b, 0);
|
||
}
|
||
for (id, v) in self.last_axis.iter_mut().enumerate() {
|
||
if *v != 0 && *v != i32::MIN {
|
||
send(c, InputKind::GamepadAxis, id as u32, 0);
|
||
}
|
||
*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();
|
||
}
|
||
}
|
||
|
||
/// Sensors stream only while a session wants them (they cost USB/BT bandwidth).
|
||
fn set_sensors(&mut self, enabled: bool) {
|
||
let Some(id) = self.active_id() else { return };
|
||
if let Some(pad) = self.opened.get_mut(&id) {
|
||
use sdl3::sensor::SensorType;
|
||
for s in [SensorType::Gyroscope, SensorType::Accelerometer] {
|
||
if unsafe { pad.has_sensor(s) } {
|
||
let _ = pad.sensor_set_enabled(s, enabled);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)]
|
||
fn run(pads_out: &Mutex<Vec<PadInfo>>, ctl: &Receiver<Ctl>) -> Result<(), String> {
|
||
// Off-main-thread + no video subsystem: keep SDL away from signals, poll pads on its own
|
||
// 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())?;
|
||
|
||
let mut w = Worker {
|
||
subsystem,
|
||
opened: HashMap::new(),
|
||
order: Vec::new(),
|
||
pinned: None,
|
||
attached: None,
|
||
last_axis: [i32::MIN; 6],
|
||
held_buttons: Vec::new(),
|
||
held_touches: std::collections::HashSet::new(),
|
||
last_accel: [0; 3],
|
||
};
|
||
|
||
let publish = |w: &Worker| {
|
||
let mut list: Vec<PadInfo> = w.order.iter().filter_map(|&id| w.pad_info(id)).collect();
|
||
list.reverse(); // most recent first — the Settings list order
|
||
*pads_out.lock().unwrap() = list;
|
||
};
|
||
|
||
loop {
|
||
// Control plane from the UI thread.
|
||
loop {
|
||
match ctl.try_recv() {
|
||
Ok(Ctl::Pin(key)) => {
|
||
let before = w.active_id();
|
||
w.pinned = key;
|
||
if w.active_id() != before {
|
||
w.flush_held();
|
||
if w.attached.is_some() {
|
||
w.set_sensors(true);
|
||
}
|
||
}
|
||
publish(&w);
|
||
}
|
||
Err(std::sync::mpsc::TryRecvError::Empty) => break,
|
||
Err(std::sync::mpsc::TryRecvError::Disconnected) => return Ok(()), // app gone
|
||
}
|
||
}
|
||
|
||
while let Some(event) = pump.poll_event() {
|
||
use sdl3::event::Event;
|
||
let active = w.active_id();
|
||
match event {
|
||
Event::ControllerDeviceAdded { which, .. } => {
|
||
if !w.opened.contains_key(&which) {
|
||
match w.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(which)) {
|
||
Ok(pad) => {
|
||
tracing::info!(
|
||
name = pad.name().unwrap_or_default(),
|
||
"gamepad attached"
|
||
);
|
||
w.opened.insert(which, pad);
|
||
w.order.push(which);
|
||
if w.attached.is_some() && w.active_id() == Some(which) {
|
||
w.set_sensors(true);
|
||
}
|
||
publish(&w);
|
||
}
|
||
Err(e) => tracing::warn!(error = %e, "gamepad open failed"),
|
||
}
|
||
}
|
||
}
|
||
Event::ControllerDeviceRemoved { which, .. } => {
|
||
if w.opened.remove(&which).is_some() {
|
||
w.order.retain(|&id| id != which);
|
||
if active == Some(which) {
|
||
w.flush_held();
|
||
}
|
||
tracing::info!("gamepad detached");
|
||
publish(&w);
|
||
}
|
||
}
|
||
Event::ControllerButtonDown { which, button, .. }
|
||
if active == Some(which) && w.attached.is_some() =>
|
||
{
|
||
if let Some(bit) = button_bit(button) {
|
||
w.held_buttons.push(bit);
|
||
send(
|
||
w.attached.as_ref().unwrap(),
|
||
InputKind::GamepadButton,
|
||
bit,
|
||
1,
|
||
);
|
||
}
|
||
}
|
||
Event::ControllerButtonUp { which, button, .. }
|
||
if active == Some(which) && w.attached.is_some() =>
|
||
{
|
||
if let Some(bit) = button_bit(button) {
|
||
w.held_buttons.retain(|&b| b != bit);
|
||
send(
|
||
w.attached.as_ref().unwrap(),
|
||
InputKind::GamepadButton,
|
||
bit,
|
||
0,
|
||
);
|
||
}
|
||
}
|
||
Event::ControllerAxisMotion {
|
||
which, axis, value, ..
|
||
} if active == Some(which) && w.attached.is_some() => {
|
||
let (id, v) = axis_value(axis, value);
|
||
if w.last_axis[id as usize] != v {
|
||
w.last_axis[id as usize] = v;
|
||
send(w.attached.as_ref().unwrap(), InputKind::GamepadAxis, id, v);
|
||
}
|
||
}
|
||
// 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,
|
||
..
|
||
}
|
||
| Event::ControllerTouchpadMotion {
|
||
which,
|
||
touchpad,
|
||
finger,
|
||
x,
|
||
y,
|
||
..
|
||
} if active == Some(which) && w.attached.is_some() => {
|
||
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() => {
|
||
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
|
||
// clients — sign/scale derived, not yet live-verified.
|
||
Event::ControllerSensorUpdated {
|
||
which,
|
||
sensor,
|
||
data,
|
||
..
|
||
} if active == Some(which) && w.attached.is_some() => {
|
||
use sdl3::sensor::SensorType;
|
||
match sensor {
|
||
SensorType::Accelerometer => {
|
||
for (i, v) in data.iter().enumerate() {
|
||
w.last_accel[i] =
|
||
(v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16;
|
||
}
|
||
}
|
||
SensorType::Gyroscope => {
|
||
let mut gyro = [0i16; 3];
|
||
for (i, v) in data.iter().enumerate() {
|
||
gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16;
|
||
}
|
||
let _ =
|
||
w.attached
|
||
.as_ref()
|
||
.unwrap()
|
||
.send_rich_input(RichInput::Motion {
|
||
pad: 0,
|
||
gyro,
|
||
accel: w.last_accel,
|
||
});
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Feedback planes (this thread is their single consumer). Rumble arrives as
|
||
// self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
|
||
// lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
|
||
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
|
||
// proven 5 s duration and rely on its periodic re-send as before.
|
||
if let Some(connector) = w.attached.clone() {
|
||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
||
if pad == 0 {
|
||
// Floor the lease so a jittered renewal can't gap the actuator between writes.
|
||
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
|
||
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
|
||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
|
||
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
|
||
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
|
||
// client-render.
|
||
if let Err(e) = p.set_rumble(low, high, dur_ms) {
|
||
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
|
||
} else {
|
||
tracing::debug!(low, high, "rumble: rendered");
|
||
}
|
||
} else {
|
||
tracing::debug!(low, high, "rumble: received but no active pad to render");
|
||
}
|
||
}
|
||
}
|
||
while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
|
||
let Some(id) = w.active_id() else { continue };
|
||
let is_ds = w.pad_info(id).is_some_and(|p| p.is_dualsense());
|
||
let Some(pad) = w.opened.get_mut(&id) else {
|
||
continue;
|
||
};
|
||
match hid {
|
||
HidOutput::Led { pad: 0, r, g, b } if is_ds => {
|
||
let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b));
|
||
}
|
||
HidOutput::Led { pad: 0, r, g, b } => {
|
||
let _ = pad.set_led(r, g, b);
|
||
}
|
||
HidOutput::PlayerLeds { pad: 0, bits } if is_ds => {
|
||
let _ = pad.send_effect(&Ds5Feedback::player_packet(bits));
|
||
}
|
||
HidOutput::Trigger {
|
||
pad: 0,
|
||
which,
|
||
ref effect,
|
||
} if is_ds => {
|
||
let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
std::thread::sleep(Duration::from_millis(if w.attached.is_some() {
|
||
2
|
||
} else {
|
||
30
|
||
}));
|
||
}
|
||
}
|