Rumble envelopes (0xCA v2) + 0.9.2 #3
@@ -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 ----
|
||||||
|
|
||||||
|
|||||||
@@ -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 host→client planes — drain both and verify, end to
|
// one feedback burst on the host→client 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".
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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 2–3 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 2–3 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user