feat(android): declare pad-audio caps and take tier-A pads off wire rumble

The two things that decide whether WP9 does anything at all on a device, both
failing silently rather than loudly if missed.

**Capability bits.** The host emits 0xD1 only toward pads that declared they can
render it (arrival flags 8/9). Without `set_pad_audio_caps` the renderer would
sit on a permanently empty plane and look like a decode bug. Declared when the
stream opens, withdrawn when it stops.

**Rumble arbitration.** `valid_flag0` bit 1 (HAPTICS_SELECT) *disables* audio
haptics and selects classic rumble, and `DsDevice` sets it on every rumble write
— as Linux's hid-playstation and SDL both do. One replayed rumble command would
mute the voice coils the 0xD1 stream is driving, for the rest of the session.
Tier A and tier C are mutually exclusive in the pad's firmware, so the
arbitration selects and never blends.

Suppression sits at `nativeNextRumble`, the pull point, rather than in Kotlin:
it keeps the rule next to the reason and covers every caller. The registry is an
atomic bitmask because the reader is the rumble poll thread and must not block
behind a start/stop on the JNI thread.

Order matters on teardown: the capability is withdrawn before the pad returns to
wire rumble, so the host has stopped sending 0xD1 before tier C resumes and the
two never overlap.

`nativeStartPadAudio`/`nativeStopPadAudio` now take the wire pad index, since
both the capability and the arbitration are per-pad. Out-of-range indices are
rejected rather than wrapped into another pad's slot.

