Replace the three XAudio kernel-export stubs (Register/Unregister/SubmitFrame) with canary-faithful implementations and add a periodic buffer-complete callback ticker reusing the existing SavedCallbackCtx injection machinery. Canary parity: - xboxkrnl_audio.cc:56-93 — read callback_ptr[0..1], wrap callback_arg in a 4-byte big-endian guest heap buffer (`wrapped_callback_arg`), write `0x4155_xxxx` to *driver_ptr. - audio_system.cc:139-141 — guest callback receives r3 = wrapped pointer, not raw callback_arg. - audio_driver.h:21-24 — frame rate 256 samples / 48 kHz ≈ 5.33 ms. Implementation: - New `crates/xenia-kernel/src/xaudio.rs` — `XAudioClient`, `XAudioState` (8-slot table, pending FIFO, dual-mode ticker), `XAUDIO_INSTR_PERIOD = 48_000` (lockstep) and `XAUDIO_PERIOD = 5.333 ms` (--parallel), same pattern as KRNBUG-D08 v-sync. - `try_inject_audio_callback` in xenia-app mirrors `try_inject_graphics_interrupt`, shares `interrupts.saved` slot for mutex with graphics callbacks. Gating: ticker + injector run only when `--xaudio-tick` / `XENIA_XAUDIO_TICK=1`. Default off because Sylpheed's audio callback enters an infinite `KeWaitForSingleObject` loop on first invocation (canary's host worker thread provides the buffer-completion fence we don't model), which hijacks a guest HW thread and regresses `swaps=2 → 1`. Default-off preserves the lockstep `sylpheed_n*m.json` goldens exactly. Producer hunt outcome (FALSIFIED for parked handles 0x1004/0x100c/0x15e4): at `-n 500M --xaudio-tick` all 3 handles still show `signal_attempts=0 (primary=0, ghost=0)`. Audio callback is not the missing producer. Next candidate per audit-findings.md is Timer DPC delivery (KeSetTimer / KeInsertQueueDpc). Tests: 562 → 576 green (10 in `xaudio.rs`, 4 in `exports.rs`). Lockstep `--stable-digest -n 100M` default-off: instructions=100000002, swaps=2 (matches pre-change baseline byte-for-byte). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
293 lines
9.8 KiB
Rust
293 lines
9.8 KiB
Rust
//! XAudio render-driver-client registration + buffer-complete callback loop
|
|
//! (canary parity: `xenia/apu/audio_system.cc`).
|
|
//!
|
|
//! Replaces the host-thread + per-client-semaphore + XAudio2 driver layer with
|
|
//! a periodic ticker that enqueues a "buffer complete" fire for each
|
|
//! registered client at the audio frame rate (256 samples / 48 kHz ≈ 5.33 ms).
|
|
//! The injection path in `xenia-app` reuses the same [`crate::SavedCallbackCtx`]
|
|
//! plumbing the graphics-interrupt path uses — only one callback runs at a
|
|
//! time across either subsystem, gated by `interrupts.is_in_callback()`.
|
|
//!
|
|
//! Lockstep mode uses an instruction-count proxy
|
|
//! ([`XAUDIO_INSTR_PERIOD`]) so `--stable-digest` stays bit-exact;
|
|
//! `--parallel` uses wall-clock ([`XAUDIO_PERIOD`]) — same dual-mode pattern
|
|
//! as KRNBUG-D08 v-sync.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Mirrors [audio_system.h:30](../../../../xenia-canary/src/xenia/apu/audio_system.h#L30)
|
|
/// `kMaximumClientCount = 8`.
|
|
pub const XAUDIO_MAX_CLIENTS: usize = 8;
|
|
|
|
/// Source code stamped into [`crate::SavedCallbackCtx::source`] when an
|
|
/// audio callback is injected. Distinct from graphics-interrupt sources
|
|
/// (`INTERRUPT_SOURCE_VSYNC = 0`, `INTERRUPT_SOURCE_CP = 1`) so logs and
|
|
/// the audit trail can disambiguate.
|
|
pub const INTERRUPT_SOURCE_AUDIO: u32 = 0x100;
|
|
|
|
/// Lockstep instruction-count period. Picked so the ratio against
|
|
/// [`crate::interrupts::VSYNC_INSTR_PERIOD`] (`150_000`) ≈ 16.67 ms / 5.33 ms,
|
|
/// matching canary's 256 samples / 48 kHz audio cadence.
|
|
pub const XAUDIO_INSTR_PERIOD: u64 = 48_000;
|
|
|
|
/// Wall-clock period under `--parallel`. 256 / 48000 s = 5.333… ms.
|
|
pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333);
|
|
|
|
/// Bound on the pending-fires FIFO. Stops a long-running export from
|
|
/// queueing unbounded callbacks while injection is starved.
|
|
pub const XAUDIO_QUEUE_CAP: usize = 16;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct XAudioClient {
|
|
pub callback_pc: u32,
|
|
pub callback_arg: u32,
|
|
/// Guest pointer to the heap-allocated 4-byte buffer holding
|
|
/// `callback_arg` big-endian — passed as r3 to the guest callback,
|
|
/// matching canary's
|
|
/// [audio_system.cc:225-228](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L225-L228)
|
|
/// + [audio_system.cc:139-141](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L139-L141).
|
|
pub wrapped_callback_arg: u32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct XAudioState {
|
|
pub clients: [Option<XAudioClient>; XAUDIO_MAX_CLIENTS],
|
|
pub pending: VecDeque<usize>,
|
|
pub delivered: u64,
|
|
pub dropped: u64,
|
|
pub accumulator: u64,
|
|
pub last_instr_count: u64,
|
|
pub last_instant: Option<Instant>,
|
|
}
|
|
|
|
impl Default for XAudioState {
|
|
fn default() -> Self {
|
|
Self {
|
|
clients: [None; XAUDIO_MAX_CLIENTS],
|
|
pending: VecDeque::new(),
|
|
delivered: 0,
|
|
dropped: 0,
|
|
accumulator: 0,
|
|
last_instr_count: 0,
|
|
last_instant: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl XAudioState {
|
|
pub fn register(&mut self, client: XAudioClient) -> Option<usize> {
|
|
for (i, slot) in self.clients.iter_mut().enumerate() {
|
|
if slot.is_none() {
|
|
*slot = Some(client);
|
|
return Some(i);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn unregister(&mut self, index: usize) {
|
|
if index < XAUDIO_MAX_CLIENTS {
|
|
self.clients[index] = None;
|
|
self.pending.retain(|&i| i != index);
|
|
}
|
|
}
|
|
|
|
pub fn get(&self, index: usize) -> Option<XAudioClient> {
|
|
self.clients.get(index).copied().flatten()
|
|
}
|
|
|
|
pub fn any_registered(&self) -> bool {
|
|
self.clients.iter().any(|c| c.is_some())
|
|
}
|
|
|
|
fn enqueue_all_active(&mut self) {
|
|
for i in 0..XAUDIO_MAX_CLIENTS {
|
|
if self.clients[i].is_none() {
|
|
continue;
|
|
}
|
|
if self.pending.len() >= XAUDIO_QUEUE_CAP {
|
|
self.dropped += 1;
|
|
return;
|
|
}
|
|
self.pending.push_back(i);
|
|
}
|
|
}
|
|
|
|
pub fn peek_next(&self) -> Option<usize> {
|
|
self.pending.front().copied()
|
|
}
|
|
|
|
pub fn take_next(&mut self) -> Option<usize> {
|
|
self.pending.pop_front()
|
|
}
|
|
|
|
/// Lockstep instruction-count ticker. Idempotently advances the
|
|
/// accumulator from `last_instr_count` to `current_instr_count` and
|
|
/// enqueues one fire-set per full [`XAUDIO_INSTR_PERIOD`] crossed.
|
|
/// Returns `true` iff at least one fire was queued.
|
|
pub fn tick_instr(&mut self, current_instr_count: u64) -> bool {
|
|
if !self.any_registered() {
|
|
self.last_instr_count = current_instr_count;
|
|
self.accumulator = 0;
|
|
return false;
|
|
}
|
|
let delta = current_instr_count.saturating_sub(self.last_instr_count);
|
|
self.last_instr_count = current_instr_count;
|
|
self.accumulator = self.accumulator.saturating_add(delta);
|
|
if self.accumulator < XAUDIO_INSTR_PERIOD {
|
|
return false;
|
|
}
|
|
let periods = self.accumulator / XAUDIO_INSTR_PERIOD;
|
|
self.accumulator %= XAUDIO_INSTR_PERIOD;
|
|
let to_fire = (periods as usize).min(XAUDIO_QUEUE_CAP);
|
|
for _ in 0..to_fire {
|
|
self.enqueue_all_active();
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Wall-clock ticker for `--parallel`. First call seeds the anchor
|
|
/// (no fire). Subsequent calls fire `floor(elapsed / XAUDIO_PERIOD)`
|
|
/// fire-sets and advance the anchor by that many full periods.
|
|
pub fn tick_wallclock(&mut self) -> bool {
|
|
if !self.any_registered() {
|
|
self.last_instant = None;
|
|
return false;
|
|
}
|
|
let now = Instant::now();
|
|
let anchor = match self.last_instant {
|
|
Some(t) => t,
|
|
None => {
|
|
self.last_instant = Some(now);
|
|
return false;
|
|
}
|
|
};
|
|
let elapsed = now.saturating_duration_since(anchor);
|
|
let period_ns = XAUDIO_PERIOD.as_nanos() as u64;
|
|
let elapsed_ns = elapsed.as_nanos() as u64;
|
|
let periods = elapsed_ns / period_ns;
|
|
if periods == 0 {
|
|
return false;
|
|
}
|
|
let advance = Duration::from_nanos(periods * period_ns);
|
|
self.last_instant = Some(anchor + advance);
|
|
let to_fire = (periods as usize).min(XAUDIO_QUEUE_CAP);
|
|
for _ in 0..to_fire {
|
|
self.enqueue_all_active();
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn dummy_client(arg: u32) -> XAudioClient {
|
|
XAudioClient {
|
|
callback_pc: 0x8200_0000 + arg,
|
|
callback_arg: arg,
|
|
wrapped_callback_arg: 0x4000_0000 + arg,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn register_assigns_first_free_slot() {
|
|
let mut s = XAudioState::default();
|
|
let i0 = s.register(dummy_client(1)).unwrap();
|
|
let i1 = s.register(dummy_client(2)).unwrap();
|
|
assert_eq!(i0, 0);
|
|
assert_eq!(i1, 1);
|
|
assert_eq!(s.get(0).unwrap().callback_arg, 1);
|
|
assert_eq!(s.get(1).unwrap().callback_arg, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn unregister_clears_slot_and_pending() {
|
|
let mut s = XAudioState::default();
|
|
let i = s.register(dummy_client(1)).unwrap();
|
|
s.pending.push_back(i);
|
|
s.unregister(i);
|
|
assert!(s.get(i).is_none());
|
|
assert!(s.pending.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn register_returns_none_when_full() {
|
|
let mut s = XAudioState::default();
|
|
for k in 0..XAUDIO_MAX_CLIENTS {
|
|
assert!(s.register(dummy_client(k as u32)).is_some());
|
|
}
|
|
assert!(s.register(dummy_client(99)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tick_instr_no_clients_does_not_fire() {
|
|
let mut s = XAudioState::default();
|
|
assert!(!s.tick_instr(XAUDIO_INSTR_PERIOD * 10));
|
|
assert!(s.pending.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tick_instr_fires_at_period() {
|
|
let mut s = XAudioState::default();
|
|
let i = s.register(dummy_client(7)).unwrap();
|
|
assert!(!s.tick_instr(XAUDIO_INSTR_PERIOD - 1));
|
|
assert!(s.pending.is_empty());
|
|
assert!(s.tick_instr(XAUDIO_INSTR_PERIOD));
|
|
assert_eq!(s.peek_next(), Some(i));
|
|
}
|
|
|
|
#[test]
|
|
fn tick_instr_drains_multiple_periods_in_one_call() {
|
|
let mut s = XAudioState::default();
|
|
let i = s.register(dummy_client(7)).unwrap();
|
|
assert!(s.tick_instr(XAUDIO_INSTR_PERIOD * 4));
|
|
assert_eq!(s.pending.len(), 4);
|
|
for _ in 0..4 {
|
|
assert_eq!(s.take_next(), Some(i));
|
|
}
|
|
assert!(s.pending.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tick_instr_fires_for_each_registered_client() {
|
|
let mut s = XAudioState::default();
|
|
let a = s.register(dummy_client(1)).unwrap();
|
|
let b = s.register(dummy_client(2)).unwrap();
|
|
assert!(s.tick_instr(XAUDIO_INSTR_PERIOD));
|
|
assert_eq!(s.pending.len(), 2);
|
|
assert_eq!(s.take_next(), Some(a));
|
|
assert_eq!(s.take_next(), Some(b));
|
|
}
|
|
|
|
#[test]
|
|
fn tick_instr_caps_queue_growth() {
|
|
let mut s = XAudioState::default();
|
|
s.register(dummy_client(1)).unwrap();
|
|
s.tick_instr(XAUDIO_INSTR_PERIOD * (XAUDIO_QUEUE_CAP as u64 + 50));
|
|
assert!(s.pending.len() <= XAUDIO_QUEUE_CAP);
|
|
}
|
|
|
|
#[test]
|
|
fn tick_wallclock_first_call_seeds_anchor() {
|
|
let mut s = XAudioState::default();
|
|
s.register(dummy_client(1)).unwrap();
|
|
assert!(!s.tick_wallclock());
|
|
assert!(s.pending.is_empty());
|
|
assert!(s.last_instant.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tick_wallclock_fires_after_period() {
|
|
let mut s = XAudioState::default();
|
|
let i = s.register(dummy_client(1)).unwrap();
|
|
s.tick_wallclock();
|
|
std::thread::sleep(XAUDIO_PERIOD + Duration::from_millis(2));
|
|
assert!(s.tick_wallclock());
|
|
assert!(!s.pending.is_empty());
|
|
assert_eq!(s.peek_next(), Some(i));
|
|
}
|
|
}
|