Files
punktfunk/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift
T
enricobuehler 396c3453f5
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
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 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
feat(apple/gamepad): rewrite rumble renderer — bounded divergence + iOS 27 plain-player fix
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent
state on a lossy channel, and the actuator's divergence from it must be
bounded, not best-effort. The old renderer rebuilt an infinite-duration
CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop
lost inside CoreHaptics left an unstoppable player buzzing forever (the
"entered the menu and rumble never stopped" bug).

- Finite 4 s segments, never infinite events — a leaked player self-silences;
  steady levels re-arm seamlessly ON the engine timeline (no stop/start race)
- GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one
  datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops)
- Host 500 ms state refreshes dedupe to a liveness stamp; zero applies
  immediately; nonzero ramps throttle to one rebake/25 ms per motor
- Throwing player stop escalates to engine.stop() (kills leaked players);
  1.6 s staleness watchdog (Policy.session) force-silences on a dead channel;
  the test panel holds levels via Policy.manual
- Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller
  haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops
  advanced loads with an XPC decode fault (-4811/4097, rumble silently dead).
  Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works
- Split-handle engines fall back to one combined .default engine on repeated
  failure; renderer publishes health transitions and the test panel shows
  them (a refused system service no longer reads as silent app breakage)
- Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS
  DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero
- RumbleTuningTests pin the scheduling math, tuning relations, and a
  queue/ticker teardown smoke test

Stuck-rumble streaming repro revalidation on glass still pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:06:45 +02:00

590 lines
30 KiB
Swift

