Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/DualSenseTriggerEffectTests.swift
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

191 lines
8.4 KiB
Swift

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