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
@@ -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(()),