Files
punktfunk/clients/android/native/src/feedback.rs
T
enricobuehler 42a0dd52be
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m10s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m21s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m0s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m2s
android / android (pull_request) Successful in 5m3s
ci / rust (pull_request) Successful in 9m44s
refactor(haptics): one copy of each thing every rumble path was transcribing
Twelve findings from the sweep's DRY/docs/dead-code tail. Most are small; three found
real defects hiding behind the duplication.

**The UHID event ABI existed five times.** Every UHID gamepad backend — DualSense,
DualShock 4, Switch Pro, Steam Controller, Steam Controller 2 — carried its own verbatim
copy of the kernel's constants plus its own `put_cstr`, and they had already drifted:
`switch_pro` was missing the SET_REPORT pair entirely, and `steam_controller` read a
FIXED 16-byte SET_REPORT window instead of the event's own `size`. That last one is a
bug in both directions — a longer report was truncated, and a shorter one had the parser
reading whatever the reused event buffer still held past the payload, i.e. acting on
rumble values the game never wrote. Now one `uhid_abi` module owns the numbers plus the
two accessors that are easy to get subtly wrong, with tests on exactly that.

**A dead force-feedback id fallback.** ff-core's `input_ff_upload` picks a free effect
slot and writes it into the effect BEFORE uinput forwards the request, so the `id == -1`
branch could never run — and allocating from a local counter would have been the wrong
answer anyway, since the kernel owns that id space. Removed, with a `debug_assert` where
it stood.

**Apple's HID path silently dropped weak rumble.** `hidByte` took the top byte with no
non-zero floor, so every amplitude below 0x0100 rendered as exactly nothing. Android has
always floored it at 1; this was the odd one out. That converter also existed twice
byte-identically inside one Gradle module — now one `wireAmplitudeToByte`.

Also: the DS5 output-report layout gets named offsets (`dualsense_proto::out_report`)
documenting all three transport bases — USB 0, SDL payload −1, Bluetooth +2 — since the
differing bases are transport-forced, not drift. `pf-client-core` cannot import them (it
and `pf-inject` do not depend on each other, and a DualSense layout has no business in
`punktfunk-core`, their only shared crate), so its copy now DERIVES its offsets by
explicit subtraction and a test pins the relationship. `PUNKTFUNK_HID_EFFECT_MAX` sizes
the struct it describes instead of a second literal 11 — the header now emits
`uint8_t effect[PUNKTFUNK_HID_EFFECT_MAX]`. The rumble policy engine's `min_pulse_ms`
and `keepalive_ms` docs stop naming cases nothing implements: no in-tree caller sets
`min_pulse_ms`, and the macOS DualSense-over-BT keepalive the doc cited CANNOT be served
by the quirk, because that renderer skips writes whose levels are unchanged and would
swallow the engine's re-emit — it keeps its own keepalive instead. `TrackpadHaptic` is
marked as staged scaffolding (the tag is on a shipped wire; removing the variant would
not reclaim it). Three ×257-vs-`<<8` doc comments corrected — the scaling itself is fine,
both round-trip to 255. `backstop_ms.max(160)` deleted as unreachable (the engine floors
at 500). New tests for `Ds5Feedback` and for the Android rumble JNI packing on BOTH sides,
with `MAX_PADS <= 16` now a compile-time assertion rather than a comment.

Closes S1-S9, S11, T2, T3 (design/haptics-sweep-2026-08-03.md M12).

S11's second half is NOT a defect and was left alone: `clients/session/src/main.rs`
calls `set_forwarding` unconditionally on every params-build (its own comment explains
why — browse mode reuses one service across launches), so `Ctl::Forwarding` routinely
arrives unchanged and that early-out is what stops a redundant `sync_open` + Valve-HIDAPI
cycle each launch.

Verified: pf-inject clippy -D warnings 0 / 91 tests; pf-client-core + punktfunk-core
clippy 0 / 437 tests (amd64 container); punktfunk-client-android 7 tests; Android :kit:
6 tests; Apple swift build + 189 tests / 0 failures; cargo fmt --all --check clean. Each
new test probed by reverting its fix — the fixed SET_REPORT window fails 3, a broken pack
shift fails 3, dropping the amplitude floor fails 1, and a wrong DS5 offset either fails
the pin or refuses to compile.
2026-08-04 22:52:38 +02:00

