windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
ci / rust (pull_request) Successful in 9m44s
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found real defects hiding behind the duplication. **The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense, DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim copy of the kernel's constants plus its own `put_cstr`, and they had already drifted: `switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a bug in both directions — a longer report was truncated, and a shorter one had the parser reading whatever the reused event buffer still held past the payload, i.e. acting on rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the two accessors that are easy to get subtly wrong, with tests on exactly that. **A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1` branch could never run — and allocating from a local counter would have been the wrong answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where it stood. **Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has always floored it at 1; this was the odd one out. That converter also existed twice byte-identically inside one Gradle module — now one `wireAmplitudeToByte`. Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`) documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it and `pf-inject` do not depend on each other, and a DualSense layout has no business in `punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes the struct it describes instead of a second literal 11 — the header now emits `uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms` and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets `min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served by the quirk, because that renderer skips writes whose levels are unchanged and would swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is marked as staged scaffolding (the tag is on a shipped wire; removing the variant would not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine, both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides, with `MAX_PADS <= 16` now a compile-time assertion rather than a comment. Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12). S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs` calls `set_forwarding` unconditionally on every params-build (its own comment explains why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI cycle each launch. Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit: 6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails the pin or refuses to compile.
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()
|
|
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
|