Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/GamepadWireTests.swift
T
enricobuehler 1d605fb781 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>
2026-06-11 16:28:33 +02:00

87 lines
4.3 KiB
Swift

// 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)
}
}