Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.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

392 lines
17 KiB
Swift

// DEBUG-only controller test panel, reached from Settings Controllers "Test Controller".
// It shows the live input of the active controller and lets you fire the hostclient feedback
// channels rumble, DualSense adaptive triggers, lightbar, player LEDs straight at the
// physical pad (no host needed), so the rendering paths a session uses can be confirmed
// on-device. Driven by PunktfunkKit's `ControllerTester`, which reuses the real renderers.
//
// tvOS is excluded for now (it has no segmented picker / the panel wants a pointer-style
// layout); macOS + iOS/iPadOS cover the validation need.
#if DEBUG && !os(tvOS)
import GameController
import PunktfunkKit
import SwiftUI
@MainActor
struct ControllerTestView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject private var gamepads = GamepadManager.shared
@StateObject private var tester = ControllerTester()
@State private var heavyOn = false
@State private var lightOn = false
@State private var intensity = 0.75
@State private var triggerTarget = TriggerTarget.both
@State private var playerLED = -1
private enum TriggerTarget: String, CaseIterable, Identifiable {
case left = "L2", right = "R2", both = "Both"
var id: String { rawValue }
}
private struct TriggerDemo: Identifiable {
let label: String
let effect: DualSenseTriggerEffect
var id: String { label }
}
private static let triggerDemos: [TriggerDemo] = [
.init(label: "Off", effect: .off),
.init(label: "Resistance", effect: .feedback(start: 0.3, strength: 0.7)),
.init(label: "Weapon", effect: .weapon(start: 0.4, end: 0.7, strength: 0.9)),
.init(label: "Vibration", effect: .vibration(start: 0.1, amplitude: 0.8, frequency: 0.5)),
.init(label: "Bow", effect: .slope(start: 0.2, end: 0.9, startStrength: 0.2, endStrength: 0.9)),
]
// (display name, hardware colour, swatch colour)
private static let lightSwatches: [(String, GCColor, Color)] = [
("Red", GCColor(red: 1, green: 0, blue: 0), .red),
("Green", GCColor(red: 0, green: 1, blue: 0), .green),
("Blue", GCColor(red: 0, green: 0.2, blue: 1), .blue),
("White", GCColor(red: 1, green: 1, blue: 1), .white),
]
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Test Controller").font(.geist(17, .semibold, relativeTo: .headline))
Spacer()
Button("Done") { dismiss() }.keyboardShortcut(.cancelAction)
}
.padding()
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let active = gamepads.active {
header(active)
inputCard
rumbleCard()
triggerCard(active)
extrasCard(active)
} else {
ContentUnavailableView(
"No controller",
systemImage: "gamecontroller",
description: Text("Connect a controller and pick it under "
+ "Settings → Controllers → Use controller."))
.frame(maxWidth: .infinity, minHeight: 220)
}
}
.padding()
}
}
.frame(minWidth: 420, minHeight: 540)
.onAppear { tester.target(gamepads.active?.controller) }
.onDisappear { tester.stop() }
.onChange(of: gamepads.active?.id) { _, _ in
heavyOn = false
lightOn = false
playerLED = -1
tester.target(gamepads.active?.controller)
}
}
// MARK: Header
private func header(_ c: GamepadManager.DiscoveredController) -> some View {
HStack(spacing: 10) {
Image(systemName: c.isDualSense ? "playstation.logo" : "gamecontroller.fill")
.font(.title2)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(c.name).font(.geist(17, .semibold, relativeTo: .headline))
Text(c.productCategory).font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
Spacer()
}
}
// MARK: Input
private var inputCard: some View {
card("Input") {
// Poll the live controller at 30 Hz no handlers installed, so nothing else's
// capture is disturbed.
TimelineView(.periodic(from: .now, by: 1.0 / 30.0)) { _ in
if let gp = gamepads.active?.controller.extendedGamepad {
inputReadout(gp, controller: gamepads.active?.controller)
} else {
Text("Not an extended gamepad").foregroundStyle(.secondary)
}
}
}
}
@ViewBuilder
private func inputReadout(_ g: GCExtendedGamepad, controller: GCController?) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 20) {
stick("L", x: g.leftThumbstick.xAxis.value, y: g.leftThumbstick.yAxis.value,
pressed: g.leftThumbstickButton?.isPressed ?? false)
stick("R", x: g.rightThumbstick.xAxis.value, y: g.rightThumbstick.yAxis.value,
pressed: g.rightThumbstickButton?.isPressed ?? false)
VStack(spacing: 8) {
triggerBar("L2", value: g.leftTrigger.value)
triggerBar("R2", value: g.rightTrigger.value)
}
}
buttonGrid(g)
if let tp = Self.touchpad(g) {
touchpadView(tp)
}
if let m = controller?.motion {
motionReadout(m)
}
}
}
private func stick(_ label: String, x: Float, y: Float, pressed: Bool) -> some View {
VStack(spacing: 4) {
ZStack {
Circle().stroke(Color.secondary.opacity(0.3))
Circle()
.fill(pressed ? Color.accentColor : Color.secondary)
.frame(width: 12, height: 12)
.offset(x: CGFloat(x) * 22, y: CGFloat(-y) * 22) // GC y is +up
}
.frame(width: 56, height: 56)
Text("\(label) \(sgn(x)),\(sgn(y))").font(.caption2.monospaced()).foregroundStyle(.secondary)
}
}
private func triggerBar(_ label: String, value: Float) -> some View {
HStack(spacing: 6) {
Text(label).font(.caption2.monospaced()).frame(width: 22, alignment: .leading)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.15))
Capsule().fill(Color.accentColor).frame(width: geo.size.width * CGFloat(value))
}
}
.frame(height: 10)
Text(mag(value)).font(.caption2.monospaced()).frame(width: 34, alignment: .trailing)
.foregroundStyle(.secondary)
}
.frame(width: 150)
}
private func buttonGrid(_ g: GCExtendedGamepad) -> some View {
var items: [(String, Bool)] = [
("A", g.buttonA.isPressed), ("B", g.buttonB.isPressed),
("X", g.buttonX.isPressed), ("Y", g.buttonY.isPressed),
("LB", g.leftShoulder.isPressed), ("RB", g.rightShoulder.isPressed),
("L3", g.leftThumbstickButton?.isPressed ?? false),
("R3", g.rightThumbstickButton?.isPressed ?? false),
("Menu", g.buttonMenu.isPressed),
("Opts", g.buttonOptions?.isPressed ?? false),
("", g.dpad.up.isPressed), ("", g.dpad.down.isPressed),
("", g.dpad.left.isPressed), ("", g.dpad.right.isPressed),
]
if let tp = Self.touchpad(g) { items.append(("Pad", tp.button.isPressed)) }
return LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 5), spacing: 6
) {
ForEach(items.indices, id: \.self) { i in
Text(items[i].0)
.font(.caption.monospaced())
.frame(maxWidth: .infinity, minHeight: 24)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(items[i].1 ? Color.accentColor : Color.secondary.opacity(0.15)))
.foregroundStyle(items[i].1 ? Color.white : Color.secondary)
}
}
}
private func touchpadView(
_ tp: (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Touchpad\(tp.button.isPressed ? " — click" : "")")
.font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
ZStack {
RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.3))
fingerDot(tp.primary, color: .accentColor)
fingerDot(tp.secondary, color: .orange)
}
.frame(width: 150, height: 74)
}
}
private func fingerDot(_ pad: GCControllerDirectionPad, color: Color) -> some View {
let x = pad.xAxis.value, y = pad.yAxis.value
let active = !(x == 0 && y == 0) // GC snaps a lifted finger to exactly (0, 0)
return Circle().fill(color).frame(width: 10, height: 10)
.offset(x: CGFloat(x) * 71, y: CGFloat(-y) * 33)
.opacity(active ? 1 : 0)
}
private func motionReadout(_ m: GCMotion) -> some View {
let a = Self.totalAccel(m)
return VStack(alignment: .leading, spacing: 2) {
Text("Motion").font(.geist(11, relativeTo: .caption2)).foregroundStyle(.secondary)
Text(String(format: "gyro %+.2f %+.2f %+.2f",
m.rotationRate.x, m.rotationRate.y, m.rotationRate.z))
.font(.caption2.monospaced())
Text(String(format: "accel %+.2f %+.2f %+.2f", a.0, a.1, a.2))
.font(.caption2.monospaced())
}
}
// MARK: Rumble
private func rumbleCard() -> some View {
card("Rumble") {
VStack(alignment: .leading, spacing: 12) {
Picker("Strength", selection: $intensity) {
Text("25%").tag(0.25)
Text("50%").tag(0.5)
Text("75%").tag(0.75)
Text("100%").tag(1.0)
}
.pickerStyle(.segmented)
Toggle("Heavy motor (left)", isOn: $heavyOn)
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).")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
.onChange(of: heavyOn) { _, _ in applyRumble() }
.onChange(of: lightOn) { _, _ in applyRumble() }
.onChange(of: intensity) { _, _ in applyRumble() }
}
}
private func applyRumble() {
tester.rumble(low: heavyOn ? Float(intensity) : 0, high: lightOn ? Float(intensity) : 0)
}
// MARK: Adaptive triggers
private func triggerCard(_ c: GamepadManager.DiscoveredController) -> some View {
card("Adaptive triggers") {
if c.hasAdaptiveTriggers {
VStack(alignment: .leading, spacing: 12) {
Picker("Apply to", selection: $triggerTarget) {
ForEach(TriggerTarget.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 96), spacing: 8)], spacing: 8
) {
ForEach(Self.triggerDemos) { demo in
Button(demo.label) { applyTrigger(demo.effect) }
.buttonStyle(.bordered)
}
}
Text("Pick an effect, then pull L2/R2 to feel the resistance.")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
} else {
Text("Adaptive triggers need a DualSense.")
.font(.geist(12, relativeTo: .caption)).foregroundStyle(.secondary)
}
}
}
private func applyTrigger(_ e: DualSenseTriggerEffect) {
switch triggerTarget {
case .left: tester.applyTrigger(e, right: false)
case .right: tester.applyTrigger(e, right: true)
case .both:
tester.applyTrigger(e, right: false)
tester.applyTrigger(e, right: true)
}
}
// MARK: Lightbar + player LED
@ViewBuilder
private func extrasCard(_ c: GamepadManager.DiscoveredController) -> some View {
if c.hasLight {
card("Lightbar & player LED") {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
ForEach(Self.lightSwatches.indices, id: \.self) { i in
Button { tester.setLight(Self.lightSwatches[i].1) } label: {
Circle().fill(Self.lightSwatches[i].2)
.frame(width: 26, height: 26)
.overlay(Circle().stroke(Color.secondary.opacity(0.4)))
}
.buttonStyle(.plain)
}
Button("Off") { tester.setLight(nil) }.buttonStyle(.bordered)
}
Picker("Player LED", selection: $playerLED) {
Text("Off").tag(-1)
Text("1").tag(0)
Text("2").tag(1)
Text("3").tag(2)
Text("4").tag(3)
}
.pickerStyle(.segmented)
.onChange(of: playerLED) { _, v in
tester.setPlayerIndex(GCControllerPlayerIndex(rawValue: v) ?? .indexUnset)
}
}
}
}
}
// MARK: Helpers
private func card<Content: View>(
_ title: String, @ViewBuilder _ content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title).font(.geist(15, .semibold, relativeTo: .subheadline))
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.08)))
}
private func sgn(_ v: Float) -> String { String(format: "%+.2f", v) }
private func mag(_ v: Float) -> String { String(format: "%.2f", v) }
/// The touchpad surface of a PlayStation pad `GCDualSenseGamepad` and `GCDualShockGamepad`
/// don't share a touchpad type, so downcast either. `nil` for any other controller.
private static func touchpad(
_ g: GCExtendedGamepad
) -> (primary: GCControllerDirectionPad, secondary: GCControllerDirectionPad,
button: GCControllerButtonInput)? {
if let ds = g as? GCDualSenseGamepad {
return (ds.touchpadPrimary, ds.touchpadSecondary, ds.touchpadButton)
}
if let ds4 = g as? GCDualShockGamepad {
return (ds4.touchpadPrimary, ds4.touchpadSecondary, ds4.touchpadButton)
}
return nil
}
/// Total acceleration in g: gravity + user when the pad splits them, else the raw vector.
private static func totalAccel(_ m: GCMotion) -> (Double, Double, Double) {
if m.hasGravityAndUserAcceleration {
return (m.gravity.x + m.userAcceleration.x,
m.gravity.y + m.userAcceleration.y,
m.gravity.z + m.userAcceleration.z)
}
return (m.acceleration.x, m.acceleration.y, m.acceleration.z)
}
}
#endif