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>
191 lines
8.4 KiB
Swift
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")
|
|
}
|
|
}
|
|
}
|