feat(audio): APUBUG-PRODUCER-001 — XAudio register driver client + opt-in callback ticker
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>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<Mutex<KernelState>>.
|
||||
#[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<xenia_cpu::ThreadRef> = 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
292
crates/xenia-kernel/src/xaudio.rs
Normal file
292
crates/xenia-kernel/src/xaudio.rs
Normal file
@@ -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<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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user