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>
3499 lines
161 KiB
Diff
3499 lines
161 KiB
Diff
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;
|