refactor(gamepad/windows): drop the dead shell fork, use pf-client-core's service
clients/windows/src/gamepad.rs was a 629-line near-verbatim fork of
pf-client-core's SDL gamepad service, frozen at an old single-pad design.
Commit 9822fc3b removed its attach/detach entry points but left the machinery,
so `Worker.attached` was initialized None and never set — ~300-400 lines
(button/axis/touchpad/motion forwarding, Ds5Feedback, the rumble/HID feedback
loop) were logically unreachable, never flagged because the guards read a
runtime Option the compiler can't prove is always None. The live remainder
(pad enumeration + pin persistence) had drifted from core: it opened every
device for metadata (vs core's no-open id-getters), force-enabled the Valve
HIDAPI drivers unconditionally, lacked the steam_virtual skip (so it could pin
Steam Input's virtual pad and kill gyro), and derived the pin key from an
opened handle — risking a cross-process byte-mismatch with the session, which
resolves the same key from id-getters.
The shell's only live job is enumerating pads for the Settings list and
persisting the pin; the spawned punktfunk-session already runs the full
pf-client-core service and does all real forwarding (session/main.rs). So
delete the fork and point the shell at pf_client_core::gamepad::GamepadService
directly — its start()/pads()/set_pinned()/clone() + PadInfo{key,name,
kind_label()} are a strict superset of what the shell uses. Idle, core's
service is hands-off the hardware (id-getter metadata, no device open, HIDAPI
off), which is the intended behavior and fixes the drift class above.
- delete clients/windows/src/gamepad.rs (-629) and `mod gamepad;`
- main.rs / app/mod.rs: use pf_client_core::gamepad::GamepadService
- drop the now-unused direct sdl3 dep (pf-client-core pulls it on Windows with
the same build-from-source,hidapi features); sync Cargo.lock
Pre-checks (dev Mac): std mpsc Sender<T>: Sync confirmed on the pinned 1.96.0
(so core's GamepadService is Sync for the WinUI cross-thread sharing, no core
change needed); rustfmt clean; no dangling refs. Windows compile is deferred
to CI (windows-only crate, unbuildable on macOS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
-1
@@ -3046,7 +3046,6 @@ dependencies = [
|
|||||||
"mdns-sd",
|
"mdns-sd",
|
||||||
"pf-client-core",
|
"pf-client-core",
|
||||||
"punktfunk-core",
|
"punktfunk-core",
|
||||||
"sdl3",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63
|
|||||||
# decode + present live in the spawned punktfunk-session binary.)
|
# decode + present live in the spawned punktfunk-session binary.)
|
||||||
ffmpeg-next = "8"
|
ffmpeg-next = "8"
|
||||||
|
|
||||||
# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform;
|
# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service
|
||||||
# built from source via the bundled CMake on Windows (no system SDL3).
|
# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3
|
||||||
sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] }
|
# itself (built from source via the bundled CMake on Windows) is pulled transitively by
|
||||||
|
# pf-client-core with the same `build-from-source,hidapi` features, so it is not a direct dep here.
|
||||||
mdns-sd = "0.20"
|
mdns-sd = "0.20"
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ mod stream;
|
|||||||
mod style;
|
mod style;
|
||||||
|
|
||||||
use crate::discovery::{self, DiscoveredHost};
|
use crate::discovery::{self, DiscoveredHost};
|
||||||
use crate::gamepad::GamepadService;
|
|
||||||
use crate::trust::{KnownHosts, Settings};
|
use crate::trust::{KnownHosts, Settings};
|
||||||
use hosts::HostsProps;
|
use hosts::HostsProps;
|
||||||
|
use pf_client_core::gamepad::GamepadService;
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::NativeClient;
|
||||||
use speed::{SpeedProps, SpeedState};
|
use speed::{SpeedProps, SpeedState};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -1,629 +0,0 @@
|
|||||||
//! 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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,6 @@ mod app;
|
|||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod gamepad;
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod gpu;
|
mod gpu;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod probe;
|
mod probe;
|
||||||
@@ -85,7 +83,11 @@ fn main() {
|
|||||||
tracing::error!(error = %e, "Windows App SDK bootstrap failed");
|
tracing::error!(error = %e, "Windows App SDK bootstrap failed");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let gamepad = gamepad::GamepadService::start();
|
// The shared SDL gamepad service (pf-client-core). The shell only enumerates pads (Settings
|
||||||
|
// list) and persists the pin; the spawned punktfunk-session runs the SAME service and does the
|
||||||
|
// actual forwarding — so, unlike the old shell fork, we never `attach()` here. Idle it stays
|
||||||
|
// hands-off the hardware (id-getter metadata, no device open, Valve HIDAPI drivers off).
|
||||||
|
let gamepad = pf_client_core::gamepad::GamepadService::start();
|
||||||
if let Err(e) = app::run(identity, gamepad) {
|
if let Err(e) = app::run(identity, gamepad) {
|
||||||
tracing::error!(error = %e, "WinUI app failed");
|
tracing::error!(error = %e, "WinUI app failed");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user