From 13b1f36d4ae52213a1c207cb8f92cbb4ddef7e6a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 14:07:32 +0200 Subject: [PATCH] feat(core,clients): one rumble policy engine for every platform (rumble root fix D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../io/unom/punktfunk/kit/GamepadFeedback.kt | 28 +- clients/android/native/src/feedback.rs | 48 +- .../Connection/PunktfunkConnection.swift | 28 + .../Gamepad/GamepadFeedback.swift | 27 +- .../PunktfunkKit/Gamepad/RumbleRenderer.swift | 72 +-- .../PunktfunkKitTests/RumbleTuningTests.swift | 53 +- crates/pf-client-core/src/gamepad.rs | 180 ++---- crates/punktfunk-core/src/abi.rs | 99 ++++ crates/punktfunk-core/src/client/mod.rs | 39 ++ crates/punktfunk-core/src/client/pump.rs | 4 + crates/punktfunk-core/src/client/rumble.rs | 530 ++++++++++++++++++ crates/punktfunk-core/src/client/worker.rs | 3 + include/punktfunk_core.h | 446 ++++++++------- 13 files changed, 1083 insertions(+), 474 deletions(-) create mode 100644 crates/punktfunk-core/src/client/rumble.rs diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt index 5bdd2a44..d7a4af37 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadFeedback.kt @@ -52,9 +52,6 @@ class GamepadFeedback( const val TAG_PLAYER_LEDS: Byte = 0x02 const val TAG_TRIGGER: Byte = 0x03 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). */ @@ -95,19 +92,19 @@ class GamepadFeedback( while (running) { val ev = NativeBridge.nativeNextRumble(handle) if (ev < 0L) continue // timeout / closed - // ev bits 49..52 = wire pad index; bit 48 = has a v2 lease; bits 32..47 = ttl_ms; - // 16..31 = low; 0..15 = high. The lease flag is out-of-band, so any ttl_ms (incl. - // 0xFFFF) is a real lease — no in-band sentinel. No lease (legacy host) → the prior - // long one-shot. + // ev bits 49..52 = wire pad index; bits 32..47 = backstop duration (ms); + // 16..31 = low; 0..15 = high. These are EFFECTIVE commands from the core's shared + // rumble policy engine — it owns every lease/staleness/close decision (uniform + // 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 hasLease = ((ev ushr 48) and 0x1L) == 0x1L - val ttl = ((ev ushr 32) and 0xFFFF).toInt() - val durationMs = if (hasLease) ttl.toLong() else LEGACY_RUMBLE_MS + val backstopMs = ((ev ushr 32) and 0xFFFF) renderRumble( pad, ((ev ushr 16) and 0xFFFF).toInt(), (ev and 0xFFFF).toInt(), - durationMs, + backstopMs, ) } }, "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), - * addressed to wire pad [pad]. `durationMs` is the host's v2 envelope TTL — the one-shot self- - * terminates after it unless the host renews, so a lost stop (or a dead host) silences at the - * lease instead of the old fixed 60 s. Against a legacy host it is [LEGACY_RUMBLE_MS]. + * addressed to wire pad [pad]. `durationMs` is the engine command's backstop — the one-shot's + * self-termination net under a stalled poll thread; the engine emits explicit zero commands at + * 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) { - 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 // 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 diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 7b0c8685..6833666e 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -24,14 +24,19 @@ const TAG_PLAYER_LEDS: u8 = 0x02; const TAG_TRIGGER: u8 = 0x03; const TAG_HID_RAW: u8 = 0x05; -/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update. -/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bit 48 = "has a v2 lease", -/// bits 32..47 = `ttl_ms`, bits 16..31 = `low`, bits 0..15 = `high` (`low`/`high` 0..=0xFFFF, `0/0` = -/// stop). The lease flag is out-of-band so ANY 16-bit `ttl_ms` — including 0xFFFF — is unambiguous (no -/// in-band sentinel to collide with a real 65535 ms lease). No lease (legacy host) → bit 48 clear, and -/// Kotlin falls back to its long one-shot. `-1` on timeout / session closed (all packed values are -/// positive, so `-1` stays unambiguous). Kotlin routes the update back to the controller holding that -/// wire `pad` index (multi-pad rumble). Run from a Kotlin poll thread. +/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next EFFECTIVE +/// rumble command from the core's shared policy engine (`design/rumble-root-fix.md` §D). The +/// engine owns ALL rumble policy — v2 lease expiry, legacy-host staleness (a uniform 1 s, ending +/// the old 60 s Android exposure), connection-close drain zeros — so Kotlin applies commands +/// verbatim: `(0, 0)` = cancel now, non-zero = one-shot at this level. +/// +/// Returns a packed positive long: bits 49..52 = wire `pad` index (0..15), bits 32..47 = the +/// 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] pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( _env: JNIEnv, @@ -43,24 +48,17 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble( if handle == 0 { return -1; } - // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_ttl is &self on - // the Sync connector — safe alongside the decode/audio/input threads. Kotlin stops these poll - // threads (and joins them — unbounded) before nativeClose frees the handle. + // SAFETY: live handle per the nativeConnect/nativeClose contract; next_rumble_command is + // &self on the Sync connector — safe alongside the decode/audio/input threads. Kotlin + // stops these poll threads (and joins them — unbounded) before nativeClose frees the + // handle. let h = unsafe { &*(handle as *const SessionHandle) }; - match h.client.next_rumble_ttl(PULL_TIMEOUT) { - Ok((pad, low, high, ttl)) => { - // The reorder gate already ran in the core, so this update is fresh. Encode the - // Option out-of-band: a real lease sets bit 48 and carries ttl_ms verbatim. The pad - // index rides above the lease flag (bits 49..52), keeping the whole word positive. - let (lease_flag, ttl_bits) = match ttl { - 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) + match h.client.next_rumble_command(PULL_TIMEOUT) { + Ok(cmd) => { + (jlong::from(cmd.pad & 0xF) << 49) + | (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32) + | (jlong::from(cmd.low) << 16) + | jlong::from(cmd.high) } Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index e3adc481..067a1a4c 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -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 /// the real controller (GCDeviceLight, GCControllerPlayerIndex, /// GCDualSenseAdaptiveTrigger). Only a `.dualSense` session emits these. diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift index 5cf9a77a..9f32ceee 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -155,21 +155,18 @@ public final class GamepadFeedback { // 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 is idempotent state, so drain the plane DRY and apply only the newest - // level PER PAD. The old one-datagram-per-cycle shape let a burst outpace the - // ~125 Hz drain: levels rendered up to ~130 ms late through the core's 16-deep - // queue, and its drop-newest overflow could shed a stop while stale nonzero - // states queued ahead of it — buzzing until the host's next 500 ms refresh. - var newestByPad: [UInt8: (low: UInt16, high: UInt16, ttl: UInt32)] = [:] + // Rumble arrives as EFFECTIVE commands from the core's shared policy engine + // (design/rumble-root-fix.md §D): the engine owns leases, legacy staleness, + // and close-drain zeros, and its per-pad mailbox already coalesces — a + // stalled drain wakes to ONE current-level command per pad, and a stop can + // never be shed by a queue. Apply verbatim, in order. var rumbleBurst = 0 while rumbleBurst < 64, !flag.isStopped, - let r = try connection.nextRumble2(timeoutMs: 0) { - newestByPad[UInt8(truncatingIfNeeded: r.pad)] = (r.low, r.high, r.ttlMs) + let c = try connection.nextRumbleCommand(timeoutMs: 0) { + self?.routeRumble( + pad: UInt8(truncatingIfNeeded: c.pad), low: c.low, high: c.high) 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 // per-frame LED/trigger reports) can't spin here or block stop() past one cycle. 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. - 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] } - 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 // 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. - if pad == 0 { deviceRumble?.apply(low: low, high: high, ttlMs: ttlMs) } + if pad == 0 { deviceRumble?.apply(low: low, high: high) } } private func withRouting(_ body: () -> R) -> R { diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index 9d3fa2a6..563a905f 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -23,23 +23,6 @@ enum RumbleTuning { /// the churn that lost stops inside CoreHaptics. Newest level wins when the window opens; /// zero is never throttled. static let minRebakeSeconds: TimeInterval = 0.025 - /// Session watchdog: silence the motors when no wire command arrived for this long. This is - /// the **legacy-host fallback only** — an old host sends no self-termination lease, so its - /// periodic re-send (every 500 ms) is the sole liveness signal and 3 vanished refreshes means - /// the channel or host died while audible. A v2 host instead supplies a per-command TTL (see - /// [`leaseSeconds`]); that deadline supersedes this watchdog. - static let sessionStaleSeconds: TimeInterval = 1.6 - - /// The legacy no-lease sentinel a v2 `ttl_ms` carries for an old host (mirrors the C ABI's - /// `PUNKTFUNK_RUMBLE_NO_TTL`). `UInt32.max` by construction. - static let noTTL: UInt32 = .max - - /// Interpret a wire TTL (ms) from a rumble update: `nil` for the legacy no-lease sentinel - /// ([`noTTL`]) — the renderer falls back to [`sessionStaleSeconds`] — else the self-termination - /// lease in seconds (render the level for at most this long unless the host renews it). - static func leaseSeconds(ttlMs: UInt32) -> TimeInterval? { - ttlMs == noTTL ? nil : TimeInterval(ttlMs) / 1000 - } /// Levels closer than this (≈0.4 % of full scale) are the same level — an identical host /// refresh must never rebuild a player. static let levelEpsilon: Float = 1.0 / 256.0 @@ -110,13 +93,15 @@ enum RumbleTuning { /// `@unchecked Sendable` is sound because every property is read and written only inside /// `queue` closures — the serial queue is the synchronization. final class RumbleRenderer: @unchecked Sendable { - /// What an un-refreshed nonzero target means. A live session ties motor life to wire - /// liveness (the host refreshes state every 500 ms); the controller test panel holds a - /// slider level indefinitely. + /// Who ends an un-refreshed nonzero target. Session mode applies the core policy engine's + /// commands verbatim — the engine (punktfunk-core `client/rumble.rs`) owns every lease, + /// 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 { - let staleAfter: TimeInterval? - static let session = Policy(staleAfter: RumbleTuning.sessionStaleSeconds) - static let manual = Policy(staleAfter: nil) + static let session = Policy() + static let manual = Policy() } /// 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 low: 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 lastCommand = DispatchTime(uptimeNanoseconds: 0) - /// The v2 envelope lease: the active level is authorized until here unless the host renews it - /// (`tick` silences at the deadline). `nil` against a legacy host (no lease — the - /// `sessionStaleSeconds` watchdog is the backstop) and while silent. - private var envelopeDeadline: DispatchTime? /// Runs while anything is (or should be) audible: staleness watchdog, segment re-arm, /// throttled-level catch-up, engine rebuild after a reset, HID keepalive. Nil while silent, /// so an idle controller costs no timer wakeups and no radio traffic. @@ -247,17 +228,9 @@ final class RumbleRenderer: @unchecked Sendable { /// against a legacy host (no lease → the staleness watchdog is the backstop). Renewals at an /// unchanged level extend the deadline before the idempotence guard, so a held rumble never /// lapses mid-effect. - func apply(low lowAmp: UInt16, high highAmp: UInt16, ttlMs: UInt32 = RumbleTuning.noTTL) { + func apply(low lowAmp: UInt16, high highAmp: UInt16) { queue.async { - self.lastCommand = .now() let active = lowAmp != 0 || highAmp != 0 - // v2 lease: a nonzero level gets an explicit deadline; a stop or a legacy update clears - // it. Set BEFORE the idempotence guard so an identical renewal still extends the lease. - if let lease = RumbleTuning.leaseSeconds(ttlMs: ttlMs), active { - self.envelopeDeadline = .now() + lease - } else { - self.envelopeDeadline = nil - } if active != self.wasActive { self.wasActive = active log.debug( @@ -275,7 +248,6 @@ final class RumbleRenderer: @unchecked Sendable { self.ticker?.cancel() self.ticker = nil self.target = (0, 0) - self.envelopeDeadline = nil self.wasActive = false self.teardown() self.closeHID() @@ -331,25 +303,11 @@ final class RumbleRenderer: @unchecked Sendable { 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() { - 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() } diff --git a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift index 36b072bd..8bbf1a4e 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RumbleTuningTests.swift @@ -52,13 +52,6 @@ final class RumbleTuningTests: XCTestCase { 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 /// 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. @@ -75,45 +68,22 @@ final class RumbleTuningTests: XCTestCase { renderer.stop() } - func testLeaseSecondsInterpretsWireTTL() { - // The legacy no-lease sentinel → nil (fall back to the staleness watchdog). - XCTAssertNil(RumbleTuning.leaseSeconds(ttlMs: RumbleTuning.noTTL)) - XCTAssertEqual(RumbleTuning.noTTL, UInt32.max) - // A real lease → its duration in seconds (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() { + /// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at + /// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only + /// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge. + func testZeroCommandSilencesAndTeardownDoesNotDeadlock() { let renderer = RumbleRenderer(policy: .session) renderer.retarget(nil) - // A 100 ms lease, then no renewal — the ticker (50 ms) must silence it on its own. - renderer.apply(low: 0x8000, high: 0x8000, ttlMs: 100) - Thread.sleep(forTimeInterval: 0.3) - // No assertion on private state; this exercises the expiry path + serial-queue teardown + renderer.apply(low: 0x8000, high: 0x8000) + Thread.sleep(forTimeInterval: 0.1) + renderer.apply(low: 0, high: 0) + Thread.sleep(forTimeInterval: 0.1) + // No assertion on private state; this exercises the stop path + serial-queue teardown // without deadlock (the ticker fires on the same queue stop() sync-hops onto). renderer.stop() } func testTuningRelationsTheDesignDependsOn() { - // 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 // segment boundary and gap. XCTAssertGreaterThanOrEqual( @@ -123,9 +93,8 @@ final class RumbleTuningTests: XCTestCase { // The rebake throttle must be far under the host refresh period, or refreshed level // changes would queue behind it; and under a frame at 30 fps so ramps stay smooth. XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30) - // The ticker (which lands throttled levels) must outpace the HID keepalive and the - // watchdog, or those deadlines could be overshot by a full period. + // The ticker (which lands throttled levels) must outpace the HID keepalive, or its + // deadline could be overshot by a full period. XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds) - XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds) } } diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index a08526f3..a0a5eac3 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -31,7 +31,7 @@ //! //! 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::input::{gamepad as wire, InputEvent, InputKind}; 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). const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); -/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's -/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated -/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the -/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as -/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz; -/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see -/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is -/// left untouched. -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; +/// Steam Deck actuator-decay keepalive cadence, declared to the core's rumble policy engine as an +/// [`ActuatorQuirks`] at slot open. The Deck's built-in actuator decays inside SDL's ~2 s internal +/// rumble resend (`SDL_RUMBLE_RESEND_MS`) and SDL short-circuits an identical `set_rumble` value +/// to a no-op device write — so a steady level is felt as a periodic pulse without sub-decay +/// re-kicks; 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. The engine owns the +/// re-kick timing, the 1-LSB dedupe-defeat jitter, and every staleness/lease bound — this worker +/// only applies the commands it emits (`design/rumble-root-fix.md` §D). +const DECK_RUMBLE_KEEPALIVE_MS: u16 = 40; /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple /// `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, - /// 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, - /// 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, - /// 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 /// 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 @@ -683,7 +647,6 @@ struct Slot { /// close lift a click held across detach/unplug. held_clicks: [bool; 2], last_accel: [i16; 3], - rumble: RumbleState, } impl Slot { @@ -699,7 +662,6 @@ impl Slot { surface_last: [(0, 0, false); 2], held_clicks: [false; 2], last_accel: [0; 3], - rumble: RumbleState::default(), } } @@ -928,6 +890,20 @@ impl Worker { // uses the session-default kind. if let Some(c) = &self.attached { 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)"); 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 /// already gone (unplug). 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() { Self::flush_slot(&c, &mut self.slots[i]); // 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. - /// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its - /// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single - /// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the - /// actuator physically fed. The SDL duration is the host's envelope TTL (a lease continuously - /// refreshed by renewals, so a sustained rumble never dies mid-effect and an abandoned one - /// 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, deck: bool) { - let dur_ms: u32 = if slot.rumble.ttl_ms == 0 { - 1_500 // legacy host: no lease — keep the proven duration + /// Hand one policy-engine command to SDL on a slot's pad, verbatim. The core engine owns all + /// rumble policy — leases, legacy-host staleness, the Deck keepalive + its dedupe-defeat + /// jitter (declared as quirks at slot open) — so this worker keeps no rumble state at all. + /// `backstop_ms` becomes the SDL duration: the hardware-level net under a stalled worker + /// thread (the engine emits explicit zeros at every policy stop, so it is never the stop + /// mechanism). + fn issue_rumble(slot: &mut Slot, low: u16, high: u16, backstop_ms: u32) { + let dur_ms: u32 = if (low, high) == (0, 0) { + 100 // a stop takes effect immediately; the duration is irrelevant } else { - // Floor the lease so a jittered renewal (or the ~40 ms Deck re-kick) can never gap the - // actuator between SDL writes. - (slot.rumble.ttl_ms as u32).max(DECK_RUMBLE_KEEPALIVE_MS as u32 * 4) + backstop_ms.max(160) // floor: a jittered renewal can never gap the actuator }; - 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 // 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. - match slot.pad.set_rumble(out_low, out_high, dur_ms) { + match slot.pad.set_rumble(low, high, dur_ms) { Err(e) => { tracing::warn!(pad = slot.index, low, high, error = %e, "rumble: SDL set_rumble failed") } 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 / /// 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 - /// carries a TTL the host renews while the level holds and lets expire when it stops, so the - /// actuator's divergence from the host's intent is bounded by the wire, not by a client guess. - /// A legacy host (`ttl == None`) has no lease — the pad falls back to SDL's own 1.5 s duration - /// expiry as before. + /// thread is their single consumer. Rumble arrives as EFFECTIVE commands from the core's + /// shared policy engine, which already applied every policy — v2 lease expiry, legacy-host + /// staleness, the Deck actuator keepalive + jitter (via the quirks declared at slot open), + /// and connection-close drain zeros — so this worker applies commands verbatim and keeps no + /// rumble state of its own (`design/rumble-root-fix.md` §D). fn render_feedback(&mut self) { let Some(connector) = self.attached.clone() else { return; }; - // Rumble envelopes (0xCA) → the slot holding that wire pad index. An update for an index - // with no live slot (a pad that just unplugged) is dropped. - while let Ok((pad, low, high, ttl)) = connector.next_rumble_ttl(Duration::ZERO) { - if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == pad) { - let deck = slot.pref == GamepadPref::SteamDeck; - slot.rumble.ttl_ms = ttl.unwrap_or(0); - // A v2 lease sets an explicit client-side deadline; a legacy update clears it and - // leans on SDL's own duration expiry (unchanged behaviour). - 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); + // Engine commands → the slot holding that wire pad index. A command for an index with no + // live slot (a pad that just unplugged) is dropped. The loop ends on NoFrame (drained + // dry this tick) or Closed (session over — the engine delivered its close-drain zeros + // first; the physical silence backstop is in `close_slot_at`). + while let Ok(cmd) = connector.next_rumble_command(Duration::ZERO) { + if let Some(slot) = self.slots.iter_mut().find(|s| s.index as u16 == cmd.pad) { + Self::issue_rumble(slot, cmd.low, cmd.high, cmd.backstop_ms); } } // HID output (lightbar / player LEDs / adaptive triggers) → the slot on that wire index. diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 7a6a0e1e..f6df680b 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -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) /// 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 diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 8f88a2c4..1cf0a394 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -28,10 +28,12 @@ mod planes; mod probe; mod pump; mod recovery; +mod rumble; mod worker; pub use self::planes::AudioPacket; pub use self::probe::ProbeOutcome; +pub use self::rumble::{ActuatorQuirks, RumbleCommand}; use self::control::{CtrlRequest, Negotiated}; use self::frame_channel::{DecodeLatAcc, FrameChannel, FramePop}; @@ -75,6 +77,10 @@ pub struct NativeClient { frames: Arc, audio: Mutex>, rumble: Mutex>, + /// 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, /// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams. hidout: Mutex>, /// 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 (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::(AUDIO_QUEUE); let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::(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::(HIDOUT_QUEUE); let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::(HDR_META_QUEUE); let (host_timing_tx, host_timing_rx) = @@ -366,6 +374,7 @@ impl NativeClient { frames: frame_chan_w, audio_tx, rumble_tx, + rumble_feed, hidout_tx, hdr_meta_tx, host_timing_tx, @@ -401,6 +410,7 @@ impl NativeClient { frames: frame_chan, audio: Mutex::new(audio_rx), rumble: Mutex::new(rumble_rx), + rumble_sched, hidout: Mutex::new(hidout_rx), hdr_meta: Mutex::new(hdr_meta_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 { + 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 /// 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 diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index d6f2de1f..704b4cb7 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -40,6 +40,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { frames, audio_tx, rumble_tx, + rumble_feed, hidout_tx, hdr_meta_tx, host_timing_tx, @@ -578,7 +579,10 @@ pub(super) async fn run_pump(args: WorkerArgs) { }; if fresh { 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)); + rumble_feed.wire_update(u.pad, u.low, u.high, ttl); } } } diff --git a/crates/punktfunk-core/src/client/rumble.rs b/crates/punktfunk-core/src/client/rumble.rs new file mode 100644 index 00000000..e3f024d9 --- /dev/null +++ b/crates/punktfunk-core/src/client/rumble.rs @@ -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, + /// 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, + /// A wire update landed since the last emit (level change OR renewal — renewals re-emit). + dirty: bool, + next_keepalive: Option, + /// 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, 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, + ) { + 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, Option) { + let mut wake: Option = 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 { + 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, + 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); + +impl RumbleFeed { + pub(crate) fn wire_update(&self, pad: u16, low: u16, high: u16, ttl_ms: Option) { + 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, 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)); + } +} diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index f706c7c0..b407acd2 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -27,6 +27,9 @@ pub(crate) struct WorkerArgs { pub(crate) frames: Arc, pub(crate) audio_tx: SyncSender, pub(crate) rumble_tx: SyncSender, + /// 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, pub(crate) hdr_meta_tx: SyncSender, pub(crate) host_timing_tx: SyncSender, diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index c93ff526..a15253fd 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -197,6 +197,18 @@ // own staleness heuristic for that update instead of a host-supplied deadline. #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. #define TAG_LEN 16 @@ -328,11 +340,201 @@ // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. #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) // Rounds per batch — matches the connect-time [`clock_sync`]. #define ClockResync_ROUNDS 8 #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) // 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), @@ -421,53 +623,6 @@ #define HOST_TIMING_MAGIC 207 #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) // 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 @@ -487,58 +642,6 @@ #define APP_EXITED_CLOSE_CODE 82 #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) // 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). @@ -551,61 +654,6 @@ #define HELLO_LAUNCH_MAX 128 #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) // Type byte of [`PairRequest`]. #define MSG_PAIR_REQUEST 16 @@ -626,42 +674,6 @@ #define MSG_PAIR_RESULT 19 #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 // on a one-frame decoder hiccup, short enough that a lost initial IDR (or a mid-GOP join) unfreezes // 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. #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 // test `rc < 0`. Do not renumber existing variants — only append. 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 extern "C" { #endif // __cplusplus @@ -1600,6 +1622,50 @@ PunktfunkStatus punktfunk_connection_next_rumble2(PunktfunkConnection *c, uint32_t timeout_ms); #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) // 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