Files
xenia-rs/audit-runs/phase-host-audio-eager/fix.diff
MechaCat02 ef93a4fa14 handoff: VSync/event-wedge fixes + iterate 2.A–2.BC research notes
Source changes (dormant parity infra, retained from iterate 2.AI/2.AO):
- xenia-kernel/exports.rs: nt_create_event manual_reset polarity +
  related event wiring
- xenia-gpu/mmio_region.rs: D1MODE_VBLANK_VLINE_STATUS hardcode parity

Also lands the audit-runs/ analysis notes (.md/.txt/.json digests) for the
iterate 2.x VSync/0x10e8/0x1004 wedge investigation. Raw trace dumps
(.jsonl/.gz/.csv/.stdout) and agent worktrees (.claude/) are gitignored as
regenerable local artifacts — see memory + HANDOFF for the running findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:19:08 +02:00

4278 lines
196 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs
index a4dfa7d..4ff6238 100644
--- a/crates/xenia-kernel/src/exports.rs
+++ b/crates/xenia-kernel/src/exports.rs
@@ -16,7 +16,12 @@ pub fn register_exports(state: &mut KernelState) {
// Debug
state.register_export(Xboxkrnl, 0x01, "DbgBreakPoint", dbg_break_point);
- state.register_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print);
+ // Phase C+6½: `DbgPrint` (ord 0x03) is table-entry-only in canary
+ // (`xboxkrnl_table.inc:17`, no `DECLARE_XBOXKRNL_EXPORT(DbgPrint)`).
+ // Canary routes through the syscall thunk, which emits NO Phase A
+ // events. Mirror that — body still logs the string (harmless side
+ // effect) but the Phase A emitter stays silent.
+ state.register_unimplemented_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print);
// ExCreateThread and friends
state.register_export(Xboxkrnl, 0x0D, "ExCreateThread", ex_create_thread);
@@ -28,7 +33,17 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x28, "HalReturnToFirmware", hal_return_to_firmware);
// I/O
- state.register_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success);
+ // Phase C+6: `IoDismountVolumeByFileHandle` has a table entry in
+ // canary's `xboxkrnl_table.inc:74` but NO `DECLARE_XBOXKRNL_EXPORT`
+ // shim, so canary routes calls through the syscall thunk
+ // (`xex_module.cc:1310-1335`) which emits NO Phase A events.
+ // Mirror that by registering as unimplemented — ours still runs
+ // `stub_success` for guest-visible semantics, but the Phase A
+ // emitter stays silent. Before this fix, ours's tid=1 main chain
+ // injected 3 spurious events (`import.call`/`kernel.call`/
+ // `kernel.return`) at idx=102132 ahead of `NtClose`, becoming the
+ // first divergence vs canary which jumps straight to `NtClose`.
+ state.register_unimplemented_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success);
// Ke* Threading/Sync
state.register_export(Xboxkrnl, 0x4D, "KeAcquireSpinLockAtRaisedIrql", stub_return_zero);
@@ -44,16 +59,36 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x7D, "KeLeaveCriticalRegion", stub_success);
state.register_export(Xboxkrnl, 0x7F, "KePulseEvent", ke_pulse_event);
state.register_export(Xboxkrnl, 0x81, "KeQueryBasePriorityThread", ke_query_base_priority_thread);
- state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor);
+ // Phase C+6½ hallucination fix: ord 0x82 = `KeQueryInterruptTime`
+ // per canary's `xboxkrnl_table.inc:130`. Canary DECLAREs this export
+ // (`xboxkrnl_misc.cc:127`) — both engines emit Phase A events.
+ // Previously mis-labeled `KeQueryIdealProcessor` in ours; the body
+ // returned a wrong value (processor index instead of interrupt-time
+ // counter). Fixed body returns a synthetic monotonic u64.
+ state.register_export(Xboxkrnl, 0x82, "KeQueryInterruptTime", ke_query_interrupt_time);
state.register_export(Xboxkrnl, 0x83, "KeQueryPerformanceFrequency", ke_query_performance_frequency);
- state.register_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
- state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", stub_return_zero);
+ // Canary declares `void KeQuerySystemTime_entry(lpqword_t time_ptr, ...)`
+ // (xboxkrnl_threading.cc:459); the time is delivered via the OUT
+ // pointer, not via gpr[3]. Phase A's `kernel.return.return_value`
+ // must be 0 (canary literal) — not r3 (which for ours is the input
+ // arg `time_ptr` left untouched). See `register_void_export` doc in
+ // state.rs.
+ state.register_void_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
+ state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", ke_raise_irql_to_dpc_level);
state.register_export(Xboxkrnl, 0x88, "KeReleaseSemaphore", ke_release_semaphore);
state.register_export(Xboxkrnl, 0x89, "KeReleaseSpinLockFromRaisedIrql", ke_release_spinlock_from_raised_irql);
state.register_export(Xboxkrnl, 0x8F, "KeResetEvent", ke_reset_event);
state.register_export(Xboxkrnl, 0x92, "KeResumeThread", ke_resume_thread);
state.register_export(Xboxkrnl, 0x97, "KeSetAffinityThread", ke_set_affinity_thread);
- state.register_export(Xboxkrnl, 0x98, "KeSetIdealProcessor", ke_set_ideal_processor);
+ // Phase C+6½ hallucination fix: ord 0x98 = `KeSetBackgroundProcessors`
+ // per canary's `xboxkrnl_table.inc:166`. Table-entry-only (no
+ // `DECLARE_XBOXKRNL_EXPORT` shim), so canary routes via the syscall
+ // thunk and emits NO Phase A events. Previously mis-labeled
+ // `KeSetIdealProcessor` in ours; the body wrote
+ // `GuestThread::ideal_processor` — wrong state mutation under the
+ // wrong name. Replaced with `stub_success` and registered as
+ // unimplemented to mirror canary's silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x98, "KeSetBackgroundProcessors", stub_success);
state.register_export(Xboxkrnl, 0x99, "KeSetBasePriorityThread", ke_set_base_priority_thread);
state.register_export(Xboxkrnl, 0x9B, "KeSetCurrentStackPointers", stub_success);
state.register_export(Xboxkrnl, 0x9D, "KeSetEvent", ke_set_event);
@@ -61,7 +96,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0xAF, "KeWaitForMultipleObjects", ke_wait_for_multiple_objects);
state.register_export(Xboxkrnl, 0xB0, "KeWaitForSingleObject", ke_wait_for_single_object);
state.register_export(Xboxkrnl, 0xB1, "KfAcquireSpinLock", kf_acquire_spin_lock);
- state.register_export(Xboxkrnl, 0xB3, "KfLowerIrql", stub_success);
+ state.register_void_export(Xboxkrnl, 0xB3, "KfLowerIrql", kf_lower_irql);
state.register_export(Xboxkrnl, 0xB4, "KfReleaseSpinLock", kf_release_spin_lock);
state.register_export(Xboxkrnl, 0x0152, "KeTlsAlloc", ke_tls_alloc);
state.register_export(Xboxkrnl, 0x0153, "KeTlsFree", stub_success);
@@ -126,13 +161,16 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0110, "ObReferenceObjectByHandle", ob_reference_object_by_handle);
// RTL
- state.register_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
+ // Phase C+6½: `RtlCaptureContext` (ord 0x119) is table-entry-only
+ // in canary — no `DECLARE_XBOXKRNL_EXPORT(RtlCaptureContext)`.
+ // Mirror canary's silence so the Phase A emitter doesn't drift.
+ state.register_unimplemented_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
state.register_export(Xboxkrnl, 0x011B, "RtlCompareMemoryUlong", rtl_compare_memory_ulong);
state.register_export(Xboxkrnl, 0x0125, "RtlEnterCriticalSection", rtl_enter_critical_section);
state.register_export(Xboxkrnl, 0x0126, "RtlFillMemoryUlong", rtl_fill_memory_ulong);
state.register_export(Xboxkrnl, 0x0127, "RtlFreeAnsiString", stub_success);
state.register_export(Xboxkrnl, 0x012B, "RtlImageXexHeaderField", rtl_image_xex_header_field);
- state.register_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
+ state.register_void_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
state.register_export(Xboxkrnl, 0x012D, "RtlInitUnicodeString", rtl_init_unicode_string);
state.register_export(Xboxkrnl, 0x012E, "RtlInitializeCriticalSection", rtl_initialize_critical_section);
state.register_export(Xboxkrnl, 0x012F, "RtlInitializeCriticalSectionAndSpinCount", rtl_initialize_critical_section);
@@ -140,18 +178,27 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0133, "RtlMultiByteToUnicodeN", rtl_multi_byte_to_unicode_n);
state.register_export(Xboxkrnl, 0x0135, "RtlNtStatusToDosError", rtl_nt_status_to_dos_error);
state.register_export(Xboxkrnl, 0x0136, "RtlRaiseException", rtl_raise_exception);
- state.register_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
+ // Phase C+6½: `sprintf` (ord 0x13B) is table-entry-only in canary
+ // — no `DECLARE_XBOXKRNL_EXPORT(sprintf)`. Mirror canary's silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
state.register_export(Xboxkrnl, 0x013F, "RtlTimeFieldsToTime", stub_success);
state.register_export(Xboxkrnl, 0x0140, "RtlTimeToTimeFields", stub_success);
state.register_export(Xboxkrnl, 0x0141, "RtlTryEnterCriticalSection", rtl_try_enter_critical_section);
state.register_export(Xboxkrnl, 0x0142, "RtlUnicodeStringToAnsiString", stub_success);
state.register_export(Xboxkrnl, 0x0143, "RtlUnicodeToMultiByteN", stub_success);
- state.register_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
- state.register_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);
+ // Phase C+6½: `RtlUnwind` (ord 0x147) is table-entry-only in canary
+ // — no `DECLARE_XBOXKRNL_EXPORT(RtlUnwind)`. Mirror canary's silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
+ // Phase C+6½: `_vsnprintf` (ord 0x14D) is table-entry-only in
+ // canary — no `DECLARE_XBOXKRNL_EXPORT(_vsnprintf)`. Mirror silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);
// Stfs
- state.register_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
- state.register_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success);
+ // Phase C+6½: `StfsCreateDevice` (ord 0x259) and `StfsControlDevice`
+ // (ord 0x25A) are table-entry-only in canary. `StfsCreateDevice` is
+ // the C+6-noted driver of tid=7→tid=2 divergence at idx=15.
+ state.register_unimplemented_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
+ state.register_unimplemented_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success);
// Video
state.register_export(Xboxkrnl, 0x01B1, "VdCallGraphicsNotificationRoutines", stub_success);
@@ -160,7 +207,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x01B9, "VdGetCurrentDisplayGamma", vd_get_current_display_gamma);
state.register_export(Xboxkrnl, 0x01BA, "VdGetCurrentDisplayInformation", stub_success);
state.register_export(Xboxkrnl, 0x01BD, "VdGetSystemCommandBuffer", vd_get_system_command_buffer);
- state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_success);
+ state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_return_one);
state.register_export(Xboxkrnl, 0x01C3, "VdInitializeRingBuffer", vd_initialize_ring_buffer);
state.register_export(Xboxkrnl, 0x01C5, "VdInitializeScalerCommandBuffer", stub_success);
state.register_export(Xboxkrnl, 0x01C6, "VdIsHSIOTrainingSucceeded", vd_is_hsio_training_succeeded);
@@ -185,9 +232,11 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", stub_success);
// Crypto
- state.register_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success);
- state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", stub_success);
- state.register_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);
+ state.register_void_export(Xboxkrnl, 0x0192, "XeCryptSha", xe_crypt_sha);
+ state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", xe_keys_console_private_key_sign);
+ // Phase C+6½: `XeKeysConsoleSignatureVerification` (ord 0x257) is
+ // table-entry-only in canary. Mirror silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);
// Xex module
state.register_export(Xboxkrnl, 0x0194, "XexCheckExecutablePrivilege", xex_check_executable_privilege);
@@ -195,7 +244,9 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xboxkrnl, 0x0197, "XexGetProcedureAddress", xex_get_procedure_address);
// Exception handling
- state.register_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler);
+ // Phase C+6½: `__C_specific_handler` (ord 0x1A5) is table-entry-only
+ // in canary. Mirror silence.
+ state.register_unimplemented_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler);
}
// ===== Generic stubs =====
@@ -208,6 +259,16 @@ fn stub_return_zero(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut Kerne
ctx.gpr[3] = 0;
}
+/// Phase W: a literal `return 1`. Matches canary's
+/// `VdInitializeEngines_entry` in `xboxkrnl_video.cc:271-279` which
+/// returns `1` (truthy success token) rather than STATUS_SUCCESS=0.
+/// Sylpheed-side guest code branches on this non-zero, so returning
+/// 0 made the game skip the VdInitializeRingBuffer-and-after init
+/// sequence and never set up the post-init render-target state.
+fn stub_return_one(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
+ ctx.gpr[3] = 1;
+}
+
// ===== Debug =====
fn dbg_break_point(_ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
@@ -280,6 +341,16 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
*slot = Some(hw_id);
}
+ // Phase C+16: install the "thread owns itself until exited"
+ // self-reference. Mirrors canary's `XThread::Create` line 414
+ // `RetainHandle()`. Released by `ex_terminate_thread` and the
+ // main-loop LR-sentinel implicit-exit path. Without this, a
+ // subsequent NtClose on the thread handle (e.g. via
+ // `XamTaskCloseHandle`) drops the only ref and prematurely
+ // destroys the thread handle while the spawned thread is
+ // still live — the original C+16 divergence at Phase A
+ // idx=102168 on the main chain (canary tid=6 ↔ ours tid=1).
+ state.retain_handle(handle);
if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle);
}
@@ -296,6 +367,33 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
create_suspended,
affinity,
);
+ // Phase C+15-α: schema-v1 `thread.create` event emitted by
+ // the **parent** thread at the kernel call that created the
+ // new guest thread. The handle.create for the thread-handle
+ // itself was already emitted inside `alloc_handle_for`
+ // above; here we surface the spawn-specific metadata
+ // (entry_pc, ctx_ptr, priority, affinity, stack, suspended).
+ // Canary's symmetric emit is at `XThread::Create` after
+ // CreationParameters are populated.
+ if crate::event_log::is_enabled() {
+ let (parent_tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
+ crate::event_log::emit_thread_create(
+ parent_tid,
+ cycle,
+ sid,
+ start_address,
+ start_context,
+ /* priority */ 0,
+ affinity,
+ stack_size,
+ create_suspended,
+ );
+ }
ctx.gpr[3] = STATUS_SUCCESS;
}
Err(_) => {
@@ -311,6 +409,19 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
/// waiting on the thread handle are woken with STATUS_SUCCESS.
fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let exit_code = ctx.gpr[3] as u32;
+ // Phase C+15-α: schema-v1 `thread.exit` event. Must emit BEFORE the
+ // scheduler unwinds the current thread, because `tid_event_idx` is
+ // per-tid and the exiting thread's counter is what gets the event.
+ // Canary symmetric emit at `XThread::Execute` exit (xthread.cc:540
+ // ff., after `kernel_state()->processor()->Execute` returns).
+ if crate::event_log::is_enabled() {
+ let (tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ crate::event_log::emit_thread_exit(tid, cycle, exit_code);
+ }
let (hw_id, tid, handle_opt) = state.scheduler.exit_current(exit_code);
tracing::info!(
"ExTerminateThread: tid={:?} hw={} exit_code={}",
@@ -318,8 +429,8 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker
hw_id,
exit_code
);
- if let Some(handle) = handle_opt
- && let Some(KernelObject::Thread {
+ if let Some(handle) = handle_opt {
+ if let Some(KernelObject::Thread {
exit_code: ec,
waiters,
..
@@ -331,6 +442,16 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker
state.scheduler.wake_ref(w);
}
}
+ // Phase C+16: release the thread's self-reference installed at
+ // spawn time (`ex_create_thread` / `xam_task_schedule` via
+ // `state.retain_handle`). Mirrors canary's `XThread::Exit`
+ // `ReleaseHandle()` at xthread.cc:524. After this release, the
+ // refcount equals only the user-visible refs (1 if guest hasn't
+ // closed the handle, 0 if guest already called NtClose during
+ // the thread's lifetime — in which case the handle is destroyed
+ // here, emitting `handle.destroy`).
+ state.release_handle(handle);
+ }
tracing::debug!("ExTerminateThread: exit_status={:#x}", ctx.gpr[3]);
ctx.gpr[3] = 0;
}
@@ -375,38 +496,51 @@ fn ke_query_base_priority_thread(
ctx.gpr[3] = pri as u32 as u64;
}
-/// `KeSetIdealProcessor(thread_handle, proc_number) -> u8 old_ideal` —
-/// Axis 5. Stores the hint on the `GuestThread` for future spawn-sibling
-/// placement; does NOT migrate a live thread (use `KeSetAffinityThread`
-/// for that).
-fn ke_set_ideal_processor(
+/// Phase C+6½ hallucination fix: ord 0x82 maps to `KeQueryInterruptTime`
+/// in canary's `xboxkrnl_table.inc:130`, with a `DECLARE_XBOXKRNL_EXPORT`
+/// shim in `xboxkrnl_misc.cc:119-127`. Ours previously mis-labeled this
+/// ord as `KeQueryIdealProcessor` (a real NT function, but at a different
+/// position on Xbox 360 — not at 0x82). The hallucinated body returned
+/// the calling thread's `ideal_processor` byte; guests calling
+/// `KeQueryInterruptTime` to read the system interrupt-time counter were
+/// receiving a 1-byte processor index instead.
+///
+/// Canary returns `bundle->interrupt_time` (u64) — the monotonic system
+/// interrupt-time counter maintained by the kernel timer ISR. Ours has
+/// no `X_TIME_STAMP_BUNDLE` infrastructure, so we mirror the
+/// `KeQuerySystemTime` approach: return a fixed synthetic value that
+/// gives a plausible monotonic-looking u64. Determinism per `KernelState`
+/// requires this be reproducible — a constant satisfies both.
+fn ke_query_interrupt_time(
ctx: &mut PpcContext,
_mem: &GuestMemory,
- state: &mut KernelState,
+ _state: &mut KernelState,
) {
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
- let ideal = ctx.gpr[4] as u8;
- let prev = state
- .scheduler
- .find_by_handle(handle)
- .map(|r| state.scheduler.set_ideal_ref(r, ideal))
- .unwrap_or(0xFF);
- ctx.gpr[3] = prev as u64;
+ // Synthetic interrupt-time count. Units are 100ns ticks since boot;
+ // value chosen large enough to look post-boot but small enough that
+ // any timer-arithmetic stays in u32 range when masked. Matches the
+ // determinism pattern used by `ke_query_system_time` above.
+ const FAKE_INTERRUPT_TIME: u64 = 0x0000_0001_0000_0000;
+ ctx.gpr[3] = FAKE_INTERRUPT_TIME;
}
-fn ke_query_ideal_processor(
- ctx: &mut PpcContext,
- _mem: &GuestMemory,
- state: &mut KernelState,
-) {
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
- let ideal = state
- .scheduler
- .find_by_handle(handle)
- .and_then(|r| state.scheduler.ideal_ref(r))
- .unwrap_or(0);
- ctx.gpr[3] = ideal as u64;
-}
+/// Phase C+6½ hallucination fix: ord 0x98 maps to
+/// `KeSetBackgroundProcessors` in canary's `xboxkrnl_table.inc:166`.
+/// Canary has NO `DECLARE_XBOXKRNL_EXPORT` shim for this name — it's a
+/// table-entry-only export, routed through the syscall thunk
+/// (`xex_module.cc:1310-1335`) which is a no-op. Ours previously
+/// mis-labeled this ord as `KeSetIdealProcessor` (a real NT function but
+/// at a different position on Xbox 360) and the hallucinated body wrote
+/// to `GuestThread::ideal_processor` — a state mutation under the wrong
+/// semantic name. Guests calling `KeSetBackgroundProcessors` to mask off
+/// CPUs for background work were instead pinning the thread's ideal
+/// processor hint.
+///
+/// Replaced with a no-op (`stub_success`) registered via
+/// `register_unimplemented_export` so the Phase A emitter stays silent
+/// (matching canary's syscall-thunk path). The underlying
+/// `Scheduler::set_ideal_ref`/`ideal_ref` methods remain available for
+/// `NtSetInformationThread` info-class `ThreadIdealProcessor`.
/// `NtSetInformationThread(handle, info_class, info_ptr, info_len)` —
/// minimal Axis 5 wiring for priority / affinity / ideal-processor
@@ -453,18 +587,33 @@ fn nt_set_information_thread(
}
}
-/// `KeSetAffinityThread(thread_handle, new_mask) -> old_mask` — Axis 4.
-/// Drives `KernelState::set_affinity` which delegates to the scheduler
-/// and then fixes up every outstanding `ThreadRef` held in waiter lists.
+/// `KeSetAffinityThread(thread_ptr, affinity, prev_affinity_ptr)` — Axis 4.
+/// Mirrors xenia-canary `KeSetAffinityThread_entry`
+/// (xboxkrnl_threading.cc:323-346): returns `X_STATUS_SUCCESS` (0) in r3
+/// and writes the previous affinity to `*prev_affinity_ptr` (r5) when
+/// non-NULL. Validates `affinity != 0` (else `X_STATUS_INVALID_PARAMETER`)
+/// and that the thread handle resolves (else `X_STATUS_INVALID_HANDLE`).
+///
+/// Stage 2 Batch 3 fix (2026-05-14): pre-fix, ours returned `old_mask` in
+/// r3 with no OUT-pointer write — guest code expecting `STATUS_SUCCESS`
+/// in r3 was reading a small bitmask as an NTSTATUS.
fn ke_set_affinity_thread(
ctx: &mut PpcContext,
mem: &GuestMemory,
state: &mut KernelState,
) {
- let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let new_mask = (ctx.gpr[4] as u32) as u8;
+ let prev_ptr = ctx.gpr[5] as u32;
+ if new_mask == 0 {
+ ctx.gpr[3] = 0xC000_000D; // X_STATUS_INVALID_PARAMETER
+ return;
+ }
+ let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let old = state.set_affinity(handle, new_mask, mem);
- ctx.gpr[3] = old as u64;
+ if prev_ptr != 0 {
+ mem.write_u32(prev_ptr, old as u32);
+ }
+ ctx.gpr[3] = 0; // X_STATUS_SUCCESS
}
fn ke_bug_check(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
@@ -495,6 +644,49 @@ fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut Ke
}
}
+/// Offset of `current_irql` (u8) within PCR. Mirrors xenia-canary's
+/// `X_KPCR.current_irql` at offset 0x18 (xthread.h:189). PCR base is in
+/// `ctx.gpr[13]` per scheduler setup.
+const PCR_CURRENT_IRQL_OFFSET: u32 = 0x18;
+
+/// Mirrors xenia-canary `KeRaiseIrqlToDpcLevel_entry`
+/// (xboxkrnl_threading.cc:1253-1264): reads PCR's `current_irql`,
+/// returns the old value in r3, writes `DISPATCH_LEVEL` (2) back.
+fn ke_raise_irql_to_dpc_level(
+ ctx: &mut PpcContext,
+ mem: &GuestMemory,
+ _state: &mut KernelState,
+) {
+ let pcr = ctx.gpr[13] as u32;
+ let old_irql = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET));
+ if old_irql > 2 {
+ tracing::warn!(
+ old_irql = old_irql,
+ "KeRaiseIrqlToDpcLevel: old_irql > 2 (DISPATCH_LEVEL)"
+ );
+ }
+ mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), 2);
+ ctx.gpr[3] = old_irql as u64;
+}
+
+/// Mirrors xenia-canary `KfLowerIrql_entry`
+/// (xboxkrnl_threading.cc:1280-1282 calling `xeKfLowerIrql`): writes
+/// `new_irql` (r3) to PCR's `current_irql`. Void return (registered via
+/// `register_void_export`).
+fn kf_lower_irql(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ let new_irql = (ctx.gpr[3] as u32) as u8;
+ let pcr = ctx.gpr[13] as u32;
+ let current = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET));
+ if new_irql > current {
+ tracing::warn!(
+ new_irql = new_irql,
+ current = current,
+ "KfLowerIrql: new_irql > current_irql"
+ );
+ }
+ mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), new_irql);
+}
+
fn ke_initialize_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = PKSEMAPHORE, r4 = initial count, r5 = limit.
// Mirrors xenia-canary KeInitializeSemaphore_entry
@@ -592,8 +784,102 @@ fn ke_tls_set_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel
ctx.gpr[3] = 1; // TRUE
}
-fn ex_get_xconfig_setting(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
- ctx.gpr[3] = 0; // STATUS_SUCCESS (writes nothing)
+/// Mirrors xenia-canary `ExGetXConfigSetting_entry` + `xeExGetXConfigSetting`
+/// (xboxkrnl_xconfig.cc:303-319 calling :65-302). Returns a small value
+/// describing one of the Xbox 360's `XCONFIG_*` settings.
+///
+/// Stage 2 Batch 6 (2026-05-14): pre-fix returned STATUS_SUCCESS with no
+/// buffer write — game saw uninitialized buffer data. We implement the
+/// most commonly queried (category, setting) pairs as constants matching
+/// canary's defaults. Unknown pairs return `STATUS_INVALID_PARAMETER_2`.
+fn ex_get_xconfig_setting(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ let category = (ctx.gpr[3] as u32) & 0xFFFF;
+ let setting = (ctx.gpr[4] as u32) & 0xFFFF;
+ let buffer_ptr = ctx.gpr[5] as u32;
+ let buffer_size = (ctx.gpr[6] as u32) & 0xFFFF;
+ let required_size_ptr = ctx.gpr[7] as u32;
+
+ // Per-setting value encoded as big-endian bytes (canary uses
+ // `xe::store_and_swap<T>`; we hand-roll the BE bytes since values
+ // are constant).
+ #[derive(Clone, Copy)]
+ enum SettingValue {
+ U8(u8),
+ U16Be(u16),
+ U32Be(u32),
+ }
+ impl SettingValue {
+ fn size(&self) -> u16 {
+ match self {
+ SettingValue::U8(_) => 1,
+ SettingValue::U16Be(_) => 2,
+ SettingValue::U32Be(_) => 4,
+ }
+ }
+ fn write(&self, mem: &GuestMemory, addr: u32) {
+ match self {
+ SettingValue::U8(v) => mem.write_u8(addr, *v),
+ SettingValue::U16Be(v) => mem.write_u16(addr, *v),
+ SettingValue::U32Be(v) => mem.write_u32(addr, *v),
+ }
+ }
+ }
+
+ let value: Option<SettingValue> = match (category, setting) {
+ // XCONFIG_SECURED_CATEGORY = 0x02
+ (0x02, 0x02) => Some(SettingValue::U32Be(1)), // SECURED_AV_REGION = NTSCM
+ // XCONFIG_USER_CATEGORY = 0x03
+ (0x03, 0x01) // TIME_ZONE_BIAS
+ | (0x03, 0x02) // TIME_ZONE_STD_NAME
+ | (0x03, 0x03) // TIME_ZONE_DLT_NAME
+ | (0x03, 0x04) // TIME_ZONE_STD_DATE
+ | (0x03, 0x05) // TIME_ZONE_DLT_DATE
+ | (0x03, 0x06) // TIME_ZONE_STD_BIAS
+ | (0x03, 0x07) // TIME_ZONE_DLT_BIAS
+ => Some(SettingValue::U32Be(0)),
+ (0x03, 0x09) => Some(SettingValue::U32Be(1)), // USER_LANGUAGE = en
+ (0x03, 0x0A) => Some(SettingValue::U32Be(0)), // USER_VIDEO_FLAGS = RatioNormal
+ (0x03, 0x0B) => Some(SettingValue::U32Be(0x00010001)), // USER_AUDIO_FLAGS
+ (0x03, 0x0C) => Some(SettingValue::U32Be(0x40)), // USER_RETAIL_FLAGS
+ (0x03, 0x0E) => Some(SettingValue::U8(103)), // USER_COUNTRY = US
+ (0x03, 0x0F) => Some(SettingValue::U8(0x03)), // USER_PC_FLAGS = XBL allowed
+ // XCONFIG_CONSOLE_CATEGORY = 0x07
+ (0x07, 0x02) => Some(SettingValue::U16Be(0)), // SCREEN_SAVER = Off
+ (0x07, 0x03) => Some(SettingValue::U16Be(0)), // AUTO_SHUT_OFF = Off
+ _ => None,
+ };
+
+ let v = match value {
+ Some(v) => v,
+ None => {
+ // Unknown category or setting. Match canary's per-category
+ // return code: invalid category vs invalid setting both
+ // surface as STATUS_INVALID_PARAMETER_x in canary; we use
+ // STATUS_INVALID_PARAMETER_2 as a single sentinel since the
+ // distinction is rarely consulted by guest code.
+ ctx.gpr[3] = 0xC000_00F0; // X_STATUS_INVALID_PARAMETER_2
+ return;
+ }
+ };
+
+ let setting_size = v.size();
+
+ if buffer_ptr != 0 {
+ if buffer_size < setting_size as u32 {
+ ctx.gpr[3] = 0xC000_0023; // X_STATUS_BUFFER_TOO_SMALL
+ return;
+ }
+ v.write(mem, buffer_ptr);
+ } else if buffer_size != 0 {
+ ctx.gpr[3] = 0xC000_00F1; // X_STATUS_INVALID_PARAMETER_3
+ return;
+ }
+
+ if required_size_ptr != 0 {
+ mem.write_u16(required_size_ptr, setting_size);
+ }
+
+ ctx.gpr[3] = 0; // STATUS_SUCCESS
}
// ===== Memory =====
@@ -730,6 +1016,34 @@ const STATUS_SEMAPHORE_LIMIT_EXCEEDED: u64 = 0xC000_0047;
const STATUS_UNSUCCESSFUL: u64 = 0xC000_0001;
const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003;
const STATUS_INFO_LENGTH_MISMATCH: u64 = 0xC000_0004;
+const STATUS_OBJECT_NAME_INVALID: u64 = 0xC000_0033;
+const STATUS_ACCESS_DENIED: u64 = 0xC000_0022;
+// Phase C+11 — canary's `NtQueryFullAttributesFile_entry` returns
+// `STATUS_NO_SUCH_FILE` (0xC000000F) on resolve-miss, not
+// `STATUS_OBJECT_NAME_NOT_FOUND` (0xC0000034). Both are negative NTSTATUS
+// values; Sylpheed treats them equivalently at the call site, but the
+// Phase A diff compares return values byte-exact, so the codes must
+// match.
+const STATUS_NO_SUCH_FILE: u64 = 0xC000_000F;
+/// Phase C+5 — canary's `NtWriteFile_entry`
+/// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) returns
+/// this NT-style status code when the underlying `XFile::is_synchronous_`
+/// is false (i.e. the file was opened without `FILE_SYNCHRONOUS_IO_ALERT`
+/// or `FILE_SYNCHRONOUS_IO_NONALERT`). The write itself still completes
+/// synchronously and the IO_STATUS_BLOCK still records STATUS_SUCCESS;
+/// only the function return value flips. Real NT uses STATUS_PENDING here
+/// as a "the caller may now wait on the event" convention.
+const STATUS_PENDING: u64 = 0x0000_0103;
+
+/// `CreateOptions` bits we care about for is-synchronous tracking
+/// (canary's `CreateOptions::FILE_SYNCHRONOUS_IO_ALERT` /
+/// `CreateOptions::FILE_SYNCHRONOUS_IO_NONALERT` in xboxkrnl_io.cc:32-33).
+/// `NtOpenFile` forwards the same options dword through its `open_options`
+/// argument, so this bitmask applies to both paths.
+const FILE_SYNCHRONOUS_IO_ALERT: u32 = 0x0000_0010;
+const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020;
+const FILE_SYNCHRONOUS_IO_MASK: u32 =
+ FILE_SYNCHRONOUS_IO_ALERT | FILE_SYNCHRONOUS_IO_NONALERT;
/// `X_ERROR_NOT_FOUND` from xenia-canary `xenia/xbox.h`. Returned by
/// `XexGetModuleHandle` for unknown module names.
const X_ERROR_NOT_FOUND: u64 = 0x0000_048B;
@@ -737,6 +1051,17 @@ const X_ERROR_NOT_FOUND: u64 = 0x0000_048B;
/// A sentinel byte-offset value meaning "read at current file position".
const FILE_USE_FILE_POINTER_POSITION: u64 = 0xFFFF_FFFF_FFFF_FFFE;
+/// Phase C+5 — register `handle` in `state.async_file_handles` iff the
+/// caller did NOT request synchronous IO (mirrors canary's
+/// `XFile::is_synchronous_` derivation in xboxkrnl_io.cc:94-97). Subsequent
+/// `nt_write_file` returns flip from `STATUS_SUCCESS` to `STATUS_PENDING`
+/// for async-opened files only.
+fn maybe_mark_async_file(state: &mut KernelState, handle: u32, create_options: u32) {
+ if (create_options & FILE_SYNCHRONOUS_IO_MASK) == 0 {
+ state.async_file_handles.insert(handle);
+ }
+}
+
/// Write an `IO_STATUS_BLOCK { status, information }` if the pointer is non-null.
fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information: u32) {
if ptr == 0 {
@@ -793,32 +1118,96 @@ fn open_cache_file(
// `cache:\d4ea4615` which then blocked subsequent hierarchical
// creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION).
const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
+ const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040;
let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0;
-
- // Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
- // cache root directory itself. Mirror canary's HostPathDevice.Open
- // which returns a directory handle (success, attributes = DIR).
- // Empty `path.file_name()` after our resolve_cache_path strip means
- // the guest asked for the mount root.
- let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
- || host_path.is_dir()
- || want_dir;
+ let want_non_dir = (create_options & FILE_NON_DIRECTORY_FILE) != 0;
+
+ // Phase C+11 — when the host path already exists, its actual on-disk
+ // type wins over the guest's `FILE_DIRECTORY_FILE` bit. Mirrors
+ // canary's `VirtualFileSystem::OpenFile` which routes to the existing
+ // entry's device-specific open without re-checking the bit. Sylpheed
+ // sets `FILE_DIRECTORY_FILE` on `NtOpenFile cache:\<H1><H2>.tmp`
+ // re-opens (the `.tmp` was already a file from a prior FILE_CREATE),
+ // which under the AUDIT-054 logic mis-routed to the directory branch
+ // and dropped `host_path` — blocking the subsequent class-10 rename
+ // with `STATUS_ACCESS_DENIED`. Also resolves Phase C+11's bug #2:
+ // `cache:\access`/`ignore`/`recent` end up as files on cold creation
+ // because `want_non_dir` (FILE_NON_DIRECTORY_FILE bit 0x40) takes
+ // precedence when set, even with FILE_DIRECTORY_FILE.
+ //
+ // Resolution order (mirrors canary):
+ // 1. Existing host entry: actual type wins (file ↔ dir).
+ // 2. `want_non_dir` set → file path (NON_DIRECTORY_FILE overrides).
+ // 3. `want_dir` set → directory path.
+ // 4. Default → file path.
+ //
+ // Root-of-mount case is captured by the existing-dir branch: the
+ // cache root always exists as a directory, so `host_path.is_dir()`
+ // is true.
+ let host_exists_as_dir = host_path.is_dir();
+ let host_exists_as_file = host_path.is_file();
+ let is_dir_open = host_exists_as_dir
+ || (!host_exists_as_file && !want_non_dir && want_dir);
if is_dir_open {
- // For non-existent paths the guest wants us to create as a
- // directory, mkdir-p; canary's HostPathDevice does the same
- // when FILE_DIRECTORY_FILE is set on a kCreate disposition.
- if want_dir && !host_path.exists() {
- if let Err(e) = std::fs::create_dir_all(host_path) {
- tracing::warn!(
- "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
- host_path,
- e
- );
+ // Phase C+11.1 — only create the host directory when the
+ // disposition is *create-capable*. Mirrors canary's
+ // `VirtualFileSystem::OpenFile` (virtual_file_system.cc:265-273):
+ // for `FileDisposition::kOpen`/`kOverwrite` on a non-existent
+ // path the function returns `X_STATUS_OBJECT_NAME_NOT_FOUND`
+ // *before* any `CreatePath` call — i.e. mkdir is never invoked
+ // on these dispositions. The pre-fix code (Phase C+11) called
+ // `create_dir_all` whenever `want_dir && !host_path.exists()`,
+ // so Sylpheed's cold-boot probes for `cache:/access`,
+ // `cache:/ignore`, `cache:/recent` (disp=1, opts=0x7) succeeded
+ // and produced spurious host directories. Canary instead
+ // returns NOT_FOUND, after which Sylpheed re-creates these as
+ // FILES via `disp=5` + `FILE_NON_DIRECTORY_FILE`.
+ //
+ // Create-capable dispositions (mkdir OK):
+ // 0 FILE_SUPERSEDE
+ // 2 FILE_CREATE
+ // 3 FILE_OPEN_IF
+ // 5 FILE_OVERWRITE_IF
+ // Non-create dispositions (must miss when path is absent):
+ // 1 FILE_OPEN
+ // 4 FILE_OVERWRITE
+ let disp_is_create_capable = matches!(
+ create_disposition,
+ FILE_SUPERSEDE | FILE_CREATE | FILE_OPEN_IF | FILE_OVERWRITE_IF
+ );
+ if !host_path.exists() {
+ if !disp_is_create_capable {
if handle_out != 0 {
mem.write_u32(handle_out, 0);
}
- write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
- return STATUS_UNSUCCESSFUL;
+ write_io_status_block(
+ mem,
+ io_status_block,
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
+ 0,
+ );
+ tracing::info!(
+ "cache open (dir) MISS path={:?} disp={} opts={:#x} -> NOT_FOUND",
+ guest_path,
+ create_disposition,
+ create_options
+ );
+ return STATUS_OBJECT_NAME_NOT_FOUND;
+ }
+ // create-capable + want_dir → mkdir-p the directory.
+ if want_dir {
+ if let Err(e) = std::fs::create_dir_all(host_path) {
+ tracing::warn!(
+ "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
+ host_path,
+ e
+ );
+ if handle_out != 0 {
+ mem.write_u32(handle_out, 0);
+ }
+ write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
+ return STATUS_UNSUCCESSFUL;
+ }
}
}
// Stored path ends with '/' so nt_query_information_file's
@@ -828,6 +1217,10 @@ fn open_cache_file(
} else {
format!("{}/", guest_path)
};
+ // Phase C+12 — register / refresh directory entry mirror.
+ if let Ok(md) = host_path.metadata() {
+ state.register_cache_entry(guest_path, &md);
+ }
let handle = state.alloc_handle_for(KernelObject::File {
path: dir_path,
size: 0,
@@ -836,6 +1229,7 @@ fn open_cache_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -918,10 +1312,16 @@ fn open_cache_file(
return STATUS_UNSUCCESSFUL;
}
}
- let size = host_path
- .metadata()
- .map(|m| m.len())
- .unwrap_or(0);
+ let metadata = host_path.metadata().ok();
+ let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
+ // Phase C+12 — register / refresh the in-memory entry mirror so
+ // subsequent `NtQueryFullAttributesFile` probes for this path
+ // resolve without re-stating the host FS (parity with canary's
+ // `Entry::CreateEntry`,
+ // `xenia-canary/src/xenia/vfs/entry.cc:88-104`).
+ if let Some(md) = metadata.as_ref() {
+ state.register_cache_entry(guest_path, md);
+ }
let handle = state.alloc_handle_for(KernelObject::File {
path: guest_path.to_string(),
size,
@@ -931,6 +1331,7 @@ fn open_cache_file(
dir_enum_pos: None,
host_path: Some(host_path.to_path_buf()),
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -950,6 +1351,32 @@ fn open_cache_file(
/// AUDIT-038 — additional NTSTATUS used by the cache-backed open path.
const STATUS_OBJECT_NAME_COLLISION: u64 = 0xC000_0035;
+/// Phase C+13 — does `raw_path` start with a prefix that aliases the
+/// (read-only) game disc? Used to scope the synth-empty fallback in
+/// `open_vfs_file`: missing disc files report `STATUS_OBJECT_NAME_NOT_FOUND`
+/// (matching canary's `NtCreateFile_entry` for game-data lookups), while
+/// missing writable-partition paths keep the legacy zero-byte synth.
+///
+/// Mirrors the disc-mapped subset of `crate::path::DEVICE_PREFIXES`:
+/// - `game:\` — canary's symbolic-link alias for the disc
+/// (xenia-canary/src/xenia/kernel/kernel_state.cc registrations).
+/// - `d:\` / `D:\` — drive-letter alias for the disc.
+/// - `\Device\Cdrom0\` — NT device path for the disc.
+///
+/// Compares case-insensitively to match canary's path resolver.
+fn is_disc_prefix(raw_path: &str) -> bool {
+ let lowered = raw_path.trim_start().to_ascii_lowercase();
+ const DISC_PREFIXES: &[&str] = &[
+ "game:\\",
+ "game:/",
+ "d:\\",
+ "d:/",
+ "\\device\\cdrom0\\",
+ "\\device\\cdrom0/",
+ ];
+ DISC_PREFIXES.iter().any(|p| lowered.starts_with(p))
+}
+
/// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the
/// create/open distinction only matters for writable volumes (cache:/),
/// which we now back with a host directory (audit-038). The disc image
@@ -980,6 +1407,17 @@ fn open_vfs_file(
// see a null handle later and trigger `XamShowDirtyDiscErrorUI`.
let path = crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr)
.unwrap_or_default();
+ // Phase C+13 — recover the raw (un-stripped) path so we can tell a
+ // disc-aliased prefix (`game:\`, `d:\`, `\Device\Cdrom0\`) apart from a
+ // writable-partition prefix (`\Device\Harddisk0\…`, `\??\`, raw "no
+ // prefix" cases). The synth-empty fallback below covers both today but
+ // canary's `NtCreateFile_entry` (xboxkrnl_io.cc:83-110) returns the
+ // VFS lookup status verbatim, which is `STATUS_OBJECT_NAME_NOT_FOUND`
+ // for any disc path that isn't in the ISO. Scoping the synth to
+ // non-disc prefixes makes us match canary's behaviour for missing
+ // game-data files (e.g. `game:\dat\files.tbl` at Phase C+13 idx 103862).
+ let raw_path = crate::path::object_attributes_raw_name(mem, obj_attrs_ptr)
+ .unwrap_or_default();
if path.is_empty() && obj_attrs_ptr == 0 {
if handle_out != 0 {
mem.write_u32(handle_out, 0);
@@ -1004,6 +1442,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1047,6 +1486,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1055,28 +1495,43 @@ fn open_vfs_file(
STATUS_SUCCESS
}
Err(e) => {
- // When the VFS can't resolve a path we synthesize a zero-byte
- // virtual file rather than returning NOT_FOUND. Two rationales:
- //
- // 1. **Writable system partitions** (`cache:/`, `cache0:`,
- // `cache1:`, `partition0:`, `partition1:`) aren't backed by
- // the disc — Canary mounts them on host directories
- // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)).
- // We skip the host mount for now, so opens there always miss
- // without this fallback.
+ // Phase C+13 — scope the synth-empty fallback to non-disc
+ // prefixes only. Canary's `NtCreateFile_entry` returns the VFS
+ // result verbatim (xboxkrnl_io.cc:83-110); for a missing disc
+ // file like `game:\dat\files.tbl` that's
+ // `STATUS_OBJECT_NAME_NOT_FOUND`. Sylpheed handles NOT_FOUND
+ // cleanly (next event in canary's trace at idx 103862 is
+ // `RtlNtStatusToDosError(0xc0000034) -> 2`, then the boot
+ // validator continues), so the synth was masking the
+ // correct branch.
//
- // 2. **Disc files that didn't make it into the ISO rip** (e.g.,
- // Sylpheed's `dat/files.tbl`, which the retail disc shipped
- // but our dump doesn't contain). Returning NOT_FOUND makes
- // Sylpheed's boot validator call `XamShowDirtyDiscErrorUI`
- // → dashboard exit; see Canary's `XamShowDirtyDiscErrorUI`
- // at xam_ui.cc:562 for the "bad or unimplemented file IO
- // calls" framing.
- //
- // A zero-byte file lets the game's existence probe succeed, its
- // read return EOF, and its "is the content here" sanity checks
- // pass. If the game actually needs the bytes for gameplay we'll
- // see a fresh failure downstream and can decide what to stub next.
+ // Synth-empty is still kept for writable system partitions
+ // (`\Device\Harddisk0\…`, `\Device\Mass*`, `\??\`, raw paths)
+ // because those aren't backed by the disc — Canary mounts
+ // them on host directories
+ // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc));
+ // ours skips the host mount for those and falls back to the
+ // legacy stub to avoid regressing audit-006 / audit-018
+ // disc-validation probes. `cache:/` was already routed to
+ // `open_cache_file` upstream of this branch (AUDIT-038).
+ if is_disc_prefix(&raw_path) {
+ if handle_out != 0 {
+ mem.write_u32(handle_out, 0);
+ }
+ write_io_status_block(
+ mem,
+ io_status_block,
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
+ 0,
+ );
+ tracing::info!(
+ "Disc path missing: raw={:?} norm={:?} err={} -> NOT_FOUND",
+ raw_path,
+ path,
+ e
+ );
+ return STATUS_OBJECT_NAME_NOT_FOUND;
+ }
let handle = state.alloc_handle_for(KernelObject::File {
path: path.clone(),
size: 0,
@@ -1085,6 +1540,7 @@ fn open_vfs_file(
dir_enum_pos: None,
host_path: None,
});
+ maybe_mark_async_file(state, handle, create_options);
if handle_out != 0 {
mem.write_u32(handle_out, handle);
}
@@ -1122,16 +1578,26 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta
}
fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
- // r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
- // r6 = io_status_block, r7 = share_access, r8 = open_options.
- // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist.
- // Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options`
+ // Phase C+5 — canary `NtOpenFile_entry`
+ // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:114-122) has
+ // FIVE args: (handle_out, desired_access, object_attributes,
+ // io_status_block, open_options). Per Xenia's shim_utils LoadValue
+ // (util/shim_utils.h:158-167), the 5th dword arg arrives in r7. Ours
+ // previously read r8 — the bit 0x01 (FILE_DIRECTORY_FILE) check still
+ // happened to pass because the game also left bit 0x01 set in r8 for
+ // dir opens (AUDIT-054 enabling condition), but the
+ // FILE_SYNCHRONOUS_IO_NONALERT bit (0x20) was wrongly set in r8 for
+ // device opens, making every file appear synchronous and causing the
+ // Phase C+5 NtWriteFile divergence at idx=102068
+ // (canary=STATUS_PENDING / ours=STATUS_SUCCESS).
+ //
+ // Per xboxkrnl_io.cc:118-122, NtOpenFile forwards `open_options`
// straight into NtCreateFile's `create_options` slot, so the
- // FILE_DIRECTORY_FILE bit applies the same way.
+ // FILE_DIRECTORY_FILE bit + sync bits apply the same way.
let handle_out = ctx.gpr[3] as u32;
let obj_attrs_ptr = ctx.gpr[5] as u32;
let io_status_block = ctx.gpr[6] as u32;
- let open_options = ctx.gpr[8] as u32;
+ let open_options = ctx.gpr[7] as u32;
ctx.gpr[3] = open_vfs_file(
mem,
state,
@@ -1171,8 +1637,10 @@ fn signal_io_completion_event(state: &mut KernelState, event_handle: u32) {
fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx,
// r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr
- let handle = ctx.gpr[3] as u32;
- let event_handle = ctx.gpr[4] as u32;
+ // Phase C+19: canonicalize dup ids → source so file/event lookups
+ // hit the canonical `state.objects` slot.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
let io_status_block = ctx.gpr[7] as u32;
let buffer = ctx.gpr[8] as u32;
let length = ctx.gpr[9] as u32;
@@ -1293,8 +1761,9 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
// r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr.
// For cache:/* (host_path Some) writes go to disk; everything else
// is still discarded (matches legacy read-only behaviour for game:/).
- let handle = ctx.gpr[3] as u32;
- let event_handle = ctx.gpr[4] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
let io_status_block = ctx.gpr[7] as u32;
let buffer = ctx.gpr[8] as u32;
let length = ctx.gpr[9] as u32;
@@ -1320,6 +1789,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
*position
};
+ let mut wrote_ok = false;
if let Some(hp) = host_path.clone() {
use std::io::{Seek, SeekFrom, Write};
let mut buf = vec![0u8; length as usize];
@@ -1341,6 +1811,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
*size = live_size;
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
ctx.gpr[3] = STATUS_SUCCESS;
+ wrote_ok = true;
tracing::info!(
"NtWriteFile cache: {} bytes to {:?} @ {} (handle={:#x})",
length, path, start_pos, handle
@@ -1356,6 +1827,19 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat
// Legacy: discard but report full-length-written so caller proceeds.
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
ctx.gpr[3] = STATUS_SUCCESS;
+ wrote_ok = true;
+ }
+ // Phase C+5 — canary `NtWriteFile_entry`
+ // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) flips
+ // the function return value to `STATUS_PENDING` after the synchronous
+ // write completes when the underlying `XFile::is_synchronous_` is
+ // false. The IO_STATUS_BLOCK already stores STATUS_SUCCESS above; only
+ // the r3 return changes. Mirroring this here closes the
+ // `tid_event_idx=102068` divergence (canary=0x103 / ours=0) on the
+ // main thread without touching `NtReadFile` / `NtReadFileScatter`
+ // (scoped to one divergence per Phase C session, per project plan).
+ if wrote_ok && state.async_file_handles.contains(&handle) {
+ ctx.gpr[3] = STATUS_PENDING;
}
signal_io_completion_event(state, event_handle);
}
@@ -1373,7 +1857,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D;
const CACHE_SIZE: u64 = 0xFF000;
- let event_handle = ctx.gpr[4] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
let io_status_block = ctx.gpr[7] as u32;
let io_control_code = ctx.gpr[8] as u32;
let sp = ctx.gpr[1] as u32;
@@ -1423,7 +1908,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
/// (14). Anything else gets zeros + success.
fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = io_status_block, r5 = file_info, r6 = length, r7 = class
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
let io_status_block = ctx.gpr[4] as u32;
let file_info = ctx.gpr[5] as u32;
let length = ctx.gpr[6] as u32;
@@ -1517,6 +2003,123 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
ctx.gpr[3] = STATUS_SUCCESS;
}
+/// Phase C+11 — XFileRenameInformation (class 10) body. Mirrors canary
+/// `xboxkrnl_io_info.cc:226-243` `file->Rename(target_path)`. Sylpheed's
+/// cache-build path writes `cache:\<H1><H2>.tmp` flat journal files, then
+/// renames them to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via this
+/// info-class. Before this body landed, ours silently fell through to the
+/// `_ => STATUS_SUCCESS` catch-all and the `.tmp` never became a leaf —
+/// blocking `NtQueryFullAttributesFile` at idx 102404 in the Phase A diff.
+///
+/// Layout per canary `info/file.h:79-83` (16 bytes total):
+/// offset 0 be<u32> replace_existing
+/// offset 4 be<u32> root_dir_handle
+/// offset 8 X_ANSI_STRING (u16 Length, u16 MaximumLength, u32 Buffer)
+///
+/// Pulled out of `nt_set_information_file`'s main `match` because it
+/// needs an immutable read of `state.cache_root` (via
+/// `resolve_cache_path`) BEFORE the mutable destructure of the file
+/// handle — Rust's borrow checker can't see through `state.method()`
+/// across both kinds of access.
+fn handle_set_info_rename(
+ mem: &GuestMemory,
+ state: &mut KernelState,
+ handle: u32,
+ info_ptr: u32,
+ info_length: u32,
+) -> (u64, u32) {
+ // Read the rename target ANSI_STRING. The raw-form helper trims
+ // whitespace but does NOT prefix-strip — we want the original
+ // `cache:\...` form so the path resolver sees it.
+ let target_raw =
+ match crate::path::file_rename_information_raw_target(mem, info_ptr, info_length) {
+ Some(s) if !s.is_empty() => s,
+ _ => return (STATUS_OBJECT_NAME_INVALID, 16),
+ };
+
+ // Translate target path. Sylpheed only renames inside `cache:\`; any
+ // other prefix is not in scope (canary's `IsValidPath` rejects
+ // anything that doesn't resolve to a writable mount).
+ let target_host_path = match state.resolve_cache_path(&target_raw) {
+ Some(p) => p,
+ None => return (STATUS_OBJECT_NAME_INVALID, 16),
+ };
+
+ // Look up the source handle. Note: ANY non-File handle (event,
+ // semaphore, etc.) is INVALID_HANDLE; a File without a
+ // `host_path` is VFS-backed (read-only) and can't be renamed.
+ let Some(KernelObject::File { path, size, host_path, .. }) = state.objects.get_mut(&handle)
+ else {
+ return (STATUS_INVALID_HANDLE, 16);
+ };
+ let Some(src_host_path) = host_path.clone() else {
+ // VFS-backed read-only handle (disc / synth stub). Canary's
+ // HostPathDevice mount is the only Rename-capable backend on
+ // Sylpheed; Disc/SVOD throws `kReadOnly`.
+ return (STATUS_ACCESS_DENIED, 16);
+ };
+
+ // Create parent directories for the destination (matches canary's
+ // `HostPathEntry::CreateEntryInternal` which calls
+ // `create_directories` before writing the file). Without this, the
+ // rename to `<root>/d4ea4615/e/46ee8ca` fails when `<root>/d4ea4615/e`
+ // doesn't yet exist (a common cold-cache scenario).
+ if let Some(parent) = target_host_path.parent() {
+ if let Err(e) = std::fs::create_dir_all(parent) {
+ tracing::warn!(
+ "NtSetInformationFile rename: create_dir_all({:?}): {}",
+ parent,
+ e
+ );
+ return (STATUS_UNSUCCESSFUL, 16);
+ }
+ }
+
+ // Perform the rename. `std::fs::rename` is atomic within a single
+ // filesystem on POSIX; cross-filesystem is the only failure path
+ // worth worrying about, and the entire cache lives under one root.
+ let old_path = path.clone();
+ let rename_outcome = match std::fs::rename(&src_host_path, &target_host_path) {
+ Ok(()) => {
+ // Update the in-engine handle to point at the new location.
+ // The handle stays valid (mirrors canary's `XFile::Rename`
+ // which keeps the file handle open at the new path).
+ *path = crate::path::normalize_path(&target_raw);
+ *host_path = Some(target_host_path.clone());
+ let new_size = std::fs::metadata(&target_host_path)
+ .map(|m| m.len())
+ .unwrap_or(*size);
+ *size = new_size;
+ Ok(())
+ }
+ Err(e) => {
+ tracing::warn!(
+ "NtSetInformationFile rename: rename({:?} -> {:?}): {}",
+ src_host_path,
+ target_host_path,
+ e
+ );
+ Err(())
+ }
+ };
+ // Drop the mutable borrow on `state.objects` before touching
+ // `state.cache_entries` via the helper methods. The `let
+ // Some(KernelObject::File { .. }) = state.objects.get_mut(...)`
+ // binding above holds it until the function returns otherwise.
+ match rename_outcome {
+ Ok(()) => {
+ // Phase C+12 — refresh the in-memory entry tree: drop the
+ // source mirror, install / refresh the target mirror.
+ state.forget_cache_entry(&old_path);
+ if let Ok(md) = std::fs::metadata(&target_host_path) {
+ state.register_cache_entry(&target_raw, &md);
+ }
+ (STATUS_SUCCESS, 16)
+ }
+ Err(()) => (STATUS_UNSUCCESSFUL, 16),
+ }
+}
+
/// `NtSetInformationFile(FileHandle, IoStatusBlock*, FileInformation,
/// Length, FileInformationClass)`. Mirrors Canary
/// [xboxkrnl_io_info.cc:180-304](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io_info.cc).
@@ -1524,16 +2127,17 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu
/// Validates `info_class` (must have a defined minimum size) and
/// `info_length` (must meet that minimum); returns
/// `STATUS_INVALID_INFO_CLASS` / `STATUS_INFO_LENGTH_MISMATCH` in those
-/// cases. The only class with real side-effects in xenia-rs is
-/// `XFilePositionInformation` (14) — seek updates the file's cursor.
-/// Read-only VFS means `XFileEndOfFileInformation` (20, truncate) can
-/// only succeed if the new length equals the current size, otherwise
-/// returns `STATUS_UNSUCCESSFUL`. Other classes acknowledge the write
-/// but have no backing store.
+/// cases. Side-effect classes:
+/// * `XFileRenameInformation` (10) — rename a cache:-backed handle.
+/// * `XFilePositionInformation` (14) — seek updates the file's cursor.
+/// * `XFileEndOfFileInformation` (20) — truncate (cache: only; disc-VFS
+/// rejects non-identity truncates with `STATUS_UNSUCCESSFUL`).
+/// Other classes acknowledge the write but have no backing store.
fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = io_status_block, r5 = info_ptr,
// r6 = info_length, r7 = info_class.
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
let iosb_ptr = ctx.gpr[4] as u32;
let info_ptr = ctx.gpr[5] as u32;
let info_length = ctx.gpr[6] as u32;
@@ -1562,6 +2166,21 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
return;
}
+ // Phase C+11 — class 10 (`XFileRenameInformation`) needs both a
+ // read of `state.cache_root` (via `resolve_cache_path`) AND a mutable
+ // borrow of the target file handle. Rust's borrow checker can't see
+ // through `&self.method()` calls, so split it out before the shared
+ // `get_mut` destructure below.
+ if info_class == 10 {
+ let (status, out_length) =
+ handle_set_info_rename(mem, state, handle, info_ptr, info_length);
+ if iosb_ptr != 0 {
+ write_io_status_block(mem, iosb_ptr, status as u32, out_length);
+ }
+ ctx.gpr[3] = status;
+ return;
+ }
+
// Handle lookup.
let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else {
ctx.gpr[3] = STATUS_INVALID_HANDLE;
@@ -1634,6 +2253,48 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
ctx.gpr[3] = status;
}
+/// Phase C+12 — write the 56-byte `X_FILE_NETWORK_OPEN_INFORMATION`
+/// (`xenia-canary/src/xenia/kernel/info/file.h:117-127`) at `out` from
+/// the entry's metadata. All multibyte fields are stored big-endian
+/// (`be<uint64_t>` / `be<uint32_t>` in the canary struct); our
+/// `GuestMemory::write_u{32,64}` already byte-swaps via `to_be_bytes`,
+/// so the writes naturally produce the BE layout the Xbox 360 expects.
+///
+/// Layout (offset / size / type / canary field):
+/// ```text
+/// 0 u64 CreationTime (FILETIME)
+/// 8 u64 LastAccessTime
+/// 16 u64 LastWriteTime
+/// 24 u64 ChangeTime (= LastWriteTime per xboxkrnl_io.cc:504)
+/// 32 u64 AllocationSize
+/// 40 u64 EndOfFile
+/// 48 u32 Attributes (FILE_ATTRIBUTE_*)
+/// 52 u32 Reserved (= 0)
+/// ```
+fn write_file_network_open_information(
+ mem: &GuestMemory,
+ out: u32,
+ meta: &crate::state::CacheEntryMeta,
+) {
+ if out == 0 {
+ return;
+ }
+ mem.write_u64(out, meta.create_time);
+ mem.write_u64(out + 8, meta.access_time);
+ mem.write_u64(out + 16, meta.write_time);
+ // change_time = write_time per canary `xboxkrnl_io.cc:504`.
+ mem.write_u64(out + 24, meta.write_time);
+ mem.write_u64(out + 32, meta.allocation_size);
+ mem.write_u64(out + 40, meta.size);
+ let attrs = if meta.is_directory {
+ crate::state::X_FILE_ATTRIBUTE_DIRECTORY
+ } else {
+ crate::state::X_FILE_ATTRIBUTE_NORMAL
+ };
+ mem.write_u32(out + 48, attrs);
+ mem.write_u32(out + 52, 0);
+}
+
fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = obj_attrs, r4 = network_open_info
let obj_attrs_ptr = ctx.gpr[3] as u32;
@@ -1647,37 +2308,41 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state:
}
};
- // AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly
- // so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`)
- // see real attributes for files we just created and miss for files we
- // haven't.
- if let Some(hp) = state.resolve_cache_path(&path) {
- let entry = std::fs::metadata(&hp);
- match entry {
- Ok(md) => {
- let filetime: u64 = 132_500_000_000_000_000;
- if out != 0 {
- for off in (0..32).step_by(4) {
- mem.write_u32(out + off, if off & 4 == 0 {
- (filetime >> 32) as u32
- } else {
- filetime as u32
- });
- }
- mem.write_u64(out + 32, md.len());
- mem.write_u64(out + 40, md.len());
- let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 };
- mem.write_u32(out + 48, attrs);
- mem.write_u32(out + 52, 0);
+ // Phase C+12 — `cache:*` paths consult the in-memory entry mirror
+ // first, mirroring canary's `NtQueryFullAttributesFile_entry` which
+ // walks the in-memory entry tree via `VirtualFileSystem::ResolvePath`
+ // and never re-stats the host
+ // (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:498-512`).
+ //
+ // The entry tree is seeded at mount time by
+ // `populate_cache_entries_from_host` (mirrors canary's eager
+ // `HostPathDevice::PopulateEntry`) and refreshed per-NtCreateFile
+ // by `register_cache_entry` (mirrors canary's `Entry::CreateEntry`).
+ // A second-line host-FS fallback handles the rare case where the
+ // entry tree lost track but the host file is present (defensive;
+ // canary returns NO_SUCH_FILE in that case so we keep this fallback
+ // narrow).
+ if path.to_ascii_lowercase().starts_with("cache:") {
+ if let Some(meta) = state.lookup_cache_entry(&path) {
+ write_file_network_open_information(mem, out, meta);
+ ctx.gpr[3] = STATUS_SUCCESS;
+ return;
+ }
+ // Host-FS defensive fallback — only fires when the in-memory
+ // tree missed but the file is on disk. Refreshes the tree as a
+ // side-effect so subsequent probes hit the fast path.
+ if let Some(hp) = state.resolve_cache_path(&path) {
+ if let Ok(md) = std::fs::metadata(&hp) {
+ state.register_cache_entry(&path, &md);
+ if let Some(meta) = state.lookup_cache_entry(&path) {
+ write_file_network_open_information(mem, out, meta);
+ ctx.gpr[3] = STATUS_SUCCESS;
+ return;
}
- ctx.gpr[3] = STATUS_SUCCESS;
- return;
- }
- Err(_) => {
- ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
- return;
}
}
+ ctx.gpr[3] = STATUS_NO_SUCH_FILE;
+ return;
}
let Some(vfs) = state.vfs.as_ref() else {
@@ -1687,24 +2352,23 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state:
match vfs.stat(&path) {
Ok(entry) => {
- // FILE_NETWORK_OPEN_INFORMATION (56 bytes): 4 × FILETIME,
- // AllocationSize(i64), EndOfFile(i64), FileAttributes(u32), pad(u32)
- let filetime: u64 = 132_500_000_000_000_000;
- if out != 0 {
- mem.write_u32(out, (filetime >> 32) as u32);
- mem.write_u32(out + 4, filetime as u32);
- mem.write_u32(out + 8, (filetime >> 32) as u32);
- mem.write_u32(out + 12, filetime as u32);
- mem.write_u32(out + 16, (filetime >> 32) as u32);
- mem.write_u32(out + 20, filetime as u32);
- mem.write_u32(out + 24, (filetime >> 32) as u32);
- mem.write_u32(out + 28, filetime as u32);
- mem.write_u64(out + 32, entry.size);
- mem.write_u64(out + 40, entry.size);
- let attrs: u32 = if entry.is_directory { 0x10 } else { 0x80 };
- mem.write_u32(out + 48, attrs);
- mem.write_u32(out + 52, 0);
- }
+ let meta = crate::state::CacheEntryMeta {
+ is_directory: entry.is_directory,
+ size: entry.size,
+ // Disc/VFS entries have no host metadata; use the same
+ // 4 KiB alignment canary derives from
+ // `device->bytes_per_sector()`. Disc devices default
+ // to 2048 in canary
+ // (`xenia-canary/src/xenia/vfs/devices/disc_image_device.cc`)
+ // but for the existence-probe consumers we hit on
+ // Sylpheed boot the exact alignment doesn't matter —
+ // they only branch on the SUCCESS/NOT_FOUND status.
+ allocation_size: (entry.size + 2047) & !2047,
+ create_time: 0,
+ access_time: 0,
+ write_time: 0,
+ };
+ write_file_network_open_information(mem, out, &meta);
ctx.gpr[3] = STATUS_SUCCESS;
}
Err(_) => {
@@ -1759,8 +2423,9 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
// r3=file_handle, r4=event_handle, r5=apc_routine, r6=apc_context,
// r7=io_status_block, r8=file_info_ptr, r9=length, r10=file_name,
// sp+... = restart_scan.
- let handle = ctx.gpr[3] as u32;
- let event_handle = ctx.gpr[4] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
+ let event_handle = state.resolve_handle(ctx.gpr[4] as u32);
let iosb_ptr = ctx.gpr[7] as u32;
let info_ptr = ctx.gpr[8] as u32;
let length = ctx.gpr[9] as u32;
@@ -1916,15 +2581,31 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut
fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let handle = ctx.gpr[3] as u32;
- // Aliased refcount: `NtDuplicateObject` returns the *source* handle as the
- // "new" handle (we don't mint fresh values), so the game commonly holds
- // two logical references to the same handle value. Without refcount, the
- // first `NtClose` wipes the object while the second reference is still
- // live, which traps any later wait on that handle (Sylpheed's
- // create→dup(SAME_ACCESS)→set→close pattern at 0x8246079c manifests this
- // — main thread then parks forever on the closed handle). Mirror Canary's
- // `ObjectTable::ReleaseHandle` (object_table.cc:189): decrement the
- // per-handle refcount and only drop the object when it reaches zero.
+ close_handle_internal(state, handle);
+ ctx.gpr[3] = 0;
+}
+
+/// Phase C+19: shared close path used by `nt_close`,
+/// `nt_duplicate_object`'s `DUPLICATE_CLOSE_SOURCE` branch, and
+/// `xam::xam_task_close_handle` (which canary defers to NtClose).
+///
+/// Mirrors canary's `ObjectTable::ReleaseHandle` (object_table.cc:237-256):
+/// decrement the slot's local refcount; on zero, emit `handle.destroy` for
+/// the slot AND release the canonical kernel object — the canonical entry
+/// (and its `KernelObject`) is removed only when `canonical_slot_count`
+/// reaches zero (all dup siblings are gone). This preserves canary's
+/// observable lifecycle:
+///
+/// - Each `NtClose` of a slot with `handle_refcount==1` emits exactly one
+/// `handle.destroy` event for that slot.
+/// - The underlying object survives until the last slot closes; only then
+/// are `state.objects`/`async_file_handles`/`pending_timer_fires` pruned.
+pub(crate) fn close_handle_internal(state: &mut KernelState, handle: u32) {
+ let prior_rc = state
+ .handle_refcount
+ .get(&handle)
+ .copied()
+ .unwrap_or(0);
let remaining = state
.handle_refcount
.get_mut(&handle)
@@ -1934,14 +2615,51 @@ fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
})
.unwrap_or(0);
if remaining == 0 {
- state.objects.remove(&handle);
state.handle_refcount.remove(&handle);
- // If the object was an armed Timer, strip its pending-fire entry
- // so a later scheduler round doesn't try to signal a dead handle.
- // `disarm_timer` is a no-op for non-timer handles.
- state.disarm_timer(handle);
+
+ // Resolve the canonical id before we discard the alias entry —
+ // we need it to decrement the slot-count and possibly drop the
+ // backing object.
+ let canonical = state.resolve_handle(handle);
+ state.handle_aliases.remove(&handle);
+
+ // Decrement the canonical's live-slot count. If this slot was the
+ // last one referring to the canonical, drop the underlying object.
+ let slots_left = match state.canonical_slot_count.get_mut(&canonical) {
+ Some(c) => {
+ *c = c.saturating_sub(1);
+ *c
+ }
+ None => 0,
+ };
+
+ if slots_left == 0 {
+ state.canonical_slot_count.remove(&canonical);
+ state.objects.remove(&canonical);
+ // Phase C+5 — prune the async-file side-table when the underlying
+ // handle is finally released. Mirrors the canary `XFile` dtor
+ // releasing `is_synchronous_`. No-op for non-file handles.
+ state.async_file_handles.remove(&canonical);
+ // If the object was an armed Timer, strip its pending-fire entry
+ // so a later scheduler round doesn't try to signal a dead handle.
+ // `disarm_timer` is a no-op for non-timer handles.
+ state.disarm_timer(canonical);
+ }
+
+ // Phase C+15-α: schema-v1 `handle.destroy` event for the SLOT being
+ // closed (which is `handle`, not the canonical). Canary emits at
+ // `ObjectTable::RemoveHandle` (object_table.cc:294-296) per-slot,
+ // regardless of whether the underlying object still has sibling
+ // slots — so we match that.
+ if crate::event_log::is_enabled() {
+ let (tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ crate::event_log::emit_handle_destroy_auto(tid, cycle, handle, prior_rc);
+ }
}
- ctx.gpr[3] = 0;
}
fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
@@ -2186,6 +2904,139 @@ fn rtl_enter_critical_section(
return;
}
let current_tid = ctx.thread_id;
+
+ // Phase D Stage 3 — contention-replay manifest. When installed (via
+ // `XENIA_CONTENTION_MANIFEST_PATH`), the manifest tells us at which
+ // (tid, tid_event_idx) canary saw real contention on which CS. We
+ // peek the next per-tid ordinal, look up the manifest, and if it hits
+ // (with a matching cs_ptr) we emit a parity `contention.observed`
+ // event and force a park via the existing `cs_waiters` path.
+ //
+ // Wake comes when some other guest thread calls
+ // `RtlLeaveCriticalSection` on this CS naturally — the existing path
+ // at lines 2972-2980 handles the lock handoff. If no peer touches
+ // the CS, `Scheduler::unblock_on_deadlock` recovers with the existing
+ // CriticalSection-blocked wake at scheduler.rs:1208 (STATUS_TIMEOUT
+ // style — owner field will read 0 post-recovery, surfaceable as a
+ // downstream trace divergence rather than a silent hang).
+ //
+ // Default mode (no manifest installed): zero overhead, byte-identical
+ // to pre-Stage-3 behavior — `state.contention_manifest.as_ref()`
+ // short-circuits before peek_tid_idx.
+ // `consume_at_peek` translates ours's `peek_tid_idx` back to
+ // canary's idx space by subtracting the count of prior
+ // `contention.observed` emits on this tid (each emit shifts
+ // ours's per-tid idx by +1 relative to canary's stream). The
+ // bookkeeping is internal to the manifest; the caller just hands
+ // it the current peek value.
+ let manifest_hit = state
+ .contention_manifest
+ .as_ref()
+ .and_then(|m| {
+ let peek = crate::event_log::peek_tid_idx(current_tid);
+ m.consume_at_peek(current_tid, peek)
+ });
+ if let Some(entry) = manifest_hit {
+ // Per-tid ordinal alignment with canary: ALWAYS emit
+ // `contention.observed` when the manifest fires, even if we end
+ // up not parking. Canary emits one here too (Stage 1) so
+ // consuming one per-tid idx slot on this side keeps the
+ // downstream events aligned. Stage 4 marks the kind
+ // engine-local in the diff tool, so the diff tool advances past
+ // these events on either side without comparison.
+ //
+ // We do NOT verify `entry.cs_ptr == cs_ptr` because canary and
+ // ours route guest-heap allocations to different VA regions
+ // (AUDIT-043 ε host-allocator divergence). Trust the
+ // `(tid, tid_event_idx)` alignment instead; if we got here, the
+ // manifest hit at the same per-tid call-site as canary's
+ // contention.observed.
+ let guest_cycle = ctx.cycle_count;
+ crate::event_log::emit_contention_observed(
+ current_tid,
+ guest_cycle,
+ cs_ptr,
+ true,
+ );
+ if entry.cs_ptr != cs_ptr {
+ tracing::debug!(
+ "manifest cs_ptr cross-engine divergence at tid={} idx={}: manifest {:#010x}, ours {:#010x} (allocator ε)",
+ current_tid,
+ entry.tid_event_idx,
+ entry.cs_ptr,
+ cs_ptr,
+ );
+ }
+ // Stage 3 aggressive mode: force-park even when CS is free in
+ // guest memory. The bet is that some other guest tid will
+ // naturally acquire+release this CS during ours's park window,
+ // triggering the natural wake at lines 2972-2980. If no peer
+ // touches the CS, `Scheduler::unblock_on_deadlock` recovers via
+ // its existing CriticalSection-blocked wake path (returning
+ // with owner=0 and any state divergence surfacing as a
+ // downstream trace mismatch rather than a silent hang).
+ //
+ // The conservative skip-when-free variant (the plan's "deadlock
+ // safe" branch) keeps the prefix at 104,607 because it doesn't
+ // actually shift behavior at the contention point. Aggressive
+ // mode tests whether driving the contention path is enough to
+ // advance past the cap. Gate via `XENIA_CONTENTION_AGGRESSIVE=1`
+ // so we can flip without rebuilding.
+ let aggressive = std::env::var("XENIA_CONTENTION_AGGRESSIVE")
+ .ok()
+ .is_some_and(|v| {
+ let v = v.trim().to_ascii_lowercase();
+ v == "1" || v == "true" || v == "yes"
+ });
+ let pre_owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD);
+ let pre_owner_live = pre_owner != 0
+ && state.scheduler.find_by_tid(pre_owner).is_some();
+ let natural_contention = pre_owner_live && pre_owner != current_tid;
+ if aggressive && !natural_contention {
+ // Synthesize a forced-park via the same path as the natural
+ // contention branch below: bump lock_count, push self onto
+ // cs_waiters, then park. Note: we set owning_thread to a
+ // SENTINEL (current_tid) so that re-entries by the same tid
+ // see "self owns it" and recursion paths work; the natural
+ // wake path will overwrite owning_thread when it transfers
+ // the lock. (NB: this is a hack; only enabled by an env-var
+ // gate so the conservative default stays deadlock-safe.)
+ let lc = mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32;
+ mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, (lc + 1) as u32);
+ let current_ref = state.scheduler.current_ref();
+ state
+ .cs_waiters
+ .entry(cs_ptr)
+ .or_default()
+ .push(current_ref);
+ tracing::debug!(
+ "manifest AGGRESSIVE force-park: hw={} cs={:#010x} tid={} idx={} (owner was {})",
+ current_ref.hw_id,
+ cs_ptr,
+ current_tid,
+ entry.tid_event_idx,
+ pre_owner,
+ );
+ ctx.gpr[3] = 0;
+ state
+ .scheduler
+ .park_current(BlockReason::CriticalSection(cs_ptr));
+ return;
+ }
+ if !natural_contention {
+ tracing::debug!(
+ "manifest hit at tid={} idx={} cs={:#010x} but CS is free/self-owned (owner={}); replay skipped (state-divergence, not schedule-divergence)",
+ current_tid,
+ entry.tid_event_idx,
+ cs_ptr,
+ pre_owner,
+ );
+ // Fall through to natural fast-path.
+ }
+ // If natural contention conditions ARE met, fall through to the
+ // existing park path below.
+ }
+
let owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD);
// "Effective owner" — if the stored tid doesn't correspond to any live HW
@@ -2382,10 +3233,79 @@ fn rtl_fill_memory_ulong(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut K
}
}
-fn rtl_image_xex_header_field(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
- // r3 = xex_header_ptr, r4 = field_id
- // Return 0 for all fields
- ctx.gpr[3] = 0;
+fn rtl_image_xex_header_field(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
+ // r3 = xex_header_guest_ptr (may be NULL — game's CRT often passes 0
+ // because ours's `*XexExecutableModuleHandle = image_base` doesn't
+ // resolve to a real LDR_DATA_TABLE_ENTRY, so its `*(hmodule + 0x58)`
+ // deref yields PE OptionalHeader bytes instead of a header pointer;
+ // those bytes fail the game's validation and the call goes through
+ // with ptr=NULL). When NULL, fall back to KernelState's recorded
+ // `xex_header_guest_ptr` (the guest-VA of the raw XEX header copy
+ // set up in `xenia-app::cmd_exec`'s Phase 3, mirroring canary's
+ // `user_module.cc:223-227` `guest_xex_header_`).
+ // r4 = field_key (xex2_header_keys).
+ //
+ // Mirror of canary's `xboxkrnl_rtl.cc:501-514` →
+ // `UserModule::GetOptHeader(memory, header, key, &field_value)`
+ // (`user_module.cc:335-369`). Iterates `header->headers[]` (flat
+ // array of (key:u32, value:u32) pairs, both BE), and for the first
+ // entry where `opt_header.key == key` returns one of:
+ // * key & 0xFF == 0x00 → `opt_header.value` (inline value).
+ // * key & 0xFF == 0x01 → guest VA of `opt_header.value` itself.
+ // * else → `header_base + opt_header.offset`
+ // i.e. guest VA inside the header of the referenced data block.
+ // Returns 0 if the resolved header pointer is NULL or the key is
+ // not found.
+ let mut xex_header_ptr = ctx.gpr[3] as u32;
+ let field_key = ctx.gpr[4] as u32;
+ if xex_header_ptr == 0 {
+ xex_header_ptr = state.xex_header_guest_ptr;
+ }
+ if xex_header_ptr == 0 {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ // xex2_header layout (raw, BE; see xenia-canary `xex2_info.h`):
+ // +0x00 magic ("XEX2"), +0x04 module_flags, +0x08 header_size,
+ // +0x0C reserved, +0x10 security_offset, +0x14 header_count,
+ // +0x18.. array of (key:u32, value:u32) pairs.
+ let header_count = mem.read_u32(xex_header_ptr.wrapping_add(0x14));
+ let entries_base = xex_header_ptr.wrapping_add(0x18);
+ let mut field_value: u32 = 0;
+ let mut found = false;
+ for i in 0..header_count {
+ let entry_addr = entries_base.wrapping_add(i.wrapping_mul(8));
+ let entry_key = mem.read_u32(entry_addr);
+ if entry_key != field_key {
+ continue;
+ }
+ found = true;
+ let entry_value_addr = entry_addr.wrapping_add(4);
+ match entry_key & 0xFF {
+ 0x00 => {
+ // Inline value.
+ field_value = mem.read_u32(entry_value_addr);
+ }
+ 0x01 => {
+ // Pointer to the inline value slot itself.
+ field_value = entry_value_addr;
+ }
+ _ => {
+ // Offset within the header. `opt_header.value` here is the
+ // file offset of the optional data block, which canary
+ // copied verbatim into guest memory at `xex_header_ptr`,
+ // so `xex_header_ptr + offset` is the in-guest VA.
+ let offset = mem.read_u32(entry_value_addr);
+ field_value = xex_header_ptr.wrapping_add(offset);
+ }
+ }
+ break;
+ }
+ if !found {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ ctx.gpr[3] = field_value as u64;
}
fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
@@ -2410,13 +3330,19 @@ fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state:
}
fn rtl_nt_status_to_dos_error(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
- // Simple mapping for common cases
+ // NTSTATUS → Win32 ERROR_* translation. Canary's
+ // `RtlNtStatusToDosError` mirrors the documented Windows
+ // implementation; the subset below covers the codes Sylpheed
+ // surfaces in the Phase A diff window. Add new mappings as new
+ // divergences appear rather than synthesising a giant table up-front.
let status = ctx.gpr[3] as u32;
ctx.gpr[3] = match status {
- 0 => 0, // ERROR_SUCCESS
- 0xC000_0034 => 2, // ERROR_FILE_NOT_FOUND
- 0xC000_0011 => 38, // ERROR_HANDLE_EOF
- _ => status as u64, // Pass through
+ 0x0000_0000 => 0, // STATUS_SUCCESS → ERROR_SUCCESS
+ 0xC000_000F => 2, // STATUS_NO_SUCH_FILE → ERROR_FILE_NOT_FOUND
+ 0xC000_0011 => 38, // STATUS_END_OF_FILE → ERROR_HANDLE_EOF
+ 0xC000_0034 => 2, // STATUS_OBJECT_NAME_NOT_FOUND → ERROR_FILE_NOT_FOUND
+ 0xC000_0035 => 183, // STATUS_OBJECT_NAME_COLLISION → ERROR_ALREADY_EXISTS
+ _ => status as u64, // Pass through
};
}
@@ -3233,10 +4159,24 @@ fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state:
state.xaudio.worker_refs[index] = Some(r);
}
+ // Phase HostAudioEager (2026-05-19): mirror canary's
+ // `client_semaphore->Release(queued_frames_=8)` at
+ // `audio_system.cc:210` — seed the audio fire queue immediately so
+ // the round prologue's `try_inject_audio_callback` delivers the
+ // first callback within a few rounds of register-return, BEFORE
+ // tid=1 reaches `ExCreateThread` for the XAudio worker threads
+ // (tid=14/15 in canary, tid=9/10 in ours). Pre-fix, the 48k-
+ // instruction ticker delay let those threads spawn and enter their
+ // spin loop on the uninitialized voice struct before any callback
+ // fired. See `audit-runs/phase-host-audio-eager/investigation.md`.
+ let seeded = state
+ .xaudio
+ .seed_fires_for(index, crate::xaudio::XAUDIO_REGISTER_SEED_FIRES);
+
tracing::info!(
- "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}",
+ "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?} seeded_fires={}",
index, callback_pc, callback_arg, wrapped, driver_id,
- state.xaudio.worker_handles[index],
+ state.xaudio.worker_handles[index], seeded,
);
ctx.gpr[3] = 0;
}
@@ -3266,6 +4206,78 @@ fn xma_create_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kern
ctx.gpr[3] = handle as u64;
}
+// ===== Crypto =====
+
+/// Mirrors xenia-canary `XeCryptSha_entry` (xboxkrnl_crypt.cc:469-489):
+/// 3-input SHA-1 accumulator. Each of the three (ptr, size) pairs is
+/// processed only when both ptr and size are non-zero. The resulting
+/// 20-byte digest is copied to `output`, truncated to `output_size`.
+/// Void return (registered via `register_void_export`).
+fn xe_crypt_sha(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
+ use sha1::{Digest, Sha1};
+ let input_1 = ctx.gpr[3] as u32;
+ let input_1_size = ctx.gpr[4] as u32;
+ let input_2 = ctx.gpr[5] as u32;
+ let input_2_size = ctx.gpr[6] as u32;
+ let input_3 = ctx.gpr[7] as u32;
+ let input_3_size = ctx.gpr[8] as u32;
+ let output = ctx.gpr[9] as u32;
+ let output_size = ctx.gpr[10] as u32;
+ let mut hasher = Sha1::new();
+ for (ptr, size) in [
+ (input_1, input_1_size),
+ (input_2, input_2_size),
+ (input_3, input_3_size),
+ ] {
+ if ptr != 0 && size != 0 {
+ let mut buf = vec![0u8; size as usize];
+ mem.read_bytes(ptr, &mut buf);
+ hasher.update(&buf);
+ }
+ }
+ let digest = hasher.finalize();
+ let n = std::cmp::min(20, output_size as usize);
+ if output != 0 && n != 0 {
+ mem.write_bytes(output, &digest[..n]);
+ }
+}
+
+/// Mirrors xenia-canary `XeKeysConsolePrivateKeySign_entry`
+/// (xboxkrnl_crypt.cc:1111-1138): writes a hardcoded fake
+/// `XE_CONSOLE_CERTIFICATE` (0x1A8 bytes) to `output` and returns 1
+/// (success). Returns 0 if either pointer is null. The 5-byte
+/// `XE_CONSOLE_ID` bit-field at offset 0x02 is laid out per MSVC
+/// `#pragma pack(1)` semantics; we write the precomputed bytes
+/// directly to avoid bit-fiddling ambiguity.
+fn xe_keys_console_private_key_sign(
+ ctx: &mut PpcContext,
+ mem: &GuestMemory,
+ _state: &mut KernelState,
+) {
+ let hash = ctx.gpr[3] as u32;
+ let output = ctx.gpr[4] as u32;
+ if hash == 0 || output == 0 {
+ ctx.gpr[3] = 0;
+ return;
+ }
+ // Zero the 0x1A8-byte struct first (canary calls `output.Zero()`).
+ let zeros = [0u8; 0x1A8];
+ mem.write_bytes(output, &zeros);
+ // XE_CONSOLE_ID at offset 0x02 (5 bytes, MSVC pack(1) bit-fields).
+ // RefurbBits = 0b0011, ManufactureMonth = 0b1001 → byte 0 = 0x93
+ // ManufactureYear = 1, MacIndex3 = 0x40, MacIndex4 = 0x66,
+ // MacIndex5 = 0x7E, Crc = 0 → bytes 1..5 = 0x01,0x64,0xE6,0x07
+ // (LSB-first packing of the 32-bit storage unit at offset 1.)
+ let console_id = [0x93u8, 0x01, 0x64, 0xE6, 0x07];
+ mem.write_bytes(output + 0x02, &console_id);
+ // console_type (u32 BE) at 0x18 → Retail = 2
+ mem.write_u32(output + 0x18, 2);
+ // manufacture_date[8] at 0x1C
+ let mfg_date = [2u8, 0, 0, 5, 1, 1, 2, 2];
+ mem.write_bytes(output + 0x1C, &mfg_date);
+ ctx.gpr[3] = 1;
+}
+
// ===== Xex =====
/// Mirrors xenia-canary `XexCheckExecutablePrivilege_entry`
@@ -3503,14 +4515,20 @@ pub(crate) fn parse_timeout(state: &KernelState, timeout_ptr: u32, mem: &GuestMe
/// running its real body, leaving the main thread parked forever on the
/// completion event.
fn resolve_pseudo_handle(state: &KernelState, handle: u32) -> u32 {
- match handle {
+ let raw = match handle {
0xFFFF_FFFF => 0,
0xFFFF_FFFE => {
let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
state.scheduler.thread_handle(hw_id).unwrap_or(0)
}
h => h,
- }
+ };
+ // Phase C+19: canonicalize through the dup-alias map so every Nt*/Ke*
+ // call site that funnels through `resolve_pseudo_handle` (18 sites at
+ // C+19 landing) automatically routes dup ids back to their source
+ // slot before indexing `state.objects`. Preserves AUDIT-062's
+ // signal-on-dup-wakes-wait-on-source invariant.
+ state.resolve_handle(raw)
}
/// Lazily register a shadow kernel object for a guest `PKEVENT` / `PKSEMAPHORE`
@@ -3590,13 +4608,62 @@ fn ensure_dispatcher_object(state: &mut KernelState, mem: &GuestMemory, ptr: u32
},
_ => return,
};
+ // Phase C+17: object_type for the schema-v1 `handle.create` emit
+ // below. Must match `KernelObject::schema_object_type` exactly so
+ // re-entrant lookups via `lookup_handle_semantic_id` resolve a SID
+ // computed from the same tuple `(create_site_pc=0, tid, idx, type)`.
+ let object_type = obj.schema_object_type();
state.objects.insert(ptr, obj);
+ // Phase C+17: each fresh shadow gets a baseline refcount of 1 so
+ // the lifecycle bookkeeping is symmetric with `alloc_handle_for`.
+ // No `handle.destroy` is currently emitted on shadow removal —
+ // canary's `GetNativeObject` lazy-wrap likewise survives for the
+ // session — but the entry's presence guards against
+ // accidental-underflow when future code wires the symmetric destroy.
+ state.handle_refcount.entry(ptr).or_insert(1);
// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
// adoption, stamp the X_DISPATCH_HEADER's wait_list with the kXObjSignature
// fourcc 'X','E','N','\0' (flink_ptr) and the stash handle (blink_ptr).
// Game code reads these to recognize already-adopted dispatchers.
mem.write_u32(ptr + 0x08, 0x58454E00);
mem.write_u32(ptr + 0x0C, ptr);
+ // Phase C+17: schema-v1 `handle.create` event for the synthesized
+ // wrapper. Mirrors canary's `ObjectTable::AddHandle` emit
+ // (util/object_table.cc:191-198) inside `XObject::GetNativeObject`
+ // (xobject.cc:436-449). The `raw_handle_id` is the guest dispatcher
+ // pointer itself — ours uses it as the shadow's handle key, and
+ // canary's `StashHandle` likewise round-trips through the same
+ // dispatcher slot, so cross-engine SID identity is independent of
+ // the concrete value. Cvar-gated default-off via
+ // `event_log::is_enabled()`. Registers the SID in the global
+ // registry so the immediately-following `wait.begin` resolves a
+ // non-zero `handles_semantic_ids` element.
+ //
+ // Phase C+18: use `emit_handle_create_shared_global` so the SID is
+ // **scheduling-invariant** — depends only on `(pointer, object_type)`.
+ // The dispatcher at this pointer is process-global; whichever guest
+ // thread happens to be the first toucher synthesizes the wrapper, but
+ // which thread wins is timing-dependent. Per-thread `(tid, idx)`-keyed
+ // SIDs would diverge between canary and ours at the SID level; the
+ // diff tool also uses SID equality to cross-tid match the floating
+ // `handle.create` event when the first-toucher is a different tid in
+ // each engine. See `event_log::semantic_id_shared_global` and the
+ // C+18 memory entry / schema-v1.md §"Shared-global SIDs".
+ if crate::event_log::is_enabled() {
+ let (tid, cycle) = if let Some(r) = state.scheduler.current {
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ } else {
+ (0u32, 0u64)
+ };
+ crate::event_log::emit_handle_create_shared_global(
+ tid,
+ cycle,
+ object_type,
+ ptr,
+ /* object_name */ None,
+ );
+ }
}
/// Set `gpr[3]` on a just-woken HW thread to reflect which handle in its
@@ -3717,54 +4784,76 @@ fn ke_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState
// for why we need the lazy-shadow step here.
let h = ctx.gpr[3] as u32;
ensure_dispatcher_object(state, mem, h);
- let previous = match state.objects.get_mut(&h) {
+ // Canary parity (xevent.cc:60-64): `XEvent::Set` returns constant `1`
+ // on success, NOT the prior signaled state as the NT contract claims.
+ // We compute `previous` for internal bookkeeping (audit_signal,
+ // wake_eligible_waiters honor the prior-state read), but report
+ // `1` for success / `0` for "no dispatcher found" to match the
+ // canary Phase A oracle. See Phase C+7 investigation.md.
+ let (previous, found) = match state.objects.get_mut(&h) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = true;
- prev as u32
+ (prev as u32, true)
}
- _ => 0,
+ _ => (0u32, false),
};
state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64);
wake_eligible_waiters(state, h);
- ctx.gpr[3] = previous as u64;
+ ctx.gpr[3] = if found { 1 } else { 0 };
}
fn ke_reset_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
+ // r3 = PKEVENT on Ke* (guest pointer). See `ensure_dispatcher_object`
+ // for the lazy-shadow step.
let h = ctx.gpr[3] as u32;
ensure_dispatcher_object(state, mem, h);
- let previous = match state.objects.get_mut(&h) {
+ // Canary parity (xevent.cc:72-75): `XEvent::Reset` returns constant `1`
+ // on success — exact sibling of `XEvent::Set`. The NT contract claims
+ // the prior signaled state, but canary hardcodes `1` and the game
+ // observes that value via Phase A oracle at idx=102164. Sibling fix
+ // of Phase C+7 KeSetEvent (xevent.cc:60-64). The `assert_always;
+ // return 0` arm is preserved (no shadow → 0).
+ let (previous, found) = match state.objects.get_mut(&h) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = false;
- prev as u32
+ (prev as u32, true)
}
- _ => 0,
+ _ => (0u32, false),
};
- ctx.gpr[3] = previous as u64;
+ state.audit_signal(h, ctx.lr as u32, "KeResetEvent", previous as u64);
+ ctx.gpr[3] = if found { 1 } else { 0 };
}
fn nt_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source so signal-on-dup wakes
+ // wait-on-source (AUDIT-062 invariant).
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
let prev_ptr = ctx.gpr[4] as u32;
- let previous = match state.objects.get_mut(&handle) {
+ // Canary parity (xboxkrnl_threading.cc:610-628): the optional out-pointer
+ // is filled with `was_signalled` = `ev->Set()` = constant 1 (see
+ // xevent.cc:60-64), NOT the prior signaled state. r3 carries
+ // STATUS_SUCCESS. We retain `previous` for internal audit/wake plumbing.
+ let (previous, found) = match state.objects.get_mut(&handle) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = true;
- prev as u32
+ (prev as u32, true)
}
- _ => 0,
+ _ => (0u32, false),
};
state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64);
wake_eligible_waiters(state, handle);
- if prev_ptr != 0 {
- mem.write_u32(prev_ptr, previous);
+ if prev_ptr != 0 && found {
+ mem.write_u32(prev_ptr, 1);
}
ctx.gpr[3] = STATUS_SUCCESS;
}
fn nt_clear_event(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&handle) {
*signaled = false;
}
@@ -4015,10 +5104,53 @@ fn nt_wait_for_single_object_ex(
) {
// r3 = handle, r4 = wait_mode, r5 = alertable, r6 = timeout_ptr
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
+ let alertable = ctx.gpr[5] != 0;
let timeout_ptr = ctx.gpr[6] as u32;
+ // Phase C+15-α: schema-v1 `wait.begin` event. Emitted BEFORE
+ // `do_wait_single` to surface the wait initiation regardless of
+ // synchronous vs. parked outcome. `wait.end` is deferred (the
+ // synchronous status is already captured in the
+ // immediately-following `kernel.return`). Canary's symmetric emit
+ // is at `NtWaitForSingleObjectEx_entry` body.
+ if crate::event_log::is_enabled() {
+ let timeout_ns = decode_timeout_ns(mem, timeout_ptr);
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
+ let (tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ crate::event_log::emit_wait_begin(
+ tid,
+ cycle,
+ &[sid],
+ timeout_ns,
+ alertable,
+ /* wait_all */ false,
+ );
+ }
do_wait_single(ctx, state, handle, timeout_ptr, mem);
}
+/// Phase C+15-α helper: decode a TIMEOUT* big-endian i64 to ns for the
+/// schema-v1 `wait.begin` payload. `timeout_ptr == 0` → INFINITE
+/// (encoded as -1 per schema). NT TIMEOUT units are 100ns. Negative
+/// values are relative (timeout from now); positive values are
+/// absolute deadlines. For simplicity (and to mirror canary's
+/// emission), we report the **raw** ticks unscaled; the diff tool
+/// only compares values, not their meaning. Encoding into ns matches
+/// schema-v1 field name; precise unit-conversion isn't required for
+/// cross-engine equality.
+fn decode_timeout_ns(mem: &GuestMemory, timeout_ptr: u32) -> i64 {
+ if timeout_ptr == 0 {
+ return -1;
+ }
+ let raw = mem.read_u64(timeout_ptr) as i64;
+ // NT TIMEOUT is 100ns ticks. Convert to ns; saturating to avoid
+ // wraparound on extreme values.
+ raw.saturating_mul(100)
+}
+
/// `NtSignalAndWaitForSingleObjectEx(signal_handle, wait_handle, wait_mode,
/// alertable, timeout_ptr)` — atomically signal one kernel object and wait on
/// another. Matches Canary's `NtSignalAndWaitForSingleObjectEx_entry`
@@ -4083,7 +5215,27 @@ fn ke_wait_for_single_object(
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
ensure_dispatcher_object(state, mem, handle);
refresh_pkevent_shadow_from_guest(state, mem, handle);
+ let alertable = ctx.gpr[6] != 0;
let timeout_ptr = ctx.gpr[7] as u32;
+ // Phase C+15-α: schema-v1 `wait.begin` event. Symmetric counterpart
+ // in canary at `xeKeWaitForSingleObject`.
+ if crate::event_log::is_enabled() {
+ let timeout_ns = decode_timeout_ns(mem, timeout_ptr);
+ let sid = crate::event_log::lookup_handle_semantic_id(handle);
+ let (tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ crate::event_log::emit_wait_begin(
+ tid,
+ cycle,
+ &[sid],
+ timeout_ns,
+ alertable,
+ /* wait_all */ false,
+ );
+ }
do_wait_single(ctx, state, handle, timeout_ptr, mem);
}
@@ -4173,7 +5325,9 @@ fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel
fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = prev_suspend_count_ptr
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source so a duplicated thread
+ // handle (rare but legal) still resolves to the scheduler entry.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
let prev_ptr = ctx.gpr[4] as u32;
let prev = state
.scheduler
@@ -4188,7 +5342,8 @@ fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS
fn nt_suspend_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = prev_suspend_count_ptr
- let handle = ctx.gpr[3] as u32;
+ // Phase C+19: canonicalize dup ids → source.
+ let handle = state.resolve_handle(ctx.gpr[3] as u32);
let prev_ptr = ctx.gpr[4] as u32;
let prev = state
.scheduler
@@ -4250,10 +5405,22 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke
/// * r4 = new_handle_ptr (if zero, the call is actually a close)
/// * r5 = options (bit 0 = DUPLICATE_CLOSE_SOURCE)
///
-/// Canary allocates a fresh handle id that refcounts the same underlying
-/// `XObject`. We don't refcount, so we alias: write the *source* handle back
-/// as the "new" handle. The game then uses it interchangeably, and both ids
-/// resolve to the same `KernelObject` entry.
+/// Canary's `ObjectTable::DuplicateHandle` (object_table.cc:210-223) allocates
+/// a fresh slot via `AddHandle` (which retains the underlying `XObject` and
+/// emits `handle.create`), returning the new slot id. Both source and dup
+/// slots independently refcount the same `XObject`; closing one decrements
+/// the slot's local count and, when zero, removes that slot. The underlying
+/// object dies only when the last slot is gone.
+///
+/// Phase C+19: ours mirrors this. Pre-C+19 we aliased `dup_id == source_id`
+/// to avoid maintaining a separate refcount across distinct ids; AUDIT-062
+/// verified the wedge-case (signal-on-dup wakes wait-on-source) worked
+/// because the ids collided into the same `state.objects` entry. Allocating
+/// a fresh id surfaces the canary-symmetric `handle.create` Phase A event
+/// at main idx=102553; the AUDIT-062 invariant is preserved by routing
+/// every Nt*/Ke* lookup through `state.resolve_handle` which canonicalizes
+/// the dup id back to the source — both ids still hit the same `KernelObject`
+/// with the same `waiters` list and `signaled` flag.
///
/// A prior `stub_success` left `*new_handle_ptr` uninitialized — Sylpheed's
/// thread-dispatch prologue does `NtDuplicateObject(event, &dup)` then passes
@@ -4261,34 +5428,70 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke
/// completion. With the stub, `dup` was stack garbage → set-event lookup
/// failed silently → main thread blocked forever on the source event.
fn nt_duplicate_object(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
- let source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
+ let raw_source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
+ // The guest may itself pass a dup id — canonicalize before validation
+ // so we always alias against the live `state.objects` entry.
+ let canonical = state.resolve_handle(raw_source);
let out_ptr = ctx.gpr[4] as u32;
let options = ctx.gpr[5] as u32;
+ const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
- if !state.objects.contains_key(&source) {
+ if !state.objects.contains_key(&canonical) {
if out_ptr != 0 {
mem.write_u32(out_ptr, 0);
}
ctx.gpr[3] = STATUS_INVALID_HANDLE;
return;
}
- if out_ptr != 0 {
- mem.write_u32(out_ptr, source);
- }
- // Aliased-handle refcount: since we return the source handle as the "new"
- // handle (no fresh id), every duplicate must bump the per-handle refcount
- // so the later `NtClose` pair (one for source, one for dup) doesn't
- // destroy the object mid-flight. `DUPLICATE_CLOSE_SOURCE` (bit 0) closes
- // the source in Canary (xboxkrnl_ob.cc:389), so in our aliased model the
- // source-close cancels the dup-gain: net refcount is unchanged. Without
- // `CLOSE_SOURCE`, both the source and the dup are separately live and we
- // need +1.
- const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
- if options & DUPLICATE_CLOSE_SOURCE == 0
- && let Some(c) = state.handle_refcount.get_mut(&source)
+
+ // Allocate a fresh slot id. Canonical refcount bumps by one slot;
+ // the dup slot starts with a single local NtClose owed to it.
+ let dup_id = state.alloc_handle();
+ state.handle_aliases.insert(dup_id, canonical);
+ state.handle_refcount.insert(dup_id, 1);
+ *state.canonical_slot_count.entry(canonical).or_insert(0) += 1;
+
+ // Phase C+15-α schema-v1 `handle.create` event. Canary's symmetric path
+ // is `ObjectTable::AddHandle` (object_table.cc:198-204) which emits
+ // when called from inside `DuplicateHandle`. SID recipe = per-tid
+ // `(creating_tid, idx_at_creation, object_type)` — matches canary's
+ // `EmitHandleCreateAuto` exactly (event_log.cc), so the same logical
+ // dup pair produces the same SID across engines.
+ if crate::event_log::is_enabled()
+ && let Some(obj) = state.objects.get(&canonical)
{
- *c += 1;
+ let object_type = obj.schema_object_type();
+ let (tid, cycle) = {
+ let r = state.scheduler.current_ref();
+ let t = state.scheduler.thread(r);
+ (t.tid, t.ctx.timebase)
+ };
+ crate::event_log::emit_handle_create_auto(
+ tid,
+ cycle,
+ /* create_site_pc */ 0,
+ object_type,
+ dup_id,
+ /* object_name */ None,
+ );
+ }
+
+ if out_ptr != 0 {
+ mem.write_u32(out_ptr, dup_id);
}
+
+ // DUPLICATE_CLOSE_SOURCE: canary additionally calls `RemoveHandle(handle)`
+ // (xboxkrnl_ob.cc:405-408) which decrements the source slot's refcount
+ // and — if zero — destroys the source slot (but leaves the underlying
+ // object alive through the dup). We mirror by routing the source through
+ // `close_handle_internal` so the symmetric `handle.destroy(source)` event
+ // fires at the canary-equivalent boundary. Note: the source value here
+ // is `raw_source` (the id the guest passed, post pseudo-handle resolve),
+ // NOT `canonical` — the close targets the *slot* the guest named.
+ if options & DUPLICATE_CLOSE_SOURCE != 0 {
+ close_handle_internal(state, raw_source);
+ }
+
ctx.gpr[3] = STATUS_SUCCESS;
}
@@ -4344,6 +5547,28 @@ mod tests {
mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
.expect("scratch page must commit");
let mut state = KernelState::new();
+ // Phase C+11 — the default cache root is now persistent, but
+ // tests must NOT share state. Override with a per-test tmpdir
+ // (unique by PID + monotonic counter + nanos) and wipe on
+ // entry. Mirrors the pre-flip AUDIT-038 behaviour for the
+ // test harness specifically.
+ static TEST_CACHE_ID: std::sync::atomic::AtomicU64 =
+ std::sync::atomic::AtomicU64::new(0);
+ let test_id = TEST_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .subsec_nanos();
+ let test_cache = std::env::temp_dir().join(format!(
+ "xenia-rs-test-cache-{}-{}-{}",
+ std::process::id(),
+ test_id,
+ nanos
+ ));
+ // Wipe any leftover, then install.
+ let _ = std::fs::remove_dir_all(&test_cache);
+ std::fs::create_dir_all(&test_cache).expect("test cache mkdir");
+ state.set_cache_root(test_cache);
// Under per-slot runqueues, most kernel exports reach through
// `scheduler.current` — tests that exercise those paths need a
// live thread installed on slot 0 first. Older tests (file I/O
@@ -4423,12 +5648,21 @@ mod tests {
// Confirm PCR was written by the spawn (sanity).
assert_eq!(mem.read_u32(pcr_base + 0x2C), 1);
- // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20).
+ // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20,
+ // prev_mask_ptr=scratch). Post Stage 2 Batch 3: r3=STATUS_SUCCESS,
+ // previous mask delivered via OUT-pointer.
+ let prev_ptr = SCRATCH_BASE + 0xA0;
+ mem.write_u32(prev_ptr, 0xFFFF_FFFF); // sentinel
ctx.gpr[3] = 0x2000;
ctx.gpr[4] = 0x20; // slot 5 only
+ ctx.gpr[5] = prev_ptr as u64;
ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
- // Return value = previous mask = 0x02.
- assert_eq!(ctx.gpr[3], 0x02);
+ assert_eq!(ctx.gpr[3], 0, "must return STATUS_SUCCESS in r3");
+ assert_eq!(
+ mem.read_u32(prev_ptr),
+ 0x02,
+ "previous affinity mask must be written to OUT-pointer"
+ );
// PCR rewritten to 5.
assert_eq!(mem.read_u32(pcr_base + 0x2C), 5);
// Thread now on slot 5.
@@ -4436,20 +5670,95 @@ mod tests {
assert_eq!(r.hw_id, 5);
}
- /// Axis 5: `KeSetIdealProcessor` stores a hint on the thread
- /// without migrating it; query round-trips.
+ /// Stage 2 Batch 3: zero affinity must return STATUS_INVALID_PARAMETER
+ /// and not touch the OUT-pointer.
+ #[test]
+ fn ke_set_affinity_thread_zero_affinity_returns_invalid_parameter() {
+ let (mut ctx, mem, mut state) = fresh();
+ let prev_ptr = SCRATCH_BASE + 0xA0;
+ mem.write_u32(prev_ptr, 0xDEAD_BEEF);
+ ctx.gpr[3] = 0x1000; // main handle
+ ctx.gpr[4] = 0; // zero affinity
+ ctx.gpr[5] = prev_ptr as u64;
+ ke_set_affinity_thread(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_000D, "STATUS_INVALID_PARAMETER");
+ assert_eq!(mem.read_u32(prev_ptr), 0xDEAD_BEEF, "OUT-ptr untouched");
+ }
+
+ /// Stage 2 Batch 3: NULL OUT-pointer is valid (mirrors canary's
+ /// `if (previous_affinity_ptr)` guard); still returns SUCCESS and
+ /// migrates the thread.
#[test]
- fn ke_set_ideal_processor_round_trips() {
+ fn ke_set_affinity_thread_null_out_ptr_still_succeeds() {
let (mut ctx, mut mem, mut state) = fresh();
- // Main thread handle is 0x1000.
- ctx.gpr[3] = 0x1000;
- ctx.gpr[4] = 3;
- ke_set_ideal_processor(&mut ctx, &mut mem, &mut state);
+ use xenia_cpu::scheduler::SpawnParams;
+ let pcr_base = SCRATCH_BASE + 0x500;
+ let params = SpawnParams {
+ entry: 0x8200_0000,
+ start_context: 0,
+ stack_base: 0x7200_0000,
+ stack_size: 0x10000,
+ pcr_base,
+ tls_base: 0,
+ thread_handle: 0x2100,
+ guest_tid: 43,
+ create_suspended: false,
+ is_initial: false,
+ tls_slot_count: 0,
+ affinity_mask: 0b0000_0010,
+ priority: 0,
+ ideal_processor: None,
+ };
+ state
+ .scheduler
+ .spawn(params, &mut crate::state::GuestMemoryPcr(&mut mem))
+ .unwrap();
+ ctx.gpr[3] = 0x2100;
+ ctx.gpr[4] = 0x10; // slot 4
+ ctx.gpr[5] = 0; // NULL OUT-ptr
+ ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS even with NULL OUT-ptr");
+ let r = state.scheduler.find_by_handle(0x2100).expect("alive");
+ assert_eq!(r.hw_id, 4);
+ }
+
+ /// Axis 5: scheduler-level ideal-processor hint round-trip via
+ /// `Scheduler::set_ideal_ref` / `ideal_ref`. The previous test
+ /// exercised `ke_set_ideal_processor` / `ke_query_ideal_processor`
+ /// which were hallucinated functions at the wrong ordinals — those
+ /// bodies were removed in Phase C+6½. The underlying scheduler
+ /// state still backs `NtSetInformationThread` info-class
+ /// `ThreadIdealProcessor`.
+ #[test]
+ fn scheduler_ideal_processor_round_trips() {
+ let (_, _, mut state) = fresh();
+ let r = state.scheduler.find_by_handle(0x1000).expect("main alive");
// Prior was 0xFF (unset sentinel).
- assert_eq!(ctx.gpr[3], 0xFF);
- ctx.gpr[3] = 0x1000;
- ke_query_ideal_processor(&mut ctx, &mut mem, &mut state);
- assert_eq!(ctx.gpr[3], 3);
+ let prev = state.scheduler.set_ideal_ref(r, 3);
+ assert_eq!(prev, 0xFF);
+ let queried = state.scheduler.ideal_ref(r);
+ assert_eq!(queried, Some(3));
+ }
+
+ /// Phase C+6½: `KeQueryInterruptTime` (ord 0x82) returns a
+ /// non-zero monotonic u64 in gpr[3]. Previously this ord was
+ /// mis-labeled `KeQueryIdealProcessor` and returned a 1-byte
+ /// processor index — guests querying the system interrupt-time
+ /// counter received the wrong value.
+ #[test]
+ fn ke_query_interrupt_time_returns_synthetic_u64() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ // Pre-clear gpr[3] so we know the function wrote it.
+ ctx.gpr[3] = 0;
+ ke_query_interrupt_time(&mut ctx, &mut mem, &mut state);
+ assert_ne!(ctx.gpr[3], 0, "interrupt time must be non-zero");
+ // Should be 64-bit (above u32::MAX) to ensure it's not
+ // truncated to a processor-index byte.
+ assert!(
+ ctx.gpr[3] > 0xFFFF_FFFF,
+ "interrupt time must occupy 64 bits, got {:#x}",
+ ctx.gpr[3]
+ );
}
/// Axis 5: `NtSetInformationThread` class `ThreadAffinityMask`
@@ -4660,6 +5969,94 @@ mod tests {
assert!(event_signaled(&state, evt), "write must signal too");
}
+ /// Phase C+5 — async-opened files (no `FILE_SYNCHRONOUS_IO_*` bit in
+ /// `create_options`) return `STATUS_PENDING` (0x103) from
+ /// `NtWriteFile`. The synchronous write still completes and
+ /// IO_STATUS_BLOCK still records STATUS_SUCCESS — only the function
+ /// return value flips. Mirrors canary
+ /// `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353`.
+ #[test]
+ fn nt_write_file_async_handle_returns_status_pending() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ // Pre-register an "async" file handle the same way `open_vfs_file`
+ // does for a file whose `create_options` omits sync bits.
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "async.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ state.async_file_handles.insert(handle);
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0; // no event
+ ctx.gpr[7] = SCRATCH_BASE as u64; // iosb at scratch base
+ ctx.gpr[9] = 8; // length
+ nt_write_file(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_PENDING,
+ "async-opened file: r3 must return STATUS_PENDING (0x103)"
+ );
+ assert_eq!(
+ mem.read_u32(SCRATCH_BASE),
+ STATUS_SUCCESS as u32,
+ "IO_STATUS_BLOCK.status still records STATUS_SUCCESS"
+ );
+ assert_eq!(
+ mem.read_u32(SCRATCH_BASE + 4),
+ 8,
+ "IO_STATUS_BLOCK.information records bytes written"
+ );
+ }
+
+ /// Sync-opened files (one of `FILE_SYNCHRONOUS_IO_*` bits set in
+ /// `create_options`) retain the legacy `STATUS_SUCCESS` return.
+ #[test]
+ fn nt_write_file_sync_handle_returns_status_success() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "sync.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ // Not inserted into `async_file_handles` — sync handle by default.
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0;
+ ctx.gpr[7] = SCRATCH_BASE as u64;
+ ctx.gpr[9] = 8;
+ nt_write_file(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "sync-opened file: r3 must return STATUS_SUCCESS"
+ );
+ }
+
+ /// `nt_close` must prune the async-file side-table when the final
+ /// refcount drops to zero so a recycled handle isn't mis-classified.
+ #[test]
+ fn nt_close_prunes_async_file_set() {
+ let (mut ctx, mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::File {
+ path: "x.tmp".to_string(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ state.async_file_handles.insert(handle);
+ ctx.gpr[3] = handle as u64;
+ nt_close(&mut ctx, &mem, &mut state);
+ assert!(
+ !state.async_file_handles.contains(&handle),
+ "nt_close must remove from async_file_handles"
+ );
+ }
+
/// Verify `FileStandardInformation` reports `Directory=1` for empty-path
/// (device-root) synthesized file handles. Sylpheed calls
/// `NtCreateFile("game:\\")` then `NtQueryInformationFile` on the returned
@@ -5023,8 +6420,13 @@ mod tests {
write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // notification
ctx.gpr[3] = kevent_ptr as u64;
ke_reset_event(&mut ctx, &mut mem, &mut state);
- // After reset, shadow exists and is unsignaled; gpr[3] reports previous=1.
- assert_eq!(ctx.gpr[3], 1, "previous state must be reported");
+ // After reset, shadow exists and is unsignaled. Post-C+8: gpr[3]
+ // reports canary-constant `1` on hit (xevent.cc:72-75 hardcodes
+ // `return 1`), NOT the prior signaled state — same value here by
+ // coincidence (prior state happens to be 1). The
+ // `ke_reset_event_returns_constant_one_on_unsignaled_*` tests below
+ // distinguish constant-return from prior-state-return.
+ assert_eq!(ctx.gpr[3], 1, "canary parity: KeResetEvent returns constant 1 on hit");
match state.objects.get(&kevent_ptr) {
Some(KernelObject::Event { manual_reset, signaled, .. }) => {
assert!(*manual_reset, "type=0 must be manual-reset");
@@ -5117,6 +6519,95 @@ mod tests {
assert_eq!(mem.read_u32(ptr + 0x0C), 0);
}
+ /// Phase C+17: first adoption of a guest dispatcher pointer via
+ /// `ensure_dispatcher_object` must seed `handle_refcount[ptr] = 1`,
+ /// mirroring canary's `ObjectTable::AddHandle` baseline
+ /// (object_table.cc:164). Symmetric to `alloc_handle_for` which
+ /// already does this for handle-based objects.
+ #[test]
+ fn ensure_dispatcher_object_initializes_handle_refcount_for_event() {
+ let (mut _ctx, mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0x800;
+ write_dispatcher_header(&mem, kevent_ptr, 1, 0); // synchronization
+ assert!(!state.handle_refcount.contains_key(&kevent_ptr));
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
+ assert!(state.objects.contains_key(&kevent_ptr));
+ assert_eq!(
+ state.handle_refcount.get(&kevent_ptr).copied(),
+ Some(1),
+ "fresh shadow must start with refcount 1"
+ );
+ }
+
+ /// Same baseline for semaphores. Header type=5 picks the
+ /// Semaphore branch; refcount is independent of count/max.
+ #[test]
+ fn ensure_dispatcher_object_initializes_handle_refcount_for_semaphore() {
+ let (mut _ctx, mem, mut state) = fresh();
+ let sem_ptr = SCRATCH_BASE + 0x820;
+ write_dispatcher_header(&mem, sem_ptr, 5, 0);
+ mem.write_u32(sem_ptr + 0x10, 4); // Limit=4
+ ensure_dispatcher_object(&mut state, &mem, sem_ptr);
+ assert!(matches!(state.objects.get(&sem_ptr), Some(KernelObject::Semaphore { .. })));
+ assert_eq!(state.handle_refcount.get(&sem_ptr).copied(), Some(1));
+ }
+
+ /// Re-entry on the same pointer is a no-op: the early-return guard
+ /// at the top of `ensure_dispatcher_object` (contains_key check)
+ /// must NOT double-bump the refcount. Mirrors canary's
+ /// `kXObjSignature` short-circuit (xobject.cc:421-427).
+ #[test]
+ fn ensure_dispatcher_object_is_idempotent_on_repeated_touch() {
+ let (mut _ctx, mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0x840;
+ write_dispatcher_header(&mem, kevent_ptr, 0, 0); // notification
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
+ ensure_dispatcher_object(&mut state, &mem, kevent_ptr);
+ assert_eq!(
+ state.handle_refcount.get(&kevent_ptr).copied(),
+ Some(1),
+ "repeated ensure must not bump refcount"
+ );
+ }
+
+ /// Two distinct native pointers each get their own shadow and
+ /// their own refcount entry. Canary's `GetNativeObject` lazy-wraps
+ /// each dispatcher independently — there's no shared XObject for
+ /// distinct guest pointers.
+ #[test]
+ fn ensure_dispatcher_object_distinct_ptrs_get_distinct_refcount_entries() {
+ let (mut _ctx, mem, mut state) = fresh();
+ let a = SCRATCH_BASE + 0x860;
+ let b = SCRATCH_BASE + 0x880;
+ write_dispatcher_header(&mem, a, 1, 0);
+ write_dispatcher_header(&mem, b, 5, 0);
+ mem.write_u32(b + 0x10, 2);
+ ensure_dispatcher_object(&mut state, &mem, a);
+ ensure_dispatcher_object(&mut state, &mem, b);
+ assert_eq!(state.handle_refcount.get(&a).copied(), Some(1));
+ assert_eq!(state.handle_refcount.get(&b).copied(), Some(1));
+ assert!(matches!(state.objects.get(&a), Some(KernelObject::Event { .. })));
+ assert!(matches!(state.objects.get(&b), Some(KernelObject::Semaphore { .. })));
+ }
+
+ /// Unsupported dispatcher types (e.g., Mutant type=2 — canary's
+ /// `GetNativeObject` `assert_always`s on them) must leave both
+ /// `state.objects` AND `state.handle_refcount` untouched. The
+ /// early-return after the match guard prevents both insertions.
+ #[test]
+ fn ensure_dispatcher_object_unknown_type_does_not_touch_refcount() {
+ let (mut _ctx, mem, mut state) = fresh();
+ let ptr = SCRATCH_BASE + 0x8A0;
+ write_dispatcher_header(&mem, ptr, 2, 0); // Mutant — unsupported
+ ensure_dispatcher_object(&mut state, &mem, ptr);
+ assert!(!state.objects.contains_key(&ptr));
+ assert!(
+ !state.handle_refcount.contains_key(&ptr),
+ "no refcount entry for unsupported dispatcher type"
+ );
+ }
+
/// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
/// adoption of a guest dispatcher, +0x08 must hold the 'X','E','N','\0'
/// fourcc and +0x0C must hold the stash handle.
@@ -6215,6 +7706,14 @@ mod tests {
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\rt.tmp");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
+ // Phase C+5 — set sp so nt_create_file reads create_options from a
+ // committed scratch slot, and set the FILE_SYNCHRONOUS_IO_NONALERT
+ // bit so `NtWriteFile` returns `STATUS_SUCCESS` (legacy assertion).
+ // Files opened WITHOUT this bit return `STATUS_PENDING` after
+ // canary's xboxkrnl_io.cc:351-353 — covered by
+ // `nt_write_file_async_handle_returns_status_pending`.
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
@@ -6335,22 +7834,1537 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
- /// `resolve_cache_path` rejects path-traversal attempts so a guest
- /// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
+ /// Phase C+11 Stage 2 — when a `cache:\<name>` file already exists
+ /// on disk as a regular file, re-opening it with the
+ /// `FILE_DIRECTORY_FILE` bit set MUST still route through the file
+ /// branch (host_path = Some) — the on-disk type wins. Pre-fix:
+ /// `is_dir_open = want_dir || host_path.is_dir()` would force
+ /// re-opens with bit 0x1 set into the dir branch, dropping
+ /// host_path and blocking subsequent class-10 renames.
#[test]
- fn cache_resolve_strips_path_traversal() {
- let dir = std::env::temp_dir().join(format!(
- "xenia-rs-cache-test-trav-{}",
- std::process::id()
- ));
- std::fs::create_dir_all(&dir).unwrap();
- let mut state = KernelState::new();
- state.init_cache_root(dir.clone()).unwrap();
- let resolved = state
- .resolve_cache_path("cache:\\..\\..\\etc\\foo")
- .expect("must resolve");
- assert!(resolved.starts_with(&dir), "must stay inside cache root");
- assert!(resolved.ends_with("etc/foo"));
- std::fs::remove_dir_all(&dir).ok();
+ fn cache_existing_file_wins_over_directory_bit() {
+ let (mut ctx, mem, mut state) = fresh();
+ let cache_root = state.cache_root.clone().unwrap();
+
+ // 1. FILE_CREATE without DIRECTORY bit → produces a real file.
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_CREATE as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ assert!(cache_root.join("foo.tmp").is_file());
+
+ // 2. Re-open with FILE_DIRECTORY_FILE bit set in r7.
+ // open_options bit 0x1 = FILE_DIRECTORY_FILE.
+ // open_options bit 0x20 = FILE_SYNCHRONOUS_IO_NONALERT (keeps
+ // the handle synchronous so NtWriteFile returns STATUS_SUCCESS).
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[7] = (0x1 | FILE_SYNCHRONOUS_IO_NONALERT) as u64;
+ nt_open_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ let handle = mem.read_u32(handle_out);
+
+ // 3. The re-opened handle MUST be a file handle with a real
+ // host_path, not a directory handle with host_path=None.
+ let obj = state.objects.get(&handle).expect("handle must exist");
+ match obj {
+ KernelObject::File { host_path, path, .. } => {
+ assert!(
+ host_path.is_some(),
+ "existing file re-open must keep host_path (got None) — bug #2 regression"
+ );
+ assert!(
+ !path.ends_with('/'),
+ "existing file re-open path must NOT have trailing '/' (got dir-shape) — bug #2 regression"
+ );
+ }
+ _ => panic!("expected File kernel object"),
+ }
+ }
+
+ /// Phase C+11 Stage 2 — `cache:\access`, `cache:\ignore`, and
+ /// `cache:\recent` are TOP-LEVEL files in canary's cache (per
+ /// the canary-cache-listing.csv enumeration). Cold creation
+ /// through ours should produce files, not directories.
+ #[test]
+ fn cache_top_level_manifests_create_as_files() {
+ for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
+ let (mut ctx, mem, mut state) = fresh();
+ let cache_root = state.cache_root.clone().unwrap();
+ let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
+
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ // Set FILE_NON_DIRECTORY_FILE explicitly so Sylpheed-style
+ // create paths produce host files. (If Sylpheed sets the
+ // DIRECTORY bit but no NON_DIRECTORY bit, the pre-fix code
+ // would mis-create as dirs; this test pins the
+ // bit-conflict-resolution policy.)
+ mem.write_u32(
+ SCRATCH_BASE + 0x700 + 0x54,
+ FILE_SYNCHRONOUS_IO_NONALERT | 0x40, // | FILE_NON_DIRECTORY_FILE
+ );
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_CREATE as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "FILE_CREATE on {} must succeed",
+ path_str
+ );
+ assert!(
+ cache_root.join(leaf_name).is_file(),
+ "cache:\\{} must be a host file (got: dir or absent)",
+ leaf_name
+ );
+ }
+ }
+
+ /// Phase C+11.1 — Sylpheed's cold-boot probe pattern: open
+ /// `cache:\access` / `cache:\ignore` / `cache:\recent` with
+ /// disp=1 (FILE_OPEN) + opts=0x7 (DIRECTORY_FILE | WRITE_THROUGH
+ /// | SEQUENTIAL_ONLY) MUST return `STATUS_OBJECT_NAME_NOT_FOUND`
+ /// and MUST NOT create a host directory. Pre-fix the
+ /// `is_dir_open` branch unconditionally mkdir-p'd whenever
+ /// `want_dir`, which produced spurious `access`/`ignore`/`recent`
+ /// directories that then occluded later `disp=5 NON_DIRECTORY`
+ /// re-creates Sylpheed uses to populate the manifests.
+ /// Mirrors canary's `VirtualFileSystem::OpenFile`
+ /// (virtual_file_system.cc:265-273) which returns
+ /// `X_STATUS_OBJECT_NAME_NOT_FOUND` for `kOpen` on missing path,
+ /// regardless of `is_directory`.
+ #[test]
+ fn cache_open_directory_on_missing_path_returns_not_found() {
+ for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
+ let (mut ctx, mem, mut state) = fresh();
+ let cache_root = state.cache_root.clone().unwrap();
+ let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
+
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ // Sylpheed's exact cold-boot bit pattern: FILE_DIRECTORY_FILE
+ // (0x1) | FILE_WRITE_THROUGH (0x2) | FILE_SEQUENTIAL_ONLY (0x4)
+ // = 0x7. Slot offset 0x54 per the `nt_create_file`
+ // arg-marshalling.
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OPEN as u64;
+ // Clear any pre-existing handle slot so the assert is honest.
+ mem.write_u32(handle_out, 0xDEAD_BEEF);
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND,
+ "FILE_OPEN+DIR on missing {} must return NOT_FOUND",
+ path_str
+ );
+ assert_eq!(
+ mem.read_u32(handle_out),
+ 0,
+ "no handle on cold-boot dir-open miss for {}",
+ path_str
+ );
+ assert!(
+ !cache_root.join(leaf_name).exists(),
+ "{} must NOT be created on disk by a non-create disp",
+ leaf_name
+ );
+ }
+ }
+
+ /// Phase C+11.1 — after the cold-boot NOT_FOUND probe (see
+ /// `cache_open_directory_on_missing_path_returns_not_found`),
+ /// Sylpheed re-issues `disp=FILE_OVERWRITE_IF (5)` with
+ /// `FILE_NON_DIRECTORY_FILE` set. That second call MUST produce
+ /// a regular file, not a directory. This pins the two-call
+ /// sequence canary actually executes on cold boot.
+ #[test]
+ fn cache_disp5_after_disp1_miss_creates_file() {
+ let (mut ctx, mem, mut state) = fresh();
+ let cache_root = state.cache_root.clone().unwrap();
+
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\access");
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+
+ // 1) Cold disp=1 + opts=0x7 → NOT_FOUND, no host-side entry.
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OPEN as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
+ assert!(!cache_root.join("access").exists());
+
+ // 2) disp=5 + opts=0x60 (FILE_NON_DIRECTORY_FILE |
+ // FILE_SYNCHRONOUS_IO_NONALERT) → FILE created.
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x60);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ assert!(
+ cache_root.join("access").is_file(),
+ "disp=5 with NON_DIRECTORY on cache:\\access must produce a host FILE"
+ );
+ }
+
+ /// Phase C+11 — write a `cache:\<H1><H2>.tmp` flat journal, then
+ /// rename it to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via
+ /// NtSetInformationFile class 10 (XFileRenameInformation). After the
+ /// rename, the flat file must be gone and the leaf must contain the
+ /// original bytes. This is the .tmp-to-leaf promotion that Sylpheed
+ /// relies on for cache build.
+ #[test]
+ fn cache_rename_information_promotes_tmp_to_leaf() {
+ let (mut ctx, mem, mut state) = fresh();
+
+ // Create cache:\foo.tmp with FILE_CREATE.
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_CREATE as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ let handle = mem.read_u32(handle_out);
+
+ // Write 4 bytes.
+ let write_buf = SCRATCH_BASE + 0x400;
+ for (i, b) in b"abcd".iter().enumerate() {
+ mem.write_u8(write_buf + i as u32, *b);
+ }
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0;
+ ctx.gpr[7] = iosb as u64;
+ ctx.gpr[8] = write_buf as u64;
+ ctx.gpr[9] = 4;
+ ctx.gpr[10] = 0;
+ nt_write_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+
+ // Confirm the flat .tmp exists.
+ let cache_root = state.cache_root.clone().expect("must have cache root");
+ assert!(cache_root.join("foo.tmp").exists(), ".tmp must exist pre-rename");
+ assert!(!cache_root.join("bar").exists(), "leaf must NOT exist yet");
+
+ // Build XFileRenameInformation buffer at SCRATCH_BASE+0x500:
+ // offset 0: be<u32> replace_existing = 1
+ // offset 4: be<u32> root_dir_handle = 0
+ // offset 8: ANSI_STRING { Length, MaxLength, BufferPtr }
+ // offset 16: path bytes
+ let info_buf = SCRATCH_BASE + 0x500;
+ let target = "cache:\\bar";
+ mem.write_u32(info_buf, 1); // replace_existing
+ mem.write_u32(info_buf + 4, 0); // root_dir_handle
+ mem.write_u16(info_buf + 8, target.len() as u16); // ANSI_STRING.Length
+ mem.write_u16(info_buf + 10, target.len() as u16); // ANSI_STRING.MaxLength
+ mem.write_u32(info_buf + 12, info_buf + 16); // ANSI_STRING.Buffer
+ for (i, b) in target.bytes().enumerate() {
+ mem.write_u8(info_buf + 16 + i as u32, b);
+ }
+
+ // NtSetInformationFile class 10 (rename).
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = iosb as u64;
+ ctx.gpr[5] = info_buf as u64;
+ ctx.gpr[6] = 16 + target.len() as u64; // info_length
+ ctx.gpr[7] = 10; // info_class = XFileRenameInformation
+ nt_set_information_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "rename must succeed");
+
+ // After rename: .tmp gone, leaf present with the original bytes.
+ assert!(!cache_root.join("foo.tmp").exists(), ".tmp must be gone");
+ assert!(cache_root.join("bar").exists(), "leaf must exist");
+ assert_eq!(
+ std::fs::read(cache_root.join("bar")).unwrap(),
+ b"abcd",
+ "leaf must have the original bytes"
+ );
+ }
+
+ /// Phase C+11 — rename also creates intermediate parent directories
+ /// (Sylpheed's leaf paths are `cache:\<H1>\<X>\<H2>` form; a
+ /// host-fs `rename` would fail without `create_dir_all` on parent).
+ #[test]
+ fn cache_rename_creates_parent_directories() {
+ let (mut ctx, mem, mut state) = fresh();
+
+ // Create cache:\src.tmp.
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\src.tmp");
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_CREATE as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ let handle = mem.read_u32(handle_out);
+
+ // Rename to cache:\d4ea4615\e\46ee8ca (depth-3 hierarchical leaf).
+ let info_buf = SCRATCH_BASE + 0x500;
+ let target = "cache:\\d4ea4615\\e\\46ee8ca";
+ mem.write_u32(info_buf, 1);
+ mem.write_u32(info_buf + 4, 0);
+ mem.write_u16(info_buf + 8, target.len() as u16);
+ mem.write_u16(info_buf + 10, target.len() as u16);
+ mem.write_u32(info_buf + 12, info_buf + 16);
+ for (i, b) in target.bytes().enumerate() {
+ mem.write_u8(info_buf + 16 + i as u32, b);
+ }
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = iosb as u64;
+ ctx.gpr[5] = info_buf as u64;
+ ctx.gpr[6] = 16 + target.len() as u64;
+ ctx.gpr[7] = 10;
+ nt_set_information_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+
+ let cache_root = state.cache_root.clone().unwrap();
+ assert!(cache_root.join("d4ea4615/e/46ee8ca").exists());
+ }
+
+ /// Phase C+11 — rename of a non-existent / closed handle returns
+ /// STATUS_INVALID_HANDLE (canary parity).
+ #[test]
+ fn cache_rename_invalid_handle_returns_status() {
+ let (mut ctx, mem, mut state) = fresh();
+ let info_buf = SCRATCH_BASE + 0x500;
+ let target = "cache:\\target";
+ mem.write_u32(info_buf, 1);
+ mem.write_u32(info_buf + 4, 0);
+ mem.write_u16(info_buf + 8, target.len() as u16);
+ mem.write_u16(info_buf + 10, target.len() as u16);
+ mem.write_u32(info_buf + 12, info_buf + 16);
+ for (i, b) in target.bytes().enumerate() {
+ mem.write_u8(info_buf + 16 + i as u32, b);
+ }
+ ctx.gpr[3] = 0xDEADBEEF; // bogus handle
+ ctx.gpr[4] = 0;
+ ctx.gpr[5] = info_buf as u64;
+ ctx.gpr[6] = 16 + target.len() as u64;
+ ctx.gpr[7] = 10;
+ nt_set_information_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
+ }
+
+ /// Phase C+12 — helper. Pins the wire-format of
+ /// `X_FILE_NETWORK_OPEN_INFORMATION` produced by
+ /// `nt_query_full_attributes_file`. Issues the query for `path` and
+ /// asserts the 8-DWord OUT struct fields (all big-endian).
+ fn assert_query_attrs_struct(
+ state: &mut KernelState,
+ mem: &GuestMemory,
+ path: &str,
+ expected_attrs: u32,
+ expected_size: u64,
+ ) -> u64 {
+ let mut ctx = PpcContext::default();
+ let obj_attrs = write_obj_attrs(mem, SCRATCH_BASE + 0x100, path);
+ let out = SCRATCH_BASE + 0x300;
+ for off in (0..56).step_by(4) {
+ mem.write_u32(out + off as u32, 0xCDCD_CDCD);
+ }
+ ctx.gpr[3] = obj_attrs as u64;
+ ctx.gpr[4] = out as u64;
+ nt_query_full_attributes_file(&mut ctx, mem, state);
+ let status = ctx.gpr[3];
+ if status == STATUS_SUCCESS {
+ assert_eq!(
+ mem.read_u32(out + 48),
+ expected_attrs,
+ "FileAttributes mismatch at {}",
+ path
+ );
+ assert_eq!(
+ mem.read_u64(out + 40),
+ expected_size,
+ "EndOfFile mismatch at {}",
+ path
+ );
+ assert_eq!(
+ mem.read_u32(out + 52),
+ 0,
+ "Reserved field must be zero at {}",
+ path
+ );
+ // AllocationSize == round_up(size, 512)
+ let expected_alloc = (expected_size + 511) & !511;
+ assert_eq!(
+ mem.read_u64(out + 32),
+ expected_alloc,
+ "AllocationSize mismatch at {}",
+ path
+ );
+ }
+ status
+ }
+
+ /// Phase C+12 — `nt_query_full_attributes_file` returns
+ /// `STATUS_NO_SUCH_FILE` for a path that's never been created.
+ /// Mirrors canary's `NtQueryFullAttributesFile_entry` returning
+ /// `X_STATUS_NO_SUCH_FILE` when `ResolvePath` returns null
+ /// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:512`).
+ #[test]
+ fn nt_query_full_attributes_file_missing_returns_no_such_file() {
+ let (_ctx, mem, mut state) = fresh();
+ let status =
+ assert_query_attrs_struct(&mut state, &mem, "cache:\\never_existed", 0, 0);
+ assert_eq!(status, STATUS_NO_SUCH_FILE);
+ }
+
+ /// Phase C+12 — after `NtCreateFile cache:\foo` succeeds (which
+ /// canary's `Entry::CreateEntry` populates the in-memory tree),
+ /// a follow-up `NtQueryFullAttributesFile` MUST resolve from the
+ /// in-memory mirror and return SUCCESS with
+ /// `FILE_ATTRIBUTE_NORMAL` (0x80) for a regular file.
+ #[test]
+ fn nt_query_full_attributes_file_after_create_returns_normal() {
+ let (mut ctx, mem, mut state) = fresh();
+ // Create cache:\foo with FILE_OVERWRITE_IF (creates if missing).
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo");
+ let handle_out = SCRATCH_BASE + 0x400;
+ let iosb = SCRATCH_BASE + 0x410;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ // Now query.
+ let status = assert_query_attrs_struct(
+ &mut state,
+ &mem,
+ "cache:\\foo",
+ crate::state::X_FILE_ATTRIBUTE_NORMAL,
+ 0,
+ );
+ assert_eq!(status, STATUS_SUCCESS);
+ }
+
+ /// Phase C+12 — mount-time scan picks up files that already exist
+ /// on disk under the cache root (canary's `HostPathDevice::
+ /// PopulateEntry` analogue). The probe MUST succeed even though
+ /// no `NtCreateFile` ran this boot — this is exactly the canary
+ /// behaviour ours was missing at idx 102404.
+ #[test]
+ fn nt_query_full_attributes_file_resolves_preexisting_host_entry() {
+ let mut state = KernelState::new();
+ let dir = std::env::temp_dir().join(format!(
+ "xenia-rs-cache-test-c12pre-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .subsec_nanos()
+ ));
+ std::fs::create_dir_all(dir.join("d4ea4615").join("e")).unwrap();
+ std::fs::write(dir.join("d4ea4615").join("e").join("46ee8ca"), b"oracle").unwrap();
+ // `set_cache_root` performs the eager scan.
+ state.set_cache_root(dir.clone());
+
+ // Wire up scratch + initial thread (mirrors `fresh()`).
+ let mut mem = GuestMemory::new().expect("memory init");
+ mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
+ .expect("scratch page must commit");
+ state.install_initial_thread(
+ PpcContext::default(),
+ 0x7000_0000,
+ 0x10_0000,
+ SCRATCH_BASE + 0x800,
+ SCRATCH_BASE + 0xC00,
+ 0x1000,
+ &mut mem,
+ );
+ state.scheduler.begin_slot_visit(0);
+
+ let status = assert_query_attrs_struct(
+ &mut state,
+ &mem,
+ "cache:\\d4ea4615\\e\\46ee8ca",
+ crate::state::X_FILE_ATTRIBUTE_NORMAL,
+ 6, // strlen("oracle")
+ );
+ assert_eq!(status, STATUS_SUCCESS);
+ // Directory probe must also resolve (mount-time scan inserts
+ // both files and dirs).
+ let status_dir = assert_query_attrs_struct(
+ &mut state,
+ &mem,
+ "cache:\\d4ea4615",
+ crate::state::X_FILE_ATTRIBUTE_DIRECTORY,
+ 0,
+ );
+ assert_eq!(status_dir, STATUS_SUCCESS);
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ /// Phase C+12 — pin the FILETIME conversion: a known Unix epoch
+ /// value (`1_700_000_000` seconds = 2023-11-14 22:13:20 UTC)
+ /// converts to the expected Windows FILETIME tick count.
+ #[test]
+ fn unix_to_filetime_known_value() {
+ let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
+ let ft = crate::state::unix_to_filetime(t);
+ // (1_700_000_000 + 11_644_473_600) * 10_000_000 = 133_444_736_000_000_000
+ assert_eq!(ft, 133_444_736_000_000_000);
+ }
+
+ /// Phase C+12 — `change_time` slot (offset 24) MUST equal
+ /// `last_write_time` (offset 16), mirroring canary's
+ /// `xboxkrnl_io.cc:504` line `file_info->change_time =
+ /// entry->write_timestamp();`. This is the only field where the
+ /// brief's "4 distinct FILETIMEs" framing differs from canary's
+ /// actual semantics.
+ #[test]
+ fn nt_query_full_attributes_file_change_time_equals_write_time() {
+ let (mut ctx, mem, mut state) = fresh();
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\writeme");
+ let handle_out = SCRATCH_BASE + 0x400;
+ let iosb = SCRATCH_BASE + 0x410;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+
+ let out = SCRATCH_BASE + 0x300;
+ ctx.gpr[3] = obj_attrs as u64;
+ ctx.gpr[4] = out as u64;
+ nt_query_full_attributes_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ let last_write = mem.read_u64(out + 16);
+ let change = mem.read_u64(out + 24);
+ assert_eq!(
+ change, last_write,
+ "change_time must equal last_write_time per canary xboxkrnl_io.cc:504"
+ );
+ }
+
+ /// Phase C+13 — `is_disc_prefix` recognises every alias canary maps
+ /// to the read-only disc partition: `game:\`, `d:\`/`D:\`, and the
+ /// raw NT device path `\Device\Cdrom0\`. Anything else (writable
+ /// partitions, raw paths) must return false so the synth-empty
+ /// fallback still fires.
+ #[test]
+ fn is_disc_prefix_recognises_disc_aliases() {
+ assert!(is_disc_prefix("game:\\dat\\files.tbl"));
+ assert!(is_disc_prefix("GAME:\\dat\\files.tbl"));
+ assert!(is_disc_prefix("d:\\default.xex"));
+ assert!(is_disc_prefix("D:\\default.xex"));
+ assert!(is_disc_prefix("\\Device\\Cdrom0\\dat\\files.tbl"));
+ assert!(is_disc_prefix("\\DEVICE\\CDROM0\\foo"));
+ // Non-disc prefixes must NOT count.
+ assert!(!is_disc_prefix("cache:\\d4ea4615\\e\\46ee8ca"));
+ assert!(!is_disc_prefix("\\Device\\Harddisk0\\Partition1\\x"));
+ assert!(!is_disc_prefix("\\??\\foo"));
+ assert!(!is_disc_prefix("\\Device\\Mass0\\foo"));
+ assert!(!is_disc_prefix("scripts/init.lua"));
+ assert!(!is_disc_prefix(""));
+ }
+
+ /// Phase C+13 — `NtCreateFile` on a disc-prefixed path that the VFS
+ /// can't resolve returns `STATUS_OBJECT_NAME_NOT_FOUND` (mirrors
+ /// canary `xboxkrnl_io.cc:83-110` which forwards the lookup
+ /// status verbatim, idx 103862 first divergence). Sylpheed
+ /// handles NOT_FOUND via `RtlNtStatusToDosError` then continues
+ /// its boot validator.
+ #[test]
+ fn nt_create_file_game_prefix_missing_returns_not_found() {
+ let (mut ctx, mem, mut state) = fresh();
+ // Install a stub VFS that doesn't resolve anything — mirrors a
+ // disc image that doesn't contain `dat/files.tbl`.
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
+ let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "game:\\dat\\files.tbl");
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OPEN as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND,
+ "missing disc file must return STATUS_OBJECT_NAME_NOT_FOUND"
+ );
+ assert_eq!(
+ mem.read_u32(handle_out),
+ 0,
+ "no handle returned on NOT_FOUND"
+ );
+ assert_eq!(
+ mem.read_u32(iosb),
+ STATUS_OBJECT_NAME_NOT_FOUND as u32,
+ "IOSB.status records NOT_FOUND"
+ );
+ }
+
+ /// Phase C+13 — same as above for the `\Device\Cdrom0\` NT-device
+ /// alias of the disc.
+ #[test]
+ fn nt_create_file_cdrom_prefix_missing_returns_not_found() {
+ let (mut ctx, mem, mut state) = fresh();
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
+ let obj_attrs = write_obj_attrs(
+ &mem,
+ SCRATCH_BASE + 0x100,
+ "\\Device\\Cdrom0\\dat\\files.tbl",
+ );
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OPEN as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
+ }
+
+ /// Phase C+13 — a non-disc prefix that misses the VFS still gets
+ /// the legacy zero-byte synth (preserves audit-006 / audit-018
+ /// behaviour for writable system-partition opens that ours
+ /// doesn't host-mount). `\Device\Harddisk0\Partition1\` is the
+ /// canonical writable mount.
+ #[test]
+ fn nt_create_file_non_disc_prefix_missing_still_synthesizes() {
+ let (mut ctx, mem, mut state) = fresh();
+ state.vfs = Some(Box::new(StubVfs { entries: vec![] }));
+ let obj_attrs = write_obj_attrs(
+ &mem,
+ SCRATCH_BASE + 0x100,
+ "\\Device\\Harddisk0\\Partition1\\sys.bin",
+ );
+ let handle_out = SCRATCH_BASE + 0x300;
+ let iosb = SCRATCH_BASE + 0x310;
+ ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
+ mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0);
+ ctx.gpr[3] = handle_out as u64;
+ ctx.gpr[5] = obj_attrs as u64;
+ ctx.gpr[6] = iosb as u64;
+ ctx.gpr[10] = FILE_OPEN as u64;
+ nt_create_file(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "non-disc missing path keeps synth-empty"
+ );
+ let handle = mem.read_u32(handle_out);
+ assert!(handle >= 0x1000, "synth handle must be allocated");
+ assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
+ }
+
+ /// `resolve_cache_path` rejects path-traversal attempts so a guest
+ /// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
+ #[test]
+ fn cache_resolve_strips_path_traversal() {
+ let dir = std::env::temp_dir().join(format!(
+ "xenia-rs-cache-test-trav-{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ let mut state = KernelState::new();
+ state.init_cache_root(dir.clone()).unwrap();
+ let resolved = state
+ .resolve_cache_path("cache:\\..\\..\\etc\\foo")
+ .expect("must resolve");
+ assert!(resolved.starts_with(&dir), "must stay inside cache root");
+ assert!(resolved.ends_with("etc/foo"));
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ // ===== Stage 2 Batch 2: Crypto handlers =====
+
+ #[test]
+ fn xe_crypt_sha_empty_input_writes_canonical_digest() {
+ let (mut ctx, mem, mut state) = fresh();
+ let input_ptr = SCRATCH_BASE;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ ctx.gpr[3] = input_ptr as u64;
+ ctx.gpr[4] = 0; // input_1_size = 0 (skips this buffer)
+ ctx.gpr[5] = 0;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 20;
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ // SHA-1 of empty input
+ let expected: [u8; 20] = [
+ 0xDA, 0x39, 0xA3, 0xEE, 0x5E, 0x6B, 0x4B, 0x0D, 0x32, 0x55, 0xBF, 0xEF, 0x95, 0x60,
+ 0x18, 0x90, 0xAF, 0xD8, 0x07, 0x09,
+ ];
+ assert_eq!(got, expected);
+ }
+
+ #[test]
+ fn xe_crypt_sha_three_inputs_concatenate() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf_a = SCRATCH_BASE;
+ let buf_b = SCRATCH_BASE + 0x10;
+ let buf_c = SCRATCH_BASE + 0x20;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ mem.write_bytes(buf_a, b"abc");
+ mem.write_bytes(buf_b, b"def");
+ mem.write_bytes(buf_c, b"ghi");
+ ctx.gpr[3] = buf_a as u64;
+ ctx.gpr[4] = 3;
+ ctx.gpr[5] = buf_b as u64;
+ ctx.gpr[6] = 3;
+ ctx.gpr[7] = buf_c as u64;
+ ctx.gpr[8] = 3;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 20;
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ // SHA-1("abcdefghi") = c63b19f1e4c8b5f76b25c49b8b87f57d8e4872a1
+ let expected: [u8; 20] = [
+ 0xC6, 0x3B, 0x19, 0xF1, 0xE4, 0xC8, 0xB5, 0xF7, 0x6B, 0x25, 0xC4, 0x9B, 0x8B, 0x87,
+ 0xF5, 0x7D, 0x8E, 0x48, 0x72, 0xA1,
+ ];
+ assert_eq!(got, expected);
+ }
+
+ #[test]
+ fn xe_crypt_sha_truncates_output() {
+ let (mut ctx, mem, mut state) = fresh();
+ let output_ptr = SCRATCH_BASE + 0x100;
+ // Pre-fill 0xFF so we can verify only 4 bytes were written.
+ mem.write_bytes(output_ptr, &[0xFFu8; 20]);
+ ctx.gpr[3] = 0;
+ ctx.gpr[4] = 0;
+ ctx.gpr[5] = 0;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = output_ptr as u64;
+ ctx.gpr[10] = 4; // truncate to 4 bytes
+ xe_crypt_sha(&mut ctx, &mem, &mut state);
+ // First 4 bytes match SHA-1 of empty; next 16 stay 0xFF.
+ let mut got = [0u8; 20];
+ mem.read_bytes(output_ptr, &mut got);
+ assert_eq!(&got[..4], &[0xDA, 0x39, 0xA3, 0xEE]);
+ assert_eq!(&got[4..], &[0xFFu8; 16]);
+ }
+
+ #[test]
+ fn xe_keys_console_private_key_sign_writes_certificate_and_returns_one() {
+ let (mut ctx, mem, mut state) = fresh();
+ let hash_ptr = SCRATCH_BASE;
+ let output_ptr = SCRATCH_BASE + 0x100;
+ ctx.gpr[3] = hash_ptr as u64;
+ ctx.gpr[4] = output_ptr as u64;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 1, "must return success");
+ // console_type at 0x18 (u32 BE) = Retail (2)
+ assert_eq!(mem.read_u32(output_ptr + 0x18), 2);
+ // manufacture_date at 0x1C
+ let mut mfg = [0u8; 8];
+ mem.read_bytes(output_ptr + 0x1C, &mut mfg);
+ assert_eq!(mfg, [2, 0, 0, 5, 1, 1, 2, 2]);
+ // XE_CONSOLE_ID byte 0 at offset 0x02
+ assert_eq!(mem.read_u8(output_ptr + 0x02), 0x93);
+ // cert_size and console_part_number must remain zero (Zero() output)
+ assert_eq!(mem.read_u16(output_ptr), 0);
+ assert_eq!(mem.read_u8(output_ptr + 0x07), 0);
+ }
+
+ // ===== Stage 2 Batch 6: ExGetXConfigSetting =====
+
+ #[test]
+ fn ex_get_xconfig_setting_user_language_returns_one() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ let req = SCRATCH_BASE + 0x208;
+ mem.write_u32(buf, 0xDEAD_BEEF);
+ mem.write_u16(req, 0xFFFF);
+ ctx.gpr[3] = 0x03; // USER_CATEGORY
+ ctx.gpr[4] = 0x09; // USER_LANGUAGE
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 4;
+ ctx.gpr[7] = req as u64;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS");
+ assert_eq!(mem.read_u32(buf), 1, "USER_LANGUAGE = en");
+ assert_eq!(mem.read_u16(req), 4, "required_size = 4 bytes");
+ }
+
+ #[test]
+ fn ex_get_xconfig_setting_unknown_returns_invalid_parameter() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ ctx.gpr[3] = 0xDEAD;
+ ctx.gpr[4] = 0xBEEF;
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 4;
+ ctx.gpr[7] = 0;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_00F0, "STATUS_INVALID_PARAMETER_2");
+ }
+
+ #[test]
+ fn ex_get_xconfig_setting_buffer_too_small_returns_error() {
+ let (mut ctx, mem, mut state) = fresh();
+ let buf = SCRATCH_BASE + 0x200;
+ mem.write_u32(buf, 0xDEAD_BEEF);
+ ctx.gpr[3] = 0x03; // USER_CATEGORY
+ ctx.gpr[4] = 0x09; // USER_LANGUAGE (4 bytes)
+ ctx.gpr[5] = buf as u64;
+ ctx.gpr[6] = 2; // too small
+ ctx.gpr[7] = 0;
+ ex_get_xconfig_setting(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0xC000_0023, "STATUS_BUFFER_TOO_SMALL");
+ // Buffer untouched
+ assert_eq!(mem.read_u32(buf), 0xDEAD_BEEF);
+ }
+
+ // ===== Stage 2 Batch 5: IRQL pair =====
+
+ /// Stage 2 Batch 5: `KeRaiseIrqlToDpcLevel` reads PCR's current_irql,
+ /// returns it in r3, and writes DISPATCH_LEVEL=2 back.
+ #[test]
+ fn ke_raise_irql_to_dpc_level_returns_old_writes_dispatch_level() {
+ let (mut ctx, mem, mut state) = fresh();
+ let pcr = SCRATCH_BASE + 0x500;
+ // Initial IRQL = PASSIVE_LEVEL (0).
+ mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0);
+ ctx.gpr[13] = pcr as u64;
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "old IRQL = PASSIVE_LEVEL");
+ assert_eq!(
+ mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET),
+ 2,
+ "PCR.current_irql = DISPATCH_LEVEL"
+ );
+ // Second Raise returns 2 (already at DPC).
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 2);
+ assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2);
+ }
+
+ /// Stage 2 Batch 5: Raise → Lower round-trip leaves PCR at the value
+ /// passed to Lower. Demonstrates the IRQL nesting invariant.
+ #[test]
+ fn ke_irql_raise_lower_round_trip() {
+ let (mut ctx, mem, mut state) = fresh();
+ let pcr = SCRATCH_BASE + 0x500;
+ mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0);
+ ctx.gpr[13] = pcr as u64;
+ ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state);
+ let prev = ctx.gpr[3] as u8;
+ assert_eq!(prev, 0);
+ assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2);
+ // Restore.
+ ctx.gpr[3] = prev as u64;
+ kf_lower_irql(&mut ctx, &mem, &mut state);
+ assert_eq!(
+ mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET),
+ 0,
+ "PCR.current_irql restored to PASSIVE_LEVEL"
+ );
+ }
+
+ #[test]
+ fn xe_keys_console_private_key_sign_rejects_null_inputs() {
+ let (mut ctx, mem, mut state) = fresh();
+ let output_ptr = SCRATCH_BASE + 0x100;
+ // null hash
+ ctx.gpr[3] = 0;
+ ctx.gpr[4] = output_ptr as u64;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "must return failure on null hash");
+ // null output
+ ctx.gpr[3] = 0x1234_5678;
+ ctx.gpr[4] = 0;
+ xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "must return failure on null output");
+ }
+
+ // ---------------------------------------------------------------
+ // Phase C+7 — KeSetEvent / NtSetEvent canary-parity return value
+ // ---------------------------------------------------------------
+
+ /// Canary parity: `KeSetEvent` on an unsignaled auto-reset event
+ /// must return constant `1` (NOT prior state). See investigation
+ /// for the `XEvent::Set` reference path.
+ #[test]
+ fn ke_set_event_returns_constant_one_on_unsignaled_auto_reset() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0x900;
+ write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // auto-reset, unsignaled
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], 1,
+ "KeSetEvent must return constant 1 on success (canary parity, xevent.cc:60-64)"
+ );
+ // Shadow must be signaled even though the return value is constant.
+ match state.objects.get(&kevent_ptr) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
+ _ => panic!("shadow not minted"),
+ }
+ }
+
+ /// Canary parity: `KeSetEvent` on an already-signaled manual-reset
+ /// event also returns constant `1` (not prior `1`). Same constant.
+ #[test]
+ fn ke_set_event_returns_constant_one_on_already_signaled_manual_reset() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0xA00;
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // manual-reset, signaled
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], 1,
+ "KeSetEvent returns 1 regardless of prior state (canary parity)"
+ );
+ match state.objects.get(&kevent_ptr) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
+ _ => panic!("shadow vanished"),
+ }
+ }
+
+ /// Canary parity: `NtSetEvent` with null `PreviousState` ptr returns
+ /// STATUS_SUCCESS and performs no out-pointer write.
+ #[test]
+ fn nt_set_event_null_prev_ptr_returns_status_success_no_write() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: false,
+ signaled: false,
+ waiters: Vec::new(),
+ });
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = 0; // null out-pointer
+ nt_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "NtSetEvent must return STATUS_SUCCESS"
+ );
+ // Event must be signaled.
+ match state.objects.get(&handle) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
+ _ => panic!("handle lookup broken"),
+ }
+ }
+
+ /// Canary parity: `NtSetEvent` with a valid out-pointer writes
+ /// **constant 1** (canary's `was_signalled = ev->Set()` always 1),
+ /// NOT the prior signaled state. See xboxkrnl_threading.cc:610-628.
+ #[test]
+ fn nt_set_event_valid_prev_ptr_writes_constant_one_and_returns_success() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: false,
+ signaled: false,
+ waiters: Vec::new(),
+ });
+ let prev_ptr = SCRATCH_BASE + 0xB00;
+ mem.write_u32(prev_ptr, 0xDEAD_BEEF); // sentinel — overwrite expected
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = prev_ptr as u64;
+ nt_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "NtSetEvent must return STATUS_SUCCESS"
+ );
+ assert_eq!(
+ mem.read_u32(prev_ptr),
+ 1,
+ "PreviousState out-ptr must receive constant 1 (canary parity)"
+ );
+ }
+
+ /// Canary parity: `NtSetEvent` on an already-signaled event still
+ /// writes constant `1` to the out-pointer (not the prior `1`,
+ /// though they happen to match here — distinguished from the
+ /// prior-state-write bug by the auto-reset/un-signaled case above).
+ #[test]
+ fn nt_set_event_on_signaled_event_writes_one() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: true,
+ signaled: true,
+ waiters: Vec::new(),
+ });
+ let prev_ptr = SCRATCH_BASE + 0xC00;
+ mem.write_u32(prev_ptr, 0);
+ ctx.gpr[3] = handle as u64;
+ ctx.gpr[4] = prev_ptr as u64;
+ nt_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(mem.read_u32(prev_ptr), 1);
+ // Event stays signaled (manual-reset).
+ match state.objects.get(&handle) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
+ _ => panic!("handle lookup broken"),
+ }
+ }
+
+ /// Wake-cascade regression: KeSetEvent on a manual-reset event with
+ /// a parked waiter still wakes the waiter post-fix. The return-value
+ /// change is observation-only — internal wake plumbing uses the
+ /// `previous` read, not the return value.
+ #[test]
+ fn ke_set_event_post_fix_still_wakes_waiter() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0xD00;
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled
+ // Mint the shadow first by calling reset_event (no waiter yet).
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
+ // Park a fake waiter.
+ match state.objects.get_mut(&kevent_ptr) {
+ Some(KernelObject::Event { waiters, .. }) => {
+ waiters.push(ThreadRef { hw_id: 4, idx: 0, generation: 0 });
+ }
+ _ => panic!("shadow not minted"),
+ }
+ // Signal.
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], 1, "constant 1 return preserved");
+ // Manual-reset: waiter list drained after wake.
+ match state.objects.get(&kevent_ptr) {
+ Some(KernelObject::Event { signaled, waiters, .. }) => {
+ assert!(*signaled, "manual-reset stays signaled");
+ assert!(waiters.is_empty(), "manual-reset wake drains all waiters");
+ }
+ _ => panic!("shadow vanished"),
+ }
+ }
+
+ // ---------------------------------------------------------------
+ // Phase C+8 — KeResetEvent canary-parity return value (sibling of C+7)
+ // ---------------------------------------------------------------
+
+ /// Canary parity: `KeResetEvent` on an unsignaled manual-reset event
+ /// must return constant `1` on shadow hit (NOT prior `0`). Canary's
+ /// `XEvent::Reset` hardcodes `return 1` regardless of prior state
+ /// (xevent.cc:72-75), exactly mirroring `XEvent::Set`. This is the
+ /// case that triggered the Phase A divergence at idx=102164: prior
+ /// state was unsignaled (`0`) and the prior-state-return bug gave
+ /// `0` while canary returns `1`.
+ #[test]
+ fn ke_reset_event_returns_constant_one_on_unsignaled_manual_reset() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0xE00;
+ write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], 1,
+ "KeResetEvent must return constant 1 on success (canary parity, xevent.cc:72-75)"
+ );
+ // Shadow stays unsignaled (was already 0, reset is idempotent).
+ match state.objects.get(&kevent_ptr) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(!*signaled),
+ _ => panic!("shadow not minted"),
+ }
+ }
+
+ /// Canary parity: `KeResetEvent` on a signaled auto-reset event also
+ /// returns constant `1`. Distinguished from the prior-state-return
+ /// bug by the unsignaled case above (where they would differ: bug=0
+ /// vs canary=1).
+ #[test]
+ fn ke_reset_event_returns_constant_one_on_signaled_auto_reset() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let kevent_ptr = SCRATCH_BASE + 0xF00;
+ write_dispatcher_header(&mut mem, kevent_ptr, 1, 1); // auto-reset, signaled
+ ctx.gpr[3] = kevent_ptr as u64;
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], 1,
+ "KeResetEvent returns 1 regardless of prior state (canary parity)"
+ );
+ match state.objects.get(&kevent_ptr) {
+ Some(KernelObject::Event { signaled, .. }) => {
+ assert!(!*signaled, "ke_reset_event must clear the shadow");
+ }
+ _ => panic!("shadow vanished"),
+ }
+ }
+
+ /// Canary parity: `KeResetEvent` on a non-existent shadow (and a
+ /// PKEVENT that doesn't match a dispatcher type the lazy-shadow can
+ /// mint) must return `0` — canary's `assert_always(); return 0` arm
+ /// for the no-XEvent-bound case (xboxkrnl_threading.cc:566-574).
+ /// We model this via a pointer below the dispatcher-shim threshold
+ /// (handle range, no kevent header pre-written).
+ #[test]
+ fn ke_reset_event_returns_zero_on_missing_object() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ // Use a low handle-range value with no allocated object — no
+ // shadow mint (handle path), no dispatcher header to lazy-mint
+ // from (ptr below 0x10000 means ensure_dispatcher_object skips).
+ ctx.gpr[3] = 0x4242; // arbitrary handle that doesn't exist
+ ke_reset_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], 0,
+ "KeResetEvent must return 0 when no event object is bound (canary's assert_always arm)"
+ );
+ }
+
+ /// `NtClearEvent` parity: returns `STATUS_SUCCESS` and resets the
+ /// shadow signaled flag. Unlike NtSetEvent, NtClearEvent has NO
+ /// PreviousState out-pointer (xboxkrnl_threading.cc:685-687 →
+ /// xeNtClearEvent calls XEvent::Clear which is void-returning).
+ /// Verified canary-parity; included for symmetry coverage.
+ #[test]
+ fn nt_clear_event_resets_shadow_and_returns_status_success() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: true,
+ signaled: true,
+ waiters: Vec::new(),
+ });
+ ctx.gpr[3] = handle as u64;
+ nt_clear_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(
+ ctx.gpr[3], STATUS_SUCCESS,
+ "NtClearEvent must return STATUS_SUCCESS on hit"
+ );
+ match state.objects.get(&handle) {
+ Some(KernelObject::Event { signaled, .. }) => {
+ assert!(!*signaled, "nt_clear_event must clear the shadow");
+ }
+ _ => panic!("handle lookup broken"),
+ }
+ }
+
+ /// Phase C+16: `ExCreateThread` must install a thread self-reference
+ /// (handle refcount = 2 post-spawn). Mirrors canary's
+ /// `XThread::Create::RetainHandle()` at xthread.cc:414. Without
+ /// this, a guest `NtClose` on the thread handle destroys it
+ /// prematurely while the spawned thread is still live — the
+ /// original C+16 divergence at Phase A idx=102168.
+ #[test]
+ fn ex_create_thread_installs_self_reference() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle_ptr = SCRATCH_BASE + 0x100;
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
+ ctx.gpr[3] = handle_ptr as u64;
+ ctx.gpr[4] = 0x10000; // stack_size
+ ctx.gpr[5] = thread_id_ptr as u64;
+ ctx.gpr[6] = 0; // xapi_startup
+ ctx.gpr[7] = 0x8200_1000; // start_address
+ ctx.gpr[8] = 0; // start_context
+ ctx.gpr[9] = 0; // creation_flags (not suspended, affinity = 0)
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "ExCreateThread must succeed");
+ let handle = mem.read_u32(handle_ptr);
+ assert_eq!(
+ state.handle_refcount.get(&handle).copied(),
+ Some(2),
+ "ExCreateThread must install self-ref (refcount = creator + self = 2)"
+ );
+ }
+
+ /// Phase C+16: `ExTerminateThread` releases the self-reference. The
+ /// thread terminates from inside its own context, so we spawn a
+ /// worker via `ex_create_thread`, switch to its slot, and then
+ /// terminate. Post-terminate: refcount = 1 (creator-only, handle
+ /// still alive). Mirrors canary's `XThread::Exit::ReleaseHandle()`
+ /// at xthread.cc:524.
+ #[test]
+ fn ex_terminate_thread_releases_self_reference() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle_ptr = SCRATCH_BASE + 0x100;
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
+ ctx.gpr[3] = handle_ptr as u64;
+ ctx.gpr[4] = 0x10000;
+ ctx.gpr[5] = thread_id_ptr as u64;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0x8200_1000;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = 0;
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
+ let handle = mem.read_u32(handle_ptr);
+ assert_eq!(state.handle_refcount.get(&handle).copied(), Some(2));
+
+ // Switch to the spawned thread's slot so `exit_current` sees it.
+ let r = state
+ .scheduler
+ .find_by_handle(handle)
+ .expect("spawned thread must be findable");
+ state.scheduler.current = Some(r);
+
+ let mut term_ctx = PpcContext::default();
+ term_ctx.gpr[3] = 0; // exit_code
+ ex_terminate_thread(&mut term_ctx, &mem, &mut state);
+
+ // self-ref dropped → refcount = 1 (creator still holds).
+ assert_eq!(
+ state.handle_refcount.get(&handle).copied(),
+ Some(1),
+ "ex_terminate_thread must release the self-ref"
+ );
+ assert!(
+ state.objects.contains_key(&handle),
+ "object must survive (creator-ref still held)"
+ );
+ }
+
+ /// Phase C+16: end-to-end refcount lifecycle balance. Spawn →
+ /// user closes → thread exits → object destroyed. No leak.
+ #[test]
+ fn ex_create_then_close_then_exit_balances_refcount() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let handle_ptr = SCRATCH_BASE + 0x100;
+ let thread_id_ptr = SCRATCH_BASE + 0x108;
+ ctx.gpr[3] = handle_ptr as u64;
+ ctx.gpr[4] = 0x10000;
+ ctx.gpr[5] = thread_id_ptr as u64;
+ ctx.gpr[6] = 0;
+ ctx.gpr[7] = 0x8200_1000;
+ ctx.gpr[8] = 0;
+ ctx.gpr[9] = 0;
+ ex_create_thread(&mut ctx, &mut mem, &mut state);
+ let handle = mem.read_u32(handle_ptr);
+
+ // User NtClose: refcount 2 → 1, object survives.
+ let mut close_ctx = PpcContext::default();
+ close_ctx.gpr[3] = handle as u64;
+ nt_close(&mut close_ctx, &mem, &mut state);
+ assert!(state.objects.contains_key(&handle));
+ assert_eq!(state.handle_refcount.get(&handle).copied(), Some(1));
+
+ // Thread exits: refcount 1 → 0, object destroyed.
+ let r = state
+ .scheduler
+ .find_by_handle(handle)
+ .expect("must still be findable");
+ state.scheduler.current = Some(r);
+ let mut term_ctx = PpcContext::default();
+ term_ctx.gpr[3] = 0;
+ ex_terminate_thread(&mut term_ctx, &mem, &mut state);
+
+ assert!(
+ !state.objects.contains_key(&handle),
+ "object must be destroyed at zero refcount"
+ );
+ assert!(
+ !state.handle_refcount.contains_key(&handle),
+ "refcount entry must be scrubbed"
+ );
+ }
+
+ // ===== Phase C+19: NtDuplicateObject fresh-slot semantics =====
+
+ /// Helper: create an Event and duplicate it; return (source, dup, state).
+ fn create_event_and_dup(
+ mem: &GuestMemory,
+ state: &mut KernelState,
+ ) -> (u32, u32) {
+ let source = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: false,
+ signaled: false,
+ waiters: Vec::new(),
+ });
+ let mut ctx = PpcContext::default();
+ ctx.gpr[3] = source as u64;
+ let out_ptr = SCRATCH_BASE + 0x100;
+ mem.write_u32(out_ptr, 0xDEAD_BEEF);
+ ctx.gpr[4] = out_ptr as u64;
+ ctx.gpr[5] = 0; // no DUPLICATE_CLOSE_SOURCE
+ nt_duplicate_object(&mut ctx, mem, state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ let dup = mem.read_u32(out_ptr);
+ (source, dup)
+ }
+
+ /// Phase C+19: dup id is a *fresh* slot, NOT aliased to source. Mirrors
+ /// canary's `ObjectTable::DuplicateHandle` → `AddHandle` (object_table.cc:210).
+ #[test]
+ fn nt_duplicate_object_allocates_fresh_handle_id() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+ assert_ne!(dup, source, "dup id must be distinct from source");
+ assert_ne!(dup, 0, "dup id must be non-zero");
+ }
+
+ /// AUDIT-062 INVARIANT (signal-on-dup wakes wait-on-source): the dup
+ /// alias canonicalizes back to the source `state.objects` entry, so
+ /// signaling the dup mutates the same `KernelObject::Event` that the
+ /// source slot points at. This is THE load-bearing test — if it fails
+ /// the C+19 fix has broken the AUDIT-062 worker-cluster wedge.
+ #[test]
+ fn nt_duplicate_object_signal_on_dup_wakes_wait_on_source() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ // Signal via dup.
+ ctx.gpr[3] = dup as u64;
+ ctx.gpr[4] = 0;
+ nt_set_event(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+
+ // Source's event entry must show signaled=true (shared underlying).
+ match state.objects.get(&source) {
+ Some(KernelObject::Event { signaled, .. }) => {
+ assert!(*signaled, "source event must be signaled by dup signal");
+ }
+ _ => panic!("source lookup must hit the canonical Event"),
+ }
+ }
+
+ /// Symmetric: signal-on-source wakes wait-on-dup. Both lookup paths
+ /// canonicalize to the same entry.
+ #[test]
+ fn nt_duplicate_object_signal_on_source_visible_via_dup() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ ctx.gpr[3] = source as u64;
+ ctx.gpr[4] = 0;
+ nt_set_event(&mut ctx, &mut mem, &mut state);
+
+ // Resolve dup → source and check signaled.
+ let canonical = state.resolve_handle(dup);
+ assert_eq!(canonical, source);
+ match state.objects.get(&canonical) {
+ Some(KernelObject::Event { signaled, .. }) => {
+ assert!(*signaled);
+ }
+ _ => panic!(),
+ }
+ }
+
+ /// Refcount: both source and dup slots independently get
+ /// `handle_refcount = 1`. The canonical's `canonical_slot_count` rises
+ /// to 2 (one per slot). Mirrors canary AddHandle (one Retain per slot).
+ #[test]
+ fn nt_duplicate_object_refcount_lifecycle() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ assert_eq!(state.handle_refcount.get(&source).copied(), Some(1));
+ assert_eq!(state.handle_refcount.get(&dup).copied(), Some(1));
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(2));
+ assert_eq!(state.handle_aliases.get(&dup).copied(), Some(source));
+ }
+
+ /// Close the dup first: dup slot is gone, source slot remains, underlying
+ /// object remains. Symmetric to canary's per-slot `RemoveHandle` (the
+ /// underlying XObject survives until the last slot is gone).
+ #[test]
+ fn nt_duplicate_object_then_close_dup_keeps_source_live() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ let mut close_ctx = PpcContext::default();
+ close_ctx.gpr[3] = dup as u64;
+ nt_close(&mut close_ctx, &mem, &mut state);
+
+ assert!(!state.handle_refcount.contains_key(&dup));
+ assert!(!state.handle_aliases.contains_key(&dup));
+ assert!(state.objects.contains_key(&source));
+ assert_eq!(state.handle_refcount.get(&source).copied(), Some(1));
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
+ }
+
+ /// Close source first: source slot is gone, dup slot remains, and
+ /// crucially the underlying object remains so the dup can still be
+ /// used. Sister of the above.
+ #[test]
+ fn nt_duplicate_object_then_close_source_keeps_dup_live() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ let mut close_ctx = PpcContext::default();
+ close_ctx.gpr[3] = source as u64;
+ nt_close(&mut close_ctx, &mem, &mut state);
+
+ assert!(!state.handle_refcount.contains_key(&source));
+ // Underlying object survives (canonical entry alive through dup slot).
+ assert!(state.objects.contains_key(&source));
+ // Dup still points at it.
+ assert_eq!(state.resolve_handle(dup), source);
+ // Slot count down to 1 (just the dup).
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
+
+ // Signal through dup still works.
+ let mut set_ctx = PpcContext::default();
+ let mut mem = mem;
+ set_ctx.gpr[3] = dup as u64;
+ set_ctx.gpr[4] = 0;
+ nt_set_event(&mut set_ctx, &mut mem, &mut state);
+ match state.objects.get(&source) {
+ Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
+ _ => panic!(),
+ }
+ }
+
+ /// Final close on the last surviving slot drops the canonical object.
+ #[test]
+ fn nt_duplicate_object_close_both_destroys_underlying() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup) = create_event_and_dup(&mem, &mut state);
+
+ let mut close_dup = PpcContext::default();
+ close_dup.gpr[3] = dup as u64;
+ nt_close(&mut close_dup, &mem, &mut state);
+
+ let mut close_src = PpcContext::default();
+ close_src.gpr[3] = source as u64;
+ nt_close(&mut close_src, &mem, &mut state);
+
+ assert!(!state.objects.contains_key(&source));
+ assert!(!state.handle_refcount.contains_key(&source));
+ assert!(!state.canonical_slot_count.contains_key(&source));
+ }
+
+ /// DUPLICATE_CLOSE_SOURCE: dup happens AND source is closed atomically.
+ /// Net result: dup is live, source is gone.
+ #[test]
+ fn nt_duplicate_object_with_close_source_flag() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let source = state.alloc_handle_for(KernelObject::Event {
+ manual_reset: false,
+ signaled: false,
+ waiters: Vec::new(),
+ });
+
+ let out_ptr = SCRATCH_BASE + 0x200;
+ mem.write_u32(out_ptr, 0);
+ ctx.gpr[3] = source as u64;
+ ctx.gpr[4] = out_ptr as u64;
+ ctx.gpr[5] = 0x1; // DUPLICATE_CLOSE_SOURCE
+ nt_duplicate_object(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+
+ let dup = mem.read_u32(out_ptr);
+ assert_ne!(dup, source);
+
+ // Source slot scrubbed.
+ assert!(!state.handle_refcount.contains_key(&source));
+ // But the canonical object is still alive through dup.
+ assert!(state.objects.contains_key(&source));
+ // Slot count is exactly 1 (the dup).
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1));
+ // Dup alias points at canonical.
+ assert_eq!(state.resolve_handle(dup), source);
+ }
+
+ /// Invalid source handle: STATUS_INVALID_HANDLE + zero write to out_ptr.
+ #[test]
+ fn nt_duplicate_object_invalid_handle_returns_invalid_handle() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ let out_ptr = SCRATCH_BASE + 0x300;
+ mem.write_u32(out_ptr, 0xCAFE_BABE);
+ ctx.gpr[3] = 0x9999 as u64; // bogus
+ ctx.gpr[4] = out_ptr as u64;
+ ctx.gpr[5] = 0;
+ nt_duplicate_object(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
+ assert_eq!(mem.read_u32(out_ptr), 0);
+ }
+
+ /// Double-dup: dup of a dup canonicalizes to the original source.
+ /// Mirrors canary's `LookupObject(TranslateHandle(handle), false)` which
+ /// resolves through nested dups by hitting the same `XObject*`.
+ #[test]
+ fn nt_duplicate_object_dup_of_dup_canonicalizes() {
+ let (_ctx, mem, mut state) = fresh();
+ let (source, dup1) = create_event_and_dup(&mem, &mut state);
+
+ // Now dup the dup.
+ let mut ctx = PpcContext::default();
+ ctx.gpr[3] = dup1 as u64;
+ let out_ptr = SCRATCH_BASE + 0x400;
+ mem.write_u32(out_ptr, 0);
+ ctx.gpr[4] = out_ptr as u64;
+ ctx.gpr[5] = 0;
+ nt_duplicate_object(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
+ let dup2 = mem.read_u32(out_ptr);
+
+ assert_ne!(dup2, source);
+ assert_ne!(dup2, dup1);
+ // All three resolve to the same canonical source.
+ assert_eq!(state.resolve_handle(dup1), source);
+ assert_eq!(state.resolve_handle(dup2), source);
+ // Slot count reflects 3 live slots.
+ assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(3));
+ }
+
+ /// Aliased dup with non-Event kernel objects also works. Mirrors
+ /// canary's `XObject::Type` codes (Event/Mutant/Semaphore/...).
+ #[test]
+ fn nt_duplicate_object_works_for_semaphore() {
+ let (_ctx, mem, mut state) = fresh();
+ let source = state.alloc_handle_for(KernelObject::Semaphore {
+ count: 3,
+ max: 10,
+ waiters: Vec::new(),
+ });
+ let mut ctx = PpcContext::default();
+ ctx.gpr[3] = source as u64;
+ let out_ptr = SCRATCH_BASE + 0x600;
+ mem.write_u32(out_ptr, 0);
+ ctx.gpr[4] = out_ptr as u64;
+ ctx.gpr[5] = 0;
+ nt_duplicate_object(&mut ctx, &mem, &mut state);
+
+ let dup = mem.read_u32(out_ptr);
+ assert_ne!(dup, source);
+ assert_eq!(state.resolve_handle(dup), source);
+ // Underlying count unchanged.
+ match state.objects.get(&source) {
+ Some(KernelObject::Semaphore { count, max, .. }) => {
+ assert_eq!(*count, 3);
+ assert_eq!(*max, 10);
+ }
+ _ => panic!(),
+ }
+ }
+
+ /// Phase W: ensure `VdInitializeEngines` writes `r3=1` (canary's
+ /// literal return value, not `STATUS_SUCCESS=0`). Anchored on the
+ /// helper directly so the registration is exercised end-to-end via
+ /// a separate code-path check (no need to actually issue the import
+ /// call). The `// canary returns 1` invariant is the entirety of
+ /// the fix.
+ #[test]
+ fn vd_initialize_engines_returns_one() {
+ let (mut ctx, mem, mut state) = fresh();
+ ctx.gpr[3] = 0xDEAD_BEEF; // sentinel — must be overwritten
+ stub_return_one(&mut ctx, &mem, &mut state);
+ assert_eq!(ctx.gpr[3], 1, "stub_return_one must put 1 in r3");
}
}
diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs
index c20fe94..cb09261 100644
--- a/crates/xenia-kernel/src/xaudio.rs
+++ b/crates/xenia-kernel/src/xaudio.rs
@@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333);
/// queueing unbounded callbacks while injection is starved.
pub const XAUDIO_QUEUE_CAP: usize = 16;
+/// Phase HostAudioEager (2026-05-19): initial seeded fire count at
+/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary
+/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210)
+/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment
+/// canary's `RegisterClient` returns, its already-running host worker
+/// thread has 8 buffer-complete fires queued to drain.
+///
+/// In ours, the dedicated guest audio worker (spawned at the same
+/// register call) can't be HOST-threaded; instead we seed the pending
+/// FIFO so the round prologue's `try_inject_audio_callback` injects
+/// the first callback on the very next round — well before tid=1
+/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15
+/// in canary, tid=9/10 in ours). This fixes the ordering issue where
+/// the 48k-instruction ticker delay let tid=9/10 spawn and enter
+/// their spin loop on the uninitialized voice struct before the
+/// callback could modify it.
+pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8;
+
#[derive(Debug, Clone, Copy)]
pub struct XAudioClient {
pub callback_pc: u32,
@@ -155,6 +173,28 @@ impl XAudioState {
}
}
+ /// Phase HostAudioEager: enqueue `n` buffer-complete fires for a
+ /// specific client slot. Used by `XAudioRegisterRenderDriverClient`
+ /// to mirror canary's `client_semaphore->Release(queued_frames_)`
+ /// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid
+ /// unbounded growth if the caller seeds aggressively. Returns the
+ /// actual number of fires enqueued.
+ pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize {
+ if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() {
+ return 0;
+ }
+ let mut queued = 0;
+ for _ in 0..n {
+ if self.pending.len() >= XAUDIO_QUEUE_CAP {
+ self.dropped += 1;
+ break;
+ }
+ self.pending.push_back(index);
+ queued += 1;
+ }
+ queued
+ }
+
pub fn peek_next(&self) -> Option<usize> {
self.pending.front().copied()
}
@@ -320,6 +360,51 @@ mod tests {
assert!(s.last_instant.is_some());
}
+ #[test]
+ fn seed_fires_for_registered_slot_enqueues_n() {
+ let mut s = XAudioState::default();
+ let i = s.register(dummy_client(1)).unwrap();
+ let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES);
+ assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES);
+ assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES);
+ // All enqueued fires reference our slot.
+ for _ in 0..XAUDIO_REGISTER_SEED_FIRES {
+ assert_eq!(s.take_next(), Some(i));
+ }
+ assert!(s.pending.is_empty());
+ }
+
+ #[test]
+ fn seed_fires_for_unregistered_slot_is_noop() {
+ let mut s = XAudioState::default();
+ // Slot 3 is empty.
+ let queued = s.seed_fires_for(3, 8);
+ assert_eq!(queued, 0);
+ assert!(s.pending.is_empty());
+ assert_eq!(s.dropped, 0);
+ }
+
+ #[test]
+ fn seed_fires_for_caps_at_queue_cap_and_counts_drops() {
+ let mut s = XAudioState::default();
+ let i = s.register(dummy_client(1)).unwrap();
+ let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4);
+ assert_eq!(queued, XAUDIO_QUEUE_CAP);
+ assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP);
+ // Excess fires are counted as dropped (per
+ // existing `enqueue_all_active` discipline).
+ assert!(s.dropped >= 1);
+ }
+
+ #[test]
+ fn seed_fires_for_out_of_range_index_is_noop() {
+ let mut s = XAudioState::default();
+ s.register(dummy_client(1)).unwrap();
+ let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4);
+ assert_eq!(queued, 0);
+ assert!(s.pending.is_empty());
+ }
+
#[test]
fn tick_wallclock_fires_after_period() {
let mut s = XAudioState::default();