// Gamepad capture → punktfunk/1 datagrams. Forwards exactly ONE controller — whatever // GamepadManager selected — as pad 0, for the lifetime of a streaming session. // // 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. // // DualSense 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. // // 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 #else import UIKit #endif import Combine import Foundation import GameController /// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`). public enum GamepadWire { public static let dpadUp: UInt32 = 0x0001 public static let dpadDown: UInt32 = 0x0002 public static let dpadLeft: UInt32 = 0x0004 public static let dpadRight: UInt32 = 0x0008 public static let start: UInt32 = 0x0010 public static let back: UInt32 = 0x0020 public static let leftStickClick: UInt32 = 0x0040 public static let rightStickClick: UInt32 = 0x0080 public static let leftShoulder: UInt32 = 0x0100 public static let rightShoulder: UInt32 = 0x0200 public static let guide: UInt32 = 0x0400 public static let a: UInt32 = 0x1000 public static let b: UInt32 = 0x2000 public static let x: UInt32 = 0x4000 public static let y: UInt32 = 0x8000 /// DualSense touchpad click (Moonlight's extended-button bit position). public static let touchpadClick: UInt32 = 0x10_0000 public static let allButtons: [UInt32] = [ dpadUp, dpadDown, dpadLeft, dpadRight, start, back, leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, a, b, x, y, touchpadClick, ] public static let axisLSX: UInt32 = 0 public static let axisLSY: UInt32 = 1 public static let axisRSX: UInt32 = 2 public static let axisRSY: UInt32 = 3 public static let axisLT: UInt32 = 4 public static let axisRT: UInt32 = 5 /// Raw DualSense gyro units per rad/s: hid-playstation's calibration over the host's /// fixed blob resolves to 20 LSB per deg/s. public static let gyroLSBPerRadS: Float = 20 * 180 / .pi /// Raw DualSense accelerometer units per g (same derivation). public static let accelLSBPerG: Float = 10_000 /// GC touchpad coordinates (±1, +y up) → wire (0...65535, origin top-left, +y down). public static func touchpad(x: Float, y: Float) -> (x: UInt16, y: UInt16) { let wx = ((x.clamped(to: -1...1) + 1) / 2 * 65535).rounded() let wy = ((1 - y.clamped(to: -1...1)) / 2 * 65535).rounded() return (UInt16(wx), UInt16(wy)) } /// Scale + clamp one motion component into the raw signed-16 sensor domain. public static func motionRaw(_ value: Float, scale: Float) -> Int16 { Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) } } extension Float { fileprivate func clamped(to range: ClosedRange) -> Float { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } @MainActor public final class GamepadCapture { private let connection: PunktfunkConnection private let manager: GamepadManager private var activeSub: 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 /// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate). private static let motionIntervalNs: UInt64 = 4_000_000 public init(connection: PunktfunkConnection, manager: GamepadManager) { self.connection = connection self.manager = manager } 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) } } #if os(macOS) let resign = NSApplication.willResignActiveNotification let activate = NSApplication.didBecomeActiveNotification #else let resign = UIApplication.willResignActiveNotification let activate = UIApplication.didBecomeActiveNotification #endif observers.append(NotificationCenter.default.addObserver( forName: resign, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.suspended = true self?.releaseAll() } }) observers.append(NotificationCenter.default.addObserver( forName: activate, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } self.suspended = false if let ext = self.bound?.extendedGamepad { self.sync(ext) } } }) } public func stop() { releaseAll() rebind(to: nil) activeSub = 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 (ext as? GCDualSenseGamepad)?.touchpadPrimary.valueChangedHandler = nil (ext as? GCDualSenseGamepad)?.touchpadSecondary.valueChangedHandler = nil } 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 } ext.valueChangedHandler = { [weak self] g, _ in MainActor.assumeIsolated { self?.sync(g) } } // The Home/PS button (→ guide; the host maps it to the DualSense PS / Xbox guide bit) does // NOT reliably fire the gamepad's valueChangedHandler on macOS, so its presses were dropped. // A dedicated handler re-syncs on every Home transition. ext.buttonHome?.pressedChangedHandler = { [weak self] _, _, _ in MainActor.assumeIsolated { guard let self, let g = self.bound?.extendedGamepad else { return } self.sync(g) } } // 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) if let ds = ext as? GCDualSenseGamepad { ds.touchpadPrimary.valueChangedHandler = { [weak self] _, x, y in MainActor.assumeIsolated { self?.touch(finger: 0, x: x, y: y) } } ds.touchpadSecondary.valueChangedHandler = { [weak self] _, x, y in MainActor.assumeIsolated { self?.touch(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) } } } } /// Snapshot the profile into wire state and send every transition since the last one. private func sync(_ g: GCExtendedGamepad) { guard !suspended else { return } let newButtons = Self.buttonMask(g) let changed = newButtons ^ buttons if changed != 0 { for bit in GamepadWire.allButtons where changed & bit != 0 { connection.send(.gamepadButton(bit, down: newButtons & bit != 0, pad: 0)) } buttons = newButtons } let newAxes: [Int32] = [ Int32((g.leftThumbstick.xAxis.value * 32767).rounded()), Int32((g.leftThumbstick.yAxis.value * 32767).rounded()), Int32((g.rightThumbstick.xAxis.value * 32767).rounded()), Int32((g.rightThumbstick.yAxis.value * 32767).rounded()), 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 } } private static func buttonMask(_ g: GCExtendedGamepad) -> UInt32 { var b: UInt32 = 0 if g.dpad.up.isPressed { b |= GamepadWire.dpadUp } if g.dpad.down.isPressed { b |= GamepadWire.dpadDown } if g.dpad.left.isPressed { b |= GamepadWire.dpadLeft } if g.dpad.right.isPressed { b |= GamepadWire.dpadRight } if g.buttonMenu.isPressed { b |= GamepadWire.start } if g.buttonOptions?.isPressed == true { b |= GamepadWire.back } if g.leftThumbstickButton?.isPressed == true { b |= GamepadWire.leftStickClick } if g.rightThumbstickButton?.isPressed == true { b |= GamepadWire.rightStickClick } if g.leftShoulder.isPressed { b |= GamepadWire.leftShoulder } if g.rightShoulder.isPressed { b |= GamepadWire.rightShoulder } if g.buttonHome?.isPressed == true { b |= GamepadWire.guide } if g.buttonA.isPressed { b |= GamepadWire.a } if g.buttonB.isPressed { b |= GamepadWire.b } if g.buttonX.isPressed { b |= GamepadWire.x } if g.buttonY.isPressed { b |= GamepadWire.y } if (g as? GCDualSenseGamepad)?.touchpadButton.isPressed == true { b |= GamepadWire.touchpadClick } return b } /// 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 /// momentarily reads as a lift; harmless for a 1-in-65k coincidence). private func touch(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) } return } fingerActive[finger] = true let w = GamepadWire.touchpad(x: x, y: y) connection.sendTouchpad(finger: UInt8(finger), active: true, x: w.x, y: w.y) } private func forwardMotion(_ m: GCMotion) { guard !suspended else { return } let now = DispatchTime.now().uptimeNanoseconds guard now &- lastMotionNs >= Self.motionIntervalNs else { return } lastMotionNs = now // Total acceleration in g: gravity + user when split, else the raw vector. let ax: Float let ay: Float let az: Float if m.hasGravityAndUserAcceleration { ax = Float(m.gravity.x + m.userAcceleration.x) ay = Float(m.gravity.y + m.userAcceleration.y) az = Float(m.gravity.z + m.userAcceleration.z) } else { ax = Float(m.acceleration.x) ay = Float(m.acceleration.y) az = Float(m.acceleration.z) } let gs = GamepadWire.gyroLSBPerRadS let as_ = GamepadWire.accelLSBPerG connection.sendMotion( gyro: ( GamepadWire.motionRaw(Float(m.rotationRate.x), scale: gs), GamepadWire.motionRaw(Float(m.rotationRate.y), scale: gs), GamepadWire.motionRaw(Float(m.rotationRate.z), scale: gs) ), accel: ( GamepadWire.motionRaw(ax, scale: as_), GamepadWire.motionRaw(ay, scale: as_), GamepadWire.motionRaw(az, scale: as_) )) } /// 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. private func releaseAll() { 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 } } }