118752c136
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>
154 lines
6.9 KiB
Swift
154 lines
6.9 KiB
Swift
// 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
|