forked from unom/punktfunk
Everything the 2026-08-03 haptics sweep filed against the pad-audio branch (P2 + P3). Four of them are the difference between a feature that works and one that fails silently. **B6 — nothing ever un-muted the coils.** Every rumble report asserts `HAPTICS_SELECT`, which is SDL's "disable audio haptics" bit: the firmware mutes the very voice coils the 0xD1 stream drives. No code anywhere cleared it again, so ONE rumble left tier-A haptics silent for the rest of that pad's life — no error, nothing in a log, and the host happily streaming into a muted actuator. `DsDevice.ds5AudioHapticsReport` is the documented undo (flag0 with both bits clear); written EP0-direct when the stream starts and again after a rumble stop while a stream is live, because the stop report re-mutes on its way past. **B10 — the desktop mix could reach a controller's coils.** Pad endpoints were filtered out inside `plan()` only. The watchdog, Follow mode and the parked default all go through `judge_default`, which classifies by NAME — and a pad endpoint is deliberately stamped "DualSense Wireless Controller" so games treat it as the pad's speaker. No name rule could ever catch one. It now refuses them by identity. **B27 — an out-of-range pad aliased onto a real slot.** The 0xCD plane's pad is the only u16 index and every consumer narrowed it with `as u8` on an assumption nothing enforced, so wire pad 256 steered pad 0's speaker volumes. Rejected at the decoder, which makes the narrowings lossless by construction. An existing test had pinned the bug in place, asserting that wire pad 513 round-trips; corrected, plus a test for the 256→0 alias specifically. **B7 — caps that arrived late were never announced.** The renderer commits the tier-A trade only once its sink opens, which is well past the arrival burst's two 100 ms ticks, and `set_pad_audio_caps` only stored an atomic. The client believed it had pad audio while the host emitted nothing. The input task now compares the live registry against what the last arrival actually carried and re-arms the burst itself — no new plumbing, and no extra traffic when nothing changed. The rest: `needs_aeb_kick` is finally ACTED on (R4) — a stored-but-not-served endpoint is declined rather than opened, because `AUTOCONVERTPCM` makes it succeed and mis-route; a failed provisioning no longer latches `PROVISIONED` for the process lifetime (R5), and `host_cap` retries, so a host that started while the audio stack was busy recovers at the next connect instead of the next reboot; the loopback init timeout reaps its thread instead of detaching one per ~2 s reopen (R6); kind-change restarts are bounded (R3) since the trigger is a client-sent arrival; the devtest uses the endpoint's real channel mask (B11) instead of letting wasapi derive 0x0F against the endpoint's 0x33; the render loop asks `is_session_ended()` rather than spinning at nice -16 (R12); short writes are counted and reported instead of dropping the tail in silence (R13); and a frame addressed to another pad is dropped before it can seed the gap tracker from a foreign sequence space (R14). Verified: punktfunk-host clippy -D warnings **0 on a real Windows box**; Linux/amd64 clippy 0 with **589 tests** (pf-client-core 114, pf-inject 101, punktfunk-client-android 20, punktfunk-core 345+1+8); Android :kit: tests + :app: compile green; fmt clean. Six punktfunk-host tests fail on that Windows box. FIVE fail identically on a tree with no pad-audio code at all (QUIC `Rejected(SetupFailed)` — the box's network environment); the sixth passes 3/3 in isolation and only failed under the parallel run, on a locally-bound ephemeral port. Neither is this change. Still owed: on-glass. This is a hardware feature and none of it has been on a real DualSense since the merge.
1008 lines
44 KiB
Rust
1008 lines
44 KiB
Rust
//! Pad audio on Android (the 0xD1 plane) — tier A, WP9.
|
||
//!
|
||
//! The Android twin of [`pf_client_core::pad_audio`]: drain the host's per-pad DualSense streams,
|
||
//! Opus-decode haptics (kind 0) and speaker (kind 1), interleave them into the pad's own
|
||
//! 4-channel layout, and render them on the physical pad.
|
||
//!
|
||
//! # Why this needs a USB driver instead of an audio API
|
||
//!
|
||
//! Every other client hands the 4-channel stream to the platform's audio graph — WASAPI on
|
||
//! Windows, PipeWire on Linux, CoreAudio on Apple. **Android has no such option for this device.**
|
||
//! AOSP's `UsbAlsaManager` carries a hardcoded VID/PID denylist that includes the DualSense
|
||
//! (`054c:0ce6`), so the kernel enumerates the pad's playback node and the framework then discards
|
||
//! it: `hasOutput: false`. There is no `AudioDeviceInfo` for `setPreferredDevice` to target, and
|
||
//! `/dev/snd` is closed to apps by SELinux. Android's own `UsbRequest` API cannot help either — it
|
||
//! rejects any endpoint that is not bulk or interrupt.
|
||
//!
|
||
//! So this path drives the pad's isochronous endpoint directly, through `uac-host` on the file
|
||
//! descriptor Java already owns. That is measured, not hoped: on a Nothing Phone (3) the claim
|
||
//! succeeds unprivileged, the gamepad and the pad's microphone both keep working, and the
|
||
//! underrun-free floor is **4 ms in flight** — including under eight-core load with the SoC in
|
||
//! severe thermal throttling.
|
||
//!
|
||
//! # The exclusivity that shapes everything here, and what is actually known about it
|
||
//!
|
||
//! `valid_flag0` bit 1 (`HAPTICS_SELECT`) disables the audio-haptics path, and every rumble write
|
||
//! that exists — `hid-playstation`, SDL, and our own [`crate::feedback`] path via `DsDevice` —
|
||
//! asserts it. So haptics and classic rumble cannot both drive the coils **as coded**, and the
|
||
//! arbitration selects rather than blends.
|
||
//!
|
||
//! What is NOT established is the stronger claim this module used to make: that the coils and the
|
||
//! rumble motors are the same physical actuators, exclusive *in the firmware*. No teardown, vendor
|
||
//! document or measurement supports it here; it traces to one reverse-engineered comment in SDL,
|
||
//! and SDL's own modern path sets `HAPTICS_SELECT` **alone** (amplitude rides `ucEnableBits3`),
|
||
//! which reads more like an independent mute for the audio path than a shared-actuator interlock.
|
||
//! The combination that would settle it — rumble asserted with `HAPTICS_SELECT` CLEARED — is
|
||
//! emitted by no code anywhere, so nothing here writes it either.
|
||
//!
|
||
//! The arbitration is therefore built on **evidence, not prediction**: haptics owns the coils only
|
||
//! while haptics frames are actually arriving (see [`haptics_owns_coils`]). That is correct under
|
||
//! either hypothesis, and it is what keeps a rumble-only title rumbling — it renders no haptics
|
||
//! audio, the host's silence gate emits nothing, and the pad simply keeps its motors.
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use punktfunk_core::audio::AudioGapTracker;
|
||
use punktfunk_core::quic::{PAD_AUDIO_KIND_HAPTICS, PAD_AUDIO_KIND_SPEAKER};
|
||
|
||
#[cfg(target_os = "android")]
|
||
use punktfunk_core::client::NativeClient;
|
||
#[cfg(target_os = "android")]
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
#[cfg(target_os = "android")]
|
||
use std::sync::Arc;
|
||
#[cfg(target_os = "android")]
|
||
use std::thread::JoinHandle;
|
||
#[cfg(target_os = "android")]
|
||
use std::time::Duration;
|
||
|
||
/// The pad's render layout: 4 interleaved channels — speaker FL/FR on 0/1, the voice coils on
|
||
/// 2/3. Feeding a 2-channel stream would leave the coils silent rather than fail, which is the
|
||
/// failure mode most worth not having.
|
||
const PAD_CHANNELS: usize = 4;
|
||
|
||
/// Both plane kinds decode as 48 kHz stereo.
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
const SAMPLE_RATE: u32 = 48_000;
|
||
|
||
/// Ring ceiling, in sample frames. 60 ms — far above the in-flight depth, because this bounds
|
||
/// *decoder* backlog when the USB side stalls, not stream latency. Overflow drops the oldest.
|
||
const MAX_BUFFER_FRAMES: usize = (SAMPLE_RATE as usize / 1000) * 60;
|
||
|
||
/// Largest Opus frame this decodes in one call: 120 ms at 48 kHz, the codec's maximum.
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
const MAX_FRAME_SAMPLES: usize = 5760;
|
||
|
||
/// How much audio to keep in flight on the USB endpoint.
|
||
///
|
||
/// WP7 measured the underrun-free floor on real hardware at **4 ms** (clean across three sweeps,
|
||
/// including one under eight-core load with the CPU thermally throttled); 3 ms was marginal and
|
||
/// 2 ms never survived. 6 ms takes one step of headroom above that floor, because the same
|
||
/// measurement found isolated transient events roughly once per three seconds that are *not*
|
||
/// depth-dependent — so the floor is a floor, not a target.
|
||
#[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's HAPTICS lane armed — i.e. did a renderer open a stream that wants the coils?
|
||
///
|
||
/// Armed is necessary but NOT sufficient to take the pad off wire rumble; see
|
||
/// [`haptics_owns_coils`]. Speaker-only rendering never arms this: the speaker pair is a
|
||
/// different pair of channels and cannot be disturbed by a rumble write.
|
||
pub(crate) fn haptics_armed(pad: u8) -> bool {
|
||
TIER_A_PADS.load(std::sync::atomic::Ordering::Relaxed) & (1u32 << (pad & 0x0f)) != 0
|
||
}
|
||
|
||
/// Last instant a real (non-concealed) haptics frame was decoded for each pad, as ms on the
|
||
/// process clock; `0` = never. Written by the render thread, read by the rumble poll thread.
|
||
static HAPTICS_SEEN_MS: [std::sync::atomic::AtomicU64; 16] =
|
||
[const { std::sync::atomic::AtomicU64::new(0) }; 16];
|
||
|
||
/// Process epoch for [`HAPTICS_SEEN_MS`] — `Instant` is not `const`-constructible.
|
||
fn epoch() -> std::time::Instant {
|
||
static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||
*EPOCH.get_or_init(std::time::Instant::now)
|
||
}
|
||
|
||
/// Milliseconds since [`epoch`], **1-based**. The `+ 1` reserves `0` as an unambiguous "never
|
||
/// stamped" sentinel: without it, a haptics frame arriving in the first millisecond of the process
|
||
/// would stamp `0` and be read as never-arrived, handing the coils to wire rumble mid-effect.
|
||
/// Stamp and comparison share this clock, so the offset cancels and adds no skew.
|
||
fn now_ms() -> u64 {
|
||
epoch().elapsed().as_millis() as u64 + 1
|
||
}
|
||
|
||
/// A pad is only silent for haptics once the host has stopped sending for longer than its own
|
||
/// silence gate can explain. The host gates at −60 dBFS with a 250 ms hangover, so a title that
|
||
/// renders no haptics audio emits NOTHING on the 0xD1 plane; doubling the hangover covers wire
|
||
/// jitter and concealment without letting a real gap read as "live".
|
||
const HAPTICS_IDLE_MS: u64 = 500;
|
||
|
||
/// Stamp a decoded haptics frame. Concealment (PLC) deliberately does not count — filling a gap
|
||
/// is not evidence that the game is still driving the coils.
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
pub(crate) fn note_haptics_frame(pad: u8) {
|
||
HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(now_ms(), std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
|
||
/// Clear a pad's liveness (slot teardown). Wire indices are recycled, so a stale stamp would let
|
||
/// a fresh pad inherit the previous one's ownership.
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
pub(crate) fn clear_haptics_liveness(pad: u8) {
|
||
HAPTICS_SEEN_MS[(pad & 0x0f) as usize].store(0, std::sync::atomic::Ordering::Relaxed);
|
||
}
|
||
|
||
/// The arbitration, pure and testable: haptics owns the coils only while frames are ACTUALLY
|
||
/// arriving. `seen_ms == 0` (never) is never live.
|
||
pub(crate) fn haptics_owns_at(armed: bool, seen_ms: u64, now: u64) -> bool {
|
||
armed && seen_ms != 0 && now.saturating_sub(seen_ms) < HAPTICS_IDLE_MS
|
||
}
|
||
|
||
/// Does this pad's haptics stream currently own the coils, so wire rumble must stand down?
|
||
///
|
||
/// Arbitrating on **evidence** rather than on a prediction about the hardware is deliberate. Every
|
||
/// rumble write this tree emits asserts `valid_flag0` bit 1 (`HAPTICS_SELECT`), which disables the
|
||
/// audio-haptics path, so the two cannot both drive the coils *as coded* — whatever the firmware
|
||
/// would allow. But a title that never renders haptics audio produces no 0xD1 frames at all, and
|
||
/// suppressing its rumble on the assumption that "the stream carries the feedback" silences it
|
||
/// outright. Frame arrival is the signal that tells the two cases apart, and it costs nothing.
|
||
pub(crate) fn haptics_owns_coils(pad: u8) -> bool {
|
||
let i = (pad & 0x0f) as usize;
|
||
haptics_owns_at(
|
||
haptics_armed(pad),
|
||
HAPTICS_SEEN_MS[i].load(std::sync::atomic::Ordering::Relaxed),
|
||
now_ms(),
|
||
)
|
||
}
|
||
|
||
// ---- the 4-channel mixer ---------------------------------------------------------------------
|
||
|
||
/// Interleave the two independent stereo streams into one 4-channel frame stream.
|
||
///
|
||
/// The kinds arrive on different cadences (haptics 5 ms, speaker 10 ms), so each has its own
|
||
/// write cursor and [`pop`](Self::pop) emits everything the further-ahead kind has filled, with
|
||
/// the lagging or absent kind's pair reading silence. A haptics-only session therefore renders
|
||
/// the coils with a silent speaker pair, and vice versa, instead of stalling on the missing kind.
|
||
///
|
||
/// Samples are `i16` — the DualSense's own wire format — so nothing converts on the hot path.
|
||
/// Pure logic, unit-tested below; pacing lives in the USB ring downstream.
|
||
pub(crate) struct QuadMixer {
|
||
/// Interleaved 4-channel samples; the front is the next frame out. Always
|
||
/// `ready_frames() * PAD_CHANNELS` long.
|
||
ring: VecDeque<i16>,
|
||
/// Per-kind write cursor in FRAMES relative to the ring front, indexed by the wire `kind`.
|
||
written: [usize; 2],
|
||
/// Frames dropped to the ceiling — a stalled USB side, visible in the logs.
|
||
dropped: u64,
|
||
}
|
||
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
impl QuadMixer {
|
||
pub(crate) fn new() -> QuadMixer {
|
||
QuadMixer {
|
||
ring: VecDeque::new(),
|
||
written: [0; 2],
|
||
dropped: 0,
|
||
}
|
||
}
|
||
|
||
/// Write one decoded stereo chunk (interleaved L/R) for `kind` at that kind's cursor,
|
||
/// zero-extending as needed. Both cursors shift together on overflow, so the two kinds can
|
||
/// never skew relative to one another.
|
||
pub(crate) fn push(&mut self, kind: u8, stereo: &[i16]) {
|
||
// Name both kinds rather than defaulting: a kind this build does not know belongs
|
||
// nowhere in a 4-channel frame, and quietly folding it into the coil pair would render
|
||
// an unknown stream straight into the actuators.
|
||
let (k, off) = match kind {
|
||
PAD_AUDIO_KIND_HAPTICS => (0usize, 2usize),
|
||
PAD_AUDIO_KIND_SPEAKER => (1usize, 0usize),
|
||
_ => return,
|
||
};
|
||
let frames = stereo.len() / 2;
|
||
let base = self.written[k];
|
||
let need = (base + frames) * PAD_CHANNELS;
|
||
if self.ring.len() < need {
|
||
self.ring.resize(need, 0);
|
||
}
|
||
for (i, fr) in stereo.chunks_exact(2).enumerate() {
|
||
let at = (base + i) * PAD_CHANNELS + off;
|
||
self.ring[at] = fr[0];
|
||
self.ring[at + 1] = fr[1];
|
||
}
|
||
self.written[k] = base + frames;
|
||
let over = self.ready_frames().saturating_sub(MAX_BUFFER_FRAMES);
|
||
if over > 0 {
|
||
self.dropped += over as u64;
|
||
self.drop_front(over);
|
||
}
|
||
}
|
||
|
||
/// Frames ready to output: the further-ahead kind's cursor.
|
||
pub(crate) fn ready_frames(&self) -> usize {
|
||
self.written[0].max(self.written[1])
|
||
}
|
||
|
||
/// Frames discarded to the ceiling since construction.
|
||
pub(crate) fn dropped_frames(&self) -> u64 {
|
||
self.dropped
|
||
}
|
||
|
||
/// Append every ready frame (interleaved 4-channel) to `out`; returns the frame count.
|
||
pub(crate) fn pop(&mut self, out: &mut Vec<i16>) -> usize {
|
||
let frames = self.ready_frames();
|
||
let n = frames * PAD_CHANNELS;
|
||
out.extend(self.ring.drain(..n.min(self.ring.len())));
|
||
for w in &mut self.written {
|
||
*w = w.saturating_sub(frames);
|
||
}
|
||
frames
|
||
}
|
||
|
||
/// Throw the ready frames away — no sink to render them on right now.
|
||
pub(crate) fn discard(&mut self) {
|
||
let f = self.ready_frames();
|
||
self.drop_front(f);
|
||
}
|
||
|
||
fn drop_front(&mut self, frames: usize) {
|
||
let n = (frames * PAD_CHANNELS).min(self.ring.len());
|
||
self.ring.drain(..n);
|
||
let f = n / PAD_CHANNELS;
|
||
for w in &mut self.written {
|
||
*w = w.saturating_sub(f);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- decode + packet loss concealment ---------------------------------------------------------
|
||
|
||
#[cfg(target_os = "android")]
|
||
/// Per-kind decode state: a stereo 48 kHz Opus decoder, the seq-gap tracker, and the last decoded
|
||
/// frame size, which is the unit PLC synthesises in.
|
||
struct KindStream {
|
||
dec: opus::Decoder,
|
||
gaps: AudioGapTracker,
|
||
frame_samples: usize,
|
||
}
|
||
|
||
/// Concealment frames to synthesise before decoding `seq`.
|
||
///
|
||
/// Zero until something has decoded, because there is nothing to size the PLC from yet. The
|
||
/// tracker is fed regardless, so a gap seen before the first real frame cannot resurface later as
|
||
/// a phantom. Pure, and unit-tested.
|
||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||
fn plc_frames(gaps: &mut AudioGapTracker, seq: u32, frame_samples: usize) -> u32 {
|
||
let missing = gaps.missing_before(seq);
|
||
if frame_samples == 0 {
|
||
0
|
||
} else {
|
||
missing
|
||
}
|
||
}
|
||
|
||
// ---- the USB sink ------------------------------------------------------------------------------
|
||
|
||
/// Everything that talks to the pad. Linux and Android only: `usbfs` is a Linux kernel ABI, and
|
||
/// this crate also builds as a host cdylib on macOS dev boxes, where the mixer and PLC above still
|
||
/// compile and still run their tests.
|
||
#[cfg(target_os = "android")]
|
||
mod sink {
|
||
use super::{IN_FLIGHT_MS, PAD_CHANNELS, SAMPLE_RATE};
|
||
|
||
/// Open the pad's 4-channel playback stream on a descriptor Java owns.
|
||
///
|
||
/// # Safety
|
||
///
|
||
/// `fd` must be a live usbfs descriptor from an open `UsbDeviceConnection` that outlives the
|
||
/// returned device — this **borrows** it and never closes it, because closing is
|
||
/// `UsbDeviceConnection.close()`'s job and a double close would strand an unrelated
|
||
/// descriptor much later.
|
||
pub(super) unsafe fn device(fd: i32) -> usbfs_iso::UsbFsDevice {
|
||
// SAFETY: forwarded from this function's own contract, which the JNI entry point upholds
|
||
// by keeping the Java connection open for the lifetime of the renderer thread.
|
||
unsafe { usbfs_iso::UsbFsDevice::from_borrowed_fd(fd) }
|
||
}
|
||
|
||
/// Find the pad's 4-channel playback stream and open it.
|
||
///
|
||
/// Four channels is a hard requirement, not a preference: the voice coils *are* channels 3
|
||
/// and 4, so a 2-channel alternate setting would open successfully and then render haptics
|
||
/// into nothing.
|
||
pub(super) fn open<'d>(
|
||
dev: &'d usbfs_iso::UsbFsDevice,
|
||
) -> Result<uac_host::Playback<'d>, uac_host::Error> {
|
||
let blob = dev.raw_descriptors()?;
|
||
let function = uac_host::parse(&blob)?;
|
||
let stream = function
|
||
.output_streams()
|
||
.find(|s| usize::from(s.channels()) == PAD_CHANNELS)
|
||
.ok_or(uac_host::Error::NoAudioFunction)?;
|
||
|
||
let opts = uac_host::OpenOptions {
|
||
depth: usbfs_iso::Depth::Millis(IN_FLIGHT_MS),
|
||
// One packet per URB: the finest granularity the bus offers, and what WP7 measured
|
||
// the 4 ms floor with. Packing more multiplies one completion's latency.
|
||
packets_per_urb: Some(1),
|
||
// Keep the endpoint fed rather than gapping when the decoder is momentarily late.
|
||
// A hole in an isochronous stream is silence forever; silence we chose is better.
|
||
underrun: usbfs_iso::Underrun::FillSilence,
|
||
..Default::default()
|
||
};
|
||
stream.open_with(dev, uac_host::Format::S16Le, SAMPLE_RATE, opts)
|
||
}
|
||
}
|
||
|
||
// ---- the self test ------------------------------------------------------------------------------
|
||
|
||
/// Drive the pad directly with a synthetic tone, through **the real client path**.
|
||
///
|
||
/// This exists because the two things most likely to be wrong here cannot be unit-tested and are
|
||
/// invisible without a host: whether the descriptor Kotlin handed over is one this renderer may
|
||
/// drive exclusively, and whether the interface claim succeeds on this kernel. A standalone
|
||
/// harness proves neither — it owns its descriptor by construction, which is exactly the condition
|
||
/// that was violated when this renderer was handed the HID link's fd and the two engines began
|
||
/// stealing each other's URB completions.
|
||
///
|
||
/// Opens the sink the same way [`render`] does and writes a sine into the voice-coil pair, which
|
||
/// is felt rather than heard. Returns sample frames written, or a negative [`SelfTest`] code.
|
||
///
|
||
/// # Safety
|
||
///
|
||
/// `fd` must be a live usbfs descriptor whose connection outlives the call, and which **nothing
|
||
/// else is driving transfers on**.
|
||
#[cfg(target_os = "android")]
|
||
pub(crate) unsafe fn self_test(fd: i32, seconds: i32, hz: i32) -> i32 {
|
||
// SAFETY: the caller's contract.
|
||
let dev = unsafe { sink::device(fd) };
|
||
let mut playback = match sink::open(&dev) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
log::warn!("pad audio self-test: could not open the stream: {e}");
|
||
return SelfTest::OPEN_FAILED;
|
||
}
|
||
};
|
||
log::info!(
|
||
"pad audio self-test: {} ch {} at {} Hz, {} us in flight",
|
||
playback.channels(),
|
||
playback.format(),
|
||
playback.rate(),
|
||
playback.schedule().in_flight_us()
|
||
);
|
||
|
||
let rate = playback.rate();
|
||
let channels = playback.channels() as usize;
|
||
let frames_per_chunk = (rate as usize / 1000).max(1);
|
||
let mut chunk = vec![0i16; frames_per_chunk * channels];
|
||
let mut phase = 0.0f32;
|
||
let step = std::f32::consts::TAU * hz.clamp(20, 500) as f32 / rate as f32;
|
||
let total = u64::from(rate) * seconds.clamp(1, 30) as u64;
|
||
let mut written = 0u64;
|
||
|
||
while written < total {
|
||
for frame in chunk.chunks_mut(channels) {
|
||
let sample = (phase.sin() * 16_384.0) as i16;
|
||
phase += step;
|
||
if phase >= std::f32::consts::TAU {
|
||
phase -= std::f32::consts::TAU;
|
||
}
|
||
frame.fill(0);
|
||
// Channels 2 and 3 are the voice coils; the speaker pair stays silent so a pass is
|
||
// unambiguously FELT rather than merely audible.
|
||
for c in 2..channels {
|
||
frame[c] = sample;
|
||
}
|
||
}
|
||
if let Err(e) = playback.write_interleaved(&chunk) {
|
||
log::warn!("pad audio self-test: write failed after {written} frames: {e}");
|
||
return SelfTest::WRITE_FAILED;
|
||
}
|
||
written += frames_per_chunk as u64;
|
||
}
|
||
let _ = playback.drain(Duration::from_millis(500));
|
||
|
||
let stats = playback.stats();
|
||
log::info!(
|
||
"pad audio self-test: {} frames, {} urbs, {} underruns, {} short bytes, {} urb errors",
|
||
playback.frames_written(),
|
||
stats.urbs_completed,
|
||
stats.underruns,
|
||
stats.short_bytes,
|
||
stats.urb_errors
|
||
);
|
||
// Underruns are a producer-pacing property and deliberately NOT a failure here: the question
|
||
// this answers is whether the client can drive the pad at all. Data reaching the bus is the
|
||
// pass condition.
|
||
if stats.urb_errors > 0 || playback.frames_written() == 0 {
|
||
return SelfTest::NO_DATA;
|
||
}
|
||
playback.frames_written().min(i32::MAX as u64) as i32
|
||
}
|
||
|
||
/// Negative results from [`self_test`]. Positive values are sample frames written.
|
||
#[cfg(target_os = "android")]
|
||
pub(crate) struct SelfTest;
|
||
|
||
#[cfg(target_os = "android")]
|
||
impl SelfTest {
|
||
/// The claim or stream open failed — the OEM-kernel case, or a descriptor another engine owns.
|
||
pub(crate) const OPEN_FAILED: i32 = -1;
|
||
/// The stream opened but a write failed part-way.
|
||
pub(crate) const WRITE_FAILED: i32 = -2;
|
||
/// It ran, but nothing reached the bus.
|
||
pub(crate) const NO_DATA: i32 = -3;
|
||
}
|
||
|
||
// ---- the renderer worker -----------------------------------------------------------------------
|
||
|
||
/// A running renderer: the stop flag and the thread, joined on drop.
|
||
///
|
||
/// Mirrors [`crate::mic::MicCapture`]'s discipline — dropping the handle is what stops the stream,
|
||
/// so a session teardown that forgets a step cannot leave a thread writing to a descriptor Java is
|
||
/// about to close.
|
||
#[cfg(target_os = "android")]
|
||
pub(crate) struct PadAudio {
|
||
pad: u8,
|
||
stop: Arc<AtomicBool>,
|
||
join: Option<JoinHandle<()>>,
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
impl Drop for PadAudio {
|
||
fn drop(&mut self) {
|
||
self.stop.store(true, Ordering::SeqCst);
|
||
if let Some(j) = self.join.take() {
|
||
let _ = j.join();
|
||
}
|
||
// Belt and braces: the thread clears these itself on the way out, but if it died in a way
|
||
// that skipped that, leaving the pad off wire rumble would cost the user all feedback.
|
||
set_tier_a(self.pad, false);
|
||
clear_haptics_liveness(self.pad);
|
||
}
|
||
}
|
||
|
||
/// Start the renderer for a pad whose descriptor Java has handed over.
|
||
///
|
||
/// Returns `None` when neither kind is enabled (nothing to render) or the thread will not start.
|
||
/// **The caller must keep the `UsbDeviceConnection` open until the returned handle is dropped** —
|
||
/// the renderer borrows the descriptor and never closes it.
|
||
#[cfg(target_os = "android")]
|
||
pub(crate) fn start(
|
||
client: Arc<NativeClient>,
|
||
pad: u8,
|
||
fd: i32,
|
||
haptics: bool,
|
||
speaker: bool,
|
||
) -> Option<PadAudio> {
|
||
if !haptics && !speaker {
|
||
return None;
|
||
}
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let join = spawn(client, Arc::clone(&stop), pad, fd, haptics, speaker)?;
|
||
Some(PadAudio {
|
||
pad,
|
||
stop,
|
||
join: Some(join),
|
||
})
|
||
}
|
||
|
||
/// Spawn the pad-audio renderer — the 0xD1 plane's single consumer on Android.
|
||
///
|
||
/// `fd` is the pad's usbfs descriptor from `UsbDeviceConnection.getFileDescriptor()`; the caller
|
||
/// **must** keep that connection open until [`stop`](AtomicBool) has been observed and the handle
|
||
/// joined. Returns `None` if the thread could not be started.
|
||
#[cfg(target_os = "android")]
|
||
pub(crate) fn spawn(
|
||
client: Arc<NativeClient>,
|
||
stop: Arc<AtomicBool>,
|
||
pad: u8,
|
||
fd: i32,
|
||
haptics: bool,
|
||
speaker: bool,
|
||
) -> Option<JoinHandle<()>> {
|
||
std::thread::Builder::new()
|
||
.name("pf-pad-audio".into())
|
||
.spawn(move || run(&client, &stop, pad, fd, haptics, speaker))
|
||
.map_err(|e| log::warn!("pad-audio thread failed to start: {e}"))
|
||
.ok()
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
fn run(client: &NativeClient, stop: &AtomicBool, pad: u8, fd: i32, haptics: bool, speaker: bool) {
|
||
// Ask the scheduler for audio priority. Android does not hand SCHED_FIFO to ordinary app
|
||
// threads, so -16 (ANDROID_PRIORITY_AUDIO) is the realistic knob — and WP7 measured that it
|
||
// both applies and is enough to hold the 4 ms floor against eight busy cores.
|
||
// SAFETY: `setpriority` on the calling thread; no pointers, no shared state.
|
||
unsafe {
|
||
libc::setpriority(libc::PRIO_PROCESS, 0, -16);
|
||
}
|
||
|
||
// SAFETY: the caller's contract — the Java connection outlives this thread.
|
||
let dev = unsafe { sink::device(fd) };
|
||
// Through a reference, deliberately: `UsbFsDevice` has a `Drop`, and opening the stream in
|
||
// this same scope would make the borrow outlive the value it borrows.
|
||
render(&dev, client, stop, pad, haptics, speaker);
|
||
}
|
||
|
||
/// Open the pad's stream and render on it until the session stops or the device goes away.
|
||
#[cfg(target_os = "android")]
|
||
fn render(
|
||
dev: &usbfs_iso::UsbFsDevice,
|
||
client: &NativeClient,
|
||
stop: &AtomicBool,
|
||
pad: u8,
|
||
haptics: bool,
|
||
speaker: bool,
|
||
) {
|
||
// A host that cannot send 0xD1 will never render anything here, so opening the stream would
|
||
// claim the interface and (before the arbitration below) take the pad off wire rumble in
|
||
// exchange for nothing. Against every released host this is the DEFAULT path — `pad_haptics`
|
||
// is on with no UI to turn it off — so without this gate a wired DualSense simply stops
|
||
// rumbling. Checked before `sink::open` so the iso interface is never claimed pointlessly.
|
||
if client.host_caps() & punktfunk_core::quic::HOST_CAP_PAD_AUDIO == 0 {
|
||
log::warn!("pad audio: host cannot send it (no HOST_CAP_PAD_AUDIO) — pad {pad} stays on wire rumble");
|
||
drain_until_stop(client, stop);
|
||
return;
|
||
}
|
||
match sink::open(dev) {
|
||
Ok(mut playback) => {
|
||
log::info!(
|
||
"pad audio: pad={pad} {} ch {} at {} Hz, {} us in flight",
|
||
playback.channels(),
|
||
playback.format(),
|
||
playback.rate(),
|
||
playback.schedule().in_flight_us()
|
||
);
|
||
// ONLY NOW commit the trade. Declaring the pad's render capability makes the host
|
||
// emit 0xD1, and taking the pad off wire rumble is what makes tier A and tier C
|
||
// mutually exclusive — doing either before the stream is known to open would, on a
|
||
// kernel that refuses the claim, leave the user with no haptics of any kind.
|
||
let caps = (if haptics { 0x01 } else { 0 }) | (if speaker { 0x02 } else { 0 });
|
||
client.set_pad_audio_caps(pad, caps);
|
||
// Arm the HAPTICS lane only — a speaker-only setup drives channels 0/1, which no
|
||
// rumble write can disturb, so taking the motors away would kill rumble with nothing
|
||
// rendering haptics in exchange.
|
||
set_tier_a(pad, haptics);
|
||
|
||
pump(client, stop, pad, haptics, speaker, &mut playback);
|
||
|
||
// Give the pad back to wire rumble before this thread goes away.
|
||
client.set_pad_audio_caps(pad, 0);
|
||
set_tier_a(pad, false);
|
||
clear_haptics_liveness(pad);
|
||
}
|
||
Err(e) => {
|
||
// A kernel that refuses the claim: some OEM kernels do, and there is no app-side fix.
|
||
// Nothing was declared and nothing was suppressed, so the session simply carries on
|
||
// at tier C with ordinary rumble — a clean degrade rather than silent total loss.
|
||
log::warn!("pad audio unavailable on pad {pad}, staying on rumble: {e}");
|
||
drain_until_stop(client, stop);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
/// Keep the plane drained without rendering, so a host that is sending 0xD1 does not back up
|
||
/// against a consumer that never reads.
|
||
fn drain_until_stop(client: &NativeClient, stop: &AtomicBool) {
|
||
while !stop.load(Ordering::Relaxed) {
|
||
if client.next_pad_audio(Duration::from_millis(20)).is_none()
|
||
&& (stop.load(Ordering::Relaxed) || client.is_session_ended())
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The steady state: decode arriving frames, interleave, and hand whole frames to the pad.
|
||
#[cfg(target_os = "android")]
|
||
fn pump(
|
||
client: &NativeClient,
|
||
stop: &AtomicBool,
|
||
pad: u8,
|
||
haptics: bool,
|
||
speaker: bool,
|
||
playback: &mut uac_host::Playback<'_>,
|
||
) {
|
||
let mut mixer = QuadMixer::new();
|
||
let mut streams: [Option<KindStream>; 2] = [None, None];
|
||
// Periodic accounting. Without it the only way to tell "the host is sending nothing" from
|
||
// "frames arrive but render silently" is to guess, and those two have completely different
|
||
// causes — one is host-side routing, the other is here.
|
||
let mut frames_in = 0u64;
|
||
let mut samples_in = 0u64;
|
||
let mut peak = 0i32;
|
||
let mut last_report = std::time::Instant::now();
|
||
// R13: caller-side short-write accounting (distinct from `st.short_bytes`, which is a
|
||
// URB-level statistic from inside the transport).
|
||
let mut st_short = 0u64;
|
||
let mut st_short_logged = std::time::Instant::now();
|
||
let mut pcm: Vec<i16> = Vec::with_capacity(MAX_FRAME_SAMPLES * 2);
|
||
let mut out: Vec<i16> = Vec::with_capacity(MAX_BUFFER_FRAMES * PAD_CHANNELS);
|
||
|
||
while !stop.load(Ordering::Relaxed) {
|
||
// Report BEFORE the frame gate. Silence on the plane is a legitimate — and highly
|
||
// diagnostic — state: it means the host's capture hears nothing, which is a routing
|
||
// problem upstream rather than anything here. Reporting only when a frame arrives makes
|
||
// that state indistinguishable from the renderer being dead.
|
||
if last_report.elapsed() >= Duration::from_secs(1) {
|
||
let st = playback.stats();
|
||
log::info!(
|
||
"pad audio: {frames_in} frames in, {samples_in} samples, peak={peak}, \
|
||
{} written, {} underruns, {} short, {st_short} dropped to back-pressure",
|
||
playback.frames_written(),
|
||
st.underruns,
|
||
st.short_bytes
|
||
);
|
||
last_report = std::time::Instant::now();
|
||
peak = 0;
|
||
}
|
||
|
||
let Some(frame) = client.next_pad_audio(Duration::from_millis(10)) else {
|
||
// R12: `next_pad_audio` collapses a DISCONNECTED channel into the same `None` as an
|
||
// ordinary timeout, so this arm cannot tell "nothing arrived in 10 ms" from "the
|
||
// session is gone and nothing will ever arrive again". Left to `continue`, a closed
|
||
// session span this loop at nice -16 until the owner's stop flag caught up — roughly
|
||
// a second of a real-time-priority thread doing nothing. Ask the connection directly.
|
||
if client.is_session_ended() {
|
||
log::debug!("pad audio: session ended, leaving the render loop");
|
||
break;
|
||
}
|
||
continue;
|
||
};
|
||
|
||
// R14: `PadAudioFrame` carries the wire pad it was addressed to, and this renderer serves
|
||
// exactly one. A frame for another pad — a queue still holding the previous occupant's
|
||
// when a slot is re-used, or a host bug — would otherwise be decoded here AND seed the
|
||
// gap tracker from a foreign sequence space, which shows up as a burst of phantom
|
||
// concealment rather than as anything obviously wrong.
|
||
if frame.pad != pad {
|
||
log::debug!(
|
||
"pad audio: dropping frame for pad {} on pad {pad}",
|
||
frame.pad
|
||
);
|
||
continue;
|
||
}
|
||
|
||
// The settings gate each kind independently: haptics off but speaker on is a legitimate
|
||
// configuration, and the host may still be sending both.
|
||
let wanted = match frame.kind {
|
||
PAD_AUDIO_KIND_HAPTICS => haptics,
|
||
PAD_AUDIO_KIND_SPEAKER => speaker,
|
||
_ => false,
|
||
};
|
||
if !wanted {
|
||
continue;
|
||
}
|
||
|
||
// A real haptics frame is the evidence that the game is driving the coils, and therefore
|
||
// that wire rumble must stand down for this pad (see `haptics_owns_coils`). Stamped on
|
||
// arrival rather than after decode so a decoder hiccup cannot hand the coils back
|
||
// mid-effect; concealment never reaches here, so PLC still does not count.
|
||
if frame.kind == PAD_AUDIO_KIND_HAPTICS {
|
||
note_haptics_frame(pad);
|
||
}
|
||
|
||
frames_in += 1;
|
||
let k = usize::from(frame.kind).min(1);
|
||
let st = match &mut streams[k] {
|
||
Some(s) => s,
|
||
slot @ None => match opus::Decoder::new(SAMPLE_RATE, opus::Channels::Stereo) {
|
||
Ok(dec) => slot.insert(KindStream {
|
||
dec,
|
||
gaps: AudioGapTracker::default(),
|
||
frame_samples: 0,
|
||
}),
|
||
Err(e) => {
|
||
log::warn!("pad audio: no Opus decoder for kind {}: {e}", frame.kind);
|
||
continue;
|
||
}
|
||
},
|
||
};
|
||
|
||
// Conceal whatever the sequence numbers say is missing, before decoding what arrived.
|
||
let missing = plc_frames(&mut st.gaps, frame.seq, st.frame_samples);
|
||
for _ in 0..missing {
|
||
pcm.resize(st.frame_samples * 2, 0);
|
||
match st.dec.decode(&[], &mut pcm, false) {
|
||
Ok(n) => mixer.push(frame.kind, &pcm[..n * 2]),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
|
||
// An empty payload is DTX silence: the tracker has already accounted for the sequence,
|
||
// and there is nothing to decode.
|
||
if !frame.opus.is_empty() {
|
||
pcm.resize(MAX_FRAME_SAMPLES * 2, 0);
|
||
match st.dec.decode(&frame.opus, &mut pcm, false) {
|
||
Ok(n) => {
|
||
st.frame_samples = n;
|
||
samples_in += n as u64;
|
||
// Peak of what actually decoded: distinguishes "frames arriving but silent"
|
||
// (a host-side routing problem) from "frames arriving with signal that is not
|
||
// reaching the actuators" (a problem here).
|
||
peak = peak.max(
|
||
pcm[..n * 2]
|
||
.iter()
|
||
.map(|s| i32::from(s.abs()))
|
||
.max()
|
||
.unwrap_or(0),
|
||
);
|
||
mixer.push(frame.kind, &pcm[..n * 2]);
|
||
}
|
||
Err(e) => log::debug!("pad audio: opus decode failed: {e}"),
|
||
}
|
||
}
|
||
|
||
// Hand over whole frames only. `write` stages any remainder internally, so a partial
|
||
// chunk is never padded with silence mid-stream.
|
||
out.clear();
|
||
if mixer.pop(&mut out) > 0 {
|
||
match playback.write_interleaved(&out) {
|
||
// R13: a SHORT write is back-pressure, not success — the endpoint took `n` frames
|
||
// and the rest is ours to deal with. Discarding the return value dropped the tail
|
||
// with nothing said, so a stalled endpoint sounded like clipped audio with a clean
|
||
// log. We cannot retry from here without unbounded buffering (the mixer's whole
|
||
// point is to stay ahead of the device), so the tail is still dropped — but it is
|
||
// now COUNTED and reported by the 1 s line, which is the difference between a
|
||
// diagnosable stall and a mystery.
|
||
Ok(n) if n < out.len() => {
|
||
st_short += (out.len() - n) as u64;
|
||
if st_short_logged.elapsed() >= Duration::from_secs(5) {
|
||
log::warn!(
|
||
"pad audio: endpoint short-wrote {} of {} samples ({st_short} total) \
|
||
— the device is not keeping up",
|
||
n,
|
||
out.len()
|
||
);
|
||
st_short_logged = std::time::Instant::now();
|
||
}
|
||
}
|
||
Ok(_) => {}
|
||
Err(e) => {
|
||
if is_fatal(&e) {
|
||
log::warn!("pad audio: stream lost: {e}");
|
||
return;
|
||
}
|
||
log::debug!("pad audio: write hiccup: {e}");
|
||
mixer.discard();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = playback.drain(Duration::from_millis(100));
|
||
let stats = playback.stats();
|
||
log::info!(
|
||
"pad audio stopped: {} frames, {} underruns, {} short bytes, {} dropped by backlog",
|
||
playback.frames_written(),
|
||
stats.underruns,
|
||
stats.short_bytes,
|
||
mixer.dropped_frames(),
|
||
);
|
||
}
|
||
|
||
/// Is this the end of the stream, or just a bad moment?
|
||
///
|
||
/// A vanished device is unrecoverable here — the descriptor belongs to a `UsbDeviceConnection`
|
||
/// that Java must re-open — so the thread exits and the session continues without tier A. Anything
|
||
/// else is treated as transient.
|
||
#[cfg(target_os = "android")]
|
||
fn is_fatal(e: &uac_host::Error) -> bool {
|
||
matches!(e, uac_host::Error::Transport(t) if t.is_disconnected())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn speaker_lands_on_the_front_pair_and_haptics_on_the_coils() {
|
||
let mut m = QuadMixer::new();
|
||
m.push(PAD_AUDIO_KIND_SPEAKER, &[100, 200]);
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[300, 400]);
|
||
let mut out = Vec::new();
|
||
assert_eq!(m.pop(&mut out), 1);
|
||
// Channels 0/1 are the speaker, 2/3 are the voice coils — the pad's own layout.
|
||
assert_eq!(out, vec![100, 200, 300, 400]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_haptics_only_session_still_renders_with_a_silent_speaker_pair() {
|
||
// The case that matters most: `pad_speaker = "off"` must not stall the coils waiting for
|
||
// a kind that will never arrive.
|
||
let mut m = QuadMixer::new();
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[7, 8, 9, 10]);
|
||
let mut out = Vec::new();
|
||
assert_eq!(m.pop(&mut out), 2);
|
||
assert_eq!(out, vec![0, 0, 7, 8, 0, 0, 9, 10]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_kinds_never_skew_when_the_ceiling_drops_frames() {
|
||
let mut m = QuadMixer::new();
|
||
// Push well past the ceiling on one kind, then a marker on the other. Both cursors must
|
||
// have moved together, so the marker still lands on the same output frame boundary.
|
||
let flood = vec![1i16; (MAX_BUFFER_FRAMES + 500) * 2];
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &flood);
|
||
assert!(m.dropped_frames() > 0);
|
||
assert_eq!(m.ready_frames(), MAX_BUFFER_FRAMES);
|
||
|
||
m.push(PAD_AUDIO_KIND_SPEAKER, &[42, 43]);
|
||
let mut out = Vec::new();
|
||
let frames = m.pop(&mut out);
|
||
assert_eq!(frames, MAX_BUFFER_FRAMES);
|
||
assert_eq!(out.len(), frames * PAD_CHANNELS);
|
||
// The speaker sample went to the FRONT of the ring (its cursor was reset with the drop),
|
||
// not to wherever the flooded kind happened to be.
|
||
assert_eq!(&out[..4], &[42, 43, 1, 1]);
|
||
}
|
||
|
||
#[test]
|
||
fn interleaving_survives_uneven_cadences() {
|
||
// Haptics arrive at 5 ms and the speaker at 10 ms; popping mid-flight must not lose the
|
||
// lagging kind's alignment.
|
||
let mut m = QuadMixer::new();
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 1, 2, 2]);
|
||
m.push(PAD_AUDIO_KIND_SPEAKER, &[9, 9]);
|
||
let mut out = Vec::new();
|
||
assert_eq!(m.pop(&mut out), 2);
|
||
assert_eq!(out, vec![9, 9, 1, 1, 0, 0, 2, 2]);
|
||
|
||
// Next round: both cursors are back at zero, so a fresh speaker frame aligns with a fresh
|
||
// haptics frame rather than inheriting the previous round's offset.
|
||
out.clear();
|
||
m.push(PAD_AUDIO_KIND_SPEAKER, &[5, 5]);
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[6, 6]);
|
||
assert_eq!(m.pop(&mut out), 1);
|
||
assert_eq!(out, vec![5, 5, 6, 6]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_kind_is_dropped_rather_than_rendered_into_the_coils() {
|
||
let mut m = QuadMixer::new();
|
||
m.push(9, &[999, 999]);
|
||
assert_eq!(
|
||
m.ready_frames(),
|
||
0,
|
||
"an unknown kind must not occupy a channel pair"
|
||
);
|
||
let mut out = Vec::new();
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2]);
|
||
assert_eq!(m.pop(&mut out), 1);
|
||
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!(haptics_armed(3));
|
||
assert!(!haptics_armed(4));
|
||
set_tier_a(4, true);
|
||
assert!(haptics_armed(3) && haptics_armed(4));
|
||
set_tier_a(3, false);
|
||
assert!(!haptics_armed(3), "clearing one pad must not clear another");
|
||
assert!(haptics_armed(4));
|
||
set_tier_a(4, false);
|
||
assert!(!haptics_armed(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!(haptics_armed(0x0f), "0x1f and 0x0f are the same wire slot");
|
||
set_tier_a(0x0f, false);
|
||
assert!(!haptics_armed(0x1f));
|
||
}
|
||
|
||
/// The arbitration that keeps a rumble-only game working. Armed alone is NOT ownership: a
|
||
/// title that never renders haptics audio produces no frames, so the host's silence gate
|
||
/// emits nothing on 0xD1 and the pad must keep its motors.
|
||
#[test]
|
||
fn haptics_owns_the_coils_only_while_frames_actually_arrive() {
|
||
// Never seen a frame — armed, but the game is not driving the coils.
|
||
assert!(
|
||
!haptics_owns_at(true, 0, 10_000),
|
||
"an armed pad that has never received a frame must keep its rumble"
|
||
);
|
||
// A frame just arrived: haptics owns, rumble stands down.
|
||
assert!(haptics_owns_at(true, 10_000, 10_000));
|
||
// Still inside the idle window (the host's own 250 ms hangover, doubled).
|
||
assert!(haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS - 1));
|
||
// The stream went quiet: the coils go back to wire rumble.
|
||
assert!(
|
||
!haptics_owns_at(true, 10_000, 10_000 + HAPTICS_IDLE_MS),
|
||
"the coils must return to rumble once haptics stops arriving"
|
||
);
|
||
// Not armed (speaker-only, or no renderer): frames or not, rumble always owns.
|
||
assert!(!haptics_owns_at(false, 10_000, 10_000));
|
||
}
|
||
|
||
/// Clock skew must never strand a pad in the suppressed state.
|
||
#[test]
|
||
fn a_stamp_ahead_of_now_does_not_wrap_the_idle_window() {
|
||
// The clock is monotonic so this should not arise, but an unsigned underflow would wrap
|
||
// to ~2^64 ms and read as EXPIRED — handing the coils back mid-effect. `saturating_sub`
|
||
// pins it to 0 (still live), and it self-corrects once the clock catches up.
|
||
assert!(haptics_owns_at(true, 10_000, 9_000));
|
||
// The 1-based clock is what makes this distinguishable: a frame stamped in the process's
|
||
// first millisecond must read as LIVE, not as never-stamped.
|
||
assert!(
|
||
haptics_owns_at(true, 1, 1),
|
||
"a frame stamped at t=0 must not be mistaken for never-stamped"
|
||
);
|
||
assert!(
|
||
!haptics_owns_at(true, 0, 0),
|
||
"never-seen stays never-seen at t=0"
|
||
);
|
||
}
|
||
|
||
/// Teardown drops the liveness stamp: wire indices are recycled, and a fresh pad must not
|
||
/// inherit the previous occupant's ownership of the coils.
|
||
#[test]
|
||
fn clearing_liveness_hands_the_coils_back() {
|
||
set_tier_a(2, true);
|
||
note_haptics_frame(2);
|
||
assert!(haptics_owns_coils(2));
|
||
clear_haptics_liveness(2);
|
||
assert!(
|
||
!haptics_owns_coils(2),
|
||
"a cleared stamp must release the coils"
|
||
);
|
||
set_tier_a(2, false);
|
||
}
|
||
|
||
#[test]
|
||
fn discard_empties_without_disturbing_alignment() {
|
||
let mut m = QuadMixer::new();
|
||
m.push(PAD_AUDIO_KIND_HAPTICS, &[1, 2, 3, 4]);
|
||
m.discard();
|
||
assert_eq!(m.ready_frames(), 0);
|
||
let mut out = Vec::new();
|
||
m.push(PAD_AUDIO_KIND_SPEAKER, &[8, 9]);
|
||
assert_eq!(m.pop(&mut out), 1);
|
||
assert_eq!(out, vec![8, 9, 0, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn plc_stays_silent_until_something_has_decoded() {
|
||
let mut g = AudioGapTracker::default();
|
||
// A gap before the first decode has nothing to size concealment from, and must not be
|
||
// replayed later as a phantom.
|
||
assert_eq!(plc_frames(&mut g, 5, 0), 0);
|
||
assert_eq!(plc_frames(&mut g, 6, 480), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn plc_conceals_a_real_gap_once_a_frame_size_is_known() {
|
||
let mut g = AudioGapTracker::default();
|
||
assert_eq!(plc_frames(&mut g, 0, 0), 0);
|
||
assert_eq!(plc_frames(&mut g, 1, 480), 0);
|
||
// Sequence 2 and 3 never arrived.
|
||
assert_eq!(plc_frames(&mut g, 4, 480), 2);
|
||
}
|
||
}
|