Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift
enricobuehler 42a0dd52be
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
refactor(haptics): one copy of each thing every rumble path was transcribing
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.
2026-08-04 22:52:38 +02:00

101 lines
5.0 KiB
Swift

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)
}
/// 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()
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()
}
/// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
let renderer = RumbleRenderer()
renderer.retarget(nil)
renderer.apply(low: 0x8000, high: 0x8000)
Thread.sleep(forTimeInterval: 0.1)
renderer.apply(low: 0, high: 0)
Thread.sleep(forTimeInterval: 0.1)
// No assertion on private state; this exercises the stop path + serial-queue teardown
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// 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, or its
// deadline could be overshot by a full period.
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
}
}