[iterate-4A] Milestone-2: XMA audio decoder + RE tooling (dispatch recorder, analyzer vtable-fix, non-perturbing probes)

Milestone-2 (intro video dat/movie/ADV.wmv) audio path + major RE tooling.

XMA AUDIO (built, working, deterministic, tested):
- APU MMIO 0x7FEA0000 + 320x64B register-mapped context array; real XMACreateContext/Release
  (xma.rs); real FFmpeg xma2 decoder XMA_CONTEXT_DATA->S16BE PCM (xma_decode.rs, xma2_codec.rs,
  ffmpeg-sys-next). Decode runs synchronously on the CPU thread (deterministic, no host thread).
- Audio-worker scheduler fix (main.rs LR_HALT restore + scheduler.rs): the XAudio render-callback
  worker was wrongly exited after ~2 deliveries; now survives -> guest drives XMA decode (70 kicks).
- XAudioSubmitRenderDriverFrame made faithful. Golden sylpheed_n50m re-baselined; tests pass.

RE TOOLING:
- Runtime indirect-dispatch recorder (dispatch_rec.rs): records (call-site->target, r3, lr);
  env-gated XENIA_DISPATCH_REC, filters XENIA_DISPATCH_REC_TARGETS/_SITES; deterministic, observe-only.
- Repaired static analyzer (vtables.rs): vtable extraction silently fragmented vtables with
  non-function head slots (missed the XMV engine vtable). Fixed via vptr-write-anchoring -> engine
  fully typed (vtables 722->1150 on rebuild).
- Fixed probe HEISENBUG (main.rs run_superblock): --audit-pc-probe-hex/--mem-watch no longer disable
  superblock chaining; probes fire inside the chain loop -> scheduling identical armed-vs-unarmed,
  movie subsystem now observable. Fixed a --quiet bug swallowing armed trace reports.

VIDEO still doesn't play (B, guest-side): the XMV engine never issues begin-playback (sub_825076F0,
vtable 0x8200a1e8 slot21) -> never primes -> 2000ms timeout. Narrowed to the ARM2 engine-setup
wrappers; no honest our-side gate-fix (masking forbidden). See HANDOFF-iterate-4A-milestone2.md for
new-machine setup (incl. the FFmpeg apt deps + sylpheed.db regeneration) and continuation pointers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-21 21:38:19 +02:00
parent acb29db444
commit 23189b95af
19 changed files with 3106 additions and 46 deletions

View File

@@ -11,6 +11,7 @@ xenia-cpu = { workspace = true }
xenia-vfs = { workspace = true }
xenia-hid = { workspace = true }
xenia-gpu = { workspace = true }
xenia-apu = { workspace = true }
tracing = { workspace = true }
metrics = { workspace = true }
thiserror = { workspace = true }

View File

