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:
File diff suppressed because it is too large
Load Diff
@@ -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<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));
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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(()),
|
||||
|
||||
@@ -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<GamepadPref>; MAX_WIRE_PADS],
|
||||
xbox360: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
DualSense(crate::inject::dualsense::DualSenseManager),
|
||||
xboxone: Option<crate::inject::gamepad::GamepadManager>,
|
||||
#[cfg(target_os = "linux")]
|
||||
DualShock4(crate::inject::dualshock4::DualShock4Manager),
|
||||
dualsense: Option<crate::inject::dualsense::DualSenseManager>,
|
||||
#[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")]
|
||||
DualSenseWindows(crate::inject::dualsense_windows::DualSenseWindowsManager),
|
||||
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
|
||||
#[cfg(target_os = "windows")]
|
||||
DualShock4Windows(crate::inject::dualshock4_windows::DualShock4WindowsManager),
|
||||
dualshock4_win: Option<crate::inject::dualshock4_windows::DualShock4WindowsManager>,
|
||||
}
|
||||
|
||||
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 {
|
||||
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")]
|
||||
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(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
xboxone: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualsense: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
dualshock4: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
steamdeck: 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(),
|
||||
dualsense_win: None,
|
||||
#[cfg(target_os = "windows")]
|
||||
dualshock4_win: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 resolved = resolve_pad_kind(kind);
|
||||
if self.kinds[idx] != resolved {
|
||||
tracing::info!(
|
||||
pad = idx,
|
||||
kind = resolved.as_str(),
|
||||
"gamepad kind declared (per-pad)"
|
||||
);
|
||||
}
|
||||
GamepadPref::DualShock4 => {
|
||||
tracing::info!("gamepad backend: virtual DualShock 4 (Windows UMDF shm channel)");
|
||||
return PadBackend::DualShock4Windows(
|
||||
crate::inject::dualshock4_windows::DualShock4WindowsManager::new(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let _ = kind;
|
||||
PadBackend::Xbox360(crate::inject::gamepad::GamepadManager::new())
|
||||
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),
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.apply_rich(_rich),
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4 {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.apply_rich(_rich),
|
||||
GamepadPref::SteamDeck => {
|
||||
if let Some(m) = &mut self.steamdeck {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualSenseWindows(m) => m.apply_rich(_rich),
|
||||
GamepadPref::DualSense => {
|
||||
if let Some(m) = &mut self.dualsense_win {
|
||||
m.apply_rich(rich)
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.apply_rich(_rich),
|
||||
GamepadPref::DualShock4 => {
|
||||
if let Some(m) = &mut self.dualshock4_win {
|
||||
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.
|
||||
/// 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,
|
||||
rumble: impl FnMut(u16, u16, u16),
|
||||
hidout: impl FnMut(punktfunk_core::quic::HidOutput),
|
||||
mut rumble: impl FnMut(u16, u16, u16),
|
||||
mut 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)
|
||||
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")]
|
||||
PadBackend::DualSense(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::DualShock4(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "linux")]
|
||||
PadBackend::SteamDeck(m) => m.pump(rumble, hidout),
|
||||
{
|
||||
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")]
|
||||
PadBackend::DualSenseWindows(m) => m.pump(rumble, hidout),
|
||||
#[cfg(target_os = "windows")]
|
||||
PadBackend::DualShock4Windows(m) => m.pump(rumble, hidout),
|
||||
{
|
||||
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 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.
|
||||
/// 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) {
|
||||
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)),
|
||||
{
|
||||
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
|
||||
@@ -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<InputEvent>,
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user