feat(gamepad): controller discovery + client-negotiated pad type + rich DualSense end to end
The Apple client grows full gamepad support and punktfunk/1 learns to negotiate the virtual pad type: - Protocol: Hello carries a GamepadPref byte (offset 21, the same trailing-byte back-compat pattern as the compositor; echoed resolved in Welcome at 54). Host precedence: explicit client choice > PUNKTFUNK_GAMEPAD env > Xbox 360, DualSense (UHID) only where available. ABI: punktfunk_connect_ex2 + punktfunk_connection_gamepad (connect_ex delegates; ABI_VERSION stays 2 — the trailing byte IS the compat mechanism). punktfunk-client-rs gets --gamepad. - Swift client: GamepadManager (app-lifetime discovery + selection — Settings lists every controller with capabilities/battery/"In use"; exactly ONE pad forwards as pad 0, auto = most recently connected, or pinned), GamepadCapture (snapshot-diff button/axis events, DualSense touchpad + ~250 Hz motion on the rich-input plane, held state released on switch/deactivate/stop), GamepadFeedback (rumble → CoreHaptics per-handle engines; lightbar → GCDeviceLight; player LEDs → playerIndex; adaptive-trigger blocks → the table-driven DualSenseTriggerEffect parser → GCDualSenseAdaptiveTrigger, exact for the 10-zone positional modes). The pad type auto-resolves from the physical controller at connect time, user-overridable in Settings. - Host DualSense fixes surfaced by adversarial review against hid-playstation / SDL / Nielk1 ground truth: input-report sensor/touch offsets were off by one (the kernel read garbage motion + phantom touches), the L2/R2 trigger blocks were swapped (the report is right-trigger-first), feedback now gates on the report's valid-flags (a plain rumble write no longer blanks lightbar/ triggers), and the touchpad rescale clamps to the advertised ABS_MT extents. - Tests: Hello/Welcome trailing-byte back-compat, pick_gamepad precedence, byte-exact input-report layout, valid-flag gating, per-mode trigger-parser table (incl. packed 3-bit zones), wire conversions, and a scripted loopback feedback burst (PUNKTFUNK_TEST_FEEDBACK=1) asserted through the xcframework on the rumble + HID-output planes. Validated: cargo test/clippy/fmt green on macOS + Linux (61 host tests), swift build/test green, test-loopback.sh green, tvOS/iOS targets compile. DualSense motion sign/scale is derived from the calibration blob, not yet live-verified (constants isolated in GamepadWire). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// 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>) -> 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) }
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user