diff --git a/Cargo.lock b/Cargo.lock index 426e9c93..22bc2523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3046,7 +3046,6 @@ dependencies = [ "mdns-sd", "pf-client-core", "punktfunk-core", - "sdl3", "serde", "serde_json", "tracing", diff --git a/clients/windows/Cargo.toml b/clients/windows/Cargo.toml index 6db0d0de..87f1e626 100644 --- a/clients/windows/Cargo.toml +++ b/clients/windows/Cargo.toml @@ -62,10 +62,10 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "a4f7b2cb7c63 # decode + present live in the spawned punktfunk-session binary.) ffmpeg-next = "8" -# Gamepads: capture + feedback (full DualSense fidelity needs hidapi). SDL3 is cross-platform; -# built from source via the bundled CMake on Windows (no system SDL3). -sdl3 = { version = "0.18", features = ["build-from-source", "hidapi"] } - +# Gamepad enumeration + pin persistence for Settings runs on pf-client-core's shared SDL service +# (see the `gamepad` field in app/); the spawned punktfunk-session does the actual forwarding. SDL3 +# 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" async-channel = "2" serde = { version = "1", features = ["derive"] } diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 716e2e4d..1dee48d9 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -32,9 +32,9 @@ mod stream; mod style; use crate::discovery::{self, DiscoveredHost}; -use crate::gamepad::GamepadService; use crate::trust::{KnownHosts, Settings}; use hosts::HostsProps; +use pf_client_core::gamepad::GamepadService; use punktfunk_core::client::NativeClient; use speed::{SpeedProps, SpeedState}; use std::collections::HashMap; diff --git a/clients/windows/src/gamepad.rs b/clients/windows/src/gamepad.rs deleted file mode 100644 index 3a957c01..00000000 --- a/clients/windows/src/gamepad.rs +++ /dev/null @@ -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), -} - -#[derive(Clone)] -pub struct GamepadService { - pads: Arc>>, - // `Arc>` (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>>, -} - -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 { - 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) { - 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 { - 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, - /// Connection order; the most recently connected is the auto selection. - order: Vec, - /// 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, - attached: Option>, - /// Wire state of the active pad — zeroed on the wire at switch/detach. - last_axis: [i32; 6], - held_buttons: Vec, - /// 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 { - 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 { - 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>, ctl: &Receiver) -> 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 = 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 - })); - } -} diff --git a/clients/windows/src/main.rs b/clients/windows/src/main.rs index 4e28917c..c6340683 100644 --- a/clients/windows/src/main.rs +++ b/clients/windows/src/main.rs @@ -24,8 +24,6 @@ mod app; #[cfg(windows)] mod discovery; #[cfg(windows)] -mod gamepad; -#[cfg(windows)] mod gpu; #[cfg(windows)] mod probe; @@ -85,7 +83,11 @@ fn main() { tracing::error!(error = %e, "Windows App SDK bootstrap failed"); 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) { tracing::error!(error = %e, "WinUI app failed"); std::process::exit(1);