diff --git a/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift b/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift new file mode 100644 index 0000000..0da3277 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift @@ -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( + _ 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 diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index a1a1e70..1197323 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -28,6 +28,9 @@ struct SettingsView: View { @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue @ObservedObject private var gamepads = GamepadManager.shared + #if DEBUG && !os(tvOS) + @State private var showControllerTest = false + #endif #if os(macOS) @AppStorage(DefaultsKey.speakerUID) private var speakerUID = "" @AppStorage(DefaultsKey.micUID) private var micUID = "" @@ -411,6 +414,11 @@ struct SettingsView: View { 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: { Text("Controllers") } footer: { diff --git a/clients/apple/Sources/PunktfunkKit/DualSenseHID.swift b/clients/apple/Sources/PunktfunkKit/DualSenseHID.swift new file mode 100644 index 0000000..7850c25 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/DualSenseHID.swift @@ -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, + 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(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 diff --git a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift index 6cbdb0a..94a3cda 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift @@ -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 diff --git a/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift b/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift new file mode 100644 index 0000000..1ea280f --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/DualSenseHIDTests.swift @@ -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