feat(core,clients): one rumble policy engine for every platform (rumble root fix D)
punktfunk-core client/rumble.rs: a per-connection policy engine consumes seq-gated wire
updates and emits EFFECTIVE actuator commands — re-emits on renewals (duration APIs stay
re-armed), self-silences at the v2 lease, a UNIFORM 1 s legacy-host staleness replacing the
per-platform zoo (Apple 1.6 s / Android 60 s / SDL 1.5 s / Deck 1 s), quirk-declared
actuator keepalives (Deck 40 ms + LSB dedupe-defeat jitter), and one stop per buzzing pad
on connection close. Per-pad mailbox semantics: a stalled embedder wakes to ONE current
command, and a stop can structurally never be the update an overflowing queue drops.
New API/ABI: NativeClient::{next_rumble_command,set_rumble_quirks} +
punktfunk_connection_next_rumble_cmd/_set_rumble_quirks (next_rumble/next_rumble2 stay for
un-migrated embedders; both consumers are fed). Migrations DELETE the platform forks:
pf-client-core loses RumbleState + the Deck keepalive loop + LEGACY_RUMBLE_CEILING_MS and
physically silences a slot at close; Android loses the 60 s legacy one-shot (backstop
repack, cancel-on-zero); Apple loses envelopeDeadline + sessionStaleSeconds + both tick
watchdogs (CoreHaptics realization untouched; mac xcframework rebuilt locally).
design/rumble-root-fix.md par. D. Engine 10/10 unit tests; core tests 176 Linux / 175
Windows + clippy -D warnings; swift build + RumbleTuningTests; Kotlin + android-native
compile green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,9 +52,6 @@ class GamepadFeedback(
|
|||||||
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
|
||||||
const val TAG_HID_RAW: Byte = 0x05
|
const val TAG_HID_RAW: Byte = 0x05
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
/** One controller's rumble binding — VibratorManager (API 31+) OR the legacy single Vibrator (API 28–30). */
|
||||||
@@ -95,19 +92,19 @@ class GamepadFeedback(
|
|||||||
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
|
||||||
// ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms;
|
// ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms);
|
||||||
// 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl.
|
// 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared
|
||||||
// 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior
|
// rumble policy engine — it owns every lease/staleness/close decision (uniform
|
||||||
// long one-shot.
|
// across all clients; the old 60 s legacy-host exposure is gone) and emits
|
||||||
|
// explicit zeros, so apply verbatim: (0, 0) = cancel, non-zero = one-shot for
|
||||||
|
// the backstop (the hardware net under a stalled poll thread).
|
||||||
val pad = ((ev ushr 49) and 0xFL).toInt()
|
val pad = ((ev ushr 49) and 0xFL).toInt()
|
||||||
val hasLease = ((ev ushr 48) and 0x1L) == 0x1L
|
val backstopMs = ((ev ushr 32) and 0xFFFF)
|
||||||
val ttl = ((ev ushr 32) and 0xFFFF).toInt()
|
|
||||||
val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS
|
|
||||||
renderRumble(
|
renderRumble(
|
||||||
pad,
|
pad,
|
||||||
((ev ushr 16) and 0xFFFF).toInt(),
|
((ev ushr 16) and 0xFFFF).toInt(),
|
||||||
(ev and 0xFFFF).toInt(),
|
(ev and 0xFFFF).toInt(),
|
||||||
durationMs,
|
backstopMs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, "pf-rumble").apply { isDaemon = true; start() }
|
}, "pf-rumble").apply { isDaemon = true; start() }
|
||||||
@@ -212,12 +209,13 @@ class GamepadFeedback(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
* low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes),
|
||||||
* addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self-
|
* addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's
|
||||||
* terminates after it unless the host renews, so a lost stop (or a dead host) silences at the
|
* self-termination net under a stalled poll thread; the engine emits explicit zero commands at
|
||||||
* lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS].
|
* every policy stop (lease expiry, legacy staleness, session close), so cancel-on-zero is the
|
||||||
|
* real stop mechanism.
|
||||||
*/
|
*/
|
||||||
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
private fun renderRumble(pad: Int, low: Int, high: Int, durationMs: Long) {
|
||||||
Log.i(TAG, "rumble pad=$pad low=$low high=$high ttlMs=$durationMs") // verification line — BEFORE any no-op return
|
Log.i(TAG, "rumble pad=$pad low=$low high=$high backstopMs=$durationMs") // verification line — BEFORE any no-op return
|
||||||
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
// Opt-in phone mirror, BEFORE the controller-bind early-return: the exact pads this
|
||||||
// serves have no vibrator of their own, so their bind below is null. It follows
|
// serves have no vibrator of their own, so their bind below is null. It follows
|
||||||
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
// controller 1 unconditionally rather than only motor-less pads — capability probing
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ const TAG_PLAYER_LEDS: u8 = 0x02;
|
|||||||
const TAG_TRIGGER: u8 = 0x03;
|
const TAG_TRIGGER: u8 = 0x03;
|
||||||
const TAG_HID_RAW: u8 = 0x05;
|
const TAG_HID_RAW: u8 = 0x05;
|
||||||
|
|
||||||
/// `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 EFFECTIVE
|
||||||
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease",
|
/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The
|
||||||
/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` =
|
/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending
|
||||||
/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no
|
/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands
|
||||||
/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and
|
/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level.
|
||||||
/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are
|
///
|
||||||
/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that
|
/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the
|
||||||
/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread.
|
/// command's `backstop_ms` (≤ 5000 — the one-shot duration, i.e. the hardware net under a stalled
|
||||||
|
/// poll thread; the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||||
|
/// mechanism), bits 16..31 = `low`, bits 0..15 = `high` (0..=0xFFFF). `-1` on timeout / session
|
||||||
|
/// closed (all packed values are positive, so `-1` stays unambiguous). Kotlin routes the command
|
||||||
|
/// back to the controller holding that wire `pad` index (multi-pad rumble). 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,
|
||||||
@@ -43,24 +48,17 @@ 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_ttl is &self on
|
// SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is
|
||||||
// the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll
|
// &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin
|
||||||
// threads (and joins them — unbounded) before nativeClose frees the handle.
|
// stops these poll 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_ttl(PULL_TIMEOUT) {
|
match h.client.next_rumble_command(PULL_TIMEOUT) {
|
||||||
Ok((pad, low, high, ttl)) => {
|
Ok(cmd) => {
|
||||||
// The reorder gate already ran in the core, so this update is fresh. Encode the
|
(jlong::from(cmd.pad & 0xF) << 49)
|
||||||
// Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad
|
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
|
||||||
// index rides above the lease flag (bits 49..52), keeping the whole word positive.
|
| (jlong::from(cmd.low) << 16)
|
||||||
let (lease_flag, ttl_bits) = match ttl {
|
| jlong::from(cmd.high)
|
||||||
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
|
||||||
None => (0, 0),
|
|
||||||
};
|
|
||||||
(jlong::from(pad & 0xF) << 49)
|
|
||||||
| 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -777,6 +777,34 @@ public final class PunktfunkConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next EFFECTIVE rumble command from the core's shared rumble policy engine — the
|
||||||
|
/// uniform replacement for per-platform rumble policy. The engine owns every decision
|
||||||
|
/// (v2 lease expiry, legacy-host staleness at a uniform 1 s, connection-close drain zeros),
|
||||||
|
/// so apply commands verbatim: `(0, 0)` = stop now, non-zero = run at this level.
|
||||||
|
/// `backstopMs` is a safety-net duration for duration-parameterized platform APIs — the
|
||||||
|
/// CoreHaptics renderer ignores it (its finite segment ceiling is the equivalent net).
|
||||||
|
/// Drain from the (single) feedback thread, alongside `nextHidOutput`.
|
||||||
|
public func nextRumbleCommand(timeoutMs: UInt32 = 0) throws
|
||||||
|
-> (pad: UInt16, low: UInt16, high: UInt16, backstopMs: UInt32)?
|
||||||
|
{
|
||||||
|
feedbackLock.lock()
|
||||||
|
defer { feedbackLock.unlock() }
|
||||||
|
guard let h = liveHandle() else { throw PunktfunkClientError.closed }
|
||||||
|
|
||||||
|
var pad: UInt16 = 0, low: UInt16 = 0, high: UInt16 = 0, backstop: UInt32 = 0
|
||||||
|
let rc = punktfunk_connection_next_rumble_cmd(h, &pad, &low, &high, &backstop, timeoutMs)
|
||||||
|
switch rc {
|
||||||
|
case statusOK:
|
||||||
|
return (pad, low, high, backstop)
|
||||||
|
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.
|
||||||
|
|||||||
@@ -155,21 +155,18 @@ public final class GamepadFeedback {
|
|||||||
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
// meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps
|
||||||
// rumble/HID latency low while leaving the lock free between polls.
|
// rumble/HID latency low while leaving the lock free between polls.
|
||||||
//
|
//
|
||||||
// Rumble is idempotent state, so drain the plane DRY and apply only the newest
|
// Rumble arrives as EFFECTIVE commands from the core's shared policy engine
|
||||||
// level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the
|
// (design/rumble-root-fix.md §D): the engine owns leases, legacy staleness,
|
||||||
// ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep
|
// and close-drain zeros, and its per-pad mailbox already coalesces — a
|
||||||
// queue, and its drop-newest overflow could shed a stop while stale nonzero
|
// stalled drain wakes to ONE current-level command per pad, and a stop can
|
||||||
// states queued ahead of it — buzzing until the host's next 500 ms refresh.
|
// never be shed by a queue. Apply verbatim, in order.
|
||||||
var newestByPad: [UInt8: (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.nextRumble2(timeoutMs: 0) {
|
let c = try connection.nextRumbleCommand(timeoutMs: 0) {
|
||||||
newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs)
|
self?.routeRumble(
|
||||||
|
pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high)
|
||||||
rumbleBurst += 1
|
rumbleBurst += 1
|
||||||
}
|
}
|
||||||
for (pad, n) in newestByPad {
|
|
||||||
self?.routeRumble(pad: pad, 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.
|
||||||
var burst = 0
|
var burst = 0
|
||||||
@@ -218,15 +215,15 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route one rumble envelope to its pad's renderer (drain thread). An update for a pad with no
|
/// Route one engine command to its pad's renderer (drain thread). A command for a pad with no
|
||||||
/// live renderer — one that just left the forwarded set — is dropped.
|
/// live renderer — one that just left the forwarded set — is dropped.
|
||||||
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16, ttlMs: UInt32) {
|
private func routeRumble(pad: UInt8, low: UInt16, high: UInt16) {
|
||||||
let renderer = withRouting { rumbleByPad[pad] }
|
let renderer = withRouting { rumbleByPad[pad] }
|
||||||
renderer?.apply(low: low, high: high, ttlMs: ttlMs)
|
renderer?.apply(low: low, high: high)
|
||||||
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
// The opt-in device mirror follows controller 1 unconditionally — the pads it exists for
|
||||||
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
// have no motors (their renderer above no-ops), and mirroring deliberately isn't gated on
|
||||||
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
// that: capability probing can't see a motor-less MFi pad, and the user opted in.
|
||||||
if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) }
|
if pad == 0 { deviceRumble?.apply(low: low, high: high) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withRouting<R>(_ body: () -> R) -> R {
|
private func withRouting<R>(_ body: () -> R) -> R {
|
||||||
|
|||||||
@@ -23,23 +23,6 @@ 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. This is
|
|
||||||
/// the **legacy-host fallback only** — an old host sends no self-termination lease, so its
|
|
||||||
/// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means
|
|
||||||
/// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see
|
|
||||||
/// [`leaseSeconds`]); that deadline supersedes this watchdog.
|
|
||||||
static let sessionStaleSeconds: TimeInterval = 1.6
|
|
||||||
|
|
||||||
/// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's
|
|
||||||
/// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction.
|
|
||||||
static let noTTL: UInt32 = .max
|
|
||||||
|
|
||||||
/// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel
|
|
||||||
/// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination
|
|
||||||
/// lease in seconds (render the level for at most this long unless the host renews it).
|
|
||||||
static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? {
|
|
||||||
ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000
|
|
||||||
}
|
|
||||||
/// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host
|
/// 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
|
||||||
@@ -110,13 +93,15 @@ enum RumbleTuning {
|
|||||||
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
/// `@unchecked Sendable` is sound because every property is read and written only inside
|
||||||
/// `queue` closures — the serial queue is the synchronization.
|
/// `queue` closures — the serial queue is the synchronization.
|
||||||
final class RumbleRenderer: @unchecked Sendable {
|
final class RumbleRenderer: @unchecked Sendable {
|
||||||
/// What an un-refreshed nonzero target means. A live session ties motor life to wire
|
/// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's
|
||||||
/// liveness (the host refreshes state every 500 ms); the controller test panel holds a
|
/// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease,
|
||||||
/// slider level indefinitely.
|
/// staleness, and close decision and emits explicit zeros, so the renderer keeps NO
|
||||||
|
/// staleness policy of its own anymore. The controller test panel (`manual`) holds a slider
|
||||||
|
/// level indefinitely; both are identical renderer-side today, the distinction is kept for
|
||||||
|
/// the call sites' intent.
|
||||||
struct Policy {
|
struct Policy {
|
||||||
let staleAfter: TimeInterval?
|
static let session = Policy()
|
||||||
static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds)
|
static let manual = Policy()
|
||||||
static let manual = Policy(staleAfter: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
/// Which physical actuator this renderer drives: the forwarded controller's haptics engine
|
||||||
@@ -160,13 +145,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
private var controller: GCController?
|
private var controller: GCController?
|
||||||
private var low: Motor?
|
private var low: Motor?
|
||||||
private var high: Motor?
|
private var high: Motor?
|
||||||
/// Wire-truth target (raw wire units) and when it was last confirmed by any command.
|
/// Wire-truth target (raw wire units) — the engine command's level, applied verbatim; the
|
||||||
|
/// core policy engine owns when it ends (explicit zero commands), so no deadline lives here.
|
||||||
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
private var target: (low: UInt16, high: UInt16) = (0, 0)
|
||||||
private var lastCommand = DispatchTime(uptimeNanoseconds: 0)
|
|
||||||
/// The v2 envelope lease: the active level is authorized until here unless the host renews it
|
|
||||||
/// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the
|
|
||||||
/// `sessionStaleSeconds` watchdog is the backstop) and while silent.
|
|
||||||
private var envelopeDeadline: DispatchTime?
|
|
||||||
/// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm,
|
/// 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.
|
||||||
@@ -247,17 +228,9 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
/// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an
|
/// 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
|
/// unchanged level extend the deadline before the idempotence guard, so a held rumble never
|
||||||
/// lapses mid-effect.
|
/// lapses mid-effect.
|
||||||
func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) {
|
func apply(low lowAmp: UInt16, high highAmp: UInt16) {
|
||||||
queue.async {
|
queue.async {
|
||||||
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(
|
||||||
@@ -275,7 +248,6 @@ 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()
|
||||||
@@ -331,25 +303,11 @@ final class RumbleRenderer: @unchecked Sendable {
|
|||||||
healthSink?(problem)
|
healthSink?(problem)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watchdog + housekeeping heartbeat while audible.
|
/// Housekeeping heartbeat while audible: segment re-arm, HID keepalive, backoff retries.
|
||||||
|
/// Every liveness decision (lease expiry, legacy-host staleness, session close) lives in the
|
||||||
|
/// core policy engine now — it emits explicit zero commands, so the renderer never guesses
|
||||||
|
/// when a level should end.
|
||||||
private func tick() {
|
private func tick() {
|
||||||
if let deadline = envelopeDeadline {
|
|
||||||
// v2 host lease: silence the moment it lapses unrenewed. This firing in the wild is the
|
|
||||||
// observable signature of a host that stopped renewing (a dropped stop, or a dead host)
|
|
||||||
// — the whole point of the envelope model: the motor can't outlive the host's intent.
|
|
||||||
if target != (0, 0), DispatchTime.now() >= deadline {
|
|
||||||
log.warning("rumble: envelope expired unrenewed — silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
envelopeDeadline = nil
|
|
||||||
}
|
|
||||||
} else if let after = policy.staleAfter, target != (0, 0), seconds(since: lastCommand) > after {
|
|
||||||
// Legacy host (no lease): it re-sends state every 500 ms, so this much silence means the
|
|
||||||
// channel (or host) died while a motor was on. A direct-connected pad would have been
|
|
||||||
// stopped by its game long ago — force the same outcome.
|
|
||||||
log.warning(
|
|
||||||
"rumble: no wire refresh for \(after, format: .fixed(precision: 1), privacy: .public)s — auto-silencing")
|
|
||||||
target = (0, 0)
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,6 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPolicies() {
|
|
||||||
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
|
||||||
// holds a level indefinitely.
|
|
||||||
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
|
||||||
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
||||||
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
||||||
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
||||||
@@ -75,45 +68,22 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLeaseSecondsInterpretsWireTTL() {
|
/// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at
|
||||||
// The legacy no-lease sentinel → nil (fall back to the staleness watchdog).
|
/// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only
|
||||||
XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL))
|
/// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge.
|
||||||
XCTAssertEqual(RumbleTuning.noTTL, UInt32.max)
|
func testZeroCommandSilencesAndTeardownDoesNotDeadlock() {
|
||||||
// A real lease → its duration in seconds (non-nil for any ttl != noTTL).
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 400) ?? .nan, 0.4, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 0) ?? .nan, 0, accuracy: 1e-9)
|
|
||||||
XCTAssertEqual(RumbleTuning.leaseSeconds(ttlMs: 150) ?? .nan, 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)
|
let renderer = RumbleRenderer(policy: .session)
|
||||||
renderer.retarget(nil)
|
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)
|
||||||
renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100)
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
Thread.sleep(forTimeInterval: 0.3)
|
renderer.apply(low: 0, high: 0)
|
||||||
// No assertion on private state; this exercises the expiry path + serial-queue teardown
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
// No assertion on private state; this exercises the stop path + serial-queue teardown
|
||||||
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
// without deadlock (the ticker fires on the same queue stop() sync-hops onto).
|
||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTuningRelationsTheDesignDependsOn() {
|
func testTuningRelationsTheDesignDependsOn() {
|
||||||
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
|
||||||
// but trip well before a stuck rumble reads as "still going".
|
|
||||||
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
|
||||||
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
|
||||||
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
||||||
// segment boundary and gap.
|
// segment boundary and gap.
|
||||||
XCTAssertGreaterThanOrEqual(
|
XCTAssertGreaterThanOrEqual(
|
||||||
@@ -123,9 +93,8 @@ final class RumbleTuningTests: XCTestCase {
|
|||||||
// The rebake throttle must be far under the host refresh period, or refreshed level
|
// The rebake throttle must be far under the host refresh period, or refreshed level
|
||||||
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
||||||
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
||||||
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
// The ticker (which lands throttled levels) must outpace the HID keepalive, or its
|
||||||
// watchdog, or those deadlines could be overshot by a full period.
|
// deadline could be overshot by a full period.
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
||||||
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
//!
|
//!
|
||||||
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
//! This thread is also the single consumer of the rumble and HID-output pull planes.
|
||||||
|
|
||||||
use punktfunk_core::client::NativeClient;
|
use punktfunk_core::client::{ActuatorQuirks, NativeClient};
|
||||||
use punktfunk_core::config::GamepadPref;
|
use punktfunk_core::config::GamepadPref;
|
||||||
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind};
|
||||||
use punktfunk_core::quic::{HidOutput, RichInput};
|
use punktfunk_core::quic::{HidOutput, RichInput};
|
||||||
@@ -61,24 +61,14 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir
|
|||||||
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
/// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
|
||||||
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's
|
/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an
|
||||||
/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated
|
/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal
|
||||||
/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the
|
/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value
|
||||||
/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as
|
/// to a no-op device write — so a steady level is felt as a periodic pulse without sub-decay
|
||||||
/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz;
|
/// re-kicks; 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. The engine owns the
|
||||||
/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see
|
/// re-kick timing, the 1-LSB dedupe-defeat jitter, and every staleness/lease bound — this worker
|
||||||
/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is
|
/// only applies the commands it emits (`design/rumble-root-fix.md` §D).
|
||||||
/// left untouched.
|
const DECK_RUMBLE_KEEPALIVE_MS: u16 = 40;
|
||||||
const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40;
|
|
||||||
|
|
||||||
/// Ceiling on a *legacy* (no-TTL) host's Steam Deck rumble: silence the actuator once a real host
|
|
||||||
/// update has been absent this long. A legacy host re-sends the held level as a flat 500 ms refresh,
|
|
||||||
/// so a genuinely-held rumble refreshes the per-slot update clock (`RumbleState::updated_at`) every
|
|
||||||
/// 500 ms and never approaches this — only a lost *stop* datagram (the host went quiet entirely)
|
|
||||||
/// lets the 40 ms keep-alive drone on. 2× the 500 ms refresh bounds that lost stop to ~1 s,
|
|
||||||
/// mirroring the Windows host's `RUMBLE_IDLE_TIMEOUT` residual cutoff. The v2 path is bounded by its
|
|
||||||
/// lease `deadline` instead and never trips this (see [`Worker::render_feedback`]).
|
|
||||||
const LEGACY_RUMBLE_CEILING_MS: u64 = 1_000;
|
|
||||||
|
|
||||||
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
/// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
|
||||||
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
|
||||||
@@ -626,32 +616,6 @@ impl Ds5Feedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-controller rumble render state (the Steam Deck keep-alive + the host's v2 lease). Held
|
|
||||||
/// per [`Slot`] so a rumble the host addressed to pad N drives only pad N's actuator.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct RumbleState {
|
|
||||||
/// Last rumble value handed to this pad (the logical host value, pre-jitter) and when —
|
|
||||||
/// drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`].
|
|
||||||
last: (u16, u16),
|
|
||||||
last_at: Option<Instant>,
|
|
||||||
/// When the last *real* host rumble datagram landed on this slot — set only in the feedback
|
|
||||||
/// drain, never bumped by the Deck keep-alive re-kick (unlike `last_at`, which the keep-alive
|
|
||||||
/// refreshes every ~40 ms). A legacy host carries no lease, so this per-slot clock is what
|
|
||||||
/// bounds a lost stop-frame: once it is stale past `LEGACY_RUMBLE_CEILING_MS` the keep-alive
|
|
||||||
/// stops and issues one (0, 0). See [`Worker::render_feedback`].
|
|
||||||
updated_at: Option<Instant>,
|
|
||||||
/// 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`]).
|
|
||||||
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).
|
|
||||||
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).
|
|
||||||
ttl_ms: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
/// One forwarded controller during an attached session: the open SDL handle, its stable wire
|
||||||
/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback
|
/// pad index (0..[`MAX_PADS`](punktfunk_core::input::MAX_PADS)), and the per-pad wire/feedback
|
||||||
/// state that used to be single-scalar on the Worker. Opening the device is what grabs the
|
/// state that used to be single-scalar on the Worker. Opening the device is what grabs the
|
||||||
@@ -683,7 +647,6 @@ struct Slot {
|
|||||||
/// close lift a click held across detach/unplug.
|
/// close lift a click held across detach/unplug.
|
||||||
held_clicks: [bool; 2],
|
held_clicks: [bool; 2],
|
||||||
last_accel: [i16; 3],
|
last_accel: [i16; 3],
|
||||||
rumble: RumbleState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Slot {
|
impl Slot {
|
||||||
@@ -699,7 +662,6 @@ impl Slot {
|
|||||||
surface_last: [(0, 0, false); 2],
|
surface_last: [(0, 0, false); 2],
|
||||||
held_clicks: [false; 2],
|
held_clicks: [false; 2],
|
||||||
last_accel: [0; 3],
|
last_accel: [0; 3],
|
||||||
rumble: RumbleState::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,6 +890,20 @@ impl Worker {
|
|||||||
// uses the session-default kind.
|
// uses the session-default kind.
|
||||||
if let Some(c) = &self.attached {
|
if let Some(c) = &self.attached {
|
||||||
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
send(c, InputKind::GamepadArrival, pref.to_u8() as u32, 0, index);
|
||||||
|
// Declare the actuator's quirks to the shared rumble policy engine. ALWAYS
|
||||||
|
// set (defaults for a well-behaved pad): wire indices are reused within a
|
||||||
|
// connection, so a Deck slot that closes must not leave its keepalive quirk
|
||||||
|
// behind for the next pad on the same index.
|
||||||
|
let quirks = if pref == GamepadPref::SteamDeck {
|
||||||
|
ActuatorQuirks {
|
||||||
|
keepalive_ms: DECK_RUMBLE_KEEPALIVE_MS,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ActuatorQuirks::default()
|
||||||
|
};
|
||||||
|
c.set_rumble_quirks(index as u16, quirks);
|
||||||
}
|
}
|
||||||
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
tracing::info!(id, index, pref = ?pref, "gamepad forwarding (slot opened)");
|
||||||
self.slots.push(slot);
|
self.slots.push(slot);
|
||||||
@@ -940,6 +916,10 @@ impl Worker {
|
|||||||
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
/// the SDL handle. The flush only emits wire events, so it is safe even when the device is
|
||||||
/// already gone (unplug).
|
/// already gone (unplug).
|
||||||
fn close_slot_at(&mut self, i: usize) {
|
fn close_slot_at(&mut self, i: usize) {
|
||||||
|
// Best-effort physical silence before the handle drops: a slot closed mid-buzz (detach /
|
||||||
|
// unplug) must not depend on what SDL does to a rumbling device at close. Errors are
|
||||||
|
// expected for an already-unplugged pad.
|
||||||
|
let _ = self.slots[i].pad.set_rumble(0, 0, 100);
|
||||||
if let Some(c) = self.attached.clone() {
|
if let Some(c) = self.attached.clone() {
|
||||||
Self::flush_slot(&c, &mut self.slots[i]);
|
Self::flush_slot(&c, &mut self.slots[i]);
|
||||||
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
// Signal the host to tear down this pad's virtual device (native hot-unplug). Sent
|
||||||
@@ -1464,107 +1444,47 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand a rumble value to SDL on one slot's pad, remembering it for the Deck keep-alive.
|
/// Hand one policy-engine command to SDL on a slot's pad, verbatim. The core engine owns all
|
||||||
/// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its
|
/// rumble policy — leases, legacy-host staleness, the Deck keepalive + its dedupe-defeat
|
||||||
/// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single
|
/// jitter (declared as quirks at slot open) — so this worker keeps no rumble state at all.
|
||||||
/// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the
|
/// `backstop_ms` becomes the SDL duration: the hardware-level net under a stalled worker
|
||||||
/// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously
|
/// thread (the engine emits explicit zeros at every policy stop, so it is never the stop
|
||||||
/// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one
|
/// mechanism).
|
||||||
/// self-silences at the TTL); against a legacy host (`ttl_ms == 0`) it stays the proven 1.5 s.
|
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, backstop_ms: u32) {
|
||||||
fn issue_rumble(slot: &mut Slot, low: u16, high: u16, deck: bool) {
|
let dur_ms: u32 = if (low, high) == (0, 0) {
|
||||||
let dur_ms: u32 = if slot.rumble.ttl_ms == 0 {
|
100 // a stop takes effect immediately; the duration is irrelevant
|
||||||
1_500 // legacy host: no lease — keep the proven duration
|
|
||||||
} else {
|
} else {
|
||||||
// Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the
|
backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator
|
||||||
// actuator between SDL writes.
|
|
||||||
(slot.rumble.ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4)
|
|
||||||
};
|
|
||||||
let (out_low, out_high) =
|
|
||||||
if deck && (low, high) == slot.rumble.last && (low, high) != (0, 0) {
|
|
||||||
slot.rumble.jitter = !slot.rumble.jitter;
|
|
||||||
(low ^ slot.rumble.jitter as u16, high)
|
|
||||||
} else {
|
|
||||||
(low, high)
|
|
||||||
};
|
};
|
||||||
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in the right
|
||||||
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
// HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs the send side
|
||||||
// on 0xCA with the pad index, so the two together pinpoint host-game vs client-render.
|
// on 0xCA with the pad index, so the two together pinpoint host-game vs client-render.
|
||||||
match slot.pad.set_rumble(out_low, out_high, dur_ms) {
|
match slot.pad.set_rumble(low, high, dur_ms) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed")
|
||||||
}
|
}
|
||||||
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
Ok(()) => tracing::trace!(pad = slot.index, low, high, "rumble: rendered"),
|
||||||
}
|
}
|
||||||
slot.rumble.last = (low, high);
|
|
||||||
slot.rumble.last_at = Some(Instant::now());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs /
|
/// Drain and render the feedback planes — rumble plus HID output (lightbar / player LEDs /
|
||||||
/// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this
|
/// adaptive triggers) — routing each update to the forwarded slot on its wire pad index; this
|
||||||
/// thread is their single consumer. Rumble arrives as self-terminating v2 envelopes: each
|
/// thread is their single consumer. Rumble arrives as EFFECTIVE commands from the core's
|
||||||
/// carries a TTL the host renews while the level holds and lets expire when it stops, so the
|
/// shared policy engine, which already applied every policy — v2 lease expiry, legacy-host
|
||||||
/// actuator's divergence from the host's intent is bounded by the wire, not by a client guess.
|
/// staleness, the Deck actuator keepalive + jitter (via the quirks declared at slot open),
|
||||||
/// A legacy host (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration
|
/// and connection-close drain zeros — so this worker applies commands verbatim and keeps no
|
||||||
/// expiry as before.
|
/// rumble state of its own (`design/rumble-root-fix.md` §D).
|
||||||
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;
|
||||||
};
|
};
|
||||||
// Rumble envelopes (0xCA) → the slot holding that wire pad index. An update for an index
|
// Engine commands → the slot holding that wire pad index. A command for an index with no
|
||||||
// with no live slot (a pad that just unplugged) is dropped.
|
// live slot (a pad that just unplugged) is dropped. The loop ends on NoFrame (drained
|
||||||
while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) {
|
// dry this tick) or Closed (session over — the engine delivered its close-drain zeros
|
||||||
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == pad) {
|
// first; the physical silence backstop is in `close_slot_at`).
|
||||||
let deck = slot.pref == GamepadPref::SteamDeck;
|
while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) {
|
||||||
slot.rumble.ttl_ms = ttl.unwrap_or(0);
|
if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) {
|
||||||
// A v2 lease sets an explicit client-side deadline; a legacy update clears it and
|
Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms);
|
||||||
// leans on SDL's own duration expiry (unchanged behaviour).
|
|
||||||
slot.rumble.deadline = match ttl {
|
|
||||||
Some(ms) if (low, high) != (0, 0) => {
|
|
||||||
Some(Instant::now() + Duration::from_millis(ms as u64))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
// Mark this as a real host update. Unlike `last_at` (which the Deck keep-alive
|
|
||||||
// re-kick refreshes every ~40 ms), this clock advances only here, so a legacy
|
|
||||||
// lost-stop can be bounded by `LEGACY_RUMBLE_CEILING_MS` in the keep-alive below.
|
|
||||||
slot.rumble.updated_at = Some(Instant::now());
|
|
||||||
Self::issue_rumble(slot, low, high, deck);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Steam Deck keep-alive, per slot: the built-in actuator decays inside SDL's ~2 s internal
|
|
||||||
// rumble resend, and SDL dedupes an unchanged `set_rumble` to a no-op device write — so a
|
|
||||||
// steady host value is felt as a periodic pulse. Re-kick a Deck slot below the decay
|
|
||||||
// (`DECK_RUMBLE_KEEPALIVE_MS`) so its discrete bursts fuse into a continuous buzz, but
|
|
||||||
// silence it once the host's lease expires (the host stopped renewing — a lost stop, or the
|
|
||||||
// host died). The per-slot timing guards make this idempotent with a fresh datagram this
|
|
||||||
// tick (a just-set `last_at`/`deadline` fails both checks). Non-Deck slots sustain/expire at
|
|
||||||
// the SDL/hardware level and never enter here.
|
|
||||||
for slot in self.slots.iter_mut() {
|
|
||||||
if slot.pref != GamepadPref::SteamDeck || slot.rumble.last == (0, 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if slot.rumble.deadline.is_some_and(|d| Instant::now() >= d) {
|
|
||||||
slot.rumble.deadline = None;
|
|
||||||
slot.rumble.ttl_ms = 0;
|
|
||||||
Self::issue_rumble(slot, 0, 0, true);
|
|
||||||
} else if slot.rumble.ttl_ms == 0
|
|
||||||
&& slot
|
|
||||||
.rumble
|
|
||||||
.updated_at
|
|
||||||
.is_some_and(|t| t.elapsed() >= Duration::from_millis(LEGACY_RUMBLE_CEILING_MS))
|
|
||||||
{
|
|
||||||
// Legacy host (no v2 lease): a held rumble refreshes `updated_at` every ~500 ms, so
|
|
||||||
// this only trips on a lost stop-frame the host never followed up — silence the
|
|
||||||
// actuator once instead of letting the 40 ms keep-alive drone forever. `issue_rumble`
|
|
||||||
// sets `last` to (0, 0), so the top-of-loop guard skips this slot on later ticks.
|
|
||||||
Self::issue_rumble(slot, 0, 0, true);
|
|
||||||
} else if slot
|
|
||||||
.rumble
|
|
||||||
.last_at
|
|
||||||
.is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS))
|
|
||||||
{
|
|
||||||
let (low, high) = slot.rumble.last;
|
|
||||||
Self::issue_rumble(slot, low, high, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index.
|
// HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index.
|
||||||
|
|||||||
@@ -2015,6 +2015,105 @@ pub unsafe extern "C" fn punktfunk_connection_next_rumble2(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `flags` bit for [`punktfunk_connection_set_rumble_quirks`]: alternate the low motor's LSB on
|
||||||
|
/// keepalive re-emits (imperceptible) so an SDL-class layer that no-ops identical values still
|
||||||
|
/// writes the device — the Steam Deck's dedupe-defeat.
|
||||||
|
pub const PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER: u32 = 1;
|
||||||
|
|
||||||
|
/// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform replacement
|
||||||
|
/// for per-platform rumble policy. Unlike [`punktfunk_connection_next_rumble2`], the caller never
|
||||||
|
/// sees a TTL and never owns a deadline: the engine emits the level on every wire update (renewals
|
||||||
|
/// re-arm duration-parameterized APIs), an explicit zero at lease expiry / legacy-host staleness
|
||||||
|
/// (a uniform 1 s) / connection close, and any keepalives declared via
|
||||||
|
/// [`punktfunk_connection_set_rumble_quirks`]. Apply commands verbatim: `(0, 0)` = stop now;
|
||||||
|
/// non-zero = run at this level, with `*backstop_ms` as the safety-net duration for platform APIs
|
||||||
|
/// that take one (explicit-stop APIs ignore it; it is `0` on stop commands).
|
||||||
|
/// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
|
||||||
|
/// every close-drain stop was delivered — silence all actuators on it.
|
||||||
|
///
|
||||||
|
/// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime,
|
||||||
|
/// never both (they consume the same wire plane).
|
||||||
|
///
|
||||||
|
/// # 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_rumble_cmd(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
pad: *mut u16,
|
||||||
|
low: *mut u16,
|
||||||
|
high: *mut u16,
|
||||||
|
backstop_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_command(std::time::Duration::from_millis(timeout_ms as u64))
|
||||||
|
{
|
||||||
|
Ok(cmd) => {
|
||||||
|
unsafe {
|
||||||
|
if !pad.is_null() {
|
||||||
|
*pad = cmd.pad;
|
||||||
|
}
|
||||||
|
if !low.is_null() {
|
||||||
|
*low = cmd.low;
|
||||||
|
}
|
||||||
|
if !high.is_null() {
|
||||||
|
*high = cmd.high;
|
||||||
|
}
|
||||||
|
if !backstop_ms.is_null() {
|
||||||
|
*backstop_ms = cmd.backstop_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
}
|
||||||
|
Err(e) => e.status(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||||
|
/// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||||
|
/// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||||
|
/// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||||
|
/// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||||
|
/// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||||
|
/// actuator.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `c` is a valid connection handle. Callable from any thread.
|
||||||
|
#[cfg(feature = "quic")]
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn punktfunk_connection_set_rumble_quirks(
|
||||||
|
c: *mut PunktfunkConnection,
|
||||||
|
pad: u16,
|
||||||
|
keepalive_ms: u16,
|
||||||
|
min_pulse_ms: u16,
|
||||||
|
flags: u32,
|
||||||
|
) -> PunktfunkStatus {
|
||||||
|
guard(|| {
|
||||||
|
let c = match unsafe { c.as_ref() } {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return PunktfunkStatus::NullPointer,
|
||||||
|
};
|
||||||
|
c.inner.set_rumble_quirks(
|
||||||
|
pad,
|
||||||
|
crate::client::ActuatorQuirks {
|
||||||
|
keepalive_ms,
|
||||||
|
min_pulse_ms,
|
||||||
|
dedup_jitter: flags & PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER != 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|||||||
@@ -28,10 +28,12 @@ mod planes;
|
|||||||
mod probe;
|
mod probe;
|
||||||
mod pump;
|
mod pump;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
|
mod rumble;
|
||||||
mod worker;
|
mod worker;
|
||||||
|
|
||||||
pub use self::planes::AudioPacket;
|
pub use self::planes::AudioPacket;
|
||||||
pub use self::probe::ProbeOutcome;
|
pub use self::probe::ProbeOutcome;
|
||||||
|
pub use self::rumble::{ActuatorQuirks, RumbleCommand};
|
||||||
|
|
||||||
use self::control::{CtrlRequest, Negotiated};
|
use self::control::{CtrlRequest, Negotiated};
|
||||||
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop};
|
||||||
@@ -75,6 +77,10 @@ pub struct NativeClient {
|
|||||||
frames: Arc<FrameChannel>,
|
frames: Arc<FrameChannel>,
|
||||||
audio: Mutex<Receiver<AudioPacket>>,
|
audio: Mutex<Receiver<AudioPacket>>,
|
||||||
rumble: Mutex<Receiver<RumbleUpdate>>,
|
rumble: Mutex<Receiver<RumbleUpdate>>,
|
||||||
|
/// The shared rumble policy engine ([`RumbleCommand`] API — the uniform per-platform-policy
|
||||||
|
/// replacement). Fed by the datagram demux in parallel with the raw `rumble` queue; an
|
||||||
|
/// embedder consumes ONE of the two APIs (documented on [`NativeClient::next_rumble_command`]).
|
||||||
|
rumble_sched: Arc<rumble::RumbleShared>,
|
||||||
/// 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.
|
||||||
@@ -299,6 +305,8 @@ impl 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::<RumbleUpdate>(RUMBLE_QUEUE);
|
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<RumbleUpdate>(RUMBLE_QUEUE);
|
||||||
|
let rumble_sched = Arc::new(rumble::RumbleShared::new());
|
||||||
|
let rumble_feed = rumble::RumbleFeed(rumble_sched.clone());
|
||||||
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) =
|
||||||
@@ -366,6 +374,7 @@ impl NativeClient {
|
|||||||
frames: frame_chan_w,
|
frames: frame_chan_w,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
|
rumble_feed,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
@@ -401,6 +410,7 @@ impl NativeClient {
|
|||||||
frames: frame_chan,
|
frames: frame_chan,
|
||||||
audio: Mutex::new(audio_rx),
|
audio: Mutex::new(audio_rx),
|
||||||
rumble: Mutex::new(rumble_rx),
|
rumble: Mutex::new(rumble_rx),
|
||||||
|
rumble_sched,
|
||||||
hidout: Mutex::new(hidout_rx),
|
hidout: Mutex::new(hidout_rx),
|
||||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||||
host_timing: Mutex::new(host_timing_rx),
|
host_timing: Mutex::new(host_timing_rx),
|
||||||
@@ -776,6 +786,35 @@ impl NativeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform
|
||||||
|
/// replacement for per-platform rumble policy (`design/rumble-root-fix.md` §D). Unlike
|
||||||
|
/// [`NativeClient::next_rumble_ttl`], the caller never sees a TTL and never owns a deadline:
|
||||||
|
/// the engine emits the level on every wire update (renewals re-arm duration-parameterized
|
||||||
|
/// APIs), an explicit zero at lease expiry / legacy staleness / connection close, and
|
||||||
|
/// quirk-declared keepalives ([`NativeClient::set_rumble_quirks`]). Apply commands verbatim:
|
||||||
|
/// `(0, 0)` = stop now; non-zero = run at this level, with `backstop_ms` as the safety-net
|
||||||
|
/// duration for APIs that take one. [`PunktfunkError::NoFrame`] on timeout;
|
||||||
|
/// [`PunktfunkError::Closed`] once the session ended AND every close-drain stop was delivered.
|
||||||
|
///
|
||||||
|
/// One puller thread, and one API: an embedder uses EITHER this or
|
||||||
|
/// `next_rumble`/`next_rumble_ttl` for a connection's lifetime, never both (both consume the
|
||||||
|
/// same wire plane; the raw queue keeps filling harmlessly while this API is used).
|
||||||
|
pub fn next_rumble_command(&self, timeout: Duration) -> Result<RumbleCommand> {
|
||||||
|
match self.rumble_sched.next_command(timeout) {
|
||||||
|
Ok(Some(c)) => Ok(c),
|
||||||
|
Ok(None) => Err(PunktfunkError::NoFrame),
|
||||||
|
Err(rumble::Closed) => Err(PunktfunkError::Closed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare a physical actuator's quirks for wire pad `pad` (see [`ActuatorQuirks`]) —
|
||||||
|
/// typically at controller attach. All-default quirks (the initial state) describe a
|
||||||
|
/// well-behaved actuator; only decaying actuators (Steam Deck, DualSense-over-BT raw HID)
|
||||||
|
/// need a keepalive.
|
||||||
|
pub fn set_rumble_quirks(&self, pad: u16, quirks: ActuatorQuirks) {
|
||||||
|
self.rumble_sched.set_quirks(pad, quirks);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive
|
/// Pull the next DualSense HID-output feedback event (lightbar / player LEDs / adaptive
|
||||||
/// trigger) the host's virtual pad received from a game; same timeout/closed semantics as
|
/// trigger) the host's virtual pad received from a game; same timeout/closed semantics as
|
||||||
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
/// [`NativeClient::next_rumble`]. Replay it on a real DualSense (e.g. via the platform's
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
frames,
|
frames,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
|
rumble_feed,
|
||||||
hidout_tx,
|
hidout_tx,
|
||||||
hdr_meta_tx,
|
hdr_meta_tx,
|
||||||
host_timing_tx,
|
host_timing_tx,
|
||||||
@@ -578,7 +579,10 @@ pub(super) async fn run_pump(args: WorkerArgs) {
|
|||||||
};
|
};
|
||||||
if fresh {
|
if fresh {
|
||||||
let ttl = u.envelope.map(|e| e.ttl_ms);
|
let ttl = u.envelope.map(|e| e.ttl_ms);
|
||||||
|
// Both consumers are fed; an embedder drains exactly one of them
|
||||||
|
// (the legacy queue, or the policy engine's command API).
|
||||||
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
let _ = rumble_tx.try_send((u.pad, u.low, u.high, ttl));
|
||||||
|
rumble_feed.wire_update(u.pad, u.low, u.high, ttl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
//! The rumble policy engine — one place, all platforms (`design/rumble-root-fix.md` §D).
|
||||||
|
//!
|
||||||
|
//! Historically every client re-implemented the *when/what-level* decisions (lease deadlines,
|
||||||
|
//! legacy staleness, actuator keepalives, stop-on-close), each with its own magic numbers
|
||||||
|
//! (Apple 1.6 s, Android 60 s, SDL 1.5 s, Deck 1 s + 40 ms) — five parallel policy forks, five
|
||||||
|
//! places for a stuck-rumble bug. The engine consumes seq-gated wire updates and emits
|
||||||
|
//! **effective actuator commands**: embedders never see a TTL, never own a deadline, never invent
|
||||||
|
//! a staleness constant. A platform keeps only *how to make this actuator vibrate* plus a small
|
||||||
|
//! [`ActuatorQuirks`] declaration (decay keepalive, duration floor) that parameterizes the shared
|
||||||
|
//! engine instead of forking it.
|
||||||
|
//!
|
||||||
|
//! Command sources, in priority order: an expiry zero (v2 lease ran out unrenewed — the host-died
|
||||||
|
//! safety net), a legacy-staleness zero (no-TTL host went quiet past [`LEGACY_STALE_MS`]), the
|
||||||
|
//! current level on every wire update (renewals re-emit so duration-parameterized platform APIs
|
||||||
|
//! keep getting re-armed — exactly the pre-engine cadence), and quirk keepalives (an actuator
|
||||||
|
//! whose hardware decays between renewals, e.g. the Deck's, gets sub-renewal re-kicks with an
|
||||||
|
//! optional 1-LSB jitter to defeat SDL's identical-value dedupe). On connection close the engine
|
||||||
|
//! drains one zero per still-buzzing pad before reporting closed, so every platform silences on
|
||||||
|
//! detach by contract.
|
||||||
|
//!
|
||||||
|
//! The engine replaces the bounded `RUMBLE_QUEUE` ring for embedders on the command API: state is
|
||||||
|
//! a per-pad mailbox and commands are generated on demand, so a stalled embedder wakes to ONE
|
||||||
|
//! current-level command instead of a backlog — and a stop can never be the update that an
|
||||||
|
//! overflowing queue drops.
|
||||||
|
|
||||||
|
use crate::input::MAX_PADS;
|
||||||
|
use std::sync::{Condvar, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
|
||||||
|
/// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
|
||||||
|
/// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
|
||||||
|
pub const LEGACY_STALE_MS: u64 = 1000;
|
||||||
|
|
||||||
|
/// Backstop duration handed to duration-parameterized platform APIs against a legacy host (the
|
||||||
|
/// engine's staleness zero lands at 1 s; this is the hardware-level net under an engine stall).
|
||||||
|
const BACKSTOP_LEGACY_MS: u32 = 2000;
|
||||||
|
|
||||||
|
/// One effective actuator command. `(0, 0)` means stop now. `backstop_ms` is a safety-net
|
||||||
|
/// duration for platform APIs that take one (SDL rumble, Android one-shots): the engine emits
|
||||||
|
/// explicit zeros at every policy stop, so the backstop only matters if the embedder thread itself
|
||||||
|
/// stalls; platforms with explicit-stop APIs ignore it. Zero commands carry `backstop_ms == 0`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct RumbleCommand {
|
||||||
|
pub pad: u16,
|
||||||
|
pub low: u16,
|
||||||
|
pub high: u16,
|
||||||
|
pub backstop_ms: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A physical actuator's declared quirks — how a platform parameterizes the shared policy instead
|
||||||
|
/// of forking it. Defaults (all zero/false) describe a well-behaved actuator.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ActuatorQuirks {
|
||||||
|
/// Re-emit an unchanged non-zero level every this many ms — for actuators whose hardware
|
||||||
|
/// output decays between wire renewals (Steam Deck ≈ 40, macOS DualSense-over-HID BT ≈ 900).
|
||||||
|
/// `0` = no keepalive (the common case).
|
||||||
|
pub keepalive_ms: u16,
|
||||||
|
/// Floor for `backstop_ms` on non-zero commands (Android's `createOneShot` throws on 0).
|
||||||
|
pub min_pulse_ms: u16,
|
||||||
|
/// Alternate the low motor's LSB on keepalive re-emits (imperceptible) so an SDL-class layer
|
||||||
|
/// that no-ops identical values still writes the device — the Deck's dedupe-defeat.
|
||||||
|
pub dedup_jitter: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct PadState {
|
||||||
|
level: (u16, u16),
|
||||||
|
/// v2 lease expiry — `None` for a zero level or a legacy pad.
|
||||||
|
deadline: Option<Instant>,
|
||||||
|
/// Last v2 TTL (drives the backstop); 0 ⇔ legacy.
|
||||||
|
ttl_ms: u16,
|
||||||
|
/// Last wire arrival iff the pad is driven by a legacy (no-TTL) host — the staleness clock.
|
||||||
|
legacy_wire: Option<Instant>,
|
||||||
|
/// A wire update landed since the last emit (level change OR renewal — renewals re-emit).
|
||||||
|
dirty: bool,
|
||||||
|
next_keepalive: Option<Instant>,
|
||||||
|
/// Current jitter phase (see [`ActuatorQuirks::dedup_jitter`]).
|
||||||
|
jitter: bool,
|
||||||
|
quirks: ActuatorQuirks,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PadState {
|
||||||
|
const NEUTRAL: PadState = PadState {
|
||||||
|
level: (0, 0),
|
||||||
|
deadline: None,
|
||||||
|
ttl_ms: 0,
|
||||||
|
legacy_wire: None,
|
||||||
|
dirty: false,
|
||||||
|
next_keepalive: None,
|
||||||
|
jitter: false,
|
||||||
|
quirks: ActuatorQuirks {
|
||||||
|
keepalive_ms: 0,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn backstop(&self) -> u32 {
|
||||||
|
let b = if self.ttl_ms > 0 {
|
||||||
|
(2 * self.ttl_ms as u32).clamp(500, 5000)
|
||||||
|
} else {
|
||||||
|
BACKSTOP_LEGACY_MS
|
||||||
|
};
|
||||||
|
b.max(self.quirks.min_pulse_ms as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero the pad's level + timers and produce the stop command.
|
||||||
|
fn silence(&mut self, pad: u16) -> RumbleCommand {
|
||||||
|
self.level = (0, 0);
|
||||||
|
self.deadline = None;
|
||||||
|
self.legacy_wire = None;
|
||||||
|
self.next_keepalive = None;
|
||||||
|
self.dirty = false;
|
||||||
|
RumbleCommand {
|
||||||
|
pad,
|
||||||
|
low: 0,
|
||||||
|
high: 0,
|
||||||
|
backstop_ms: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure per-connection policy state machine. Time is always passed in (`now`) so the policy
|
||||||
|
/// is deterministic and table-testable — the [`RumbleShared`] wrapper owns the real clock.
|
||||||
|
pub(crate) struct RumbleEngine {
|
||||||
|
pads: [PadState; MAX_PADS],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_wake(wake: &mut Option<Instant>, t: Instant) {
|
||||||
|
*wake = Some(wake.map_or(t, |w| w.min(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RumbleEngine {
|
||||||
|
pub(crate) fn new() -> RumbleEngine {
|
||||||
|
RumbleEngine {
|
||||||
|
pads: [PadState::NEUTRAL; MAX_PADS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one seq-gated wire update in. Every update dirties the pad (renewals re-emit so
|
||||||
|
/// platform duration timers re-arm); a v2 update replaces the lease deadline, a legacy update
|
||||||
|
/// refreshes the staleness clock.
|
||||||
|
pub(crate) fn wire_update(
|
||||||
|
&mut self,
|
||||||
|
now: Instant,
|
||||||
|
pad: u16,
|
||||||
|
low: u16,
|
||||||
|
high: u16,
|
||||||
|
ttl_ms: Option<u16>,
|
||||||
|
) {
|
||||||
|
let Some(p) = self.pads.get_mut(pad as usize) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
p.level = (low, high);
|
||||||
|
p.dirty = true;
|
||||||
|
match ttl_ms {
|
||||||
|
Some(t) => {
|
||||||
|
p.ttl_ms = t;
|
||||||
|
p.legacy_wire = None;
|
||||||
|
p.deadline = if (low, high) != (0, 0) {
|
||||||
|
Some(now + Duration::from_millis(t as u64))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
p.ttl_ms = 0;
|
||||||
|
p.deadline = None;
|
||||||
|
p.legacy_wire = Some(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_quirks(&mut self, pad: u16, q: ActuatorQuirks) {
|
||||||
|
if let Some(p) = self.pads.get_mut(pad as usize) {
|
||||||
|
p.quirks = q;
|
||||||
|
// A changed cadence re-derives from the next emit/poll; a stale scheduled kick from
|
||||||
|
// the old cadence is harmless, a cleared quirk must cancel it.
|
||||||
|
if q.keepalive_ms == 0 {
|
||||||
|
p.next_keepalive = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce the next due command at `now`, if any, plus the earliest future instant something
|
||||||
|
/// becomes due (the caller's wake deadline; `None` = nothing scheduled, wait for wire input).
|
||||||
|
pub(crate) fn poll(&mut self, now: Instant) -> (Option<RumbleCommand>, Option<Instant>) {
|
||||||
|
let mut wake: Option<Instant> = None;
|
||||||
|
for i in 0..MAX_PADS {
|
||||||
|
let p = &mut self.pads[i];
|
||||||
|
let pad = i as u16;
|
||||||
|
if p.level != (0, 0) {
|
||||||
|
// 1) v2 lease expiry — the host stopped renewing (died / stopped caring). This
|
||||||
|
// firing in the wild is the signature of a host-side bug: worth a log line.
|
||||||
|
if let Some(d) = p.deadline {
|
||||||
|
if now >= d {
|
||||||
|
tracing::info!(pad, "rumble: envelope expired unrenewed — silencing");
|
||||||
|
return (Some(p.silence(pad)), None);
|
||||||
|
}
|
||||||
|
merge_wake(&mut wake, d);
|
||||||
|
}
|
||||||
|
// 2) legacy-host staleness — the uniform replacement for every per-platform bound.
|
||||||
|
if let Some(lw) = p.legacy_wire {
|
||||||
|
let stale_at = lw + Duration::from_millis(LEGACY_STALE_MS);
|
||||||
|
if now >= stale_at {
|
||||||
|
tracing::debug!(pad, "rumble: legacy host went quiet — silencing");
|
||||||
|
return (Some(p.silence(pad)), None);
|
||||||
|
}
|
||||||
|
merge_wake(&mut wake, stale_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) a wire update to relay (level change or renewal re-arm).
|
||||||
|
if p.dirty {
|
||||||
|
p.dirty = false;
|
||||||
|
if p.level == (0, 0) {
|
||||||
|
return (Some(p.silence(pad)), None);
|
||||||
|
}
|
||||||
|
if p.quirks.keepalive_ms > 0 {
|
||||||
|
p.next_keepalive =
|
||||||
|
Some(now + Duration::from_millis(p.quirks.keepalive_ms as u64));
|
||||||
|
}
|
||||||
|
let (low, high) = p.level;
|
||||||
|
return (
|
||||||
|
Some(RumbleCommand {
|
||||||
|
pad,
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
backstop_ms: p.backstop(),
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 4) actuator-decay keepalive, bounded by (1)/(2) above by construction: an expired
|
||||||
|
// or stale pad was silenced before reaching here, so a keepalive can never sustain a
|
||||||
|
// level the policy has ended.
|
||||||
|
if p.level != (0, 0) && p.quirks.keepalive_ms > 0 {
|
||||||
|
let ka = Duration::from_millis(p.quirks.keepalive_ms as u64);
|
||||||
|
let due = *p.next_keepalive.get_or_insert(now + ka);
|
||||||
|
if now >= due {
|
||||||
|
p.next_keepalive = Some(now + ka);
|
||||||
|
let (mut low, high) = p.level;
|
||||||
|
if p.quirks.dedup_jitter {
|
||||||
|
p.jitter = !p.jitter;
|
||||||
|
low ^= p.jitter as u16;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Some(RumbleCommand {
|
||||||
|
pad,
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
backstop_ms: p.backstop(),
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
merge_wake(&mut wake, due);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, wake)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close drain: one stop per still-buzzing pad (call until `None`), so a session drop mid-buzz
|
||||||
|
/// silences every platform by contract instead of by per-client accident.
|
||||||
|
pub(crate) fn close_drain(&mut self) -> Option<RumbleCommand> {
|
||||||
|
for i in 0..MAX_PADS {
|
||||||
|
if self.pads[i].level != (0, 0) {
|
||||||
|
return Some(self.pads[i].silence(i as u16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine behind a lock + condvar — the demux thread feeds it, one embedder thread polls it.
|
||||||
|
pub(crate) struct RumbleShared {
|
||||||
|
inner: Mutex<SharedState>,
|
||||||
|
cv: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharedState {
|
||||||
|
engine: RumbleEngine,
|
||||||
|
closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets `closed` (and wakes the poller) when the owner — the datagram demux task — ends for any
|
||||||
|
/// reason, so the embedder's command poll always observes connection teardown.
|
||||||
|
pub(crate) struct RumbleFeed(pub(crate) std::sync::Arc<RumbleShared>);
|
||||||
|
|
||||||
|
impl RumbleFeed {
|
||||||
|
pub(crate) fn wire_update(&self, pad: u16, low: u16, high: u16, ttl_ms: Option<u16>) {
|
||||||
|
let mut g = self.0.inner.lock().unwrap();
|
||||||
|
g.engine.wire_update(Instant::now(), pad, low, high, ttl_ms);
|
||||||
|
drop(g);
|
||||||
|
self.0.cv.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RumbleFeed {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.inner.lock().unwrap().closed = true;
|
||||||
|
self.0.cv.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RumbleShared {
|
||||||
|
pub(crate) fn new() -> RumbleShared {
|
||||||
|
RumbleShared {
|
||||||
|
inner: Mutex::new(SharedState {
|
||||||
|
engine: RumbleEngine::new(),
|
||||||
|
closed: false,
|
||||||
|
}),
|
||||||
|
cv: Condvar::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_quirks(&self, pad: u16, q: ActuatorQuirks) {
|
||||||
|
self.inner.lock().unwrap().engine.set_quirks(pad, q);
|
||||||
|
self.cv.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block up to `timeout` for the next effective command. `Ok(Some)` = a command, `Ok(None)` =
|
||||||
|
/// timeout, `Err(Closed)` = the connection ended AND the close-drain zeros were all delivered.
|
||||||
|
pub(crate) fn next_command(&self, timeout: Duration) -> Result<Option<RumbleCommand>, Closed> {
|
||||||
|
let overall = Instant::now() + timeout;
|
||||||
|
let mut g = self.inner.lock().unwrap();
|
||||||
|
loop {
|
||||||
|
let now = Instant::now();
|
||||||
|
let (cmd, wake) = g.engine.poll(now);
|
||||||
|
if let Some(c) = cmd {
|
||||||
|
return Ok(Some(c));
|
||||||
|
}
|
||||||
|
if g.closed {
|
||||||
|
return match g.engine.close_drain() {
|
||||||
|
Some(c) => Ok(Some(c)),
|
||||||
|
None => Err(Closed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let until = wake.map_or(overall, |w| w.min(overall));
|
||||||
|
if until <= now {
|
||||||
|
if now >= overall {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (guard, _) = self.cv.wait_timeout(g, until - now).unwrap();
|
||||||
|
g = guard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connection ended and every close-drain stop has been delivered.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Closed;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ms(v: u64) -> Duration {
|
||||||
|
Duration::from_millis(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd(pad: u16, low: u16, high: u16, backstop_ms: u32) -> RumbleCommand {
|
||||||
|
RumbleCommand {
|
||||||
|
pad,
|
||||||
|
low,
|
||||||
|
high,
|
||||||
|
backstop_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_level_emits_and_expires_at_the_lease() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 0x4000, 0x8000, Some(400));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 0x4000, 0x8000, 800))); // backstop = 2×ttl
|
||||||
|
// No renewal: at the deadline the engine self-silences — the host-died safety net.
|
||||||
|
let (c, wake) = e.poll(t0 + ms(200));
|
||||||
|
assert_eq!(c, None);
|
||||||
|
assert_eq!(wake, Some(t0 + ms(400)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(500)), (None, None)); // silenced — nothing scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renewal_re_emits_and_extends_the_deadline() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 0, Some(400));
|
||||||
|
assert!(e.poll(t0).0.is_some());
|
||||||
|
// A same-level renewal at t+300 re-emits (platform duration timers re-arm) and pushes the
|
||||||
|
// deadline to t+700 — so t+500 (past the ORIGINAL deadline) still rumbles.
|
||||||
|
e.wire_update(t0 + ms(300), 0, 100, 0, Some(400));
|
||||||
|
assert_eq!(e.poll(t0 + ms(300)).0, Some(cmd(0, 100, 0, 800)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(500)).0, None);
|
||||||
|
assert_eq!(e.poll(t0 + ms(700)).0, Some(cmd(0, 0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_stop_is_immediate_and_cancels_the_lease() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 2, 500, 500, Some(400));
|
||||||
|
assert!(e.poll(t0).0.is_some());
|
||||||
|
e.wire_update(t0 + ms(50), 2, 0, 0, Some(0));
|
||||||
|
assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(2, 0, 0, 0)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(600)), (None, None)); // no phantom expiry later
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_host_gets_the_uniform_staleness_bound() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 300, 0, None); // legacy: no TTL
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 300, 0, 2000)));
|
||||||
|
// The legacy 500 ms refresh keeps it alive…
|
||||||
|
e.wire_update(t0 + ms(500), 0, 300, 0, None);
|
||||||
|
assert_eq!(e.poll(t0 + ms(500)).0, Some(cmd(0, 300, 0, 2000)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(1400)).0, None); // 900 ms since last wire — inside the bound
|
||||||
|
// …and one second of silence cuts it, on every platform alike.
|
||||||
|
assert_eq!(e.poll(t0 + ms(1500)).0, Some(cmd(0, 0, 0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keepalive_rekicks_with_jitter_and_never_outlives_the_lease() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
e.set_quirks(
|
||||||
|
0,
|
||||||
|
ActuatorQuirks {
|
||||||
|
keepalive_ms: 40,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 200, Some(400));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 200, 800)));
|
||||||
|
// Keepalives at the quirk cadence, alternating the low LSB to defeat SDL's dedupe.
|
||||||
|
assert_eq!(e.poll(t0 + ms(40)).0, Some(cmd(0, 101, 200, 800)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(80)).0, Some(cmd(0, 100, 200, 800)));
|
||||||
|
// At the lease deadline the EXPIRY wins — a keepalive can never sustain an ended level.
|
||||||
|
assert_eq!(e.poll(t0 + ms(400)).0, Some(cmd(0, 0, 0, 0)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(440)), (None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quirk_registered_mid_rumble_starts_keepalives() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 0, Some(400));
|
||||||
|
assert!(e.poll(t0).0.is_some());
|
||||||
|
e.set_quirks(
|
||||||
|
0,
|
||||||
|
ActuatorQuirks {
|
||||||
|
keepalive_ms: 40,
|
||||||
|
min_pulse_ms: 0,
|
||||||
|
dedup_jitter: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// First poll schedules from `now`; the next cadence tick emits.
|
||||||
|
let (c, wake) = e.poll(t0 + ms(10));
|
||||||
|
assert_eq!(c, None);
|
||||||
|
assert_eq!(wake, Some(t0 + ms(50)));
|
||||||
|
assert_eq!(e.poll(t0 + ms(50)).0, Some(cmd(0, 100, 0, 800)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_pulse_floors_the_backstop() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
e.set_quirks(
|
||||||
|
0,
|
||||||
|
ActuatorQuirks {
|
||||||
|
keepalive_ms: 0,
|
||||||
|
min_pulse_ms: 5000,
|
||||||
|
dedup_jitter: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 0, Some(100));
|
||||||
|
assert_eq!(e.poll(t0).0, Some(cmd(0, 100, 0, 5000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_drain_silences_every_buzzing_pad_once() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
e.wire_update(t0, 0, 100, 0, Some(400));
|
||||||
|
e.wire_update(t0, 3, 0, 900, Some(400));
|
||||||
|
let _ = e.poll(t0);
|
||||||
|
let _ = e.poll(t0);
|
||||||
|
let a = e.close_drain().unwrap();
|
||||||
|
let b = e.close_drain().unwrap();
|
||||||
|
assert_eq!((a.pad, a.low, a.high), (0, 0, 0));
|
||||||
|
assert_eq!((b.pad, b.low, b.high), (3, 0, 0));
|
||||||
|
assert_eq!(e.close_drain(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stalled_embedder_wakes_to_one_current_command_not_a_backlog() {
|
||||||
|
let mut e = RumbleEngine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
// 20 renewals landed while the embedder was stalled — state, not a queue: exactly one
|
||||||
|
// command comes out, carrying the latest level.
|
||||||
|
for k in 0..20u64 {
|
||||||
|
e.wire_update(t0 + ms(k * 120), 0, 100 + k as u16, 0, Some(400));
|
||||||
|
}
|
||||||
|
let t = t0 + ms(20 * 120);
|
||||||
|
assert_eq!(e.poll(t).0, Some(cmd(0, 119, 0, 800)));
|
||||||
|
assert_eq!(e.poll(t).0, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_close_delivers_drain_zero_then_closed() {
|
||||||
|
let shared = std::sync::Arc::new(RumbleShared::new());
|
||||||
|
let feed = RumbleFeed(shared.clone());
|
||||||
|
feed.wire_update(1, 100, 0, Some(400));
|
||||||
|
assert_eq!(
|
||||||
|
shared.next_command(ms(100)).unwrap().unwrap(),
|
||||||
|
cmd(1, 100, 0, 800)
|
||||||
|
);
|
||||||
|
drop(feed); // demux ended
|
||||||
|
assert_eq!(
|
||||||
|
shared.next_command(ms(100)).unwrap().unwrap(),
|
||||||
|
cmd(1, 0, 0, 0)
|
||||||
|
);
|
||||||
|
assert_eq!(shared.next_command(ms(10)), Err(Closed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ pub(crate) struct WorkerArgs {
|
|||||||
pub(crate) frames: Arc<FrameChannel>,
|
pub(crate) frames: Arc<FrameChannel>,
|
||||||
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
pub(crate) audio_tx: SyncSender<AudioPacket>,
|
||||||
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
pub(crate) rumble_tx: SyncSender<RumbleUpdate>,
|
||||||
|
/// Feed half of the rumble policy engine — its `Drop` (demux task end) marks the engine
|
||||||
|
/// closed, so the command API always observes connection teardown.
|
||||||
|
pub(crate) rumble_feed: super::rumble::RumbleFeed,
|
||||||
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
pub(crate) hidout_tx: SyncSender<HidOutput>,
|
||||||
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
pub(crate) hdr_meta_tx: SyncSender<HdrMeta>,
|
||||||
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
pub(crate) host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||||
|
|||||||
+256
-190
@@ -197,6 +197,18 @@
|
|||||||
// own staleness heuristic for that update instead of a host-supplied deadline.
|
// own staleness heuristic for that update instead of a host-supplied deadline.
|
||||||
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
#define PUNKTFUNK_RUMBLE_NO_TTL 4294967295
|
||||||
|
|
||||||
|
// `flags` bit for [`punktfunk_connection_set_rumble_quirks`]: alternate the low motor's LSB on
|
||||||
|
// keepalive re-emits (imperceptible) so an SDL-class layer that no-ops identical values still
|
||||||
|
// writes the device — the Steam Deck's dedupe-defeat.
|
||||||
|
#define PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER 1
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// The uniform no-TTL-host staleness bound: a legacy host refreshes state every 500 ms, so two
|
||||||
|
// missed refreshes = quiet host → silence. Replaces the per-platform zoo (1.6 s / 60 s / 1.5 s /
|
||||||
|
// 1 s), and matches the ratio the Steam Deck ceiling shipped with.
|
||||||
|
#define LEGACY_STALE_MS 1000
|
||||||
|
#endif
|
||||||
|
|
||||||
// 16-byte AEAD authentication tag appended by GCM.
|
// 16-byte AEAD authentication tag appended by GCM.
|
||||||
#define TAG_LEN 16
|
#define TAG_LEN 16
|
||||||
|
|
||||||
@@ -328,11 +340,201 @@
|
|||||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
#define MAX_DATAGRAM_BYTES 2048
|
#define MAX_DATAGRAM_BYTES 2048
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||||
|
#define VIDEO_CAP_10BIT 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||||
|
#define VIDEO_CAP_HDR 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
|
||||||
|
// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a
|
||||||
|
// client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY
|
||||||
|
// when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC,
|
||||||
|
// **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays
|
||||||
|
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
|
||||||
|
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
|
||||||
|
// where the hardware allows).
|
||||||
|
#define VIDEO_CAP_444 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client consumes per-AU host-timing datagrams
|
||||||
|
// ([`HOST_TIMING_MAGIC`], 0xCF) — the host's capture→send duration per frame, letting the client
|
||||||
|
// split its `host+network` latency stage into `host` and `network`
|
||||||
|
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
|
||||||
|
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
|
||||||
|
// stage. Purely observability — never changes what the host encodes.
|
||||||
|
#define VIDEO_CAP_HOST_TIMING 8
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own
|
||||||
|
// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`]
|
||||||
|
// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session
|
||||||
|
// speed test burns thousands of video indexes that are invisible to every client-side gap detector
|
||||||
|
// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a
|
||||||
|
// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's
|
||||||
|
// encode loop own the video numbering outright (the wire-index contract
|
||||||
|
// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation
|
||||||
|
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
||||||
|
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
||||||
|
// reassembler would silently drop as stale.
|
||||||
|
#define VIDEO_CAP_PROBE_SEQ 16
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
||||||
|
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
||||||
|
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
||||||
|
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
||||||
|
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
||||||
|
#define HOST_CAP_GAMEPAD_STATE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
||||||
|
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
||||||
|
// advertise this.
|
||||||
|
#define CODEC_H264 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
|
||||||
|
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
|
||||||
|
#define CODEC_HEVC 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_codecs`] bit: the client can decode AV1.
|
||||||
|
#define CODEC_AV1 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN
|
||||||
|
// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR,
|
||||||
|
// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s
|
||||||
|
// precedence ladder**: it is selected only when the client also names it
|
||||||
|
// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec
|
||||||
|
// that needs a wired-LAN bitrate must never win a negotiation just because both ends support
|
||||||
|
// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin"
|
||||||
|
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
|
||||||
|
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
|
||||||
|
// version instead (plan §4.2).
|
||||||
|
#define CODEC_PYROWAVE 8
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||||
|
// default when a peer omits [`Welcome::chroma_format`].
|
||||||
|
#define CHROMA_IDC_420 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||||
|
#define CHROMA_IDC_444 3
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP colour-primaries code point: BT.709.
|
||||||
|
#define ColorInfo_CP_BT709 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP colour-primaries code point: BT.2020.
|
||||||
|
#define ColorInfo_CP_BT2020 9
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP transfer code point: BT.709.
|
||||||
|
#define ColorInfo_TRC_BT709 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP transfer code point: PQ (SMPTE ST.2084).
|
||||||
|
#define ColorInfo_TRC_PQ 16
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100).
|
||||||
|
#define ColorInfo_TRC_HLG 18
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP matrix code point: BT.709.
|
||||||
|
#define ColorInfo_MC_BT709 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance —
|
||||||
|
// no client decodes it.)
|
||||||
|
#define ColorInfo_MC_BT2020_NCL 9
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Rounds per batch — matches the connect-time [`clock_sync`].
|
// Rounds per batch — matches the connect-time [`clock_sync`].
|
||||||
#define ClockResync_ROUNDS 8
|
#define ClockResync_ROUNDS 8
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
|
#define MSG_RECONFIGURE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`Reconfigured`].
|
||||||
|
#define MSG_RECONFIGURED 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`RequestKeyframe`].
|
||||||
|
#define MSG_REQUEST_KEYFRAME 3
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`LossReport`].
|
||||||
|
#define MSG_LOSS_REPORT 4
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`SetBitrate`].
|
||||||
|
#define MSG_SET_BITRATE 5
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`BitrateChanged`].
|
||||||
|
#define MSG_BITRATE_CHANGED 6
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`RfiRequest`].
|
||||||
|
#define MSG_RFI_REQUEST 7
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ProbeRequest`].
|
||||||
|
#define MSG_PROBE_REQUEST 32
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ProbeResult`].
|
||||||
|
#define MSG_PROBE_RESULT 33
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClockProbe`].
|
||||||
|
#define MSG_CLOCK_PROBE 48
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`ClockEcho`].
|
||||||
|
#define MSG_CLOCK_ECHO 49
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||||
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||||
@@ -421,53 +623,6 @@
|
|||||||
#define HOST_TIMING_MAGIC 207
|
#define HOST_TIMING_MAGIC 207
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
|
||||||
#define VIDEO_CAP_10BIT 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
|
||||||
#define VIDEO_CAP_HDR 2
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
|
|
||||||
// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a
|
|
||||||
// client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY
|
|
||||||
// when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC,
|
|
||||||
// **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays
|
|
||||||
// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of
|
|
||||||
// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine
|
|
||||||
// where the hardware allows).
|
|
||||||
#define VIDEO_CAP_444 4
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_caps`] bit: the client consumes per-AU host-timing datagrams
|
|
||||||
// ([`HOST_TIMING_MAGIC`], 0xCF) — the host's capture→send duration per frame, letting the client
|
|
||||||
// split its `host+network` latency stage into `host` and `network`
|
|
||||||
// (design/stats-unification.md Phase 2). The host emits 0xCF ONLY when this bit is set (an older
|
|
||||||
// host ignores it and simply never sends any); a client that doesn't set it keeps the combined
|
|
||||||
// stage. Purely observability — never changes what the host encodes.
|
|
||||||
#define VIDEO_CAP_HOST_TIMING 8
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_caps`] bit: the client's reassembler keeps **speed-test probe filler in its own
|
|
||||||
// frame-index space** (a second reassembly window keyed on the [`crate::packet::FLAG_PROBE`]
|
|
||||||
// user-flag), so probe bursts no longer consume video `frame_index`es. Without this, a mid-session
|
|
||||||
// speed test burns thousands of video indexes that are invisible to every client-side gap detector
|
|
||||||
// (probe frames are filtered before the pump sees them) — the first real AU afterwards reads as a
|
|
||||||
// phantom multi-thousand-frame loss (spurious freeze + a nonsense RFI). It also lets the host's
|
|
||||||
// encode loop own the video numbering outright (the wire-index contract
|
|
||||||
// [`crate::packet::Packetizer::packetize_each`] documents), which reference-frame invalidation
|
|
||||||
// depends on. The host runs mid-session probe bursts ONLY against clients that set this bit — an
|
|
||||||
// older client gets a declined (zeroed) [`ProbeResult`] instead of a measurement its single-window
|
|
||||||
// reassembler would silently drop as stale.
|
|
||||||
#define VIDEO_CAP_PROBE_SEQ 16
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
// QUIC application error code a punktfunk/1 client closes the control connection with on a
|
||||||
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
|
||||||
@@ -487,58 +642,6 @@
|
|||||||
#define APP_EXITED_CLOSE_CODE 82
|
#define APP_EXITED_CLOSE_CODE 82
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Welcome::host_caps`] bit: the host applies [`InputKind::GamepadState`]
|
|
||||||
// (crate::input::InputKind::GamepadState) snapshot events — full per-pad state with a reorder
|
|
||||||
// sequence number. A capable client then sends gamepad state as snapshots (idempotent on the
|
|
||||||
// lossy datagram plane, periodically refreshed) instead of the fragile per-transition
|
|
||||||
// button/axis events; toward a host that doesn't set the bit it keeps the legacy events.
|
|
||||||
#define HOST_CAP_GAMEPAD_STATE 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
|
|
||||||
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
|
|
||||||
// advertise this.
|
|
||||||
#define CODEC_H264 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_codecs`] bit: the client can decode H.265 / HEVC — the default every existing
|
|
||||||
// build produces and decodes (a peer that omits [`Hello::video_codecs`] is treated as HEVC-only).
|
|
||||||
#define CODEC_HEVC 2
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_codecs`] bit: the client can decode AV1.
|
|
||||||
#define CODEC_AV1 4
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// [`Hello::video_codecs`] bit: the client can decode **PyroWave** — the opt-in wired-LAN
|
|
||||||
// intra-only wavelet codec (design/pyrowave-codec-plan.md; 100–400 Mbps class, 8-bit SDR,
|
|
||||||
// every frame independently decodable). Deliberately **absent from [`resolve_codec`]'s
|
|
||||||
// precedence ladder**: it is selected only when the client also names it
|
|
||||||
// [`Hello::preferred_codec`] (or the host operator forces the advertisement mask) — a codec
|
|
||||||
// that needs a wired-LAN bitrate must never win a negotiation just because both ends support
|
|
||||||
// it. The bit means "PyroWave bitstream as of the punktfunk-vendored pin"
|
|
||||||
// (`crates/pyrowave-sys/vendor/pyrowave/PUNKTFUNK-VENDOR.txt`): upstream has no bitstream
|
|
||||||
// version field, so a vendored bump that changes the bitstream bumps the punktfunk protocol
|
|
||||||
// version instead (plan §4.2).
|
|
||||||
#define CODEC_PYROWAVE 8
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
|
||||||
// default when a peer omits [`Welcome::chroma_format`].
|
|
||||||
#define CHROMA_IDC_420 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
|
||||||
#define CHROMA_IDC_444 3
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||||
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||||
@@ -551,61 +654,6 @@
|
|||||||
#define HELLO_LAUNCH_MAX 128
|
#define HELLO_LAUNCH_MAX 128
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`Reconfigure`] (first byte after the magic).
|
|
||||||
#define MSG_RECONFIGURE 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`Reconfigured`].
|
|
||||||
#define MSG_RECONFIGURED 2
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`RequestKeyframe`].
|
|
||||||
#define MSG_REQUEST_KEYFRAME 3
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`LossReport`].
|
|
||||||
#define MSG_LOSS_REPORT 4
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`SetBitrate`].
|
|
||||||
#define MSG_SET_BITRATE 5
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`BitrateChanged`].
|
|
||||||
#define MSG_BITRATE_CHANGED 6
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`RfiRequest`].
|
|
||||||
#define MSG_RFI_REQUEST 7
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`ProbeRequest`].
|
|
||||||
#define MSG_PROBE_REQUEST 32
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`ProbeResult`].
|
|
||||||
#define MSG_PROBE_RESULT 33
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`ClockProbe`].
|
|
||||||
#define MSG_CLOCK_PROBE 48
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// Type byte of [`ClockEcho`].
|
|
||||||
#define MSG_CLOCK_ECHO 49
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Type byte of [`PairRequest`].
|
// Type byte of [`PairRequest`].
|
||||||
#define MSG_PAIR_REQUEST 16
|
#define MSG_PAIR_REQUEST 16
|
||||||
@@ -626,42 +674,6 @@
|
|||||||
#define MSG_PAIR_RESULT 19
|
#define MSG_PAIR_RESULT 19
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP colour-primaries code point: BT.709.
|
|
||||||
#define ColorInfo_CP_BT709 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP colour-primaries code point: BT.2020.
|
|
||||||
#define ColorInfo_CP_BT2020 9
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP transfer code point: BT.709.
|
|
||||||
#define ColorInfo_TRC_BT709 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP transfer code point: PQ (SMPTE ST.2084).
|
|
||||||
#define ColorInfo_TRC_PQ 16
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100).
|
|
||||||
#define ColorInfo_TRC_HLG 18
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP matrix code point: BT.709.
|
|
||||||
#define ColorInfo_MC_BT709 1
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
|
||||||
// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance —
|
|
||||||
// no client decodes it.)
|
|
||||||
#define ColorInfo_MC_BT2020_NCL 9
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
// Consecutive no-output AUs that force a keyframe request. ~50 ms at 60 Hz — long enough not to fire
|
||||||
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
// on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes
|
||||||
// almost immediately instead of never.
|
// almost immediately instead of never.
|
||||||
@@ -717,6 +729,12 @@
|
|||||||
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
// The client's wire (protocol) version does not match the host's — one side needs updating.
|
||||||
#define WIRE_VERSION_CLOSE_CODE 103
|
#define WIRE_VERSION_CLOSE_CODE 103
|
||||||
|
|
||||||
|
// Minimum supported multiplier (renders under native, upscaled on present).
|
||||||
|
#define MIN_SCALE 0.5
|
||||||
|
|
||||||
|
// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
|
||||||
|
#define MAX_SCALE 4.0
|
||||||
|
|
||||||
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
// Stable C ABI status codes. `Ok` is 0; all errors are negative so callers can
|
||||||
// test `rc < 0`. Do not renumber existing variants — only append.
|
// test `rc < 0`. Do not renumber existing variants — only append.
|
||||||
enum PunktfunkStatus
|
enum PunktfunkStatus
|
||||||
@@ -1120,6 +1138,10 @@ typedef struct {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
|
||||||
|
// users reason about. Shared so every client's list stays identical.
|
||||||
|
#define PRESETS { 0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, }
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#endif // __cplusplus
|
#endif // __cplusplus
|
||||||
@@ -1600,6 +1622,50 @@ PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c,
|
|||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Pull the next EFFECTIVE rumble command from the shared policy engine — the uniform replacement
|
||||||
|
// for per-platform rumble policy. Unlike [`punktfunk_connection_next_rumble2`], the caller never
|
||||||
|
// sees a TTL and never owns a deadline: the engine emits the level on every wire update (renewals
|
||||||
|
// re-arm duration-parameterized APIs), an explicit zero at lease expiry / legacy-host staleness
|
||||||
|
// (a uniform 1 s) / connection close, and any keepalives declared via
|
||||||
|
// [`punktfunk_connection_set_rumble_quirks`]. Apply commands verbatim: `(0, 0)` = stop now;
|
||||||
|
// non-zero = run at this level, with `*backstop_ms` as the safety-net duration for platform APIs
|
||||||
|
// that take one (explicit-stop APIs ignore it; it is `0` on stop commands).
|
||||||
|
// [`PunktfunkStatus::NoFrame`] on timeout; [`PunktfunkStatus::Closed`] once the session ended AND
|
||||||
|
// every close-drain stop was delivered — silence all actuators on it.
|
||||||
|
//
|
||||||
|
// An embedder uses EITHER this or `next_rumble`/`next_rumble2` for a connection's lifetime,
|
||||||
|
// never both (they consume the same wire plane).
|
||||||
|
//
|
||||||
|
// # 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_rumble_cmd(PunktfunkConnection *c,
|
||||||
|
uint16_t *pad,
|
||||||
|
uint16_t *low,
|
||||||
|
uint16_t *high,
|
||||||
|
uint32_t *backstop_ms,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Declare a physical actuator's quirks for wire pad `pad` — how a platform parameterizes the
|
||||||
|
// shared rumble policy engine instead of forking it (typically called at controller attach).
|
||||||
|
// `keepalive_ms`: re-emit an unchanged non-zero level at this cadence for actuators whose
|
||||||
|
// hardware output decays between wire renewals (Steam Deck ≈ 40, DualSense-over-BT raw HID
|
||||||
|
// ≈ 900); `0` = none. `min_pulse_ms`: floor for `backstop_ms` on non-zero commands. `flags`:
|
||||||
|
// [`PUNKTFUNK_RUMBLE_QUIRK_DEDUP_JITTER`]. All-zero (the initial state) describes a well-behaved
|
||||||
|
// actuator.
|
||||||
|
//
|
||||||
|
// # Safety
|
||||||
|
// `c` is a valid connection handle. Callable from any thread.
|
||||||
|
PunktfunkStatus punktfunk_connection_set_rumble_quirks(PunktfunkConnection *c,
|
||||||
|
uint16_t pad,
|
||||||
|
uint16_t keepalive_ms,
|
||||||
|
uint16_t min_pulse_ms,
|
||||||
|
uint32_t flags);
|
||||||
|
#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