diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs index c20fe94..cb09261 100644 --- a/crates/xenia-kernel/src/xaudio.rs +++ b/crates/xenia-kernel/src/xaudio.rs @@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333); /// queueing unbounded callbacks while injection is starved. pub const XAUDIO_QUEUE_CAP: usize = 16; +/// Phase HostAudioEager (2026-05-19): initial seeded fire count at +/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary +/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210) +/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment +/// canary's `RegisterClient` returns, its already-running host worker +/// thread has 8 buffer-complete fires queued to drain. +/// +/// In ours, the dedicated guest audio worker (spawned at the same +/// register call) can't be HOST-threaded; instead we seed the pending +/// FIFO so the round prologue's `try_inject_audio_callback` injects +/// the first callback on the very next round — well before tid=1 +/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15 +/// in canary, tid=9/10 in ours). This fixes the ordering issue where +/// the 48k-instruction ticker delay let tid=9/10 spawn and enter +/// their spin loop on the uninitialized voice struct before the +/// callback could modify it. +pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8; + #[derive(Debug, Clone, Copy)] pub struct XAudioClient { pub callback_pc: u32, @@ -155,6 +173,28 @@ impl XAudioState { } } + /// Phase HostAudioEager: enqueue `n` buffer-complete fires for a + /// specific client slot. Used by `XAudioRegisterRenderDriverClient` + /// to mirror canary's `client_semaphore->Release(queued_frames_)` + /// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid + /// unbounded growth if the caller seeds aggressively. Returns the + /// actual number of fires enqueued. + pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize { + if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() { + return 0; + } + let mut queued = 0; + for _ in 0..n { + if self.pending.len() >= XAUDIO_QUEUE_CAP { + self.dropped += 1; + break; + } + self.pending.push_back(index); + queued += 1; + } + queued + } + pub fn peek_next(&self) -> Option { self.pending.front().copied() } @@ -320,6 +360,51 @@ mod tests { assert!(s.last_instant.is_some()); } + #[test] + fn seed_fires_for_registered_slot_enqueues_n() { + let mut s = XAudioState::default(); + let i = s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES); + assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES); + assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES); + // All enqueued fires reference our slot. + for _ in 0..XAUDIO_REGISTER_SEED_FIRES { + assert_eq!(s.take_next(), Some(i)); + } + assert!(s.pending.is_empty()); + } + + #[test] + fn seed_fires_for_unregistered_slot_is_noop() { + let mut s = XAudioState::default(); + // Slot 3 is empty. + let queued = s.seed_fires_for(3, 8); + assert_eq!(queued, 0); + assert!(s.pending.is_empty()); + assert_eq!(s.dropped, 0); + } + + #[test] + fn seed_fires_for_caps_at_queue_cap_and_counts_drops() { + let mut s = XAudioState::default(); + let i = s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4); + assert_eq!(queued, XAUDIO_QUEUE_CAP); + assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP); + // Excess fires are counted as dropped (per + // existing `enqueue_all_active` discipline). + assert!(s.dropped >= 1); + } + + #[test] + fn seed_fires_for_out_of_range_index_is_noop() { + let mut s = XAudioState::default(); + s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4); + assert_eq!(queued, 0); + assert!(s.pending.is_empty()); + } + #[test] fn tick_wallclock_fires_after_period() { let mut s = XAudioState::default();