import CoreHaptics
import Foundation
import GameController
import os
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
enum RumbleTuning {
/// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
/// of (a stop dropped inside CoreHaptics, an engine race) self-silences when its segment
/// expires, so this is the hard ceiling on how long the actuator can diverge from the
/// target state.
static let segmentSeconds: TimeInterval = 4.0
/// Re-arm the successor segment once the current one has less than this left. Generous
/// against the ticker period so a steady rumble can never miss the boundary and gap.
static let rearmHeadroom: TimeInterval = 1.0
/// Renderer ticker period while anything is (or should be) audible. Silence runs no timer.
static let tickSeconds: TimeInterval = 0.05
/// Minimum spacing between player rebuilds for nonzerononzero level changes a game
/// ramping rumble per frame would otherwise stop/start players at 60+ Hz, which is exactly
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
static let sessionStaleSeconds: TimeInterval = 1.6
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
/// macOS DualSense raw-HID path: re-write an unchanged nonzero level this often so the
/// pad's firmware never times the rumble out mid-effect (Bluetooth pads watchdog output
/// reports), and a dropped report heals.
static let hidKeepaliveSeconds: TimeInterval = 0.9
/// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
/// defined frequency to move at all (an intensity-only event left them silent) while a
/// classic Xbox ERM rotor ignores it. On split-handle pads the wire's two motors render at
/// distinct frequencies mirroring the real hardware they emulate low/left the heavy
/// low-frequency rotor, high/right the light buzzer; a single combined actuator keeps the
/// proven mid value.
static let sharpnessLow: Float = 0.3
static let sharpnessHigh: Float = 0.7
static let sharpnessCombined: Float = 0.5
/// Wire amplitude (0...0xFFFF) CoreHaptics intensity (0...1).
static func amplitude(_ wire: UInt16) -> Float { Float(wire) / 65535 }
/// Wire amplitude DualSense HID motor byte.
static func hidByte(_ wire: UInt16) -> UInt8 { UInt8(wire >> 8) }
/// Single-actuator pads render whichever motor is stronger.
static func combined(low: UInt16, high: UInt16) -> UInt16 { max(low, high) }
/// Are two baked levels the same (skip the rebuild)?
static func sameLevel(_ a: Float, _ b: Float) -> Bool { abs(a - b) <= levelEpsilon }
/// Time for a segment handoff to act (engine timeline).
static func shouldRearm(endsAt: TimeInterval, now: TimeInterval) -> Bool {
endsAt - now <= rearmHeadroom
}
/// When the successor segment starts: exactly as the current one expires unless that
/// already passed (the gap already happened; start now).
static func handoffStart(endsAt: TimeInterval, now: TimeInterval) -> TimeInterval {
max(endsAt, now)
}
}
/// Rumble the active physical controller (CoreHaptics; a DualSense on macOS goes over raw HID
/// instead, see `DualSenseHID`), built around one principle: **rumble is idempotent state on a
/// lossy channel, and the actuator's divergence from that state must be bounded** not
/// best-effort. The previous renderer drove infinite-duration players torn down and rebuilt per
/// wire update; one asynchronous `stop` dropped inside CoreHaptics left an unstoppable player
/// buzzing with its handle discarded, which no later (0,0) could reach the "walked into the
/// menu and the rumble never stopped" bug.
///
/// The invariants that bound divergence now:
/// 1. **No infinite events.** A motor plays finite `segmentSeconds` segments; while the level
/// holds, the successor is scheduled ON the engine timeline to start exactly when the
/// current segment expires (seamless no stop/start race in steady state). A leaked player
/// therefore self-silences in `segmentSeconds`.
/// 2. **Idempotent targets.** An update equal to the current target (the host re-sends rumble
/// state every 500 ms as its loss heal) is a liveness stamp, never a player rebuild.
/// 3. **Zero is immediate, ramps are throttled.** (0,0) stops players the moment it lands;
/// nonzerononzero changes rebuild at most every `minRebakeSeconds` per motor (the ticker
/// lands the newest value once the window opens).
/// 4. **Escalating stop.** A throwing `player.stop` means the engine's state is unknown the
/// whole engine is stopped (silencing every player it hosts) and lazily rebuilt behind the
/// exponential backoff.
/// 5. **Staleness watchdog** (`Policy.session`): audible with no wire command for
/// `sessionStaleSeconds` force silence. A lost stop can outlive the host's 500 ms heal
/// only if the channel itself died, and then the pad must not buzz forever. `Policy.manual`
/// (the settings test panel) instead holds a level until it is changed.
///
/// Engines are created lazily on the first nonzero amplitude and torn down on retarget;
/// failures (pads without haptics, engine resets) downgrade to silence rumble is best-effort
/// by design, but *staying silent* when told to stop is not.
///
/// `@unchecked Sendable` is sound because every property is read and written only inside
/// `queue` closures the serial queue is the synchronization.
final class RumbleRenderer: @unchecked Sendable {
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
/// slider level indefinitely.
struct Policy {
let staleAfter: TimeInterval?
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
static let manual = Policy(staleAfter: nil)
}
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
private let policy: Policy
/// One finite haptic play on a motor: the player plus when (engine timeline) it expires.
/// A PLAIN pattern player on purpose: the controller haptics server (gamecontrollerd)
/// advertises `adv players: 0`, and as of iOS 27 beta 2 an advanced-player sequence load
/// doesn't degrade gracefully there the daemon faults decoding the XPC message and drops
/// it (CoreHaptics -4811/4097, rumble dead). We only need `start(atTime:)`/`stop(atTime:)`,
/// which the plain protocol has.
private struct Segment {
let player: CHHapticPatternPlayer
let endsAt: TimeInterval
}
/// One actuator's started engine and the segment(s) realizing `level` on it. `retiring` is
/// the predecessor across a segment handoff left to expire naturally (its successor
/// starts the instant it ends), but the reference is held so a level change or stop can
/// still force-stop it.
private struct Motor {
let engine: CHHapticEngine
let sharpness: Float
var level: Float = 0
var current: Segment?
var retiring: Segment?
var lastRebake = DispatchTime(uptimeNanoseconds: 0)
}
private var controller: GCController?
private var low: Motor?
private var high: Motor?
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
private var ticker: DispatchSourceTimer?
// `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
// on an OS that doesn't expose rumble through GameController, a Siri Remote) nothing to retry
// until the controller changes. A transient engine failure does NOT latch it; it tears down for
// a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
private var broken = false
/// Last logged active/silent state for a one-line transition log, not per-event spam.
private var wasActive = false
// Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
// -4811 "server connection broke") fails EVERY rebuild until the service relaunches and that
// break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
// update immediately rebuilds into the same dead connection, flooding the log and never
// recovering. Delay the next setup() growing 0.5124 s on repeated failure and clear it
// the moment a player is actually running (or the controller changes).
private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
private var consecutiveFailures = 0
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine
/// the configuration virtually every iOS game (and this app's own menu haptics) uses before
/// treating the service as unreachable. A haptics daemon that mishandles per-handle
/// localities for a particular pad can still serve the combined engine. One-way per
/// controller; retarget resets it.
private var preferCombined = false
/// Health reporting for the debug test panel: a human-readable problem while rumble cannot
/// work (nil = healthy). Without this, a wedged system haptics service (gamecontrollerd
/// refusing every XPC connection CoreHaptics -4811/4097, which no in-app retry can fix)
/// reads as "the app's rumble is broken" when actually no app on the device can rumble.
private var healthSink: ((String?) -> Void)?
private var lastHealth: String?
#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?
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
((0, 0), DispatchTime(uptimeNanoseconds: 0))
#endif
init(policy: Policy = .session) {
self.policy = policy
}
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
/// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
/// between working and structurally failing (nil = healthy) both for the debug test panel.
func retarget(
_ c: GCController?, onBackend: ((String) -> Void)? = nil,
onHealth: ((String?) -> Void)? = nil
) {
queue.async {
self.teardown()
self.closeHID()
self.controller = c
self.broken = false
self.preferCombined = false
self.consecutiveFailures = 0
self.retryAfter = DispatchTime(uptimeNanoseconds: 0)
if let onHealth { self.healthSink = onHealth }
self.lastHealth = nil
self.healthSink?(nil)
_ = self.openHIDIfDualSense(c)
onBackend?(self.backendNote(for: c))
// The target survives the swap: render replays the current level onto the new pad
// right away (a mid-rumble controller change keeps rumbling, like moving a real pad
// between hands mid-effect).
self.render()
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0
if active != self.wasActive {
self.wasActive = active
log.debug(
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
}
guard (lowAmp, highAmp) != self.target else { return }
self.target = (lowAmp, highAmp)
self.render()
}
}
/// Silence the motors and drop the engines. Blocks until done call off the main actor.
func stop() {
queue.sync {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.wasActive = false
self.teardown()
self.closeHID()
}
}
// MARK: - Reconciliation (all on `queue`)
/// Drive the actuators toward `target`. Idempotent safe to call from every wire update,
/// tick, and retarget; when everything already matches it does nothing.
private func render() {
defer { updateTicker() }
if renderHID() { return }
guard !broken else { return }
let audible = target.low != 0 || target.high != 0
if audible, low == nil, high == nil, DispatchTime.now() >= retryAfter {
setup()
}
// Reconcile BOTH motors (no short-circuit skipping the second on a first-motor error),
// and tear down OUTSIDE the `inout` accesses so teardown() never mutates a motor a
// reconcile call still holds an exclusive reference to.
let ok: Bool
if high != nil {
// Per-handle: low = left/heavy motor, high = right/light the XInput convention
// the wire carries.
let okLow = reconcile(&low, to: RumbleTuning.amplitude(target.low))
let okHigh = reconcile(&high, to: RumbleTuning.amplitude(target.high))
ok = okLow && okHigh
} else {
let mixed = RumbleTuning.combined(low: target.low, high: target.high)
ok = reconcile(&low, to: RumbleTuning.amplitude(mixed))
}
if !ok {
let wasSplit = high != nil
teardown()
scheduleRetryBackoff()
if wasSplit, !preferCombined {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
} else if low?.current != nil || high?.current != nil {
// A player is actually running the path has recovered; clear the backoff.
consecutiveFailures = 0
retryAfter = DispatchTime(uptimeNanoseconds: 0)
reportHealth(nil)
}
}
/// Publish a health transition to the test panel (deduped transitions only).
private func reportHealth(_ problem: String?) {
guard problem != lastHealth else { return }
lastHealth = problem
healthSink?(problem)
}
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
target = (0, 0)
}
render()
}
/// Drive one motor toward `desired`, per the invariants above. Returns false when the
/// engine errored the caller then tears everything down (outside this `inout` access) for
/// a lazy, backoff-gated rebuild.
private func reconcile(_ slot: inout Motor?, to desired: Float) -> Bool {
guard var m = slot else { return true }
defer { slot = m }
// Release a handed-off predecessor once it has expired on its own.
if let r = m.retiring, m.engine.currentTime >= r.endsAt + 0.25 {
m.retiring = nil
}
if desired <= RumbleTuning.levelEpsilon {
guard m.level > 0 || m.current != nil || m.retiring != nil else { return true }
m.level = 0
return stopSegments(&m)
}
if RumbleTuning.sameLevel(desired, m.level), m.current != nil {
return rearmIfNeeded(&m)
}
// Nonzero level change. Throttled: the ticker re-runs render() and lands the newest
// value once the window opens (zero above is never throttled).
if m.current != nil, seconds(since: m.lastRebake) < RumbleTuning.minRebakeSeconds {
return true
}
guard stopSegments(&m) else { return false }
do {
m.current = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: desired, at: CHHapticTimeImmediate)
m.level = desired
m.lastRebake = .now()
return true
} catch {
// A transient failure (the engine stopped/reset between its handler firing and now).
// Signal a rebuild do NOT latch rumble off for the session.
log.warning("rumble: haptic start failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Keep a steady level seamless across the finite-segment boundary: when the current
/// segment nears its end, start the successor ON the engine timeline exactly as it expires
/// no stop call, no race, no gap. The old segment is kept as `retiring` until it dies
/// naturally, so a level change can still force-stop it.
private func rearmIfNeeded(_ m: inout Motor) -> Bool {
guard let cur = m.current else { return true }
let now = m.engine.currentTime
guard RumbleTuning.shouldRearm(endsAt: cur.endsAt, now: now) else { return true }
// A predecessor still held this deep into the segment already expired; drop it.
m.retiring = nil
do {
let next = try makeSegment(
m.engine, sharpness: m.sharpness, amplitude: m.level,
at: RumbleTuning.handoffStart(endsAt: cur.endsAt, now: now))
m.retiring = m.current
m.current = next
return true
} catch {
log.warning("rumble: segment re-arm failed — rebuilding: \(error, privacy: .public)")
return false
}
}
/// Stop every segment on the motor NOW. False = a stop threw, so the engine's real state is
/// unknown (a player may still run with its handle gone) the caller must escalate to a
/// full engine teardown, whose `engine.stop()` silences every player the engine hosts.
private func stopSegments(_ m: inout Motor) -> Bool {
var ok = true
for seg in [m.current, m.retiring].compactMap({ $0 }) {
do {
try seg.player.stop(atTime: CHHapticTimeImmediate)
} catch {
log.warning(
"rumble: player stop failed — escalating to engine stop: \(error, privacy: .public)")
ok = false
}
}
m.current = nil
m.retiring = nil
return ok
}
/// Build + start one finite continuous event at `amplitude`. `at` is `CHHapticTimeImmediate`
/// or an absolute engine-timeline instant (a scheduled handoff). The intensity is BAKED into
/// the event: a fixed event scaled by a dynamic `.hapticIntensityControl` parameter drives
/// the iPhone Taptic Engine but is silent on a controller's haptic engine.
private func makeSegment(
_ engine: CHHapticEngine, sharpness: Float, amplitude: Float, at start: TimeInterval
) throws -> Segment {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness),
],
relativeTime: 0,
duration: RumbleTuning.segmentSeconds)
let player = try engine.makePlayer(
with: CHHapticPattern(events: [event], parameters: []))
try player.start(atTime: start)
let begins = start == CHHapticTimeImmediate ? engine.currentTime : start
return Segment(player: player, endsAt: begins + RumbleTuning.segmentSeconds)
}
/// The ticker runs only while something needs tending any nonzero target (watchdog,
/// throttle catch-up, HID keepalive, post-reset engine rebuild) or segments still alive.
private func updateTicker() {
let needed = target != (0, 0)
|| low?.current != nil || low?.retiring != nil
|| high?.current != nil || high?.retiring != nil
if needed, ticker == nil {
let t = DispatchSource.makeTimerSource(queue: queue)
t.schedule(
deadline: .now() + RumbleTuning.tickSeconds, repeating: RumbleTuning.tickSeconds)
t.setEventHandler { [weak self] in self?.tick() }
t.resume()
ticker = t
} else if !needed, let t = ticker {
t.cancel()
ticker = nil
}
}
// MARK: - Engine lifecycle
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
/// high = right/light the Xbox/XInput convention the wire carries); one combined
/// engine otherwise, driven by whichever amplitude is stronger.
private func setup() {
guard let haptics = controller?.haptics else {
// No haptics engine at all an Xbox controller on an OS/firmware that doesn't expose
// rumble through GameController (works on Android via the standard Vibrator path, but
// Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
// the controller changes; latch off (retarget clears it) and say so once.
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
broken = true
reportHealth("This controller exposes no rumble engine to apps on this OS.")
return
}
let localities = haptics.supportedLocalities
let split =
!preferCombined && localities.contains(.leftHandle)
&& localities.contains(.rightHandle)
if split {
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
} else {
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
}
if low == nil, high == nil {
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
// NOT latch broken back off and a later render past the cooldown retries.
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
scheduleRetryBackoff()
if split {
preferCombined = true
log.info("rumble: split-handle engines failing — will retry with one combined engine")
}
}
}
/// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
/// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
/// every rumble update.
private func scheduleRetryBackoff() {
consecutiveFailures += 1
let shift = min(consecutiveFailures - 1, 4)
retryAfter = .now() + min(0.5 * Double(1 << shift), 4)
if consecutiveFailures >= 2 {
// One failure is a hiccup; repeated ones are the wedged-service signature (every
// XPC connection to gamecontrollerd.haptics breaks no app on the device can
// rumble until it relaunches). Say so instead of failing silently.
reportHealth(
"The system haptics service is refusing connections — no app can rumble a "
+ "controller right now. Rebooting the device usually clears it.")
}
}
private func makeMotor(
_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality, sharpness: Float
) -> Motor? {
guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
// A controller's motors carry no audio, so keep this engine OUT of the app's audio session
// (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
// letting a haptics-only engine join it is a needless coupling that can get its
// gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
engine.playsHapticsOnly = true
// The haptic server can stop or reset the engine out from under us app backgrounding, an
// audio-session interruption (a call, Siri, another audio app), or a server crash. Left
// unhandled the players go dead and every later rumble throws, latching rumble off for the
// rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
// serial queue; the ticker (or the next wire update) lazily rebuilds the engine and
// re-renders the still-current target.
engine.stoppedHandler = { [weak self] reason in
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
self?.queue.async { self?.teardown() }
}
engine.resetHandler = { [weak self] in
log.info("rumble: haptic engine reset — will rebuild")
self?.queue.async { self?.teardown() }
}
do {
// Start the engine now; the players that actually move the motor are the finite
// segments `reconcile` bakes per level.
try engine.start()
return Motor(engine: engine, sharpness: sharpness)
} catch {
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
return nil
}
}
private func teardown() {
for m in [low, high].compactMap({ $0 }) {
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
m.engine.stoppedHandler = { _ in }
m.engine.resetHandler = {}
for seg in [m.current, m.retiring].compactMap({ $0 }) {
try? seg.player.stop(atTime: CHHapticTimeImmediate)
}
// The authoritative silencer: a stopped engine plays nothing, including any player
// whose individual stop was dropped.
m.engine.stop()
}
low = nil
high = nil
}
private func seconds(since t: DispatchTime) -> TimeInterval {
TimeInterval(DispatchTime.now().uptimeNanoseconds - t.uptimeNanoseconds) / 1_000_000_000
}
// 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.
// Runs 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
}
/// Write the target to the DualSense over HID if that's the active backend; false not a
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
private func renderHID() -> Bool {
#if os(macOS)
guard let hid = dualSenseHID else { return false }
let levels = (RumbleTuning.hidByte(target.low), RumbleTuning.hidByte(target.high))
let keepalive = levels != (0, 0)
&& seconds(since: lastHidWrite.at) > RumbleTuning.hidKeepaliveSeconds
if levels != lastHidWrite.levels || keepalive {
hid.rumble(low: levels.0, high: levels.1)
lastHidWrite = (levels, .now())
}
return true
#else
return false
#endif
}
private func closeHID() {
#if os(macOS)
dualSenseHID?.close() // writes (0,0) before releasing
dualSenseHID = nil
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
#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"
}
}