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:
@@ -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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user