243 lines
10 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);
/// Width of the packed `pad` field in [`pack_rumble`] — 4 bits, i.e. indices 0..15.
const PAD_BITS: u32 = 4;
/// The packing is only lossless while every representable pad index fits in [`PAD_BITS`]. This was
/// a comment before; growing `MAX_PADS` past 16 would have silently aliased pad 16 onto pad 0
/// rather than failing the build.
const _: () = assert!(
punktfunk_core::input::MAX_PADS <= 1usize << PAD_BITS,
"MAX_PADS no longer fits the 4-bit pad field in the packed rumble long"
);
/// Pack one effective rumble command into the `jlong` `nativeNextRumble` returns.
///
/// Layout — mirrored by `unpackRumbleEvent` in `RumbleWire.kt`: bits 49..52 `pad`, 32..47
/// `backstop_ms`, 16..31 `low`, 0..15 `high`. Always non-negative, so the `-1` timeout/closed
/// sentinel stays unambiguous. Split out from the JNI entry point purely so it can be tested
/// without a live session handle — the shift arithmetic is the part worth pinning.
fn pack_rumble(pad: u16, low: u16, high: u16, backstop_ms: u32) -> jlong {
(jlong::from(pad & ((1 << PAD_BITS) - 1)) << 49)
| (jlong::from(backstop_ms.min(0xFFFF) as u16) << 32)
| (jlong::from(low) << 16)
| jlong::from(high)
}
// 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;
const TAG_HID_RAW: u8 = 0x05;
/// `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,
_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_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_command(PULL_TIMEOUT) {
Ok(cmd) => pack_rumble(cmd.pad, cmd.low, cmd.high, cmd.backstop_ms),
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 `[pad][kind][fields…]` (the
/// leading `pad` is the wire pad index the event is addressed to, so Kotlin routes it to that
/// controller — multi-pad HID feedback):
/// Led → `[pad][0x01][r][g][b]` (len 5)
/// PlayerLeds → `[pad][0x02][bits]` (len 3)
/// Trigger → `[pad][0x03][which][effect…]` (len 3 + 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) };
// out[0] = wire pad index; out[1] = kind tag; the rest is the per-kind payload.
let n = match ev {
HidOutput::Led { pad, r, g, b } => {
if cap < 5 {
return -1;
}
out[0] = pad;
out[1] = TAG_LED;
out[2] = r;
out[3] = g;
out[4] = b;
5
}
HidOutput::PlayerLeds { pad, bits } => {
if cap < 3 {
return -1;
}
out[0] = pad;
out[1] = TAG_PLAYER_LEDS;
out[2] = bits;
3
}
HidOutput::Trigger { pad, which, effect } => {
let n = 3 + effect.len();
if cap < n {
return -1; // the raw DS5 trigger block is ~11 bytes; Kotlin allocates 64
}
out[0] = pad;
out[1] = TAG_TRIGGER;
out[2] = which;
out[3..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;
}
HidOutput::HidRaw { pad, kind, data } => {
// As-is SC2 passthrough: the host's hidraw consumer (Steam) wrote this report to
// the virtual pad; Kotlin replays it verbatim on the physical controller.
// `[pad][0x05][kind][report…]` — kind 0 = output report, 1 = feature report.
let n = 3 + data.len();
if cap < n {
return -1; // reports are ≤ 64 bytes; Kotlin allocates 128
}
out[0] = pad;
out[1] = TAG_HID_RAW;
out[2] = kind;
out[3..n].copy_from_slice(&data);
n
}
};
n as jint
})
}
#[cfg(test)]
mod pack_rumble_tests {
use super::*;
use punktfunk_core::input::MAX_PADS;
/// Kotlin's `unpackRumbleEvent`, transcribed — if these two ever disagree the boundary is
/// broken, and nothing else in the build would say so.
fn unpack(ev: jlong) -> (u16, u16, u16, u32) {
let pad = ((ev >> 49) & 0xF) as u16;
let backstop = ((ev >> 32) & 0xFFFF) as u32;
let low = ((ev >> 16) & 0xFFFF) as u16;
let high = (ev & 0xFFFF) as u16;
(pad, low, high, backstop)
}
#[test]
fn round_trips_every_field_at_its_extremes() {
for &(pad, low, high, backstop) in &[
(0u16, 0u16, 0u16, 0u32),
(15, 0xFFFF, 0xFFFF, 0xFFFF),
(1, 0x1234, 0x5678, 500),
(7, 0, 0xFFFF, 2000),
] {
let ev = pack_rumble(pad, low, high, backstop);
assert_eq!(unpack(ev), (pad, low, high, backstop), "pad {pad}");
}
}
#[test]
fn every_representable_pad_survives_the_four_bit_field() {
for pad in 0..MAX_PADS as u16 {
let (got, ..) = unpack(pack_rumble(pad, 1, 2, 3));
assert_eq!(got, pad, "pad {pad} aliased in the packed long");
}
}
#[test]
fn a_packed_command_is_never_negative() {
// `-1` is the timeout/closed sentinel; any packed value colliding with it would read as
// "no command" and the rumble would simply vanish.
assert!(pack_rumble(15, 0xFFFF, 0xFFFF, 0xFFFF) >= 0);
assert!(pack_rumble(0, 0, 0, 0) >= 0);
}
#[test]
fn an_oversized_backstop_saturates_instead_of_corrupting_the_pad_field() {
let ev = pack_rumble(3, 0, 0, u32::MAX);
let (pad, _, _, backstop) = unpack(ev);
assert_eq!(pad, 3, "a huge backstop must not bleed into the pad bits");
assert_eq!(backstop, 0xFFFF);
}
#[test]
fn a_stop_is_distinguishable_from_a_hold() {
let stop = pack_rumble(2, 0, 0, 0);
let hold = pack_rumble(2, 0x8000, 0x8000, 500);
assert_ne!(stop, hold);
assert_eq!(unpack(stop).1, 0);
assert_eq!(unpack(stop).2, 0);
}
}