1d605fb781
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>
87 lines
4.3 KiB
Swift
87 lines
4.3 KiB
Swift
// The gamepad wire contract: button bit positions (must match
|
|
// punktfunk_core::input::gamepad), GC→DualSense 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)
|
|
}
|
|
}
|