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_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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user