fix(apple): drive DualSense rumble over raw HID (CoreHaptics is silent on macOS)
apple / swift (push) Successful in 54s
release / apple (push) Successful in 5m3s
ci / rust (push) Failing after 31s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m2s
apple / swift (push) Successful in 54s
release / apple (push) Successful in 5m3s
ci / rust (push) Failing after 31s
ci / web (push) Successful in 38s
ci / docs-site (push) Successful in 1m1s
android / android (push) Successful in 3m32s
deb / build-publish (push) Successful in 2m16s
decky / build-publish (push) Successful in 10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m27s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m2s
GameController's CHHapticEngine never reaches the DualSense's motors on macOS — its
adaptive triggers and lightbar work, but rumble stays silent (a documented platform
gap). Drive the motors directly via the DualSense HID output report instead, the way
SDL and the Linux hid-playstation driver do — the same report that already rumbles
the pad on a Linux host. Confirmed live on macOS.
- DualSenseHID (macOS): opens the Sony DualSense via IOHIDManager and writes the USB
(0x02, 48 bytes) and Bluetooth (0x31, 78 bytes + CRC32) output reports through
IOHIDDeviceSetReport. Allowed under the App Sandbox by the existing device.usb +
device.bluetooth entitlements; coexists with GameController (non-seized open).
Flags mirror the kernel driver (COMPATIBLE_VIBRATION | HAPTICS_SELECT +
COMPATIBLE_VIBRATION2); valid_flag1 = 0 so a rumble report leaves the
GameController-managed lightbar / triggers / player LEDs untouched.
- RumbleRenderer routes a DualSense to the HID backend and keeps CoreHaptics for
every other pad, fixing both live sessions and the test panel (shared renderer).
- CoreHaptics path reworked too: bake the target intensity + an explicit sharpness
into the continuous event (the dynamic-parameter scaling is silent on controller
engines) and tear down outside the inout access to fix a latent exclusivity hazard.
Adds a DEBUG-only Settings -> Controllers -> "Test Controller" panel (ControllerTestView
+ ControllerTester) that shows live input and fires rumble / adaptive triggers /
lightbar / player LEDs straight at the pad, with a readout of the active rumble backend
("DualSense HID - USB/Bluetooth"). Used to validate the fix.
Tests: DualSenseHIDTests pins the USB/BT report layout and the BT CRC32 (canonical
0xCBF43926 check vector). Debug + release build clean; gamepad suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
// Raw-HID DualSense rumble for macOS.
|
||||
//
|
||||
// Apple's GameController/CHHapticEngine path does NOT drive the DualSense's rumble motors on
|
||||
// macOS — a documented platform gap: adaptive triggers, lightbar and player LEDs all work
|
||||
// (different APIs), but `CHHapticEngine` output never reaches the motors. So we write the motor
|
||||
// amplitudes straight into the DualSense HID output report, exactly the way SDL and the Linux
|
||||
// `hid-playstation` driver do (the same report that already rumbles this pad on a Linux host).
|
||||
//
|
||||
// USB (report 0x02, 48 bytes, no CRC) and Bluetooth (report 0x31, 78 bytes, trailing CRC32) are
|
||||
// both handled. The App Sandbox permits the raw-HID access via the app's `device.usb` +
|
||||
// `device.bluetooth` entitlements, and this coexists with GameController holding the same device
|
||||
// (non-seized open). Output-only, so no run-loop scheduling is needed.
|
||||
//
|
||||
// macOS-only: IOKit HID device access isn't available to apps on iOS/tvOS.
|
||||
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import IOKit
|
||||
import IOKit.hid
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||
|
||||
/// Opens the first connected Sony DualSense and forwards motor rumble to it over raw HID.
|
||||
/// Single-pad model (we forward exactly one controller), so the first match is the right one.
|
||||
final class DualSenseHID {
|
||||
private let manager: IOHIDManager
|
||||
private var device: IOHIDDevice?
|
||||
private var bluetooth = false
|
||||
private var closed = false
|
||||
|
||||
private static let vendorSony = 0x054C
|
||||
// DualSense (0x0CE6) and DualSense Edge (0x0DF2). The DualShock 4 uses a different report
|
||||
// layout and is intentionally not handled here.
|
||||
private static let productIDs = [0x0CE6, 0x0DF2]
|
||||
|
||||
/// "USB" or "Bluetooth" — for logs / the debug panel. Valid after a successful `open()`.
|
||||
var transport: String { bluetooth ? "Bluetooth" : "USB" }
|
||||
|
||||
init() {
|
||||
manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
deinit { close() }
|
||||
|
||||
/// Find and open the first connected DualSense. Returns false if none is present or it can't
|
||||
/// be opened (caller then falls back to CoreHaptics).
|
||||
func open() -> Bool {
|
||||
let matches = Self.productIDs.map { pid in
|
||||
[kIOHIDVendorIDKey: Self.vendorSony, kIOHIDProductIDKey: pid] as CFDictionary
|
||||
}
|
||||
IOHIDManagerSetDeviceMatchingMultiple(manager, matches as CFArray)
|
||||
guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
|
||||
log.info("rumble: DualSense HID manager open failed — falling back to CoreHaptics")
|
||||
return false
|
||||
}
|
||||
guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
|
||||
let dev = devices.first
|
||||
else {
|
||||
log.info("rumble: no DualSense HID device found — falling back to CoreHaptics")
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
return false
|
||||
}
|
||||
device = dev
|
||||
let transport = IOHIDDeviceGetProperty(dev, kIOHIDTransportKey as CFString) as? String
|
||||
bluetooth = transport?.lowercased().contains("bluetooth") ?? false
|
||||
log.info("rumble: DualSense raw-HID rumble active (transport=\(self.transport, privacy: .public))")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drive the motors. `low` = left/heavy (low-frequency), `high` = right/light (high-frequency),
|
||||
/// each 0...255. (0, 0) stops.
|
||||
func rumble(low: UInt8, high: UInt8) {
|
||||
guard let dev = device else { return }
|
||||
let report = bluetooth
|
||||
? Self.bluetoothReport(low: low, high: high)
|
||||
: Self.usbReport(low: low, high: high)
|
||||
let rc = report.withUnsafeBufferPointer { buf in
|
||||
IOHIDDeviceSetReport(
|
||||
dev, kIOHIDReportTypeOutput, CFIndex(report[0]), buf.baseAddress!, buf.count)
|
||||
}
|
||||
if rc != kIOReturnSuccess {
|
||||
log.error("rumble: IOHIDDeviceSetReport failed (0x\(String(format: "%08x", rc), privacy: .public))")
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
guard !closed else { return }
|
||||
closed = true
|
||||
if device != nil { rumble(low: 0, high: 0) } // silence the motors before releasing
|
||||
device = nil
|
||||
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
|
||||
}
|
||||
|
||||
// MARK: - Report builders
|
||||
|
||||
// DualSense effects payload (DS5EffectsState_t / hid-playstation `common`) — offsets relative
|
||||
// to the payload start:
|
||||
// 0 flag0 (enable bits) 2 motor_right (high-freq) 3 motor_left (low-freq)
|
||||
// 1 flag1 38 flag2 (enhanced enable)
|
||||
// We mirror the Linux driver: flag0 = COMPATIBLE_VIBRATION | HAPTICS_SELECT, flag2 =
|
||||
// COMPATIBLE_VIBRATION2 (the enhanced-firmware path), motors sent directly. valid_flag1 stays
|
||||
// 0 so this rumble-only report leaves the lightbar / triggers / player LEDs (driven by
|
||||
// GameController) untouched.
|
||||
private static func fillEffects(_ data: inout [UInt8], at base: Int, low: UInt8, high: UInt8) {
|
||||
data[base + 0] = 0x03 // COMPATIBLE_VIBRATION (0x01) | HAPTICS_SELECT (0x02)
|
||||
data[base + 2] = high // motor_right
|
||||
data[base + 3] = low // motor_left
|
||||
data[base + 38] = 0x04 // COMPATIBLE_VIBRATION2 (enhanced rumble, firmware ≥ 0x0224)
|
||||
}
|
||||
|
||||
// `usbReport` / `bluetoothReport` / `crc32` are internal (not private) so the unit tests can
|
||||
// pin the exact wire layout against the SDL / hid-playstation spec without a physical pad.
|
||||
static func usbReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 48)
|
||||
d[0] = 0x02 // report id
|
||||
fillEffects(&d, at: 1, low: low, high: high)
|
||||
return d
|
||||
}
|
||||
|
||||
static func bluetoothReport(low: UInt8, high: UInt8) -> [UInt8] {
|
||||
var d = [UInt8](repeating: 0, count: 78)
|
||||
d[0] = 0x31 // report id
|
||||
d[1] = 0x00 // seq/tag (static, as SDL)
|
||||
d[2] = 0x10 // magic
|
||||
fillEffects(&d, at: 3, low: low, high: high)
|
||||
// Trailing CRC32 over a 0xA2 seed byte + the report minus its 4 CRC bytes, little-endian.
|
||||
let crc = Self.crc32(seed: 0xA2, d[0..<(d.count - 4)])
|
||||
d[74] = UInt8(crc & 0xFF)
|
||||
d[75] = UInt8((crc >> 8) & 0xFF)
|
||||
d[76] = UInt8((crc >> 16) & 0xFF)
|
||||
d[77] = UInt8((crc >> 24) & 0xFF)
|
||||
return d
|
||||
}
|
||||
|
||||
/// Standard reflected CRC32 (zlib poly 0xEDB88320, init 0xFFFFFFFF, final XOR) over `seed`
|
||||
/// followed by `bytes` — the DualSense Bluetooth output-report checksum (seed 0xA2). Matches
|
||||
/// SDL's `SDL_crc32`/the kernel's `crc32_le` framing.
|
||||
static func crc32<S: Sequence>(seed: UInt8, _ bytes: S) -> UInt32
|
||||
where S.Element == UInt8 {
|
||||
var crc: UInt32 = 0xFFFF_FFFF
|
||||
func step(_ b: UInt8) {
|
||||
crc ^= UInt32(b)
|
||||
for _ in 0..<8 {
|
||||
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB8_8320 : crc >> 1
|
||||
}
|
||||
}
|
||||
step(seed)
|
||||
for b in bytes { step(b) }
|
||||
return ~crc
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -50,10 +50,12 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
||||
private final class RumbleRenderer: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||
|
||||
/// One actuator's started engine plus the player currently driving it (nil = idle). The
|
||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
||||
private struct Motor {
|
||||
let engine: CHHapticEngine
|
||||
let player: CHHapticAdvancedPatternPlayer
|
||||
var playing = false
|
||||
var player: CHHapticAdvancedPatternPlayer?
|
||||
}
|
||||
|
||||
private var controller: GCController?
|
||||
@@ -67,11 +69,30 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||
private var wasActive = false
|
||||
|
||||
func retarget(_ c: GCController?) {
|
||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
||||
private static let sharpness: Float = 0.5
|
||||
|
||||
#if os(macOS)
|
||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
||||
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||
/// every other controller, which keeps the CoreHaptics path.
|
||||
private var dualSenseHID: DualSenseHID?
|
||||
#endif
|
||||
|
||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
||||
/// rumble backend now in use — for the debug controller-test panel.
|
||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
||||
queue.async {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
self.controller = c
|
||||
self.broken = false
|
||||
_ = self.openHIDIfDualSense(c)
|
||||
onBackend?(self.backendNote(for: c))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,22 +104,36 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
log.debug(
|
||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||
}
|
||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
||||
// other pad (and for a DualSense whose HID device could not be opened).
|
||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
||||
guard !self.broken else { return }
|
||||
if active, self.low == nil, self.high == nil {
|
||||
self.setup()
|
||||
}
|
||||
let ok: Bool
|
||||
if self.high != nil {
|
||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
self.drive(&self.high, Float(highAmp) / 65535)
|
||||
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||
// the wire carries.
|
||||
let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
|
||||
let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
|
||||
ok = okLow && okHigh
|
||||
} else {
|
||||
// Combined engine: whichever motor is stronger wins.
|
||||
self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
|
||||
}
|
||||
// Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
|
||||
// the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
|
||||
// still holds an exclusive reference to.
|
||||
if !ok { self.teardown() }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.sync { self.teardown() }
|
||||
queue.sync {
|
||||
self.teardown()
|
||||
self.closeHID()
|
||||
}
|
||||
}
|
||||
|
||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||
@@ -144,44 +179,51 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
self?.queue.async { self?.teardown() }
|
||||
}
|
||||
do {
|
||||
// Start the engine now; the player that actually moves the motor is built per level
|
||||
// change in `drive` (a fresh event baked at the target intensity).
|
||||
try engine.start()
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [CHHapticEventParameter(parameterID: .hapticIntensity, value: 1)],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try engine.makeAdvancedPlayer(with: CHHapticPattern(events: [event], parameters: []))
|
||||
return Motor(engine: engine, player: player)
|
||||
return Motor(engine: engine, player: nil)
|
||||
} catch {
|
||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
||||
guard var m = motor else { return }
|
||||
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||
/// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
|
||||
/// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
|
||||
/// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
|
||||
/// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
|
||||
/// duration so a single host update — the host sends rumble only when the level changes —
|
||||
/// sustains until the next one. Returns false if the engine errored; the caller tears down for
|
||||
/// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
|
||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
|
||||
guard var m = motor else { return true }
|
||||
// Replace any running player: stop the old, and for a zero level leave the motor idle.
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.player = nil
|
||||
guard amplitude > 0 else { motor = m; return true }
|
||||
do {
|
||||
if amplitude > 0 {
|
||||
if !m.playing {
|
||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
||||
m.playing = true
|
||||
}
|
||||
try m.player.sendParameters(
|
||||
[CHHapticDynamicParameter(
|
||||
parameterID: .hapticIntensityControl,
|
||||
value: amplitude, relativeTime: 0)],
|
||||
atTime: CHHapticTimeImmediate)
|
||||
} else if m.playing {
|
||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
m.playing = false
|
||||
}
|
||||
let event = CHHapticEvent(
|
||||
eventType: .hapticContinuous,
|
||||
parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||
],
|
||||
relativeTime: 0,
|
||||
duration: TimeInterval(GCHapticDurationInfinite))
|
||||
let player = try m.engine.makeAdvancedPlayer(
|
||||
with: CHHapticPattern(events: [event], parameters: []))
|
||||
try player.start(atTime: CHHapticTimeImmediate)
|
||||
m.player = player
|
||||
motor = m
|
||||
return true
|
||||
} catch {
|
||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
||||
// Tear down so the next nonzero amplitude rebuilds — do NOT latch rumble off for the
|
||||
// session (that was the old "spotty" behaviour).
|
||||
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
||||
teardown()
|
||||
motor = m
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,12 +233,56 @@ private final class RumbleRenderer: @unchecked Sendable {
|
||||
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||
m.engine.stoppedHandler = { _ in }
|
||||
m.engine.resetHandler = {}
|
||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||
m.engine.stop()
|
||||
}
|
||||
low = nil
|
||||
high = nil
|
||||
}
|
||||
|
||||
// MARK: - DualSense raw-HID rumble (macOS)
|
||||
//
|
||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
||||
// we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
|
||||
// All three run on the serial `queue`, like the rest of the renderer state.
|
||||
|
||||
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
|
||||
let hid = DualSenseHID()
|
||||
guard hid.open() else { return false }
|
||||
dualSenseHID = hid
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
||||
#if os(macOS)
|
||||
guard let hid = dualSenseHID else { return false }
|
||||
hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
private func closeHID() {
|
||||
#if os(macOS)
|
||||
dualSenseHID?.close()
|
||||
dualSenseHID = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func backendNote(for c: GCController?) -> String {
|
||||
#if os(macOS)
|
||||
if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
|
||||
#endif
|
||||
return c == nil ? "—" : "CoreHaptics"
|
||||
}
|
||||
}
|
||||
|
||||
public final class GamepadFeedback {
|
||||
@@ -369,3 +455,74 @@ public final class GamepadFeedback {
|
||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
||||
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
||||
/// live session uses — just aimed at the physically-connected controller instead of the
|
||||
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
||||
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
||||
/// a passing test exercises the exact code a session runs.
|
||||
@MainActor
|
||||
public final class ControllerTester: ObservableObject {
|
||||
private let renderer = RumbleRenderer()
|
||||
private weak var controller: GCController?
|
||||
|
||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
||||
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||
@Published public private(set) var rumbleBackend = "—"
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
||||
/// active-controller change.
|
||||
public func target(_ c: GCController?) {
|
||||
guard c !== controller else { return }
|
||||
controller = c
|
||||
renderer.retarget(c) { [weak self] note in
|
||||
Task { @MainActor in self?.rumbleBackend = note }
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
||||
public func rumble(low: Float, high: Float) {
|
||||
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
||||
renderer.apply(low: u16(low), high: u16(high))
|
||||
}
|
||||
|
||||
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
||||
|
||||
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
||||
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
||||
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
||||
}
|
||||
|
||||
public func resetTriggers() {
|
||||
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
||||
ds.leftTrigger.setModeOff()
|
||||
ds.rightTrigger.setModeOff()
|
||||
}
|
||||
|
||||
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
||||
public func setLight(_ color: GCColor?) {
|
||||
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
||||
}
|
||||
|
||||
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
||||
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
||||
controller?.playerIndex = index
|
||||
}
|
||||
|
||||
/// Silence every channel and release the controller — call on the panel's disappear.
|
||||
public func stop() {
|
||||
resetTriggers()
|
||||
setPlayerIndex(.indexUnset)
|
||||
setLight(nil)
|
||||
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
||||
controller = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user