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:
@@ -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"]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>) -> UInt8? {
|
||||
(0..<UInt8(GamepadWire.maxPads)).first { !taken.contains($0) }
|
||||
}
|
||||
|
||||
/// The wire pad index a forwarded controller streams on, or nil when it isn't forwarded.
|
||||
public func padIndex(for controller: DiscoveredController) -> 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user