13b1f36d4a
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.
New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).
design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
101 lines
5.1 KiB
Swift
101 lines
5.1 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(policy: .session)
|
|
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(policy: .session)
|
|
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)
|
|
}
|
|
}
|