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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user