From 76be4c3e126d1f42c3209f60b2b2396a654bb785 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 12 Jul 2026 21:52:15 +0200 Subject: [PATCH] feat(gamepad): multi-controller support on the native plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host was already built for 16 pads; the blocker was every client hard-coding pad 0. This lands the host-side + reference-client contract: - input.rs: new wire kinds GamepadArrival=14 (declares a pad's type: code=GamepadPref byte, flags=pad) and GamepadRemove=13 (flags=seq<<24|pad, shares the snapshot seq space via encode/decode_gamepad_remove). - pf-client-core/gamepad.rs: reworked from a single `open` pad to a slots: Vec model — every forwarded controller gets a stable lowest-free wire index held for its lifetime, per-slot held/axis/touch/ rumble state, GamepadArrival on open + GamepadRemove on close, and feedback routed back per wire index. Automatic forwards all real pads; a pin forces single-player. - punktfunk1.rs: replaced the single-session PadBackend enum with a Pads router — per-pad kinds[]/owner[] arrays, lazily-created per-kind managers, pure route_decision keeping a live device in its manager across a kind change (no ghost/dup). Input thread seq-gates GamepadRemove (clears the pad_mask bit, resets rumble) and applies GamepadArrival kinds. - inject linux/windows backends: add the two new no-op InputKind arms. Native/session + default-Windows clients (both spawn punktfunk-session) inherit this. 57 core + 33 client-core + 272 host tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-client-core/src/gamepad.rs | 869 +++++++++++------- crates/punktfunk-core/src/client.rs | 90 +- crates/punktfunk-core/src/input.rs | 57 +- .../src/inject/linux/kwin_fake_input.rs | 6 +- .../punktfunk-host/src/inject/linux/libei.rs | 16 +- crates/punktfunk-host/src/inject/linux/wlr.rs | 6 +- .../src/inject/windows/sendinput.rs | 2 + crates/punktfunk-host/src/punktfunk1.rs | 476 +++++++--- include/punktfunk_core.h | 22 + 9 files changed, 1082 insertions(+), 462 deletions(-) diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 71fcc0b3..9ed548dd 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -251,12 +251,6 @@ pub struct PadInfo { } 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 controller-kind label for the Settings list (`""` for a plain Xbox/standard pad). pub fn kind_label(&self) -> &'static str { match self.pref { @@ -506,14 +500,26 @@ impl GamepadPump { } } -fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32) { +/// The lowest wire pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)) not already held +/// by a slot, or `None` when every index is taken. Assigning lowest-free keeps slot indices +/// stable across hot-plug churn: a pad that disconnects frees only its own index, so the others +/// never renumber (a game must not see its players shuffle when one pad drops). +fn lowest_free_index(taken: &[u8]) -> Option { + (0..punktfunk_core::input::MAX_PADS as u8).find(|i| !taken.contains(i)) +} + +/// Send one per-transition gamepad event tagged with its wire pad index (`flags`). The core +/// input task folds these per-pad into the seq'd [`GamepadState`](punktfunk_core::input::InputKind::GamepadState) +/// snapshots the host applies (keyed on this same `flags` index), so the only thing multi-pad +/// forwarding must get right here is the index — one controller per slot, one slot per index. +fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, pad: u8) { let _ = connector.send_input(&InputEvent { kind, _pad: [0; 3], code, x, y: 0, - flags: 0, // pad index 0 — single-pad model + flags: pad as u32, }); } @@ -603,28 +609,47 @@ impl Ds5Feedback { } } -struct Worker { - subsystem: sdl3::GamepadSubsystem, - /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. - pads_out: Arc>>, - active_out: Arc>>, - /// The ONE device held open — the active pad while a session is attached, `None` - /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the - /// hidraw device away from the system), so idle keeps this empty; see the module doc. - open: Option<(u32, sdl3::gamepad::Gamepad)>, - /// Connected pad ids in connection order (metadata only, no device open); the most - /// recently connected is the auto selection. - order: Vec, - /// Stable key of the user-pinned controller (persisted in Settings) — matched against - /// connected pads, so it survives restarts and disconnects. - pinned: Option, - attached: Option>, - /// Wire state of the active pad — zeroed on the wire at switch/detach. +/// Per-controller rumble render state (the Steam Deck keep-alive + the host's v2 lease). Held +/// per [`Slot`] so a rumble the host addressed to pad N drives only pad N's actuator. +#[derive(Default)] +struct RumbleState { + /// Last rumble value handed to this pad (the logical host value, pre-jitter) and when — + /// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. + last: (u16, u16), + last_at: Option, + /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a + /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). + jitter: bool, + /// The host lease from a v2 rumble envelope: last non-zero level expires at this instant + /// unless the host renews it. `None` outside a live rumble or against a legacy host (which + /// sends no lease — the pad then relies on SDL's own duration expiry as before). + deadline: Option, + /// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble` + /// duration; `0` = legacy host (fall back to the proven 1.5 s duration). + ttl_ms: u16, +} + +/// One forwarded controller during an attached session: the open SDL handle, its stable wire +/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback +/// state that used to be single-scalar on the Worker. Opening the device is what grabs the +/// hardware (SDL's HIDAPI drivers take the hidraw node from the system), so slots exist only +/// while a session is attached — idle/menu never populates them (see the module doc). +struct Slot { + /// SDL instance id (`ControllerDevice*::which`). + id: u32, + /// Wire pad index — stable for the life of the slot; assigned lowest-free on open so a + /// disconnect+replug of one pad never renumbers the others. + index: u8, + pad: sdl3::gamepad::Gamepad, + /// Resolved controller kind (captured at open) — selects the Deck rumble keep-alive and the + /// DualSense raw-effect feedback path without re-querying SDL metadata under a `&mut` borrow. + pref: GamepadPref, + /// Wire axis state — zeroed on the wire when this slot closes (detach / unplug). last_axis: [i32; 6], held_buttons: Vec, - /// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted on pad - /// switch / detach so a contact held at that moment doesn't stick. surface 0 = the legacy single - /// touchpad, 1/2 = a Steam left/right pad. + /// Touchpad contacts the host believes are down, keyed by `(surface, finger)` — lifted when + /// the slot closes so a contact held at that moment doesn't stick. surface 0 = the legacy + /// single touchpad, 1/2 = a Steam left/right pad. held_touches: std::collections::HashSet<(u8, u8)>, /// Per Steam-pad surface (index 0 = left/surface 1, 1 = right/surface 2): the last wire /// coordinates + whether a finger is on it. Pad CLICKS arrive as buttons with no position, @@ -632,14 +657,61 @@ struct Worker { surface_last: [(i16, i16, bool); 2], /// Steam-pad clicks currently held (surface−1 indexed): keeps the click bit asserted /// through touch-motion frames (which would otherwise clear it host-side) and lets the - /// flush lift a click held across detach/pad-switch. + /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], + rumble: RumbleState, +} + +impl Slot { + fn new(id: u32, index: u8, pref: GamepadPref, pad: sdl3::gamepad::Gamepad) -> Slot { + Slot { + id, + index, + pad, + pref, + last_axis: [i32::MIN; 6], + held_buttons: Vec::new(), + held_touches: std::collections::HashSet::new(), + surface_last: [(0, 0, false); 2], + held_clicks: [false; 2], + last_accel: [0; 3], + rumble: RumbleState::default(), + } + } + + /// This pad has two touchpads (Steam Deck / Steam Controller) — gates the `TouchpadEx` + /// surface encoding and the pad-click button re-route. + fn is_multi_touchpad(&self) -> bool { + self.pad.touchpads_count() >= 2 + } +} + +struct Worker { + subsystem: sdl3::GamepadSubsystem, + /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. + pads_out: Arc>>, + active_out: Arc>>, + /// The forwarded controllers held open while a session is attached — one [`Slot`] per + /// physical pad, each on its own wire index. Empty when idle/menu (opening grabs the + /// hardware; see the module doc). Populated by [`Worker::reconcile_slots`]. + slots: Vec, + /// The ONE device held open for menu navigation while menu mode is on and NO session is + /// attached (`active_id`); mutually exclusive with `slots` (a session supersedes the menu). + menu_open: Option<(u32, sdl3::gamepad::Gamepad)>, + /// Connected pad ids in connection order (metadata only, no device open); the most + /// recently connected is the auto selection. + order: Vec, + /// Stable key of the user-pinned controller (persisted in Settings) — matched against + /// connected pads, so it survives restarts and disconnects. A pin forwards ONLY that pad + /// (an explicit single-player choice); Automatic forwards every real controller. + pinned: Option, + attached: Option>, /// Raises the UI escape signal; the escape chord fires it once per press. escape_tx: async_channel::Sender<()>, /// Raises the UI disconnect signal when the escape chord is held past [`DISCONNECT_HOLD`]. disconnect_tx: async_channel::Sender<()>, - /// The escape chord is fully held — latched so it fires once, not every poll. + /// The escape chord is fully held (by any one forwarded pad) — latched so it fires once. chord_armed: bool, /// When the escape chord became fully held (drives the hold-to-disconnect escalation); `None` /// when the chord is broken. @@ -651,21 +723,6 @@ struct Worker { menu_mode: bool, menu_nav: MenuNav, menu_tx: async_channel::Sender, - /// Last rumble value handed to the active pad (the logical host value, pre-jitter) and - /// when — drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. - rumble_last: (u16, u16), - rumble_last_at: Option, - /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a - /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). - rumble_jitter: bool, - /// The host lease from a v2 rumble envelope: last non-zero level expires at this instant - /// unless the host renews it. `None` outside a live rumble or against a legacy host (which - /// sends no lease — the pad then relies on SDL's own duration expiry as before). - rumble_deadline: Option, - /// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble` - /// duration; `0` = legacy host (fall back to the proven 1.5 s duration). Read by - /// [`Worker::issue_rumble`]. - rumble_ttl_ms: u16, } impl Worker { @@ -724,122 +781,247 @@ impl Worker { }) } - /// Hold exactly the right device: the active pad while a session is attached or menu - /// mode owns navigation, nothing otherwise. The single place that decides to open - /// (= grab) hardware; dropping the old handle closes it (`SDL_CloseGamepad`) — on a - /// Deck the firmware watchdog then restores lizard mode. + /// The controllers to forward this session, in slot-assignment preference order. A pin + /// forwards ONLY the pinned pad (an explicit single-player choice — matched by stable key, + /// most-recent wins); Automatic forwards every real (non-Steam-virtual) controller, falling + /// back to the single most-recent pad when only a Steam-virtual pad is present (the Deck + /// game-mode case — otherwise its gyro/paddles/input would have nowhere to land). + fn forwarded_ids(&self) -> Vec { + if let Some(key) = &self.pinned { + if let Some(id) = self + .order + .iter() + .rev() + .copied() + .find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key)) + { + return vec![id]; + } + // A pin matching nothing connected falls through to Automatic (mirrors the old + // single-pad `active_id`, which never cleared an unmatched pin). + } + let real: Vec = self + .order + .iter() + .copied() + .filter(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual)) + .collect(); + if !real.is_empty() { + real + } else { + self.order.last().copied().into_iter().collect() + } + } + + /// Hold exactly the right devices open: a [`Slot`] per forwarded controller while a session + /// is attached, or the single menu pad while menu mode owns navigation, and nothing + /// otherwise. The one place that opens (= grabs) hardware; dropping a handle closes it + /// (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then restores lizard mode. fn sync_open(&mut self) { - let want = if self.attached.is_some() || self.menu_mode { + if self.attached.is_some() { + // A session forwards every pad; the menu never holds a device at the same time. + self.menu_open = None; + self.reconcile_slots(); + return; + } + // No session: close any forwarded slots, then (menu mode only) hold the one nav pad. + self.close_all_slots(); + let want = if self.menu_mode { self.active_id() } else { None }; - if self.open.as_ref().map(|(id, _)| *id) == want { + if self.menu_open.as_ref().map(|(id, _)| *id) == want { return; } - self.open = None; + self.menu_open = None; let Some(id) = want else { return }; match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { Ok(pad) => { - self.open = Some((id, pad)); - // Sensors stream only for an attached session (USB/BT bandwidth); the - // menu needs buttons + stick only. - if self.attached.is_some() { - self.set_sensors(true); - } else { - // The menu pad changed under us (hot-plug while the launcher is - // open): adopt the new pad's held state instead of firing it. - self.menu_nav.reset(); - } + self.menu_open = Some((id, pad)); + // The menu pad changed under us (hot-plug while the launcher is open): adopt the + // new pad's held state instead of firing it. Menu needs buttons + stick only, so + // no sensors. + self.menu_nav.reset(); } Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"), } } - /// React to anything that may have moved the active-pad selection (hotplug, pin - /// change): flush held wire state if it did, then re-sync the opened device and the - /// UI-facing snapshot. - fn refresh_active(&mut self, before: Option) { - if self.active_id() != before { - self.flush_held(); + /// Bring `self.slots` in line with [`forwarded_ids`](Self::forwarded_ids): close any slot no + /// longer wanted (flushing its held wire state first) and open any newly-wanted pad into the + /// lowest free wire index. Slot indices stay stable across the churn — a pad that disconnects + /// frees only its own index; the others keep theirs, so a game never sees its players shuffle. + fn reconcile_slots(&mut self) { + let want = self.forwarded_ids(); + let mut i = 0; + while i < self.slots.len() { + if want.contains(&self.slots[i].id) { + i += 1; + } else { + self.close_slot_at(i); + } } + for id in want { + if self.slots.iter().any(|s| s.id == id) { + continue; + } + self.open_slot(id); + } + } + + /// Open `id` into the lowest free wire index and enable its sensors for the session. Skipped + /// (logged) when every wire slot is taken or the SDL open fails. + fn open_slot(&mut self, id: u32) { + let taken: Vec = self.slots.iter().map(|s| s.index).collect(); + let Some(index) = lowest_free_index(&taken) else { + tracing::warn!( + id, + max = punktfunk_core::input::MAX_PADS, + "gamepad slots full — controller not forwarded" + ); + return; + }; + let pref = self + .pad_info(id) + .map(|p| p.pref) + .unwrap_or(GamepadPref::Xbox360); + match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { + Ok(pad) => { + let mut slot = Slot::new(id, index, pref, pad); + Self::set_slot_sensors(&mut slot, true); + // Declare this pad's kind BEFORE any of its input, so the host builds a matching + // virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core + // re-sends it a few times against datagram loss; an older host ignores it and + // uses the session-default kind. + if let Some(c) = &self.attached { + send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index); + } + tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)"); + self.slots.push(slot); + } + Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"), + } + } + + /// Flush a slot's held wire state (so nothing sticks down host-side) and drop it — closing + /// the SDL handle. The flush only emits wire events, so it is safe even when the device is + /// already gone (unplug). + fn close_slot_at(&mut self, i: usize) { + if let Some(c) = self.attached.clone() { + Self::flush_slot(&c, &mut self.slots[i]); + // Signal the host to tear down this pad's virtual device (native hot-unplug). Sent + // after the flush so the core stamps it with a seq past the zeroing snapshots; the + // host seq-gates it, so a reordered snapshot can't resurrect the removed pad. + send(&c, InputKind::GamepadRemove, 0, 0, self.slots[i].index); + } + let slot = self.slots.remove(i); + tracing::info!( + id = slot.id, + index = slot.index, + "gamepad forwarding stopped (slot closed)" + ); + } + + fn close_all_slots(&mut self) { + while !self.slots.is_empty() { + self.close_slot_at(0); + } + } + + /// Enable/disable a slot's motion sensors — they stream only while a session wants them + /// (they cost USB/BT bandwidth). Called once at open. + fn set_slot_sensors(slot: &mut Slot, enabled: bool) { + use sdl3::sensor::SensorType; + for s in [SensorType::Gyroscope, SensorType::Accelerometer] { + if unsafe { slot.pad.has_sensor(s) } { + let _ = slot.pad.sensor_set_enabled(s, enabled); + } + } + } + + /// Re-sync opened devices + the UI-facing snapshot after anything that may have moved the + /// forwarded set (hotplug, pin change). Slot flush-on-close is handled inside + /// [`reconcile_slots`](Self::reconcile_slots); a pad that held the escape chord may have just + /// unplugged, so re-arm it here. + fn refresh_active(&mut self) { self.sync_open(); + self.rearm_escape(); self.publish(); } - /// 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; - } - // Lift any Steam-pad click held at this moment — a click that survives a - // detach/pad-switch would leave the host's pad pressed forever. - for i in 0..2usize { - if std::mem::take(&mut self.held_clicks[i]) { - let (x, y, _) = self.surface_last[i]; - let _ = c.send_rich_input(RichInput::TouchpadEx { - pad: 0, - surface: (i as u8) + 1, - finger: 0, - touch: false, - click: false, - x, - y, - pressure: 0, - }); - } - } - self.surface_last = [(0, 0, false); 2]; - // Lift any touchpad contact the host still believes is down (surface 0 = legacy pad). - 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(); - self.held_clicks = [false; 2]; - self.surface_last = [(0, 0, false); 2]; + /// Zero everything the host believes is held for one slot — on slot close (detach / unplug). + /// Emits wire events only (no SDL device calls), so it is safe against an already-removed pad. + fn flush_slot(c: &NativeClient, slot: &mut Slot) { + let pad = slot.index; + for b in slot.held_buttons.drain(..) { + send(c, InputKind::GamepadButton, b, 0, pad); + } + for (id, v) in slot.last_axis.iter_mut().enumerate() { + if *v != 0 && *v != i32::MIN { + send(c, InputKind::GamepadAxis, id as u32, 0, pad); + } + *v = i32::MIN; + } + // Lift any Steam-pad click held at this moment — a click that survives a close would + // leave the host's pad pressed forever. + for i in 0..2usize { + if std::mem::take(&mut slot.held_clicks[i]) { + let (x, y, _) = slot.surface_last[i]; + let _ = c.send_rich_input(RichInput::TouchpadEx { + pad, + surface: (i as u8) + 1, + finger: 0, + touch: false, + click: false, + x, + y, + pressure: 0, + }); + } + } + slot.surface_last = [(0, 0, false); 2]; + // Lift any touchpad contact the host still believes is down (surface 0 = legacy pad). + for (surface, finger) in slot.held_touches.drain() { + let rich = if surface == 0 { + RichInput::Touchpad { + pad, + finger, + active: false, + x: 0, + y: 0, + } + } else { + RichInput::TouchpadEx { + pad, + surface, + finger, + touch: false, + click: false, + x: 0, + y: 0, + pressure: 0, + } + }; + let _ = c.send_rich_input(rich); } - // A held chord doesn't survive a flush (detach / pad-switch) — clear its latches too. - self.reset_chord(); } - /// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed (latched so it - /// fires once per press) and start the hold-to-disconnect timer. Called after each - /// button-down updates `held_buttons`. + /// True when any one forwarded pad holds the entire escape chord (any player can leave). + fn chord_held(&self) -> bool { + self.slots + .iter() + .any(|s| ESCAPE_CHORD.iter().all(|b| s.held_buttons.contains(b))) + } + + /// Raise the UI escape signal when the [`ESCAPE_CHORD`] just completed on some pad (latched + /// so it fires once per press) and start the hold-to-disconnect timer. Called after each + /// button-down updates a slot's `held_buttons`. fn maybe_fire_escape(&mut self) { if self.chord_armed { return; } - if ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { + if self.chord_held() { self.chord_armed = true; self.chord_since = Some(Instant::now()); let _ = self.escape_tx.try_send(()); @@ -864,53 +1046,40 @@ impl Worker { } } - /// Re-arm once the chord is broken (any of its buttons released). + /// Re-arm once the chord is broken (no pad still holds it — a release, or the holding pad + /// unplugged). fn rearm_escape(&mut self) { - if self.chord_armed && !ESCAPE_CHORD.iter().all(|b| self.held_buttons.contains(b)) { + if self.chord_armed && !self.chord_held() { self.reset_chord(); } } - /// Clear the escape/disconnect chord latches. Called at every session boundary - /// ([`flush_held`](Self::flush_held) on detach/pad-switch + on attach): the hold-to-disconnect - /// path *always* ends the session while the chord is still physically held, so the matching - /// button-up events arrive after detach (dropped by the `attached` guard) and `rearm_escape` - /// never runs — without this the latched state would leak into the next session and either - /// swallow its first chord press or fire a stale disconnect on connect. + /// Clear the escape/disconnect chord latches. Called at every session boundary (detach + on + /// attach): the hold-to-disconnect path *always* ends the session while the chord is still + /// physically held, so the matching button-up events arrive after detach (dropped once the + /// slots are gone) and `rearm_escape` never runs — without this the latched state would leak + /// into the next session and either swallow its first chord press or fire a stale disconnect. fn reset_chord(&mut self) { self.chord_armed = false; self.chord_since = None; self.disconnect_fired = false; } - /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). - fn set_sensors(&mut self, enabled: bool) { - if let Some((_, pad)) = self.open.as_mut() { - 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). + /// Forward one touchpad contact on the rich-input plane for `slot`. 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). Tagged with the slot's wire pad index. fn forward_touch( - &mut self, - which: u32, + c: &NativeClient, + slot: &mut Slot, touchpad: u32, finger: u8, x: f32, y: f32, active: bool, ) { - let Some(c) = self.attached.clone() else { - return; - }; - let multi = self.is_multi_touchpad(which); + let pad = slot.index; + let multi = slot.is_multi_touchpad(); 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 { @@ -919,22 +1088,22 @@ impl Worker { (cy * 65535.0 - 32768.0) as i16, ); let i = (surface - 1).min(1) as usize; - self.surface_last[i] = (wx, wy, active); + slot.surface_last[i] = (wx, wy, active); RichInput::TouchpadEx { - pad: 0, + pad, surface, finger, touch: active, // The pad's physical click is a separate BUTTON event (see forward_click) — // carry the held state so a motion frame can't clear a click mid-press. - click: self.held_clicks[i], + click: slot.held_clicks[i], x: wx, y: wy, pressure: 0, } } else { RichInput::Touchpad { - pad: 0, + pad, finger, active, x: (cx * 65535.0) as u16, @@ -943,31 +1112,21 @@ impl Worker { }; let _ = c.send_rich_input(rich); if active { - self.held_touches.insert((surface, finger)); + slot.held_touches.insert((surface, finger)); } else { - self.held_touches.remove(&(surface, finger)); + slot.held_touches.remove(&(surface, finger)); } } - /// The open pad has two touchpads (Steam Deck / Steam Controller) — the gate for the - /// `TouchpadEx` surface encoding and the pad-click button re-route. - fn is_multi_touchpad(&self, which: u32) -> bool { - self.open - .as_ref() - .filter(|(id, _)| *id == which) - .map(|(_, p)| p.touchpads_count() >= 2) - .unwrap_or(false) - } - /// SDL's Steam Deck mapping delivers the pad CLICKS as gamepad buttons — the generic /// `touchpad` button is the LEFT pad's click and `misc2` the RIGHT's (SDL_gamepad_db.h /// `touchpad:b17,misc2:b16`). They must NOT ride the button plane: it has no surface /// identity, and the host maps `BTN_TOUCHPAD` to the RIGHT pad (DualSense convention) — - /// which is exactly "a left-pad click registers on the right pad". Only for the open + /// which is exactly "a left-pad click registers on the right pad". Only for a /// multi-touchpad pad; a DualSense's single `touchpad` button stays a wire button. - fn steam_click_surface(&self, which: u32, button: sdl3::gamepad::Button) -> Option { + fn steam_click_surface(slot: &Slot, button: sdl3::gamepad::Button) -> Option { use sdl3::gamepad::Button; - if !self.is_multi_touchpad(which) { + if !slot.is_multi_touchpad() { return None; } match button { @@ -981,15 +1140,12 @@ impl Worker { /// no position, so reuse the surface's live contact point; a physical click implies /// contact, so `touch` stays asserted while the click is down even if the touch event /// hasn't arrived yet (event-order safety). - fn forward_click(&mut self, surface: u8, down: bool) { - let Some(c) = self.attached.clone() else { - return; - }; + fn forward_click(c: &NativeClient, slot: &mut Slot, surface: u8, down: bool) { let i = (surface - 1).min(1) as usize; - self.held_clicks[i] = down; - let (x, y, touching) = self.surface_last[i]; + slot.held_clicks[i] = down; + let (x, y, touching) = slot.surface_last[i]; let _ = c.send_rich_input(RichInput::TouchpadEx { - pad: 0, + pad: slot.index, surface, finger: 0, touch: touching || down, @@ -1019,18 +1175,20 @@ impl Worker { match ctl.try_recv() { Ok(Ctl::Attach(c)) => { self.attached = Some(c); - self.last_axis = [i32::MIN; 6]; self.reset_chord(); // every session starts un-latched (Attach doesn't flush) // The Valve HIDAPI drivers run only in-session (see set_valve_hidapi); // enabling them re-enumerates a Deck's built-in pad with paddles/ - // trackpads/gyro first-class — sync_open follows the churn events. + // trackpads/gyro first-class — sync_open opens a slot per forwarded pad. set_valve_hidapi(true); self.sync_open(); } Ok(Ctl::Detach) => { - self.flush_held(); + // Flush + close every forwarded slot while the connector is still live, so + // nothing stays held host-side, then drop the session. + self.close_all_slots(); self.attached = None; - self.sync_open(); // closes the held device (menu mode keeps it) + self.reset_chord(); + self.sync_open(); // opens the menu pad if menu mode, else nothing set_valve_hidapi(false); if self.menu_mode { // Back to the launcher: adopt whatever is still physically held @@ -1040,9 +1198,8 @@ impl Worker { } } Ok(Ctl::Pin(key)) => { - let before = self.active_id(); self.pinned = key; - self.refresh_active(before); + self.refresh_active(); } Ok(Ctl::MenuMode(on)) => { self.menu_mode = on; @@ -1053,7 +1210,7 @@ impl Worker { } Ok(Ctl::MenuRumble(pulse)) => { if self.attached.is_none() { - if let Some((_, pad)) = self.open.as_mut() { + if let Some((_, pad)) = self.menu_open.as_mut() { let (low, high, ms) = match pulse { // Light high-freq detent — won't jackhammer at repeat rate. MenuPulse::Move => (0, 0x3000, 25), @@ -1073,10 +1230,12 @@ impl Worker { } /// Route one SDL event: pad hotplug bookkeeping, and — while a session is attached — - /// buttons/axes/touchpads/motion of the active pad onto the wire. + /// buttons/axes/touchpads/motion of each forwarded pad onto the wire, tagged with the + /// pad's own [`Slot::index`]. An event for a controller with no slot (not forwarded) is + /// ignored; slots exist only during an attached session, so the slot lookup also gates + /// "is a session live". fn handle_event(&mut self, event: sdl3::event::Event) { use sdl3::event::Event; - let active = self.active_id(); match event { Event::ControllerDeviceAdded { which, .. } => { if !self.order.contains(&which) { @@ -1093,57 +1252,65 @@ impl Worker { "gamepad attached" ); } - self.refresh_active(active); + self.refresh_active(); } } Event::ControllerDeviceRemoved { which, .. } => { if self.order.contains(&which) { self.order.retain(|&id| id != which); - if self.open.as_ref().map(|(id, _)| *id) == Some(which) { - self.open = None; // the device is gone; drop our handle - } tracing::info!("gamepad detached"); - self.refresh_active(active); + // refresh_active → reconcile_slots closes (and flushes) this pad's slot; + // the flush emits wire-only events, safe against the now-gone device. + self.refresh_active(); } } - Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { - if let Some(surface) = self.steam_click_surface(which, button) { - self.forward_click(surface, true); - return; - } + Event::ControllerButtonDown { which, button, .. } => { let Some(c) = self.attached.clone() else { return; }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; + if let Some(surface) = Self::steam_click_surface(slot, button) { + Self::forward_click(&c, slot, surface, true); + return; + } if let Some(bit) = button_bit(button) { - self.held_buttons.push(bit); - send(&c, InputKind::GamepadButton, bit, 1); + slot.held_buttons.push(bit); + send(&c, InputKind::GamepadButton, bit, 1, slot.index); self.maybe_fire_escape(); } } - Event::ControllerButtonUp { which, button, .. } if active == Some(which) => { - if let Some(surface) = self.steam_click_surface(which, button) { - self.forward_click(surface, false); - return; - } + Event::ControllerButtonUp { which, button, .. } => { let Some(c) = self.attached.clone() else { return; }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; + if let Some(surface) = Self::steam_click_surface(slot, button) { + Self::forward_click(&c, slot, surface, false); + return; + } if let Some(bit) = button_bit(button) { - self.held_buttons.retain(|&b| b != bit); - send(&c, InputKind::GamepadButton, bit, 0); + slot.held_buttons.retain(|&b| b != bit); + send(&c, InputKind::GamepadButton, bit, 0, slot.index); self.rearm_escape(); } } Event::ControllerAxisMotion { which, axis, value, .. - } if active == Some(which) => { + } => { let Some(c) = self.attached.clone() else { return; }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; let (id, v) = axis_value(axis, value); - if self.last_axis[id as usize] != v { - self.last_axis[id as usize] = v; - send(&c, InputKind::GamepadAxis, id, v); + if slot.last_axis[id as usize] != v { + slot.last_axis[id as usize] = v; + send(&c, InputKind::GamepadAxis, id, v, slot.index); } } // Touchpad contacts → the rich-input plane. One pad (DualSense) keeps the legacy @@ -1163,8 +1330,14 @@ impl Worker { x, y, .. - } if active == Some(which) && self.attached.is_some() => { - self.forward_touch(which, touchpad as u32, finger as u8, x, y, true); + } => { + let Some(c) = self.attached.clone() else { + return; + }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; + Self::forward_touch(&c, slot, touchpad as u32, finger as u8, x, y, true); } Event::ControllerTouchpadUp { which, @@ -1173,8 +1346,14 @@ impl Worker { x, y, .. - } if active == Some(which) && self.attached.is_some() => { - self.forward_touch(which, touchpad as u32, finger as u8, x, y, false); + } => { + let Some(c) = self.attached.clone() else { + return; + }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; + Self::forward_touch(&c, slot, 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 @@ -1184,15 +1363,18 @@ impl Worker { sensor, data, .. - } if active == Some(which) => { + } => { let Some(c) = self.attached.clone() else { return; }; + let Some(slot) = self.slots.iter_mut().find(|s| s.id == which) else { + return; + }; use sdl3::sensor::SensorType; match sensor { SensorType::Accelerometer => { for (i, v) in data.iter().enumerate() { - self.last_accel[i] = + slot.last_accel[i] = (v / G * ACCEL_LSB_PER_G).clamp(-32768.0, 32767.0) as i16; } } @@ -1202,9 +1384,9 @@ impl Worker { gyro[i] = (v * GYRO_LSB_PER_RAD_S).clamp(-32768.0, 32767.0) as i16; } let _ = c.send_rich_input(RichInput::Motion { - pad: 0, + pad: slot.index, gyro, - accel: self.last_accel, + accel: slot.last_accel, }); } _ => {} @@ -1221,7 +1403,7 @@ impl Worker { if !self.menu_mode || self.attached.is_some() { return; } - let Some((_, pad)) = self.open.as_ref() else { + let Some((_, pad)) = self.menu_open.as_ref() else { return; }; use sdl3::gamepad::{Axis, Button}; @@ -1250,128 +1432,117 @@ impl Worker { } } - /// Hand a rumble value to SDL on the active pad, remembering it for the Deck keep-alive. + /// Hand a rumble value to SDL on one slot's pad, remembering it for the Deck keep-alive. /// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its /// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single /// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the /// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously /// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one - /// self-silences at the TTL); against a legacy host (`rumble_ttl_ms == 0`) it stays the proven - /// 1.5 s. - fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) { - let dur_ms: u32 = if self.rumble_ttl_ms == 0 { + /// self-silences at the TTL); against a legacy host (`ttl_ms == 0`) it stays the proven 1.5 s. + fn issue_rumble(slot: &mut Slot, low: u16, high: u16, deck: bool) { + let dur_ms: u32 = if slot.rumble.ttl_ms == 0 { 1_500 // legacy host: no lease — keep the proven duration } else { // Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the // actuator between SDL writes. - (self.rumble_ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4) + (slot.rumble.ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4) }; let (out_low, out_high) = - if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) { - self.rumble_jitter = !self.rumble_jitter; - (low ^ self.rumble_jitter as u16, high) + if deck && (low, high) == slot.rumble.last && (low, high) != (0, 0) { + slot.rumble.jitter = !slot.rumble.jitter; + (low ^ slot.rumble.jitter as u16, high) } else { (low, high) }; - match self - .open - .as_mut() - .map(|(_, p)| p.set_rumble(out_low, out_high, dur_ms)) - { - // 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. - Some(Err(e)) => tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"), - Some(Ok(())) => tracing::debug!(low, high, "rumble: rendered"), - None => tracing::debug!(low, high, "rumble: received but no active pad to render"), + // 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 with the pad index, so the two together pinpoint host-game vs client-render. + match slot.pad.set_rumble(out_low, out_high, dur_ms) { + Err(e) => { + tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed") + } + Ok(()) => tracing::debug!(pad = slot.index, low, high, "rumble: rendered"), } - self.rumble_last = (low, high); - self.rumble_last_at = Some(Instant::now()); + slot.rumble.last = (low, high); + slot.rumble.last_at = Some(Instant::now()); } - /// Drain and render the feedback planes — rumble plus HID output (lightbar / - /// player LEDs / adaptive triggers) — on the active pad; this thread is their single - /// consumer. Rumble arrives as self-terminating v2 envelopes: each carries a TTL the host - /// renews while the level holds and lets expire when it stops, so the actuator's divergence - /// from the host's intent is bounded by the wire, not by a client guess. A legacy host - /// (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration expiry as - /// before. + /// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs / + /// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this + /// thread is their single consumer. Rumble arrives as self-terminating v2 envelopes: each + /// carries a TTL the host renews while the level holds and lets expire when it stops, so the + /// actuator's divergence from the host's intent is bounded by the wire, not by a client guess. + /// A legacy host (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration + /// expiry as before. fn render_feedback(&mut self) { let Some(connector) = self.attached.clone() else { return; }; - // The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble - // resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a - // steady host value is felt as a periodic pulse rather than a continuous buzz. Detect the - // Deck pad here and keep it fed below the decay (`DECK_RUMBLE_KEEPALIVE_MS`) — an actuator - // limitation no wire lease can fix — but bound the re-kick by the host's TTL so it can no - // longer sustain a value the host has stopped renewing. Every other pad sustains (and - // expires) at the SDL/hardware level. - let deck = self - .open - .as_ref() - .and_then(|(id, _)| self.pad_info(*id)) - .is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck)); - let mut fresh = false; + // Rumble envelopes (0xCA) → the slot holding that wire pad index. An update for an index + // with no live slot (a pad that just unplugged) is dropped. while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) { - if pad == 0 { - fresh = true; - self.rumble_ttl_ms = ttl.unwrap_or(0); + if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == pad) { + let deck = slot.pref == GamepadPref::SteamDeck; + slot.rumble.ttl_ms = ttl.unwrap_or(0); // A v2 lease sets an explicit client-side deadline; a legacy update clears it and // leans on SDL's own duration expiry (unchanged behaviour). - self.rumble_deadline = match ttl { + slot.rumble.deadline = match ttl { Some(ms) if (low, high) != (0, 0) => { Some(Instant::now() + Duration::from_millis(ms as u64)) } _ => None, }; - self.issue_rumble(low, high, deck); + Self::issue_rumble(slot, low, high, deck); } } - // Deck keep-alive: no fresh datagram this tick but a non-zero value is latched. If the - // host lease has expired, silence the actuator (the host stopped renewing — the stop - // datagram was lost, or the host died); otherwise re-kick it so its discrete haptic bursts - // fuse into a continuous buzz. A legacy update leaves `rumble_deadline` None, so the - // re-kick behaves exactly as before (SDL's duration is the only backstop). Non-Deck pads - // never enter here (`deck` is false). - if deck && !fresh && self.rumble_last != (0, 0) { - if self.rumble_deadline.is_some_and(|d| Instant::now() >= d) { - self.rumble_deadline = None; - self.rumble_ttl_ms = 0; - self.issue_rumble(0, 0, deck); - } else if self - .rumble_last_at + // Steam Deck keep-alive, per slot: the built-in actuator decays inside SDL's ~2 s internal + // rumble resend, and SDL dedupes an unchanged `set_rumble` to a no-op device write — so a + // steady host value is felt as a periodic pulse. Re-kick a Deck slot below the decay + // (`DECK_RUMBLE_KEEPALIVE_MS`) so its discrete bursts fuse into a continuous buzz, but + // silence it once the host's lease expires (the host stopped renewing — a lost stop, or the + // host died). The per-slot timing guards make this idempotent with a fresh datagram this + // tick (a just-set `last_at`/`deadline` fails both checks). Non-Deck slots sustain/expire at + // the SDL/hardware level and never enter here. + for slot in self.slots.iter_mut() { + if slot.pref != GamepadPref::SteamDeck || slot.rumble.last == (0, 0) { + continue; + } + if slot.rumble.deadline.is_some_and(|d| Instant::now() >= d) { + slot.rumble.deadline = None; + slot.rumble.ttl_ms = 0; + Self::issue_rumble(slot, 0, 0, true); + } else if slot + .rumble + .last_at .is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS)) { - let (low, high) = self.rumble_last; - self.issue_rumble(low, high, deck); + let (low, high) = slot.rumble.last; + Self::issue_rumble(slot, low, high, true); } } + // HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index. while let Ok(hid) = connector.next_hidout(Duration::ZERO) { - let is_ds = self - .open - .as_ref() - .and_then(|(id, _)| self.pad_info(*id)) - .is_some_and(|p| p.is_dualsense()); - let Some((_, pad)) = self.open.as_mut() else { + let idx = hidout_pad(&hid); + let Some(slot) = self.slots.iter_mut().find(|s| s.index == idx) else { continue; }; + let is_ds = slot.pref == GamepadPref::DualSense; match hid { - HidOutput::Led { pad: 0, r, g, b } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); + HidOutput::Led { r, g, b, .. } if is_ds => { + let _ = slot.pad.send_effect(&Ds5Feedback::lightbar_packet(r, g, b)); } - HidOutput::Led { pad: 0, r, g, b } => { - let _ = pad.set_led(r, g, b); + HidOutput::Led { r, g, b, .. } => { + let _ = slot.pad.set_led(r, g, b); } - HidOutput::PlayerLeds { pad: 0, bits } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::player_packet(bits)); + HidOutput::PlayerLeds { bits, .. } if is_ds => { + let _ = slot.pad.send_effect(&Ds5Feedback::player_packet(bits)); } HidOutput::Trigger { - pad: 0, - which, - ref effect, + which, ref effect, .. } if is_ds => { - let _ = pad.send_effect(&Ds5Feedback::trigger_packet(which, effect)); + let _ = slot + .pad + .send_effect(&Ds5Feedback::trigger_packet(which, effect)); } _ => {} } @@ -1379,6 +1550,16 @@ impl Worker { } } +/// The wire pad index a [`HidOutput`] is addressed to (every variant carries `pad`). +fn hidout_pad(h: &HidOutput) -> u8 { + match h { + HidOutput::Led { pad, .. } + | HidOutput::PlayerLeds { pad, .. } + | HidOutput::Trigger { pad, .. } + | HidOutput::TrackpadHaptic { pad, .. } => *pad, + } +} + impl Worker { /// The blank worker over an SDL gamepad subsystem — shared by the threaded service /// (`run`) and the caller-pumped variant (`GamepadService::pumped`). @@ -1394,16 +1575,11 @@ impl Worker { subsystem, pads_out, active_out, - open: None, + slots: Vec::new(), + menu_open: None, order: Vec::new(), pinned: None, attached: None, - last_axis: [i32::MIN; 6], - held_buttons: Vec::new(), - held_touches: std::collections::HashSet::new(), - surface_last: [(0, 0, false); 2], - held_clicks: [false; 2], - last_accel: [0; 3], escape_tx, disconnect_tx, chord_armed: false, @@ -1412,11 +1588,6 @@ impl Worker { menu_mode: false, menu_nav: MenuNav::new(), menu_tx, - rumble_last: (0, 0), - rumble_last_at: None, - rumble_jitter: false, - rumble_deadline: None, - rumble_ttl_ms: 0, } } } @@ -1647,3 +1818,61 @@ mod menu_nav_tests { ); } } + +#[cfg(test)] +mod slot_tests { + use super::*; + + #[test] + fn lowest_free_index_fills_gaps_and_bounds() { + // Empty: first pad is player 1 (index 0). + assert_eq!(lowest_free_index(&[]), Some(0)); + // Sequential occupancy hands out the next index. + assert_eq!(lowest_free_index(&[0]), Some(1)); + assert_eq!(lowest_free_index(&[0, 1, 2]), Some(3)); + // A freed middle index is reused before growing — the stable-index property: pad 0 and + // pad 2 stay put when pad 1 unplugs, and a re-plug reclaims slot 1 (not slot 3). + assert_eq!(lowest_free_index(&[0, 2]), Some(1)); + // Order-independent. + assert_eq!(lowest_free_index(&[2, 0]), Some(1)); + // Full: every wire index taken → no slot. + let all: Vec = (0..punktfunk_core::input::MAX_PADS as u8).collect(); + assert_eq!(lowest_free_index(&all), None); + // One free near the top is still found. + let mut but_seven = all.clone(); + but_seven.retain(|&i| i != 7); + assert_eq!(lowest_free_index(&but_seven), Some(7)); + } + + #[test] + fn hidout_pad_reads_every_variant() { + assert_eq!( + hidout_pad(&HidOutput::Led { + pad: 3, + r: 1, + g: 2, + b: 3 + }), + 3 + ); + assert_eq!(hidout_pad(&HidOutput::PlayerLeds { pad: 5, bits: 1 }), 5); + assert_eq!( + hidout_pad(&HidOutput::Trigger { + pad: 2, + which: 0, + effect: vec![1, 2, 3] + }), + 2 + ); + assert_eq!( + hidout_pad(&HidOutput::TrackpadHaptic { + pad: 4, + side: 0, + amplitude: 1, + period: 2, + count: 3 + }), + 4 + ); + } +} diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 82a170c2..59631ea4 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -1573,6 +1573,23 @@ async fn worker_main(args: WorkerArgs) { // Touched pads only: an entry appears on the first gamepad event for that index, so the // refresh never conjures a virtual pad the embedder didn't drive. let mut pads: [Option; MAX_PADS] = [None; MAX_PADS]; + // Per-pad wrapping seq that PERSISTS across a pad's remove/re-add on the same index (the + // snapshot itself is cleared to `None` on removal). A removal takes `seq[idx] + 1` so it + // supersedes every prior snapshot; the re-added pad's first snapshot takes the next value + // after that, so the host's seq gate accepts it instead of rejecting a restarted-at-0 seq. + let mut seq: [u8; MAX_PADS] = [0; MAX_PADS]; + // Re-sends of a removal still owed on refresh ticks (the removal rides the lossy datagram + // plane; a single lost one would silently strand a ghost pad on the host — the exact bug + // the removal fixes). Mirrors the host's rumble stop burst: a few time-spread re-sends, + // each with a fresh (higher) seq, and canceled the moment the pad is driven again. + const REMOVE_RESENDS: u8 = 2; + let mut remove_owed: [u8; MAX_PADS] = [0; MAX_PADS]; + // Per-pad declared controller kind ([`GamepadArrival`]) + its owed re-sends: the host needs + // the kind before the pad's first frame to build a matching virtual device (mixed types), so + // like the removal it rides the lossy plane with a small time-spread re-send burst. + const ARRIVAL_RESENDS: u8 = 2; + let mut arrival: [Option; MAX_PADS] = [None; MAX_PADS]; + let mut arrival_owed: [u8; MAX_PADS] = [0; MAX_PADS]; let mut refresh = tokio::time::interval(Duration::from_millis(100)); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { @@ -1584,24 +1601,89 @@ async fn worker_main(args: WorkerArgs) { && matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis) && idx < MAX_PADS { + // The pad is being driven — cancel any owed removal (a re-plug on this + // index; its fresh snapshot seq already supersedes the removal's). + remove_owed[idx] = 0; let snap = pads[idx].get_or_insert(GamepadSnapshot { pad: idx as u8, ..Default::default() }); // Unknown axis ids don't send (the host's legacy fold drops them too). if snap.fold(&ev) { - snap.seq = snap.seq.wrapping_add(1); + seq[idx] = seq[idx].wrapping_add(1); + snap.seq = seq[idx]; let _ = input_conn .send_datagram(snap.to_event().encode().to_vec().into()); } continue; } + if gamepad_snapshots && ev.kind == InputKind::GamepadRemove && idx < MAX_PADS { + // Stop refreshing the pad and forward a seq-stamped removal (in the shared + // seq space) so the host tears its virtual device down and no reordered + // snapshot can resurrect it; arm the re-send burst against datagram loss. + // Drop any owed kind declaration too — a re-plug on this index sends its own. + pads[idx] = None; + arrival[idx] = None; + arrival_owed[idx] = 0; + seq[idx] = seq[idx].wrapping_add(1); + remove_owed[idx] = REMOVE_RESENDS; + let rem = crate::input::InputEvent { + flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]), + ..ev + }; + let _ = input_conn.send_datagram(rem.encode().to_vec().into()); + continue; + } + if gamepad_snapshots && ev.kind == InputKind::GamepadArrival && idx < MAX_PADS { + // Remember the declared kind (`code`) and forward it, arming a re-send burst + // so the host learns it before the pad's first frame even under loss. + arrival[idx] = Some(ev.code as u8); + arrival_owed[idx] = ARRIVAL_RESENDS; + let _ = input_conn.send_datagram(ev.encode().to_vec().into()); + continue; + } let _ = input_conn.send_datagram(ev.encode().to_vec().into()); } _ = refresh.tick() => { - for snap in pads.iter_mut().flatten() { - snap.seq = snap.seq.wrapping_add(1); - let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into()); + for idx in 0..MAX_PADS { + // Re-send an owed kind declaration (independent of whether the pad has state + // yet — it may be idle-but-connected). Idempotent on the host. + if arrival_owed[idx] > 0 { + if let Some(kind) = arrival[idx] { + arrival_owed[idx] -= 1; + let arr = crate::input::InputEvent { + kind: InputKind::GamepadArrival, + _pad: [0; 3], + code: kind as u32, + x: 0, + y: 0, + flags: idx as u32, + }; + let _ = input_conn.send_datagram(arr.encode().to_vec().into()); + } else { + arrival_owed[idx] = 0; + } + } + if let Some(snap) = pads[idx].as_mut() { + seq[idx] = seq[idx].wrapping_add(1); + snap.seq = seq[idx]; + let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into()); + } else if remove_owed[idx] > 0 { + // Idempotent removal re-send with a fresh seq (the host drops it as a + // no-op once the pad is already gone, but a re-plug's later snapshot + // still wins by seq). + remove_owed[idx] -= 1; + seq[idx] = seq[idx].wrapping_add(1); + let rem = crate::input::InputEvent { + kind: InputKind::GamepadRemove, + _pad: [0; 3], + code: 0, + x: 0, + y: 0, + flags: crate::input::encode_gamepad_remove(idx as u8, seq[idx]), + }; + let _ = input_conn.send_datagram(rem.encode().to_vec().into()); + } } } } diff --git a/crates/punktfunk-core/src/input.rs b/crates/punktfunk-core/src/input.rs index 1b57634d..a38b61d7 100644 --- a/crates/punktfunk-core/src/input.rs +++ b/crates/punktfunk-core/src/input.rs @@ -51,6 +51,39 @@ pub enum InputKind { /// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep /// receiving the per-transition events. GamepadState = 12, + /// A pad was unplugged client-side (the native plane's answer to GameStream's + /// `activeGamepadMask`, which the per-transition/snapshot planes otherwise lack — see + /// [`encode_gamepad_remove`]). `flags` packs `seq << 24 | pad`: the low byte is the pad + /// index, the high byte a per-pad wrapping seq sharing the [`GamepadSnapshot`] sequence + /// space. The host clears the pad's `active_mask` bit so its virtual device is torn down, + /// seq-gated against snapshots so one the network reordered past the removal can't resurrect + /// the pad, and the shared seq space keeps the same index reusable by a later re-plug. Sent + /// only to a host that advertised [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); + /// an older host ignores the unknown tag (the pad then lingers until session end — the + /// pre-existing behaviour). + GamepadRemove = 13, + /// Declares which controller KIND a pad presents so a session can MIX types (pad 0 a + /// DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref) + /// wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's + /// first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The + /// host resolves the kind to a buildable backend and routes that pad's virtual device to it; a + /// pad the client never declares (an older client, or a fully-lost declaration) falls back to + /// the session-default kind from the handshake. Idempotent (no seq): re-declaring the same kind + /// is a no-op. Meaningful only to a host that advertised + /// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); an older host ignores the + /// unknown tag (every pad then uses the session-default kind — the pre-existing behaviour). + GamepadArrival = 14, +} + +/// Pack a [`InputKind::GamepadRemove`] `flags` word (`seq << 24 | pad`) — the same low-byte-pad / +/// high-byte-seq layout as [`GamepadSnapshot::to_event`], so a removal seq-gates against snapshots. +pub fn encode_gamepad_remove(pad: u8, seq: u8) -> u32 { + ((seq as u32) << 24) | (pad as u32) +} + +/// Unpack a [`InputKind::GamepadRemove`] `flags` word into `(pad, seq)`. +pub fn decode_gamepad_remove(flags: u32) -> (u8, u8) { + (flags as u8, (flags >> 24) as u8) } /// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`]. @@ -123,6 +156,8 @@ impl InputKind { 10 => TouchMove, 11 => TouchUp, 12 => GamepadState, + 13 => GamepadRemove, + 14 => GamepadArrival, _ => return None, }) } @@ -321,8 +356,26 @@ mod tests { }; assert_eq!(InputEvent::decode(&e.encode()), Some(e)); } - // 13 (one past GamepadState) is not a valid kind. - assert_eq!(InputKind::from_u8(13), None); + // GamepadRemove + GamepadArrival are valid kinds; 15 (one past them) is not. + assert_eq!(InputKind::from_u8(13), Some(InputKind::GamepadRemove)); + assert_eq!(InputKind::from_u8(14), Some(InputKind::GamepadArrival)); + assert_eq!(InputKind::from_u8(15), None); + } + + #[test] + fn gamepad_remove_flags_roundtrip() { + for (pad, seq) in [(0u8, 0u8), (3, 200), (15, 255), (7, 1)] { + let flags = encode_gamepad_remove(pad, seq); + assert_eq!(decode_gamepad_remove(flags), (pad, seq)); + } + // Layout matches the snapshot's pad/seq packing (low byte pad, high byte seq). + let snap = GamepadSnapshot { + pad: 9, + seq: 123, + ..Default::default() + }; + let (pad, seq) = decode_gamepad_remove(snap.to_event().flags); + assert_eq!((pad, seq), (9, 123)); } #[test] diff --git a/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs index bd6ef249..d2b5a641 100644 --- a/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs +++ b/crates/punktfunk-host/src/inject/linux/kwin_fake_input.rs @@ -425,7 +425,11 @@ impl InputInjector for KwinFakeInjector { self.fake.touch_frame(); } // Gamepads are injected through uinput, not the compositor. - InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} + InputKind::GamepadState + | InputKind::GamepadButton + | InputKind::GamepadAxis + | InputKind::GamepadRemove + | InputKind::GamepadArrival => {} } // Surface protocol errors / disconnects, then push the batch to the compositor. self.queue diff --git a/crates/punktfunk-host/src/inject/linux/libei.rs b/crates/punktfunk-host/src/inject/linux/libei.rs index 097234fb..dc037437 100644 --- a/crates/punktfunk-host/src/inject/linux/libei.rs +++ b/crates/punktfunk-host/src/inject/linux/libei.rs @@ -404,6 +404,8 @@ fn kind_bit(kind: InputKind) -> u32 { InputKind::GamepadButton => 10, InputKind::GamepadAxis => 11, InputKind::GamepadState => 12, + InputKind::GamepadRemove => 13, + InputKind::GamepadArrival => 14, }; 1 << i } @@ -546,7 +548,11 @@ impl EiState { InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => { DeviceCapability::Touch } - InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later) + InputKind::GamepadState + | InputKind::GamepadButton + | InputKind::GamepadAxis + | InputKind::GamepadRemove + | InputKind::GamepadArrival => return, // uinput path (later) }; self.injected += 1; let n = self.injected; @@ -693,9 +699,11 @@ impl EiState { Some(t) => t.up(ev.code), None => emitted = false, }, - InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => { - emitted = false - } + InputKind::GamepadState + | InputKind::GamepadButton + | InputKind::GamepadAxis + | InputKind::GamepadRemove + | InputKind::GamepadArrival => emitted = false, } if emitted { diff --git a/crates/punktfunk-host/src/inject/linux/wlr.rs b/crates/punktfunk-host/src/inject/linux/wlr.rs index e6a44278..7e66fe01 100644 --- a/crates/punktfunk-host/src/inject/linux/wlr.rs +++ b/crates/punktfunk-host/src/inject/linux/wlr.rs @@ -254,7 +254,11 @@ impl InputInjector for WlrootsInjector { tracing::debug!(vk = event.code, "unmapped VK keycode — dropped"); } } - InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected + InputKind::GamepadState + | InputKind::GamepadButton + | InputKind::GamepadAxis + | InputKind::GamepadRemove + | InputKind::GamepadArrival => {} // not yet injected // wlroots has no virtual-touch protocol wired here; touch is the libei path only. InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {} } diff --git a/crates/punktfunk-host/src/inject/windows/sendinput.rs b/crates/punktfunk-host/src/inject/windows/sendinput.rs index 0113e46d..6caaa152 100644 --- a/crates/punktfunk-host/src/inject/windows/sendinput.rs +++ b/crates/punktfunk-host/src/inject/windows/sendinput.rs @@ -301,6 +301,8 @@ impl InputInjector for SendInputInjector { InputKind::GamepadButton | InputKind::GamepadAxis | InputKind::GamepadState + | InputKind::GamepadRemove + | InputKind::GamepadArrival | InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => Ok(()), diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index c7e42932..04a8e48b 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1740,166 +1740,315 @@ const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS; /// virtual mic has its own tuning — see [`crate::audio::MicPump`].) const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); -/// The session's virtual-gamepad backend, resolved once per session (sessions run serially). +/// Per-pad virtual-gamepad router: each pad index is served by a backend of that pad's declared +/// kind ([`InputKind::GamepadArrival`](punktfunk_core::input::InputKind::GamepadArrival)), so ONE +/// session can MIX controller types — pad 0 a DualSense, pad 1 an Xbox pad. A pad the client never +/// declares uses `default` (the session kind resolved from the Hello — the pre-existing single-kind +/// behaviour). /// -/// - `Xbox360` — uinput X-Box-360 pads on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager)), -/// the in-tree XUSB companion driver (classic XInput) on Windows. Also the X-Box One/Series identity -/// (`PUNKTFUNK_GAMEPAD=xboxone`): the same -/// backend with the One/Series USB VID/PID so games show One/Series glyphs (XInput-identical -/// otherwise). The Linux pad carries it as a [`PadIdentity`](crate::inject::gamepad::PadIdentity). -/// - `DualSense` (`PUNKTFUNK_GAMEPAD=dualsense`) — virtual DualSense via UHID + `hid-playstation`, -/// so a game sees a *real* DualSense (adaptive triggers, lightbar, touchpad, motion); feedback -/// flows back over the rich HID-output plane. -/// - `DualShock4` (`PUNKTFUNK_GAMEPAD=ps4`) — virtual DualShock 4 via the same UHID path: lightbar, -/// touchpad, motion, rumble (DualSense minus adaptive triggers / player LEDs / mute). +/// Backends are created lazily per kind (an empty manager holds no device), and each owns only the +/// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers +/// because an index another manager owns is `None` in this one, so the sweep never touches it. /// -/// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF -/// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a -/// build never constructs a variant it lacks. -enum PadBackend { - Xbox360(crate::inject::gamepad::GamepadManager), +/// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager), +/// two identities), the XUSB companion driver (classic XInput) on Windows. +/// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver. +/// - Steam Deck — Linux UHID `hid-steam`. +/// +/// [`resolve_pad_kind`] folds any kind a platform can't build into one it can, so this never +/// constructs a manager the build lacks. +struct Pads { + /// Declared (and host-resolved) kind per pad index; `default` until a `GamepadArrival` lands. + kinds: [GamepadPref; MAX_WIRE_PADS], + /// The kind of the manager that currently OWNS a built device at each index (`None` = no + /// device). A live device stays in its manager even if `kinds[idx]` later changes (the rare + /// arrival-after-first-frame reorder), so a pad is never duplicated across managers and its + /// removal always reaches the manager that actually holds it. + owner: [Option; MAX_WIRE_PADS], + xbox360: Option, #[cfg(target_os = "linux")] - DualSense(crate::inject::dualsense::DualSenseManager), + xboxone: Option, #[cfg(target_os = "linux")] - DualShock4(crate::inject::dualshock4::DualShock4Manager), + dualsense: Option, #[cfg(target_os = "linux")] - SteamDeck(crate::inject::steam_controller::SteamControllerManager), + dualshock4: Option, + #[cfg(target_os = "linux")] + steamdeck: Option, #[cfg(target_os = "windows")] - DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), + dualsense_win: Option, #[cfg(target_os = "windows")] - DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager), + dualshock4_win: Option, } -impl PadBackend { - /// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference, - /// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever - /// construct the X-Box backend, whatever the resolution said. - fn select(kind: GamepadPref) -> PadBackend { - #[cfg(target_os = "linux")] - match kind { - GamepadPref::DualSense => { - tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); - return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); - } - GamepadPref::DualShock4 => { - tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)"); - return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new()); - } - GamepadPref::SteamDeck => { - tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)"); - return PadBackend::SteamDeck( - crate::inject::steam_controller::SteamControllerManager::new(), - ); - } - GamepadPref::XboxOne => { - tracing::info!("gamepad backend: uinput X-Box One/Series pad"); - return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity( - crate::inject::gamepad::PadIdentity::xbox_one(), - )); - } - _ => {} +impl Pads { + /// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the + /// client declares its own kind. + fn new(default: GamepadPref) -> Pads { + let default = resolve_pad_kind(default); + tracing::info!( + default = default.as_str(), + "gamepad backends: per-pad router (session default)" + ); + Pads { + kinds: [default; MAX_WIRE_PADS], + owner: [None; MAX_WIRE_PADS], + xbox360: None, + #[cfg(target_os = "linux")] + xboxone: None, + #[cfg(target_os = "linux")] + dualsense: None, + #[cfg(target_os = "linux")] + dualshock4: None, + #[cfg(target_os = "linux")] + steamdeck: None, + #[cfg(target_os = "windows")] + dualsense_win: None, + #[cfg(target_os = "windows")] + dualshock4_win: None, } - #[cfg(target_os = "windows")] - match kind { - GamepadPref::DualSense => { - tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)"); - return PadBackend::DualSenseWindows( - crate::inject::dualsense_windows::DualSenseWindowsManager::new(), - ); - } - GamepadPref::DualShock4 => { - tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)"); - return PadBackend::DualShock4Windows( - crate::inject::dualshock4_windows::DualShock4WindowsManager::new(), - ); - } - _ => {} + } + + /// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the + /// pad's next frame; the arrival is sent before the pad's first input, so a device already + /// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then + /// keeps the earlier kind until re-plug (no live device swap). + fn set_kind(&mut self, idx: usize, kind: GamepadPref) { + if idx >= MAX_WIRE_PADS { + return; } - let _ = kind; - PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) + let resolved = resolve_pad_kind(kind); + if self.kinds[idx] != resolved { + tracing::info!( + pad = idx, + kind = resolved.as_str(), + "gamepad kind declared (per-pad)" + ); + } + self.kinds[idx] = resolved; } fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) { - match self { - PadBackend::Xbox360(m) => m.handle(ev), + use crate::gamestream::gamepad::GamepadEvent; + // Present = a create/update frame (the pad's mask bit is set); a cleared bit is the + // removal frame emitted by the native detach path (`GamepadRemove`). + let (idx, present) = match ev { + GamepadEvent::State(f) => { + let idx = f.index as usize; + (idx, f.active_mask & (1 << idx) != 0) + } + GamepadEvent::Arrival { index, .. } => (*index as usize, true), + }; + if idx >= MAX_WIRE_PADS { + return; + } + let (kind, new_owner) = route_decision(self.owner[idx], self.kinds[idx], present); + self.owner[idx] = new_owner; + self.route_handle(kind, ev); + } + + /// Dispatch a decoded event to the manager for `kind`, creating it lazily. + fn route_handle(&mut self, kind: GamepadPref, ev: &crate::gamestream::gamepad::GamepadEvent) { + match kind { #[cfg(target_os = "linux")] - PadBackend::DualSense(m) => m.handle(ev), + GamepadPref::DualSense => self + .dualsense + .get_or_insert_with(crate::inject::dualsense::DualSenseManager::new) + .handle(ev), #[cfg(target_os = "linux")] - PadBackend::DualShock4(m) => m.handle(ev), + GamepadPref::DualShock4 => self + .dualshock4 + .get_or_insert_with(crate::inject::dualshock4::DualShock4Manager::new) + .handle(ev), #[cfg(target_os = "linux")] - PadBackend::SteamDeck(m) => m.handle(ev), + GamepadPref::SteamDeck => self + .steamdeck + .get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new) + .handle(ev), + #[cfg(target_os = "linux")] + GamepadPref::XboxOne => self + .xboxone + .get_or_insert_with(|| { + crate::inject::gamepad::GamepadManager::with_identity( + crate::inject::gamepad::PadIdentity::xbox_one(), + ) + }) + .handle(ev), #[cfg(target_os = "windows")] - PadBackend::DualSenseWindows(m) => m.handle(ev), + GamepadPref::DualSense => self + .dualsense_win + .get_or_insert_with(crate::inject::dualsense_windows::DualSenseWindowsManager::new) + .handle(ev), #[cfg(target_os = "windows")] - PadBackend::DualShock4Windows(m) => m.handle(ev), + GamepadPref::DualShock4 => self + .dualshock4_win + .get_or_insert_with( + crate::inject::dualshock4_windows::DualShock4WindowsManager::new, + ) + .handle(ev), + _ => self + .xbox360 + .get_or_insert_with(crate::inject::gamepad::GamepadManager::new) + .handle(ev), } } - /// Apply a rich client→host event (touchpad / motion). A no-op for the X-Box pad, which has no - /// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors. - fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { - match self { - PadBackend::Xbox360(_) => {} + /// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists + /// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich + /// plane, so those indices ignore it. + fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) { + use punktfunk_core::quic::RichInput; + let idx = match rich { + RichInput::Touchpad { pad, .. } + | RichInput::Motion { pad, .. } + | RichInput::TouchpadEx { pad, .. } => pad as usize, + }; + // Route to the manager that actually owns the device (falling back to the declared kind + // before the first frame builds it), so a pad's touchpad/motion never lands on the wrong + // backend after a kind change. + let kind = self + .owner + .get(idx) + .copied() + .flatten() + .or_else(|| self.kinds.get(idx).copied()) + .unwrap_or(GamepadPref::Xbox360); + match kind { #[cfg(target_os = "linux")] - PadBackend::DualSense(m) => m.apply_rich(_rich), - #[cfg(target_os = "linux")] - PadBackend::DualShock4(m) => m.apply_rich(_rich), - #[cfg(target_os = "linux")] - PadBackend::SteamDeck(m) => m.apply_rich(_rich), - #[cfg(target_os = "windows")] - PadBackend::DualSenseWindows(m) => m.apply_rich(_rich), - #[cfg(target_os = "windows")] - PadBackend::DualShock4Windows(m) => m.apply_rich(_rich), - } - } - - /// Service feedback every cycle. `rumble` carries motor force-feedback on the universal plane - /// (every backend); `hidout` carries rich feedback on the HID-output plane — lightbar (both - /// UHID pads), plus player LEDs / adaptive triggers (DualSense only). The X-Box pad has no - /// rich-feedback plane. - fn pump( - &mut self, - rumble: impl FnMut(u16, u16, u16), - hidout: impl FnMut(punktfunk_core::quic::HidOutput), - ) { - match self { - PadBackend::Xbox360(m) => { - let _ = hidout; // the X-Box pad has no rich-feedback plane - m.pump_rumble(rumble) + GamepadPref::DualSense => { + if let Some(m) = &mut self.dualsense { + m.apply_rich(rich) + } } #[cfg(target_os = "linux")] - PadBackend::DualSense(m) => m.pump(rumble, hidout), + GamepadPref::DualShock4 => { + if let Some(m) = &mut self.dualshock4 { + m.apply_rich(rich) + } + } #[cfg(target_os = "linux")] - PadBackend::DualShock4(m) => m.pump(rumble, hidout), - #[cfg(target_os = "linux")] - PadBackend::SteamDeck(m) => m.pump(rumble, hidout), + GamepadPref::SteamDeck => { + if let Some(m) = &mut self.steamdeck { + m.apply_rich(rich) + } + } #[cfg(target_os = "windows")] - PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout), + GamepadPref::DualSense => { + if let Some(m) = &mut self.dualsense_win { + m.apply_rich(rich) + } + } #[cfg(target_os = "windows")] - PadBackend::DualShock4Windows(m) => m.pump(rumble, hidout), + GamepadPref::DualShock4 => { + if let Some(m) = &mut self.dualshock4_win { + m.apply_rich(rich) + } + } + _ => {} } } - /// Keep a virtual UHID pad alive during input silence: re-emit its current HID report if it's - /// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as - /// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev - /// holds last-known state with no periodic-report requirement). Called every input-thread tick; - /// the per-pad gap timer (not the tick rate) governs the actual emit cadence. - fn heartbeat(&mut self) { - match self { - PadBackend::Xbox360(_) => {} - #[cfg(target_os = "linux")] - PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), - #[cfg(target_os = "linux")] - PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), - #[cfg(target_os = "linux")] - PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)), - #[cfg(target_os = "windows")] - PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), - #[cfg(target_os = "windows")] - PadBackend::DualShock4Windows(m) => m.heartbeat(std::time::Duration::from_millis(8)), + /// Service feedback for every instantiated backend each cycle. `rumble` carries motor + /// force-feedback on the universal plane (every backend, tagged with its own pad index); + /// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF + /// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend. + fn pump( + &mut self, + mut rumble: impl FnMut(u16, u16, u16), + mut hidout: impl FnMut(punktfunk_core::quic::HidOutput), + ) { + if let Some(m) = &mut self.xbox360 { + m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane + } + #[cfg(target_os = "linux")] + { + if let Some(m) = &mut self.xboxone { + m.pump_rumble(&mut rumble); + } + if let Some(m) = &mut self.dualsense { + m.pump(&mut rumble, &mut hidout); + } + if let Some(m) = &mut self.dualshock4 { + m.pump(&mut rumble, &mut hidout); + } + if let Some(m) = &mut self.steamdeck { + m.pump(&mut rumble, &mut hidout); + } + } + #[cfg(target_os = "windows")] + { + if let Some(m) = &mut self.dualsense_win { + m.pump(&mut rumble, &mut hidout); + } + if let Some(m) = &mut self.dualshock4_win { + m.pump(&mut rumble, &mut hidout); + } } } + + /// Keep every instantiated virtual UHID/UMDF pad alive during input silence (re-emit its HID + /// report so the kernel driver / SDL don't drop a held-steady pad). The X-Box pads need no + /// heartbeat (evdev holds last-known state). Per-pad gap timers inside each manager govern the + /// actual emit cadence, not this per-tick call. + fn heartbeat(&mut self) { + #[cfg(target_os = "linux")] + { + let gap = std::time::Duration::from_millis(8); + if let Some(m) = &mut self.dualsense { + m.heartbeat(gap); + } + if let Some(m) = &mut self.dualshock4 { + m.heartbeat(gap); + } + if let Some(m) = &mut self.steamdeck { + m.heartbeat(gap); + } + } + #[cfg(target_os = "windows")] + { + let gap = std::time::Duration::from_millis(8); + if let Some(m) = &mut self.dualsense_win { + m.heartbeat(gap); + } + if let Some(m) = &mut self.dualshock4_win { + m.heartbeat(gap); + } + } + } +} + +/// The per-pad routing decision for one frame ([`Pads::handle`]): given `owner` (the manager +/// holding a live device at this index, if any), the client-`declared` kind, and whether this is a +/// create/update frame (`present`) vs a removal, return `(kind to route to, new owner)`. +/// +/// A live device stays in its owning manager even if the declared kind later changes (so a pad is +/// never duplicated across managers); the declared kind takes effect only when no device exists +/// yet; a removal routes to the owner's manager (so it tears the right device down) and clears the +/// owner. +fn route_decision( + owner: Option, + declared: GamepadPref, + present: bool, +) -> (GamepadPref, Option) { + match (owner, present) { + (Some(k), true) => (k, Some(k)), // keep the existing device in its manager + (Some(k), false) => (k, None), // removal → owner's manager, then clear + (None, true) => (declared, Some(declared)), // create in the declared kind's manager + (None, false) => (declared, None), // removal with no device — a harmless no-op + } +} + +/// Resolve one client-declared per-pad kind to a backend this host can actually build (mixed +/// types): the platform map + the runtime UHID / Steam-conflict degrades that [`resolve_gamepad`] +/// applies to the session default, minus the Auto/env session logic (a per-pad declaration is +/// always a concrete kind). +fn resolve_pad_kind(kind: GamepadPref) -> GamepadPref { + let chosen = pick_gamepad( + kind, + None, + cfg!(target_os = "linux"), + cfg!(target_os = "windows"), + ); + degrade_steam_on_conflict(degrade_if_no_uhid(chosen)) } /// One client→host input item, both planes on ONE channel so the input thread wakes the @@ -1956,8 +2105,9 @@ fn send_rumble( } /// The per-session input thread: route pointer/keyboard events to the host-lifetime injector -/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the -/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich +/// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the +/// resolved Hello preference is the per-pad default; clients declare each pad's kind so a session +/// can mix uinput X-Box pads and virtual DualSense pads), with rich /// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and /// feedback pumped between events — rumble on the universal datagram plane, DualSense /// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with @@ -1975,7 +2125,7 @@ fn input_thread( inj_tx: std::sync::mpsc::Sender, gamepad: GamepadPref, ) { - let mut pads = PadBackend::select(gamepad); + let mut pads = Pads::new(gamepad); // Motion-cadence observability (debug level): inter-arrival percentiles per 5 s window, // the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad // is 5000 u32s. @@ -2098,6 +2248,44 @@ fn input_thread( } } } + InputKind::GamepadRemove => { + // Mid-session hot-unplug from a snapshot-capable client (the native plane's + // `activeGamepadMask` equivalent). Seq-gated in the SAME per-pad sequence + // space as snapshots, so a snapshot the network reordered past this removal + // is dropped (older seq) and can't resurrect the pad — while a later re-plug + // on the same index arrives with a still-newer seq and is accepted. Clearing + // the `active_mask` bit and re-emitting the frame fires every backend's + // unplug sweep (`inject/*/gamepad.rs`), tearing down just this pad's device. + let (pad, seq) = punktfunk_core::input::decode_gamepad_remove(ev.flags); + let idx = pad as usize; + if idx < MAX_WIRE_PADS + && punktfunk_core::input::GamepadSnapshot::seq_newer(seq, pad_seq[idx]) + { + pad_seq[idx] = Some(seq); + if pad_mask & (1 << idx) != 0 { + pad_mask &= !(1 << idx); + pad_state[idx] = PadState::default(); + let frame = pad_state[idx].frame(idx, pad_mask); + pads.handle(&crate::gamestream::gamepad::GamepadEvent::State(frame)); + tracing::info!(pad = idx, "gamepad unplugged (native detach)"); + } + // Fresh feedback bookkeeping so a later re-plug on this index inherits no + // stale rumble lease/seq (a lease still ticking would buzz the new pad). + rumble_state[idx] = (0, 0); + rumble_seen[idx] = false; + rumble_seq[idx] = 0; + rumble_stop_burst[idx] = 0; + } + } + InputKind::GamepadArrival => { + // Per-pad controller kind declaration (mixed types): route this pad's future + // frames to a backend of the declared kind. `code` = the GamepadPref wire byte, + // `flags` = pad index. Applied before the pad's first frame (the client sends it + // on slot open), so the device is built as the right type from the start. + let idx = ev.flags as usize; + let kind = GamepadPref::from_u8(ev.code as u8); + pads.set_kind(idx, kind); + } _ => { // Track press/release so a mid-press disconnect can be undone below. match ev.kind { @@ -4766,6 +4954,34 @@ fn build_pipeline( mod tests { use super::*; + #[test] + fn per_pad_route_decision() { + use GamepadPref::{DualSense, Xbox360}; + // First frame with no device: create in the declared kind's manager, record ownership. + assert_eq!( + route_decision(None, DualSense, true), + (DualSense, Some(DualSense)) + ); + // Subsequent frame: stays in the owning manager even if the declared kind now differs + // (the arrival-after-first-frame reorder) — never a second device in another manager. + assert_eq!( + route_decision(Some(DualSense), Xbox360, true), + (DualSense, Some(DualSense)) + ); + // Removal (cleared bit): routes to the owner so the RIGHT device is torn down, then clears. + assert_eq!( + route_decision(Some(DualSense), Xbox360, false), + (DualSense, None) + ); + // Removal with no device is a harmless no-op route (owner stays cleared). + assert_eq!(route_decision(None, Xbox360, false), (Xbox360, None)); + // A fresh device after a re-plug picks up the newly-declared kind (owner was cleared). + assert_eq!( + route_decision(None, Xbox360, true), + (Xbox360, Some(Xbox360)) + ); + } + #[test] fn live_mode_pack_roundtrips_and_interval_recovers_hz() { // The live-stats mode slot (H3): pack → unpack is exact for real modes. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 5956f52c..47625b49 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -658,6 +658,28 @@ enum PunktfunkInputKind // [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep // receiving the per-transition events. PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12, + // A pad was unplugged client-side (the native plane's answer to GameStream's + // `activeGamepadMask`, which the per-transition/snapshot planes otherwise lack — see + // [`encode_gamepad_remove`]). `flags` packs `seq << 24 | pad`: the low byte is the pad + // index, the high byte a per-pad wrapping seq sharing the [`GamepadSnapshot`] sequence + // space. The host clears the pad's `active_mask` bit so its virtual device is torn down, + // seq-gated against snapshots so one the network reordered past the removal can't resurrect + // the pad, and the shared seq space keeps the same index reusable by a later re-plug. Sent + // only to a host that advertised [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); + // an older host ignores the unknown tag (the pad then lingers until session end — the + // pre-existing behaviour). + PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE = 13, + // Declares which controller KIND a pad presents so a session can MIX types (pad 0 a + // DualSense, pad 1 an Xbox pad). `code` = the [`GamepadPref`](crate::config::GamepadPref) + // wire byte, `flags` = pad index. Sent when the client opens a pad slot — before that pad's + // first input — and re-sent a few times against datagram loss (like [`GamepadRemove`]). The + // host resolves the kind to a buildable backend and routes that pad's virtual device to it; a + // pad the client never declares (an older client, or a fully-lost declaration) falls back to + // the session-default kind from the handshake. Idempotent (no seq): re-declaring the same kind + // is a no-op. Meaningful only to a host that advertised + // [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); an older host ignores the + // unknown tag (every pad then uses the session-default kind — the pre-existing behaviour). + PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL = 14, }; #ifndef __cplusplus #if __STDC_VERSION__ >= 202311L