Files
xenia-rs/audit-runs/phase-c12-NtQueryFullAttributesFile/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

3499 lines
161 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..e956473 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);
@@ -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 =====
@@ -375,38 +426,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 +517,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 +574,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 +714,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 +946,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 +981,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 +1048,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 +1147,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 +1159,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 +1242,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 +1261,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);
}
@@ -1004,6 +1335,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 +1379,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);
}
@@ -1085,6 +1418,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 +1456,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,
@@ -1320,6 +1664,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 +1686,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 +1702,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);
}
@@ -1517,6 +1876,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,12 +2000,12 @@ 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.
@@ -1562,6 +2038,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 +2125,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 +2180,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 +2224,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(_) => {
@@ -1936,6 +2472,10 @@ fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
if remaining == 0 {
state.objects.remove(&handle);
state.handle_refcount.remove(&handle);
+ // 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(&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.
@@ -2382,10 +2922,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 +3019,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
};
}
@@ -3266,6 +3881,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`
@@ -3717,48 +4404,67 @@ 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;
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;
}
@@ -4344,6 +5050,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 +5151,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 +5173,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,40 +5472,128 @@ mod tests {
assert!(event_signaled(&state, evt), "write must signal too");
}
- /// Verify `FileStandardInformation` reports `Directory=1` for empty-path
- /// (device-root) synthesized file handles. Sylpheed calls
- /// `NtCreateFile("game:\\")` then `NtQueryInformationFile` on the returned
- /// handle as a disc-validation probe — seeing `Directory=0` triggers its
- /// `XamShowDirtyDiscErrorUI` path.
+ /// 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_query_information_file_reports_directory_for_root_synth() {
+ fn nt_write_file_async_handle_returns_status_pending() {
let (mut ctx, mut mem, mut state) = fresh();
- // Synth a "game:\" style empty-path file, matching what `open_vfs_file`
- // produces when the prefix-strip leaves nothing behind.
- let h = state.alloc_handle_for(KernelObject::File {
- path: String::new(),
+ // 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,
});
- let info_buf = SCRATCH_BASE + 0x600;
- ctx.gpr[3] = h as u64; // handle
- ctx.gpr[4] = SCRATCH_BASE as u64; // iosb
- ctx.gpr[5] = info_buf as u64; // file_info
- ctx.gpr[6] = 24; // length
- ctx.gpr[7] = 5; // FileStandardInformation
- nt_query_information_file(&mut ctx, &mut mem, &mut state);
- assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected");
+ 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!(
- mem.read_u8(info_buf + 21),
- 1,
- "Directory byte must be 1 for root-of-device synth"
+ ctx.gpr[3], STATUS_PENDING,
+ "async-opened file: r3 must return STATUS_PENDING (0x103)"
);
- }
-
- /// `NtQueryDirectoryFile` takes an optional completion event at r4
+ 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
+ /// handle as a disc-validation probe — seeing `Directory=0` triggers its
+ /// `XamShowDirtyDiscErrorUI` path.
+ #[test]
+ fn nt_query_information_file_reports_directory_for_root_synth() {
+ let (mut ctx, mut mem, mut state) = fresh();
+ // Synth a "game:\" style empty-path file, matching what `open_vfs_file`
+ // produces when the prefix-strip leaves nothing behind.
+ let h = state.alloc_handle_for(KernelObject::File {
+ path: String::new(),
+ size: 0,
+ position: 0,
+ data: std::sync::Arc::new(Vec::new()),
+ dir_enum_pos: None,
+ host_path: None,
+ });
+ let info_buf = SCRATCH_BASE + 0x600;
+ ctx.gpr[3] = h as u64; // handle
+ ctx.gpr[4] = SCRATCH_BASE as u64; // iosb
+ ctx.gpr[5] = info_buf as u64; // file_info
+ ctx.gpr[6] = 24; // length
+ ctx.gpr[7] = 5; // FileStandardInformation
+ nt_query_information_file(&mut ctx, &mut mem, &mut state);
+ assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected");
+ assert_eq!(
+ mem.read_u8(info_buf + 21),
+ 1,
+ "Directory byte must be 1 for root-of-device synth"
+ );
+ }
+
+ /// `NtQueryDirectoryFile` takes an optional completion event at r4
/// (Canary `xboxkrnl_io.cc:516`). The handler must signal that event
/// so waiters wake up, and must write the IOSB at r7 (the prior stub
/// mis-used r4, clobbering low guest memory). Without a VFS mounted
@@ -5023,8 +5923,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");
@@ -6215,6 +7120,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,6 +7248,543 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
+ /// 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_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"
+ );
+ }
+
/// `resolve_cache_path` rejects path-traversal attempts so a guest
/// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
#[test]
@@ -6353,4 +7803,466 @@ mod tests {
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"),
+ }
+ }
}
diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs
index b256fe7..0d8fcdd 100644
--- a/crates/xenia-kernel/src/state.rs
+++ b/crates/xenia-kernel/src/state.rs
@@ -47,9 +47,138 @@ pub enum ModuleId {
pub const HMODULE_XBOXKRNL: u32 = 0xFFFE_0001;
pub const HMODULE_XAM: u32 = 0xFFFE_0002;
+/// Phase C+12 — mirrors a single `xe::vfs::Entry` for the `cache:` mount.
+/// Stored in [`KernelState::cache_entries`] keyed by the normalized guest
+/// path (forward-slashed; see `crate::path::normalize_path`).
+///
+/// Field semantics match canary's `xe::vfs::Entry`
+/// (`xenia-canary/src/xenia/vfs/entry.h:67-95`):
+///
+/// * `is_directory` — true for directories (Xbox attribute 0x10),
+/// false for regular files (Xbox attribute 0x80).
+/// * `size` — `entry->size()` (bytes; 0 for directories).
+/// * `allocation_size`— `entry->allocation_size()` =
+/// `round_up(size, bytes_per_sector)`. Canary's
+/// `HostPathEntry::Create` uses
+/// `device->bytes_per_sector()` which defaults to
+/// 512 (`Device::bytes_per_sector_` ctor default;
+/// cache: is a writable host-path device, no
+/// override). We match that.
+/// * `create_time` / `access_time` / `write_time` — Windows FILETIME
+/// (100ns ticks since 1601-01-01 UTC). Populated
+/// from `xe::filesystem::FileInfo::{create,
+/// access, write}_timestamp` on canary
+/// (`filesystem_win.cc:226-228`); on our Linux
+/// host we derive the equivalent FILETIME from
+/// `std::fs::Metadata::{created, accessed,
+/// modified}` via [`unix_to_filetime`]. `change_
+/// time` (the fourth FILETIME canary writes via
+/// `entry->write_timestamp()`,
+/// `xboxkrnl_io.cc:504`) reuses `write_time`.
+#[derive(Debug, Clone)]
+pub struct CacheEntryMeta {
+ pub is_directory: bool,
+ pub size: u64,
+ pub allocation_size: u64,
+ pub create_time: u64,
+ pub access_time: u64,
+ pub write_time: u64,
+}
+
+/// Phase C+12 — convert a [`std::time::SystemTime`] to a Windows FILETIME
+/// value (100-ns ticks since 1601-01-01 UTC). Matches what canary's
+/// Windows build emits via `COMBINE_TIME(ftCreationTime)` in
+/// `xenia-canary/src/xenia/base/filesystem_win.cc:226`.
+///
+/// Conversion: Unix epoch = 1970-01-01 UTC. The Windows epoch is
+/// 1601-01-01 UTC, which is `11_644_473_600` seconds earlier.
+///
+/// Pre-1970 inputs (rare on Linux, but `created()` can return them on
+/// filesystems that lack a creation-time stamp) are clamped to 0,
+/// which canary itself emits when the win32 `FILETIME` is zero — safer
+/// than wrapping arithmetic.
+pub fn unix_to_filetime(t: std::time::SystemTime) -> u64 {
+ const UNIX_TO_WINDOWS_EPOCH_SECS: u64 = 11_644_473_600;
+ match t.duration_since(std::time::UNIX_EPOCH) {
+ Ok(d) => {
+ let secs = d.as_secs();
+ let nanos = d.subsec_nanos() as u64;
+ secs.saturating_add(UNIX_TO_WINDOWS_EPOCH_SECS)
+ .saturating_mul(10_000_000)
+ .saturating_add(nanos / 100)
+ }
+ Err(_) => 0,
+ }
+}
+
+/// Phase C+12 — build a [`CacheEntryMeta`] from a host-FS metadata
+/// snapshot. Mirrors `HostPathEntry::Create`
+/// (`xenia-canary/src/xenia/vfs/devices/host_path_entry.cc:32-54`):
+/// directory → attribute 0x10, size 0; file → attribute 0x80, size
+/// from metadata, `allocation_size` rounded up to a 512-byte sector.
+/// The `cache:` device is read-write so we never set the READONLY bit.
+pub fn cache_entry_from_metadata(md: &std::fs::Metadata) -> CacheEntryMeta {
+ let is_directory = md.is_dir();
+ let size = if is_directory { 0 } else { md.len() };
+ let allocation_size = if is_directory {
+ 0
+ } else {
+ // bytes_per_sector = 512 default (canary `Device::Device`).
+ (size + 511) & !511
+ };
+ let create_time = md
+ .created()
+ .map(unix_to_filetime)
+ .unwrap_or_else(|_| md.modified().map(unix_to_filetime).unwrap_or(0));
+ let access_time = md.accessed().map(unix_to_filetime).unwrap_or(0);
+ let write_time = md.modified().map(unix_to_filetime).unwrap_or(0);
+ CacheEntryMeta {
+ is_directory,
+ size,
+ allocation_size,
+ create_time,
+ access_time,
+ write_time,
+ }
+}
+
+/// Phase C+12 — `FILE_ATTRIBUTE_*` constants (NT semantics, Xbox 360
+/// uses the same bitmask as Windows for `X_FILE_NETWORK_OPEN_
+/// INFORMATION::attributes`). Source:
+/// `xenia-canary/src/xenia/vfs/entry.h:67-73`.
+pub const X_FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0010;
+pub const X_FILE_ATTRIBUTE_NORMAL: u32 = 0x0080;
+
/// Central kernel state tracking all guest OS state.
pub struct KernelState {
exports: HashMap<(ModuleId, u32), (&'static str, KernelExportFn)>,
+ /// Phase A: kernel exports whose canary signature is `void` (no
+ /// dword_result_t / pointer_result_t). For symmetry with canary's
+ /// `if constexpr (std::is_void<R>::value)` trampoline branch
+ /// (see `xenia-canary/src/xenia/kernel/util/shim_utils.h`), the
+ /// Phase A `kernel.return` event for these exports emits
+ /// `return_value=0` instead of `gpr[3]` (which for void fns is
+ /// just the input arg pointer left untouched). Without this,
+ /// e.g. `KeQuerySystemTime` — declared `void` in canary, taking a
+ /// `lpqword_t time_ptr` — would report ours's r3=time_ptr but
+ /// canary's literal 0, producing a spurious diff. Cvar-OFF inert.
+ void_exports: std::collections::HashSet<(ModuleId, u32)>,
+ /// Phase C+6: kernel exports that have a table-entry in canary's
+ /// `xboxkrnl_table.inc` but NO `DECLARE_XBOXKRNL_EXPORT` / shim
+ /// implementation. Canary wires such imports to the syscall thunk
+ /// (`sc 2; blr`) which does NOT call any `Trampoline` and therefore
+ /// emits NO Phase A events (see `xenia-canary/src/xenia/cpu/
+ /// xex_module.cc:1316-1335` and `ppc_frontend.cc:83-92`). For ours
+ /// to match canary's event stream, we must skip
+ /// `import.call`/`kernel.call`/`kernel.return` emission for these
+ /// exports even though we still execute their stub body (typically
+ /// `stub_success` setting `r3=0`). Without this, every guest call
+ /// to e.g. `IoDismountVolumeByFileHandle` injects 3 spurious events
+ /// into ours's Phase A stream while canary's stays silent — causing
+ /// per-call alignment drift downstream. Cvar-OFF inert (this flag
+ /// is consumed only inside the Phase A `phase_a_on` guard in
+ /// `call_export`).
+ unimplemented_exports: std::collections::HashSet<(ModuleId, u32)>,
/// M2.4: bump allocator for kernel handles. `AtomicU32` so concurrent
/// HLE calls under M3 can `fetch_add` without a lock. `Relaxed` is
/// fine — the allocated value is a fresh ID with no prior payload to
@@ -70,6 +199,16 @@ pub struct KernelState {
pub cs_waiters: HashMap<u32, Vec<ThreadRef>>,
/// Kernel object table: handle → object
pub objects: HashMap<u32, KernelObject>,
+ /// Phase C+5 — set of file handles opened WITHOUT
+ /// `FILE_SYNCHRONOUS_IO_ALERT` (0x10) or `FILE_SYNCHRONOUS_IO_NONALERT`
+ /// (0x20). Canary's `NtWriteFile_entry`
+ /// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353)
+ /// completes such writes synchronously but returns `STATUS_PENDING`
+ /// (0x103) instead of `STATUS_SUCCESS`. Mirrors `xfile.is_synchronous_`
+ /// in canary (xfile.h:177, xfile.cc:22). Populated by `open_vfs_file`
+ /// and `open_cache_file`; pruned by `nt_close` when the handle's
+ /// refcount drops to zero.
+ pub async_file_handles: std::collections::HashSet<u32>,
/// Bump allocator for guest heap (NtAllocateVirtualMemory etc.).
/// M2.4: `AtomicU32` for lock-free concurrent allocation.
pub heap_cursor: std::sync::atomic::AtomicU32,
@@ -91,6 +230,17 @@ pub struct KernelState {
pub last_input_bytes: u128,
/// Image base of the loaded XEX (for XexExecutableModuleHandle etc.)
pub image_base: u32,
+ /// Guest VA of the raw XEX header bytes copied into guest memory at
+ /// startup (mirrors canary's `UserModule::guest_xex_header_`,
+ /// allocated in `user_module.cc:224`). Used by `RtlImageXexHeaderField`
+ /// to compute return values that are offsets into the in-guest header
+ /// copy (canary's `xboxkrnl_rtl.cc:501-514` calls `UserModule::Get
+ /// OptHeader(memory, header, key, &field_value)` which iterates
+ /// `header->headers[]` and returns `HostToGuestVirtual(header) +
+ /// opt_header.offset` for "else"-class keys, key low byte != 0/1). Zero
+ /// when the executable hasn't been installed yet. Set once by
+ /// `xenia-app` after `mem.write_bulk(base, &image_data)`.
+ pub xex_header_guest_ptr: u32,
/// `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) parsed from the loaded
/// XEX header. Queried by `XexCheckExecutablePrivilege`: privilege bit
/// `n` is set iff `(xex_system_flags & (1 << n)) != 0`. Zero before the
@@ -123,6 +273,31 @@ pub struct KernelState {
/// at startup; cleared at the same time so lockstep digests stay
/// reproducible across reruns.
pub cache_root: Option<std::path::PathBuf>,
+ /// Phase C+12 — in-memory VFS entry tracker for the `cache:` mount,
+ /// mirroring canary's `HostPathDevice` entry tree. Keyed by the
+ /// normalized guest path (e.g. `cache:/d4ea4615/e/46ee8ca`,
+ /// post-`normalize_path` form with forward slashes). Populated at
+ /// mount time by [`Self::populate_cache_entries`] (analogue of
+ /// canary's `HostPathDevice::PopulateEntry`,
+ /// `xenia-canary/src/xenia/vfs/devices/host_path_device.cc:63`) and
+ /// per-NtCreateFile success by [`Self::register_cache_entry`]
+ /// (analogue of `Entry::CreateEntry` /
+ /// `HostPathEntry::CreateEntryInternal`,
+ /// `xenia-canary/src/xenia/vfs/devices/host_path_entry.cc:78`).
+ ///
+ /// Consulted by `nt_query_full_attributes_file` BEFORE any
+ /// `std::fs::metadata` host-FS call, mirroring canary's
+ /// `NtQueryFullAttributesFile_entry`
+ /// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:498-512`)
+ /// which only walks the in-memory entry tree via
+ /// `VirtualFileSystem::ResolvePath` and never re-stats the host.
+ ///
+ /// This resolves Phase C+11.1's main-chain divergence at idx
+ /// 102404 (NtQueryFullAttributesFile on `cache:\d4ea4615\e\46ee8ca`)
+ /// where canary's mount-time scan + in-memory tree allowed the
+ /// probe to succeed even before the file existed on disk this
+ /// boot, while ours's direct `std::fs::metadata` reported NOT_FOUND.
+ pub cache_entries: HashMap<String, CacheEntryMeta>,
/// Bridge to the host UI. `None` when running headless. Installed by
/// `cmd_exec` when the user passes `--ui`.
pub ui: Option<UiBridge>,
@@ -264,6 +439,23 @@ pub struct KernelState {
pub dump_addrs: Vec<u32>,
/// `--dump-section=BASE:LEN:PATH` end-of-run snapshot, page-gated by `is_mapped`.
pub dump_section: Option<(u32, u32, std::path::PathBuf)>,
+ /// Phase B initial-state snapshot — directory under which a
+ /// `ours/{cpu_state,memory,kernel,vfs,config}.json` + `manifest.json`
+ /// snapshot is written at the moment immediately before the first
+ /// guest PPC instruction of the XEX entry_point. `None` (default) =
+ /// disabled, zero overhead. See
+ /// `xenia-rs/audit-runs/phase-b-state-equivalence/`.
+ pub phase_b_snapshot_dir: Option<std::path::PathBuf>,
+ /// Phase B: after writing the snapshot, exit the process immediately
+ /// so re-runs are byte-deterministic. Default false.
+ pub phase_b_snapshot_and_exit: bool,
+ /// Phase B: include raw bytes in `memory.json`'s `section_contents`.
+ /// Default false — per-region SHA-256 is enough for the routine diff.
+ pub phase_b_dump_section_content: bool,
+ /// Phase B: the XEX entry_point address — captured by the app at
+ /// `install_initial_thread` time and consulted by the snapshot hook
+ /// to validate the firing thread is the entry thread.
+ pub entry_pc: u32,
}
impl KernelState {
@@ -288,11 +480,14 @@ impl KernelState {
scheduler.set_reservation_table(Some(reservations.clone()));
let mut state = Self {
exports: HashMap::new(),
+ void_exports: std::collections::HashSet::new(),
+ unimplemented_exports: std::collections::HashSet::new(),
next_handle: AtomicU32::new(0x1000),
scheduler,
next_tls_index: AtomicU32::new(0),
cs_waiters: HashMap::new(),
objects: HashMap::new(),
+ async_file_handles: std::collections::HashSet::new(),
heap_cursor: AtomicU32::new(0x4000_0000), // Start of user heap region
stack_cursor: AtomicU32::new(0x7100_0000), // Above main stack
gpu_command_buffer: 0,
@@ -300,6 +495,7 @@ impl KernelState {
input_packet_number: 0,
last_input_bytes: 0,
image_base: 0,
+ xex_header_guest_ptr: 0,
xex_system_flags: 0,
xex_priv_logged: std::collections::HashSet::new(),
has_notified_startup: false,
@@ -307,6 +503,7 @@ impl KernelState {
next_thread_id: AtomicU32::new(1),
vfs: None,
cache_root: None,
+ cache_entries: HashMap::new(),
ui: None,
interrupts: crate::interrupts::InterruptState::default(),
xaudio: crate::xaudio::XAudioState::default(),
@@ -331,6 +528,10 @@ impl KernelState {
lr_trace_writer: None,
dump_addrs: Vec::new(),
dump_section: None,
+ phase_b_snapshot_dir: None,
+ phase_b_snapshot_and_exit: false,
+ phase_b_dump_section_content: false,
+ entry_pc: 0,
};
crate::exports::register_exports(&mut state);
crate::xam::register_exports(&mut state);
@@ -358,6 +559,16 @@ impl KernelState {
e
);
}
+ // Phase C+12 — eager mount-time entry-tree population mirrors
+ // canary's `HostPathDevice::PopulateEntry` recursion
+ // (`xenia-canary/src/xenia/vfs/devices/host_path_device.cc:63`).
+ // After the (optional) wipe, the on-disk tree is the source of
+ // truth; `nt_query_full_attributes_file` will consult the
+ // in-memory table built here before any host-FS round-trip.
+ if state.cache_root.is_some() {
+ let root_clone = state.cache_root.clone().unwrap();
+ state.populate_cache_entries_from_host(&root_clone);
+ }
state
}
@@ -377,6 +588,42 @@ impl KernelState {
self.exports.insert((module, ordinal), (name, func));
}
+ /// Register a kernel export whose canary signature is `void`.
+ /// See `KernelState::void_exports` doc. Identical semantics to
+ /// `register_export` except the Phase A `kernel.return` payload's
+ /// `return_value` field is emitted as 0 instead of `gpr[3]`,
+ /// matching canary's `EmitReturn(name, 0)` branch.
+ pub fn register_void_export(
+ &mut self,
+ module: ModuleId,
+ ordinal: u32,
+ name: &'static str,
+ func: KernelExportFn,
+ ) {
+ self.exports.insert((module, ordinal), (name, func));
+ self.void_exports.insert((module, ordinal));
+ }
+
+ /// Phase C+6: register a kernel export that has a table-entry in
+ /// canary's `xboxkrnl_table.inc` but NO `DECLARE_XBOXKRNL_EXPORT`
+ /// shim. Identical execution semantics to `register_export`; only
+ /// difference is the Phase A emitter is silent for this export (to
+ /// mirror canary's syscall-thunk path which never reaches the
+ /// `Trampoline` that issues `import.call`/`kernel.call`/
+ /// `kernel.return`). See `KernelState::unimplemented_exports` doc.
+ /// Use for ords whose `func` is a `stub_*` and which would
+ /// otherwise inject spurious Phase A alignment drift.
+ pub fn register_unimplemented_export(
+ &mut self,
+ module: ModuleId,
+ ordinal: u32,
+ name: &'static str,
+ func: KernelExportFn,
+ ) {
+ self.exports.insert((module, ordinal), (name, func));
+ self.unimplemented_exports.insert((module, ordinal));
+ }
+
/// AUDIT-038 — install a host directory as the backing store for the
/// `cache:` mount. The directory is unconditionally cleared (and then
/// re-created) on entry so two consecutive runs see byte-identical
@@ -397,14 +644,164 @@ impl KernelState {
}
std::fs::create_dir_all(&root)?;
self.cache_root = Some(root);
+ // Phase C+12 — wipe path: tree is by definition empty after the
+ // clear-then-recreate. A subsequent `set_cache_root` could be
+ // called by tests that want a populated tree; we leave that path
+ // handle the eager scan.
+ self.cache_entries.clear();
+ // Insert the root directory entry so callers that probe
+ // `cache:/` directly (rare; Sylpheed does `NtOpenFile cache:\`
+ // at idx 102382) see canary's "yes, root is a directory" answer.
+ self.cache_entries.insert(
+ "cache:/".to_string(),
+ CacheEntryMeta {
+ is_directory: true,
+ size: 0,
+ allocation_size: 0,
+ create_time: 0,
+ access_time: 0,
+ write_time: 0,
+ },
+ );
Ok(())
}
/// AUDIT-054 — direct (non-wiping) cache-root install for tests
/// that want byte-for-byte control over what's already on disk
/// when the kernel boots. Skips the `init_cache_root` clear pass.
+ ///
+ /// Phase C+12 — this also eagerly populates [`Self::cache_entries`]
+ /// from the existing host-FS tree under `root`, mirroring canary's
+ /// `HostPathDevice::Initialize` → `PopulateEntry`
+ /// (`xenia-canary/src/xenia/vfs/devices/host_path_device.cc:31-48,
+ /// 63-75`).
pub fn set_cache_root(&mut self, root: std::path::PathBuf) {
- self.cache_root = Some(root);
+ self.cache_root = Some(root.clone());
+ self.cache_entries.clear();
+ self.populate_cache_entries_from_host(&root);
+ }
+
+ /// Phase C+12 — eager mount-time scan. Walks `root` recursively
+ /// and inserts a [`CacheEntryMeta`] for every entry under the
+ /// `cache:/` namespace. Mirrors canary's `HostPathDevice::
+ /// PopulateEntry` recursion. Errors are non-fatal (logged at
+ /// trace level); missing/unreadable host paths just leave the
+ /// in-memory tree empty for that subtree, exactly like canary
+ /// (which uses `ListFiles` whose `WIN32_FIND_DATA` errors silently
+ /// produce an empty vector).
+ fn populate_cache_entries_from_host(&mut self, root: &std::path::Path) {
+ // Always seed the device root.
+ self.cache_entries.insert(
+ "cache:/".to_string(),
+ CacheEntryMeta {
+ is_directory: true,
+ size: 0,
+ allocation_size: 0,
+ create_time: 0,
+ access_time: 0,
+ write_time: 0,
+ },
+ );
+ if !root.is_dir() {
+ return;
+ }
+ let mut stack: Vec<(std::path::PathBuf, String)> =
+ vec![(root.to_path_buf(), "cache:".to_string())];
+ while let Some((host_dir, guest_prefix)) = stack.pop() {
+ let Ok(rd) = std::fs::read_dir(&host_dir) else {
+ continue;
+ };
+ for entry in rd.flatten() {
+ let host_path = entry.path();
+ let Some(name) = host_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ else {
+ continue;
+ };
+ let guest_path = format!("{}/{}", guest_prefix, name);
+ let Ok(md) = entry.metadata() else { continue };
+ let meta = cache_entry_from_metadata(&md);
+ let is_dir = meta.is_directory;
+ self.cache_entries.insert(guest_path.clone(), meta);
+ if is_dir {
+ stack.push((host_path, guest_path));
+ }
+ }
+ }
+ }
+
+ /// Phase C+12 — register / refresh a single cache-mount entry by
+ /// guest path (forward-slashed; matches `crate::path::normalize_path`
+ /// output and the keys produced by [`Self::populate_cache_entries_
+ /// from_host`]). Called from [`crate::exports::open_cache_file`]
+ /// after a successful create-or-open so subsequent
+ /// `NtQueryFullAttributesFile` probes see the freshly-materialised
+ /// entry without re-stating the host FS, mirroring canary's
+ /// `Entry::CreateEntry` insert path.
+ ///
+ /// Idempotent — calling twice with the same path just refreshes
+ /// the cached metadata from `metadata` (useful after a write that
+ /// changed size / mtime).
+ pub fn register_cache_entry(&mut self, guest_path: &str, metadata: &std::fs::Metadata) {
+ let key = Self::normalize_cache_key(guest_path);
+ self.cache_entries
+ .insert(key, cache_entry_from_metadata(metadata));
+ }
+
+ /// Phase C+12 — drop a cache entry (used on NtSetInformationFile
+ /// rename and on delete). Idempotent.
+ pub fn forget_cache_entry(&mut self, guest_path: &str) {
+ let key = Self::normalize_cache_key(guest_path);
+ self.cache_entries.remove(&key);
+ }
+
+ /// Phase C+12 — look up a cache entry by guest path. The lookup
+ /// key is case-insensitive on the `cache:` prefix (canary matches
+ /// device-prefix case-insensitively via
+ /// `xe::utf8::starts_with` against `cache:`) and forward-slashed
+ /// for the rest. Path-traversal `..` / `.` components and leading
+ /// slashes are stripped to match the canonicalization
+ /// [`Self::resolve_cache_path`] performs against the host FS.
+ pub fn lookup_cache_entry(&self, raw: &str) -> Option<&CacheEntryMeta> {
+ let key = Self::normalize_cache_key(raw);
+ self.cache_entries.get(&key)
+ }
+
+ /// Canonical key form for [`Self::cache_entries`]:
+ /// `cache:/<lower-slashed-relative>`. Mirrors what
+ /// `crate::path::normalize_path` produces (forward slashes,
+ /// `cache:` prefix preserved). Accepts both `cache:\foo\bar` and
+ /// `cache:/foo/bar`, and treats `cache0:` / `cache1:` as aliases
+ /// of `cache:` (same backing dir; see [`Self::resolve_cache_path`]).
+ fn normalize_cache_key(raw: &str) -> String {
+ let lower = raw.to_ascii_lowercase();
+ let after_prefix = if let Some(rest) = lower
+ .strip_prefix("cache:\\")
+ .or_else(|| lower.strip_prefix("cache:/"))
+ {
+ rest
+ } else if let Some(rest) = lower
+ .strip_prefix("cache0:\\")
+ .or_else(|| lower.strip_prefix("cache0:/"))
+ .or_else(|| lower.strip_prefix("cache1:\\"))
+ .or_else(|| lower.strip_prefix("cache1:/"))
+ {
+ rest
+ } else if lower == "cache:" || lower == "cache:/" || lower == "cache:\\" {
+ return "cache:/".to_string();
+ } else {
+ return lower;
+ };
+ let clean: Vec<&str> = after_prefix
+ .split(|c: char| c == '/' || c == '\\')
+ .filter(|s| !s.is_empty() && *s != "." && *s != "..")
+ .collect();
+ if clean.is_empty() {
+ "cache:/".to_string()
+ } else {
+ format!("cache:/{}", clean.join("/"))
+ }
}
/// Resolve a guest VFS path (e.g. `cache:\d4ea4615e46ee8ca.tmp`) to
@@ -514,7 +911,115 @@ impl KernelState {
metrics::counter!("kernel.calls", "name" => name).increment(1);
tracing::trace!(target: "probe_calls", "hw={} call={} r3={:#x} r4={:#x} r5={:#x} lr={:#x}",
r.hw_id, name, ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.lr);
+ // Phase A event log — see crates/xenia-kernel/src/event_log.rs.
+ // Hot path: `is_enabled` is a relaxed atomic-bool load.
+ // Phase C+6: exports flagged `unimplemented_exports` mirror
+ // canary's table-entry-without-DECLARE_XBOXKRNL_EXPORT path
+ // (`xenia-canary/src/xenia/cpu/xex_module.cc:1316-1335`),
+ // which dispatches through the syscall thunk and never
+ // reaches the `Trampoline` that emits Phase A events. Suppress
+ // event emission so ours's stream matches canary's. The stub
+ // body still runs.
+ let phase_a_on = crate::event_log::is_enabled()
+ && !self.unimplemented_exports.contains(&(module, ordinal));
+ let (phase_a_tid, phase_a_cycle) = if phase_a_on {
+ let tid = self.scheduler.thread(r).tid;
+ let cycle = ctx.cycle_count;
+ (tid, cycle)
+ } else {
+ (0u32, 0u64)
+ };
+ if phase_a_on {
+ let module_name = match module {
+ ModuleId::Xboxkrnl => "xboxkrnl.exe",
+ ModuleId::Xam => "xam.xex",
+ ModuleId::Xbdm => "xbdm.xex",
+ };
+ crate::event_log::emit_import_call(
+ phase_a_tid,
+ phase_a_cycle,
+ module_name,
+ ordinal as u16,
+ name,
+ );
+ // Phase C+10 schema-v1 extension: resolve path args for
+ // OBJECT_ATTRIBUTES*-taking exports so divergences on file
+ // existence probes carry the actual path string in the diff.
+ // Additive — degrades to empty args_resolved when name is
+ // not in the path-bearing set or resolution fails.
+ let resolved_path = match name {
+ // Path-bearing exports — argument positions per canary's
+ // `xboxkrnl/xboxkrnl_io.cc` signatures (verified):
+ // NtCreateFile (r3 = file_handle_ptr, r4 = ..., r5 = obj_attrs)
+ // NtOpenFile (r3 = file_handle_ptr, r4 = ..., r5 = obj_attrs)
+ // NtQueryFullAttributesFile (r3 = obj_attrs, r4 = file_info)
+ // NtOpenSymbolicLinkObject (r3 = handle_out, r4 = obj_attrs)
+ // Use the raw (untransformed) form to avoid masking
+ // upstream divergences via normalization.
+ "NtQueryFullAttributesFile" => {
+ crate::path::object_attributes_raw_name(mem, ctx.gpr[3] as u32)
+ }
+ "NtOpenSymbolicLinkObject" => {
+ crate::path::object_attributes_raw_name(mem, ctx.gpr[4] as u32)
+ }
+ "NtCreateFile" | "NtOpenFile" => {
+ crate::path::object_attributes_raw_name(mem, ctx.gpr[5] as u32)
+ }
+ // Phase C+11 — surface the rename target path for
+ // `NtSetInformationFile` calls with info_class==10
+ // (`XFileRenameInformation`). The target is in the
+ // info buffer, not OBJECT_ATTRIBUTES.
+ //
+ // Calling convention (canary `xboxkrnl_io_info.cc:180`):
+ // r3 = handle, r4 = iosb, r5 = info_ptr,
+ // r6 = info_length, r7 = info_class.
+ "NtSetInformationFile" if ctx.gpr[7] as u32 == 10 => {
+ crate::path::file_rename_information_raw_target(
+ mem,
+ ctx.gpr[5] as u32,
+ ctx.gpr[6] as u32,
+ )
+ }
+ _ => None,
+ };
+ crate::event_log::emit_kernel_call_with_path(
+ phase_a_tid,
+ phase_a_cycle,
+ name,
+ resolved_path.as_deref(),
+ );
+ }
+ let is_void = self.void_exports.contains(&(module, ordinal));
func(&mut ctx, mem, self);
+ if phase_a_on {
+ // Mirror canary's `if constexpr (std::is_void<R>::value)`
+ // trampoline branch: void exports emit literal 0; non-void
+ // emit post-call gpr[3]. Without this, void exports that
+ // take a pointer arg (e.g. `KeQuerySystemTime`) would
+ // report ours=r3=arg_ptr vs canary=0 — a Phase A diff
+ // that is purely an emitter-framing asymmetry, not an
+ // engine semantic divergence.
+ //
+ // Phase C+11 — sign-extend the lower 32 bits to match
+ // canary's `ResultBase::Store` (shim_utils.h:359-361):
+ // `ppc_context->r[3] = uint64_t(int32_t(value_));`
+ // For positive-as-i32 returns (status SUCCESS, pointers
+ // < 0x80000000) this is a no-op. For "negative" NTSTATUS
+ // codes (e.g. STATUS_NO_SUCH_FILE = 0xC000000F) it
+ // produces 0xFFFFFFFFC000000F — matching the diff's
+ // expected u64 representation.
+ let return_value = if is_void {
+ 0
+ } else {
+ (ctx.gpr[3] as u32 as i32 as i64) as u64
+ };
+ crate::event_log::emit_kernel_return(
+ phase_a_tid,
+ ctx.cycle_count,
+ name,
+ return_value,
+ );
+ }
true
} else {
metrics::counter!("kernel.unimplemented").increment(1);
@@ -1026,20 +1531,36 @@ impl Default for KernelState {
}
}
-/// AUDIT-054 — pick the cache root path + wipe-on-init mode for a
-/// fresh `KernelState`.
+/// Pick the cache root path + wipe-on-init mode for a fresh
+/// `KernelState`.
+///
+/// Phase C+11 (2026-05-14) — default flipped to PERSISTENT. Prior
+/// AUDIT-038 behaviour (per-process tmpdir + wipe) is still
+/// reachable via `XENIA_CACHE_WIPE=1`. Rationale for the flip:
+///
+/// * AUDIT-052 refuted AUDIT-038's "missing-or-stale ≡ fresh"
+/// premise: Sylpheed's work-submitter wakeup is GATED on cache
+/// existence, so wipe-on-boot blocks the cache-build cascade.
+/// * AUDIT-054 introduced opt-in `XENIA_CACHE_PERSIST=1`; the
+/// Phase C+11 fixes (NtSetInformationFile class 10 rename +
+/// `is_dir_open` existing-file-wins + STATUS_NO_SUCH_FILE on
+/// query miss + sign-extended status returns) make
+/// Sylpheed's own cache-build path converge to canary-parity
+/// leaf layout. The diff harness no longer needs the wipe.
+/// * The C+10 args_resolved.path emitter surfaces any cache
+/// divergence in the Phase A diff regardless of cache state,
+/// so the original "lockstep determinism" rationale for the
+/// wipe is no longer the only mechanism preventing silent
+/// cache divergences.
///
-/// Default behaviour matches AUDIT-038: per-process tmpdir + full
-/// wipe so two consecutive runs see byte-identical initial state
-/// (lockstep / oracle determinism). AUDIT-054 found that Sylpheed's
-/// `cache:\<hash>.tmp` journal-style writes append on each boot, so
-/// a naive persistent root makes the on-disk state self-inconsistent
-/// after the second boot (`runtime_error` throws from version-check
-/// on reload). Opt-in to persistence via env:
-/// * `XENIA_CACHE_ROOT=<path>` — explicit persistent path. Caller
-/// is responsible for wiping when needed.
-/// * `XENIA_CACHE_PERSIST=1` — use `$XDG_DATA_HOME/xenia-rs/cache`
-/// (or `$HOME/.local/share/xenia-rs/cache`) without wiping.
+/// Env-var contract (unchanged):
+/// * `XENIA_CACHE_ROOT=<path>` — explicit persistent path.
+/// Highest precedence. No wipe.
+/// * `XENIA_CACHE_PERSIST=1` — alias for the new default. Kept
+/// for backwards compatibility (no-op now).
+/// * `XENIA_CACHE_WIPE=1` — opt back into the AUDIT-038
+/// per-process tmpdir + wipe. Use for emergency lockstep
+/// state-reset scenarios.
///
/// Returns `(root, wipe)` where `wipe = true` triggers the
/// `init_cache_root` clear-then-recreate dance.
@@ -1049,37 +1570,55 @@ fn resolve_default_cache_root() -> (std::path::PathBuf, bool) {
return (std::path::PathBuf::from(p), false);
}
}
- let persist = std::env::var("XENIA_CACHE_PERSIST")
+ // Opt-out: explicit AUDIT-038-style wipe + tmpdir. Kept for
+ // emergency state-reset, e.g. Phase A determinism baseline
+ // captures that must start from a known-empty cache.
+ let wipe_explicit = std::env::var("XENIA_CACHE_WIPE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
- if persist {
- if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
- if !xdg.is_empty() {
- return (
- std::path::PathBuf::from(xdg).join("xenia-rs/cache"),
- false,
- );
- }
+ if wipe_explicit {
+ static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 =
+ std::sync::atomic::AtomicU64::new(0);
+ let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ return (
+ std::env::temp_dir().join(format!(
+ "xenia-rs-cache-{}-{}",
+ std::process::id(),
+ id
+ )),
+ true,
+ );
+ }
+ // Default: persistent cache at the standard XDG location.
+ // `XENIA_CACHE_PERSIST=1` is a no-op alias for the default
+ // — keep accepting it for callers that set it explicitly.
+ if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
+ if !xdg.is_empty() {
+ return (
+ std::path::PathBuf::from(xdg).join("xenia-rs/cache"),
+ false,
+ );
}
- if let Ok(home) = std::env::var("HOME") {
- if !home.is_empty() {
- return (
- std::path::PathBuf::from(home).join(".local/share/xenia-rs/cache"),
- false,
- );
- }
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ if !home.is_empty() {
+ return (
+ std::path::PathBuf::from(home).join(".local/share/xenia-rs/cache"),
+ false,
+ );
}
}
- static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 =
+ // Final fallback: tmpdir without wipe (no $HOME, very rare).
+ static NEXT_CACHE_ID_FALLBACK: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
- let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let id = NEXT_CACHE_ID_FALLBACK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
(
std::env::temp_dir().join(format!(
- "xenia-rs-cache-{}-{}",
+ "xenia-rs-cache-fallback-{}-{}",
std::process::id(),
id
)),
- true,
+ false,
)
}
@@ -1635,6 +2174,41 @@ mod tests {
assert!(state.ctor_probe_pcs.contains(&0x8217_C850));
}
+ #[test]
+ fn register_unimplemented_export_marks_set_membership() {
+ // Phase C+6: `register_unimplemented_export` must (a) install the
+ // export func like `register_export` does, AND (b) flag the
+ // (module, ord) pair in `unimplemented_exports` so the Phase A
+ // emitter inside `call_export` can suppress events for it. Without
+ // (a), guest calls would fault as "unimplemented ordinal". Without
+ // (b), ours would inject `import.call`/`kernel.call`/
+ // `kernel.return` triples that canary's syscall-thunk path never
+ // emits, drifting Phase A alignment.
+ fn noop(_: &mut PpcContext, _: &GuestMemory, _: &mut KernelState) {}
+ let mut state = KernelState::new();
+ state.register_unimplemented_export(
+ ModuleId::Xboxkrnl,
+ 0xFFEE,
+ "FakeUnimplementedXboxkrnl",
+ noop,
+ );
+ assert!(state.exports.contains_key(&(ModuleId::Xboxkrnl, 0xFFEE)));
+ assert!(state
+ .unimplemented_exports
+ .contains(&(ModuleId::Xboxkrnl, 0xFFEE)));
+ // A normal `register_export` must NOT mark it unimplemented.
+ state.register_export(
+ ModuleId::Xboxkrnl,
+ 0xFFEF,
+ "FakeRegularXboxkrnl",
+ noop,
+ );
+ assert!(state.exports.contains_key(&(ModuleId::Xboxkrnl, 0xFFEF)));
+ assert!(!state
+ .unimplemented_exports
+ .contains(&(ModuleId::Xboxkrnl, 0xFFEF)));
+ }
+
#[test]
fn read_ascii_cstring_handles_termination_and_garbage() {
use xenia_memory::page_table::MemoryProtect;