[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:
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user