feat(rumble): host-authoritative self-terminating envelopes (0xCA v2)

Rumble was level-triggered, unbounded state on a lossy channel: a non-zero
level meant "buzz until further notice", healed only by the host re-sending
state every 500 ms, and every client guessed when the host had died with its own
magic timeout (SDL 1.5 s, Apple 1.6 s, Android up to 60 s). A lost stop, a
reordered start, or a dead host could drone the motor for seconds.

Make "stuck rumble" inexpressible on the wire. The 0xCA datagram grows a
length-tolerant tail — [u8 seq][u16 ttl_ms] — so it self-terminates: the host
authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds,
letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder
gate (reusing GamepadSnapshot::seq_newer) so a reordered stale start can't
re-light a stopped motor. Decoders read the first 7 bytes as a plain level and
ignore the tail, so no wire-version bump: an old client renders a new host's
levels, and a new client falls back to its prior staleness heuristic against an
old host (ttl = None). All four generation pairings render correctly.

- core: encode_rumble_datagram_v2 / decode_rumble_envelope (datagram.rs); the
  client demux applies the seq gate then forwards (pad, low, high, Option<ttl>);
  next_rumble is unchanged (drops ttl), next_rumble_ttl keeps it; ABI adds
  punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4->5
  (WIRE_VERSION unchanged — the tail is backward-compatible).
- host (punktfunk1.rs): the flat 500 ms refresh becomes a renewal loop that bumps
  seq + stamps a fresh TTL on active pads and drains a short post-stop zero burst,
  then goes quiet. Hatches: PUNKTFUNK_RUMBLE_ENVELOPE=0 (legacy v1 + flat refresh,
  a bisect switch), PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]).