12 host tests (2 new, including one pinning that an out-of-range index cannot
shift the mask into undefined territory), 0 clippy findings, check clean on all
three Android ABIs.
This commit is contained in:
enricobuehler
2026-08-02 23:44:51 +02:00
parent b5f91d50bb
commit a10bde39bb
3 changed files with 84 additions and 3 deletions
+6
View File
@@ -54,6 +54,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeNextRumble(
// handle.
let h = unsafe { &*(handle as *const SessionHandle) };
match h.client.next_rumble_command(PULL_TIMEOUT) {
// A pad rendering tier-A audio must never see wire rumble. `DsDevice` sets
// `valid_flag0` bit 1 (`HAPTICS_SELECT`) on every rumble write, and that bit
// *disables* audio haptics — so one replayed command would silently mute the voice
// coils the 0xD1 stream is driving, for the rest of the session. Dropping it here
// (rather than in Kotlin) keeps the rule next to the reason, and covers every caller.
Ok(cmd) if crate::pad_audio::is_tier_a((cmd.pad & 0xF) as u8) => -1,
Ok(cmd) => {
(jlong::from(cmd.pad & 0xF) << 49)
| (jlong::from(cmd.backstop_ms.min(0xFFFF) as u16) << 32)
+58
View File
@@ -71,6 +71,38 @@ const MAX_FRAME_SAMPLES: usize = 5760;
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const IN_FLIGHT_MS: u32 = 6;
// ---- tier-A registry ---------------------------------------------------------------------------
/// Which wire pad indices are currently rendering tier-A audio, as a bitmask over the 16 wire
/// slots.
///
/// Read on the rumble poll thread and written on the JNI thread, so it is an atomic rather than a
/// lock: the reader is on a latency path and must never block behind a start/stop.
static TIER_A_PADS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// Mark (or clear) a pad as rendering tier-A audio.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn set_tier_a(pad: u8, on: bool) {
use std::sync::atomic::Ordering;
let bit = 1u32 << (pad & 0x0f);
if on {
TIER_A_PADS.fetch_or(bit, Ordering::Relaxed);
} else {
TIER_A_PADS.fetch_and(!bit, Ordering::Relaxed);
}
}
/// Is this pad rendering tier-A audio, and therefore forbidden from receiving wire rumble?
///
/// **This is a firmware constraint, not a preference.** `valid_flag0` bit 1 (`HAPTICS_SELECT`)
/// *disables* audio haptics and selects classic rumble, and `DsDevice` sets it on every rumble
/// write — as Linux's `hid-playstation` and SDL both do. So a single rumble command reaching a
/// tier-A pad silently mutes the voice coils this stream drives, for the rest of the session.
/// Tier A and tier C are mutually exclusive **in the pad**: the arbitration selects, never blends.
pub(crate) fn is_tier_a(pad: u8) -> bool {
TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0
}
// ---- the 4-channel mixer ---------------------------------------------------------------------
/// Interleave the two independent stereo streams into one 4-channel frame stream.
@@ -560,6 +592,32 @@ mod tests {
assert_eq!(out, vec![0, 0, 1, 2]);
}
#[test]
fn tier_a_registry_tracks_pads_independently() {
// A rumble command reaching a tier-A pad mutes its coils for the session, so this gate
// has to be exact rather than approximately right.
set_tier_a(3, true);
assert!(is_tier_a(3));
assert!(!is_tier_a(4));
set_tier_a(4, true);
assert!(is_tier_a(3) && is_tier_a(4));
set_tier_a(3, false);
assert!(!is_tier_a(3), "clearing one pad must not clear another");
assert!(is_tier_a(4));
set_tier_a(4, false);
assert!(!is_tier_a(4));
}
#[test]
fn tier_a_registry_wraps_the_pad_index_into_the_wire_slot_space() {
// The wire pad space is 4 bits; an out-of-range index must not shift the mask into
// undefined territory (a shift >= 32 is a panic in debug and garbage in release).
set_tier_a(0x1f, true);
assert!(is_tier_a(0x0f), "0x1f and 0x0f are the same wire slot");
set_tier_a(0x0f, false);
assert!(!is_tier_a(0x1f));
}
#[test]
fn discard_empties_without_disturbing_alignment() {
let mut m = QuadMixer::new();
+20 -3
View File
@@ -460,7 +460,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopMic(
})
}
/// `NativeBridge.nativeStartPadAudio(handle, fd, haptics, speaker): Boolean` — start tier-A
/// `NativeBridge.nativeStartPadAudio(handle, pad, fd, haptics, speaker): Boolean` — start tier-A
/// DualSense pad audio on a descriptor Kotlin has already obtained.
///
/// `fd` comes from `UsbDeviceConnection.getFileDescriptor()` **after** claiming the pad's audio
@@ -478,12 +478,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
fd: jni::sys::jint,
haptics: jboolean,
speaker: jboolean,
) -> jboolean {
jni_guard(0, || {
if handle == 0 || fd < 0 {
if handle == 0 || fd < 0 || !(0..16).contains(&pad) {
return 0;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
@@ -499,6 +500,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
) {
Some(p) => {
*h.pad_audio.lock().unwrap() = Some(p);
// Declare what this pad can render. Without these bits the host never emits 0xD1
// for it at all, so the renderer would sit on an empty plane forever — the bits
// ride the gamepad arrival (flags 8/9) toward a HOST_CAP_PAD_AUDIO host.
let caps =
(if haptics != 0 { 0x01 } else { 0 }) | (if speaker != 0 { 0x02 } else { 0 });
h.client.set_pad_audio_caps(pad as u8, caps);
// And take this pad off wire rumble: tier A and tier C are mutually exclusive in
// the pad's firmware (see `pad_audio::is_tier_a`).
crate::pad_audio::set_tier_a(pad as u8, true);
1
}
None => 0,
@@ -506,7 +516,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartPadAud
})
}
/// `NativeBridge.nativeStopPadAudio(handle)` — stop tier-A pad audio and join its thread.
/// `NativeBridge.nativeStopPadAudio(handle, pad)` — stop tier-A pad audio and join its thread.
///
/// Returns only once the render thread is joined, which is the point: Kotlin may close the
/// `UsbDeviceConnection` as soon as this returns and not before.
@@ -516,12 +526,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopPadAudi
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jni::sys::jint,
) {
jni_guard((), || {
if handle != 0 {
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.stop_pad_audio();
if (0..16).contains(&pad) {
// Withdraw the capability and hand the pad back to wire rumble, in that order:
// the host stops sending 0xD1 before tier C resumes, so the two never overlap.
h.client.set_pad_audio_caps(pad as u8, 0);
crate::pad_audio::set_tier_a(pad as u8, false);
}
}
})
}