Rumble envelopes (0xCA v2) + 0.9.2 #3

Merged
enricobuehler merged 5 commits from rumble-envelopes into main 2026-07-11 01:35:01 +00:00
17 changed files with 734 additions and 91 deletions
Showing only changes of commit 73c911cae4 - Show all commits
@@ -35,6 +35,9 @@ class GamepadFeedback(private val handle: Long) {
const val TAG_LED: Byte = 0x01 const val TAG_LED: Byte = 0x01
const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_PLAYER_LEDS: Byte = 0x02
const val TAG_TRIGGER: Byte = 0x03 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 @Volatile private var running = false
@@ -66,7 +69,17 @@ class GamepadFeedback(private val handle: Long) {
while (running) { while (running) {
val ev = NativeBridge.nativeNextRumble(handle) val ev = NativeBridge.nativeNextRumble(handle)
if (ev < 0L) continue // timeout / closed 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() } }, "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) { * low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes).
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return * `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 lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
val m = vm val m = vm
@@ -157,12 +175,12 @@ class GamepadFeedback(private val handle: Long) {
val combo = CombinedVibration.startParallel() val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) { if (amplitudeControlled && vibratorIds.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention). // ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi)) if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi, durationMs))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo)) if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo, durationMs))
} else { } else {
// Single motor or no amplitude control: blend both into one effect. // Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) 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()) } runCatching { m.vibrate(combo.combine()) }
return return
@@ -175,7 +193,10 @@ class GamepadFeedback(private val handle: Long) {
} }
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255) val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching { 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 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. // One-shot held for `durationMs` — the host's v2 TTL (renewed while the level holds), so it
private fun oneShot(amp: Int): VibrationEffect = VibrationEffect.createOneShot(60_000L, amp) // self-terminates on a lost stop; cancel on zero.
private fun oneShot(amp: Int, durationMs: Long): VibrationEffect =
VibrationEffect.createOneShot(durationMs, amp)
// ---- HID output ---- // ---- HID output ----
+18 -6
View File
@@ -24,8 +24,12 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
const TAG_TRIGGER: u8 = 0x03; const TAG_TRIGGER: u8 = 0x03;
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. /// `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. /// Returns a packed positive long: bit 48 = "has a v2 lease", bits 32..47 = `ttl_ms`, bits 16..31 =
/// Pad index is dropped (single-pad model). Run from a dedicated Kotlin poll thread. /// `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] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
_env: JNIEnv, _env: JNIEnv,
@@ -37,12 +41,20 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
if handle == 0 { if handle == 0 {
return -1; return -1;
} }
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble is &self on the // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on
// Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll // the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
// threads (and joins them — unbounded) before nativeClose frees the handle. // threads (and joins them — unbounded) before nativeClose frees the handle.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble(PULL_TIMEOUT) { match h.client.next_rumble_ttl(PULL_TIMEOUT) {
Ok((_pad, low, high)) => (jlong::from(low) << 16) | jlong::from(high), 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 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: /// Pull the next force-feedback update for the GCController haptics engine:
/// `(pad, lowFrequency, highFrequency)` with 0...0xFFFF amplitudes, (0, 0) = stop. /// `(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)? { public func nextRumble(timeoutMs: UInt32 = 0) throws -> (pad: UInt16, low: UInt16, high: UInt16)? {
feedbackLock.lock() feedbackLock.lock()
defer { feedbackLock.unlock() } 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 /// One DualSense feedback event a game wrote to the host's virtual pad replay it on
/// the real controller (GCDeviceLight, GCControllerPlayerIndex, /// the real controller (GCDeviceLight, GCControllerPlayerIndex,
/// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these. /// 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, // 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 // 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. // 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 var rumbleBurst = 0
while rumbleBurst < 64, !flag.isStopped, while rumbleBurst < 64, !flag.isStopped,
let r = try connection.nextRumble(timeoutMs: 0) { let r = try connection.nextRumble2(timeoutMs: 0) {
if r.pad == 0 { newest = (r.low, r.high) } if r.pad == 0 { newest = (r.low, r.high, r.ttlMs) }
rumbleBurst += 1 rumbleBurst += 1
} }
if let n = newest { 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 // 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. // 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; /// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens;
/// zero is never throttled. /// zero is never throttled.
static let minRebakeSeconds: TimeInterval = 0.025 static let minRebakeSeconds: TimeInterval = 0.025
/// Session watchdog: silence the motors when no wire command arrived for this long. The /// Session watchdog: silence the motors when no wire command arrived for this long. This is
/// host re-sends the current rumble state every 500 ms as its loss heal, so this trips only /// the **legacy-host fallback only** an old host sends no self-termination lease, so its
/// after 3 consecutive refreshes vanished i.e. the channel or host died while audible. /// 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 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 /// Levels closer than this (0.4 % of full scale) are the same level an identical host
/// refresh must never rebuild a player. /// refresh must never rebuild a player.
static let levelEpsilon: Float = 1.0 / 256.0 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. /// 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 target: (low: UInt16, high: UInt16) = (0, 0)
private var lastCommand = DispatchTime(uptimeNanoseconds: 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, /// 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, /// 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. /// 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 /// Set the wire-truth target. Called with every 0xCA state the host sends level changes AND
/// AND the 500 ms refreshes; refreshes stamp liveness for the watchdog and are otherwise /// renewals (v2) / 500 ms refreshes (legacy); both stamp liveness and, for v2, refresh the
/// free (invariant 2). /// self-termination deadline. `ttlMs` is the envelope lease in ms, or [`RumbleTuning.noTTL`]
func apply(low lowAmp: UInt16, high highAmp: UInt16) { /// 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 { queue.async {
self.lastCommand = .now() self.lastCommand = .now()
let active = lowAmp != 0 || highAmp != 0 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 { if active != self.wasActive {
self.wasActive = active self.wasActive = active
log.debug( log.debug(
@@ -236,6 +263,7 @@ final class RumbleRenderer: @unchecked Sendable {
self.ticker?.cancel() self.ticker?.cancel()
self.ticker = nil self.ticker = nil
self.target = (0, 0) self.target = (0, 0)
self.envelopeDeadline = nil
self.wasActive = false self.wasActive = false
self.teardown() self.teardown()
self.closeHID() self.closeHID()
@@ -293,9 +321,18 @@ final class RumbleRenderer: @unchecked Sendable {
/// Watchdog + housekeeping heartbeat while audible. /// Watchdog + housekeeping heartbeat while audible.
private func tick() { private func tick() {
if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after { if let deadline = envelopeDeadline {
// The host refreshes rumble state every 500 ms; this much silence means the channel // v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
// (or host) died while a motor was on. A direct-connected pad would have been // 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. // stopped by its game long ago force the same outcome.
log.warning( log.warning(
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing") "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 // one feedback burst on the hostclient planes drain both and verify, end to
// end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD). // end through the xcframework: rumble (0xCA) + the three hidout kinds (0xCD).
if ProcessInfo.processInfo.environment["PUNKTFUNK_TEST_FEEDBACK"] == "1" { 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] = [] var hidout: [PunktfunkConnection.HidOutputEvent] = []
let feedbackDeadline = Date().addingTimeInterval(10) let feedbackDeadline = Date().addingTimeInterval(10)
while (rumble == nil || hidout.count < 3), Date() < feedbackDeadline { 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) } if let ev = try conn.nextHidOutput(timeoutMs: 100) { hidout.append(ev) }
} }
XCTAssertEqual(rumble?.pad, 0) XCTAssertEqual(rumble?.pad, 0)
XCTAssertEqual(rumble?.low, 0x4000) XCTAssertEqual(rumble?.low, 0x4000)
XCTAssertEqual(rumble?.high, 0x8000) 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( XCTAssertTrue(
hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)), hidout.contains(.led(pad: 0, r: 10, g: 20, b: 30)),
"missing the scripted lightbar event: \(hidout)") "missing the scripted lightbar event: \(hidout)")
@@ -75,6 +75,40 @@ final class RumbleTuningTests: XCTestCase {
renderer.stop() 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() { func testTuningRelationsTheDesignDependsOn() {
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps) // 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". // 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 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 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)); 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 // 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. // and reports the host/network split. try_send: overflow drops samples, never blocks QUIC.
let (host_timing_tx, host_timing_rx) = let (host_timing_tx, host_timing_rx) =
@@ -1018,6 +1022,7 @@ async fn session(args: Args) -> Result<()> {
rumble_pkts.clone(), rumble_pkts.clone(),
hidout_pkts.clone(), hidout_pkts.clone(),
); );
let saw_v2 = saw_v2_rumble.clone();
let ht_tx = host_timing_tx; let ht_tx = host_timing_tx;
let conn2 = conn.clone(); let conn2 = conn.clone();
// Build a multistream decoder for the host-RESOLVED layout so the probe actually decodes // 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 { tokio::spawn(async move {
use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::Ordering::Relaxed;
let mut hdr_logged = false; let mut hdr_logged = false;
let mut rumble_logged = false;
let layout = punktfunk_core::audio::layout_for(audio_channels, false); let layout = punktfunk_core::audio::layout_for(audio_channels, false);
let mut audio_dec = let mut audio_dec =
opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping).ok(); 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"), 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); r.fetch_add(1, Relaxed);
} else if let Some(meta) = punktfunk_core::quic::decode_hdr_meta_datagram(&d) { } 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 // 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 // `--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). // exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
let close_code = if args.quit { 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 // Feedback planes (this thread is their single consumer). Rumble arrives as
// periodically, so a generous duration with refresh-on-update is safe — a dropped stop // self-terminating v2 envelopes: the host renews an active level and lets an abandoned one
// heals within ~500 ms. // 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() { 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 { 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)) { 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 // 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 // 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 // host logs the send side on 0xCA, so the two together pinpoint host-game vs
// client-render. // 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"); tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed");
} else { } else {
tracing::debug!(low, high, "rumble: rendered"); tracing::debug!(low, high, "rumble: rendered");
+55 -20
View File
@@ -658,6 +658,14 @@ struct Worker {
/// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a
/// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]).
rumble_jitter: bool, rumble_jitter: bool,
/// The host lease from a v2 rumble envelope: last non-zero level expires at this instant
/// unless the host renews it. `None` outside a live rumble or against a legacy host (which
/// sends no lease — the pad then relies on SDL's own duration expiry as before).
rumble_deadline: Option<Instant>,
/// The host-supplied TTL (ms) of the current envelope, handed to SDL as the `set_rumble`
/// duration; `0` = legacy host (fall back to the proven 1.5 s duration). Read by
/// [`Worker::issue_rumble`].
rumble_ttl_ms: u16,
} }
impl Worker { impl Worker {
@@ -1246,9 +1254,18 @@ impl Worker {
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its /// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single /// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the /// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
/// actuator physically fed. The 1500 ms SDL duration is kept on every issue so SDL's logical /// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
/// expiration is continuously refreshed and a genuine sustained rumble never dies at 1.5 s. /// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
/// self-silences at the TTL); against a legacy host (`rumble_ttl_ms == 0`) it stays the proven
/// 1.5 s.
fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) { fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) {
let dur_ms: u32 = if self.rumble_ttl_ms == 0 {
1_500 // legacy host: no lease — keep the proven duration
} else {
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
// actuator between SDL writes.
(self.rumble_ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
};
let (out_low, out_high) = let (out_low, out_high) =
if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) { if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) {
self.rumble_jitter = !self.rumble_jitter; self.rumble_jitter = !self.rumble_jitter;
@@ -1259,7 +1276,7 @@ impl Worker {
match self match self
.open .open
.as_mut() .as_mut()
.map(|(_, p)| p.set_rumble(out_low, out_high, 1_500)) .map(|(_, p)| p.set_rumble(out_low, out_high, dur_ms))
{ {
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the // 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 // right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs
@@ -1274,46 +1291,62 @@ impl Worker {
/// Drain and render the feedback planes — rumble plus HID output (lightbar / /// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// player LEDs / adaptive triggers) — on the active pad; this thread is their single
/// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only /// consumer. Rumble arrives as self-terminating v2 envelopes: each carries a TTL the host
/// needs to outlive a couple of refresh periods: long enough that one or two lost /// renews while the level holds and lets expire when it stops, so the actuator's divergence
/// refreshes don't gap a genuine long rumble, short enough that a stale nonzero state /// from the host's intent is bounded by the wire, not by a client guess. A legacy host
/// (a stop lost host-side, a session torn down mid-buzz) dies on its own instead of /// (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration expiry as
/// droning for seconds. /// before.
fn render_feedback(&mut self) { fn render_feedback(&mut self) {
let Some(connector) = self.attached.clone() else { let Some(connector) = self.attached.clone() else {
return; return;
}; };
// The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble // The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble
// resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a // resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a
// steady host value (delivered only as identical 500 ms refreshes) is felt as a periodic // steady host value is felt as a periodic pulse rather than a continuous buzz. Detect the
// pulse rather than a continuous buzz. Detect the Deck pad here and keep it fed below the // Deck pad here and keep it fed below the decay (`DECK_RUMBLE_KEEPALIVE_MS`) — an actuator
// decay (`DECK_RUMBLE_KEEPALIVE_MS`); every other pad sustains at the hardware level. // limitation no wire lease can fix — but bound the re-kick by the host's TTL so it can no
// longer sustain a value the host has stopped renewing. Every other pad sustains (and
// expires) at the SDL/hardware level.
let deck = self let deck = self
.open .open
.as_ref() .as_ref()
.and_then(|(id, _)| self.pad_info(*id)) .and_then(|(id, _)| self.pad_info(*id))
.is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck)); .is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck));
let mut fresh = false; let mut fresh = false;
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 { if pad == 0 {
fresh = true; fresh = true;
self.rumble_ttl_ms = ttl.unwrap_or(0);
// A v2 lease sets an explicit client-side deadline; a legacy update clears it and
// leans on SDL's own duration expiry (unchanged behaviour).
self.rumble_deadline = match ttl {
Some(ms) if (low, high) != (0, 0) => {
Some(Instant::now() + Duration::from_millis(ms as u64))
}
_ => None,
};
self.issue_rumble(low, high, deck); self.issue_rumble(low, high, deck);
} }
} }
// Deck keep-alive: no fresh datagram this tick but a non-zero value is latched — re-kick // Deck keep-alive: no fresh datagram this tick but a non-zero value is latched. If the
// the actuator so its discrete haptic bursts fuse into a continuous buzz instead of a // host lease has expired, silence the actuator (the host stopped renewing — the stop
// ~2 s pulse. Zero is left alone (a real stop must stay stopped); non-Deck pads never // datagram was lost, or the host died); otherwise re-kick it so its discrete haptic bursts
// enter here (`deck` is false), so their behaviour is byte-for-byte unchanged. // fuse into a continuous buzz. A legacy update leaves `rumble_deadline` None, so the
if deck // re-kick behaves exactly as before (SDL's duration is the only backstop). Non-Deck pads
&& !fresh // never enter here (`deck` is false).
&& self.rumble_last != (0, 0) if deck && !fresh && self.rumble_last != (0, 0) {
&& self if self.rumble_deadline.is_some_and(|d| Instant::now() >= d) {
self.rumble_deadline = None;
self.rumble_ttl_ms = 0;
self.issue_rumble(0, 0, deck);
} else if self
.rumble_last_at .rumble_last_at
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS)) .is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
{ {
let (low, high) = self.rumble_last; let (low, high) = self.rumble_last;
self.issue_rumble(low, high, deck); self.issue_rumble(low, high, deck);
} }
}
while let Ok(hid) = connector.next_hidout(Duration::ZERO) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let is_ds = self let is_ds = self
.open .open
@@ -1382,6 +1415,8 @@ impl Worker {
rumble_last: (0, 0), rumble_last: (0, 0),
rumble_last_at: None, rumble_last_at: None,
rumble_jitter: false, rumble_jitter: false,
rumble_deadline: None,
rumble_ttl_ms: 0,
} }
} }
} }
+60
View File
@@ -1752,6 +1752,10 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop. /// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`]. /// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
/// ///
/// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this
/// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to
/// honor the host-supplied lease and delete the client-side timeout heuristics.
///
/// # Safety /// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At /// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers. /// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
@@ -1792,6 +1796,62 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble(
}) })
} }
/// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
/// datagram — an old host that sent no self-termination lease. The client then falls back to its
/// own staleness heuristic for that update instead of a host-supplied deadline.
pub const PUNKTFUNK_RUMBLE_NO_TTL: u32 = 0xFFFF_FFFF;
/// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to
/// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus
/// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews
/// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side
/// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a
/// stale/reordered envelope never reaches the caller.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
/// thread pulls rumble — it may run concurrently with the video/audio pullers.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
c: *mut PunktfunkConnection,
pad: *mut u16,
low: *mut u16,
high: *mut u16,
ttl_ms: *mut u32,
timeout_ms: u32,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
match c
.inner
.next_rumble_ttl(std::time::Duration::from_millis(timeout_ms as u64))
{
Ok((p, l, h, ttl)) => {
unsafe {
if !pad.is_null() {
*pad = p;
}
if !low.is_null() {
*low = l;
}
if !high.is_null() {
*high = h;
}
if !ttl_ms.is_null() {
*ttl_ms = ttl.map_or(PUNKTFUNK_RUMBLE_NO_TTL, u32::from);
}
}
PunktfunkStatus::Ok
}
Err(e) => e.status(),
}
})
}
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger) /// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
/// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on /// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on
/// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend /// timeout, [`PunktfunkStatus::Closed`] once the session ended. Only the DualSense host backend
+54 -8
View File
@@ -322,10 +322,17 @@ impl FrameChannel {
const AUDIO_QUEUE: usize = 64; const AUDIO_QUEUE: usize = 64;
/// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same /// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same
/// `try_send` discipline as the other planes) — the host re-sends rumble state /// `try_send` discipline as the other planes) — the host renews rumble state periodically
/// periodically, so a dropped transition (including a stop) heals within ~500 ms. /// (v2 envelopes) or re-sends it (legacy v1), so a dropped transition (including a stop) heals
/// within one renewal/refresh period.
const RUMBLE_QUEUE: usize = 16; const RUMBLE_QUEUE: usize = 16;
/// A rumble update handed to the embedder: `(pad, low, high, ttl_ms)`. `ttl_ms` is `Some(ms)` for
/// a self-terminating v2 envelope (render for at most that long) and `None` for a legacy v1
/// datagram (an old host — the renderer applies its own staleness policy). The seq from a v2
/// envelope is consumed by the reorder gate in the datagram demux and is NOT forwarded.
type RumbleUpdate = (u16, u16, u16, Option<u16>);
/// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder. /// HID-output (DualSense lightbar / player LEDs / adaptive triggers) buffered for the embedder.
/// Same overflow discipline as rumble; the host re-sends on the next feedback change. /// Same overflow discipline as rumble; the host re-sends on the next feedback change.
const HIDOUT_QUEUE: usize = 32; const HIDOUT_QUEUE: usize = 32;
@@ -355,7 +362,7 @@ pub struct NativeClient {
// and two threads racing one plane now serialize instead of being undefined). // and two threads racing one plane now serialize instead of being undefined).
frames: Arc<FrameChannel>, frames: Arc<FrameChannel>,
audio: Mutex<Receiver<AudioPacket>>, audio: Mutex<Receiver<AudioPacket>>,
rumble: Mutex<Receiver<(u16, u16, u16)>>, rumble: Mutex<Receiver<RumbleUpdate>>,
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
hidout: Mutex<Receiver<HidOutput>>, hidout: Mutex<Receiver<HidOutput>>,
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams. /// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
@@ -564,7 +571,7 @@ impl NativeClient {
) -> Result<NativeClient> { ) -> Result<NativeClient> {
let frame_chan = Arc::new(FrameChannel::new()); let frame_chan = Arc::new(FrameChannel::new());
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE); let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<RumbleUpdate>(RUMBLE_QUEUE);
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE); let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE); let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
let (host_timing_tx, host_timing_rx) = let (host_timing_tx, host_timing_rx) =
@@ -1024,8 +1031,20 @@ impl NativeClient {
} }
/// Pull the next rumble update `(pad, low, high)`; same semantics as /// Pull the next rumble update `(pad, low, high)`; same semantics as
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. /// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop. The self-terminating
/// TTL of a v2 envelope is dropped here — use [`NativeClient::next_rumble_ttl`] to honor it (a
/// renderer that only sees `(pad, low, high)` keeps its own staleness policy exactly as before,
/// which is what makes this back-compatible for un-updated embedders).
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> { pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
self.next_rumble_ttl(timeout).map(|(p, l, h, _)| (p, l, h))
}
/// Pull the next rumble update including its self-termination TTL: `(pad, low, high, ttl_ms)`.
/// `ttl_ms` is `Some(ms)` for a v2 envelope — render the level for at most that long, then
/// silence — and `None` for a legacy v1 datagram (an old host with no lease; fall back to the
/// renderer's own staleness heuristic). The reorder gate (seq) is applied in the datagram demux
/// before the update reaches this queue, so a stale/reordered envelope never surfaces here.
pub fn next_rumble_ttl(&self, timeout: Duration) -> Result<RumbleUpdate> {
match self.rumble.lock().unwrap().recv_timeout(timeout) { match self.rumble.lock().unwrap().recv_timeout(timeout) {
Ok(r) => Ok(r), Ok(r) => Ok(r),
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame), Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
@@ -1168,7 +1187,7 @@ struct WorkerArgs {
identity: Option<(String, String)>, identity: Option<(String, String)>,
frames: Arc<FrameChannel>, frames: Arc<FrameChannel>,
audio_tx: SyncSender<AudioPacket>, audio_tx: SyncSender<AudioPacket>,
rumble_tx: SyncSender<(u16, u16, u16)>, rumble_tx: SyncSender<RumbleUpdate>,
hidout_tx: SyncSender<HidOutput>, hidout_tx: SyncSender<HidOutput>,
hdr_meta_tx: SyncSender<HdrMeta>, hdr_meta_tx: SyncSender<HdrMeta>,
host_timing_tx: SyncSender<crate::quic::HostTiming>, host_timing_tx: SyncSender<crate::quic::HostTiming>,
@@ -1593,6 +1612,10 @@ async fn worker_main(args: WorkerArgs) {
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the // Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
// newest packet rather than backing up the QUIC receive path). // newest packet rather than backing up the QUIC receive path).
let dgram_conn = conn.clone(); let dgram_conn = conn.clone();
// Per-pad reorder gate for v2 rumble envelopes (the seq analog of the host's gamepad-state
// gate): a datagram the network reordered must not roll a stopped motor back on. Legacy v1
// datagrams carry no seq and bypass it (an old host's own periodic re-send is the only heal).
let mut rumble_last_seq: [Option<u8>; crate::input::MAX_PADS] = [None; crate::input::MAX_PADS];
tokio::spawn(async move { tokio::spawn(async move {
while let Ok(d) = dgram_conn.read_datagram().await { while let Ok(d) = dgram_conn.read_datagram().await {
match d.first() { match d.first() {
@@ -1606,8 +1629,31 @@ async fn worker_main(args: WorkerArgs) {
} }
} }
Some(&crate::quic::RUMBLE_MAGIC) => { Some(&crate::quic::RUMBLE_MAGIC) => {
if let Some(r) = crate::quic::decode_rumble_datagram(&d) { if let Some(u) = crate::quic::decode_rumble_envelope(&d) {
let _ = rumble_tx.try_send(r); // Gate v2 envelopes on their per-pad seq; forward v1 (envelope: None) as-is.
let fresh = match u.envelope {
Some(env) => {
let idx = u.pad as usize;
if idx < crate::input::MAX_PADS {
if crate::input::GamepadSnapshot::seq_newer(
env.seq,
rumble_last_seq[idx],
) {
rumble_last_seq[idx] = Some(env.seq);
true
} else {
false // reordered/duplicate — drop, keep the newer state
}
} else {
true // out-of-range pad (host never sends these): no gate
}
}
None => true,
};
if fresh {
let ttl = u.envelope.map(|e| e.ttl_ms);
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
}
} }
} }
Some(&crate::quic::HIDOUT_MAGIC) => { Some(&crate::quic::HIDOUT_MAGIC) => {
+5 -1
View File
@@ -57,7 +57,11 @@ pub use stats::Stats;
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). /// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
/// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — /// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
/// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). /// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
pub const ABI_VERSION: u32 = 4; /// v5: added `punktfunk_connection_next_rumble2` (rumble pull that also yields the self-terminating
/// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
/// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
/// [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 5;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. /// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** /// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+79 -4
View File
@@ -42,8 +42,12 @@ pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
Some((seq, pts_ns, &b[13..])) Some((seq, pts_ns, &b[13..]))
} }
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`. /// Legacy rumble datagram (v1), host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop). /// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop) as *level-triggered* state
/// — it persists until superseded, which is why the host re-sends it periodically as its loss
/// heal. New hosts emit the self-terminating [`encode_rumble_datagram_v2`] instead; this is kept
/// for the loopback tests and as the wire an old host still speaks (a new client decodes both via
/// [`decode_rumble_envelope`]).
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] { pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
let mut b = [0u8; 7]; let mut b = [0u8; 7];
b[0] = RUMBLE_MAGIC; b[0] = RUMBLE_MAGIC;
@@ -53,15 +57,86 @@ pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
b b
} }
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length. /// Wire length of a v1 (legacy, level) rumble datagram.
pub const RUMBLE_V1_LEN: usize = 7;
/// Wire length of a v2 (envelope) rumble datagram — the v1 body plus a `[u8 seq][u16 ttl_ms LE]`
/// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
/// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
/// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
pub const RUMBLE_V2_LEN: usize = 10;
/// Rumble envelope datagram (v2), host → client:
/// `[0xCA][u16 pad LE][u16 low LE][u16 high LE][u8 seq][u16 ttl_ms LE]`.
///
/// A *self-terminating* force-feedback command: the level is authorized for at most `ttl_ms`, so
/// a rumble the host stops renewing (or a host that dies) silences on its own — "stuck forever"
/// is inexpressible on the wire. `seq` is a per-pad wrapping counter (bumped on every send,
/// changes *and* renewals) compared with [`GamepadSnapshot::seq_newer`](crate::input::GamepadSnapshot::seq_newer)
/// so a reordered stale start can't re-light the motors after a stop. Renewals fully replace the
/// prior envelope's deadline; they never stack. An explicit stop is still `low == high == 0` sent
/// immediately (expiry is the safety net, never the stop mechanism).
pub fn encode_rumble_datagram_v2(pad: u16, low: u16, high: u16, seq: u8, ttl_ms: u16) -> [u8; 10] {
let mut b = [0u8; RUMBLE_V2_LEN];
b[0] = RUMBLE_MAGIC;
b[1..3].copy_from_slice(&pad.to_le_bytes());
b[3..5].copy_from_slice(&low.to_le_bytes());
b[5..7].copy_from_slice(&high.to_le_bytes());
b[7] = seq;
b[8..10].copy_from_slice(&ttl_ms.to_le_bytes());
b
}
/// The self-termination tail of a v2 rumble envelope (see [`encode_rumble_datagram_v2`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleEnvelope {
/// Per-pad wrapping send counter — the reorder gate (see [`decode_rumble_envelope`]).
pub seq: u8,
/// How long, in ms, this envelope authorizes the stated level before the client must silence.
pub ttl_ms: u16,
}
/// A decoded rumble update. `envelope` is `None` for a legacy 7-byte datagram (an old host, which
/// has no seq/ttl — the client applies its own staleness policy), `Some` for a v2 envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RumbleUpdate {
pub pad: u16,
pub low: u16,
pub high: u16,
pub envelope: Option<RumbleEnvelope>,
}
/// Parse a rumble datagram → `(pad, low, high)`, tolerating (and ignoring) a v2 envelope tail.
/// `None` on bad tag/length. Kept for callers that only need the level (the probe, the loopback
/// assertions); clients that honor TTL use [`decode_rumble_envelope`].
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> { pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
if b.len() < 7 || b[0] != RUMBLE_MAGIC { if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None; return None;
} }
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
Some((u16at(1), u16at(3), u16at(5))) Some((u16at(1), u16at(3), u16at(5)))
} }
/// Parse a rumble datagram → [`RumbleUpdate`], detecting the v2 envelope tail by length. A
/// `>= RUMBLE_V2_LEN` buffer carries `seq`/`ttl_ms`; a 7..RUMBLE_V2_LEN buffer is a legacy level
/// (`envelope: None`) — the same tolerance as an old client would apply, so a torn/short tail
/// degrades to a level rather than dropping. `None` on bad tag/length.
pub fn decode_rumble_envelope(b: &[u8]) -> Option<RumbleUpdate> {
if b.len() < RUMBLE_V1_LEN || b[0] != RUMBLE_MAGIC {
return None;
}
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let envelope = (b.len() >= RUMBLE_V2_LEN).then(|| RumbleEnvelope {
seq: b[7],
ttl_ms: u16::from_le_bytes([b[8], b[9]]),
});
Some(RumbleUpdate {
pad: u16at(1),
low: u16at(3),
high: u16at(5),
envelope,
})
}
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same /// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram. /// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> { pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
+73
View File
@@ -949,6 +949,79 @@ fn rumble_datagram_roundtrip() {
assert!(decode_rumble_datagram(&d[..6]).is_none()); assert!(decode_rumble_datagram(&d[..6]).is_none());
} }
#[test]
fn rumble_envelope_roundtrip_and_legacy_tolerance() {
// v2 envelope round-trips seq + ttl.
let d = encode_rumble_datagram_v2(2, 0x4000, 0x8000, 7, 400);
assert_eq!(d[0], RUMBLE_MAGIC);
assert_eq!(d.len(), RUMBLE_V2_LEN);
assert_eq!(
decode_rumble_envelope(&d),
Some(RumbleUpdate {
pad: 2,
low: 0x4000,
high: 0x8000,
envelope: Some(RumbleEnvelope {
seq: 7,
ttl_ms: 400
}),
})
);
// The legacy level decoder reads a v2 datagram as a plain level — the tail is ignored, so an
// old client running against a new host still renders the right amplitudes.
assert_eq!(decode_rumble_datagram(&d), Some((2, 0x4000, 0x8000)));
// A legacy 7-byte datagram (old host) decodes as a level with no envelope — a new client then
// applies its own staleness policy.
let v1 = encode_rumble_datagram(3, 0x1111, 0x2222);
assert_eq!(
decode_rumble_envelope(&v1),
Some(RumbleUpdate {
pad: 3,
low: 0x1111,
high: 0x2222,
envelope: None,
})
);
// A torn/short tail (8 or 9 bytes) is not a valid envelope — degrade to a level, never panic
// or drop. (The host never emits these; a truncating middlebox might.)
assert_eq!(
decode_rumble_envelope(&d[..8]).map(|u| u.envelope),
Some(None)
);
assert_eq!(
decode_rumble_envelope(&d[..9]).map(|u| u.envelope),
Some(None)
);
// Bad tag / too short → None on both decoders.
assert!(decode_rumble_envelope(&d[..6]).is_none());
let mut wrong_tag = d;
wrong_tag[0] = AUDIO_MAGIC;
assert!(decode_rumble_envelope(&wrong_tag).is_none());
}
#[test]
fn rumble_envelope_seq_gate_drops_reordered_stale_start() {
use crate::input::GamepadSnapshot;
// The client-side reorder gate (reused verbatim from gamepad snapshots): a stale start
// arriving after a stop must not re-light the motors.
let stop = decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0, 0, 10, 0)).unwrap();
let stale_start =
decode_rumble_envelope(&encode_rumble_datagram_v2(0, 0x8000, 0x8000, 9, 400)).unwrap();
let stop_seq = stop.envelope.unwrap().seq;
let stale_seq = stale_start.envelope.unwrap().seq;
// Nothing applied yet → the first update always passes.
assert!(GamepadSnapshot::seq_newer(stop_seq, None));
// The reordered older start does NOT supersede the stop.
assert!(!GamepadSnapshot::seq_newer(stale_seq, Some(stop_seq)));
// A genuine later renewal does.
assert!(GamepadSnapshot::seq_newer(11, Some(stop_seq)));
// Wraps: seq 1 supersedes 254.
assert!(GamepadSnapshot::seq_newer(1, Some(254)));
}
#[test] #[test]
fn mic_datagram_roundtrip_and_disjoint_from_audio() { fn mic_datagram_roundtrip_and_disjoint_from_audio() {
let opus = [0x5Au8; 80]; let opus = [0x5Au8; 80];
+135 -11
View File
@@ -1388,7 +1388,9 @@ async fn serve_session(
&& std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1") && std::env::var("PUNKTFUNK_TEST_FEEDBACK").as_deref() == Ok("1")
{ {
use punktfunk_core::quic::HidOutput; use punktfunk_core::quic::HidOutput;
let d = punktfunk_core::quic::encode_rumble_datagram(0, 0x4000, 0x8000); // v2 envelope (seq 0, 400 ms TTL) so the loopback/probe assertion covers the self-
// terminating tail, not just the level.
let d = punktfunk_core::quic::encode_rumble_datagram_v2(0, 0x4000, 0x8000, 0, 400);
let _ = conn.send_datagram(d.to_vec().into()); let _ = conn.send_datagram(d.to_vec().into());
for h in [ for h in [
HidOutput::Led { HidOutput::Led {
@@ -1826,6 +1828,49 @@ enum ClientInput {
Rich(punktfunk_core::quic::RichInput), Rich(punktfunk_core::quic::RichInput),
} }
/// Default TTL stamped on a non-zero rumble envelope (0xCA v2): how long the client renders the
/// level before silencing unless the host renews it. Tolerates 23 lost renewals (same loss
/// margin the old flat 500 ms refresh gave) while capping a host-abandoned rumble at this on every
/// client — versus the per-platform client heuristics it replaces (SDL 1.5 s, Apple 1.6 s, Android
/// up to the QUIC idle-timeout). Overridable via `PUNKTFUNK_RUMBLE_TTL_MS` (floored at
/// [`RUMBLE_TTL_FLOOR_MS`] so expiry jitter stays below the clients' tick granularity).
const RUMBLE_TTL_MS: u16 = 400;
/// Floor for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch — below this the ~50 ms client ticks make expiry
/// audible (see `rumble-envelope-plan.md` §5).
const RUMBLE_TTL_FLOOR_MS: u16 = 150;
/// Ceiling for the `PUNKTFUNK_RUMBLE_TTL_MS` hatch. A lease longer than a few seconds defeats the
/// design's "an abandoned rumble stops promptly" goal, and keeping it well under `u16::MAX` means
/// the wire never emits a TTL a narrower client-side slot could mistake for a sentinel.
const RUMBLE_TTL_CEIL_MS: u16 = 5_000;
/// Floor for the derived renewal interval (renew = ttl × 3/10) so an aggressive TTL hatch can't
/// spin the renewal loop faster than this.
const RUMBLE_RENEW_FLOOR_MS: u64 = 60;
/// How many times a transition-to-zero (a stop) is re-sent on the renewal ticks after the
/// immediate stop datagram, before the pad goes quiet. Covers stop-datagram loss for legacy
/// clients (a v2 client also self-silences at TTL); even a fully lost burst heals via the client's
/// own expiry. `3` total zero sends = the immediate one + this many renewal re-sends.
const RUMBLE_STOP_BURST: u8 = 2;
/// Send one rumble datagram on the universal 0xCA plane. `envelope_on` picks the self-terminating
/// v2 form (`[level][seq][ttl_ms]`, the default) or the legacy v1 level datagram (the
/// `PUNKTFUNK_RUMBLE_ENVELOPE=0` bisect hatch). Best-effort like every side-plane datagram.
fn send_rumble(
conn: &quinn::Connection,
envelope_on: bool,
pad: u16,
low: u16,
high: u16,
seq: u8,
ttl_ms: u16,
) {
let d: Vec<u8> = if envelope_on {
punktfunk_core::quic::encode_rumble_datagram_v2(pad, low, high, seq, ttl_ms).to_vec()
} else {
punktfunk_core::quic::encode_rumble_datagram(pad, low, high).to_vec()
};
let _ = conn.send_datagram(d.into());
}
/// The per-session input thread: route pointer/keyboard events to the host-lifetime injector /// The per-session input thread: route pointer/keyboard events to the host-lifetime injector
/// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the /// service (`inj_tx`) and gamepad events to this session's [`PadBackend`] (`gamepad` — the
/// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich /// resolved Hello preference: uinput X-Box pads or virtual DualSense pads), with rich
@@ -1834,6 +1879,12 @@ enum ClientInput {
/// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with /// LED/trigger feedback on the HID-output plane. The gamepads are created and torn down with
/// the session; the pointer/keyboard injector (and its portal grant) lives in the service, /// the session; the pointer/keyboard injector (and its portal grant) lives in the service,
/// across sessions. /// across sessions.
///
/// Rumble is emitted as self-terminating 0xCA v2 envelopes (`[level][seq][ttl_ms]`): the host owns
/// the timeline, renewing an active level every ~`RUMBLE_TTL_MS × 3/10` ms and letting an
/// abandoned one expire client-side, so "stuck rumble" is inexpressible on the wire (see
/// `punktfunk-planning/design/rumble-envelope-plan.md`). `PUNKTFUNK_RUMBLE_ENVELOPE=0` reverts to
/// legacy v1 level datagrams + the flat 500 ms refresh (bisect hatch).
fn input_thread( fn input_thread(
rx: std::sync::mpsc::Receiver<ClientInput>, rx: std::sync::mpsc::Receiver<ClientInput>,
conn: quinn::Connection, conn: quinn::Connection,
@@ -1852,12 +1903,31 @@ fn input_thread(
// Last applied snapshot seq per pad (`None` until the first one): the reorder gate for // Last applied snapshot seq per pad (`None` until the first one): the reorder gate for
// `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back. // `InputKind::GamepadState` — a late datagram with an older seq must not roll held state back.
let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS]; let mut pad_seq: [Option<u8>; MAX_WIRE_PADS] = [None; MAX_WIRE_PADS];
// Rumble is idempotent state on a lossy channel (client-side overflow drops datagrams), // Rumble self-terminating envelopes (0xCA v2). Each non-zero level is authorized for
// so re-send the current state of every rumbling-capable pad every 500 ms — a dropped // `rumble_ttl_ms`; the host renews an active pad every `rumble_renew` and lets an abandoned
// transition (including a stop) heals on the next refresh. // one expire on the client, so a dropped transition heals on the next renewal and a stop that
// is lost heals via the stop burst (or the client's own TTL expiry). `rumble_seq` is the
// per-pad wrapping reorder counter (bumped on changes AND renewals) the client gates on;
// `rumble_stop_burst` counts the post-stop zero re-sends still owed. `PUNKTFUNK_RUMBLE_ENVELOPE=0`
// reverts to legacy v1 datagrams re-sent flat every 500 ms.
let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS]; let mut rumble_state = [(0u16, 0u16); MAX_WIRE_PADS];
let mut rumble_seen = [false; MAX_WIRE_PADS]; let mut rumble_seen = [false; MAX_WIRE_PADS];
let mut rumble_seq = [0u8; MAX_WIRE_PADS];
let mut rumble_stop_burst = [0u8; MAX_WIRE_PADS];
let mut last_refresh = std::time::Instant::now(); let mut last_refresh = std::time::Instant::now();
let rumble_envelope_on = std::env::var("PUNKTFUNK_RUMBLE_ENVELOPE").as_deref() != Ok("0");
let rumble_ttl_ms: u16 = std::env::var("PUNKTFUNK_RUMBLE_TTL_MS")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.map(|v| v.clamp(RUMBLE_TTL_FLOOR_MS, RUMBLE_TTL_CEIL_MS))
.unwrap_or(RUMBLE_TTL_MS);
// Renew at 30 % of the TTL (≈120 ms for the 400 ms default) so 23 renewals cover the lease;
// in legacy mode the periodic block instead runs the old flat 500 ms full-state refresh.
let rumble_refresh_interval = if rumble_envelope_on {
std::time::Duration::from_millis((rumble_ttl_ms as u64 * 3 / 10).max(RUMBLE_RENEW_FLOOR_MS))
} else {
std::time::Duration::from_millis(500)
};
// Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a // Pointer buttons / keys the client currently holds down. The injector is host-lifetime, so a
// press left dangling by an abrupt client disconnect stays latched in the compositor across the // press left dangling by an abrupt client disconnect stays latched in the compositor across the
// reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck // reconnect (Mutter keeps the implicit pointer grab of the still-pressed button — a stuck
@@ -1976,17 +2046,43 @@ fn input_thread(
// plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD. // plane; DualSense rich feedback (lightbar / player LEDs / adaptive triggers) → 0xCD.
pads.pump( pads.pump(
|pad, low, high| { |pad, low, high| {
if let Some(s) = rumble_state.get_mut(pad as usize) { let idx = pad as usize;
if idx < MAX_WIRE_PADS {
let prev = rumble_state[idx];
// Log the silent→active transition (once per buzz) so a live test can tell // Log the silent→active transition (once per buzz) so a live test can tell
// "host never gets rumble from the game" apart from "client doesn't render it". // "host never gets rumble from the game" apart from "client doesn't render it".
if *s == (0, 0) && (low != 0 || high != 0) { if prev == (0, 0) && (low != 0 || high != 0) {
tracing::info!(pad, low, high, "rumble: forwarding to client (0xCA)"); tracing::info!(pad, low, high, "rumble: forwarding to client (0xCA)");
} }
*s = (low, high); rumble_state[idx] = (low, high);
rumble_seen[pad as usize] = true; rumble_seen[idx] = true;
// Bump the reorder counter on every change, then arm the stop burst on a
// transition to zero (so a lost stop still reaches a legacy client) and clear
// it when the game re-asserts a non-zero level.
rumble_seq[idx] = rumble_seq[idx].wrapping_add(1);
if (low, high) == (0, 0) {
rumble_stop_burst[idx] = if prev != (0, 0) { RUMBLE_STOP_BURST } else { 0 };
} else {
rumble_stop_burst[idx] = 0;
}
let ttl = if (low, high) == (0, 0) {
0
} else {
rumble_ttl_ms
};
send_rumble(
&conn,
rumble_envelope_on,
pad,
low,
high,
rumble_seq[idx],
ttl,
);
} else {
// Out-of-range pad (a backend never produces these) — forward without gating.
send_rumble(&conn, rumble_envelope_on, pad, low, high, 0, rumble_ttl_ms);
} }
let d = punktfunk_core::quic::encode_rumble_datagram(pad, low, high);
let _ = conn.send_datagram(d.to_vec().into());
}, },
|h| { |h| {
let _ = conn.send_datagram(h.encode().into()); let _ = conn.send_datagram(h.encode().into());
@@ -1996,8 +2092,35 @@ fn input_thread(
// held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop // held-steady pad sends no wire events, so without a periodic re-emit the kernel/SDL drop
// it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick. // it as unplugged. The 8 ms gap inside heartbeat() governs the rate, not this ≤4 ms tick.
pads.heartbeat(); pads.heartbeat();
if last_refresh.elapsed() >= std::time::Duration::from_millis(500) { if last_refresh.elapsed() >= rumble_refresh_interval {
last_refresh = std::time::Instant::now(); last_refresh = std::time::Instant::now();
if rumble_envelope_on {
// Renewal: refresh an active pad's lease (bump seq, fresh TTL), and drain each
// pad's post-stop zero burst, then let it go quiet — no perpetual zero refreshes.
for i in 0..MAX_WIRE_PADS {
if !rumble_seen[i] {
continue;
}
let (low, high) = rumble_state[i];
if (low, high) != (0, 0) {
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(
&conn,
true,
i as u16,
low,
high,
rumble_seq[i],
rumble_ttl_ms,
);
} else if rumble_stop_burst[i] > 0 {
rumble_stop_burst[i] -= 1;
rumble_seq[i] = rumble_seq[i].wrapping_add(1);
send_rumble(&conn, true, i as u16, 0, 0, rumble_seq[i], 0);
}
}
} else {
// Legacy: re-send the current level of every seen pad every 500 ms (v1).
for (i, &(low, high)) in rumble_state.iter().enumerate() { for (i, &(low, high)) in rumble_state.iter().enumerate() {
if rumble_seen[i] { if rumble_seen[i] {
let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high); let d = punktfunk_core::quic::encode_rumble_datagram(i as u16, low, high);
@@ -2006,6 +2129,7 @@ fn input_thread(
} }
} }
} }
}
// Session ended (client gone). Release anything still held through the host-lifetime injector — // Session ended (client gone). Release anything still held through the host-lifetime injector —
// its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this // its EIS connection (and any implicit grab Mutter holds for our pressed button) outlives this
// session, so without this a button pressed at disconnect stays latched and breaks clicks for // session, so without this a button pressed at disconnect stays latched and breaks clicks for
+46 -1
View File
@@ -21,7 +21,11 @@
// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). // clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
// v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake — // v4: added `punktfunk_probe` (bounded, trust-agnostic, mDNS-independent reachability handshake —
// the display-side companion to dial-first, so saved-host "online" pips reflect real reachability). // the display-side companion to dial-first, so saved-host "online" pips reflect real reachability).
#define ABI_VERSION 4 // v5: added `punktfunk_connection_next_rumble2` (rumble pull that also yields the self-terminating
// TTL of a v2 envelope; `punktfunk_connection_next_rumble` is unchanged and drops it). Additive —
// the wire is backward-compatible (the envelope is a length-tolerant tail on 0xCA), so
// [`WIRE_VERSION`] is unchanged.
#define ABI_VERSION 5
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check. // The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface** // Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -155,6 +159,11 @@
// Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.) // Codec bit: AV1. (Mirrors `quic::CODEC_AV1`.)
#define PUNKTFUNK_CODEC_AV1 4 #define PUNKTFUNK_CODEC_AV1 4
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
// datagram — an old host that sent no self-termination lease. The client then falls back to its
// own staleness heuristic for that update instead of a host-supplied deadline.
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
// 16-byte AEAD authentication tag appended by GCM. // 16-byte AEAD authentication tag appended by GCM.
#define TAG_LEN 16 #define TAG_LEN 16
@@ -289,6 +298,19 @@
#define HIDOUT_MAGIC 205 #define HIDOUT_MAGIC 205
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v1 (legacy, level) rumble datagram.
#define RUMBLE_V1_LEN 7
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Wire length of a v2 (envelope) rumble datagram — the v1 body plus a `[u8 seq][u16 ttl_ms LE]`
// tail. Decoders are length-tolerant (see [`decode_rumble_envelope`]): an old client reads the
// first 7 bytes as a plain level and ignores the tail, so no wire-version bump is needed — the
// same dual-size idiom the HDR-luminance `AddRequest` tail uses.
#define RUMBLE_V2_LEN 10
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI; // HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. // see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
@@ -1291,6 +1313,10 @@ PunktfunkStatus punktfunk_connection_next_audio_pcm(PunktfunkConnection *c,
// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop. // are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
// Same timeout/closed semantics as [`punktfunk_connection_next_audio`]. // Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
// //
// This drops the self-terminating TTL of a v2 rumble envelope — an embedder that only calls this
// keeps its own staleness policy, exactly as before. Use [`punktfunk_connection_next_rumble2`] to
// honor the host-supplied lease and delete the client-side timeout heuristics.
//
// # Safety // # Safety
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At // `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
// most one thread pulls rumble — it may run concurrently with the video/audio pullers. // most one thread pulls rumble — it may run concurrently with the video/audio pullers.
@@ -1301,6 +1327,25 @@ PunktfunkStatus punktfunk_connection_next_rumble(PunktfunkConnection *c,
uint32_t timeout_ms); uint32_t timeout_ms);
#endif #endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next rumble update *including its self-termination TTL* (v2 envelopes), waiting up to
// `timeout_ms`. Same `pad`/`low`/`high` semantics as [`punktfunk_connection_next_rumble`], plus
// `*ttl_ms`: how long (milliseconds) to render this level before silencing unless the host renews
// it. [`PUNKTFUNK_RUMBLE_NO_TTL`] means "no lease" — a legacy host; fall back to a client-side
// timeout. The reorder gate (seq) is applied inside the core before the update surfaces here, so a
// stale/reordered envelope never reaches the caller.
//
// # Safety
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At most one
// thread pulls rumble — it may run concurrently with the video/audio pullers.
PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c,
uint16_t *pad,
uint16_t *low,
uint16_t *high,
uint32_t *ttl_ms,
uint32_t timeout_ms);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger) // Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive trigger)
// the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on // the host's virtual pad received from a game, into `*out`. [`PunktfunkStatus::NoFrame`] on