- renderers honor the TTL as their playback duration/deadline and keep their old
  heuristic only for a legacy (ttl=None) update: pf-client-core (the Deck haptic
  keep-alive is now deadline-bounded so it can't sustain a host-stopped rumble),
  clients/windows (SDL duration), android (JNI packs the lease out-of-band in bit
  48 so any u16 ttl is unambiguous; Kotlin createOneShot(ttl)), apple
  (RumbleRenderer.envelopeDeadline + nextRumble2; sessionStaleSeconds demoted to
  the legacy fallback).
- tests: codec round-trip + tail tolerance + seq-gate reorder (Rust); the probe
  asserts the v2 tail arrived under PUNKTFUNK_TEST_FEEDBACK; the Apple loopback
  asserts ttlMs round-trips end to end; RumbleTuning lease-decision cases.

The host-side idle-timeout from the previous commit is defense in depth on the
game side; this is the guarantee on the client side. Design:
punktfunk-planning/design/rumble-envelope-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 03:08:27 +02:00
parent 19e9828e8d
commit 73c911cae4
17 changed files with 734 additions and 91 deletions
@@ -35,6 +35,9 @@ class GamepadFeedback(private val handle: Long) {
const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03
// Fallback one-shot duration against a legacy host (no v2 TTL lease): the prior fixed value.
// A new host renews far below this, so it never actually holds this long there.
const val LEGACY_RUMBLE_MS = 60_000L
}
@Volatile private var running = false
@@ -66,7 +69,17 @@ class GamepadFeedback(private val handle: Long) {
while (running) {
val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed
renderRumble(((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt())
// ev bit 48 = has a v2 lease; bits 32..47 = ttl_ms; 16..31 = low; 0..15 = high. The
// lease flag is out-of-band, so any ttl_ms (incl. 0xFFFF) is a real lease — no
// in-band sentinel. No lease (legacy host) → the prior long one-shot.
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
renderRumble(
((ev ushr 16) and 0xFFFF).toInt(),
(ev and 0xFFFF).toInt(),
durationMs,
)
}
}, "pf-rumble").apply { isDaemon = true; start() }
@@ -143,9 +156,14 @@ class GamepadFeedback(private val handle: Long) {
}
}
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
private fun renderRumble(low: Int, high: Int) {
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
/**
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
* `durationMs` is the host's v2 envelope TTL — the one-shot self-terminates after it unless the
* host renews, so a lost stop (or a dead host) silences at the lease instead of the old fixed
* 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS] (the prior fixed duration).
*/
private fun renderRumble(low: Int, high: Int, durationMs: Long) {
Log.i(TAG, "rumble low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
val m = vm
@@ -157,12 +175,12 @@ class GamepadFeedback(private val handle: Long) {
val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
} else {
// Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
for (id in vibratorIds) combo.addVibrator(id, oneShot(a, durationMs))
}
runCatching { m.vibrate(combo.combine()) }
return
@@ -175,7 +193,10 @@ class GamepadFeedback(private val handle: Long) {
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
lv.vibrate(
if (amplitudeControlled) oneShot(a, durationMs)
else oneShot(VibrationEffect.DEFAULT_AMPLITUDE, durationMs)
)
}
}
@@ -185,8 +206,10 @@ class GamepadFeedback(private val handle: Long) {
return if (v16 != 0 && a == 0) 1 else a
}
// Long one-shot held until the next packet (the host re-sends ~periodically); cancel on zero.
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp)
// One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
// self-terminates on a lost stop; cancel on zero.
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp)
// ---- HID output ----
+18 -6
View File
@@ -24,8 +24,12 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
/// Returns `(low << 16) | high` (each 0..=0xFFFF; `0` = stop), or `-1` on timeout / session closed.
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread.
/// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
/// `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = stop). The lease flag is
/// out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no in-band sentinel to
/// collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and Kotlin falls
/// back to its long one-shot. `-1` on timeout / session closed (all packed values are positive, so
/// `-1` stays unambiguous). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv,
@@ -37,12 +41,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
if handle == 0 {
return -1;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high),
match h.client.next_rumble_ttl(PULL_TIMEOUT) {
Ok((_pad, low, high, ttl)) => {
// The reorder gate already ran in the core, so this update is fresh. Encode the
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim.
let (lease_flag, ttl_bits) = match ttl {
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
None => (0, 0),
};
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
}
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
}
})
@@ -570,7 +570,8 @@ public final class PunktfunkConnection {
/// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
/// Drain from the (single) feedback thread, alongside `nextHidOutput`. Drops the v2
/// self-termination TTL use `nextRumble2` to honor the host lease.
public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
feedbackLock.lock()
defer { feedbackLock.unlock() }
@@ -590,6 +591,33 @@ public final class PunktfunkConnection {
}
}
/// Pull the next force-feedback update *including its self-termination TTL* (v2 envelopes):
/// `(pad, low, high, ttlMs)`. `ttlMs` is how long to render this level before silencing unless
/// the host renews it; `RumbleTuning.noTTL` (`UInt32.max`) means "no lease" a legacy host, so
/// fall back to a client-side staleness timeout. The reorder gate (seq) already ran in the
/// core, so a stale/reordered envelope never surfaces here. Drain from the (single) feedback
/// thread, alongside `nextHidOutput`.
public func nextRumble2(timeoutMs: UInt32 = 0) throws
-> (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
{
feedbackLock.lock()
defer { feedbackLock.unlock() }
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, ttl: UInt32 = .max
let rc = punktfunk_connection_next_rumble2(h, &pad, &low, &high, &ttl, timeoutMs)
switch rc {
case statusOK:
return (pad, low, high, ttl)
case statusNoFrame:
return nil
case statusClosed:
throw PunktfunkClientError.closed
default:
throw PunktfunkClientError.status(rc)
}
}
/// One DualSense feedback event a game wrote to the host's virtual pad replay it on
/// the real controller (GCDeviceLight, GCControllerPlayerIndex,
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these.
@@ -92,15 +92,15 @@ public final class GamepadFeedback {
// drain: levels rendered up to ~130 ms late through the core's 16-deep queue,
// and its drop-newest overflow could shed a stop while stale nonzero states
// queued ahead of it buzzing until the host's next 500 ms refresh.
var newest: (low: UInt16, high: UInt16)?
var newest: (low: UInt16, high: UInt16, ttl: UInt32)?
var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) }
let r = try connection.nextRumble2(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
rumbleBurst += 1
}
if let n = newest {
self?.rumble.apply(low: n.low, high: n.high)
self?.rumble.apply(low: n.low, high: n.high, ttlMs: n.ttl)
}
// Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing
// per-frame LED/trigger reports) can't spin here or block stop() past one cycle.
@@ -23,10 +23,23 @@ enum RumbleTuning {
/// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible.
/// Session watchdog: silence the motors when no wire command arrived for this long. This is
/// the **legacy-host fallback only** an old host sends no self-termination lease, so its
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
static let sessionStaleSeconds: TimeInterval = 1.6
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
static let noTTL: UInt32 = .max
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
/// ([`noTTL`]) the renderer falls back to [`sessionStaleSeconds`] else the self-termination
/// lease in seconds (render the level for at most this long unless the host renews it).
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
}
/// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0
@@ -139,6 +152,10 @@ final class RumbleRenderer: @unchecked Sendable {
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
private var target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease the
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
private var envelopeDeadline: DispatchTime?
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
/// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent,
/// so an idle controller costs no timer wakeups and no radio traffic.
@@ -212,13 +229,23 @@ final class RumbleRenderer: @unchecked Sendable {
}
}
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise
/// free (invariant 2).
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
/// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
/// against a legacy host (no lease the staleness watchdog is the backstop). Renewals at an
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
/// lapses mid-effect.
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
queue.async {
self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0
// v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears
// it. Set BEFORE the idempotence guard so an identical renewal still extends the lease.
if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active {
self.envelopeDeadline = .now() + lease
} else {
self.envelopeDeadline = nil
}
if active != self.wasActive {
self.wasActive = active
log.debug(
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
self.ticker?.cancel()
self.ticker = nil
self.target = (0, 0)
self.envelopeDeadline = nil
self.wasActive = false
self.teardown()
self.closeHID()
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
/// Watchdog + housekeeping heartbeat while audible.
private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// The host refreshes rumble state every 500 ms; this much silence means the channel
// (or host) died while a motor was on. A direct-connected pad would have been
if let deadline = envelopeDeadline {
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
// the whole point of the envelope model: the motor can't outlive the host's intent.
if target != (0, 0), DispatchTime.now() >= deadline {
log.warning("rumble: envelope expired unrenewed — silencing")
target = (0, 0)
envelopeDeadline = nil
}
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
// channel (or host) died while a motor was on. A direct-connected pad would have been
// stopped by its game long ago force the same outcome.
log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
@@ -88,16 +88,19 @@ final class LoopbackIntegrationTests: XCTestCase {
// one feedback burst on the hostclient planes drain both and verify, end to
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" {
var rumble: (pad: UInt16, low: UInt16, high: UInt16)?
var rumble: (pad: UInt16, low: UInt16, high: UInt16, ttlMs: UInt32)?
var hidout: [PunktfunkConnection.HidOutputEvent] = []
let feedbackDeadline = Date().addingTimeInterval(10)
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline {
if rumble == nil, let r = try conn.nextRumble(timeoutMs: 100) { rumble = r }
if rumble == nil, let r = try conn.nextRumble2(timeoutMs: 100) { rumble = r }
if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
}
XCTAssertEqual(rumble?.pad, 0)
XCTAssertEqual(rumble?.low, 0x4000)
XCTAssertEqual(rumble?.high, 0x8000)
// The synthetic host emits a v2 envelope (400 ms TTL) assert the self-terminating tail
// survived the full wire C ABI Swift path, not just the level.
XCTAssertEqual(rumble?.ttlMs, 400)
XCTAssertTrue(
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
"missing the scripted lightbar event: \(hidout)")
@@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase {
renderer.stop()
}
func testLeaseSecondsInterpretsWireTTL() {
// The legacy no-lease sentinel nil (fall back to the staleness watchdog).
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
// A real lease its duration in seconds.
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400), 0.4, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0), 0, accuracy: 1e-9)
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150), 0.15, accuracy: 1e-9)
}
func testEnvelopeLeaseBoundsMotorLifeTighterThanTheLegacyWatchdog() {
// The whole point of v2: a host-supplied lease silences the motor faster than the
// legacy staleness watchdog ever could (which needs sessionStaleSeconds of silence). The
// default 400 ms TTL is well under that, on every platform.
let defaultTTL = RumbleTuning.leaseSeconds(ttlMs: 400)
XCTAssertNotNil(defaultTTL)
XCTAssertLessThan(defaultTTL!, RumbleTuning.sessionStaleSeconds)
// The ticker must be able to observe an expired lease promptly (well within one TTL).
XCTAssertLessThan(RumbleTuning.tickSeconds, defaultTTL!)
}
/// A v2 envelope with a short TTL, left unrenewed, must self-silence the renderer's core
/// promise. Drive the real queue/ticker (no physical pad) and confirm it doesn't wedge.
func testEnvelopeExpiresWhenUnrenewed() {
let renderer = RumbleRenderer(policy: .session)
renderer.retarget(nil)
// A 100 ms lease, then no renewal the ticker (50 ms) must silence it on its own.
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
Thread.sleep(forTimeInterval: 0.3)
// No assertion on private state; this exercises the expiry path + serial-queue teardown
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
renderer.stop()
}
func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
// but trip well before a stuck rumble reads as "still going".
+41 -1
View File
@@ -1007,6 +1007,10 @@ async fn session(args: Args) -> Result<()> {
let audio_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let rumble_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let hidout_pkts = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
// Set when a self-terminating v2 rumble envelope (0xCA with the seq+ttl tail) arrives — the
// Rust-side contract check for `PUNKTFUNK_TEST_FEEDBACK` (asserted at report time). A legacy v1
// datagram leaves it false, so this only ever fails when a v2 tail we EXPECTED went missing.
let saw_v2_rumble = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
// Per-AU host timings (0xCF) → the stream loop, which matches them to received AUs by pts
// and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
let (host_timing_tx, host_timing_rx) =
@@ -1018,6 +1022,7 @@ async fn session(args: Args) -> Result<()> {
rumble_pkts.clone(),
hidout_pkts.clone(),
);
let saw_v2 = saw_v2_rumble.clone();
let ht_tx = host_timing_tx;
let conn2 = conn.clone();
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes
@@ -1026,6 +1031,7 @@ async fn session(args: Args) -> Result<()> {
tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed;
let mut hdr_logged = false;
let mut rumble_logged = false;
let layout = punktfunk_core::audio::layout_for(audio_channels, false);
let mut audio_dec =
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok();
@@ -1051,7 +1057,24 @@ async fn session(args: Args) -> Result<()> {
Err(e) => tracing::debug!(error = %e, "probe audio decode"),
}
}
} else if punktfunk_core::quic::decode_rumble_datagram(&d).is_some() {
} else if let Some(u) = punktfunk_core::quic::decode_rumble_envelope(&d) {
// Log the first rumble so a loopback test can see the self-terminating v2
// envelope tail (seq + TTL) arrived, not just the level.
if !rumble_logged {
rumble_logged = true;
tracing::info!(
pad = u.pad,
low = u.low,
high = u.high,
envelope = ?u.envelope,
"rumble (0xCA)"
);
}
// Record that a v2 tail was present — the Rust-side seq/ttl contract check for
// PUNKTFUNK_TEST_FEEDBACK (asserted at report time).
if u.envelope.is_some() {
saw_v2.store(true, Relaxed);
}
r.fetch_add(1, Relaxed);
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) {
// HDR static metadata (0xCE). Log the first receipt so a loopback test can
@@ -1292,6 +1315,23 @@ async fn session(args: Args) -> Result<()> {
}
}
// Rust-side rumble-envelope contract check: when the host was told to script a feedback burst
// (PUNKTFUNK_TEST_FEEDBACK, shared by a loopback harness), fail if no self-terminating v2 tail
// (seq + TTL) arrived — a regression that reverted the host to v1 level datagrams increments the
// rumble counter identically and would otherwise pass silently. Only tightens an otherwise-OK run
// (a video failure stays the primary error). The level + hidout planes are asserted end-to-end by
// the Apple loopback; this covers the seq/ttl tail on the Rust/Linux path.
let result = if std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
&& result.is_ok()
&& !saw_v2_rumble.load(std::sync::atomic::Ordering::Relaxed)
{
Err(anyhow::anyhow!(
"PUNKTFUNK_TEST_FEEDBACK: expected a v2 rumble envelope (0xCA seq+ttl tail), received none"
))
} else {
result
};
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
let close_code = if args.quit {
+9 -5
View File
@@ -607,18 +607,22 @@ fn run(
}
}
// Feedback planes (this thread is their single consumer). The host re-sends rumble state
// periodically, so a generous duration with refresh-on-update is safe — a dropped stop
// heals within ~500 ms.
// Feedback planes (this thread is their single consumer). Rumble arrives as
// self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
// lapse, so the SDL duration is the host's TTL — a lost stop (or a dead host) self-silences
// at the lease instead of droning. A legacy host (`ttl == None`) sends no lease → keep the
// proven 5 s duration and rely on its periodic re-send as before.
if let Some(connector) = w.attached.clone() {
while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
if pad == 0 {
// Floor the lease so a jittered renewal can't gap the actuator between writes.
let dur_ms = ttl.map_or(5_000, |ms| (ms as u32).max(240));
if let Some(p) = w.active_id().and_then(|id| w.opened.get_mut(&id)) {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in
// the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The
// host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render.
if let Err(e) = p.set_rumble(low, high, 5_000) {
if let Err(e) = p.set_rumble(low, high, dur_ms) {
tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else {
tracing::debug!(low, high, "rumble: rendered");