@@ -182,7 +182,7 @@ pub fn register_exports(state: &mut KernelState) {
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);
state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", stub_success);
state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", xma_release_context);
// Crypto
state.register_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success);
@@ -3398,6 +3398,7 @@ fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state:
callback_pc,
callback_arg,
wrapped_callback_arg: wrapped,
submitted_frames: 0,
};
let Some(index) = state.xaudio.register(client) else {
tracing::warn!("XAudioRegisterRenderDriverClient: client table full");
@@ -3506,18 +3507,75 @@ fn xaudio_unregister_render_driver(ctx: &mut PpcContext, _mem: &GuestMemory, sta
ctx.gpr[3] = 0;
}
/// Mirrors canary `XAudioSubmitRenderDriverFrame_entry` →
/// `AudioSystem::SubmitFrame(driver_ptr & 0xFFFF, samples)`:
/// the guest render-driver mixer (`sub_824DC350`) calls this once per audio
/// frame with `r3 = driver_id` (`0x4155_xxxx`) and `r4 = sample buffer`.
/// Canary forwards `samples` to the client's `AudioDriver`; the driver's
/// playback-completion callback later releases the client semaphore, which is
/// the buffer-consumed pacing our XAudio callback ticker
/// (`tick_instr` + `try_inject_audio_callback`) already drives. SubmitFrame
/// returns void and the caller discards r3 / reads no field SubmitFrame
/// writes, so faithfully we validate the client index and account the frame
/// (observational; never read back by the guest). Always returns
/// `X_ERROR_SUCCESS`, matching canary. Deterministic: only this guest-driven
/// export mutates state; no wall-clock, no host thread.
fn xaudio_submit_render_driver_frame(
ctx: &mut PpcContext,
_mem: &GuestMemory,
_state: &mut KernelState,
state: &mut KernelState,
) {
let driver_id = ctx.gpr[3] as u32;
let index = (driver_id & XAUDIO_DRIVER_INDEX_MASK) as usize;
let registered = state.xaudio.record_submit(index);
if !registered {
// Canary logs and submits silence to keep the callback chain alive
// for an unregistered/invalid index; our ticker keeps the chain
// alive independently, so a debug log suffices.
tracing::debug!(
driver_id = format_args!("{driver_id:#010x}"),
index,
"XAudioSubmitRenderDriverFrame: unregistered client index"
);
} else if state.xaudio.submitted_frames(index) == 1 {
tracing::info!(
driver_id = format_args!("{driver_id:#010x}"),
index,
"XAudioSubmitRenderDriverFrame: first frame submitted by guest mixer"
);
}
ctx.gpr[3] = 0;
}
fn xma_create_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let handle = state.alloc_handle();
tracing::info!("XMACreateContext: handle={:#x}", handle);
ctx.gpr[3] = handle as u64;
/// Mirrors xenia-canary `XMACreateContext_entry(lpdword_t context_out_ptr)`:
/// allocate a context from the register-mapped array, write its guest pointer
/// to `*context_out_ptr`, and return `X_STATUS_SUCCESS` (or `X_STATUS_NO_MEMORY`
/// when the 320-slot array is exhausted).
fn xma_create_context(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let out_ptr = ctx.gpr[3] as u32;
let context_ptr = state.xma.lock().unwrap().allocate_context();
if out_ptr != 0 {
mem.write_u32(out_ptr, context_ptr);
}
tracing::info!(
out_ptr = format_args!("{out_ptr:#010x}"),
context_ptr = format_args!("{context_ptr:#010x}"),
"XMACreateContext"
);
ctx.gpr[3] = if context_ptr == 0 {
0xC000_0017 // X_STATUS_NO_MEMORY
} else {
0 // X_STATUS_SUCCESS
};
}
/// Mirrors xenia-canary `XMAReleaseContext_entry(lpvoid_t context_ptr)`:
/// free the context slot and return 0.
fn xma_release_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let context_ptr = ctx.gpr[3] as u32;
state.xma.lock().unwrap().release_context(context_ptr);
tracing::info!(context_ptr = format_args!("{context_ptr:#010x}"), "XMAReleaseContext");
ctx.gpr[3] = 0;
}
// ===== Xex =====
@@ -4413,7 +4471,8 @@ fn nt_yield_execution(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut Ker
}
fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let raw = ctx.gpr[3] as u32;
let handle = resolve_pseudo_handle(state, raw);
match state.scheduler.find_by_handle(handle) {
Some(r) => {
state.scheduler.resume_ref(r);
@@ -4429,13 +4488,18 @@ fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
// r3 = handle, r4 = prev_suspend_count_ptr
let handle = ctx.gpr[3] as u32;
let prev_ptr = ctx.gpr[4] as u32;
let prev = state
.scheduler
.find_by_handle(handle)
.map(|r| state.scheduler.resume_ref(r))
.unwrap_or(0);
if prev_ptr != 0 {
mem.write_u32(prev_ptr, prev);
match state.scheduler.find_by_handle(handle) {
Some(r) => {
let prev = state.scheduler.resume_ref(r);
if prev_ptr != 0 {
mem.write_u32(prev_ptr, prev);
}
}
None => {
if prev_ptr != 0 {
mem.write_u32(prev_ptr, 0);
}
}
}
ctx.gpr[3] = STATUS_SUCCESS;
}

View File

@@ -161,6 +161,11 @@ pub struct KernelState {
/// graphics interrupts is enforced by the injector's
/// `is_in_callback()` guard.
pub xaudio: crate::xaudio::XAudioState,
/// Register-mapped XMA context array (apu stage 1). Shared with the
/// `0x7FEA0000` MMIO region installed by the app and with the
/// `XMACreateContext`/`XMAReleaseContext` exports, so it lives behind an
/// `Arc<Mutex<…>>`. Stage 1 records kicks; stage 3 will decode them.
pub xma: std::sync::Arc<std::sync::Mutex<xenia_apu::XmaDecoder>>,
/// AUDIT-032 Plan B (default true). When true, the round prologue
/// runs the XAudio ticker + `try_inject_audio_callback`. Pre-fix this
/// was off by default because injection used random-victim selection
@@ -449,6 +454,9 @@ impl KernelState {
ui: None,
interrupts: crate::interrupts::InterruptState::default(),
xaudio: crate::xaudio::XAudioState::default(),
// apu stage 1 — un-initialized until the app reserves the context
// array and calls `xma.lock().init(va, phys)`.
xma: std::sync::Arc::new(std::sync::Mutex::new(xenia_apu::XmaDecoder::new())),
// AUDIT-032: dedicated audio worker per client (Plan B in
// `xaudio_register_render_driver`) — not victim hijack, so safe
// to enable by default. Previously gated off because the

View File

@@ -35,6 +35,14 @@ pub const XAUDIO_MAX_CLIENTS: usize = 8;
/// no-op anyway).
pub const XAUDIO_SYNTHETIC_HANDLE_BASE: u32 = 0xF000_0000;
/// The scheduler's deadlock force-wake skips waiters parked solely on
/// handles at/above [`xenia_cpu::scheduler::SYNTHETIC_PARK_HANDLE_FLOOR`]
/// so it never destroys a parked audio worker. Keep these in lockstep:
/// every `synthetic_park_handle` must fall inside that protected range.
const _: () = assert!(
XAUDIO_SYNTHETIC_HANDLE_BASE >= xenia_cpu::scheduler::SYNTHETIC_PARK_HANDLE_FLOOR
);
/// Compute the synthetic park-handle for client slot `i`.
pub const fn synthetic_park_handle(i: usize) -> u32 {
XAUDIO_SYNTHETIC_HANDLE_BASE | (i as u32)
@@ -68,6 +76,16 @@ pub struct XAudioClient {
/// [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,
/// Count of frames the guest has handed us via
/// `XAudioSubmitRenderDriverFrame` for this client. Canary's
/// `AudioSystem::SubmitFrame` forwards the sample buffer to the client's
/// driver, whose playback completion later releases the client semaphore
/// — the pacing our callback ticker emulates. The guest mixer
/// (`sub_824DC350`) discards SubmitFrame's return and reads no field it
/// writes, so this counter is purely observational (logging / liveness),
/// never read back by the guest. Deterministic: incremented only inside
/// the guest-driven export call.
pub submitted_frames: u64,
}
#[derive(Debug)]
@@ -138,6 +156,35 @@ impl XAudioState {
self.clients.get(index).copied().flatten()
}
/// Faithful counterpart to canary `AudioSystem::SubmitFrame`: the guest
/// driver client `index` handed us one frame of samples. Canary forwards
/// `samples` to the client's `AudioDriver`, whose playback-completion
/// callback later releases the client semaphore — the buffer-consumed
/// pacing our [`tick_instr`]/[`try_inject_audio_callback`] path already
/// emulates. SubmitFrame itself returns void and the guest mixer
/// (`sub_824DC350`) reads no field from it, so all we faithfully need to
/// do is validate the client and account the frame. Returns `true` iff
/// `index` is a registered client (canary submits silence / warns
/// otherwise). Deterministic — only the guest-driven export mutates this.
pub fn record_submit(&mut self, index: usize) -> bool {
match self.clients.get_mut(index) {
Some(Some(c)) => {
c.submitted_frames = c.submitted_frames.saturating_add(1);
true
}
_ => false,
}
}
pub fn submitted_frames(&self, index: usize) -> u64 {
self.clients
.get(index)
.copied()
.flatten()
.map(|c| c.submitted_frames)
.unwrap_or(0)
}
pub fn any_registered(&self) -> bool {
self.clients.iter().any(|c| c.is_some())
}
@@ -230,6 +277,7 @@ mod tests {
callback_pc: 0x8200_0000 + arg,
callback_arg: arg,
wrapped_callback_arg: 0x4000_0000 + arg,
submitted_frames: 0,
}
}