feat(apple): multi-controller support

Roll the pf-client-core slot pattern to the Apple client (Swift):

- GamepadManager tracks all connected GCControllers, assigning each a stable
  lowest-free wire pad index + concrete type, emitting GamepadArrival on
  connect and GamepadRemove on disconnect (index freed for reuse on re-plug).
- GamepadCapture binds every controller with per-controller Slot state
  (buttons/axes/fingers/motion), threading the pad index into flags on every
  event; GamepadWire/InputEvents carry the pad + the two new events.
- GamepadFeedback + RumbleRenderer go per-pad (rumbleByPad, slots[pad]),
  routing rumble/HID back to the correct controller by wire index.
- ContentView/Settings surface every forwarded controller.

pad 0 => flags 0, so single-controller wire is byte-identical. Cannot build
on the Linux dev box (no Swift toolchain / Apple frameworks); wire bytes
hand-checked against input.rs and GamepadWireTests extended for multi-pad.
CI apple.yml (swift build/test on macOS) is the compile gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:52:34 +02:00
parent 0ad4e6eff7
commit 97c67b2692
9 changed files with 470 additions and 192 deletions
@@ -1,20 +1,23 @@
// Hostclient gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
// HID-output (0xCD) planes and replays them on the active physical controller
// HID-output (0xCD) planes and replays each update on the forwarded physical controller it is
// ADDRESSED TO by wire pad index
//
// rumble CHHapticEngine players (per-handle localities when the pad has them,
// one combined engine otherwise),
// one combined engine otherwise), a RumbleRenderer per pad,
// lightbar GCDeviceLight,
// player LEDs GCController.playerIndex (the DS bit patterns map to player 14),
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) the
// drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state
// is replayed onto the new one.
// Every forwarded controller gets a per-pad feedback slot (its RumbleRenderer + last light /
// player-LED / trigger state) keyed on the same wire index GamepadCapture streams it on, so a
// rumble the host aimed at pad 1 drives pad 1's actuator and nothing else. An update for a pad
// with no live slot (one that just closed) is dropped. HID-output traffic exists only on
// PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only); the drain always
// polls both planes with short timeouts and never spins, so an Xbox pad just renders rumble.
// GameController profile mutation happens on main; CHHapticEngine work on the renderer's serial
// queue; the drain thread itself touches neither (it routes rumble to the pad's renderer under a
// lock and hops HID to main). When a controller leaves the forwarded set the old pad is reset
// (triggers off, player index unset) and its renderer silenced.
import Combine
import Foundation
@@ -22,26 +25,40 @@ import GameController
public final class GamepadFeedback {
private let connection: PunktfunkConnection
private let manager: GamepadManager
private let flag = StopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false
private let rumble = RumbleRenderer(policy: .session)
private var activeSub: AnyCancellable?
private var forwardedSub: AnyCancellable?
// Last applied feedback (main-actor) replayed when the active controller changes.
@MainActor private var target: GCController?
@MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
@MainActor private var lastPlayerBits: UInt8?
@MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
/// One forwarded controller's non-rumble feedback state (main-actor) the GC target plus the
/// last applied lightbar / player-LED / trigger, replayed if the controller on this pad swaps.
@MainActor private final class Slot {
var controller: GCController?
var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
var lastPlayerBits: UInt8?
var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]
init(controller: GCController?) { self.controller = controller }
}
/// HID / lightbar / player-LED slots, keyed by wire pad index. Main-actor only.
@MainActor private var slots: [UInt8: Slot] = [:]
/// Rumble renderers keyed by wire pad index, guarded by `routingLock` so the background drain
/// thread can route an incoming envelope to the right pad's renderer while the main actor
/// reconciles the set. RumbleRenderer serializes on its own queue, so calling `apply` from the
/// drain thread is safe only the map lookup needs the lock.
private let routingLock = NSLock()
private var rumbleByPad: [UInt8: RumbleRenderer] = [:]
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
self.manager = manager
// Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
// an implicit strong one and the subscription (stored on self) never retain-cycles.
Task { @MainActor [weak self] in
guard let self else { return }
self.activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
self.forwardedSub = manager.$forwarded.sink { [weak self] list in
MainActor.assumeIsolated { self?.reconcile(list) }
}
}
}
@@ -67,6 +84,38 @@ public final class GamepadFeedback {
}
}
/// Bring the per-pad feedback slots in line with the forwarded set: drop pads no longer
/// forwarded (silence + release their renderer, reset their controller), add a slot +
/// renderer for each new pad, and retarget a pad whose controller changed (a re-plug into the
/// same freed index) replaying its cached feedback onto the new device.
@MainActor
private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) {
var want: [UInt8: GCController] = [:]
for dc in forwarded {
if let pad = manager.padIndex(for: dc) { want[pad] = dc.controller }
}
for (pad, slot) in slots where want[pad] == nil {
reset(slot.controller)
slots[pad] = nil
let renderer = withRouting { rumbleByPad.removeValue(forKey: pad) }
renderer?.stop()
}
for (pad, controller) in want {
if let slot = slots[pad] {
guard slot.controller !== controller else { continue }
reset(slot.controller)
slot.controller = controller
withRouting { rumbleByPad[pad]?.retarget(controller) }
replay(slot)
} else {
slots[pad] = Slot(controller: controller)
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(controller)
withRouting { rumbleByPad[pad] = renderer }
}
}
}
public func start() {
guard !drainStarted else { return }
drainStarted = true
@@ -88,19 +137,19 @@ public final class GamepadFeedback {
// rumble/HID latency low while leaving the lock free between polls.
//
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
// level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
// queue, and its drop-newest overflow could shed a stop while stale nonzero
// states queued ahead of it buzzing until the host's next 500 ms refresh.
var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:]
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble2(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
for (pad, n) in newestByPad {
self?.routeRumble(pad: pad, low: n.low, high: n.high, ttlMs: n.ttl)
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -126,7 +175,7 @@ public final class GamepadFeedback {
thread.start()
}
/// Stop the drain and silence the motors. Blocks until the drain thread exits ( one
/// Stop the drain and silence every pad's motors. Blocks until the drain thread exits ( one
/// poll cycle) call off the main actor, before `connection.close()`.
public func stop() {
flag.stop()
@@ -134,17 +183,32 @@ public final class GamepadFeedback {
drainDone.wait()
drainStarted = false
}
rumble.stop()
// Drop the retarget subscription and the dead session's cached feedback a
// controller change after teardown must not replay this session's triggers/LEDs.
Task { @MainActor in
self.activeSub = nil
self.lastLight = nil
self.lastPlayerBits = nil
self.lastTrigger = [nil, nil]
self.reset(self.target)
self.target = nil
let renderers = withRouting { () -> [RumbleRenderer] in
let r = Array(rumbleByPad.values)
rumbleByPad.removeAll()
return r
}
for r in renderers { r.stop() }
// Drop the subscription and every dead pad's cached feedback a controller change after
// teardown must not replay this session's triggers/LEDs.
Task { @MainActor in
self.forwardedSub = nil
for slot in self.slots.values { self.reset(slot.controller) }
self.slots.removeAll()
}
}
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
/// live renderer one that just left the forwarded set is dropped.
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
let renderer = withRouting { rumbleByPad[pad] }
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
}
private func withRouting<R>(_ body: () -> R) -> R {
routingLock.lock()
defer { routingLock.unlock() }
return body()
}
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
@@ -157,40 +221,37 @@ public final class GamepadFeedback {
private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
switch ev {
case let .led(pad, r, g, b):
guard pad == 0 else { return }
lastLight = (r, g, b)
target?.light?.color = GCColor(
guard let slot = slots[pad] else { return }
slot.lastLight = (r, g, b)
slot.controller?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
case let .playerLEDs(pad, bits):
guard pad == 0 else { return }
lastPlayerBits = bits
target?.playerIndex = Self.playerIndex(forBits: bits)
guard let slot = slots[pad] else { return }
slot.lastPlayerBits = bits
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
case let .triggerEffect(pad, which, effect):
guard pad == 0, which < 2 else { return }
guard which < 2, let slot = slots[pad] else { return }
let parsed = DualSenseTriggerEffect.parse(effect)
lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(which) {
slot.lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(slot.controller, which) {
parsed.apply(to: trigger)
}
}
}
/// Replay a pad's cached feedback onto its (swapped-in) controller so a re-plug looks the same.
@MainActor
private func retarget(_ controller: GCController?) {
guard controller !== target else { return }
reset(target)
target = controller
rumble.retarget(controller)
// Replay the session's feedback state so a swapped-in controller looks the same.
if let (r, g, b) = lastLight {
controller?.light?.color = GCColor(
private func replay(_ slot: Slot) {
if let (r, g, b) = slot.lastLight {
slot.controller?.light?.color = GCColor(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
}
if let bits = lastPlayerBits {
controller?.playerIndex = Self.playerIndex(forBits: bits)
if let bits = slot.lastPlayerBits {
slot.controller?.playerIndex = Self.playerIndex(forBits: bits)
}
for which in 0..<2 {
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
if let effect = slot.lastTrigger[which],
let trigger = adaptiveTrigger(slot.controller, UInt8(which)) {
effect.apply(to: trigger)
}
}
@@ -207,8 +268,8 @@ public final class GamepadFeedback {
}
@MainActor
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
private func adaptiveTrigger(_ controller: GCController?, _ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return nil }
return which == 0 ? ds.leftTrigger : ds.rightTrigger
}
}