From 97c67b2692530e7b338db005df010e91e600942e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 12 Jul 2026 21:52:34 +0200 Subject: [PATCH] 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) --- clients/apple/Package.swift | 4 +- .../Session/SessionModel.swift | 7 +- .../Settings/SettingsView+Support.swift | 6 +- .../PunktfunkKit/Connection/InputEvents.swift | 20 ++ .../PunktfunkKit/Gamepad/GamepadCapture.swift | 319 +++++++++++------- .../Gamepad/GamepadFeedback.swift | 181 ++++++---- .../PunktfunkKit/Gamepad/GamepadManager.swift | 81 ++++- .../PunktfunkKit/Gamepad/GamepadWire.swift | 6 +- .../PunktfunkKitTests/GamepadWireTests.swift | 38 +++ 9 files changed, 470 insertions(+), 192 deletions(-) diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 34478721..1afa0f18 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -40,6 +40,8 @@ let package = Package( // its manifest breaks SwiftPM whole-graph validation on macOS, and only the // Punktfunk-tvOS target links it; the #if os(tvOS) import never compiles here.) .executableTarget(name: "PunktfunkClient", dependencies: ["PunktfunkKit"]), - .testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit"]), + // PunktfunkCore is a direct dep too so the wire tests can name the C ABI's + // `PunktfunkInputEvent` / `PUNKTFUNK_INPUT_KIND_*` when asserting the gamepad byte layout. + .testTarget(name: "PunktfunkKitTests", dependencies: ["PunktfunkKit", "PunktfunkCore"]), ] ) diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 4c524714..23d21ca2 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -419,9 +419,10 @@ final class SessionModel: ObservableObject { micChannel: defaults.integer(forKey: DefaultsKey.micChannel), micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true) self.audio = audio - // Gamepads: forward GamepadManager's active controller as pad 0 and render the - // host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the - // session's virtual pad is a DualSense). Same trust gate as audio — nothing is + // Gamepads: forward every controller GamepadManager selected — each on its own wire pad + // index (a pin forwards only one, Automatic forwards all) — and render the host's feedback + // back to the pad it's addressed to (rumble always; lightbar/player-LEDs/adaptive-triggers + // when a pad's virtual device is a DualSense). Same trust gate as audio — nothing is // forwarded during the trust prompt. let capture = GamepadCapture(connection: conn, manager: .shared) // The cross-client escape chord (hold L1+R1+Start+Select 1.5 s) — on tvOS the only diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift index b8b0c57c..72607f52 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift @@ -133,8 +133,10 @@ extension SettingsView { .foregroundStyle(.secondary) } Spacer() - if gamepads.active?.id == controller.id { - Text("In use") + // Every forwarded controller is surfaced (not just the primary `active`) with its + // wire pad index as a player number — a pin forwards only one, Automatic forwards all. + if let pad = gamepads.padIndex(for: controller) { + Text("Player \(pad + 1)") .font(.geist(11, .semibold, relativeTo: .caption2)) .padding(.horizontal, 8) .padding(.vertical, 3) diff --git a/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift b/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift index aafda2d6..0b5ade07 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift @@ -59,6 +59,26 @@ public extension PunktfunkInputEvent { make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad) } + /// Declare a pad's controller KIND (`InputKind::GamepadArrival`): `pref` is the + /// `GamepadType` wire byte (Auto=0, Xbox360=1, DualSense=2, XboxOne=3, DualShock4=4, + /// SteamController=5, SteamDeck=6), `pad` the wire index. Sent once when a controller slot + /// opens — BEFORE that pad's first input — so the host builds a matching virtual device and a + /// session can mix types (pad 0 a DualSense, pad 1 an Xbox pad). The core re-sends it a few + /// times against datagram loss and folds per-pad state behind it; a host that predates the tag + /// ignores it and uses the session-default kind from the handshake. Idempotent on the host. + static func gamepadArrival(pref: UInt32, pad: UInt32) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue, code: pref, x: 0, y: 0, flags: pad) + } + + /// A pad disconnected (`InputKind::GamepadRemove`): `flags` = pad index. The client sends the + /// bare index; the core stamps the per-pad removal seq (`encode_gamepad_remove`) in the shared + /// snapshot seq space and arms a loss-resistant re-send burst, so the host tears the pad's + /// virtual device down and no reordered snapshot can resurrect it. A host that predates the tag + /// ignores it (the pad then lingers until session end — the pre-existing behaviour). + static func gamepadRemove(pad: UInt32) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue, code: 0, x: 0, y: 0, flags: pad) + } + // Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes // fingers and is reusable after touchUp; coordinates are absolute pixels on the // client's touch surface, whose size rides in `flags` so the host can rescale — diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index 6933f820..f15b02c2 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -1,24 +1,33 @@ -// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever -// GamepadManager selected — as pad 0, for the lifetime of a streaming session. +// Gamepad capture → punktfunk/1 datagrams. Forwards EVERY controller GamepadManager selected — +// each on its own stable wire pad index (pf-client-core's slot model) — for the lifetime of a +// streaming session. One physical controller with no pin is player 0 (byte-identical to the old +// single-pad path); a pin forwards only that one, also as pad 0. // -// The wire is incremental (one button/axis transition per 18-byte event, accumulated -// host-side into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the -// full GCExtendedGamepad state on every valueChanged and diff against the previous -// snapshot. Sticks are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. +// Each forwarded controller gets a `Slot`: its open GC handlers plus the wire state (buttons, +// axes, touchpad fingers, motion throttle) for its pad index — isolated per device so two +// controllers never clobber each other. On connect a slot opens (GamepadArrival declares its +// kind, then input flows); on disconnect / pin change / stop it closes (held state flushed to +// rest on the wire, then GamepadRemove tells the host to tear the pad's virtual device down). +// +// The wire is incremental (one button/axis transition per 18-byte event, accumulated host-side +// into the virtual pad — see punktfunk_core::input::gamepad), so we snapshot the full +// GCExtendedGamepad state on every valueChanged and diff against the previous snapshot. Sticks +// are ±32767 with +y = up (GC already matches, no flip), triggers 0...255. The core folds these +// per-pad transitions into idempotent, sequence-numbered snapshots keyed on the same pad index, +// so all this layer must get right is the index — one controller per slot, one slot per index. // // PlayStation-pad extras ride the rich-input plane (0xCC): touchpad contacts normalized -// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion -// samples in raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — -// derived from the host's fixed calibration blob; the conversion lives in ONE place, -// `Wire`, so a live sign/scale correction is a one-line change). The host ignores both -// unless the session's virtual pad is a DualSense or DualShock 4 — both carry a touchpad -// and motion, so the capture below covers either (`GCDualShockGamepad` exposes the same -// `touchpad*` surface as `GCDualSenseGamepad`). +// 0...65535 (origin top-left, +y down — GC's ±1/+y-up is converted here) and motion samples in +// raw DualSense sensor units (gyro 20 LSB per deg/s, accel 10000 LSB per g — derived from the +// host's fixed calibration blob; the conversion lives in ONE place, `Wire`, so a live sign/scale +// correction is a one-line change). The host ignores both unless a pad's virtual device is a +// DualSense or DualShock 4 — both carry a touchpad and motion, so the capture below covers either +// (`GCDualShockGamepad` exposes the same `touchpad*` surface as `GCDualSenseGamepad`). // -// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture -// toggle — a controller can't click local UI, so it always drives the host while the app -// is active. On deactivation, controller switch, or stop, every held control is released -// on the wire (the host pad would otherwise stay stuck on the last state). +// Unlike mouse/keyboard capture, gamepad forwarding is NOT gated on the mouse-capture toggle — a +// controller can't click local UI, so it always drives the host while the app is active. On +// deactivation, controller switch, or stop, every held control is released on the wire (the host +// pad would otherwise stay stuck on the last state). #if os(macOS) import AppKit @@ -33,17 +42,35 @@ import GameController public final class GamepadCapture { private let connection: PunktfunkConnection private let manager: GamepadManager - private var activeSub: AnyCancellable? + private var forwardedSub: AnyCancellable? private var observers: [NSObjectProtocol] = [] - private var bound: GCController? /// App inactive → GC stops delivering; everything is released and stays silent. private var suspended = false - // Last wire state (the diff base — also what releaseAll() unwinds). - private var buttons: UInt32 = 0 - private var axes: [Int32] = [0, 0, 0, 0, 0, 0] - private var fingerActive: [Bool] = [false, false] - private var lastMotionNs: UInt64 = 0 + /// One forwarded controller: the open device plus the last wire state for its pad index (the + /// diff base — also what `flush` unwinds). Held per Slot so two controllers never clobber each + /// other's held buttons/axes/fingers. Mirrors pf-client-core's `Slot`. + private final class Slot { + let controller: GCController + /// Wire pad index (GamepadManager's stable lowest-free assignment), threaded onto every + /// event this controller sends — the low byte of `flags`. + let pad: UInt32 + /// The controller KIND declared to the host (GamepadArrival) when the slot opened. + let pref: PunktfunkConnection.GamepadType + var buttons: UInt32 = 0 + var axes: [Int32] = [0, 0, 0, 0, 0, 0] + var fingerActive: [Bool] = [false, false] + var lastMotionNs: UInt64 = 0 + init(controller: GCController, pad: UInt32, pref: PunktfunkConnection.GamepadType) { + self.controller = controller + self.pad = pad + self.pref = pref + } + } + + /// Open forwarded controllers, one Slot per physical pad on its own wire index. Reconciled + /// against `manager.forwarded` (empty until a session's `start`, cleared by `stop`). + private var slots: [Slot] = [] /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). private static let motionIntervalNs: UInt64 = 4_000_000 @@ -71,10 +98,14 @@ public final class GamepadCapture { } public func start() { - // Fires immediately with the current selection, then on every change — a switch - // releases the old controller's wire state before the new one takes over. - activeSub = manager.$active.sink { [weak self] dc in - MainActor.assumeIsolated { self?.rebind(to: dc?.controller) } + // Session-scoped index assignment: a controller pinned before the session forwards as + // pad 0 (pf-client-core assigns indices at slot-open time, not app-launch time). + manager.resetForwardingAssignment() + // Fires immediately with the current forwarded set, then on every change — a connect, + // disconnect, or pin change reconciles the open slots against it (opening/closing devices + // and flushing wire state so nothing sticks down). + forwardedSub = manager.$forwarded.sink { [weak self] list in + MainActor.assumeIsolated { self?.reconcile(list) } } #if os(macOS) let resign = NSApplication.willResignActiveNotification @@ -97,53 +128,56 @@ public final class GamepadCapture { MainActor.assumeIsolated { guard let self else { return } self.suspended = false - if let ext = self.bound?.extendedGamepad { self.sync(ext) } + // Re-send every open pad's current state (GC delivered nothing while inactive). + for slot in self.slots { + if let ext = slot.controller.extendedGamepad { self.sync(slot, ext) } + } } }) } public func stop() { - releaseAll() - rebind(to: nil) - activeSub = nil + closeAllSlots() + forwardedSub = nil observers.forEach { NotificationCenter.default.removeObserver($0) } observers.removeAll() } - private func rebind(to controller: GCController?) { - guard controller !== bound else { return } - releaseAll() - if let ext = bound?.extendedGamepad { - ext.valueChangedHandler = nil - let tp = Self.touchpad(ext) - tp?.primary.valueChangedHandler = nil - tp?.secondary.valueChangedHandler = nil + /// Bring `slots` in line with the forwarded set: close any slot no longer wanted (flushing its + /// held wire state and sending GamepadRemove first) and open any newly-forwarded controller into + /// its assigned wire index. A controller that stays forwarded keeps its slot untouched, so a + /// second pad connecting never disturbs the first. Mirrors pf-client-core's `reconcile_slots`. + private func reconcile(_ forwarded: [GamepadManager.DiscoveredController]) { + let wantIDs = Set(forwarded.map { ObjectIdentifier($0.controller) }) + for slot in slots where !wantIDs.contains(ObjectIdentifier(slot.controller)) { + closeSlot(slot) } - // Hand the system gestures back to the OS before letting the old pad go — outside a - // stream the share button's screenshot and the Home overlay are the user's, not ours. - if let old = bound { - for element in old.physicalInputProfile.elements.values { - element.preferredSystemGestureState = .enabled - } + for dc in forwarded where !slots.contains(where: { $0.controller === dc.controller }) { + openSlot(dc) } - if let motion = bound?.motion { - motion.valueChangedHandler = nil - // Power the sensors back down — left active they keep the pad streaming - // gyro/accel over Bluetooth (battery drain) long after the session. - if motion.sensorsRequireManualActivation { motion.sensorsActive = false } - } - bound = controller - guard let c = controller, let ext = c.extendedGamepad else { return } + // A chord-holding pad may have just unplugged — re-evaluate so a stale hold disarms. + updateEscapeChord() + } - ext.valueChangedHandler = { [weak self] g, _ in - MainActor.assumeIsolated { self?.sync(g) } + /// Open one forwarded controller on its assigned wire index: attach GC handlers, claim its + /// system gestures, declare its kind (GamepadArrival — before any input), then wake the host + /// pad and send its initial state. Skipped when the pad has no wire index (every slot taken) + /// or exposes no extended profile. + private func openSlot(_ dc: GamepadManager.DiscoveredController) { + guard let pad = manager.padIndex(for: dc), let ext = dc.controller.extendedGamepad else { return } + let c = dc.controller + let slot = Slot(controller: c, pad: UInt32(pad), pref: dc.kind) + slots.append(slot) + + ext.valueChangedHandler = { [weak self, weak slot] g, _ in + MainActor.assumeIsolated { if let self, let slot { self.sync(slot, g) } } } // Claim EVERY element's system gesture while this pad drives a stream. The OS attaches // gestures to several controller buttons — share/create → local screenshot/recording, // Home → Game Center overlay (iOS) / Launchpad's Games folder (macOS) — and with a // gesture attached the press is the system's, not the game's. During capture the remote // session IS the game: the share button must reach the host (e.g. Steam screenshots), - // the PS button must open the host's Steam overlay. Restored to .enabled on unbind. + // the PS button must open the host's Steam overlay. Restored to .enabled on close. for element in c.physicalInputProfile.elements.values { element.preferredSystemGestureState = .disabled } @@ -153,42 +187,83 @@ public final class GamepadCapture { // `extendedGamepad.buttonHome` is unreliable/often nil even when the physical element // exists. On tvOS the element is absent (reserved) → nil, the whole block no-ops. if let home = c.physicalInputProfile.buttons[GCInputButtonHome] { - home.pressedChangedHandler = { [weak self] _, _, pressed in - MainActor.assumeIsolated { self?.sendGuide(down: pressed) } + home.pressedChangedHandler = { [weak self, weak slot] _, _, pressed in + MainActor.assumeIsolated { if let self, let slot { self.sendGuide(slot, down: pressed) } } } } - // Wake the host pad immediately (pads are created lazily from the first event; - // a DualSense's UHID handshake + initial lightbar write only start then). - connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0)) - sync(ext) + // Declare this pad's controller KIND before any of its input, so the host builds a + // matching virtual device (mixed types — pad 0 a DualSense, pad 1 an Xbox pad). The core + // re-sends it a few times against datagram loss; an older host ignores it and uses the + // session-default kind. Then wake the host pad (pads are created lazily from the first + // event; a DualSense's UHID handshake + initial lightbar write only start then). + connection.send(.gamepadArrival(pref: slot.pref.rawValue, pad: slot.pad)) + connection.send(.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: slot.pad)) + sync(slot, ext) if let tp = Self.touchpad(ext) { - tp.primary.valueChangedHandler = { [weak self] _, x, y in - MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) } + tp.primary.valueChangedHandler = { [weak self, weak slot] _, x, y in + MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 0, x: x, y: y) } } } - tp.secondary.valueChangedHandler = { [weak self] _, x, y in - MainActor.assumeIsolated { self?.touch(finger: 1, x: x, y: y) } + tp.secondary.valueChangedHandler = { [weak self, weak slot] _, x, y in + MainActor.assumeIsolated { if let self, let slot { self.touch(slot, finger: 1, x: x, y: y) } } } } if let motion = c.motion { if motion.sensorsRequireManualActivation { motion.sensorsActive = true } - motion.valueChangedHandler = { [weak self] m in - MainActor.assumeIsolated { self?.forwardMotion(m) } + motion.valueChangedHandler = { [weak self, weak slot] m in + MainActor.assumeIsolated { if let self, let slot { self.forwardMotion(slot, m) } } } } } - /// Snapshot the profile into wire state and send every transition since the last one. - private func sync(_ g: GCExtendedGamepad) { + /// Flush a slot's held wire state (so nothing sticks down host-side) and signal the host to tear + /// its virtual device down (GamepadRemove), then detach GC handlers, hand the system gestures + /// back, and power the sensors down. Wire-only until the GC cleanup, so it is safe even when the + /// device already physically unplugged. Mirrors pf-client-core's `close_slot_at`. + private func closeSlot(_ slot: Slot) { + flush(slot) + // Sent after the flush so the core stamps it with a seq past the zeroing snapshots; the host + // seq-gates it, so a reordered snapshot can't resurrect the removed pad. + connection.send(.gamepadRemove(pad: slot.pad)) + let c = slot.controller + if let ext = c.extendedGamepad { + ext.valueChangedHandler = nil + let tp = Self.touchpad(ext) + tp?.primary.valueChangedHandler = nil + tp?.secondary.valueChangedHandler = nil + } + c.physicalInputProfile.buttons[GCInputButtonHome]?.pressedChangedHandler = nil + // Hand the system gestures back to the OS before letting the pad go — outside a stream the + // share button's screenshot and the Home overlay are the user's, not ours. + for element in c.physicalInputProfile.elements.values { + element.preferredSystemGestureState = .enabled + } + if let motion = c.motion { + motion.valueChangedHandler = nil + // Power the sensors back down — left active they keep the pad streaming gyro/accel + // over Bluetooth (battery drain) long after the session. + if motion.sensorsRequireManualActivation { motion.sensorsActive = false } + } + slots.removeAll { $0 === slot } + } + + private func closeAllSlots() { + while let slot = slots.first { closeSlot(slot) } + chordTimer?.invalidate() + chordTimer = nil + } + + /// Snapshot the profile into a slot's wire state and send every transition since the last one, + /// tagged with the slot's wire pad index. + private func sync(_ slot: Slot, _ g: GCExtendedGamepad) { guard !suspended else { return } let newButtons = Self.buttonMask(g) - updateEscapeChord(newButtons) - let changed = newButtons ^ buttons + let changed = newButtons ^ slot.buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { - connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0)) + connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: slot.pad)) } - buttons = newButtons + slot.buttons = newButtons } let newAxes: [Int32] = [ Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), @@ -198,22 +273,23 @@ public final class GamepadCapture { Int32((g.leftTrigger.value * 255).rounded()), Int32((g.rightTrigger.value * 255).rounded()), ] - for (i, v) in newAxes.enumerated() where v != axes[i] { - connection.send(.gamepadAxis(UInt32(i), value: v, pad: 0)) - axes[i] = v + for (i, v) in newAxes.enumerated() where v != slot.axes[i] { + connection.send(.gamepadAxis(UInt32(i), value: v, pad: slot.pad)) + slot.axes[i] = v } + updateEscapeChord() } /// Forward the guide (Home/PS) transition directly — it's kept out of `buttonMask` (the legacy - /// `buttonHome` element is unreliable). Folds into `buttons` so a held PS button is released by - /// `releaseAll` on focus loss just like the others. - private func sendGuide(down: Bool) { + /// `buttonHome` element is unreliable). Folds into the slot's `buttons` so a held PS button is + /// released by `flush` on focus loss / close just like the others. + private func sendGuide(_ slot: Slot, down: Bool) { guard !suspended else { return } let bit = GamepadWire.guide - let now = down ? (buttons | bit) : (buttons & ~bit) - guard now != buttons else { return } - connection.send(.gamepadButton(bit, down: down, pad: 0)) - buttons = now + let now = down ? (slot.buttons | bit) : (slot.buttons & ~bit) + guard now != slot.buttons else { return } + connection.send(.gamepadButton(bit, down: down, pad: slot.pad)) + slot.buttons = now } private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 { @@ -234,7 +310,7 @@ public final class GamepadCapture { if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder } // guide (Home/PS) is NOT read here — it's forwarded directly by the Home button's - // pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `rebind`. + // pressedChangedHandler (the legacy `buttonHome` element is unreliable). See `openSlot`. if g.buttonA.isPressed { b |= GamepadWire.a } if g.buttonB.isPressed { b |= GamepadWire.b } if g.buttonX.isPressed { b |= GamepadWire.x } @@ -262,29 +338,29 @@ public final class GamepadCapture { return nil } - /// One touchpad finger moved. GC reports ±1 positions and snaps to exactly (0, 0) on - /// lift — treated as the lift signal (a real finger landing on the precise center + /// One touchpad finger moved on a slot's pad. GC reports ±1 positions and snaps to exactly + /// (0, 0) on lift — treated as the lift signal (a real finger landing on the precise center /// momentarily reads as a lift; harmless for a 1-in-65k coincidence). - private func touch(finger: Int, x: Float, y: Float) { + private func touch(_ slot: Slot, finger: Int, x: Float, y: Float) { guard !suspended else { return } let lifted = x == 0 && y == 0 if lifted { - if fingerActive[finger] { - fingerActive[finger] = false - connection.sendTouchpad(finger: UInt8(finger), active: false, x: 0, y: 0) + if slot.fingerActive[finger] { + slot.fingerActive[finger] = false + connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: false, x: 0, y: 0) } return } - fingerActive[finger] = true + slot.fingerActive[finger] = true let w = GamepadWire.touchpad(x: x, y: y) - connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y) + connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(finger), active: true, x: w.x, y: w.y) } - private func forwardMotion(_ m: GCMotion) { + private func forwardMotion(_ slot: Slot, _ m: GCMotion) { guard !suspended else { return } let now = DispatchTime.now().uptimeNanoseconds - guard now &- lastMotionNs >= Self.motionIntervalNs else { return } - lastMotionNs = now + guard now &- slot.lastMotionNs >= Self.motionIntervalNs else { return } + slot.lastMotionNs = now // Total acceleration in g: gravity + user when split, else the raw vector. let ax: Float let ay: Float @@ -301,6 +377,7 @@ public final class GamepadCapture { let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG connection.sendMotion( + pad: UInt8(slot.pad), gyro: ( GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), @@ -313,13 +390,12 @@ public final class GamepadCapture { )) } - /// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The - /// host's virtual pad returns to rest instead of running with the last state. - /// Arm the disconnect timer when the full chord lands, disarm the moment any of the four - /// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the - /// handler won't fire again until something moves. - private func updateEscapeChord(_ newButtons: UInt32) { - let held = newButtons & Self.escapeChord == Self.escapeChord + /// Arm the disconnect timer when ANY forwarded pad holds the full escape chord, disarm the + /// moment none do — a release, or the holding pad unplugged (pf-client-core's `chord_held` is + /// likewise any-slot). GC events only arrive on state CHANGES, so a held chord needs the timer: + /// the handler won't fire again until something moves. + private func updateEscapeChord() { + let held = slots.contains { $0.buttons & Self.escapeChord == Self.escapeChord } if held, chordTimer == nil { let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in Task { @MainActor in self?.onDisconnectRequest?() } @@ -332,20 +408,31 @@ public final class GamepadCapture { } } + /// Unwind everything a slot holds on the wire: button-ups, neutral axes, lifted fingers. The + /// host's virtual pad returns to rest instead of running with the last state. Wire events only + /// (no GC calls) — safe against an already-removed device. Does NOT close the slot or send + /// GamepadRemove (that's `closeSlot`). + private func flush(_ slot: Slot) { + for bit in GamepadWire.allButtons where slot.buttons & bit != 0 { + connection.send(.gamepadButton(bit, down: false, pad: slot.pad)) + } + slot.buttons = 0 + for (i, v) in slot.axes.enumerated() where v != 0 { + connection.send(.gamepadAxis(UInt32(i), value: 0, pad: slot.pad)) + slot.axes[i] = 0 + } + for (f, active) in slot.fingerActive.enumerated() where active { + connection.sendTouchpad(pad: UInt8(slot.pad), finger: UInt8(f), active: false, x: 0, y: 0) + slot.fingerActive[f] = false + } + } + + /// Flush every open slot's held state (app deactivation) — keeps the slots open (GC just stops + /// delivering; resume re-syncs), disarms the escape chord. Distinct from `closeAllSlots`, which + /// also sends GamepadRemove and detaches handlers. private func releaseAll() { chordTimer?.invalidate() chordTimer = nil - for bit in GamepadWire.allButtons where buttons & bit != 0 { - connection.send(.gamepadButton(bit, down: false, pad: 0)) - } - buttons = 0 - for (i, v) in axes.enumerated() where v != 0 { - connection.send(.gamepadAxis(UInt32(i), value: 0, pad: 0)) - axes[i] = 0 - } - for (f, active) in fingerActive.enumerated() where active { - connection.sendTouchpad(finger: UInt8(f), active: false, x: 0, y: 0) - fingerActive[f] = false - } + for slot in slots { flush(slot) } } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index ddd38d90..92798f59 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -1,20 +1,23 @@ // Host→client 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 1–4), // 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(_ 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 } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index a0406f02..d52c6b20 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -1,14 +1,18 @@ // Controller discovery + selection, app-lifetime. One GamepadManager (`.shared`) watches // GCController connect/disconnect from launch, so the Settings page shows live controller // state without a session, and the session components (GamepadCapture / GamepadFeedback) -// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0. +// follow `forwarded` — every forwarded controller is streamed to the host, each on its own +// wire pad index (pf-client-core parity; up to `GamepadWire.maxPads`). // -// Selection: the user can pin a controller in Settings (persisted under -// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently -// connected extended gamepad wins. GCController has no stable hardware serial, so the pin -// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins); -// identical twin controllers may swap a pin across reconnects, which the Settings footer -// documents. +// Selection (mirrors pf-client-core's `forwarded_ids` + slot model): with no pin, EVERY +// extended controller is forwarded — each assigned a stable lowest-free pad index held for +// its forwarded lifetime, so a disconnect frees only its own index and never renumbers the +// others. A pin (Settings, persisted under DefaultsKey.gamepadID) forwards ONLY that one pad +// — an explicit single-player choice. `active` stays the single "primary" pad (the pinned +// one, else the most recently connected extended gamepad) that the Settings / launcher / menu +// UI reads. GCController has no stable hardware serial, so the pin is a fingerprint of +// vendorName|productCategory (+ a connect-order suffix for twins); identical twin controllers +// may swap a pin across reconnects, which the Settings footer documents. // // A singleton (not a SwiftUI environment object) because macOS shows Settings in its own // `Settings{}` scene — there is no common ancestor view to inject from. @@ -60,9 +64,23 @@ public final class GamepadManager: ObservableObject { /// Every detected controller, in connect order (Settings lists these). @Published public private(set) var controllers: [DiscoveredController] = [] - /// The one controller forwarded to the host (pad 0); nil when none qualifies. + /// The single "primary" controller — the pinned one, else the most recently connected + /// extended gamepad; nil when none qualifies. The Settings / launcher / menu UI and the + /// connect-time `resolveType` read this; the streaming input path uses `forwarded`. @Published public private(set) var active: DiscoveredController? + /// The controllers forwarded to the host this session, in wire-pad-index preference order + /// (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad; Automatic forwards + /// every extended controller. GamepadCapture opens a slot per entry and GamepadFeedback routes + /// feedback back to it, each on the index from `padIndex(for:)`. + @Published public private(set) var forwarded: [DiscoveredController] = [] + + /// Stable wire pad index (0..<`GamepadWire.maxPads`) per forwarded controller, keyed by + /// GCController identity. Lowest-free, held while the controller stays forwarded — a + /// disconnect frees only its own index so the others never renumber (pf-client-core's + /// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes. + private var padIndexByController: [ObjectIdentifier: UInt8] = [:] + /// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it /// reselects immediately, so a Settings Picker can bind straight to this. @Published public var preferredID: String { @@ -159,7 +177,52 @@ public final class GamepadManager: ObservableObject { let candidates = controllers.filter(\.isExtended) // The pin wins when present; otherwise the most recently connected extended pad // (list is in connect order). A stale pin falls back to automatic. - active = candidates.last { $0.id == preferredID } ?? candidates.last + let pinned = candidates.last { $0.id == preferredID } + active = pinned ?? candidates.last + // Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad + // (explicit single-player); Automatic forwards every extended controller in connect order + // (oldest→newest), so a game's player numbers are stable across hot-plug churn. + let next = pinned.map { [$0] } ?? candidates + // Update the pad-index assignment BEFORE publishing `forwarded`: @Published emits in + // `willSet`, so GamepadCapture/GamepadFeedback reconcile against `padIndex(for:)` the + // instant this assignment lands — a stale map here would skip a newly-forwarded pad. + assignPadIndices(for: next) + forwarded = next + } + + /// Assign each forwarded controller a stable wire pad index (lowest-free, held while it stays + /// forwarded) — mirrors pf-client-core's slot model, where a disconnect frees only its own + /// index and the others keep theirs. A controller already holding an index keeps it across the + /// churn; a slot beyond `GamepadWire.maxPads` goes unassigned (that pad is not forwarded). + private func assignPadIndices(for next: [DiscoveredController]) { + let live = Set(next.map { ObjectIdentifier($0.controller) }) + padIndexByController = padIndexByController.filter { live.contains($0.key) } + for dc in next { + let key = ObjectIdentifier(dc.controller) + guard padIndexByController[key] == nil, + let free = Self.lowestFreeIndex(Set(padIndexByController.values)) else { continue } + padIndexByController[key] = free + } + } + + /// The lowest wire pad index not already taken, or nil when all `GamepadWire.maxPads` are in + /// use (pf-client-core's `lowest_free_index`). + private static func lowestFreeIndex(_ taken: Set) -> UInt8? { + (0.. UInt8? { + padIndexByController[ObjectIdentifier(controller.controller)] + } + + /// Drop every pad-index assignment and recompute from the current forwarded set — called when + /// a streaming session begins so the assignment starts fresh (a controller pinned before the + /// session forwards as pad 0, not whatever index it held for the Settings list). pf-client-core + /// assigns indices at slot-open time; this reproduces that session-scoped start. + public func resetForwardingAssignment() { + padIndexByController.removeAll() + reselect() } private static func describe(_ c: GCController, id: String) -> DiscoveredController { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift index fc043ec8..0d90c7cc 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -1,10 +1,14 @@ // The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback), -// and the tests — button bits, axis ids, and the touchpad/motion unit conversions. +// and the tests — the pad count, button bits, axis ids, and the touchpad/motion unit conversions. import Foundation /// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`). public enum GamepadWire { + /// Gamepads addressable on the wire — the pad index rides the low byte of `flags` on every + /// per-pad event, 0...15 (`punktfunk_core::input::MAX_PADS`). + public static let maxPads: Int = 16 + public static let dpadUp: UInt32 = 0x0001 public static let dpadDown: UInt32 = 0x0002 public static let dpadLeft: UInt32 = 0x0004 diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift index c74833b6..eee4c8c8 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift @@ -3,6 +3,7 @@ // player-LED-bits → GCControllerPlayerIndex map. All pure functions. import GameController +import PunktfunkCore import XCTest @testable import PunktfunkKit @@ -40,6 +41,43 @@ final class GamepadWireTests: XCTestCase { XCTAssertEqual(GamepadWire.axisRT, 5) } + func testPadIndexRidesFlagsOnEveryPerPadEvent() { + // The wire pad index is the low byte of `flags` (punktfunk_core::input) on button + axis. + let btn = PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: true, pad: 3) + XCTAssertEqual(btn.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue)) + XCTAssertEqual(btn.code, GamepadWire.a) + XCTAssertEqual(btn.x, 1) + XCTAssertEqual(btn.flags, 3) + let axis = PunktfunkInputEvent.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 5) + XCTAssertEqual(axis.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue)) + XCTAssertEqual(axis.code, GamepadWire.axisRT) + XCTAssertEqual(axis.x, 200) + XCTAssertEqual(axis.flags, 5) + // Single-controller path stays byte-identical: pad 0 ⇒ flags 0, exactly as before. + XCTAssertEqual(PunktfunkInputEvent.gamepadButton(GamepadWire.a, down: false, pad: 0).flags, 0) + XCTAssertEqual(PunktfunkInputEvent.gamepadAxis(GamepadWire.axisLSX, value: 0, pad: 0).flags, 0) + } + + func testArrivalAndRemoveWireLayout() { + // GamepadArrival (kind 14): code = the GamepadType wire byte, flags = pad index. + let arrival = PunktfunkInputEvent.gamepadArrival( + pref: PunktfunkConnection.GamepadType.dualSense.rawValue, pad: 2) + XCTAssertEqual(arrival.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_ARRIVAL.rawValue)) + XCTAssertEqual(arrival.code, PunktfunkConnection.GamepadType.dualSense.rawValue) // 2 + XCTAssertEqual(arrival.flags, 2) + // The GamepadType raw values ARE the GamepadPref wire bytes the host resolves. + XCTAssertEqual(PunktfunkConnection.GamepadType.xbox360.rawValue, 1) + XCTAssertEqual(PunktfunkConnection.GamepadType.dualSense.rawValue, 2) + XCTAssertEqual(PunktfunkConnection.GamepadType.xboxOne.rawValue, 3) + XCTAssertEqual(PunktfunkConnection.GamepadType.dualShock4.rawValue, 4) + // GamepadRemove (kind 13): flags = pad index (the core stamps the per-pad seq). + let remove = PunktfunkInputEvent.gamepadRemove(pad: 7) + XCTAssertEqual(remove.kind, UInt8(PUNKTFUNK_INPUT_KIND_GAMEPAD_REMOVE.rawValue)) + XCTAssertEqual(remove.flags, 7) + // 16 addressable pads (punktfunk_core::input::MAX_PADS). + XCTAssertEqual(GamepadWire.maxPads, 16) + } + func testTouchpadConversionCorners() { // GC ±1 with +y up → wire 0...65535 with origin top-left, +y down. let topLeft = GamepadWire.touchpad(x: -1, y: 1)