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,190 @@
// Table-driven coverage of the DualSense trigger-effect parser: every supported mode
// byte, the packed 10-zone decoding, and the it-must-never-trap guarantee for garbage.
// Pure data data, no controller needed.
import XCTest
@testable import PunktfunkKit
final class DualSenseTriggerEffectTests: XCTestCase {
/// Build an 11-byte block: mode + up to 10 params (zero-padded).
private func block(_ mode: UInt8, _ params: [UInt8] = []) -> [UInt8] {
var b = [mode] + params
while b.count < 11 { b.append(0) }
return b
}
/// Pack a 10-zone effect: active-zone bitmask (p0/p1) + 3-bit values (p2...p5).
private func zones(_ mode: UInt8, values: [UInt8], extra: [UInt8] = []) -> [UInt8] {
precondition(values.count == 10)
var mask: UInt16 = 0
var packed: UInt32 = 0
for (i, v) in values.enumerated() where v > 0 {
mask |= 1 << UInt16(i)
packed |= UInt32(v & 0x07) << (3 * UInt32(i))
}
var p: [UInt8] = [
UInt8(mask & 0xFF), UInt8(mask >> 8),
UInt8(packed & 0xFF), UInt8((packed >> 8) & 0xFF),
UInt8((packed >> 16) & 0xFF), UInt8((packed >> 24) & 0xFF),
]
p += extra
return block(mode, p)
}
func testOffModes() {
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x00)), .off)
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0x05, [9, 9, 9])), .off)
XCTAssertEqual(DualSenseTriggerEffect.parse(block(0xFC)), .off)
}
func testUnknownAndGarbageNeverTrap() {
XCTAssertEqual(DualSenseTriggerEffect.parse([]), .off)
XCTAssertEqual(DualSenseTriggerEffect.parse([0x99]), .off)
XCTAssertEqual(DualSenseTriggerEffect.parse([0x42, 0xFF]), .off)
// Short blocks of known modes parse with zero-padded params.
XCTAssertEqual(
DualSenseTriggerEffect.parse([0x01, 128]),
.feedback(start: 128.0 / 255, strength: 1.0 / 8))
// Every possible mode byte with random-ish params returns *something*.
for mode in UInt8.min...UInt8.max {
_ = DualSenseTriggerEffect.parse(block(mode, [255, 255, 255, 255, 255, 255, 255, 255, 255, 255]))
}
}
func testLegacySimpleModes() {
// 0x01: continuous resistance (start, force).
XCTAssertEqual(
DualSenseTriggerEffect.parse(block(0x01, [51, 255])),
.feedback(start: 51.0 / 255, strength: 1))
// Zero force still resists faintly (an active effect is never strength 0).
XCTAssertEqual(
DualSenseTriggerEffect.parse(block(0x01, [0, 0])),
.feedback(start: 0, strength: 1.0 / 8))
// 0x02: section between start and end.
guard case let .weapon(start, end, strength) =
DualSenseTriggerEffect.parse(block(0x02, [51, 204])) else {
return XCTFail("0x02 should parse as weapon")
}
XCTAssertEqual(start, 51.0 / 255, accuracy: 0.001)
XCTAssertEqual(end, 204.0 / 255, accuracy: 0.001)
XCTAssertEqual(strength, 1)
// 0x06: vibration Nielk1's Simple_Vibration order is (frequency, amplitude, position).
XCTAssertEqual(
DualSenseTriggerEffect.parse(block(0x06, [30, 128, 51])),
.vibration(start: 51.0 / 255, amplitude: 128.0 / 255, frequency: 30.0 / 255))
}
func testFeedback0x21UniformSuffixSimplifies() {
// Zones 3...9 all at strength 4 one simple feedback call from zone 3.
let b = zones(0x21, values: [0, 0, 0, 4, 4, 4, 4, 4, 4, 4])
XCTAssertEqual(
DualSenseTriggerEffect.parse(b),
.feedback(start: 3.0 / 9, strength: 5.0 / 8))
}
func testFeedback0x21MixedGoesPositional() {
let b = zones(0x21, values: [0, 0, 2, 0, 0, 7, 0, 0, 0, 1])
guard case let .positionalFeedback(strengths) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("mixed zones should parse positional")
}
XCTAssertEqual(strengths.count, 10)
XCTAssertEqual(strengths[2], 3.0 / 8) // 3-bit value 2 (2+1)/8
XCTAssertEqual(strengths[5], 8.0 / 8)
XCTAssertEqual(strengths[9], 2.0 / 8)
XCTAssertEqual(strengths[0], 0) // inactive zone
XCTAssertEqual(strengths[3], 0)
}
func testFeedback0x21EmptyMaskIsOff() {
XCTAssertEqual(
DualSenseTriggerEffect.parse(zones(0x21, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
.off)
}
func testWeapon0x25() {
// Nielk1 Weapon = mode 0x25: start zone 2, end zone 7 from the mask; p2 = strength.
var b = zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 1, 0, 0])
b[3] = 6 // p2 (block[3] = params[2]): strength, stored minus one
guard case let .weapon(start, end, strength) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("0x25 should parse as weapon")
}
XCTAssertEqual(start, 2.0 / 9, accuracy: 0.001)
XCTAssertEqual(end, 7.0 / 9, accuracy: 0.001)
XCTAssertEqual(strength, 7.0 / 8)
// A single active zone can't form a section.
XCTAssertEqual(
DualSenseTriggerEffect.parse(zones(0x25, values: [0, 0, 1, 0, 0, 0, 0, 0, 0, 0])),
.off)
}
func testBow0x22BecomesSlope() {
// Nielk1 Bow = mode 0x22, draw + snap packed as a 3-bit pair in p2 (p3 always 0).
var b = zones(0x22, values: [0, 1, 0, 0, 0, 0, 0, 0, 1, 0])
b[3] = (7 & 0x07) | ((3 & 0x07) << 3) // p2: draw (low) | snap (bits 3-5)
guard case let .slope(start, end, from, to) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("0x22 should parse as slope")
}
XCTAssertEqual(start, 1.0 / 9, accuracy: 0.001)
XCTAssertEqual(end, 8.0 / 9, accuracy: 0.001)
XCTAssertEqual(from, 8.0 / 8)
XCTAssertEqual(to, 4.0 / 8)
}
func testVibration0x26() {
var b = zones(0x26, values: [0, 0, 0, 0, 0, 5, 5, 5, 5, 5])
b[9] = 40 // p8 (block[9]): frequency Hz
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("0x26 should parse as positional vibration")
}
XCTAssertEqual(amps[4], 0)
XCTAssertEqual(amps[5], 6.0 / 8)
XCTAssertEqual(amps[9], 6.0 / 8)
XCTAssertEqual(freq, 40.0 / 255, accuracy: 0.001)
XCTAssertEqual(
DualSenseTriggerEffect.parse(zones(0x26, values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0])),
.off)
}
func testGalloping0x23() {
// Nielk1 Galloping = mode 0x23: zone range + p3 frequency (p2 is foot timing).
var b = zones(0x23, values: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0])
b[4] = 20 // p3 (block[4]): frequency
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("0x23 should parse as positional vibration")
}
XCTAssertEqual(amps[1], 0)
XCTAssertEqual(amps[2], 0.5)
XCTAssertEqual(amps[6], 0.5)
XCTAssertEqual(amps[7], 0)
XCTAssertEqual(freq, 20.0 / 255, accuracy: 0.001)
}
func testMachine0x27() {
// Nielk1 Machine = mode 0x27: zone range, p2 = two raw 3-bit amplitudes, p3 = frequency.
var b = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0])
b[3] = (2 & 0x07) | ((6 & 0x07) << 3) // p2: amplitude A = 2, B = 6
b[4] = 90 // p3: frequency
guard case let .positionalVibration(amps, freq) = DualSenseTriggerEffect.parse(b) else {
return XCTFail("0x27 should parse as positional vibration")
}
XCTAssertEqual(amps[2], 0)
XCTAssertEqual(amps[3], 6.0 / 7, accuracy: 0.001) // the stronger leg
XCTAssertEqual(amps[8], 6.0 / 7, accuracy: 0.001)
XCTAssertEqual(amps[9], 0)
XCTAssertEqual(freq, 90.0 / 255, accuracy: 0.001)
// Zero amplitudes render as off, whatever the mask says.
var z = zones(0x27, values: [0, 0, 0, 1, 0, 0, 0, 0, 1, 0])
z[3] = 0
XCTAssertEqual(DualSenseTriggerEffect.parse(z), .off)
}
/// The host's PUNKTFUNK_TEST_FEEDBACK burst sends [0x21, 1, 2, 3, ...] make sure the
/// loopback test's expected shape stays a real parse result.
func testScriptedLoopbackBlockParses() {
let scripted: [UInt8] = [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
if case .off = DualSenseTriggerEffect.parse(scripted) {
XCTFail("the scripted test block should parse as a feedback effect")
}
}
}
@@ -0,0 +1,86 @@
// The gamepad wire contract: button bit positions (must match
// punktfunk_core::input::gamepad), GCDualSense touchpad/motion conversions, and the
// player-LED-bits GCControllerPlayerIndex map. All pure functions.
import GameController
import XCTest
@testable import PunktfunkKit
final class GamepadWireTests: XCTestCase {
func testButtonBitsMatchTheRustWireContract() {
// punktfunk_core::input::gamepad constants, spot-checked bit for bit.
XCTAssertEqual(GamepadWire.dpadUp, 0x0001)
XCTAssertEqual(GamepadWire.dpadDown, 0x0002)
XCTAssertEqual(GamepadWire.dpadLeft, 0x0004)
XCTAssertEqual(GamepadWire.dpadRight, 0x0008)
XCTAssertEqual(GamepadWire.start, 0x0010)
XCTAssertEqual(GamepadWire.back, 0x0020)
XCTAssertEqual(GamepadWire.leftStickClick, 0x0040)
XCTAssertEqual(GamepadWire.rightStickClick, 0x0080)
XCTAssertEqual(GamepadWire.leftShoulder, 0x0100)
XCTAssertEqual(GamepadWire.rightShoulder, 0x0200)
XCTAssertEqual(GamepadWire.guide, 0x0400)
XCTAssertEqual(GamepadWire.a, 0x1000)
XCTAssertEqual(GamepadWire.b, 0x2000)
XCTAssertEqual(GamepadWire.x, 0x4000)
XCTAssertEqual(GamepadWire.y, 0x8000)
XCTAssertEqual(GamepadWire.touchpadClick, 0x10_0000)
// Every button is enumerated exactly once (releaseAll walks this list).
let combined: UInt32 = GamepadWire.allButtons.reduce(0) { $0 | $1 }
XCTAssertEqual(combined, 0x0010_F7FF)
XCTAssertEqual(GamepadWire.allButtons.count, 16)
XCTAssertEqual(GamepadWire.allButtons.count, Set(GamepadWire.allButtons).count)
// Axis ids.
XCTAssertEqual(GamepadWire.axisLSX, 0)
XCTAssertEqual(GamepadWire.axisLSY, 1)
XCTAssertEqual(GamepadWire.axisRSX, 2)
XCTAssertEqual(GamepadWire.axisRSY, 3)
XCTAssertEqual(GamepadWire.axisLT, 4)
XCTAssertEqual(GamepadWire.axisRT, 5)
}
func testTouchpadConversionCorners() {
// GC ±1 with +y up wire 0...65535 with origin top-left, +y down.
let topLeft = GamepadWire.touchpad(x: -1, y: 1)
XCTAssertEqual(topLeft.x, 0)
XCTAssertEqual(topLeft.y, 0)
let bottomRight = GamepadWire.touchpad(x: 1, y: -1)
XCTAssertEqual(bottomRight.x, 65535)
XCTAssertEqual(bottomRight.y, 65535)
let center = GamepadWire.touchpad(x: 0, y: 0)
XCTAssertEqual(Int(center.x), 32768, accuracy: 1)
XCTAssertEqual(Int(center.y), 32768, accuracy: 1)
// Out-of-range input clamps instead of wrapping.
let wild = GamepadWire.touchpad(x: 5, y: -7)
XCTAssertEqual(wild.x, 65535)
XCTAssertEqual(wild.y, 65535)
}
func testMotionScalingAndClamping() {
// 20 raw LSB per deg/s one full revolution per second (360 deg/s = 2π rad/s).
XCTAssertEqual(GamepadWire.motionRaw(2 * .pi, scale: GamepadWire.gyroLSBPerRadS), 7200)
// 1 g 10000 raw.
XCTAssertEqual(GamepadWire.motionRaw(1, scale: GamepadWire.accelLSBPerG), 10000)
XCTAssertEqual(GamepadWire.motionRaw(-1, scale: GamepadWire.accelLSBPerG), -10000)
// Saturation, not overflow.
XCTAssertEqual(GamepadWire.motionRaw(100, scale: GamepadWire.accelLSBPerG), Int16.max)
XCTAssertEqual(GamepadWire.motionRaw(-100, scale: GamepadWire.accelLSBPerG), Int16.min)
XCTAssertEqual(GamepadWire.motionRaw(0, scale: GamepadWire.gyroLSBPerRadS), 0)
}
func testPlayerIndexMap() {
// hid-playstation's DualSense player-LED patterns.
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0), .indexUnset)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00100), .index1)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b01010), .index2)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b10101), .index3)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11011), .index4)
// Unknown patterns: lit-count fallback, clamped to GC's four indices.
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00001), .index1)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b00011), .index2)
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b11111), .index4)
// Bits above the 5 LEDs are ignored.
XCTAssertEqual(GamepadFeedback.playerIndex(forBits: 0b1110_0000), .indexUnset)
}
}
@@ -43,17 +43,51 @@ final class LoopbackIntegrationTests: XCTestCase {
XCTAssertGreaterThanOrEqual(lastIndex, 24)
// Input goes the other way (enqueue-only; the host logs the count on close)
// including the touch kinds and the mic uplink plane (the synthetic host counts
// the datagrams; injection/decoding are Linux-side concerns).
// including the touch kinds, gamepad events, the rich-input plane (DualSense
// touchpad/motion), and the mic uplink plane (the synthetic host counts the
// datagrams; injection/decoding are Linux-side concerns).
conn.send(.mouseMove(dx: 1, dy: 2))
conn.send(.key(0x41, down: true))
conn.send(.key(0x41, down: false))
conn.send(.touchDown(id: 0, x: 100, y: 200, surfaceWidth: 1280, surfaceHeight: 720))
conn.send(.touchMove(id: 0, x: 110, y: 210, surfaceWidth: 1280, surfaceHeight: 720))
conn.send(.touchUp(id: 0))
conn.send(.gamepadButton(GamepadWire.a, down: true, pad: 0))
conn.send(.gamepadButton(GamepadWire.a, down: false, pad: 0))
conn.send(.gamepadAxis(GamepadWire.axisLSX, value: 12345, pad: 0))
conn.send(.gamepadAxis(GamepadWire.axisRT, value: 200, pad: 0))
conn.sendTouchpad(finger: 0, active: true, x: 32768, y: 16384)
conn.sendTouchpad(finger: 0, active: false, x: 0, y: 0)
conn.sendMotion(gyro: (100, -100, 0), accel: (0, 0, 10000))
conn.sendMic(Data([0xFC, 0xFF, 0xFE]), seq: 0, ptsNs: 1) // tiny opus-ish frame
conn.sendMic(Data(), seq: 1, ptsNs: 2) // DTX silence frame
// The synthetic host (PUNKTFUNK_TEST_FEEDBACK=1, set by test-loopback.sh) scripts
// one feedback burst on the hostclient planes drain both and verify, end to
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
var rumble: (pad: UInt16, low: UInt16, high: UInt16)?
var hidout: [PunktfunkConnection.HidOutputEvent] = []
let feedbackDeadline = Date().addingTimeInterval(10)
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r }
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
}
XCTAssertEqual(rumble?.pad, 0)
XCTAssertEqual(rumble?.low, 0x4000)
XCTAssertEqual(rumble?.high, 0x8000)
XCTAssertTrue(
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
"missing the scripted lightbar event: \(hidout)")
XCTAssertTrue(
hidout.contains(.playerLEDs(pad: 0, bits: 0b00100)),
"missing the scripted player-LED event: \(hidout)")
XCTAssertTrue(
hidout.contains(.triggerEffect(
pad: 0, which: 1, effect: [0x21, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])),
"missing the scripted trigger event: \(hidout)")
}
conn.close()
XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in
guard case PunktfunkClientError.closed = error else {