From a10bde39bbcdb23be47f25fdae3b868fdecd6f4a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 2 Aug 2026 23:44:51 +0200 Subject: [PATCH] feat(android): declare pad-audio caps and take tier-A pads off wire rumble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- clients/android/native/src/feedback.rs | 6 ++ clients/android/native/src/pad_audio.rs | 58 ++++++++++++++++++++ clients/android/native/src/session/planes.rs | 23 +++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/clients/android/native/src/feedback.rs b/clients/android/native/src/feedback.rs index 4e4225be..3ea7adf8 100644 --- a/clients/android/native/src/feedback.rs +++ b/clients/android/native/src/feedback.rs @@ -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) diff --git a/clients/android/native/src/pad_audio.rs b/clients/android/native/src/pad_audio.rs index 03f52b9c..009e8597 100644 --- a/clients/android/native/src/pad_audio.rs +++ b/clients/android/native/src/pad_audio.rs @@ -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(); diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 716228de..54594da1 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -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); + } } }) }