diff --git a/audit-findings.md b/audit-findings.md index 083ff3d..080c7cc 100644 --- a/audit-findings.md +++ b/audit-findings.md @@ -4021,3 +4021,99 @@ known signal source on Xbox 360 audio engines; the stub may be hiding the producer for one of the 3 handles. If that lead is also falsified, escalate to file I/O completion (`signal_io_completion_event` already real but possibly mis-routed) or Timer DPC delivery. + +### APUBUG-PRODUCER-001 — XAudioRegisterRenderDriverClient was stub + no callback ticker + +**Status:** fixed (registration + ticker + injection landed). Hypothesis +falsified for handles `0x1004` / `0x100c` / `0x15e4`. + +**Site:** `crates/xenia-kernel/src/exports.rs:2624` (pre-fix); the +`XAudioUnregister*` and `XAudioSubmitRenderDriverFrame` exports +shared the same fate (stubs). New module: `crates/xenia-kernel/src/xaudio.rs`. + +**Canary parity:** +- `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_audio.cc:56-93` + (the three exports — register reads `callback_ptr[0..1]` for the + guest callback PC + arg, allocates a 4-byte heap buffer holding + `callback_arg` big-endian as `wrapped_callback_arg`, and writes + `0x4155_0000 | index` to `*driver_ptr`). +- `xenia-canary/src/xenia/apu/audio_system.cc:202-237` (`RegisterClient`) + + `:100-159` (`WorkerThreadMain` — host worker that waits on + per-client semaphores and calls + `processor_->Execute(callback, args=[wrapped_callback_arg], 1)`, + i.e. r3 = wrapped pointer). +- `xenia-canary/src/xenia/apu/xaudio2/xaudio2_audio_driver.cc:34-36` + (`OnBufferEnd → semaphore_->Release(1)`) — drives the steady-state + cadence at 256 samples / 48 kHz = ~5.33 ms. + +**Implementation:** +- `XAudioRegisterRenderDriverClient`: reads `callback_ptr[0..1]`, + allocates 4-byte guest heap, writes `callback_arg` BE, registers in + the new `XAudioState` table, writes `0x4155_xxxx` to `*driver_ptr`. +- `XAudioUnregisterRenderDriverClient`: clears the slot identified by + `driver_id & 0xFFFF`. +- `XAudioSubmitRenderDriverFrame`: returns `STATUS_SUCCESS` (no + buffer state yet — XmaDecoder unimplemented). +- `XAudioState::tick_instr` (lockstep) and `tick_wallclock` + (`--parallel`) — same dual-mode pattern as KRNBUG-D08 v-sync. + `XAUDIO_INSTR_PERIOD = 48_000` and `XAUDIO_PERIOD = 5.333 ms` + approximate canary's frame rate. +- `try_inject_audio_callback` (xenia-app) injects via the same + `SavedCallbackCtx` machinery as graphics interrupts; mutual + exclusion via the shared `interrupts.saved` slot. r3 is set to + `wrapped_callback_arg` per canary `processor_->Execute`. + +**Gating:** the periodic ticker + injector run only when +`--xaudio-tick` / `XENIA_XAUDIO_TICK=1` is set. Default off because +firing the callback hijacks a guest HW thread (we don't have a +dedicated host worker thread) and Sylpheed's callback enters +something resembling an infinite wait loop on its first invocation, +which regresses `swaps=2 → 1` and explodes `imports` 12× at -n 100M. +Default-off preserves all existing lockstep goldens +(`sylpheed_n50m.json` etc.). + +**Verification:** +- Workspace tests: 562 → 576 green (10 in `xaudio.rs` + 4 in + `exports.rs`). +- `--stable-digest -n 100M` lockstep, default off: + `instructions=100000002`, `swaps=2`, `imports=987685` — IDENTICAL + to pre-change baseline; goldens unaffected. +- `--stable-digest -n 100M --xaudio-tick`: `instructions=100000001` + (1-instr boundary shift, deterministic across runs — verified by + byte-identical digest JSON), `swaps=1` (regression), `imports=12.3M` + (mostly `KeWaitForSingleObject` — 4M calls — confirming the + callback enters a tight wait loop). 1 audio callback fires + (`xaudio.callback.delivered = 1`) but apparently never returns to + `LR_HALT_SENTINEL`, so subsequent fires are gated out by + `is_in_callback() == true`. +- `--xaudio-tick -n 500M --halt-on-deadlock --trace-handles-focus`: + all 3 handles still show `signal_attempts=0 (primary=0, ghost=0)`. + +**Outcome — falsified for this set of handles:** running the audio +buffer-complete callback once does **not** wake handles `0x1004` / +`0x100c` / `0x15e4`. The producer is not the audio path (or, more +weakly: it's not the *first* iteration of the audio callback). + +**Side effects worth noting for the next session:** +1. The fact that the audio callback fires once and apparently never + returns is itself diagnostic — Sylpheed's audio callback waits on + *something* the canary worker provides (probably a semaphore + credit on `client_semaphore`, drained by `OnBufferEnd`). Our + `XAudioSubmitRenderDriverFrame` is a stub; if a future session + wires the audio submit → buffer-completion-event → next-callback + loop properly, the callback might return and the question + re-opens. +2. The SavedCallbackCtx-injection mechanism is a poor fit for + blocking callbacks. Canary uses a dedicated `XHostThread` + (audio worker) that runs each callback on its own stack. If we + want clean audio-callback semantics we'd need a similar + per-driver guest-thread spawn at registration time. + +### Recommended next producer candidate (post-APUBUG-PRODUCER-001) + +Per the producer-hunt charter the remaining strong candidates are +**Timer DPC delivery** (`KeSetTimer` / `KeInsertQueueDpc` — +`exports.rs` has stubs/partials) and **file I/O completion event +routing**. Timer DPC is the next-strongest because the parked +handles are explicit `Event/Manual`s with no current waker, and +Xbox 360 timer-driven DPCs are a common signal source. diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index 08b411f..747c4b2 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -166,6 +166,16 @@ enum Commands { /// `XENIA_PARALLEL=1`. #[arg(long)] parallel: bool, + /// **APUBUG-PRODUCER-001 diagnostic.** When set, the round + /// prologue ticks the XAudio buffer-complete callback at the + /// audio frame rate and injects via the same SavedCallbackCtx + /// machinery as graphics interrupts. Default off — firing + /// shifts the boot trajectory enough to regress `swaps=2→1` + /// under Sylpheed, which would break the lockstep + /// `sylpheed_n*m.json` goldens. Settable via + /// `XENIA_XAUDIO_TICK=1`. + #[arg(long)] + xaudio_tick: bool, }, /// Browse XISO disc image contents Browse { @@ -255,6 +265,11 @@ enum Commands { /// M3 — spawn HW-thread host workers under coarse Arc>. #[arg(long)] parallel: bool, + /// APUBUG-PRODUCER-001 diagnostic — enable the XAudio buffer-complete + /// callback ticker. Default off; see the `Exec --xaudio-tick` doc for + /// rationale. + #[arg(long)] + xaudio_tick: bool, }, } @@ -315,6 +330,7 @@ fn main() -> Result<()> { gpu_inline, reservations_table, parallel, + xaudio_tick, } => cmd_exec( &path, max_instructions, @@ -332,6 +348,7 @@ fn main() -> Result<()> { gpu_inline, reservations_table, parallel, + xaudio_tick, ), Commands::Browse { path } => cmd_browse(&path), Commands::Info { path } => cmd_info(&path), @@ -347,6 +364,7 @@ fn main() -> Result<()> { gpu_inline, reservations_table, parallel, + xaudio_tick, } => cmd_check( &path, max_instructions, @@ -357,6 +375,7 @@ fn main() -> Result<()> { gpu_inline, reservations_table, parallel, + xaudio_tick, ), }; @@ -530,6 +549,7 @@ fn cmd_exec( gpu_inline: bool, reservations_table: bool, parallel: bool, + xaudio_tick: bool, ) -> Result<()> { cmd_exec_inner( path, @@ -548,6 +568,7 @@ fn cmd_exec( gpu_inline, reservations_table, parallel, + xaudio_tick, None, None, false, @@ -566,6 +587,7 @@ fn cmd_check( gpu_inline: bool, reservations_table: bool, parallel: bool, + xaudio_tick: bool, ) -> Result<()> { cmd_exec_inner( path, @@ -584,6 +606,7 @@ fn cmd_check( gpu_inline, reservations_table, parallel, + xaudio_tick, out, expect, stable_digest, @@ -607,6 +630,7 @@ fn cmd_exec_inner( gpu_inline: bool, reservations_table: bool, parallel: bool, + xaudio_tick: bool, digest_out: Option<&str>, digest_expect: Option<&str>, stable_digest: bool, @@ -787,6 +811,18 @@ fn cmd_exec_inner( }); let parallel_active = parallel || parallel_via_env; kernel.parallel_active = parallel_active; + let xaudio_tick_via_env = std::env::var("XENIA_XAUDIO_TICK") + .ok() + .is_some_and(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" || v == "yes" + }); + kernel.xaudio_tick_enabled = xaudio_tick || xaudio_tick_via_env; + if kernel.xaudio_tick_enabled && !quiet { + tracing::info!( + "XAudio callback ticker enabled (--xaudio-tick / XENIA_XAUDIO_TICK=1)" + ); + } if reservations_table || reservations_via_env || parallel_active { kernel.reservations.enable(); if !quiet { @@ -1546,8 +1582,19 @@ fn coord_pre_round( .store(prev | 0x1, Ordering::Relaxed); } + if kernel.xaudio_tick_enabled { + if kernel.parallel_active { + kernel.xaudio.tick_wallclock(); + } else { + kernel.xaudio.tick_instr(stats.instruction_count); + } + } + kernel.fire_due_timers(); try_inject_graphics_interrupt(kernel); + if kernel.xaudio_tick_enabled { + try_inject_audio_callback(kernel); + } RoundCtl::Continue } @@ -2805,6 +2852,94 @@ fn try_inject_graphics_interrupt(kernel: &mut xenia_kernel::KernelState) { ); } +/// APUBUG-PRODUCER-001 — inject a pending XAudio buffer-complete callback +/// into the next available HW thread. Mirrors +/// [`try_inject_graphics_interrupt`] (same victim-selection policy, same +/// SP-pad, same saved-context restore-on-sentinel) but pulls the callback +/// PC + r3 argument from a registered [`xenia_kernel::xaudio::XAudioClient`] +/// instead of the graphics-callback registration. +/// +/// Mutual exclusion with the graphics path is via the shared +/// `interrupts.saved` slot — if a graphics callback is already in flight, +/// `is_in_callback()` returns true and we bail until it returns to the +/// `LR_HALT_SENTINEL`. +fn try_inject_audio_callback(kernel: &mut xenia_kernel::KernelState) { + use xenia_cpu::scheduler::HwState; + + if kernel.interrupts.is_in_callback() { + return; + } + let Some(index) = kernel.xaudio.peek_next() else { + return; + }; + let Some(client) = kernel.xaudio.get(index) else { + let _ = kernel.xaudio.take_next(); + kernel.xaudio.dropped += 1; + return; + }; + + let mut victim: Option = None; + 'outer_ready: for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() { + for (idx, t) in slot.runqueue.iter().enumerate() { + if matches!(t.state, HwState::Ready) { + victim = Some(xenia_cpu::ThreadRef::new(hw_id as u8, idx as u16)); + break 'outer_ready; + } + } + } + if victim.is_none() { + 'outer_blocked: for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() { + for (idx, t) in slot.runqueue.iter().enumerate() { + if matches!(t.state, HwState::Blocked(_)) { + victim = Some(xenia_cpu::ThreadRef::new(hw_id as u8, idx as u16)); + break 'outer_blocked; + } + } + } + } + let Some(target_ref) = victim else { + let _ = kernel.xaudio.take_next(); + kernel.xaudio.dropped += 1; + return; + }; + + let t = kernel.scheduler.thread_mut(target_ref); + let prev_state = t.state.clone(); + match prev_state { + HwState::Ready => {} + HwState::Blocked(reason) => { + t.state = HwState::ServicingIrq(reason); + } + _ => unreachable!("victim selection above filtered out other variants"), + } + + let _ = kernel.xaudio.take_next(); + let t = kernel.scheduler.thread_mut(target_ref); + let saved = xenia_kernel::SavedCallbackCtx::capture( + &t.ctx, + xenia_kernel::INTERRUPT_SOURCE_AUDIO, + ); + kernel.interrupts.injected_ref = Some(target_ref); + t.ctx.pc = client.callback_pc; + t.ctx.lr = xenia_cpu::context::LR_HALT_SENTINEL; + t.ctx.gpr[1] = t + .ctx + .gpr[1] + .wrapping_sub(xenia_kernel::interrupts::CALLBACK_STACK_PAD as u64); + t.ctx.gpr[3] = client.wrapped_callback_arg as u64; + kernel.interrupts.saved = Some(saved); + kernel.xaudio.delivered += 1; + metrics::counter!("xaudio.callback.delivered").increment(1); + tracing::debug!( + index, + hw_id = target_ref.hw_id, + idx = target_ref.idx, + callback = format_args!("{:#010x}", client.callback_pc), + wrapped = format_args!("{:#010x}", client.wrapped_callback_arg), + "xaudio callback: injecting" + ); +} + fn print_summary( ctx: &xenia_cpu::PpcContext, debugger: &xenia_debugger::Debugger, diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index d410e88..40ee9e5 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -177,8 +177,8 @@ pub fn register_exports(state: &mut KernelState) { // Audio state.register_export(Xboxkrnl, 0x01F3, "XAudioRegisterRenderDriverClient", xaudio_register_render_driver); - state.register_export(Xboxkrnl, 0x01F4, "XAudioUnregisterRenderDriverClient", stub_success); - state.register_export(Xboxkrnl, 0x01F5, "XAudioSubmitRenderDriverFrame", stub_success); + state.register_export(Xboxkrnl, 0x01F4, "XAudioUnregisterRenderDriverClient", xaudio_unregister_render_driver); + state.register_export(Xboxkrnl, 0x01F5, "XAudioSubmitRenderDriverFrame", xaudio_submit_render_driver_frame); state.register_export(Xboxkrnl, 0x01F7, "XAudioGetVoiceCategoryVolumeChangeMask", stub_return_zero); state.register_export(Xboxkrnl, 0x01F8, "XAudioGetVoiceCategoryVolume", stub_success); state.register_export(Xboxkrnl, 0x0224, "XMACreateContext", xma_create_context); @@ -2621,10 +2621,68 @@ fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // ===== Audio ===== -fn xaudio_register_render_driver(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { - let handle = state.alloc_handle(); - tracing::info!("XAudioRegisterRenderDriverClient: handle={:#x}", handle); - // r3 = callback_ptr, r4 = driver_ptr -> write handle +const X_E_INVALIDARG: u64 = 0x8007_0057; +const XAUDIO_DRIVER_TAG: u32 = 0x4155_0000; +const XAUDIO_DRIVER_INDEX_MASK: u32 = 0x0000_FFFF; + +fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { + let callback_ptr = ctx.gpr[3] as u32; + let driver_ptr = ctx.gpr[4] as u32; + if callback_ptr == 0 { + ctx.gpr[3] = X_E_INVALIDARG; + return; + } + let callback_pc = mem.read_u32(callback_ptr); + if callback_pc == 0 { + ctx.gpr[3] = X_E_INVALIDARG; + return; + } + let callback_arg = mem.read_u32(callback_ptr.wrapping_add(4)); + + let Some(wrapped) = state.heap_alloc(4, mem) else { + tracing::warn!("XAudioRegisterRenderDriverClient: heap_alloc(4) failed"); + ctx.gpr[3] = X_E_INVALIDARG; + return; + }; + mem.write_u32(wrapped, callback_arg); + + let client = crate::xaudio::XAudioClient { + callback_pc, + callback_arg, + wrapped_callback_arg: wrapped, + }; + let Some(index) = state.xaudio.register(client) else { + tracing::warn!("XAudioRegisterRenderDriverClient: client table full"); + ctx.gpr[3] = X_E_INVALIDARG; + return; + }; + let driver_id = XAUDIO_DRIVER_TAG | (index as u32 & XAUDIO_DRIVER_INDEX_MASK); + if driver_ptr != 0 { + mem.write_u32(driver_ptr, driver_id); + } + tracing::info!( + "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x}", + index, callback_pc, callback_arg, wrapped, driver_id, + ); + ctx.gpr[3] = 0; +} + +fn xaudio_unregister_render_driver(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { + let driver_id = ctx.gpr[3] as u32; + let index = (driver_id & XAUDIO_DRIVER_INDEX_MASK) as usize; + state.xaudio.unregister(index); + tracing::info!( + "XAudioUnregisterRenderDriverClient: driver={:#010x} index={}", + driver_id, index, + ); + ctx.gpr[3] = 0; +} + +fn xaudio_submit_render_driver_frame( + ctx: &mut PpcContext, + _mem: &GuestMemory, + _state: &mut KernelState, +) { ctx.gpr[3] = 0; } @@ -5181,4 +5239,99 @@ mod tests { assert_ne!(h_main, h_krnl, "main module distinct from xboxkrnl"); assert_ne!(h_krnl, h_xam, "xboxkrnl distinct from xam"); } + + /// `XAudioRegisterRenderDriverClient` records the (callback, arg) pair, + /// allocates a 4-byte heap buffer holding `callback_arg` in big-endian, + /// and writes `0x4155_xxxx` to `*driver_ptr`. Mirrors canary + /// [audio_system.cc:202-237](../../../xenia-canary/src/xenia/apu/audio_system.cc#L202-L237). + #[test] + fn xaudio_register_records_client_and_writes_driver_id() { + let (mut ctx, mem, mut state) = fresh(); + let cb_block = SCRATCH_BASE + 0x100; + let driver_out = SCRATCH_BASE + 0x200; + mem.write_u32(cb_block, 0x8200_BEEF); + mem.write_u32(cb_block + 4, 0xDEAD_F00D); + ctx.gpr[3] = cb_block as u64; + ctx.gpr[4] = driver_out as u64; + + xaudio_register_render_driver(&mut ctx, &mem, &mut state); + + assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS"); + let driver_id = mem.read_u32(driver_out); + assert_eq!(driver_id & 0xFFFF_0000, 0x4155_0000); + let index = (driver_id & 0x0000_FFFF) as usize; + let client = state.xaudio.get(index).expect("client must be registered"); + assert_eq!(client.callback_pc, 0x8200_BEEF); + assert_eq!(client.callback_arg, 0xDEAD_F00D); + assert_ne!(client.wrapped_callback_arg, 0); + assert_eq!( + mem.read_u32(client.wrapped_callback_arg), + 0xDEAD_F00D, + "wrapped buffer must hold callback_arg big-endian" + ); + } + + /// Null `callback_ptr` or null callback function returns `X_E_INVALIDARG` + /// without registering — canary + /// [xboxkrnl_audio.cc:58-66](../../../xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_audio.cc#L58-L66). + #[test] + fn xaudio_register_rejects_null_inputs() { + let (mut ctx, mem, mut state) = fresh(); + ctx.gpr[3] = 0; + ctx.gpr[4] = (SCRATCH_BASE + 0x300) as u64; + xaudio_register_render_driver(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], X_E_INVALIDARG); + assert!(!state.xaudio.any_registered()); + + let cb_block = SCRATCH_BASE + 0x400; + mem.write_u32(cb_block, 0); // callback function = null + mem.write_u32(cb_block + 4, 0xCAFE); + ctx.gpr[3] = cb_block as u64; + ctx.gpr[4] = (SCRATCH_BASE + 0x500) as u64; + xaudio_register_render_driver(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], X_E_INVALIDARG); + assert!(!state.xaudio.any_registered()); + } + + /// Unregister clears the slot identified by the lower 16 bits of the + /// driver token. + #[test] + fn xaudio_unregister_clears_slot() { + let (mut ctx, mem, mut state) = fresh(); + let cb_block = SCRATCH_BASE + 0x100; + let driver_out = SCRATCH_BASE + 0x200; + mem.write_u32(cb_block, 0x8200_AAAA); + mem.write_u32(cb_block + 4, 0xBBBB_BBBB); + ctx.gpr[3] = cb_block as u64; + ctx.gpr[4] = driver_out as u64; + xaudio_register_render_driver(&mut ctx, &mem, &mut state); + let driver_id = mem.read_u32(driver_out); + let index = (driver_id & 0x0000_FFFF) as usize; + assert!(state.xaudio.get(index).is_some()); + + ctx.gpr[3] = driver_id as u64; + xaudio_unregister_render_driver(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0); + assert!(state.xaudio.get(index).is_none()); + } + + /// Once a client is registered, the lockstep ticker eventually queues + /// a fire — proves the producer pipeline is wired end-to-end through + /// the kernel state. + #[test] + fn xaudio_register_then_tick_instr_queues_callback() { + let (mut ctx, mem, mut state) = fresh(); + let cb_block = SCRATCH_BASE + 0x100; + let driver_out = SCRATCH_BASE + 0x200; + mem.write_u32(cb_block, 0x8200_C0DE); + mem.write_u32(cb_block + 4, 0xFEED_FACE); + ctx.gpr[3] = cb_block as u64; + ctx.gpr[4] = driver_out as u64; + xaudio_register_render_driver(&mut ctx, &mem, &mut state); + + assert!(state.xaudio.tick_instr(crate::xaudio::XAUDIO_INSTR_PERIOD)); + let i = state.xaudio.peek_next().expect("must queue a fire"); + let client = state.xaudio.get(i).unwrap(); + assert_eq!(client.callback_pc, 0x8200_C0DE); + } } diff --git a/crates/xenia-kernel/src/lib.rs b/crates/xenia-kernel/src/lib.rs index 9cdceb9..13a15d0 100644 --- a/crates/xenia-kernel/src/lib.rs +++ b/crates/xenia-kernel/src/lib.rs @@ -7,6 +7,7 @@ pub mod state; pub mod thread; pub mod ui_bridge; pub mod xam; +pub mod xaudio; pub use interrupts::{ GraphicsInterruptCallback, InterruptState, SavedCallbackCtx, INTERRUPT_SOURCE_CP, @@ -15,3 +16,7 @@ pub use interrupts::{ pub use state::{KernelState, ModuleId}; pub use thread::{allocate_thread_image, ThreadImage}; pub use ui_bridge::{SwapInfo, UiBridge}; +pub use xaudio::{ + XAudioClient, XAudioState, INTERRUPT_SOURCE_AUDIO, XAUDIO_INSTR_PERIOD, XAUDIO_MAX_CLIENTS, + XAUDIO_PERIOD, +}; diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs index 25380fb..8dc6d55 100644 --- a/crates/xenia-kernel/src/state.rs +++ b/crates/xenia-kernel/src/state.rs @@ -104,6 +104,23 @@ pub struct KernelState { /// the callback set by `VdSetGraphicsInterruptCallback` and tracks /// the paused-context snapshot while HW thread 0 is running it. pub interrupts: crate::interrupts::InterruptState, + /// XAudio render-driver clients + buffer-complete callback ticker. + /// Mirrors canary's [`xenia/apu/audio_system.cc`] worker — registered + /// guest callbacks can fire at the audio frame rate so guest threads + /// parked on audio-buffer events get woken (APUBUG-PRODUCER-001). + /// Shares the [`crate::interrupts::InterruptState::saved`] / + /// `injected_ref` slot at injection time; mutual exclusion with + /// graphics interrupts is enforced by the injector's + /// `is_in_callback()` guard. + pub xaudio: crate::xaudio::XAudioState, + /// Default false. When true, the round prologue runs the XAudio + /// ticker + `try_inject_audio_callback`. Off by default because the + /// callback firing shifts the boot trajectory under Sylpheed + /// (regresses `swaps=2`→`1` and 12×s `imports`), which would break + /// the `sylpheed_n*m.json` lockstep goldens. Flipped on by + /// `--xaudio-tick` / `XENIA_XAUDIO_TICK=1` for the diagnostic + /// producer-hunt path. + pub xaudio_tick_enabled: bool, /// Per-handle refcount. Since `NtDuplicateObject` aliases (returns the /// source handle value as the "new" handle rather than minting a fresh /// id), a single handle commonly has multiple logical references. This @@ -202,6 +219,8 @@ impl KernelState { vfs: None, ui: None, interrupts: crate::interrupts::InterruptState::default(), + xaudio: crate::xaudio::XAudioState::default(), + xaudio_tick_enabled: false, handle_refcount: HashMap::new(), pending_timer_fires: Vec::new(), audit: HandleAudit::default(), diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs new file mode 100644 index 0000000..4c40122 --- /dev/null +++ b/crates/xenia-kernel/src/xaudio.rs @@ -0,0 +1,292 @@ +//! 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; XAUDIO_MAX_CLIENTS], + pub pending: VecDeque, + pub delivered: u64, + pub dropped: u64, + pub accumulator: u64, + pub last_instr_count: u64, + pub last_instant: Option, +} + +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 { + 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 { + 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 { + self.pending.front().copied() + } + + pub fn take_next(&mut self) -> Option { + 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)); + } +}