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:
2026-06-11 16:28:33 +02:00
parent d86896da16
commit 1d605fb781
24 changed files with 2321 additions and 142 deletions
@@ -0,0 +1,315 @@
// Hostclient gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
// HID-output (0xCD) planes and replays them on the active physical controller
//
// rumble CHHapticEngine players (per-handle localities when the pad has them,
// one combined engine otherwise),
// lightbar GCDeviceLight,
// player LEDs GCController.playerIndex (the DS bit patterns map to player 14),
// trigger FX DualSenseTriggerEffect.parse GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on DualSense sessions 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.
import Combine
import CoreHaptics
import Foundation
import GameController
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
private final class FeedbackStopFlag: @unchecked Sendable {
private let lock = NSLock()
private var stopped = false
var isStopped: Bool {
lock.lock()
defer { lock.unlock() }
return stopped
}
func stop() {
lock.lock()
stopped = true
lock.unlock()
}
}
/// Rumble CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
/// amplitude and torn down on retarget; players run only while their motor is on, so an
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
/// downgrade to silence rumble is best-effort by design.
private final class RumbleRenderer: @unchecked Sendable {
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private struct Motor {
let engine: CHHapticEngine
let player: CHHapticAdvancedPatternPlayer
var playing = false
}
private var controller: GCController?
private var low: Motor?
private var high: Motor?
private var broken = false
func retarget(_ c: GCController?) {
queue.async {
self.teardown()
self.controller = c
self.broken = false
}
}
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async {
guard !self.broken else { return }
if (lowAmp != 0 || highAmp != 0), self.low == nil, self.high == nil {
self.setup()
}
if self.high != nil {
self.drive(&self.low, Float(lowAmp) / 65535)
self.drive(&self.high, Float(highAmp) / 65535)
} else {
// Combined engine: whichever motor is stronger wins.
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
}
}
}
func stop() {
queue.sync { self.teardown() }
}
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger.
private func setup() {
guard let haptics = controller?.haptics else { return }
let localities = haptics.supportedLocalities
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
low = makeMotor(haptics, .leftHandle)
high = makeMotor(haptics, .rightHandle)
} else {
low = makeMotor(haptics, .default)
}
if low == nil && high == nil {
broken = true // no usable engine (e.g. Siri Remote) stay silent
}
}
private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
do {
try engine.start()
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
relativeTime: 0,
duration: TimeInterval(GCHapticDurationInfinite))
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
return Motor(engine: engine, player: player)
} catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil
}
}
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
guard var m = motor else { return }
do {
if amplitude > 0 {
if !m.playing {
try m.player.start(atTime: CHHapticTimeImmediate)
m.playing = true
}
try m.player.sendParameters(
[CHHapticDynamicParameter(
parameterID: .hapticIntensityControl,
value: amplitude, relativeTime: 0)],
atTime: CHHapticTimeImmediate)
} else if m.playing {
try m.player.stop(atTime: CHHapticTimeImmediate)
m.playing = false
}
motor = m
} catch {
log.warning("haptic update failed — rumble disabled: \(error, privacy: .public)")
teardown()
broken = true
}
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
try? m.player.stop(atTime: CHHapticTimeImmediate)
m.engine.stop()
}
low = nil
high = nil
}
}
public final class GamepadFeedback {
private let connection: PunktfunkConnection
private let flag = FeedbackStopFlag()
private let drainDone = DispatchSemaphore(value: 0)
private var drainStarted = false
private let rumble = RumbleRenderer()
private var activeSub: 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]
public init(connection: PunktfunkConnection, manager: GamepadManager) {
self.connection = connection
Task { @MainActor in
self.activeSub = manager.$active.sink { [weak self] dc in
MainActor.assumeIsolated { self?.retarget(dc?.controller) }
}
}
}
/// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player
/// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit
/// count, clamped to the four indices GC offers.
public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex {
switch bits & 0x1F {
case 0: return .indexUnset
case 0b00100: return .index1
case 0b01010: return .index2
case 0b10101: return .index3
case 0b11011: return .index4
default:
let lit = (bits & 0x1F).nonzeroBitCount
return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1
}
}
public func start() {
guard !drainStarted else { return }
drainStarted = true
// No hidout traffic can exist on a non-DualSense session poll that plane
// nonblocking there and let rumble own the wait.
let hidTimeout: UInt32 = connection.resolvedGamepad == .dualSense ? 10 : 0
let thread = Thread { [connection, flag, drainDone, weak self] in
while !flag.isStopped {
do {
if let r = try connection.nextRumble(timeoutMs: 10), r.pad == 0 {
self?.rumble.apply(low: r.low, high: r.high)
}
// Drain a BOUNDED burst of hidout events: only the first poll waits,
// and the cap + stop check keep sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) from starving the rumble poll above
// or blocking stop() past one cycle.
var burst = 0
while burst < 64, !flag.isStopped,
let ev = try connection.nextHidOutput(
timeoutMs: burst == 0 ? hidTimeout : 0) {
self?.render(ev)
burst += 1
}
} catch {
break // .closed (or fatal) the session is over
}
}
drainDone.signal()
}
thread.name = "punktfunk-feedback"
thread.qualityOfService = .userInteractive
thread.start()
}
/// Stop the drain and silence the motors. Blocks until the drain thread exits ( one
/// poll cycle) call off the main actor, before `connection.close()`.
public func stop() {
flag.stop()
if drainStarted {
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
}
}
private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
DispatchQueue.main.async {
MainActor.assumeIsolated { self.apply(ev) }
}
}
@MainActor
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(
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)
case let .triggerEffect(pad, which, effect):
guard pad == 0, which < 2 else { return }
let parsed = DualSenseTriggerEffect.parse(effect)
lastTrigger[Int(which)] = parsed
if let trigger = adaptiveTrigger(which) {
parsed.apply(to: trigger)
}
}
}
@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(
red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
}
if let bits = lastPlayerBits {
controller?.playerIndex = Self.playerIndex(forBits: bits)
}
for which in 0..<2 {
if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
effect.apply(to: trigger)
}
}
}
@MainActor
private func reset(_ controller: GCController?) {
guard let c = controller else { return }
c.playerIndex = .indexUnset
if let ds = c.extendedGamepad as? GCDualSenseGamepad {
ds.leftTrigger.setModeOff()
ds.rightTrigger.setModeOff()
}
}
@MainActor
private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
return which == 0 ? ds.leftTrigger : ds.rightTrigger
}
}