Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 396c3453f5 | |||
| 6921e147dd |
@@ -126,6 +126,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
for DEB in dist/*.deb; do
|
for DEB in dist/*.deb; do
|
||||||
echo "uploading $DEB"
|
echo "uploading $DEB"
|
||||||
|
# A re-tagged release re-fires this workflow and the apt registry 409s on duplicate
|
||||||
|
# package versions — delete any prior copy of this exact name/version/arch first
|
||||||
|
# (404 on the first publish is fine).
|
||||||
|
NAME=$(dpkg-deb -f "$DEB" Package)
|
||||||
|
VER=$(dpkg-deb -f "$DEB" Version)
|
||||||
|
ARCH=$(dpkg-deb -f "$DEB" Architecture)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/$NAME/$VER/$ARCH" || true
|
||||||
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
# PAT owner (enricobuehler), not the push actor — matches docker.yml's registry login.
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$DEB" \
|
||||||
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
"https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload"
|
||||||
|
|||||||
@@ -122,8 +122,13 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||||
# 1) Immutable, versioned URL + its update manifest (the manifest's `artifact` points
|
# 1) Versioned URL + its update manifest (the manifest's `artifact` points here, so the
|
||||||
# here, so the published sha256 keeps matching what Decky later downloads).
|
# published sha256 keeps matching what Decky later downloads). A re-tagged release
|
||||||
|
# re-fires this workflow and the registry 409s on duplicate uploads — delete any
|
||||||
|
# prior copy of this version first (404 on the first publish is fine).
|
||||||
|
for f in punktfunk.zip manifest.json; do
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$VERSION/$f" || true
|
||||||
|
done
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \
|
||||||
"$BASE/$VERSION/punktfunk.zip"
|
"$BASE/$VERSION/punktfunk.zip"
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/manifest.json" \
|
||||||
|
|||||||
@@ -133,7 +133,10 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
BASE="https://$REGISTRY/api/packages/$OWNER/generic/$PACKAGE"
|
||||||
# 1) Immutable, versioned URL.
|
# 1) Versioned URL. A re-tagged release re-fires this workflow and the registry 409s on
|
||||||
|
# duplicate uploads — delete any prior copy first (404 on the first publish is fine).
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"$BASE/$VERSION/$BUNDLE" || true
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \
|
||||||
"$BASE/$VERSION/$BUNDLE"
|
"$BASE/$VERSION/$BUNDLE"
|
||||||
echo "published $BASE/$VERSION/$BUNDLE"
|
echo "published $BASE/$VERSION/$BUNDLE"
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ jobs:
|
|||||||
for rpm in dist/*.rpm; do
|
for rpm in dist/*.rpm; do
|
||||||
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac
|
||||||
echo "uploading $rpm"
|
echo "uploading $rpm"
|
||||||
|
# A re-tagged release re-fires this workflow and the rpm registry 409s on duplicate
|
||||||
|
# package versions — delete any prior copy of this exact name/version-release/arch
|
||||||
|
# first (404 on the first publish is fine).
|
||||||
|
NAME=$(rpm -qp --qf '%{NAME}' "$rpm" 2>/dev/null)
|
||||||
|
VR=$(rpm -qp --qf '%{VERSION}-%{RELEASE}' "$rpm" 2>/dev/null)
|
||||||
|
ARCH=$(rpm -qp --qf '%{ARCH}' "$rpm" 2>/dev/null)
|
||||||
|
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
|
||||||
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/package/$NAME/$VR/$ARCH" || true
|
||||||
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \
|
||||||
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
"https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -172,7 +172,19 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
player LEDs / adaptive triggers → `GCDeviceLight`/`playerIndex`/
|
||||||
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
`GCDualSenseAdaptiveTrigger` via the table-driven `DualSenseTriggerEffect` parser).
|
||||||
Loopback-tested end to end (`PUNKTFUNK_TEST_FEEDBACK=1` scripted burst); DualSense
|
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
|
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
|
(`Home/Gamepad*` + `Settings/GamepadSettingsView`) — host carousel with a trailing Add
|
||||||
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
Host tile (A connect · Y library · X settings · B back), a controller-navigable
|
||||||
|
|||||||
@@ -255,6 +255,10 @@ struct ControllerTestView: View {
|
|||||||
Toggle("Light motor (right)", isOn: $lightOn)
|
Toggle("Light motor (right)", isOn: $lightOn)
|
||||||
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
Label("Backend: \(tester.rumbleBackend)", systemImage: "waveform")
|
||||||
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
|
.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 "
|
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 "
|
+ "rumble onto these two. A DualSense is driven over raw HID (CoreHaptics "
|
||||||
+ "can't reach its motors on macOS).")
|
+ "can't reach its motors on macOS).")
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ import GameController
|
|||||||
/// a passing test exercises the exact code a session runs.
|
/// a passing test exercises the exact code a session runs.
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class ControllerTester: ObservableObject {
|
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?
|
private weak var controller: GCController?
|
||||||
|
|
||||||
/// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
|
/// 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.
|
/// for the test panel to display so it's obvious which path a given pad takes.
|
||||||
@Published public private(set) var rumbleBackend = "—"
|
@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() {}
|
public init() {}
|
||||||
|
|
||||||
/// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
|
/// 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?) {
|
public func target(_ c: GCController?) {
|
||||||
guard c !== controller else { return }
|
guard c !== controller else { return }
|
||||||
controller = c
|
controller = c
|
||||||
renderer.retarget(c) { [weak self] note in
|
renderer.retarget(
|
||||||
Task { @MainActor in self?.rumbleBackend = note }
|
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
|
/// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public final class GamepadFeedback {
|
|||||||
private let flag = StopFlag()
|
private let flag = StopFlag()
|
||||||
private let drainDone = DispatchSemaphore(value: 0)
|
private let drainDone = DispatchSemaphore(value: 0)
|
||||||
private var drainStarted = false
|
private var drainStarted = false
|
||||||
private let rumble = RumbleRenderer()
|
private let rumble = RumbleRenderer(policy: .session)
|
||||||
private var activeSub: AnyCancellable?
|
private var activeSub: AnyCancellable?
|
||||||
|
|
||||||
// Last applied feedback (main-actor) — replayed when the active controller changes.
|
// 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
|
// 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
|
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// 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
|
// 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.
|
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
|
||||||
|
|||||||
@@ -5,28 +5,145 @@ import os
|
|||||||
|
|
||||||
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")
|
||||||
|
|
||||||
/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
|
/// Tuning constants + the pure scheduling decisions of the rumble renderer, split out so the
|
||||||
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
|
/// policy is unit-testable without a `CHHapticEngine` or a physical pad.
|
||||||
/// amplitude and torn down on retarget; players run only while their motor is on, so an
|
enum RumbleTuning {
|
||||||
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
|
/// Haptic segment length. **No event is ever infinite**: a player the renderer loses track
|
||||||
/// downgrade to silence — rumble is best-effort by design.
|
/// 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
|
||||||
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
|
/// target state.
|
||||||
/// read and written only inside `queue` closures — the serial queue is the synchronization.
|
static let segmentSeconds: TimeInterval = 4.0
|
||||||
final class RumbleRenderer: @unchecked Sendable {
|
/// Re-arm the successor segment once the current one has less than this left. Generous
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
/// 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
|
/// `CHHapticEvent` sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
||||||
/// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
|
/// defined frequency to move at all (an intensity-only event left them silent) while a
|
||||||
/// continuous event rather than scaling a long-lived one with a dynamic parameter.
|
/// 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 {
|
private struct Motor {
|
||||||
let engine: CHHapticEngine
|
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 controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: 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
|
// `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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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).
|
// the moment a player is actually running (or the controller changes).
|
||||||
private var retryAfter = Date.distantPast
|
private var retryAfter = DispatchTime(uptimeNanoseconds: 0)
|
||||||
private var consecutiveFailures = 0
|
private var consecutiveFailures = 0
|
||||||
|
/// Downgrade after split-handle engines fail: retry with ONE combined `.default` engine —
|
||||||
/// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
|
/// the configuration virtually every iOS game (and this app's own menu haptics) uses — before
|
||||||
/// defined frequency to move at all — an intensity-only event (no sharpness) left them
|
/// treating the service as unreachable. A haptics daemon that mishandles per-handle
|
||||||
/// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
|
/// localities for a particular pad can still serve the combined engine. One-way per
|
||||||
/// value the known-working macOS DualSense rumble implementations use. (Used only on the
|
/// controller; retarget resets it.
|
||||||
/// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
|
private var preferCombined = false
|
||||||
private static let sharpness: Float = 0.5
|
/// 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)
|
#if os(macOS)
|
||||||
/// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
|
/// 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
|
/// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
|
||||||
/// every other controller, which keeps the CoreHaptics path.
|
/// every other controller, which keeps the CoreHaptics path.
|
||||||
private var dualSenseHID: DualSenseHID?
|
private var dualSenseHID: DualSenseHID?
|
||||||
|
private var lastHidWrite: (levels: (UInt8, UInt8), at: DispatchTime) =
|
||||||
|
((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
init(policy: Policy = .session) {
|
||||||
|
self.policy = policy
|
||||||
|
}
|
||||||
|
|
||||||
/// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
|
/// `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.
|
/// rumble backend now in use; `onHealth` with a problem description whenever rumble transitions
|
||||||
func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
|
/// 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 {
|
queue.async {
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
self.closeHID()
|
||||||
self.controller = c
|
self.controller = c
|
||||||
self.broken = false
|
self.broken = false
|
||||||
|
self.preferCombined = false
|
||||||
self.consecutiveFailures = 0
|
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)
|
_ = self.openHIDIfDualSense(c)
|
||||||
onBackend?(self.backendNote(for: 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) {
|
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||||
queue.async {
|
queue.async {
|
||||||
|
self.lastCommand = .now()
|
||||||
let active = lowAmp != 0 || highAmp != 0
|
let active = lowAmp != 0 || highAmp != 0
|
||||||
if active != self.wasActive {
|
if active != self.wasActive {
|
||||||
self.wasActive = active
|
self.wasActive = active
|
||||||
log.debug(
|
log.debug(
|
||||||
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
"rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
|
||||||
}
|
}
|
||||||
// A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
|
guard (lowAmp, highAmp) != self.target else { return }
|
||||||
// other pad (and for a DualSense whose HID device could not be opened).
|
self.target = (lowAmp, highAmp)
|
||||||
if self.hidRumble(low: lowAmp, high: highAmp) { return }
|
self.render()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Silence the motors and drop the engines. Blocks until done — call off the main actor.
|
||||||
func stop() {
|
func stop() {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
self.ticker?.cancel()
|
||||||
|
self.ticker = nil
|
||||||
|
self.target = (0, 0)
|
||||||
|
self.wasActive = false
|
||||||
self.teardown()
|
self.teardown()
|
||||||
self.closeHID()
|
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,
|
/// Engines per handle when the pad distinguishes them (low = left/heavy motor,
|
||||||
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
/// high = right/light — the Xbox/XInput convention the wire carries); one combined
|
||||||
/// engine otherwise, driven by whichever amplitude is stronger.
|
/// 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.
|
// the controller changes; latch off (retarget clears it) and say so once.
|
||||||
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
|
||||||
broken = true
|
broken = true
|
||||||
|
reportHealth("This controller exposes no rumble engine to apps on this OS.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let localities = haptics.supportedLocalities
|
let localities = haptics.supportedLocalities
|
||||||
if localities.contains(.leftHandle), localities.contains(.rightHandle) {
|
let split =
|
||||||
low = makeMotor(haptics, .leftHandle)
|
!preferCombined && localities.contains(.leftHandle)
|
||||||
high = makeMotor(haptics, .rightHandle)
|
&& localities.contains(.rightHandle)
|
||||||
|
if split {
|
||||||
|
low = makeMotor(haptics, .leftHandle, sharpness: RumbleTuning.sharpnessLow)
|
||||||
|
high = makeMotor(haptics, .rightHandle, sharpness: RumbleTuning.sharpnessHigh)
|
||||||
} else {
|
} else {
|
||||||
low = makeMotor(haptics, .default)
|
low = makeMotor(haptics, .default, sharpness: RumbleTuning.sharpnessCombined)
|
||||||
}
|
}
|
||||||
if low == nil, high == nil {
|
if low == nil, high == nil {
|
||||||
// Haptics present but no engine could be built right now (server busy / XPC broken). Do
|
// 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")
|
log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
|
||||||
scheduleRetryBackoff()
|
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() {
|
private func scheduleRetryBackoff() {
|
||||||
consecutiveFailures += 1
|
consecutiveFailures += 1
|
||||||
let shift = min(consecutiveFailures - 1, 4)
|
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 }
|
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
|
// 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;
|
// (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
|
// 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
|
// 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
|
// 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
|
engine.stoppedHandler = { [weak self] reason in
|
||||||
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
|
||||||
self?.queue.async { self?.teardown() }
|
self?.queue.async { self?.teardown() }
|
||||||
@@ -177,72 +504,42 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
self?.queue.async { self?.teardown() }
|
self?.queue.async { self?.teardown() }
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
// Start the engine now; the player that actually moves the motor is built per level
|
// Start the engine now; the players that actually move the motor are the finite
|
||||||
// change in `drive` (a fresh event baked at the target intensity).
|
// segments `reconcile` bakes per level.
|
||||||
try engine.start()
|
try engine.start()
|
||||||
return Motor(engine: engine, player: nil)
|
return Motor(engine: engine, sharpness: sharpness)
|
||||||
} catch {
|
} catch {
|
||||||
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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() {
|
private func teardown() {
|
||||||
for m in [low, high].compactMap({ $0 }) {
|
for m in [low, high].compactMap({ $0 }) {
|
||||||
// Disarm the handlers before stopping so stop() can't re-enter teardown via them.
|
// 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.)
|
// (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
|
||||||
m.engine.stoppedHandler = { _ in }
|
m.engine.stoppedHandler = { _ in }
|
||||||
m.engine.resetHandler = {}
|
m.engine.resetHandler = {}
|
||||||
try? m.player?.stop(atTime: CHHapticTimeImmediate)
|
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()
|
m.engine.stop()
|
||||||
}
|
}
|
||||||
low = nil
|
low = nil
|
||||||
high = 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)
|
// MARK: - DualSense raw-HID rumble (macOS)
|
||||||
//
|
//
|
||||||
// On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
|
// 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.
|
// 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 {
|
private func openHIDIfDualSense(_ c: GCController?) -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -256,12 +553,19 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
|
/// Write the target to the DualSense over HID if that's the active backend; false → not a
|
||||||
/// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
|
/// HID pad, so the caller renders via CoreHaptics. Deduped on the pad's 0...255 resolution,
|
||||||
private func hidRumble(low: UInt16, high: UInt16) -> Bool {
|
/// with a periodic keepalive re-write while nonzero (the ticker calls back in here).
|
||||||
|
private func renderHID() -> Bool {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
guard let hid = dualSenseHID else { return false }
|
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
|
return true
|
||||||
#else
|
#else
|
||||||
return false
|
return false
|
||||||
@@ -270,8 +574,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
|
|
||||||
private func closeHID() {
|
private func closeHID() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
dualSenseHID?.close()
|
dualSenseHID?.close() // writes (0,0) before releasing
|
||||||
dualSenseHID = nil
|
dualSenseHID = nil
|
||||||
|
lastHidWrite = ((0, 0), DispatchTime(uptimeNanoseconds: 0))
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user