396c3453f5
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent state on a lossy channel, and the actuator's divergence from it must be bounded, not best-effort. The old renderer rebuilt an infinite-duration CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop lost inside CoreHaptics left an unstoppable player buzzing forever (the "entered the menu and rumble never stopped" bug). - Finite 4 s segments, never infinite events — a leaked player self-silences; steady levels re-arm seamlessly ON the engine timeline (no stop/start race) - GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops) - Host 500 ms state refreshes dedupe to a liveness stamp; zero applies immediately; nonzero ramps throttle to one rebake/25 ms per motor - Throwing player stop escalates to engine.stop() (kills leaked players); 1.6 s staleness watchdog (Policy.session) force-silences on a dead channel; the test panel holds levels via Policy.manual - Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops advanced loads with an XPC decode fault (-4811/4097, rumble silently dead). Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works - Split-handle engines fall back to one combined .default engine on repeated failure; renderer publishes health transitions and the test panel shows them (a refused system service no longer reads as silent app breakage) - Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero - RumbleTuningTests pin the scheduling math, tuning relations, and a queue/ticker teardown smoke test Stuck-rumble streaming repro revalidation on glass still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 lines
3.8 KiB
Swift
86 lines
3.8 KiB
Swift
#if DEBUG
|
|
import Combine
|
|
import GameController
|
|
|
|
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
|
|
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
|
|
/// live session uses — just aimed at the physically-connected controller instead of the
|
|
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
|
|
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
|
|
/// a passing test exercises the exact code a session runs.
|
|
@MainActor
|
|
public final class ControllerTester: ObservableObject {
|
|
// `.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
|
|
/// active-controller change.
|
|
public func target(_ c: GCController?) {
|
|
guard c !== controller else { return }
|
|
controller = c
|
|
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
|
|
/// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
|
|
public func rumble(low: Float, high: Float) {
|
|
func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
|
|
renderer.apply(low: u16(low), high: u16(high))
|
|
}
|
|
|
|
public func stopRumble() { renderer.apply(low: 0, high: 0) }
|
|
|
|
/// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
|
|
/// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
|
|
public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
|
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
|
effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
|
|
}
|
|
|
|
public func resetTriggers() {
|
|
guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
|
|
ds.leftTrigger.setModeOff()
|
|
ds.rightTrigger.setModeOff()
|
|
}
|
|
|
|
/// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
|
|
public func setLight(_ color: GCColor?) {
|
|
controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
|
|
}
|
|
|
|
/// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
|
|
public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
|
|
controller?.playerIndex = index
|
|
}
|
|
|
|
/// Silence every channel and release the controller — call on the panel's disappear.
|
|
public func stop() {
|
|
resetTriggers()
|
|
setPlayerIndex(.indexUnset)
|
|
setLight(nil)
|
|
renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
|
|
controller = nil
|
|
}
|
|
}
|
|
#endif
|