forked from unom/punktfunk
Rumble was level-triggered, unbounded state on a lossy channel: a non-zero level meant "buzz until further notice", healed only by the host re-sending state every 500 ms, and every client guessed when the host had died with its own magic timeout (SDL 1.5 s, Apple 1.6 s, Android up to 60 s). A lost stop, a reordered start, or a dead host could drone the motor for seconds. Make "stuck rumble" inexpressible on the wire. The 0xCA datagram grows a length-tolerant tail — [u8 seq][u16 ttl_ms] — so it self-terminates: the host authorizes a level for at most ttl_ms and renews it (~120 ms) while it holds, letting an abandoned one lapse client-side. seq is a per-pad wrapping reorder gate (reusing GamepadSnapshot::seq_newer) so a reordered stale start can't re-light a stopped motor. Decoders read the first 7 bytes as a plain level and ignore the tail, so no wire-version bump: an old client renders a new host's levels, and a new client falls back to its prior staleness heuristic against an old host (ttl = None). All four generation pairings render correctly. - core: encode_rumble_datagram_v2 / decode_rumble_envelope (datagram.rs); the client demux applies the seq gate then forwards (pad, low, high, Option<ttl>); next_rumble is unchanged (drops ttl), next_rumble_ttl keeps it; ABI adds punktfunk_connection_next_rumble2 + PUNKTFUNK_RUMBLE_NO_TTL, ABI_VERSION 4->5 (WIRE_VERSION unchanged — the tail is backward-compatible). - host (punktfunk1.rs): the flat 500 ms refresh becomes a renewal loop that bumps seq + stamps a fresh TTL on active pads and drains a short post-stop zero burst, then goes quiet. Hatches: PUNKTFUNK_RUMBLE_ENVELOPE=0 (legacy v1 + flat refresh, a bisect switch), PUNKTFUNK_RUMBLE_TTL_MS (clamped [150, 5000]). - renderers honor the TTL as their playback duration/deadline and keep their old heuristic only for a legacy (ttl=None) update: pf-client-core (the Deck haptic keep-alive is now deadline-bounded so it can't sustain a host-stopped rumble), clients/windows (SDL duration), android (JNI packs the lease out-of-band in bit 48 so any u16 ttl is unambiguous; Kotlin createOneShot(ttl)), apple (RumbleRenderer.envelopeDeadline + nextRumble2; sessionStaleSeconds demoted to the legacy fallback). - tests: codec round-trip + tail tolerance + seq-gate reorder (Rust); the probe asserts the v2 tail arrived under PUNKTFUNK_TEST_FEEDBACK; the Apple loopback asserts ttlMs round-trips end to end; RumbleTuning lease-decision cases. The host-side idle-timeout from the previous commit is defense in depth on the game side; this is the guarantee on the client side. Design: punktfunk-planning/design/rumble-envelope-plan.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
6.1 KiB
Rust
138 lines
6.1 KiB
Rust
//! Host→client gamepad feedback pulls (Option B): blocking JNI shims that forward to the connector's
|
|
//! rumble (0xCA) / HID-output (0xCD) planes and return one decoded event. Kotlin owns the poll
|
|
//! threads + the Android Vibrator/Lights rendering (see `GamepadFeedback.kt`) — no JNI upcalls, no
|
|
//! `JavaVM` attach, no cached method ids. Mirrors the audio plane's one-thread-per-plane contract,
|
|
//! except the thread lives in Kotlin and we just expose the blocking pull.
|
|
//!
|
|
//! Not android-gated: `next_rumble`/`next_hidout` are pure-Rust on the `quic` feature, so these
|
|
//! compile on the host build too (parity with the input shims in [`crate::session`]).
|
|
|
|
use crate::session::{jni_guard, SessionHandle};
|
|
use jni::objects::{JByteBuffer, JObject};
|
|
use jni::sys::{jint, jlong};
|
|
use jni::JNIEnv;
|
|
use punktfunk_core::quic::HidOutput;
|
|
use std::time::Duration;
|
|
|
|
/// Short blocking timeout: long enough not to busy-spin, short enough that the Kotlin poll thread
|
|
/// observes its `running=false` flag promptly on teardown.
|
|
const PULL_TIMEOUT: Duration = Duration::from_millis(100);
|
|
|
|
// HID-output kind tags written into the returned ByteBuffer (Kotlin reads them back).
|
|
const TAG_LED: u8 = 0x01;
|
|
const TAG_PLAYER_LEDS: u8 = 0x02;
|
|
const TAG_TRIGGER: u8 = 0x03;
|
|
|
|
/// `NativeBridge.nativeNextRumble(handle): Long` — block up to ~100 ms for the next rumble update.
|
|
/// Returns a packed positive long: 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). Pad index is dropped (single-pad model). Run from a Kotlin poll thread.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
|
|
_env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
) -> jlong {
|
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
|
jni_guard(-1, || {
|
|
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.
|
|
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.
|
|
let (lease_flag, ttl_bits) = match ttl {
|
|
Some(ms) => (1i64 << 48, jlong::from(ms) << 32),
|
|
None => (0, 0),
|
|
};
|
|
lease_flag | ttl_bits | (jlong::from(low) << 16) | jlong::from(high)
|
|
}
|
|
Err(_) => -1, // NoFrame (timeout) or Closed — Kotlin loops on its running flag
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `NativeBridge.nativeNextHidout(handle, buf): Int` — block up to ~100 ms for the next DualSense
|
|
/// HID-output event, written into the caller's direct ByteBuffer as `[kind][fields…]`:
|
|
/// Led → `[0x01][r][g][b]` (len 4)
|
|
/// PlayerLeds → `[0x02][bits]` (len 2)
|
|
/// Trigger → `[0x03][which][effect…]` (len 2 + effect.len())
|
|
/// Returns the byte count written, or `-1` on timeout / session closed / buffer too small.
|
|
#[no_mangle]
|
|
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextHidout(
|
|
env: JNIEnv,
|
|
_this: JObject,
|
|
handle: jlong,
|
|
buf: JByteBuffer,
|
|
) -> jint {
|
|
// Runs on a Kotlin poll thread, so a panic here would abort the process; guard the boundary.
|
|
jni_guard(-1, || {
|
|
if handle == 0 {
|
|
return -1;
|
|
}
|
|
// SAFETY: live handle per the contract; next_hidout is &self on the Sync connector.
|
|
let h = unsafe { &*(handle as *const SessionHandle) };
|
|
let ev = match h.client.next_hidout(PULL_TIMEOUT) {
|
|
Ok(ev) => ev,
|
|
Err(_) => return -1, // timeout or closed — Kotlin loops
|
|
};
|
|
|
|
// The caller passes a direct ByteBuffer (allocateDirect) so we write its backing store directly.
|
|
let cap = match env.get_direct_buffer_capacity(&buf) {
|
|
Ok(c) => c,
|
|
Err(_) => return -1,
|
|
};
|
|
let ptr = match env.get_direct_buffer_address(&buf) {
|
|
Ok(p) if !p.is_null() => p,
|
|
_ => return -1,
|
|
};
|
|
// SAFETY: `ptr`/`cap` describe the direct ByteBuffer's backing store, valid for this call.
|
|
let out = unsafe { std::slice::from_raw_parts_mut(ptr, cap) };
|
|
|
|
let n = match ev {
|
|
HidOutput::Led { r, g, b, .. } => {
|
|
if cap < 4 {
|
|
return -1;
|
|
}
|
|
out[0] = TAG_LED;
|
|
out[1] = r;
|
|
out[2] = g;
|
|
out[3] = b;
|
|
4
|
|
}
|
|
HidOutput::PlayerLeds { bits, .. } => {
|
|
if cap < 2 {
|
|
return -1;
|
|
}
|
|
out[0] = TAG_PLAYER_LEDS;
|
|
out[1] = bits;
|
|
2
|
|
}
|
|
HidOutput::Trigger { which, effect, .. } => {
|
|
let n = 2 + effect.len();
|
|
if cap < n {
|
|
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
|
|
}
|
|
out[0] = TAG_TRIGGER;
|
|
out[1] = which;
|
|
out[2..n].copy_from_slice(&effect);
|
|
n
|
|
}
|
|
HidOutput::TrackpadHaptic { .. } => {
|
|
// Steam Controller trackpad-coil haptics — no Android equivalent; drop it (motor
|
|
// rumble already rides the universal 0xCA plane).
|
|
return -1;
|
|
}
|
|
};
|
|
n as jint
|
|
})
|
|
}
|