From 396c3453f5da13eb531a52113d8959c5c9df3a99 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 2 Jul 2026 23:06:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple/gamepad):=20rewrite=20rumble=20rende?= =?UTF-8?q?rer=20=E2=80=94=20bounded=20divergence=20+=20iOS=2027=20plain-p?= =?UTF-8?q?layer=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 14 +- .../Settings/ControllerTestView.swift | 4 + .../Gamepad/ControllerTester.swift | 20 +- .../Gamepad/GamepadFeedback.swift | 19 +- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 527 ++++++++++++++---- .../PunktfunkKitTests/RumbleTuningTests.swift | 97 ++++ 6 files changed, 562 insertions(+), 119 deletions(-) create mode 100644 clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 7cd3e89..74029ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,19 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/ `GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser). Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense - motion sign/scale derived, not yet live-verified. **Gamepad UI (iOS/iPadOS + macOS, + motion sign/scale derived, not yet live-verified. **Rumble renderer rewritten + (2026-07-02, `RumbleRenderer.swift`)** around "rumble is idempotent state, divergence + must be bounded": the old per-datagram infinite-duration CoreHaptics players could leak + one dropped async `stop` into a forever-buzzing motor (the stuck-rumble-after-menu bug) + — now finite self-expiring segments with seamless engine-timeline re-arm, newest-wins + dry drain of the 0xCA plane (was 1 datagram/8 ms), dedupe of the host's 500 ms state + refreshes, zero-immediate/ramp-throttled rebakes, escalation to `engine.stop()` on a + throwing player stop, and a 1.6 s staleness watchdog (`Policy.session`; the settings + test panel uses `.manual` = hold). Controller engines use **plain `makePlayer` — never + `makeAdvancedPlayer`**: the controller haptics server (gamecontrollerd) advertises + `adv players: 0`, and iOS 27 beta 2 hard-drops advanced-player loads (XPC decode fault → + CoreHaptics -4811/4097, rumble silently dead). Unit-tested (`RumbleTuningTests`); + stuck-rumble repro on-glass revalidation pending. **Gamepad UI (iOS/iPadOS + macOS, 2026-07-02 rework):** a connected pad swaps the home for a console-style launcher (`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add Host tile (A connect · Y library · X settings · B back), a controller-navigable diff --git a/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift b/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift index 10bcaa6..6165e78 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift @@ -255,6 +255,10 @@ struct ControllerTestView: View { Toggle("Light motor (right)", isOn: $lightOn) Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform") .font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary) + if let problem = tester.rumbleHealth { + Label(problem, systemImage: "exclamationmark.triangle.fill") + .font(.geist(12, relativeTo: .caption)).foregroundStyle(.orange) + } 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).") diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift index 9ea4689..422182c 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift @@ -10,13 +10,20 @@ import GameController /// a passing test exercises the exact code a session runs. @MainActor public final class ControllerTester: ObservableObject { - private let renderer = RumbleRenderer() + // `.manual`: the panel's toggles hold a level until changed — no session wire refreshes + // exist here to keep the renderer's staleness watchdog fed. + private let renderer = RumbleRenderer(policy: .manual) 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 = "—" + /// Why rumble structurally cannot work right now (nil = healthy) — e.g. the device's + /// haptics service refusing every connection, or a pad with no rumble engine. Shown by the + /// test panel so silence diagnoses itself instead of reading as an app bug. + @Published public private(set) var rumbleHealth: String? + public init() {} /// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every @@ -24,9 +31,14 @@ public final class ControllerTester: ObservableObject { 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 } - } + renderer.retarget( + c, + onBackend: { [weak self] note in + Task { @MainActor in self?.rumbleBackend = note } + }, + onHealth: { [weak self] problem in + Task { @MainActor in self?.rumbleHealth = problem } + }) } /// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 786489f..3a08113 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -25,7 +25,7 @@ public final class GamepadFeedback { private let flag = StopFlag() private let drainDone = DispatchSemaphore(value: 0) private var drainStarted = false - private let rumble = RumbleRenderer() + private let rumble = RumbleRenderer(policy: .session) private var activeSub: AnyCancellable? // Last applied feedback (main-actor) — replayed when the active controller changes. @@ -82,8 +82,21 @@ public final class GamepadFeedback { // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps // rumble/HID latency low while leaving the lock free between polls. - if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { - self?.rumble.apply(low: r.low, high: r.high) + // + // Rumble is idempotent state, so drain the plane DRY and apply only the newest + // level. The old one-datagram-per-cycle shape let a burst outpace the ~125 Hz + // drain: levels rendered up to ~130 ms late through the core's 16-deep queue, + // and its drop-newest overflow could shed a stop while stale nonzero states + // queued ahead of it — buzzing until the host's next 500 ms refresh. + var newest: (low: UInt16, high: UInt16)? + var rumbleBurst = 0 + while rumbleBurst < 64, !flag.isStopped, + let r = try connection.nextRumble(timeoutMs: 0) { + if r.pad == 0 { newest = (r.low, r.high) } + rumbleBurst += 1 + } + if let n = newest { + self?.rumble.apply(low: n.low, high: n.high) } // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing // per-frame LED/trigger reports) can't spin here or block stop() past one cycle. diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 66e5540..772ac5e 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -5,28 +5,145 @@ import os private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") -/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, -/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero -/// amplitude and torn down on retarget; players run only while their motor is on, so an -/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets) -/// downgrade to silence — rumble is best-effort by design. -/// -/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is -/// read and written only inside `queue` closures — the serial queue is the synchronization. -final class RumbleRenderer: @unchecked Sendable { - private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) +/// 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 nonzero→nonzero 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 - /// 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. + /// `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; +/// nonzero→nonzero 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 - var player: CHHapticAdvancedPatternPlayer? + 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 @@ -39,86 +156,277 @@ final class RumbleRenderer: @unchecked Sendable { // 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.5→1→2→4 s on repeated failure — and clear it - // the moment a player runs cleanly (or the controller changes). - private var retryAfter = Date.distantPast + // the moment a player is actually running (or the controller changes). + private var retryAfter = DispatchTime(uptimeNanoseconds: 0) private var consecutiveFailures = 0 - - /// 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 + /// 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 — for the debug controller-test panel. - func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) { + /// 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 = .distantPast + 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)") } - // 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, Date() >= self.retryAfter { - self.setup() - } - let ok: Bool - if self.high != nil { - // 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. - 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. Back off so a broken XPC isn't re-hit every - // update; once a player is actually running the path has recovered, so clear the backoff. - if !ok { - self.teardown() - self.scheduleRetryBackoff() - } else if self.low?.player != nil || self.high?.player != nil { - self.consecutiveFailures = 0 - self.retryAfter = .distantPast - } + 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. @@ -130,20 +438,28 @@ final class RumbleRenderer: @unchecked Sendable { // 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 - if localities.contains(.leftHandle), localities.contains(.rightHandle) { - low = makeMotor(haptics, .leftHandle) - high = makeMotor(haptics, .rightHandle) + 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) + 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 the next nonzero amplitude past the cooldown retries. + // 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") + } } } @@ -153,10 +469,20 @@ final class RumbleRenderer: @unchecked Sendable { private func scheduleRetryBackoff() { consecutiveFailures += 1 let shift = min(consecutiveFailures - 1, 4) - retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 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) -> Motor? { + 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; @@ -167,7 +493,8 @@ final class RumbleRenderer: @unchecked Sendable { // 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 so the next nonzero amplitude lazily rebuilds the engine, instead. + // 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() } @@ -177,72 +504,42 @@ 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). + // 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, player: nil) + return Motor(engine: engine, sharpness: sharpness) } catch { log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)") return nil } } - /// 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 { - 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). - // 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)") - motor = m - return false - } - } - 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 = {} - try? m.player?.stop(atTime: CHHapticTimeImmediate) + 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. - // All three run on the serial `queue`, like the rest of the renderer state. + // Runs on the serial `queue`, like the rest of the renderer state. private func openHIDIfDualSense(_ c: GCController?) -> Bool { #if os(macOS) @@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable { #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 { + /// 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 } - hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8)) + 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 @@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable { private func closeHID() { #if os(macOS) - dualSenseHID?.close() + dualSenseHID?.close() // writes (0,0) before releasing dualSenseHID = nil + lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0)) #endif } diff --git a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift new file mode 100644 index 0000000..d5562e0 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift @@ -0,0 +1,97 @@ +import XCTest + +@testable import PunktfunkKit + +/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its +/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No +/// CHHapticEngine or physical pad involved. +final class RumbleTuningTests: XCTestCase { + func testAmplitudeMapsWireRangeToUnitInterval() { + XCTAssertEqual(RumbleTuning.amplitude(0), 0) + XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1) + XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6) + // Monotonic — a stronger wire value can never render weaker. + XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000)) + } + + func testHidByteMapsWireRangeToPadRange() { + XCTAssertEqual(RumbleTuning.hidByte(0), 0) + XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255) + XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80) + } + + func testCombinedActuatorRendersStrongerMotor() { + XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000) + XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000) + XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0) + } + + func testLevelDedupeEpsilon() { + // An identical host refresh (and LSB jitter) is the same level — no player rebuild. + XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5)) + XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon)) + // A real level change is not. + XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3)) + XCTAssertFalse(RumbleTuning.sameLevel(0, 1)) + } + + func testRearmDecision() { + let ends: TimeInterval = 100 + XCTAssertFalse( + RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1)) + XCTAssertTrue( + RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1)) + // Even a segment already past its end re-arms (the gap already happened; recover). + XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1)) + } + + func testHandoffStartsAtSegmentEndNeverInThePast() { + // Successor starts exactly at the predecessor's end... + XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100) + // ...unless that instant already passed — then start immediately, not in the past. + XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5) + } + + func testPolicies() { + // The session policy ties motor life to wire liveness; the manual (test-panel) policy + // holds a level indefinitely. + XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter) + XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter) + } + + /// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call + /// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs + /// `queue.sync` against the same serial queue the ticker fires on and must not deadlock. + func testRendererSurvivesCallStormAndTeardownWithoutController() { + let renderer = RumbleRenderer(policy: .session) + renderer.retarget(nil) + for i in 0..<500 { + renderer.apply( + low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37)) + } + // Leave a nonzero target long enough for the ticker to spin a few times. + renderer.apply(low: 0x4000, high: 0x4000) + Thread.sleep(forTimeInterval: 0.2) + renderer.stop() + } + + func testTuningRelationsTheDesignDependsOn() { + // The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps) + // but trip well before a stuck rumble reads as "still going". + XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5) + XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5) + // Re-arm headroom must clear several ticker periods, or a steady rumble could miss the + // segment boundary and gap. + XCTAssertGreaterThanOrEqual( + RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds) + // The headroom must fit inside a segment, or re-arm would trigger instantly forever. + XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds) + // The rebake throttle must be far under the host refresh period, or refreshed level + // changes would queue behind it; and under a frame at 30 fps so ramps stay smooth. + XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30) + // The ticker (which lands throttled levels) must outpace the HID keepalive and the + // watchdog, or those deadlines could be overshot by a full period. + XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds) + XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds) + } +}