feat(gamepad): multi-controller support on the native plane

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<Slot> 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:52:15 +02:00
parent 84329205eb
commit 76be4c3e12
9 changed files with 1082 additions and 462 deletions
File diff suppressed because it is too large Load Diff
+86 -4
View File
@@ -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 // 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. // refresh never conjures a virtual pad the embedder didn't drive.
let mut pads: [Option<GamepadSnapshot>; MAX_PADS] = [None; MAX_PADS]; let mut pads: [Option<GamepadSnapshot>; 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<u8>; 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)); let mut refresh = tokio::time::interval(Duration::from_millis(100));
refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop { loop {
@@ -1584,24 +1601,89 @@ async fn worker_main(args: WorkerArgs) {
&& matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis) && matches!(ev.kind, InputKind::GamepadButton | InputKind::GamepadAxis)
&& idx < MAX_PADS && 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 { let snap = pads[idx].get_or_insert(GamepadSnapshot {
pad: idx as u8, pad: idx as u8,
..Default::default() ..Default::default()
}); });
// Unknown axis ids don't send (the host's legacy fold drops them too). // Unknown axis ids don't send (the host's legacy fold drops them too).
if snap.fold(&ev) { 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 let _ = input_conn
.send_datagram(snap.to_event().encode().to_vec().into()); .send_datagram(snap.to_event().encode().to_vec().into());
} }
continue; 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()); let _ = input_conn.send_datagram(ev.encode().to_vec().into());
} }
_ = refresh.tick() => { _ = refresh.tick() => {
for snap in pads.iter_mut().flatten() { for idx in 0..MAX_PADS {
snap.seq = snap.seq.wrapping_add(1); // Re-send an owed kind declaration (independent of whether the pad has state
let _ = input_conn.send_datagram(snap.to_event().encode().to_vec().into()); // 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());
}
} }
} }
} }
+55 -2
View File
@@ -51,6 +51,39 @@ pub enum InputKind {
/// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep /// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
/// receiving the per-transition events. /// receiving the per-transition events.
GamepadState = 12, 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`]. /// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
@@ -123,6 +156,8 @@ impl InputKind {
10 => TouchMove, 10 => TouchMove,
11 => TouchUp, 11 => TouchUp,
12 => GamepadState, 12 => GamepadState,
13 => GamepadRemove,
14 => GamepadArrival,
_ => return None, _ => return None,
}) })
} }
@@ -321,8 +356,26 @@ mod tests {
}; };
assert_eq!(InputEvent::decode(&e.encode()), Some(e)); assert_eq!(InputEvent::decode(&e.encode()), Some(e));
} }
// 13 (one past GamepadState) is not a valid kind. // GamepadRemove + GamepadArrival are valid kinds; 15 (one past them) is not.
assert_eq!(InputKind::from_u8(13), None); 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] #[test]
@@ -425,7 +425,11 @@ impl InputInjector for KwinFakeInjector {
self.fake.touch_frame(); self.fake.touch_frame();
} }
// Gamepads are injected through uinput, not the compositor. // 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. // Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue self.queue
@@ -404,6 +404,8 @@ fn kind_bit(kind: InputKind) -> u32 {
InputKind::GamepadButton => 10, InputKind::GamepadButton => 10,
InputKind::GamepadAxis => 11, InputKind::GamepadAxis => 11,
InputKind::GamepadState => 12, InputKind::GamepadState => 12,
InputKind::GamepadRemove => 13,
InputKind::GamepadArrival => 14,
}; };
1 << i 1 << i
} }
@@ -546,7 +548,11 @@ impl EiState {
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => { InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
DeviceCapability::Touch 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; self.injected += 1;
let n = self.injected; let n = self.injected;
@@ -693,9 +699,11 @@ impl EiState {
Some(t) => t.up(ev.code), Some(t) => t.up(ev.code),
None => emitted = false, None => emitted = false,
}, },
InputKind::GamepadState | InputKind::GamepadButton | InputKind::GamepadAxis => { InputKind::GamepadState
emitted = false | InputKind::GamepadButton
} | InputKind::GamepadAxis
| InputKind::GamepadRemove
| InputKind::GamepadArrival => emitted = false,
} }
if emitted { if emitted {
@@ -254,7 +254,11 @@ impl InputInjector for WlrootsInjector {
tracing::debug!(vk = event.code, "unmapped VK keycode — dropped"); 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. // wlroots has no virtual-touch protocol wired here; touch is the libei path only.
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {} InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
} }
@@ -301,6 +301,8 @@ impl InputInjector for SendInputInjector {
InputKind::GamepadButton InputKind::GamepadButton
| InputKind::GamepadAxis | InputKind::GamepadAxis
| InputKind::GamepadState | InputKind::GamepadState
| InputKind::GamepadRemove
| InputKind::GamepadArrival
| InputKind::TouchDown | InputKind::TouchDown
| InputKind::TouchMove | InputKind::TouchMove
| InputKind::TouchUp => Ok(()), | InputKind::TouchUp => Ok(()),
+346 -130
View File
@@ -1740,166 +1740,315 @@ const MAX_WIRE_PADS: usize = punktfunk_core::input::MAX_PADS;
/// virtual mic has its own tuning — see [`crate::audio::MicPump`].) /// virtual mic has its own tuning — see [`crate::audio::MicPump`].)
const INJECTOR_REOPEN_BACKOFF: std::time::Duration = std::time::Duration::from_secs(2); 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)), /// Backends are created lazily per kind (an empty manager holds no device), and each owns only the
/// the in-tree XUSB companion driver (classic XInput) on Windows. Also the X-Box One/Series identity /// indices routed to it. A manager's `active_mask` unplug sweep stays correct across managers
/// (`PUNKTFUNK_GAMEPAD=xboxone`): the same /// because an index another manager owns is `None` in this one, so the sweep never touches it.
/// 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).
/// ///
/// DualShock 4 + One/Series are Linux-only; DualSense has both a Linux (UHID) and a Windows (UMDF /// - Xbox 360 / One — uinput on Linux ([`GamepadManager`](crate::inject::gamepad::GamepadManager),
/// minidriver) backend. The resolver folds any type a platform can't build into `Xbox360`, so a /// two identities), the XUSB companion driver (classic XInput) on Windows.
/// build never constructs a variant it lacks. /// - DualSense / DualShock 4 — Linux UHID `hid-playstation`, or the Windows UMDF minidriver.
enum PadBackend { /// - Steam Deck — Linux UHID `hid-steam`.
Xbox360(crate::inject::gamepad::GamepadManager), ///
/// [`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<GamepadPref>; MAX_WIRE_PADS],
xbox360: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualSense(crate::inject::dualsense::DualSenseManager), xboxone: Option<crate::inject::gamepad::GamepadManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
DualShock4(crate::inject::dualshock4::DualShock4Manager), dualsense: Option<crate::inject::dualsense::DualSenseManager>,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
SteamDeck(crate::inject::steam_controller::SteamControllerManager), dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager), dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager), dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
} }
impl PadBackend { impl Pads {
/// `kind` is the session's resolved backend (see [`resolve_gamepad`] — client preference, /// `default` is the session kind (see [`resolve_gamepad`]); every pad starts on it until the
/// env var, X-Box 360, in that order). Defensive cfg guard: a non-Linux build can only ever /// client declares its own kind.
/// construct the X-Box backend, whatever the resolution said. fn new(default: GamepadPref) -> Pads {
fn select(kind: GamepadPref) -> PadBackend { let default = resolve_pad_kind(default);
#[cfg(target_os = "linux")] tracing::info!(
match kind { default = default.as_str(),
GamepadPref::DualSense => { "gamepad backends: per-pad router (session default)"
tracing::info!("gamepad backend: virtual DualSense (UHID hid-playstation)"); );
return PadBackend::DualSense(crate::inject::dualsense::DualSenseManager::new()); Pads {
} kinds: [default; MAX_WIRE_PADS],
GamepadPref::DualShock4 => { owner: [None; MAX_WIRE_PADS],
tracing::info!("gamepad backend: virtual DualShock 4 (UHID hid-playstation)"); xbox360: None,
return PadBackend::DualShock4(crate::inject::dualshock4::DualShock4Manager::new()); #[cfg(target_os = "linux")]
} xboxone: None,
GamepadPref::SteamDeck => { #[cfg(target_os = "linux")]
tracing::info!("gamepad backend: virtual Steam Deck (UHID hid-steam)"); dualsense: None,
return PadBackend::SteamDeck( #[cfg(target_os = "linux")]
crate::inject::steam_controller::SteamControllerManager::new(), dualshock4: None,
); #[cfg(target_os = "linux")]
} steamdeck: None,
GamepadPref::XboxOne => { #[cfg(target_os = "windows")]
tracing::info!("gamepad backend: uinput X-Box One/Series pad"); dualsense_win: None,
return PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::with_identity( #[cfg(target_os = "windows")]
crate::inject::gamepad::PadIdentity::xbox_one(), dualshock4_win: None,
));
}
_ => {}
} }
#[cfg(target_os = "windows")] }
match kind {
GamepadPref::DualSense => { /// Record a pad's client-declared kind (resolved to a buildable backend). Takes effect on the
tracing::info!("gamepad backend: virtual DualSense (Windows UMDF shm channel)"); /// pad's next frame; the arrival is sent before the pad's first input, so a device already
return PadBackend::DualSenseWindows( /// built under the wrong kind is only the rare arrival-after-first-frame reorder — it then
crate::inject::dualsense_windows::DualSenseWindowsManager::new(), /// 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 {
GamepadPref::DualShock4 => { return;
tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)");
return PadBackend::DualShock4Windows(
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
);
}
_ => {}
} }
let _ = kind; let resolved = resolve_pad_kind(kind);
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new()) 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) { fn handle(&mut self, ev: &crate::gamestream::gamepad::GamepadEvent) {
match self { use crate::gamestream::gamepad::GamepadEvent;
PadBackend::Xbox360(m) => m.handle(ev), // 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")] #[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")] #[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")] #[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")] #[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")] #[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 /// Apply a rich client→host event (touchpad / motion) to the pad's kind manager, if it exists
/// equivalent; the DualSense and DualShock 4 pads both carry a touchpad + motion sensors. /// (rich before the first frame = no device yet = a no-op anyway). The X-Box pads have no rich
fn apply_rich(&mut self, _rich: punktfunk_core::quic::RichInput) { /// plane, so those indices ignore it.
match self { fn apply_rich(&mut self, rich: punktfunk_core::quic::RichInput) {
PadBackend::Xbox360(_) => {} 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")] #[cfg(target_os = "linux")]
PadBackend::DualSense(m) => m.apply_rich(_rich), GamepadPref::DualSense => {
#[cfg(target_os = "linux")] if let Some(m) = &mut self.dualsense {
PadBackend::DualShock4(m) => m.apply_rich(_rich), 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)
} }
#[cfg(target_os = "linux")] #[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")] #[cfg(target_os = "linux")]
PadBackend::DualShock4(m) => m.pump(rumble, hidout), GamepadPref::SteamDeck => {
#[cfg(target_os = "linux")] if let Some(m) = &mut self.steamdeck {
PadBackend::SteamDeck(m) => m.pump(rumble, hidout), m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")] #[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")] #[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 /// Service feedback for every instantiated backend each cycle. `rumble` carries motor
/// gone quiet, so the kernel `hid-playstation` driver / SDL don't treat a held-steady pad as /// force-feedback on the universal plane (every backend, tagged with its own pad index);
/// unplugged ("controller disconnected every few seconds"). No-op for the X-Box pad (evdev /// `hidout` carries rich feedback (lightbar / player LEDs / adaptive triggers) for the UHID/UMDF
/// holds last-known state with no periodic-report requirement). Called every input-thread tick; /// pads. The `&mut` closure re-borrows satisfy `FnMut` for each backend.
/// the per-pad gap timer (not the tick rate) governs the actual emit cadence. fn pump(
fn heartbeat(&mut self) { &mut self,
match self { mut rumble: impl FnMut(u16, u16, u16),
PadBackend::Xbox360(_) => {} mut hidout: impl FnMut(punktfunk_core::quic::HidOutput),
#[cfg(target_os = "linux")] ) {
PadBackend::DualSense(m) => m.heartbeat(std::time::Duration::from_millis(8)), if let Some(m) = &mut self.xbox360 {
#[cfg(target_os = "linux")] m.pump_rumble(&mut rumble); // the X-Box pad has no rich-feedback plane
PadBackend::DualShock4(m) => m.heartbeat(std::time::Duration::from_millis(8)), }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
PadBackend::SteamDeck(m) => m.heartbeat(std::time::Duration::from_millis(8)), {
#[cfg(target_os = "windows")] if let Some(m) = &mut self.xboxone {
PadBackend::DualSenseWindows(m) => m.heartbeat(std::time::Duration::from_millis(8)), m.pump_rumble(&mut rumble);
#[cfg(target_os = "windows")] }
PadBackend::DualShock4Windows(m) => m.heartbeat(std::time::Duration::from_millis(8)), 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<GamepadPref>,
declared: GamepadPref,
present: bool,
) -> (GamepadPref, Option<GamepadPref>) {
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 /// 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 /// 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 /// service (`inj_tx`) and gamepad events to this session's [`Pads`] router (`gamepad` — the
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich /// 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 /// client→host input (touchpad / motion, [`ClientInput::Rich`]) applied on arrival and
/// feedback pumped between events — rumble on the universal datagram plane, DualSense /// 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 /// 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<InputEvent>, inj_tx: std::sync::mpsc::Sender<InputEvent>,
gamepad: GamepadPref, 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, // 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 // the measurement a "gyro feels floaty" report needs. Bounded: 5 s at even a 1 kHz pad
// is 5000 u32s. // 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. // Track press/release so a mid-press disconnect can be undone below.
match ev.kind { match ev.kind {
@@ -4766,6 +4954,34 @@ fn build_pipeline(
mod tests { mod tests {
use super::*; 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] #[test]
fn live_mode_pack_roundtrips_and_interval_recovers_hz() { fn live_mode_pack_roundtrips_and_interval_recovers_hz() {
// The live-stats mode slot (H3): pack → unpack is exact for real modes. // The live-stats mode slot (H3): pack → unpack is exact for real modes.
+22
View File
@@ -658,6 +658,28 @@ enum PunktfunkInputKind
// [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep // [`HOST_CAP_GAMEPAD_STATE`](crate::quic::HOST_CAP_GAMEPAD_STATE); older hosts keep
// receiving the per-transition events. // receiving the per-transition events.
PUNKTFUNK_INPUT_KIND_GAMEPAD_STATE = 12, 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 #ifndef __cplusplus
#if __STDC_VERSION__ >= 202311L #if __STDC_VERSION__ >= 202311L