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

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

Stuck-rumble streaming repro revalidation on glass still pending.

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

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
/// hostclient 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