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.
101 lines
5.0 KiB
Swift
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)
|
|
}
|
|
}
|