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,387 @@
|
|||||||
|
// DEBUG-only controller test panel, reached from Settings → Controllers → "Test Controller…".
|
||||||
|
// It shows the live input of the active controller and lets you fire the host→client feedback
|
||||||
|
// channels — rumble, DualSense adaptive triggers, lightbar, player LEDs — straight at the
|
||||||
|
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
|
||||||
|
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
|
||||||
|
//
|
||||||
|
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
|
||||||
|
// layout); macOS + iOS/iPadOS cover the validation need.
|
||||||
|
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
import GameController
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ControllerTestView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
@StateObject private var tester = ControllerTester()
|
||||||
|
|
||||||
|
@State private var heavyOn = false
|
||||||
|
@State private var lightOn = false
|
||||||
|
@State private var intensity = 0.75
|
||||||
|
@State private var triggerTarget = TriggerTarget.both
|
||||||
|
@State private var playerLED = -1
|
||||||
|
|
||||||
|
private enum TriggerTarget: String, CaseIterable, Identifiable {
|
||||||
|
case left = "L2", right = "R2", both = "Both"
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TriggerDemo: Identifiable {
|
||||||
|
let label: String
|
||||||
|
let effect: DualSenseTriggerEffect
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let triggerDemos: [TriggerDemo] = [
|
||||||
|
.init(label: "Off", effect: .off),
|
||||||
|
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
|
||||||
|
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
|
||||||
|
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
|
||||||
|
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
|
||||||
|
]
|
||||||
|
|
||||||
|
// (display name, hardware colour, swatch colour)
|
||||||
|
private static let lightSwatches: [(String, GCColor, Color)] = [
|
||||||
|
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
|
||||||
|
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
|
||||||
|
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
|
||||||
|
("White", GCColor(red: 1, green: 1, blue: 1), .white),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("Test Controller").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let active = gamepads.active {
|
||||||
|
header(active)
|
||||||
|
inputCard
|
||||||
|
rumbleCard()
|
||||||
|
triggerCard(active)
|
||||||
|
extrasCard(active)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No controller",
|
||||||
|
systemImage: "gamecontroller",
|
||||||
|
description: Text("Connect a controller and pick it under "
|
||||||
|
+ "Settings → Controllers → Use controller."))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 420, minHeight: 540)
|
||||||
|
.onAppear { tester.target(gamepads.active?.controller) }
|
||||||
|
.onDisappear { tester.stop() }
|
||||||
|
.onChange(of: gamepads.active?.id) { _, _ in
|
||||||
|
heavyOn = false
|
||||||
|
lightOn = false
|
||||||
|
playerLED = -1
|
||||||
|
tester.target(gamepads.active?.controller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Header
|
||||||
|
|
||||||
|
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(c.name).font(.headline)
|
||||||
|
Text(c.productCategory).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Input
|
||||||
|
|
||||||
|
private var inputCard: some View {
|
||||||
|
card("Input") {
|
||||||
|
// Poll the live controller at 30 Hz — no handlers installed, so nothing else's
|
||||||
|
// capture is disturbed.
|
||||||
|
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
|
||||||
|
if let gp = gamepads.active?.controller.extendedGamepad {
|
||||||
|
inputReadout(gp, controller: gamepads.active?.controller)
|
||||||
|
} else {
|
||||||
|
Text("Not an extended gamepad").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
|
||||||
|
pressed: g.leftThumbstickButton?.isPressed ?? false)
|
||||||
|
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
|
||||||
|
pressed: g.rightThumbstickButton?.isPressed ?? false)
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
triggerBar("L2", value: g.leftTrigger.value)
|
||||||
|
triggerBar("R2", value: g.rightTrigger.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buttonGrid(g)
|
||||||
|
if let tp = Self.touchpad(g) {
|
||||||
|
touchpadView(tp)
|
||||||
|
}
|
||||||
|
if let m = controller?.motion {
|
||||||
|
motionReadout(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
ZStack {
|
||||||
|
Circle().stroke(Color.secondary.opacity(0.3))
|
||||||
|
Circle()
|
||||||
|
.fill(pressed ? Color.accentColor : Color.secondary)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
|
||||||
|
}
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerBar(_ label: String, value: Float) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
|
||||||
|
GeometryReader { geo in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule().fill(Color.secondary.opacity(0.15))
|
||||||
|
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 10)
|
||||||
|
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(width: 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
|
||||||
|
var items: [(String, Bool)] = [
|
||||||
|
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
|
||||||
|
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
|
||||||
|
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
|
||||||
|
("L3", g.leftThumbstickButton?.isPressed ?? false),
|
||||||
|
("R3", g.rightThumbstickButton?.isPressed ?? false),
|
||||||
|
("Menu", g.buttonMenu.isPressed),
|
||||||
|
("Opts", g.buttonOptions?.isPressed ?? false),
|
||||||
|
("↑", g.dpad.up.isPressed), ("↓", g.dpad.down.isPressed),
|
||||||
|
("←", g.dpad.left.isPressed), ("→", g.dpad.right.isPressed),
|
||||||
|
]
|
||||||
|
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
|
||||||
|
return LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
|
||||||
|
) {
|
||||||
|
ForEach(items.indices, id: \.self) { i in
|
||||||
|
Text(items[i].0)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
|
||||||
|
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touchpadView(
|
||||||
|
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
|
||||||
|
fingerDot(tp.primary, color: .accentColor)
|
||||||
|
fingerDot(tp.secondary, color: .orange)
|
||||||
|
}
|
||||||
|
.frame(width: 150, height: 74)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
|
||||||
|
let x = pad.xAxis.value, y = pad.yAxis.value
|
||||||
|
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
|
||||||
|
return Circle().fill(color).frame(width: 10, height: 10)
|
||||||
|
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
|
||||||
|
.opacity(active ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func motionReadout(_ m: GCMotion) -> some View {
|
||||||
|
let a = Self.totalAccel(m)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Motion").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
Text(String(format: "gyro %+.2f %+.2f %+.2f",
|
||||||
|
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Rumble
|
||||||
|
|
||||||
|
private func rumbleCard() -> some View {
|
||||||
|
card("Rumble") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Strength", selection: $intensity) {
|
||||||
|
Text("25%").tag(0.25)
|
||||||
|
Text("50%").tag(0.5)
|
||||||
|
Text("75%").tag(0.75)
|
||||||
|
Text("100%").tag(1.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
Toggle("Heavy motor (left)", isOn: $heavyOn)
|
||||||
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text("Toggle a motor to feel it. The host maps a game's low/high-frequency "
|
||||||
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
|
+ "can't reach its motors on macOS).")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.onChange(of: heavyOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: lightOn) { _, _ in applyRumble() }
|
||||||
|
.onChange(of: intensity) { _, _ in applyRumble() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRumble() {
|
||||||
|
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Adaptive triggers
|
||||||
|
|
||||||
|
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
card("Adaptive triggers") {
|
||||||
|
if c.hasAdaptiveTriggers {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Picker("Apply to", selection: $triggerTarget) {
|
||||||
|
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
|
||||||
|
) {
|
||||||
|
ForEach(Self.triggerDemos) { demo in
|
||||||
|
Button(demo.label) { applyTrigger(demo.effect) }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Adaptive triggers need a DualSense.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTrigger(_ e: DualSenseTriggerEffect) {
|
||||||
|
switch triggerTarget {
|
||||||
|
case .left: tester.applyTrigger(e, right: false)
|
||||||
|
case .right: tester.applyTrigger(e, right: true)
|
||||||
|
case .both:
|
||||||
|
tester.applyTrigger(e, right: false)
|
||||||
|
tester.applyTrigger(e, right: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Lightbar + player LED
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
|
||||||
|
if c.hasLight {
|
||||||
|
card("Lightbar & player LED") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(Self.lightSwatches.indices, id: \.self) { i in
|
||||||
|
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
|
||||||
|
Circle().fill(Self.lightSwatches[i].2)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
Picker("Player LED", selection: $playerLED) {
|
||||||
|
Text("Off").tag(-1)
|
||||||
|
Text("1").tag(0)
|
||||||
|
Text("2").tag(1)
|
||||||
|
Text("3").tag(2)
|
||||||
|
Text("4").tag(3)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: playerLED) { _, v in
|
||||||
|
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
private func card<Content: View>(
|
||||||
|
_ title: String, @ViewBuilder _ content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title).font(.subheadline.weight(.semibold))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(14)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
|
||||||
|
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
|
||||||
|
|
||||||
|
/// The touchpad surface of a PlayStation pad — `GCDualSenseGamepad` and `GCDualShockGamepad`
|
||||||
|
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
|
||||||
|
private static func touchpad(
|
||||||
|
_ g: GCExtendedGamepad
|
||||||
|
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
|
||||||
|
button: GCControllerButtonInput)? {
|
||||||
|
if let ds = g as? GCDualSenseGamepad {
|
||||||
|
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
|
||||||
|
}
|
||||||
|
if let ds4 = g as? GCDualShockGamepad {
|
||||||
|
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
|
||||||
|
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
|
||||||
|
if m.hasGravityAndUserAcceleration {
|
||||||
|
return (m.gravity.x + m.userAcceleration.x,
|
||||||
|
m.gravity.y + m.userAcceleration.y,
|
||||||
|
m.gravity.z + m.userAcceleration.z)
|
||||||
|
}
|
||||||
|
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -28,6 +28,9 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
@AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
@State private var showControllerTest = false
|
||||||
|
#endif
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||||
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||||
@@ -411,6 +414,11 @@ struct SettingsView: View {
|
|||||||
Text(option.label).tag(option.tag)
|
Text(option.label).tag(option.tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if DEBUG && !os(tvOS)
|
||||||
|
Button("Test Controller…") { showControllerTest = true }
|
||||||
|
.disabled(gamepads.active == nil)
|
||||||
|
.sheet(isPresented: $showControllerTest) { ControllerTestView() }
|
||||||
|
#endif
|
||||||
} header: {
|
} header: {
|
||||||
Text("Controllers")
|
Text("Controllers")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -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 final class RumbleRenderer: @unchecked Sendable {
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
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 {
|
private struct Motor {
|
||||||
let engine: CHHapticEngine
|
let engine: CHHapticEngine
|
||||||
let player: CHHapticAdvancedPatternPlayer
|
var player: CHHapticAdvancedPatternPlayer?
|
||||||
var playing = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controller: GCController?
|
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.
|
/// Last logged active/silent state — for a one-line transition log, not per-event spam.
|
||||||
private var wasActive = false
|
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 {
|
queue.async {
|
||||||
self.teardown()
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
_ = self.openHIDIfDualSense(c)
|
||||||
|
onBackend?(self.backendNote(for: c))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,22 +104,36 @@ private final class RumbleRenderer: @unchecked Sendable {
|
|||||||
log.debug(
|
log.debug(
|
||||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
"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 }
|
guard !self.broken else { return }
|
||||||
if active, self.low == nil, self.high == nil {
|
if active, self.low == nil, self.high == nil {
|
||||||
self.setup()
|
self.setup()
|
||||||
}
|
}
|
||||||
|
let ok: Bool
|
||||||
if self.high != nil {
|
if self.high != nil {
|
||||||
self.drive(&self.low, Float(lowAmp) / 65535)
|
// Per-handle: low = left/heavy motor, high = right/light — the XInput convention
|
||||||
self.drive(&self.high, Float(highAmp) / 65535)
|
// 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 {
|
} else {
|
||||||
// Combined engine: whichever motor is stronger wins.
|
// 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() {
|
func stop() {
|
||||||
queue.sync { self.teardown() }
|
queue.sync {
|
||||||
|
self.teardown()
|
||||||
|
self.closeHID()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
/// 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() }
|
self?.queue.async { self?.teardown() }
|
||||||
}
|
}
|
||||||
do {
|
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()
|
try engine.start()
|
||||||
let event = CHHapticEvent(
|
return Motor(engine: engine, player: nil)
|
||||||
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)
|
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drive(_ motor: inout Motor?, _ amplitude: Float) {
|
/// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
|
||||||
guard var m = motor else { return }
|
/// 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 {
|
do {
|
||||||
if amplitude > 0 {
|
let event = CHHapticEvent(
|
||||||
if !m.playing {
|
eventType: .hapticContinuous,
|
||||||
try m.player.start(atTime: CHHapticTimeImmediate)
|
parameters: [
|
||||||
m.playing = true
|
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
|
||||||
}
|
CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
|
||||||
try m.player.sendParameters(
|
],
|
||||||
[CHHapticDynamicParameter(
|
relativeTime: 0,
|
||||||
parameterID: .hapticIntensityControl,
|
duration: TimeInterval(GCHapticDurationInfinite))
|
||||||
value: amplitude, relativeTime: 0)],
|
let player = try m.engine.makeAdvancedPlayer(
|
||||||
atTime: CHHapticTimeImmediate)
|
with: CHHapticPattern(events: [event], parameters: []))
|
||||||
} else if m.playing {
|
try player.start(atTime: CHHapticTimeImmediate)
|
||||||
try m.player.stop(atTime: CHHapticTimeImmediate)
|
m.player = player
|
||||||
m.playing = false
|
|
||||||
}
|
|
||||||
motor = m
|
motor = m
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// A transient failure (the engine stopped/reset between its handler firing and now).
|
// 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
|
// Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
|
||||||
// session (that was the old "spotty" behaviour).
|
|
||||||
log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
|
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.)
|
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||||
m.engine.stoppedHandler = { _ in }
|
m.engine.stoppedHandler = { _ in }
|
||||||
m.engine.resetHandler = {}
|
m.engine.resetHandler = {}
|
||||||
try? m.player.stop(atTime: CHHapticTimeImmediate)
|
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
||||||
m.engine.stop()
|
m.engine.stop()
|
||||||
}
|
}
|
||||||
low = nil
|
low = nil
|
||||||
high = 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 {
|
public final class GamepadFeedback {
|
||||||
@@ -369,3 +455,74 @@ public final class GamepadFeedback {
|
|||||||
return which == 0 ? ds.leftTrigger : ds.rightTrigger
|
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
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Locks the DualSense raw-HID rumble report layout to the SDL / Linux hid-playstation spec.
|
||||||
|
// The motors can only be confirmed on a physical pad, but these guard against a silent byte
|
||||||
|
// error in the offsets, enable flags, lengths, and the Bluetooth CRC32 — the parts most likely
|
||||||
|
// to regress unnoticed. macOS-only (DualSenseHID isn't compiled elsewhere).
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
final class DualSenseHIDTests: XCTestCase {
|
||||||
|
func testUSBReportLayout() {
|
||||||
|
let r = DualSenseHID.usbReport(low: 0xAA, high: 0xBB)
|
||||||
|
XCTAssertEqual(r.count, 48)
|
||||||
|
XCTAssertEqual(r[0], 0x02) // report id
|
||||||
|
XCTAssertEqual(r[1], 0x03) // flag0: COMPATIBLE_VIBRATION | HAPTICS_SELECT
|
||||||
|
XCTAssertEqual(r[2], 0x00) // flag1 (untouched — leaves lightbar/LEDs alone)
|
||||||
|
XCTAssertEqual(r[3], 0xBB) // motor_right = high
|
||||||
|
XCTAssertEqual(r[4], 0xAA) // motor_left = low
|
||||||
|
XCTAssertEqual(r[39], 0x04) // flag2: COMPATIBLE_VIBRATION2 (payload offset 38 + report id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBluetoothReportLayoutAndCRC() {
|
||||||
|
let r = DualSenseHID.bluetoothReport(low: 0xAA, high: 0xBB)
|
||||||
|
XCTAssertEqual(r.count, 78)
|
||||||
|
XCTAssertEqual(r[0], 0x31) // report id
|
||||||
|
XCTAssertEqual(r[1], 0x00) // seq/tag
|
||||||
|
XCTAssertEqual(r[2], 0x10) // magic
|
||||||
|
XCTAssertEqual(r[3], 0x03) // flag0
|
||||||
|
XCTAssertEqual(r[5], 0xBB) // motor_right = high (payload offset 2 + 3-byte BT header)
|
||||||
|
XCTAssertEqual(r[6], 0xAA) // motor_left = low
|
||||||
|
XCTAssertEqual(r[41], 0x04) // flag2 (payload offset 38 + 3)
|
||||||
|
|
||||||
|
// Trailing CRC32 = standard CRC32 over (0xA2 seed + report[0..<74]), little-endian.
|
||||||
|
let expected = DualSenseHID.crc32(seed: 0xA2, r[0..<74])
|
||||||
|
let stored = UInt32(r[74]) | (UInt32(r[75]) << 8) | (UInt32(r[76]) << 16) | (UInt32(r[77]) << 24)
|
||||||
|
XCTAssertEqual(stored, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCRC32MatchesStandardCheckVector() {
|
||||||
|
// The canonical CRC32 check value: CRC32("123456789") == 0xCBF43926. Our helper folds a
|
||||||
|
// seed byte in first, so feed seed='1' and the rest — proving poly/reflection/init/final.
|
||||||
|
let crc = DualSenseHID.crc32(seed: UInt8(ascii: "1"), Array("23456789".utf8))
|
||||||
|
XCTAssertEqual(crc, 0xCBF4_3926)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Reference in New Issue
Block a user