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`; 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 = 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:\

.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:\

.tmp` flat journal files, then +/// renames them to the hierarchical leaf `cache:\

\\

` 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 replace_existing +/// offset 4 be 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 `/d4ea4615/e/46ee8ca` fails when `/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` / `be` 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:\` 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:\

.tmp` flat journal, then + /// rename it to the hierarchical leaf `cache:\

\\

` 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 replace_existing = 1 + // offset 4: be 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:\

\\

` 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::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>, /// Kernel object table: handle → object pub objects: HashMap, + /// 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, /// 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, + /// 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, /// Bridge to the host UI. `None` when running headless. Installed by /// `cmd_exec` when the user passes `--ui`. pub ui: Option, @@ -264,6 +439,23 @@ pub struct KernelState { pub dump_addrs: Vec, /// `--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, + /// 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:/`. 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::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:\.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=` — 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=` — 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;