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>
189 lines
9.9 KiB
Swift
189 lines
9.9 KiB
Swift
// DualSense adaptive-trigger effect parsing: the host forwards the raw 11-byte trigger
|
||
// parameter block a game wrote to its virtual DualSense (output report 0x02, bytes 11–21
|
||
// for L2 / 22–32 for R2: one mode byte + 10 parameter bytes). The mode values and layouts
|
||
// follow the community-established conventions (Nielk1's TriggerEffectGenerator, ds5w,
|
||
// inputtino) — Sony has never documented them. Parsing is TOTAL: any unknown or short
|
||
// block degrades to `.off`, never traps, so a game using an exotic raw mode can't break
|
||
// the session.
|
||
//
|
||
// `parse` is a pure function (unit-tested without a controller); `apply(to:)` maps the
|
||
// result onto Apple's GCDualSenseAdaptiveTrigger — exact for the 10-zone positional modes
|
||
// (0x21/0x26 → the positional resistiveStrengths/amplitudes APIs), best-effort for the
|
||
// composite ones (bow, galloping) that have no GC equivalent.
|
||
|
||
import Foundation
|
||
import GameController
|
||
|
||
/// A parsed DualSense trigger effect. Positions and strengths are normalized 0...1
|
||
/// (GCDualSenseAdaptiveTrigger's domain); `positional*` carry one value per trigger zone
|
||
/// (10 zones across the travel).
|
||
public enum DualSenseTriggerEffect: Equatable, Sendable {
|
||
case off
|
||
/// Constant resistance from `start` to the end of travel.
|
||
case feedback(start: Float, strength: Float)
|
||
/// Resistance from `start` that releases past `end` (trigger-break / weapon feel).
|
||
case weapon(start: Float, end: Float, strength: Float)
|
||
/// Vibration once the trigger passes `start`.
|
||
case vibration(start: Float, amplitude: Float, frequency: Float)
|
||
/// Per-zone resistance (10 zones).
|
||
case positionalFeedback(strengths: [Float])
|
||
/// Per-zone vibration amplitudes (10 zones) at `frequency`.
|
||
case positionalVibration(amplitudes: [Float], frequency: Float)
|
||
/// Resistance ramping `startStrength` → `endStrength` between `start` and `end`
|
||
/// (the closest GC rendering of the bow effect).
|
||
case slope(start: Float, end: Float, startStrength: Float, endStrength: Float)
|
||
|
||
/// Parse a raw trigger parameter block (`[mode, p0...p9]`, ≤ 11 bytes — shorter blocks
|
||
/// are zero-padded). Never fails: unknown modes are `.off`.
|
||
public static func parse(_ block: [UInt8]) -> DualSenseTriggerEffect {
|
||
guard let mode = block.first else { return .off }
|
||
var p = [UInt8](block.dropFirst())
|
||
if p.count < 10 { p.append(contentsOf: [UInt8](repeating: 0, count: 10 - p.count)) }
|
||
|
||
// Helpers for the rich (0x2x) modes: a 10-bit active-zone mask in p0/p1 and 3-bit
|
||
// per-zone values packed little-endian into the following 4 bytes.
|
||
let zoneMask = UInt16(p[0]) | (UInt16(p[1]) << 8)
|
||
let packed = UInt32(p[2]) | (UInt32(p[3]) << 8) | (UInt32(p[4]) << 16) | (UInt32(p[5]) << 24)
|
||
func zoneValues() -> [UInt8] {
|
||
(0..<10).map { i in
|
||
zoneMask & (1 << i) != 0 ? UInt8((packed >> (3 * UInt32(i))) & 0x07) : 0
|
||
}
|
||
}
|
||
// DualSense 3-bit strengths are 0...7 where an *active* zone's value v renders as
|
||
// (v+1)/8 — a present-but-zero strength still resists slightly.
|
||
func strength01(_ v: UInt8, active: Bool) -> Float {
|
||
active ? Float(v + 1) / 8 : 0
|
||
}
|
||
func zone01(_ z: Int) -> Float { Float(z) / 9 }
|
||
func firstActive() -> Int? { (0..<10).first { zoneMask & (1 << $0) != 0 } }
|
||
func lastActive() -> Int? { (0..<10).last { zoneMask & (1 << $0) != 0 } }
|
||
|
||
switch mode {
|
||
case 0x00, 0x05, 0xF0...0xFF:
|
||
// 0x00/0x05 are the documented off/reset modes; 0xFC/0xFB/0xFA show up from
|
||
// calibration-adjacent writes — all render as off.
|
||
return .off
|
||
|
||
case 0x01:
|
||
// Legacy continuous resistance: p0 = start position, p1 = force (both 0...255).
|
||
return .feedback(start: Float(p[0]) / 255, strength: max(Float(p[1]) / 255, 1.0 / 8))
|
||
|
||
case 0x02:
|
||
// Legacy section: p0 = start, p1 = end (0...255); full-strength inside.
|
||
let s = Float(p[0]) / 255
|
||
let e = Float(p[1]) / 255
|
||
return .weapon(start: min(s, e), end: max(max(s, e), min(s, e) + 0.01), strength: 1)
|
||
|
||
case 0x06:
|
||
// Legacy vibration ("automatic gun") — Nielk1's Simple_Vibration order:
|
||
// p0 = frequency Hz, p1 = amplitude, p2 = start position.
|
||
return .vibration(
|
||
start: Float(p[2]) / 255,
|
||
amplitude: max(Float(p[1]) / 255, 1.0 / 8),
|
||
frequency: Float(p[0]) / 255)
|
||
|
||
case 0x21:
|
||
// Feedback: 10-bit zone mask + 3-bit strength per zone. A uniform suffix maps
|
||
// exactly onto the simple feedback call; mixed strengths use the positional API.
|
||
guard let first = firstActive() else { return .off }
|
||
let values = zoneValues()
|
||
let active = values.enumerated().filter { zoneMask & (1 << $0.offset) != 0 }
|
||
if active.allSatisfy({ $0.element == active[0].element })
|
||
&& active.last?.offset == 9
|
||
&& active.map(\.offset) == Array(first...9)
|
||
{
|
||
return .feedback(
|
||
start: zone01(first), strength: strength01(active[0].element, active: true))
|
||
}
|
||
return .positionalFeedback(
|
||
strengths: values.enumerated().map {
|
||
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
|
||
})
|
||
|
||
case 0x22:
|
||
// Bow (Nielk1 mode byte 0x22): start/end zones + draw strength and snap force
|
||
// packed as a 3-bit pair in p2 (low bits draw, bits 3-5 snap; p3 is always 0).
|
||
// No GC equivalent — render as a slope from draw resistance down to the snap.
|
||
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
|
||
let draw = strength01(p[2] & 0x07, active: true)
|
||
let snap = strength01((p[2] >> 3) & 0x07, active: true)
|
||
return .slope(start: zone01(s), end: zone01(e), startStrength: draw, endStrength: snap)
|
||
|
||
case 0x25:
|
||
// Weapon (Nielk1 mode byte 0x25): zone mask marks the start and end zones,
|
||
// p2 = strength (3-bit, stored minus one).
|
||
guard let s = firstActive(), let e = lastActive(), s < e else { return .off }
|
||
return .weapon(start: zone01(s), end: zone01(e), strength: strength01(p[2] & 0x07, active: true))
|
||
|
||
case 0x26:
|
||
// Vibration: 10-bit zone mask + 3-bit amplitude per zone, p8 = frequency Hz.
|
||
guard zoneMask != 0 else { return .off }
|
||
let amplitudes = zoneValues().enumerated().map {
|
||
strength01($0.element, active: zoneMask & (1 << $0.offset) != 0)
|
||
}
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[8]) / 255)
|
||
|
||
case 0x23:
|
||
// Galloping (Nielk1 mode byte 0x23): start/end zones, p2 = packed foot timing,
|
||
// p3 = frequency Hz. The temporal hoofbeat pattern has no GC equivalent —
|
||
// render as vibration across the active range.
|
||
guard let s = firstActive(), let e = lastActive() else { return .off }
|
||
var amplitudes = [Float](repeating: 0, count: 10)
|
||
for z in s...e { amplitudes[z] = 0.5 }
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
|
||
|
||
case 0x27:
|
||
// Machine (Nielk1 mode byte 0x27): start/end zones, p2 = two 3-bit amplitudes
|
||
// (low bits A, bits 3-5 B — raw 0...7, no minus-one), p3 = frequency Hz. The
|
||
// A/B alternation is temporal — render its stronger leg across the range.
|
||
guard let s = firstActive(), let e = lastActive() else { return .off }
|
||
let amp = Float(max(p[2] & 0x07, (p[2] >> 3) & 0x07)) / 7
|
||
guard amp > 0 else { return .off }
|
||
var amplitudes = [Float](repeating: 0, count: 10)
|
||
for z in s...e { amplitudes[z] = amp }
|
||
return .positionalVibration(amplitudes: amplitudes, frequency: Float(p[3]) / 255)
|
||
|
||
default:
|
||
return .off
|
||
}
|
||
}
|
||
}
|
||
|
||
extension DualSenseTriggerEffect {
|
||
/// Replay this effect on a physical DualSense trigger. Main-thread only (GameController
|
||
/// profile mutation). The GC `frequency` parameter is normalized 0...1 like ours.
|
||
@MainActor
|
||
public func apply(to trigger: GCDualSenseAdaptiveTrigger) {
|
||
switch self {
|
||
case .off:
|
||
trigger.setModeOff()
|
||
case let .feedback(start, strength):
|
||
trigger.setModeFeedbackWithStartPosition(start, resistiveStrength: strength)
|
||
case let .weapon(start, end, strength):
|
||
trigger.setModeWeaponWithStartPosition(start, endPosition: end, resistiveStrength: strength)
|
||
case let .vibration(start, amplitude, frequency):
|
||
trigger.setModeVibrationWithStartPosition(start, amplitude: amplitude, frequency: frequency)
|
||
case let .positionalFeedback(strengths):
|
||
var s = GCDualSenseAdaptiveTrigger.PositionalResistiveStrengths(
|
||
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||
withUnsafeMutableBytes(of: &s.values) { raw in
|
||
let f = raw.bindMemory(to: Float.self)
|
||
for (i, v) in strengths.prefix(10).enumerated() { f[i] = v }
|
||
}
|
||
trigger.setModeFeedback(resistiveStrengths: s)
|
||
case let .positionalVibration(amplitudes, frequency):
|
||
var a = GCDualSenseAdaptiveTrigger.PositionalAmplitudes(
|
||
values: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
||
withUnsafeMutableBytes(of: &a.values) { raw in
|
||
let f = raw.bindMemory(to: Float.self)
|
||
for (i, v) in amplitudes.prefix(10).enumerated() { f[i] = v }
|
||
}
|
||
trigger.setModeVibration(amplitudes: a, frequency: frequency)
|
||
case let .slope(start, end, startStrength, endStrength):
|
||
trigger.setModeSlopeFeedback(
|
||
startPosition: start, endPosition: end,
|
||
startStrength: startStrength, endStrength: endStrength)
|
||
}
|
||
}
|
||
}
|