diff --git a/crates/xenia-kernel/src/exports.rs b/crates/xenia-kernel/src/exports.rs index a4dfa7d..4ff6238 100644 --- a/crates/xenia-kernel/src/exports.rs +++ b/crates/xenia-kernel/src/exports.rs @@ -16,7 +16,12 @@ pub fn register_exports(state: &mut KernelState) { // Debug state.register_export(Xboxkrnl, 0x01, "DbgBreakPoint", dbg_break_point); - state.register_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print); + // Phase C+6½: `DbgPrint` (ord 0x03) is table-entry-only in canary + // (`xboxkrnl_table.inc:17`, no `DECLARE_XBOXKRNL_EXPORT(DbgPrint)`). + // Canary routes through the syscall thunk, which emits NO Phase A + // events. Mirror that — body still logs the string (harmless side + // effect) but the Phase A emitter stays silent. + state.register_unimplemented_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print); // ExCreateThread and friends state.register_export(Xboxkrnl, 0x0D, "ExCreateThread", ex_create_thread); @@ -28,7 +33,17 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x28, "HalReturnToFirmware", hal_return_to_firmware); // I/O - state.register_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success); + // Phase C+6: `IoDismountVolumeByFileHandle` has a table entry in + // canary's `xboxkrnl_table.inc:74` but NO `DECLARE_XBOXKRNL_EXPORT` + // shim, so canary routes calls through the syscall thunk + // (`xex_module.cc:1310-1335`) which emits NO Phase A events. + // Mirror that by registering as unimplemented — ours still runs + // `stub_success` for guest-visible semantics, but the Phase A + // emitter stays silent. Before this fix, ours's tid=1 main chain + // injected 3 spurious events (`import.call`/`kernel.call`/ + // `kernel.return`) at idx=102132 ahead of `NtClose`, becoming the + // first divergence vs canary which jumps straight to `NtClose`. + state.register_unimplemented_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success); // Ke* Threading/Sync state.register_export(Xboxkrnl, 0x4D, "KeAcquireSpinLockAtRaisedIrql", stub_return_zero); @@ -44,16 +59,36 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x7D, "KeLeaveCriticalRegion", stub_success); state.register_export(Xboxkrnl, 0x7F, "KePulseEvent", ke_pulse_event); state.register_export(Xboxkrnl, 0x81, "KeQueryBasePriorityThread", ke_query_base_priority_thread); - state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor); + // Phase C+6½ hallucination fix: ord 0x82 = `KeQueryInterruptTime` + // per canary's `xboxkrnl_table.inc:130`. Canary DECLAREs this export + // (`xboxkrnl_misc.cc:127`) — both engines emit Phase A events. + // Previously mis-labeled `KeQueryIdealProcessor` in ours; the body + // returned a wrong value (processor index instead of interrupt-time + // counter). Fixed body returns a synthetic monotonic u64. + state.register_export(Xboxkrnl, 0x82, "KeQueryInterruptTime", ke_query_interrupt_time); state.register_export(Xboxkrnl, 0x83, "KeQueryPerformanceFrequency", ke_query_performance_frequency); - state.register_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time); - state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", stub_return_zero); + // Canary declares `void KeQuerySystemTime_entry(lpqword_t time_ptr, ...)` + // (xboxkrnl_threading.cc:459); the time is delivered via the OUT + // pointer, not via gpr[3]. Phase A's `kernel.return.return_value` + // must be 0 (canary literal) — not r3 (which for ours is the input + // arg `time_ptr` left untouched). See `register_void_export` doc in + // state.rs. + state.register_void_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time); + state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", ke_raise_irql_to_dpc_level); state.register_export(Xboxkrnl, 0x88, "KeReleaseSemaphore", ke_release_semaphore); state.register_export(Xboxkrnl, 0x89, "KeReleaseSpinLockFromRaisedIrql", ke_release_spinlock_from_raised_irql); state.register_export(Xboxkrnl, 0x8F, "KeResetEvent", ke_reset_event); state.register_export(Xboxkrnl, 0x92, "KeResumeThread", ke_resume_thread); state.register_export(Xboxkrnl, 0x97, "KeSetAffinityThread", ke_set_affinity_thread); - state.register_export(Xboxkrnl, 0x98, "KeSetIdealProcessor", ke_set_ideal_processor); + // Phase C+6½ hallucination fix: ord 0x98 = `KeSetBackgroundProcessors` + // per canary's `xboxkrnl_table.inc:166`. Table-entry-only (no + // `DECLARE_XBOXKRNL_EXPORT` shim), so canary routes via the syscall + // thunk and emits NO Phase A events. Previously mis-labeled + // `KeSetIdealProcessor` in ours; the body wrote + // `GuestThread::ideal_processor` — wrong state mutation under the + // wrong name. Replaced with `stub_success` and registered as + // unimplemented to mirror canary's silence. + state.register_unimplemented_export(Xboxkrnl, 0x98, "KeSetBackgroundProcessors", stub_success); state.register_export(Xboxkrnl, 0x99, "KeSetBasePriorityThread", ke_set_base_priority_thread); state.register_export(Xboxkrnl, 0x9B, "KeSetCurrentStackPointers", stub_success); state.register_export(Xboxkrnl, 0x9D, "KeSetEvent", ke_set_event); @@ -61,7 +96,7 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0xAF, "KeWaitForMultipleObjects", ke_wait_for_multiple_objects); state.register_export(Xboxkrnl, 0xB0, "KeWaitForSingleObject", ke_wait_for_single_object); state.register_export(Xboxkrnl, 0xB1, "KfAcquireSpinLock", kf_acquire_spin_lock); - state.register_export(Xboxkrnl, 0xB3, "KfLowerIrql", stub_success); + state.register_void_export(Xboxkrnl, 0xB3, "KfLowerIrql", kf_lower_irql); state.register_export(Xboxkrnl, 0xB4, "KfReleaseSpinLock", kf_release_spin_lock); state.register_export(Xboxkrnl, 0x0152, "KeTlsAlloc", ke_tls_alloc); state.register_export(Xboxkrnl, 0x0153, "KeTlsFree", stub_success); @@ -126,13 +161,16 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x0110, "ObReferenceObjectByHandle", ob_reference_object_by_handle); // RTL - state.register_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context); + // Phase C+6½: `RtlCaptureContext` (ord 0x119) is table-entry-only + // in canary — no `DECLARE_XBOXKRNL_EXPORT(RtlCaptureContext)`. + // Mirror canary's silence so the Phase A emitter doesn't drift. + state.register_unimplemented_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context); state.register_export(Xboxkrnl, 0x011B, "RtlCompareMemoryUlong", rtl_compare_memory_ulong); state.register_export(Xboxkrnl, 0x0125, "RtlEnterCriticalSection", rtl_enter_critical_section); state.register_export(Xboxkrnl, 0x0126, "RtlFillMemoryUlong", rtl_fill_memory_ulong); state.register_export(Xboxkrnl, 0x0127, "RtlFreeAnsiString", stub_success); state.register_export(Xboxkrnl, 0x012B, "RtlImageXexHeaderField", rtl_image_xex_header_field); - state.register_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string); + state.register_void_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string); state.register_export(Xboxkrnl, 0x012D, "RtlInitUnicodeString", rtl_init_unicode_string); state.register_export(Xboxkrnl, 0x012E, "RtlInitializeCriticalSection", rtl_initialize_critical_section); state.register_export(Xboxkrnl, 0x012F, "RtlInitializeCriticalSectionAndSpinCount", rtl_initialize_critical_section); @@ -140,18 +178,27 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x0133, "RtlMultiByteToUnicodeN", rtl_multi_byte_to_unicode_n); state.register_export(Xboxkrnl, 0x0135, "RtlNtStatusToDosError", rtl_nt_status_to_dos_error); state.register_export(Xboxkrnl, 0x0136, "RtlRaiseException", rtl_raise_exception); - state.register_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf); + // Phase C+6½: `sprintf` (ord 0x13B) is table-entry-only in canary + // — no `DECLARE_XBOXKRNL_EXPORT(sprintf)`. Mirror canary's silence. + state.register_unimplemented_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf); state.register_export(Xboxkrnl, 0x013F, "RtlTimeFieldsToTime", stub_success); state.register_export(Xboxkrnl, 0x0140, "RtlTimeToTimeFields", stub_success); state.register_export(Xboxkrnl, 0x0141, "RtlTryEnterCriticalSection", rtl_try_enter_critical_section); state.register_export(Xboxkrnl, 0x0142, "RtlUnicodeStringToAnsiString", stub_success); state.register_export(Xboxkrnl, 0x0143, "RtlUnicodeToMultiByteN", stub_success); - state.register_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind); - state.register_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf); + // Phase C+6½: `RtlUnwind` (ord 0x147) is table-entry-only in canary + // — no `DECLARE_XBOXKRNL_EXPORT(RtlUnwind)`. Mirror canary's silence. + state.register_unimplemented_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind); + // Phase C+6½: `_vsnprintf` (ord 0x14D) is table-entry-only in + // canary — no `DECLARE_XBOXKRNL_EXPORT(_vsnprintf)`. Mirror silence. + state.register_unimplemented_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf); // Stfs - state.register_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success); - state.register_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success); + // Phase C+6½: `StfsCreateDevice` (ord 0x259) and `StfsControlDevice` + // (ord 0x25A) are table-entry-only in canary. `StfsCreateDevice` is + // the C+6-noted driver of tid=7→tid=2 divergence at idx=15. + state.register_unimplemented_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success); + state.register_unimplemented_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success); // Video state.register_export(Xboxkrnl, 0x01B1, "VdCallGraphicsNotificationRoutines", stub_success); @@ -160,7 +207,7 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x01B9, "VdGetCurrentDisplayGamma", vd_get_current_display_gamma); state.register_export(Xboxkrnl, 0x01BA, "VdGetCurrentDisplayInformation", stub_success); state.register_export(Xboxkrnl, 0x01BD, "VdGetSystemCommandBuffer", vd_get_system_command_buffer); - state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_success); + state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_return_one); state.register_export(Xboxkrnl, 0x01C3, "VdInitializeRingBuffer", vd_initialize_ring_buffer); state.register_export(Xboxkrnl, 0x01C5, "VdInitializeScalerCommandBuffer", stub_success); state.register_export(Xboxkrnl, 0x01C6, "VdIsHSIOTrainingSucceeded", vd_is_hsio_training_succeeded); @@ -185,9 +232,11 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", stub_success); // Crypto - state.register_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success); - state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", stub_success); - state.register_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success); + state.register_void_export(Xboxkrnl, 0x0192, "XeCryptSha", xe_crypt_sha); + state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", xe_keys_console_private_key_sign); + // Phase C+6½: `XeKeysConsoleSignatureVerification` (ord 0x257) is + // table-entry-only in canary. Mirror silence. + state.register_unimplemented_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success); // Xex module state.register_export(Xboxkrnl, 0x0194, "XexCheckExecutablePrivilege", xex_check_executable_privilege); @@ -195,7 +244,9 @@ pub fn register_exports(state: &mut KernelState) { state.register_export(Xboxkrnl, 0x0197, "XexGetProcedureAddress", xex_get_procedure_address); // Exception handling - state.register_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler); + // Phase C+6½: `__C_specific_handler` (ord 0x1A5) is table-entry-only + // in canary. Mirror silence. + state.register_unimplemented_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler); } // ===== Generic stubs ===== @@ -208,6 +259,16 @@ fn stub_return_zero(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut Kerne ctx.gpr[3] = 0; } +/// Phase W: a literal `return 1`. Matches canary's +/// `VdInitializeEngines_entry` in `xboxkrnl_video.cc:271-279` which +/// returns `1` (truthy success token) rather than STATUS_SUCCESS=0. +/// Sylpheed-side guest code branches on this non-zero, so returning +/// 0 made the game skip the VdInitializeRingBuffer-and-after init +/// sequence and never set up the post-init render-target state. +fn stub_return_one(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { + ctx.gpr[3] = 1; +} + // ===== Debug ===== fn dbg_break_point(_ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { @@ -280,6 +341,16 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) { *slot = Some(hw_id); } + // Phase C+16: install the "thread owns itself until exited" + // self-reference. Mirrors canary's `XThread::Create` line 414 + // `RetainHandle()`. Released by `ex_terminate_thread` and the + // main-loop LR-sentinel implicit-exit path. Without this, a + // subsequent NtClose on the thread handle (e.g. via + // `XamTaskCloseHandle`) drops the only ref and prematurely + // destroys the thread handle while the spawned thread is + // still live — the original C+16 divergence at Phase A + // idx=102168 on the main chain (canary tid=6 ↔ ours tid=1). + state.retain_handle(handle); if handle_ptr != 0 { mem.write_u32(handle_ptr, handle); } @@ -296,6 +367,33 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS create_suspended, affinity, ); + // Phase C+15-α: schema-v1 `thread.create` event emitted by + // the **parent** thread at the kernel call that created the + // new guest thread. The handle.create for the thread-handle + // itself was already emitted inside `alloc_handle_for` + // above; here we surface the spawn-specific metadata + // (entry_pc, ctx_ptr, priority, affinity, stack, suspended). + // Canary's symmetric emit is at `XThread::Create` after + // CreationParameters are populated. + if crate::event_log::is_enabled() { + let (parent_tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + let sid = crate::event_log::lookup_handle_semantic_id(handle); + crate::event_log::emit_thread_create( + parent_tid, + cycle, + sid, + start_address, + start_context, + /* priority */ 0, + affinity, + stack_size, + create_suspended, + ); + } ctx.gpr[3] = STATUS_SUCCESS; } Err(_) => { @@ -311,6 +409,19 @@ fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS /// waiting on the thread handle are woken with STATUS_SUCCESS. fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let exit_code = ctx.gpr[3] as u32; + // Phase C+15-α: schema-v1 `thread.exit` event. Must emit BEFORE the + // scheduler unwinds the current thread, because `tid_event_idx` is + // per-tid and the exiting thread's counter is what gets the event. + // Canary symmetric emit at `XThread::Execute` exit (xthread.cc:540 + // ff., after `kernel_state()->processor()->Execute` returns). + if crate::event_log::is_enabled() { + let (tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + crate::event_log::emit_thread_exit(tid, cycle, exit_code); + } let (hw_id, tid, handle_opt) = state.scheduler.exit_current(exit_code); tracing::info!( "ExTerminateThread: tid={:?} hw={} exit_code={}", @@ -318,8 +429,8 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker hw_id, exit_code ); - if let Some(handle) = handle_opt - && let Some(KernelObject::Thread { + if let Some(handle) = handle_opt { + if let Some(KernelObject::Thread { exit_code: ec, waiters, .. @@ -331,6 +442,16 @@ fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Ker state.scheduler.wake_ref(w); } } + // Phase C+16: release the thread's self-reference installed at + // spawn time (`ex_create_thread` / `xam_task_schedule` via + // `state.retain_handle`). Mirrors canary's `XThread::Exit` + // `ReleaseHandle()` at xthread.cc:524. After this release, the + // refcount equals only the user-visible refs (1 if guest hasn't + // closed the handle, 0 if guest already called NtClose during + // the thread's lifetime — in which case the handle is destroyed + // here, emitting `handle.destroy`). + state.release_handle(handle); + } tracing::debug!("ExTerminateThread: exit_status={:#x}", ctx.gpr[3]); ctx.gpr[3] = 0; } @@ -375,38 +496,51 @@ fn ke_query_base_priority_thread( ctx.gpr[3] = pri as u32 as u64; } -/// `KeSetIdealProcessor(thread_handle, proc_number) -> u8 old_ideal` — -/// Axis 5. Stores the hint on the `GuestThread` for future spawn-sibling -/// placement; does NOT migrate a live thread (use `KeSetAffinityThread` -/// for that). -fn ke_set_ideal_processor( +/// Phase C+6½ hallucination fix: ord 0x82 maps to `KeQueryInterruptTime` +/// in canary's `xboxkrnl_table.inc:130`, with a `DECLARE_XBOXKRNL_EXPORT` +/// shim in `xboxkrnl_misc.cc:119-127`. Ours previously mis-labeled this +/// ord as `KeQueryIdealProcessor` (a real NT function, but at a different +/// position on Xbox 360 — not at 0x82). The hallucinated body returned +/// the calling thread's `ideal_processor` byte; guests calling +/// `KeQueryInterruptTime` to read the system interrupt-time counter were +/// receiving a 1-byte processor index instead. +/// +/// Canary returns `bundle->interrupt_time` (u64) — the monotonic system +/// interrupt-time counter maintained by the kernel timer ISR. Ours has +/// no `X_TIME_STAMP_BUNDLE` infrastructure, so we mirror the +/// `KeQuerySystemTime` approach: return a fixed synthetic value that +/// gives a plausible monotonic-looking u64. Determinism per `KernelState` +/// requires this be reproducible — a constant satisfies both. +fn ke_query_interrupt_time( ctx: &mut PpcContext, _mem: &GuestMemory, - state: &mut KernelState, + _state: &mut KernelState, ) { - let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); - let ideal = ctx.gpr[4] as u8; - let prev = state - .scheduler - .find_by_handle(handle) - .map(|r| state.scheduler.set_ideal_ref(r, ideal)) - .unwrap_or(0xFF); - ctx.gpr[3] = prev as u64; + // Synthetic interrupt-time count. Units are 100ns ticks since boot; + // value chosen large enough to look post-boot but small enough that + // any timer-arithmetic stays in u32 range when masked. Matches the + // determinism pattern used by `ke_query_system_time` above. + const FAKE_INTERRUPT_TIME: u64 = 0x0000_0001_0000_0000; + ctx.gpr[3] = FAKE_INTERRUPT_TIME; } -fn ke_query_ideal_processor( - ctx: &mut PpcContext, - _mem: &GuestMemory, - state: &mut KernelState, -) { - let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); - let ideal = state - .scheduler - .find_by_handle(handle) - .and_then(|r| state.scheduler.ideal_ref(r)) - .unwrap_or(0); - ctx.gpr[3] = ideal as u64; -} +/// Phase C+6½ hallucination fix: ord 0x98 maps to +/// `KeSetBackgroundProcessors` in canary's `xboxkrnl_table.inc:166`. +/// Canary has NO `DECLARE_XBOXKRNL_EXPORT` shim for this name — it's a +/// table-entry-only export, routed through the syscall thunk +/// (`xex_module.cc:1310-1335`) which is a no-op. Ours previously +/// mis-labeled this ord as `KeSetIdealProcessor` (a real NT function but +/// at a different position on Xbox 360) and the hallucinated body wrote +/// to `GuestThread::ideal_processor` — a state mutation under the wrong +/// semantic name. Guests calling `KeSetBackgroundProcessors` to mask off +/// CPUs for background work were instead pinning the thread's ideal +/// processor hint. +/// +/// Replaced with a no-op (`stub_success`) registered via +/// `register_unimplemented_export` so the Phase A emitter stays silent +/// (matching canary's syscall-thunk path). The underlying +/// `Scheduler::set_ideal_ref`/`ideal_ref` methods remain available for +/// `NtSetInformationThread` info-class `ThreadIdealProcessor`. /// `NtSetInformationThread(handle, info_class, info_ptr, info_len)` — /// minimal Axis 5 wiring for priority / affinity / ideal-processor @@ -453,18 +587,33 @@ fn nt_set_information_thread( } } -/// `KeSetAffinityThread(thread_handle, new_mask) -> old_mask` — Axis 4. -/// Drives `KernelState::set_affinity` which delegates to the scheduler -/// and then fixes up every outstanding `ThreadRef` held in waiter lists. +/// `KeSetAffinityThread(thread_ptr, affinity, prev_affinity_ptr)` — Axis 4. +/// Mirrors xenia-canary `KeSetAffinityThread_entry` +/// (xboxkrnl_threading.cc:323-346): returns `X_STATUS_SUCCESS` (0) in r3 +/// and writes the previous affinity to `*prev_affinity_ptr` (r5) when +/// non-NULL. Validates `affinity != 0` (else `X_STATUS_INVALID_PARAMETER`) +/// and that the thread handle resolves (else `X_STATUS_INVALID_HANDLE`). +/// +/// Stage 2 Batch 3 fix (2026-05-14): pre-fix, ours returned `old_mask` in +/// r3 with no OUT-pointer write — guest code expecting `STATUS_SUCCESS` +/// in r3 was reading a small bitmask as an NTSTATUS. fn ke_set_affinity_thread( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { - let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let new_mask = (ctx.gpr[4] as u32) as u8; + let prev_ptr = ctx.gpr[5] as u32; + if new_mask == 0 { + ctx.gpr[3] = 0xC000_000D; // X_STATUS_INVALID_PARAMETER + return; + } + let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let old = state.set_affinity(handle, new_mask, mem); - ctx.gpr[3] = old as u64; + if prev_ptr != 0 { + mem.write_u32(prev_ptr, old as u32); + } + ctx.gpr[3] = 0; // X_STATUS_SUCCESS } fn ke_bug_check(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { @@ -495,6 +644,49 @@ fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut Ke } } +/// Offset of `current_irql` (u8) within PCR. Mirrors xenia-canary's +/// `X_KPCR.current_irql` at offset 0x18 (xthread.h:189). PCR base is in +/// `ctx.gpr[13]` per scheduler setup. +const PCR_CURRENT_IRQL_OFFSET: u32 = 0x18; + +/// Mirrors xenia-canary `KeRaiseIrqlToDpcLevel_entry` +/// (xboxkrnl_threading.cc:1253-1264): reads PCR's `current_irql`, +/// returns the old value in r3, writes `DISPATCH_LEVEL` (2) back. +fn ke_raise_irql_to_dpc_level( + ctx: &mut PpcContext, + mem: &GuestMemory, + _state: &mut KernelState, +) { + let pcr = ctx.gpr[13] as u32; + let old_irql = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET)); + if old_irql > 2 { + tracing::warn!( + old_irql = old_irql, + "KeRaiseIrqlToDpcLevel: old_irql > 2 (DISPATCH_LEVEL)" + ); + } + mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), 2); + ctx.gpr[3] = old_irql as u64; +} + +/// Mirrors xenia-canary `KfLowerIrql_entry` +/// (xboxkrnl_threading.cc:1280-1282 calling `xeKfLowerIrql`): writes +/// `new_irql` (r3) to PCR's `current_irql`. Void return (registered via +/// `register_void_export`). +fn kf_lower_irql(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { + let new_irql = (ctx.gpr[3] as u32) as u8; + let pcr = ctx.gpr[13] as u32; + let current = mem.read_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET)); + if new_irql > current { + tracing::warn!( + new_irql = new_irql, + current = current, + "KfLowerIrql: new_irql > current_irql" + ); + } + mem.write_u8(pcr.wrapping_add(PCR_CURRENT_IRQL_OFFSET), new_irql); +} + fn ke_initialize_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = PKSEMAPHORE, r4 = initial count, r5 = limit. // Mirrors xenia-canary KeInitializeSemaphore_entry @@ -592,8 +784,102 @@ fn ke_tls_set_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel ctx.gpr[3] = 1; // TRUE } -fn ex_get_xconfig_setting(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { - ctx.gpr[3] = 0; // STATUS_SUCCESS (writes nothing) +/// Mirrors xenia-canary `ExGetXConfigSetting_entry` + `xeExGetXConfigSetting` +/// (xboxkrnl_xconfig.cc:303-319 calling :65-302). Returns a small value +/// describing one of the Xbox 360's `XCONFIG_*` settings. +/// +/// Stage 2 Batch 6 (2026-05-14): pre-fix returned STATUS_SUCCESS with no +/// buffer write — game saw uninitialized buffer data. We implement the +/// most commonly queried (category, setting) pairs as constants matching +/// canary's defaults. Unknown pairs return `STATUS_INVALID_PARAMETER_2`. +fn ex_get_xconfig_setting(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { + let category = (ctx.gpr[3] as u32) & 0xFFFF; + let setting = (ctx.gpr[4] as u32) & 0xFFFF; + let buffer_ptr = ctx.gpr[5] as u32; + let buffer_size = (ctx.gpr[6] as u32) & 0xFFFF; + let required_size_ptr = ctx.gpr[7] as u32; + + // Per-setting value encoded as big-endian bytes (canary uses + // `xe::store_and_swap`; 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 +1016,34 @@ const STATUS_SEMAPHORE_LIMIT_EXCEEDED: u64 = 0xC000_0047; const STATUS_UNSUCCESSFUL: u64 = 0xC000_0001; const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003; const STATUS_INFO_LENGTH_MISMATCH: u64 = 0xC000_0004; +const STATUS_OBJECT_NAME_INVALID: u64 = 0xC000_0033; +const STATUS_ACCESS_DENIED: u64 = 0xC000_0022; +// Phase C+11 — canary's `NtQueryFullAttributesFile_entry` returns +// `STATUS_NO_SUCH_FILE` (0xC000000F) on resolve-miss, not +// `STATUS_OBJECT_NAME_NOT_FOUND` (0xC0000034). Both are negative NTSTATUS +// values; Sylpheed treats them equivalently at the call site, but the +// Phase A diff compares return values byte-exact, so the codes must +// match. +const STATUS_NO_SUCH_FILE: u64 = 0xC000_000F; +/// Phase C+5 — canary's `NtWriteFile_entry` +/// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) returns +/// this NT-style status code when the underlying `XFile::is_synchronous_` +/// is false (i.e. the file was opened without `FILE_SYNCHRONOUS_IO_ALERT` +/// or `FILE_SYNCHRONOUS_IO_NONALERT`). The write itself still completes +/// synchronously and the IO_STATUS_BLOCK still records STATUS_SUCCESS; +/// only the function return value flips. Real NT uses STATUS_PENDING here +/// as a "the caller may now wait on the event" convention. +const STATUS_PENDING: u64 = 0x0000_0103; + +/// `CreateOptions` bits we care about for is-synchronous tracking +/// (canary's `CreateOptions::FILE_SYNCHRONOUS_IO_ALERT` / +/// `CreateOptions::FILE_SYNCHRONOUS_IO_NONALERT` in xboxkrnl_io.cc:32-33). +/// `NtOpenFile` forwards the same options dword through its `open_options` +/// argument, so this bitmask applies to both paths. +const FILE_SYNCHRONOUS_IO_ALERT: u32 = 0x0000_0010; +const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; +const FILE_SYNCHRONOUS_IO_MASK: u32 = + FILE_SYNCHRONOUS_IO_ALERT | FILE_SYNCHRONOUS_IO_NONALERT; /// `X_ERROR_NOT_FOUND` from xenia-canary `xenia/xbox.h`. Returned by /// `XexGetModuleHandle` for unknown module names. const X_ERROR_NOT_FOUND: u64 = 0x0000_048B; @@ -737,6 +1051,17 @@ const X_ERROR_NOT_FOUND: u64 = 0x0000_048B; /// A sentinel byte-offset value meaning "read at current file position". const FILE_USE_FILE_POINTER_POSITION: u64 = 0xFFFF_FFFF_FFFF_FFFE; +/// Phase C+5 — register `handle` in `state.async_file_handles` iff the +/// caller did NOT request synchronous IO (mirrors canary's +/// `XFile::is_synchronous_` derivation in xboxkrnl_io.cc:94-97). Subsequent +/// `nt_write_file` returns flip from `STATUS_SUCCESS` to `STATUS_PENDING` +/// for async-opened files only. +fn maybe_mark_async_file(state: &mut KernelState, handle: u32, create_options: u32) { + if (create_options & FILE_SYNCHRONOUS_IO_MASK) == 0 { + state.async_file_handles.insert(handle); + } +} + /// Write an `IO_STATUS_BLOCK { status, information }` if the pointer is non-null. fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information: u32) { if ptr == 0 { @@ -793,32 +1118,96 @@ fn open_cache_file( // `cache:\d4ea4615` which then blocked subsequent hierarchical // creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION). const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0; - - // Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the - // cache root directory itself. Mirror canary's HostPathDevice.Open - // which returns a directory handle (success, attributes = DIR). - // Empty `path.file_name()` after our resolve_cache_path strip means - // the guest asked for the mount root. - let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path) - || host_path.is_dir() - || want_dir; + let want_non_dir = (create_options & FILE_NON_DIRECTORY_FILE) != 0; + + // Phase C+11 — when the host path already exists, its actual on-disk + // type wins over the guest's `FILE_DIRECTORY_FILE` bit. Mirrors + // canary's `VirtualFileSystem::OpenFile` which routes to the existing + // entry's device-specific open without re-checking the bit. Sylpheed + // sets `FILE_DIRECTORY_FILE` on `NtOpenFile cache:\

.tmp` + // re-opens (the `.tmp` was already a file from a prior FILE_CREATE), + // which under the AUDIT-054 logic mis-routed to the directory branch + // and dropped `host_path` — blocking the subsequent class-10 rename + // with `STATUS_ACCESS_DENIED`. Also resolves Phase C+11's bug #2: + // `cache:\access`/`ignore`/`recent` end up as files on cold creation + // because `want_non_dir` (FILE_NON_DIRECTORY_FILE bit 0x40) takes + // precedence when set, even with FILE_DIRECTORY_FILE. + // + // Resolution order (mirrors canary): + // 1. Existing host entry: actual type wins (file ↔ dir). + // 2. `want_non_dir` set → file path (NON_DIRECTORY_FILE overrides). + // 3. `want_dir` set → directory path. + // 4. Default → file path. + // + // Root-of-mount case is captured by the existing-dir branch: the + // cache root always exists as a directory, so `host_path.is_dir()` + // is true. + let host_exists_as_dir = host_path.is_dir(); + let host_exists_as_file = host_path.is_file(); + let is_dir_open = host_exists_as_dir + || (!host_exists_as_file && !want_non_dir && want_dir); if is_dir_open { - // For non-existent paths the guest wants us to create as a - // directory, mkdir-p; canary's HostPathDevice does the same - // when FILE_DIRECTORY_FILE is set on a kCreate disposition. - if want_dir && !host_path.exists() { - if let Err(e) = std::fs::create_dir_all(host_path) { - tracing::warn!( - "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL", - host_path, - e - ); + // Phase C+11.1 — only create the host directory when the + // disposition is *create-capable*. Mirrors canary's + // `VirtualFileSystem::OpenFile` (virtual_file_system.cc:265-273): + // for `FileDisposition::kOpen`/`kOverwrite` on a non-existent + // path the function returns `X_STATUS_OBJECT_NAME_NOT_FOUND` + // *before* any `CreatePath` call — i.e. mkdir is never invoked + // on these dispositions. The pre-fix code (Phase C+11) called + // `create_dir_all` whenever `want_dir && !host_path.exists()`, + // so Sylpheed's cold-boot probes for `cache:/access`, + // `cache:/ignore`, `cache:/recent` (disp=1, opts=0x7) succeeded + // and produced spurious host directories. Canary instead + // returns NOT_FOUND, after which Sylpheed re-creates these as + // FILES via `disp=5` + `FILE_NON_DIRECTORY_FILE`. + // + // Create-capable dispositions (mkdir OK): + // 0 FILE_SUPERSEDE + // 2 FILE_CREATE + // 3 FILE_OPEN_IF + // 5 FILE_OVERWRITE_IF + // Non-create dispositions (must miss when path is absent): + // 1 FILE_OPEN + // 4 FILE_OVERWRITE + let disp_is_create_capable = matches!( + create_disposition, + FILE_SUPERSEDE | FILE_CREATE | FILE_OPEN_IF | FILE_OVERWRITE_IF + ); + if !host_path.exists() { + if !disp_is_create_capable { if handle_out != 0 { mem.write_u32(handle_out, 0); } - write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); - return STATUS_UNSUCCESSFUL; + write_io_status_block( + mem, + io_status_block, + STATUS_OBJECT_NAME_NOT_FOUND as u32, + 0, + ); + tracing::info!( + "cache open (dir) MISS path={:?} disp={} opts={:#x} -> NOT_FOUND", + guest_path, + create_disposition, + create_options + ); + return STATUS_OBJECT_NAME_NOT_FOUND; + } + // create-capable + want_dir → mkdir-p the directory. + if want_dir { + if let Err(e) = std::fs::create_dir_all(host_path) { + tracing::warn!( + "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL", + host_path, + e + ); + if handle_out != 0 { + mem.write_u32(handle_out, 0); + } + write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); + return STATUS_UNSUCCESSFUL; + } } } // Stored path ends with '/' so nt_query_information_file's @@ -828,6 +1217,10 @@ fn open_cache_file( } else { format!("{}/", guest_path) }; + // Phase C+12 — register / refresh directory entry mirror. + if let Ok(md) = host_path.metadata() { + state.register_cache_entry(guest_path, &md); + } let handle = state.alloc_handle_for(KernelObject::File { path: dir_path, size: 0, @@ -836,6 +1229,7 @@ fn open_cache_file( dir_enum_pos: None, host_path: None, }); + maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } @@ -918,10 +1312,16 @@ fn open_cache_file( return STATUS_UNSUCCESSFUL; } } - let size = host_path - .metadata() - .map(|m| m.len()) - .unwrap_or(0); + let metadata = host_path.metadata().ok(); + let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); + // Phase C+12 — register / refresh the in-memory entry mirror so + // subsequent `NtQueryFullAttributesFile` probes for this path + // resolve without re-stating the host FS (parity with canary's + // `Entry::CreateEntry`, + // `xenia-canary/src/xenia/vfs/entry.cc:88-104`). + if let Some(md) = metadata.as_ref() { + state.register_cache_entry(guest_path, md); + } let handle = state.alloc_handle_for(KernelObject::File { path: guest_path.to_string(), size, @@ -931,6 +1331,7 @@ fn open_cache_file( dir_enum_pos: None, host_path: Some(host_path.to_path_buf()), }); + maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } @@ -950,6 +1351,32 @@ fn open_cache_file( /// AUDIT-038 — additional NTSTATUS used by the cache-backed open path. const STATUS_OBJECT_NAME_COLLISION: u64 = 0xC000_0035; +/// Phase C+13 — does `raw_path` start with a prefix that aliases the +/// (read-only) game disc? Used to scope the synth-empty fallback in +/// `open_vfs_file`: missing disc files report `STATUS_OBJECT_NAME_NOT_FOUND` +/// (matching canary's `NtCreateFile_entry` for game-data lookups), while +/// missing writable-partition paths keep the legacy zero-byte synth. +/// +/// Mirrors the disc-mapped subset of `crate::path::DEVICE_PREFIXES`: +/// - `game:\` — canary's symbolic-link alias for the disc +/// (xenia-canary/src/xenia/kernel/kernel_state.cc registrations). +/// - `d:\` / `D:\` — drive-letter alias for the disc. +/// - `\Device\Cdrom0\` — NT device path for the disc. +/// +/// Compares case-insensitively to match canary's path resolver. +fn is_disc_prefix(raw_path: &str) -> bool { + let lowered = raw_path.trim_start().to_ascii_lowercase(); + const DISC_PREFIXES: &[&str] = &[ + "game:\\", + "game:/", + "d:\\", + "d:/", + "\\device\\cdrom0\\", + "\\device\\cdrom0/", + ]; + DISC_PREFIXES.iter().any(|p| lowered.starts_with(p)) +} + /// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the /// create/open distinction only matters for writable volumes (cache:/), /// which we now back with a host directory (audit-038). The disc image @@ -980,6 +1407,17 @@ fn open_vfs_file( // see a null handle later and trigger `XamShowDirtyDiscErrorUI`. let path = crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr) .unwrap_or_default(); + // Phase C+13 — recover the raw (un-stripped) path so we can tell a + // disc-aliased prefix (`game:\`, `d:\`, `\Device\Cdrom0\`) apart from a + // writable-partition prefix (`\Device\Harddisk0\…`, `\??\`, raw "no + // prefix" cases). The synth-empty fallback below covers both today but + // canary's `NtCreateFile_entry` (xboxkrnl_io.cc:83-110) returns the + // VFS lookup status verbatim, which is `STATUS_OBJECT_NAME_NOT_FOUND` + // for any disc path that isn't in the ISO. Scoping the synth to + // non-disc prefixes makes us match canary's behaviour for missing + // game-data files (e.g. `game:\dat\files.tbl` at Phase C+13 idx 103862). + let raw_path = crate::path::object_attributes_raw_name(mem, obj_attrs_ptr) + .unwrap_or_default(); if path.is_empty() && obj_attrs_ptr == 0 { if handle_out != 0 { mem.write_u32(handle_out, 0); @@ -1004,6 +1442,7 @@ fn open_vfs_file( dir_enum_pos: None, host_path: None, }); + maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } @@ -1047,6 +1486,7 @@ fn open_vfs_file( dir_enum_pos: None, host_path: None, }); + maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } @@ -1055,28 +1495,43 @@ fn open_vfs_file( STATUS_SUCCESS } Err(e) => { - // When the VFS can't resolve a path we synthesize a zero-byte - // virtual file rather than returning NOT_FOUND. Two rationales: - // - // 1. **Writable system partitions** (`cache:/`, `cache0:`, - // `cache1:`, `partition0:`, `partition1:`) aren't backed by - // the disc — Canary mounts them on host directories - // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)). - // We skip the host mount for now, so opens there always miss - // without this fallback. + // Phase C+13 — scope the synth-empty fallback to non-disc + // prefixes only. Canary's `NtCreateFile_entry` returns the VFS + // result verbatim (xboxkrnl_io.cc:83-110); for a missing disc + // file like `game:\dat\files.tbl` that's + // `STATUS_OBJECT_NAME_NOT_FOUND`. Sylpheed handles NOT_FOUND + // cleanly (next event in canary's trace at idx 103862 is + // `RtlNtStatusToDosError(0xc0000034) -> 2`, then the boot + // validator continues), so the synth was masking the + // correct branch. // - // 2. **Disc files that didn't make it into the ISO rip** (e.g., - // Sylpheed's `dat/files.tbl`, which the retail disc shipped - // but our dump doesn't contain). Returning NOT_FOUND makes - // Sylpheed's boot validator call `XamShowDirtyDiscErrorUI` - // → dashboard exit; see Canary's `XamShowDirtyDiscErrorUI` - // at xam_ui.cc:562 for the "bad or unimplemented file IO - // calls" framing. - // - // A zero-byte file lets the game's existence probe succeed, its - // read return EOF, and its "is the content here" sanity checks - // pass. If the game actually needs the bytes for gameplay we'll - // see a fresh failure downstream and can decide what to stub next. + // Synth-empty is still kept for writable system partitions + // (`\Device\Harddisk0\…`, `\Device\Mass*`, `\??\`, raw paths) + // because those aren't backed by the disc — Canary mounts + // them on host directories + // ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)); + // ours skips the host mount for those and falls back to the + // legacy stub to avoid regressing audit-006 / audit-018 + // disc-validation probes. `cache:/` was already routed to + // `open_cache_file` upstream of this branch (AUDIT-038). + if is_disc_prefix(&raw_path) { + if handle_out != 0 { + mem.write_u32(handle_out, 0); + } + write_io_status_block( + mem, + io_status_block, + STATUS_OBJECT_NAME_NOT_FOUND as u32, + 0, + ); + tracing::info!( + "Disc path missing: raw={:?} norm={:?} err={} -> NOT_FOUND", + raw_path, + path, + e + ); + return STATUS_OBJECT_NAME_NOT_FOUND; + } let handle = state.alloc_handle_for(KernelObject::File { path: path.clone(), size: 0, @@ -1085,6 +1540,7 @@ fn open_vfs_file( dir_enum_pos: None, host_path: None, }); + maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } @@ -1122,16 +1578,26 @@ fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelSta } fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { - // r3 = handle_out, r4 = desired_access, r5 = obj_attrs, - // r6 = io_status_block, r7 = share_access, r8 = open_options. - // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist. - // Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options` + // Phase C+5 — canary `NtOpenFile_entry` + // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:114-122) has + // FIVE args: (handle_out, desired_access, object_attributes, + // io_status_block, open_options). Per Xenia's shim_utils LoadValue + // (util/shim_utils.h:158-167), the 5th dword arg arrives in r7. Ours + // previously read r8 — the bit 0x01 (FILE_DIRECTORY_FILE) check still + // happened to pass because the game also left bit 0x01 set in r8 for + // dir opens (AUDIT-054 enabling condition), but the + // FILE_SYNCHRONOUS_IO_NONALERT bit (0x20) was wrongly set in r8 for + // device opens, making every file appear synchronous and causing the + // Phase C+5 NtWriteFile divergence at idx=102068 + // (canary=STATUS_PENDING / ours=STATUS_SUCCESS). + // + // Per xboxkrnl_io.cc:118-122, NtOpenFile forwards `open_options` // straight into NtCreateFile's `create_options` slot, so the - // FILE_DIRECTORY_FILE bit applies the same way. + // FILE_DIRECTORY_FILE bit + sync bits apply the same way. let handle_out = ctx.gpr[3] as u32; let obj_attrs_ptr = ctx.gpr[5] as u32; let io_status_block = ctx.gpr[6] as u32; - let open_options = ctx.gpr[8] as u32; + let open_options = ctx.gpr[7] as u32; ctx.gpr[3] = open_vfs_file( mem, state, @@ -1171,8 +1637,10 @@ fn signal_io_completion_event(state: &mut KernelState, event_handle: u32) { fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx, // r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr - let handle = ctx.gpr[3] as u32; - let event_handle = ctx.gpr[4] as u32; + // Phase C+19: canonicalize dup ids → source so file/event lookups + // hit the canonical `state.objects` slot. + let handle = state.resolve_handle(ctx.gpr[3] as u32); + let event_handle = state.resolve_handle(ctx.gpr[4] as u32); let io_status_block = ctx.gpr[7] as u32; let buffer = ctx.gpr[8] as u32; let length = ctx.gpr[9] as u32; @@ -1293,8 +1761,9 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat // r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr. // For cache:/* (host_path Some) writes go to disk; everything else // is still discarded (matches legacy read-only behaviour for game:/). - let handle = ctx.gpr[3] as u32; - let event_handle = ctx.gpr[4] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); + let event_handle = state.resolve_handle(ctx.gpr[4] as u32); let io_status_block = ctx.gpr[7] as u32; let buffer = ctx.gpr[8] as u32; let length = ctx.gpr[9] as u32; @@ -1320,6 +1789,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat *position }; + let mut wrote_ok = false; if let Some(hp) = host_path.clone() { use std::io::{Seek, SeekFrom, Write}; let mut buf = vec![0u8; length as usize]; @@ -1341,6 +1811,7 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat *size = live_size; write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length); ctx.gpr[3] = STATUS_SUCCESS; + wrote_ok = true; tracing::info!( "NtWriteFile cache: {} bytes to {:?} @ {} (handle={:#x})", length, path, start_pos, handle @@ -1356,6 +1827,19 @@ fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelStat // Legacy: discard but report full-length-written so caller proceeds. write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length); ctx.gpr[3] = STATUS_SUCCESS; + wrote_ok = true; + } + // Phase C+5 — canary `NtWriteFile_entry` + // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353) flips + // the function return value to `STATUS_PENDING` after the synchronous + // write completes when the underlying `XFile::is_synchronous_` is + // false. The IO_STATUS_BLOCK already stores STATUS_SUCCESS above; only + // the r3 return changes. Mirroring this here closes the + // `tid_event_idx=102068` divergence (canary=0x103 / ours=0) on the + // main thread without touching `NtReadFile` / `NtReadFileScatter` + // (scoped to one divergence per Phase C session, per project plan). + if wrote_ok && state.async_file_handles.contains(&handle) { + ctx.gpr[3] = STATUS_PENDING; } signal_io_completion_event(state, event_handle); } @@ -1373,7 +1857,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D; const CACHE_SIZE: u64 = 0xFF000; - let event_handle = ctx.gpr[4] as u32; + // Phase C+19: canonicalize dup ids → source. + let event_handle = state.resolve_handle(ctx.gpr[4] as u32); let io_status_block = ctx.gpr[7] as u32; let io_control_code = ctx.gpr[8] as u32; let sp = ctx.gpr[1] as u32; @@ -1423,7 +1908,8 @@ fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu /// (14). Anything else gets zeros + success. fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = io_status_block, r5 = file_info, r6 = length, r7 = class - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); let io_status_block = ctx.gpr[4] as u32; let file_info = ctx.gpr[5] as u32; let length = ctx.gpr[6] as u32; @@ -1517,6 +2003,123 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu ctx.gpr[3] = STATUS_SUCCESS; } +/// Phase C+11 — XFileRenameInformation (class 10) body. Mirrors canary +/// `xboxkrnl_io_info.cc:226-243` `file->Rename(target_path)`. Sylpheed's +/// cache-build path writes `cache:\

.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,16 +2127,17 @@ fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mu /// Validates `info_class` (must have a defined minimum size) and /// `info_length` (must meet that minimum); returns /// `STATUS_INVALID_INFO_CLASS` / `STATUS_INFO_LENGTH_MISMATCH` in those -/// cases. The only class with real side-effects in xenia-rs is -/// `XFilePositionInformation` (14) — seek updates the file's cursor. -/// Read-only VFS means `XFileEndOfFileInformation` (20, truncate) can -/// only succeed if the new length equals the current size, otherwise -/// returns `STATUS_UNSUCCESSFUL`. Other classes acknowledge the write -/// but have no backing store. +/// cases. Side-effect classes: +/// * `XFileRenameInformation` (10) — rename a cache:-backed handle. +/// * `XFilePositionInformation` (14) — seek updates the file's cursor. +/// * `XFileEndOfFileInformation` (20) — truncate (cache: only; disc-VFS +/// rejects non-identity truncates with `STATUS_UNSUCCESSFUL`). +/// Other classes acknowledge the write but have no backing store. fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = io_status_block, r5 = info_ptr, // r6 = info_length, r7 = info_class. - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); let iosb_ptr = ctx.gpr[4] as u32; let info_ptr = ctx.gpr[5] as u32; let info_length = ctx.gpr[6] as u32; @@ -1562,6 +2166,21 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut return; } + // Phase C+11 — class 10 (`XFileRenameInformation`) needs both a + // read of `state.cache_root` (via `resolve_cache_path`) AND a mutable + // borrow of the target file handle. Rust's borrow checker can't see + // through `&self.method()` calls, so split it out before the shared + // `get_mut` destructure below. + if info_class == 10 { + let (status, out_length) = + handle_set_info_rename(mem, state, handle, info_ptr, info_length); + if iosb_ptr != 0 { + write_io_status_block(mem, iosb_ptr, status as u32, out_length); + } + ctx.gpr[3] = status; + return; + } + // Handle lookup. let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else { ctx.gpr[3] = STATUS_INVALID_HANDLE; @@ -1634,6 +2253,48 @@ fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut ctx.gpr[3] = status; } +/// Phase C+12 — write the 56-byte `X_FILE_NETWORK_OPEN_INFORMATION` +/// (`xenia-canary/src/xenia/kernel/info/file.h:117-127`) at `out` from +/// the entry's metadata. All multibyte fields are stored big-endian +/// (`be` / `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 +2308,41 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: } }; - // AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly - // so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`) - // see real attributes for files we just created and miss for files we - // haven't. - if let Some(hp) = state.resolve_cache_path(&path) { - let entry = std::fs::metadata(&hp); - match entry { - Ok(md) => { - let filetime: u64 = 132_500_000_000_000_000; - if out != 0 { - for off in (0..32).step_by(4) { - mem.write_u32(out + off, if off & 4 == 0 { - (filetime >> 32) as u32 - } else { - filetime as u32 - }); - } - mem.write_u64(out + 32, md.len()); - mem.write_u64(out + 40, md.len()); - let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 }; - mem.write_u32(out + 48, attrs); - mem.write_u32(out + 52, 0); + // Phase C+12 — `cache:*` paths consult the in-memory entry mirror + // first, mirroring canary's `NtQueryFullAttributesFile_entry` which + // walks the in-memory entry tree via `VirtualFileSystem::ResolvePath` + // and never re-stats the host + // (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:498-512`). + // + // The entry tree is seeded at mount time by + // `populate_cache_entries_from_host` (mirrors canary's eager + // `HostPathDevice::PopulateEntry`) and refreshed per-NtCreateFile + // by `register_cache_entry` (mirrors canary's `Entry::CreateEntry`). + // A second-line host-FS fallback handles the rare case where the + // entry tree lost track but the host file is present (defensive; + // canary returns NO_SUCH_FILE in that case so we keep this fallback + // narrow). + if path.to_ascii_lowercase().starts_with("cache:") { + if let Some(meta) = state.lookup_cache_entry(&path) { + write_file_network_open_information(mem, out, meta); + ctx.gpr[3] = STATUS_SUCCESS; + return; + } + // Host-FS defensive fallback — only fires when the in-memory + // tree missed but the file is on disk. Refreshes the tree as a + // side-effect so subsequent probes hit the fast path. + if let Some(hp) = state.resolve_cache_path(&path) { + if let Ok(md) = std::fs::metadata(&hp) { + state.register_cache_entry(&path, &md); + if let Some(meta) = state.lookup_cache_entry(&path) { + write_file_network_open_information(mem, out, meta); + ctx.gpr[3] = STATUS_SUCCESS; + return; } - ctx.gpr[3] = STATUS_SUCCESS; - return; - } - Err(_) => { - ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; - return; } } + ctx.gpr[3] = STATUS_NO_SUCH_FILE; + return; } let Some(vfs) = state.vfs.as_ref() else { @@ -1687,24 +2352,23 @@ fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: match vfs.stat(&path) { Ok(entry) => { - // FILE_NETWORK_OPEN_INFORMATION (56 bytes): 4 × FILETIME, - // AllocationSize(i64), EndOfFile(i64), FileAttributes(u32), pad(u32) - let filetime: u64 = 132_500_000_000_000_000; - if out != 0 { - mem.write_u32(out, (filetime >> 32) as u32); - mem.write_u32(out + 4, filetime as u32); - mem.write_u32(out + 8, (filetime >> 32) as u32); - mem.write_u32(out + 12, filetime as u32); - mem.write_u32(out + 16, (filetime >> 32) as u32); - mem.write_u32(out + 20, filetime as u32); - mem.write_u32(out + 24, (filetime >> 32) as u32); - mem.write_u32(out + 28, filetime as u32); - mem.write_u64(out + 32, entry.size); - mem.write_u64(out + 40, entry.size); - let attrs: u32 = if entry.is_directory { 0x10 } else { 0x80 }; - mem.write_u32(out + 48, attrs); - mem.write_u32(out + 52, 0); - } + let meta = crate::state::CacheEntryMeta { + is_directory: entry.is_directory, + size: entry.size, + // Disc/VFS entries have no host metadata; use the same + // 4 KiB alignment canary derives from + // `device->bytes_per_sector()`. Disc devices default + // to 2048 in canary + // (`xenia-canary/src/xenia/vfs/devices/disc_image_device.cc`) + // but for the existence-probe consumers we hit on + // Sylpheed boot the exact alignment doesn't matter — + // they only branch on the SUCCESS/NOT_FOUND status. + allocation_size: (entry.size + 2047) & !2047, + create_time: 0, + access_time: 0, + write_time: 0, + }; + write_file_network_open_information(mem, out, &meta); ctx.gpr[3] = STATUS_SUCCESS; } Err(_) => { @@ -1759,8 +2423,9 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut // r3=file_handle, r4=event_handle, r5=apc_routine, r6=apc_context, // r7=io_status_block, r8=file_info_ptr, r9=length, r10=file_name, // sp+... = restart_scan. - let handle = ctx.gpr[3] as u32; - let event_handle = ctx.gpr[4] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); + let event_handle = state.resolve_handle(ctx.gpr[4] as u32); let iosb_ptr = ctx.gpr[7] as u32; let info_ptr = ctx.gpr[8] as u32; let length = ctx.gpr[9] as u32; @@ -1916,15 +2581,31 @@ fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let handle = ctx.gpr[3] as u32; - // Aliased refcount: `NtDuplicateObject` returns the *source* handle as the - // "new" handle (we don't mint fresh values), so the game commonly holds - // two logical references to the same handle value. Without refcount, the - // first `NtClose` wipes the object while the second reference is still - // live, which traps any later wait on that handle (Sylpheed's - // create→dup(SAME_ACCESS)→set→close pattern at 0x8246079c manifests this - // — main thread then parks forever on the closed handle). Mirror Canary's - // `ObjectTable::ReleaseHandle` (object_table.cc:189): decrement the - // per-handle refcount and only drop the object when it reaches zero. + close_handle_internal(state, handle); + ctx.gpr[3] = 0; +} + +/// Phase C+19: shared close path used by `nt_close`, +/// `nt_duplicate_object`'s `DUPLICATE_CLOSE_SOURCE` branch, and +/// `xam::xam_task_close_handle` (which canary defers to NtClose). +/// +/// Mirrors canary's `ObjectTable::ReleaseHandle` (object_table.cc:237-256): +/// decrement the slot's local refcount; on zero, emit `handle.destroy` for +/// the slot AND release the canonical kernel object — the canonical entry +/// (and its `KernelObject`) is removed only when `canonical_slot_count` +/// reaches zero (all dup siblings are gone). This preserves canary's +/// observable lifecycle: +/// +/// - Each `NtClose` of a slot with `handle_refcount==1` emits exactly one +/// `handle.destroy` event for that slot. +/// - The underlying object survives until the last slot closes; only then +/// are `state.objects`/`async_file_handles`/`pending_timer_fires` pruned. +pub(crate) fn close_handle_internal(state: &mut KernelState, handle: u32) { + let prior_rc = state + .handle_refcount + .get(&handle) + .copied() + .unwrap_or(0); let remaining = state .handle_refcount .get_mut(&handle) @@ -1934,14 +2615,51 @@ fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { }) .unwrap_or(0); if remaining == 0 { - state.objects.remove(&handle); state.handle_refcount.remove(&handle); - // If the object was an armed Timer, strip its pending-fire entry - // so a later scheduler round doesn't try to signal a dead handle. - // `disarm_timer` is a no-op for non-timer handles. - state.disarm_timer(handle); + + // Resolve the canonical id before we discard the alias entry — + // we need it to decrement the slot-count and possibly drop the + // backing object. + let canonical = state.resolve_handle(handle); + state.handle_aliases.remove(&handle); + + // Decrement the canonical's live-slot count. If this slot was the + // last one referring to the canonical, drop the underlying object. + let slots_left = match state.canonical_slot_count.get_mut(&canonical) { + Some(c) => { + *c = c.saturating_sub(1); + *c + } + None => 0, + }; + + if slots_left == 0 { + state.canonical_slot_count.remove(&canonical); + state.objects.remove(&canonical); + // Phase C+5 — prune the async-file side-table when the underlying + // handle is finally released. Mirrors the canary `XFile` dtor + // releasing `is_synchronous_`. No-op for non-file handles. + state.async_file_handles.remove(&canonical); + // If the object was an armed Timer, strip its pending-fire entry + // so a later scheduler round doesn't try to signal a dead handle. + // `disarm_timer` is a no-op for non-timer handles. + state.disarm_timer(canonical); + } + + // Phase C+15-α: schema-v1 `handle.destroy` event for the SLOT being + // closed (which is `handle`, not the canonical). Canary emits at + // `ObjectTable::RemoveHandle` (object_table.cc:294-296) per-slot, + // regardless of whether the underlying object still has sibling + // slots — so we match that. + if crate::event_log::is_enabled() { + let (tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + crate::event_log::emit_handle_destroy_auto(tid, cycle, handle, prior_rc); + } } - ctx.gpr[3] = 0; } fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { @@ -2186,6 +2904,139 @@ fn rtl_enter_critical_section( return; } let current_tid = ctx.thread_id; + + // Phase D Stage 3 — contention-replay manifest. When installed (via + // `XENIA_CONTENTION_MANIFEST_PATH`), the manifest tells us at which + // (tid, tid_event_idx) canary saw real contention on which CS. We + // peek the next per-tid ordinal, look up the manifest, and if it hits + // (with a matching cs_ptr) we emit a parity `contention.observed` + // event and force a park via the existing `cs_waiters` path. + // + // Wake comes when some other guest thread calls + // `RtlLeaveCriticalSection` on this CS naturally — the existing path + // at lines 2972-2980 handles the lock handoff. If no peer touches + // the CS, `Scheduler::unblock_on_deadlock` recovers with the existing + // CriticalSection-blocked wake at scheduler.rs:1208 (STATUS_TIMEOUT + // style — owner field will read 0 post-recovery, surfaceable as a + // downstream trace divergence rather than a silent hang). + // + // Default mode (no manifest installed): zero overhead, byte-identical + // to pre-Stage-3 behavior — `state.contention_manifest.as_ref()` + // short-circuits before peek_tid_idx. + // `consume_at_peek` translates ours's `peek_tid_idx` back to + // canary's idx space by subtracting the count of prior + // `contention.observed` emits on this tid (each emit shifts + // ours's per-tid idx by +1 relative to canary's stream). The + // bookkeeping is internal to the manifest; the caller just hands + // it the current peek value. + let manifest_hit = state + .contention_manifest + .as_ref() + .and_then(|m| { + let peek = crate::event_log::peek_tid_idx(current_tid); + m.consume_at_peek(current_tid, peek) + }); + if let Some(entry) = manifest_hit { + // Per-tid ordinal alignment with canary: ALWAYS emit + // `contention.observed` when the manifest fires, even if we end + // up not parking. Canary emits one here too (Stage 1) so + // consuming one per-tid idx slot on this side keeps the + // downstream events aligned. Stage 4 marks the kind + // engine-local in the diff tool, so the diff tool advances past + // these events on either side without comparison. + // + // We do NOT verify `entry.cs_ptr == cs_ptr` because canary and + // ours route guest-heap allocations to different VA regions + // (AUDIT-043 ε host-allocator divergence). Trust the + // `(tid, tid_event_idx)` alignment instead; if we got here, the + // manifest hit at the same per-tid call-site as canary's + // contention.observed. + let guest_cycle = ctx.cycle_count; + crate::event_log::emit_contention_observed( + current_tid, + guest_cycle, + cs_ptr, + true, + ); + if entry.cs_ptr != cs_ptr { + tracing::debug!( + "manifest cs_ptr cross-engine divergence at tid={} idx={}: manifest {:#010x}, ours {:#010x} (allocator ε)", + current_tid, + entry.tid_event_idx, + entry.cs_ptr, + cs_ptr, + ); + } + // Stage 3 aggressive mode: force-park even when CS is free in + // guest memory. The bet is that some other guest tid will + // naturally acquire+release this CS during ours's park window, + // triggering the natural wake at lines 2972-2980. If no peer + // touches the CS, `Scheduler::unblock_on_deadlock` recovers via + // its existing CriticalSection-blocked wake path (returning + // with owner=0 and any state divergence surfacing as a + // downstream trace mismatch rather than a silent hang). + // + // The conservative skip-when-free variant (the plan's "deadlock + // safe" branch) keeps the prefix at 104,607 because it doesn't + // actually shift behavior at the contention point. Aggressive + // mode tests whether driving the contention path is enough to + // advance past the cap. Gate via `XENIA_CONTENTION_AGGRESSIVE=1` + // so we can flip without rebuilding. + let aggressive = std::env::var("XENIA_CONTENTION_AGGRESSIVE") + .ok() + .is_some_and(|v| { + let v = v.trim().to_ascii_lowercase(); + v == "1" || v == "true" || v == "yes" + }); + let pre_owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD); + let pre_owner_live = pre_owner != 0 + && state.scheduler.find_by_tid(pre_owner).is_some(); + let natural_contention = pre_owner_live && pre_owner != current_tid; + if aggressive && !natural_contention { + // Synthesize a forced-park via the same path as the natural + // contention branch below: bump lock_count, push self onto + // cs_waiters, then park. Note: we set owning_thread to a + // SENTINEL (current_tid) so that re-entries by the same tid + // see "self owns it" and recursion paths work; the natural + // wake path will overwrite owning_thread when it transfers + // the lock. (NB: this is a hack; only enabled by an env-var + // gate so the conservative default stays deadlock-safe.) + let lc = mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32; + mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, (lc + 1) as u32); + let current_ref = state.scheduler.current_ref(); + state + .cs_waiters + .entry(cs_ptr) + .or_default() + .push(current_ref); + tracing::debug!( + "manifest AGGRESSIVE force-park: hw={} cs={:#010x} tid={} idx={} (owner was {})", + current_ref.hw_id, + cs_ptr, + current_tid, + entry.tid_event_idx, + pre_owner, + ); + ctx.gpr[3] = 0; + state + .scheduler + .park_current(BlockReason::CriticalSection(cs_ptr)); + return; + } + if !natural_contention { + tracing::debug!( + "manifest hit at tid={} idx={} cs={:#010x} but CS is free/self-owned (owner={}); replay skipped (state-divergence, not schedule-divergence)", + current_tid, + entry.tid_event_idx, + cs_ptr, + pre_owner, + ); + // Fall through to natural fast-path. + } + // If natural contention conditions ARE met, fall through to the + // existing park path below. + } + let owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD); // "Effective owner" — if the stored tid doesn't correspond to any live HW @@ -2382,10 +3233,79 @@ fn rtl_fill_memory_ulong(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut K } } -fn rtl_image_xex_header_field(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { - // r3 = xex_header_ptr, r4 = field_id - // Return 0 for all fields - ctx.gpr[3] = 0; +fn rtl_image_xex_header_field(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { + // r3 = xex_header_guest_ptr (may be NULL — game's CRT often passes 0 + // because ours's `*XexExecutableModuleHandle = image_base` doesn't + // resolve to a real LDR_DATA_TABLE_ENTRY, so its `*(hmodule + 0x58)` + // deref yields PE OptionalHeader bytes instead of a header pointer; + // those bytes fail the game's validation and the call goes through + // with ptr=NULL). When NULL, fall back to KernelState's recorded + // `xex_header_guest_ptr` (the guest-VA of the raw XEX header copy + // set up in `xenia-app::cmd_exec`'s Phase 3, mirroring canary's + // `user_module.cc:223-227` `guest_xex_header_`). + // r4 = field_key (xex2_header_keys). + // + // Mirror of canary's `xboxkrnl_rtl.cc:501-514` → + // `UserModule::GetOptHeader(memory, header, key, &field_value)` + // (`user_module.cc:335-369`). Iterates `header->headers[]` (flat + // array of (key:u32, value:u32) pairs, both BE), and for the first + // entry where `opt_header.key == key` returns one of: + // * key & 0xFF == 0x00 → `opt_header.value` (inline value). + // * key & 0xFF == 0x01 → guest VA of `opt_header.value` itself. + // * else → `header_base + opt_header.offset` + // i.e. guest VA inside the header of the referenced data block. + // Returns 0 if the resolved header pointer is NULL or the key is + // not found. + let mut xex_header_ptr = ctx.gpr[3] as u32; + let field_key = ctx.gpr[4] as u32; + if xex_header_ptr == 0 { + xex_header_ptr = state.xex_header_guest_ptr; + } + if xex_header_ptr == 0 { + ctx.gpr[3] = 0; + return; + } + // xex2_header layout (raw, BE; see xenia-canary `xex2_info.h`): + // +0x00 magic ("XEX2"), +0x04 module_flags, +0x08 header_size, + // +0x0C reserved, +0x10 security_offset, +0x14 header_count, + // +0x18.. array of (key:u32, value:u32) pairs. + let header_count = mem.read_u32(xex_header_ptr.wrapping_add(0x14)); + let entries_base = xex_header_ptr.wrapping_add(0x18); + let mut field_value: u32 = 0; + let mut found = false; + for i in 0..header_count { + let entry_addr = entries_base.wrapping_add(i.wrapping_mul(8)); + let entry_key = mem.read_u32(entry_addr); + if entry_key != field_key { + continue; + } + found = true; + let entry_value_addr = entry_addr.wrapping_add(4); + match entry_key & 0xFF { + 0x00 => { + // Inline value. + field_value = mem.read_u32(entry_value_addr); + } + 0x01 => { + // Pointer to the inline value slot itself. + field_value = entry_value_addr; + } + _ => { + // Offset within the header. `opt_header.value` here is the + // file offset of the optional data block, which canary + // copied verbatim into guest memory at `xex_header_ptr`, + // so `xex_header_ptr + offset` is the in-guest VA. + let offset = mem.read_u32(entry_value_addr); + field_value = xex_header_ptr.wrapping_add(offset); + } + } + break; + } + if !found { + ctx.gpr[3] = 0; + return; + } + ctx.gpr[3] = field_value as u64; } fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { @@ -2410,13 +3330,19 @@ fn rtl_multi_byte_to_unicode_n(ctx: &mut PpcContext, mem: &GuestMemory, _state: } fn rtl_nt_status_to_dos_error(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { - // Simple mapping for common cases + // NTSTATUS → Win32 ERROR_* translation. Canary's + // `RtlNtStatusToDosError` mirrors the documented Windows + // implementation; the subset below covers the codes Sylpheed + // surfaces in the Phase A diff window. Add new mappings as new + // divergences appear rather than synthesising a giant table up-front. let status = ctx.gpr[3] as u32; ctx.gpr[3] = match status { - 0 => 0, // ERROR_SUCCESS - 0xC000_0034 => 2, // ERROR_FILE_NOT_FOUND - 0xC000_0011 => 38, // ERROR_HANDLE_EOF - _ => status as u64, // Pass through + 0x0000_0000 => 0, // STATUS_SUCCESS → ERROR_SUCCESS + 0xC000_000F => 2, // STATUS_NO_SUCH_FILE → ERROR_FILE_NOT_FOUND + 0xC000_0011 => 38, // STATUS_END_OF_FILE → ERROR_HANDLE_EOF + 0xC000_0034 => 2, // STATUS_OBJECT_NAME_NOT_FOUND → ERROR_FILE_NOT_FOUND + 0xC000_0035 => 183, // STATUS_OBJECT_NAME_COLLISION → ERROR_ALREADY_EXISTS + _ => status as u64, // Pass through }; } @@ -3233,10 +4159,24 @@ fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state: state.xaudio.worker_refs[index] = Some(r); } + // Phase HostAudioEager (2026-05-19): mirror canary's + // `client_semaphore->Release(queued_frames_=8)` at + // `audio_system.cc:210` — seed the audio fire queue immediately so + // the round prologue's `try_inject_audio_callback` delivers the + // first callback within a few rounds of register-return, BEFORE + // tid=1 reaches `ExCreateThread` for the XAudio worker threads + // (tid=14/15 in canary, tid=9/10 in ours). Pre-fix, the 48k- + // instruction ticker delay let those threads spawn and enter their + // spin loop on the uninitialized voice struct before any callback + // fired. See `audit-runs/phase-host-audio-eager/investigation.md`. + let seeded = state + .xaudio + .seed_fires_for(index, crate::xaudio::XAUDIO_REGISTER_SEED_FIRES); + tracing::info!( - "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}", + "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?} seeded_fires={}", index, callback_pc, callback_arg, wrapped, driver_id, - state.xaudio.worker_handles[index], + state.xaudio.worker_handles[index], seeded, ); ctx.gpr[3] = 0; } @@ -3266,6 +4206,78 @@ fn xma_create_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kern ctx.gpr[3] = handle as u64; } +// ===== Crypto ===== + +/// Mirrors xenia-canary `XeCryptSha_entry` (xboxkrnl_crypt.cc:469-489): +/// 3-input SHA-1 accumulator. Each of the three (ptr, size) pairs is +/// processed only when both ptr and size are non-zero. The resulting +/// 20-byte digest is copied to `output`, truncated to `output_size`. +/// Void return (registered via `register_void_export`). +fn xe_crypt_sha(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { + use sha1::{Digest, Sha1}; + let input_1 = ctx.gpr[3] as u32; + let input_1_size = ctx.gpr[4] as u32; + let input_2 = ctx.gpr[5] as u32; + let input_2_size = ctx.gpr[6] as u32; + let input_3 = ctx.gpr[7] as u32; + let input_3_size = ctx.gpr[8] as u32; + let output = ctx.gpr[9] as u32; + let output_size = ctx.gpr[10] as u32; + let mut hasher = Sha1::new(); + for (ptr, size) in [ + (input_1, input_1_size), + (input_2, input_2_size), + (input_3, input_3_size), + ] { + if ptr != 0 && size != 0 { + let mut buf = vec![0u8; size as usize]; + mem.read_bytes(ptr, &mut buf); + hasher.update(&buf); + } + } + let digest = hasher.finalize(); + let n = std::cmp::min(20, output_size as usize); + if output != 0 && n != 0 { + mem.write_bytes(output, &digest[..n]); + } +} + +/// Mirrors xenia-canary `XeKeysConsolePrivateKeySign_entry` +/// (xboxkrnl_crypt.cc:1111-1138): writes a hardcoded fake +/// `XE_CONSOLE_CERTIFICATE` (0x1A8 bytes) to `output` and returns 1 +/// (success). Returns 0 if either pointer is null. The 5-byte +/// `XE_CONSOLE_ID` bit-field at offset 0x02 is laid out per MSVC +/// `#pragma pack(1)` semantics; we write the precomputed bytes +/// directly to avoid bit-fiddling ambiguity. +fn xe_keys_console_private_key_sign( + ctx: &mut PpcContext, + mem: &GuestMemory, + _state: &mut KernelState, +) { + let hash = ctx.gpr[3] as u32; + let output = ctx.gpr[4] as u32; + if hash == 0 || output == 0 { + ctx.gpr[3] = 0; + return; + } + // Zero the 0x1A8-byte struct first (canary calls `output.Zero()`). + let zeros = [0u8; 0x1A8]; + mem.write_bytes(output, &zeros); + // XE_CONSOLE_ID at offset 0x02 (5 bytes, MSVC pack(1) bit-fields). + // RefurbBits = 0b0011, ManufactureMonth = 0b1001 → byte 0 = 0x93 + // ManufactureYear = 1, MacIndex3 = 0x40, MacIndex4 = 0x66, + // MacIndex5 = 0x7E, Crc = 0 → bytes 1..5 = 0x01,0x64,0xE6,0x07 + // (LSB-first packing of the 32-bit storage unit at offset 1.) + let console_id = [0x93u8, 0x01, 0x64, 0xE6, 0x07]; + mem.write_bytes(output + 0x02, &console_id); + // console_type (u32 BE) at 0x18 → Retail = 2 + mem.write_u32(output + 0x18, 2); + // manufacture_date[8] at 0x1C + let mfg_date = [2u8, 0, 0, 5, 1, 1, 2, 2]; + mem.write_bytes(output + 0x1C, &mfg_date); + ctx.gpr[3] = 1; +} + // ===== Xex ===== /// Mirrors xenia-canary `XexCheckExecutablePrivilege_entry` @@ -3503,14 +4515,20 @@ pub(crate) fn parse_timeout(state: &KernelState, timeout_ptr: u32, mem: &GuestMe /// running its real body, leaving the main thread parked forever on the /// completion event. fn resolve_pseudo_handle(state: &KernelState, handle: u32) -> u32 { - match handle { + let raw = match handle { 0xFFFF_FFFF => 0, 0xFFFF_FFFE => { let hw_id = state.scheduler.current_hw_id().unwrap_or(0); state.scheduler.thread_handle(hw_id).unwrap_or(0) } h => h, - } + }; + // Phase C+19: canonicalize through the dup-alias map so every Nt*/Ke* + // call site that funnels through `resolve_pseudo_handle` (18 sites at + // C+19 landing) automatically routes dup ids back to their source + // slot before indexing `state.objects`. Preserves AUDIT-062's + // signal-on-dup-wakes-wait-on-source invariant. + state.resolve_handle(raw) } /// Lazily register a shadow kernel object for a guest `PKEVENT` / `PKSEMAPHORE` @@ -3590,13 +4608,62 @@ fn ensure_dispatcher_object(state: &mut KernelState, mem: &GuestMemory, ptr: u32 }, _ => return, }; + // Phase C+17: object_type for the schema-v1 `handle.create` emit + // below. Must match `KernelObject::schema_object_type` exactly so + // re-entrant lookups via `lookup_handle_semantic_id` resolve a SID + // computed from the same tuple `(create_site_pc=0, tid, idx, type)`. + let object_type = obj.schema_object_type(); state.objects.insert(ptr, obj); + // Phase C+17: each fresh shadow gets a baseline refcount of 1 so + // the lifecycle bookkeeping is symmetric with `alloc_handle_for`. + // No `handle.destroy` is currently emitted on shadow removal — + // canary's `GetNativeObject` lazy-wrap likewise survives for the + // session — but the entry's presence guards against + // accidental-underflow when future code wires the symmetric destroy. + state.handle_refcount.entry(ptr).or_insert(1); // Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first // adoption, stamp the X_DISPATCH_HEADER's wait_list with the kXObjSignature // fourcc 'X','E','N','\0' (flink_ptr) and the stash handle (blink_ptr). // Game code reads these to recognize already-adopted dispatchers. mem.write_u32(ptr + 0x08, 0x58454E00); mem.write_u32(ptr + 0x0C, ptr); + // Phase C+17: schema-v1 `handle.create` event for the synthesized + // wrapper. Mirrors canary's `ObjectTable::AddHandle` emit + // (util/object_table.cc:191-198) inside `XObject::GetNativeObject` + // (xobject.cc:436-449). The `raw_handle_id` is the guest dispatcher + // pointer itself — ours uses it as the shadow's handle key, and + // canary's `StashHandle` likewise round-trips through the same + // dispatcher slot, so cross-engine SID identity is independent of + // the concrete value. Cvar-gated default-off via + // `event_log::is_enabled()`. Registers the SID in the global + // registry so the immediately-following `wait.begin` resolves a + // non-zero `handles_semantic_ids` element. + // + // Phase C+18: use `emit_handle_create_shared_global` so the SID is + // **scheduling-invariant** — depends only on `(pointer, object_type)`. + // The dispatcher at this pointer is process-global; whichever guest + // thread happens to be the first toucher synthesizes the wrapper, but + // which thread wins is timing-dependent. Per-thread `(tid, idx)`-keyed + // SIDs would diverge between canary and ours at the SID level; the + // diff tool also uses SID equality to cross-tid match the floating + // `handle.create` event when the first-toucher is a different tid in + // each engine. See `event_log::semantic_id_shared_global` and the + // C+18 memory entry / schema-v1.md §"Shared-global SIDs". + if crate::event_log::is_enabled() { + let (tid, cycle) = if let Some(r) = state.scheduler.current { + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + } else { + (0u32, 0u64) + }; + crate::event_log::emit_handle_create_shared_global( + tid, + cycle, + object_type, + ptr, + /* object_name */ None, + ); + } } /// Set `gpr[3]` on a just-woken HW thread to reflect which handle in its @@ -3717,54 +4784,76 @@ fn ke_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState // for why we need the lazy-shadow step here. let h = ctx.gpr[3] as u32; ensure_dispatcher_object(state, mem, h); - let previous = match state.objects.get_mut(&h) { + // Canary parity (xevent.cc:60-64): `XEvent::Set` returns constant `1` + // on success, NOT the prior signaled state as the NT contract claims. + // We compute `previous` for internal bookkeeping (audit_signal, + // wake_eligible_waiters honor the prior-state read), but report + // `1` for success / `0` for "no dispatcher found" to match the + // canary Phase A oracle. See Phase C+7 investigation.md. + let (previous, found) = match state.objects.get_mut(&h) { Some(KernelObject::Event { signaled, .. }) => { let prev = *signaled; *signaled = true; - prev as u32 + (prev as u32, true) } - _ => 0, + _ => (0u32, false), }; state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64); wake_eligible_waiters(state, h); - ctx.gpr[3] = previous as u64; + ctx.gpr[3] = if found { 1 } else { 0 }; } fn ke_reset_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { + // r3 = PKEVENT on Ke* (guest pointer). See `ensure_dispatcher_object` + // for the lazy-shadow step. let h = ctx.gpr[3] as u32; ensure_dispatcher_object(state, mem, h); - let previous = match state.objects.get_mut(&h) { + // Canary parity (xevent.cc:72-75): `XEvent::Reset` returns constant `1` + // on success — exact sibling of `XEvent::Set`. The NT contract claims + // the prior signaled state, but canary hardcodes `1` and the game + // observes that value via Phase A oracle at idx=102164. Sibling fix + // of Phase C+7 KeSetEvent (xevent.cc:60-64). The `assert_always; + // return 0` arm is preserved (no shadow → 0). + let (previous, found) = match state.objects.get_mut(&h) { Some(KernelObject::Event { signaled, .. }) => { let prev = *signaled; *signaled = false; - prev as u32 + (prev as u32, true) } - _ => 0, + _ => (0u32, false), }; - ctx.gpr[3] = previous as u64; + state.audit_signal(h, ctx.lr as u32, "KeResetEvent", previous as u64); + ctx.gpr[3] = if found { 1 } else { 0 }; } fn nt_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source so signal-on-dup wakes + // wait-on-source (AUDIT-062 invariant). + let handle = state.resolve_handle(ctx.gpr[3] as u32); let prev_ptr = ctx.gpr[4] as u32; - let previous = match state.objects.get_mut(&handle) { + // Canary parity (xboxkrnl_threading.cc:610-628): the optional out-pointer + // is filled with `was_signalled` = `ev->Set()` = constant 1 (see + // xevent.cc:60-64), NOT the prior signaled state. r3 carries + // STATUS_SUCCESS. We retain `previous` for internal audit/wake plumbing. + let (previous, found) = match state.objects.get_mut(&handle) { Some(KernelObject::Event { signaled, .. }) => { let prev = *signaled; *signaled = true; - prev as u32 + (prev as u32, true) } - _ => 0, + _ => (0u32, false), }; state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64); wake_eligible_waiters(state, handle); - if prev_ptr != 0 { - mem.write_u32(prev_ptr, previous); + if prev_ptr != 0 && found { + mem.write_u32(prev_ptr, 1); } ctx.gpr[3] = STATUS_SUCCESS; } fn nt_clear_event(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&handle) { *signaled = false; } @@ -4015,10 +5104,53 @@ fn nt_wait_for_single_object_ex( ) { // r3 = handle, r4 = wait_mode, r5 = alertable, r6 = timeout_ptr let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); + let alertable = ctx.gpr[5] != 0; let timeout_ptr = ctx.gpr[6] as u32; + // Phase C+15-α: schema-v1 `wait.begin` event. Emitted BEFORE + // `do_wait_single` to surface the wait initiation regardless of + // synchronous vs. parked outcome. `wait.end` is deferred (the + // synchronous status is already captured in the + // immediately-following `kernel.return`). Canary's symmetric emit + // is at `NtWaitForSingleObjectEx_entry` body. + if crate::event_log::is_enabled() { + let timeout_ns = decode_timeout_ns(mem, timeout_ptr); + let sid = crate::event_log::lookup_handle_semantic_id(handle); + let (tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + crate::event_log::emit_wait_begin( + tid, + cycle, + &[sid], + timeout_ns, + alertable, + /* wait_all */ false, + ); + } do_wait_single(ctx, state, handle, timeout_ptr, mem); } +/// Phase C+15-α helper: decode a TIMEOUT* big-endian i64 to ns for the +/// schema-v1 `wait.begin` payload. `timeout_ptr == 0` → INFINITE +/// (encoded as -1 per schema). NT TIMEOUT units are 100ns. Negative +/// values are relative (timeout from now); positive values are +/// absolute deadlines. For simplicity (and to mirror canary's +/// emission), we report the **raw** ticks unscaled; the diff tool +/// only compares values, not their meaning. Encoding into ns matches +/// schema-v1 field name; precise unit-conversion isn't required for +/// cross-engine equality. +fn decode_timeout_ns(mem: &GuestMemory, timeout_ptr: u32) -> i64 { + if timeout_ptr == 0 { + return -1; + } + let raw = mem.read_u64(timeout_ptr) as i64; + // NT TIMEOUT is 100ns ticks. Convert to ns; saturating to avoid + // wraparound on extreme values. + raw.saturating_mul(100) +} + /// `NtSignalAndWaitForSingleObjectEx(signal_handle, wait_handle, wait_mode, /// alertable, timeout_ptr)` — atomically signal one kernel object and wait on /// another. Matches Canary's `NtSignalAndWaitForSingleObjectEx_entry` @@ -4083,7 +5215,27 @@ fn ke_wait_for_single_object( let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); ensure_dispatcher_object(state, mem, handle); refresh_pkevent_shadow_from_guest(state, mem, handle); + let alertable = ctx.gpr[6] != 0; let timeout_ptr = ctx.gpr[7] as u32; + // Phase C+15-α: schema-v1 `wait.begin` event. Symmetric counterpart + // in canary at `xeKeWaitForSingleObject`. + if crate::event_log::is_enabled() { + let timeout_ns = decode_timeout_ns(mem, timeout_ptr); + let sid = crate::event_log::lookup_handle_semantic_id(handle); + let (tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + crate::event_log::emit_wait_begin( + tid, + cycle, + &[sid], + timeout_ns, + alertable, + /* wait_all */ false, + ); + } do_wait_single(ctx, state, handle, timeout_ptr, mem); } @@ -4173,7 +5325,9 @@ fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut Kernel fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = prev_suspend_count_ptr - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source so a duplicated thread + // handle (rare but legal) still resolves to the scheduler entry. + let handle = state.resolve_handle(ctx.gpr[3] as u32); let prev_ptr = ctx.gpr[4] as u32; let prev = state .scheduler @@ -4188,7 +5342,8 @@ fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelS fn nt_suspend_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = prev_suspend_count_ptr - let handle = ctx.gpr[3] as u32; + // Phase C+19: canonicalize dup ids → source. + let handle = state.resolve_handle(ctx.gpr[3] as u32); let prev_ptr = ctx.gpr[4] as u32; let prev = state .scheduler @@ -4250,10 +5405,22 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke /// * r4 = new_handle_ptr (if zero, the call is actually a close) /// * r5 = options (bit 0 = DUPLICATE_CLOSE_SOURCE) /// -/// Canary allocates a fresh handle id that refcounts the same underlying -/// `XObject`. We don't refcount, so we alias: write the *source* handle back -/// as the "new" handle. The game then uses it interchangeably, and both ids -/// resolve to the same `KernelObject` entry. +/// Canary's `ObjectTable::DuplicateHandle` (object_table.cc:210-223) allocates +/// a fresh slot via `AddHandle` (which retains the underlying `XObject` and +/// emits `handle.create`), returning the new slot id. Both source and dup +/// slots independently refcount the same `XObject`; closing one decrements +/// the slot's local count and, when zero, removes that slot. The underlying +/// object dies only when the last slot is gone. +/// +/// Phase C+19: ours mirrors this. Pre-C+19 we aliased `dup_id == source_id` +/// to avoid maintaining a separate refcount across distinct ids; AUDIT-062 +/// verified the wedge-case (signal-on-dup wakes wait-on-source) worked +/// because the ids collided into the same `state.objects` entry. Allocating +/// a fresh id surfaces the canary-symmetric `handle.create` Phase A event +/// at main idx=102553; the AUDIT-062 invariant is preserved by routing +/// every Nt*/Ke* lookup through `state.resolve_handle` which canonicalizes +/// the dup id back to the source — both ids still hit the same `KernelObject` +/// with the same `waiters` list and `signaled` flag. /// /// A prior `stub_success` left `*new_handle_ptr` uninitialized — Sylpheed's /// thread-dispatch prologue does `NtDuplicateObject(event, &dup)` then passes @@ -4261,34 +5428,70 @@ fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Ke /// completion. With the stub, `dup` was stack garbage → set-event lookup /// failed silently → main thread blocked forever on the source event. fn nt_duplicate_object(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { - let source = resolve_pseudo_handle(state, ctx.gpr[3] as u32); + let raw_source = resolve_pseudo_handle(state, ctx.gpr[3] as u32); + // The guest may itself pass a dup id — canonicalize before validation + // so we always alias against the live `state.objects` entry. + let canonical = state.resolve_handle(raw_source); let out_ptr = ctx.gpr[4] as u32; let options = ctx.gpr[5] as u32; + const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001; - if !state.objects.contains_key(&source) { + if !state.objects.contains_key(&canonical) { if out_ptr != 0 { mem.write_u32(out_ptr, 0); } ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } - if out_ptr != 0 { - mem.write_u32(out_ptr, source); - } - // Aliased-handle refcount: since we return the source handle as the "new" - // handle (no fresh id), every duplicate must bump the per-handle refcount - // so the later `NtClose` pair (one for source, one for dup) doesn't - // destroy the object mid-flight. `DUPLICATE_CLOSE_SOURCE` (bit 0) closes - // the source in Canary (xboxkrnl_ob.cc:389), so in our aliased model the - // source-close cancels the dup-gain: net refcount is unchanged. Without - // `CLOSE_SOURCE`, both the source and the dup are separately live and we - // need +1. - const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001; - if options & DUPLICATE_CLOSE_SOURCE == 0 - && let Some(c) = state.handle_refcount.get_mut(&source) + + // Allocate a fresh slot id. Canonical refcount bumps by one slot; + // the dup slot starts with a single local NtClose owed to it. + let dup_id = state.alloc_handle(); + state.handle_aliases.insert(dup_id, canonical); + state.handle_refcount.insert(dup_id, 1); + *state.canonical_slot_count.entry(canonical).or_insert(0) += 1; + + // Phase C+15-α schema-v1 `handle.create` event. Canary's symmetric path + // is `ObjectTable::AddHandle` (object_table.cc:198-204) which emits + // when called from inside `DuplicateHandle`. SID recipe = per-tid + // `(creating_tid, idx_at_creation, object_type)` — matches canary's + // `EmitHandleCreateAuto` exactly (event_log.cc), so the same logical + // dup pair produces the same SID across engines. + if crate::event_log::is_enabled() + && let Some(obj) = state.objects.get(&canonical) { - *c += 1; + let object_type = obj.schema_object_type(); + let (tid, cycle) = { + let r = state.scheduler.current_ref(); + let t = state.scheduler.thread(r); + (t.tid, t.ctx.timebase) + }; + crate::event_log::emit_handle_create_auto( + tid, + cycle, + /* create_site_pc */ 0, + object_type, + dup_id, + /* object_name */ None, + ); + } + + if out_ptr != 0 { + mem.write_u32(out_ptr, dup_id); } + + // DUPLICATE_CLOSE_SOURCE: canary additionally calls `RemoveHandle(handle)` + // (xboxkrnl_ob.cc:405-408) which decrements the source slot's refcount + // and — if zero — destroys the source slot (but leaves the underlying + // object alive through the dup). We mirror by routing the source through + // `close_handle_internal` so the symmetric `handle.destroy(source)` event + // fires at the canary-equivalent boundary. Note: the source value here + // is `raw_source` (the id the guest passed, post pseudo-handle resolve), + // NOT `canonical` — the close targets the *slot* the guest named. + if options & DUPLICATE_CLOSE_SOURCE != 0 { + close_handle_internal(state, raw_source); + } + ctx.gpr[3] = STATUS_SUCCESS; } @@ -4344,6 +5547,28 @@ mod tests { mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) .expect("scratch page must commit"); let mut state = KernelState::new(); + // Phase C+11 — the default cache root is now persistent, but + // tests must NOT share state. Override with a per-test tmpdir + // (unique by PID + monotonic counter + nanos) and wipe on + // entry. Mirrors the pre-flip AUDIT-038 behaviour for the + // test harness specifically. + static TEST_CACHE_ID: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let test_id = TEST_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos(); + let test_cache = std::env::temp_dir().join(format!( + "xenia-rs-test-cache-{}-{}-{}", + std::process::id(), + test_id, + nanos + )); + // Wipe any leftover, then install. + let _ = std::fs::remove_dir_all(&test_cache); + std::fs::create_dir_all(&test_cache).expect("test cache mkdir"); + state.set_cache_root(test_cache); // Under per-slot runqueues, most kernel exports reach through // `scheduler.current` — tests that exercise those paths need a // live thread installed on slot 0 first. Older tests (file I/O @@ -4423,12 +5648,21 @@ mod tests { // Confirm PCR was written by the spawn (sanity). assert_eq!(mem.read_u32(pcr_base + 0x2C), 1); - // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20). + // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20, + // prev_mask_ptr=scratch). Post Stage 2 Batch 3: r3=STATUS_SUCCESS, + // previous mask delivered via OUT-pointer. + let prev_ptr = SCRATCH_BASE + 0xA0; + mem.write_u32(prev_ptr, 0xFFFF_FFFF); // sentinel ctx.gpr[3] = 0x2000; ctx.gpr[4] = 0x20; // slot 5 only + ctx.gpr[5] = prev_ptr as u64; ke_set_affinity_thread(&mut ctx, &mut mem, &mut state); - // Return value = previous mask = 0x02. - assert_eq!(ctx.gpr[3], 0x02); + assert_eq!(ctx.gpr[3], 0, "must return STATUS_SUCCESS in r3"); + assert_eq!( + mem.read_u32(prev_ptr), + 0x02, + "previous affinity mask must be written to OUT-pointer" + ); // PCR rewritten to 5. assert_eq!(mem.read_u32(pcr_base + 0x2C), 5); // Thread now on slot 5. @@ -4436,20 +5670,95 @@ mod tests { assert_eq!(r.hw_id, 5); } - /// Axis 5: `KeSetIdealProcessor` stores a hint on the thread - /// without migrating it; query round-trips. + /// Stage 2 Batch 3: zero affinity must return STATUS_INVALID_PARAMETER + /// and not touch the OUT-pointer. + #[test] + fn ke_set_affinity_thread_zero_affinity_returns_invalid_parameter() { + let (mut ctx, mem, mut state) = fresh(); + let prev_ptr = SCRATCH_BASE + 0xA0; + mem.write_u32(prev_ptr, 0xDEAD_BEEF); + ctx.gpr[3] = 0x1000; // main handle + ctx.gpr[4] = 0; // zero affinity + ctx.gpr[5] = prev_ptr as u64; + ke_set_affinity_thread(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0xC000_000D, "STATUS_INVALID_PARAMETER"); + assert_eq!(mem.read_u32(prev_ptr), 0xDEAD_BEEF, "OUT-ptr untouched"); + } + + /// Stage 2 Batch 3: NULL OUT-pointer is valid (mirrors canary's + /// `if (previous_affinity_ptr)` guard); still returns SUCCESS and + /// migrates the thread. #[test] - fn ke_set_ideal_processor_round_trips() { + fn ke_set_affinity_thread_null_out_ptr_still_succeeds() { let (mut ctx, mut mem, mut state) = fresh(); - // Main thread handle is 0x1000. - ctx.gpr[3] = 0x1000; - ctx.gpr[4] = 3; - ke_set_ideal_processor(&mut ctx, &mut mem, &mut state); + use xenia_cpu::scheduler::SpawnParams; + let pcr_base = SCRATCH_BASE + 0x500; + let params = SpawnParams { + entry: 0x8200_0000, + start_context: 0, + stack_base: 0x7200_0000, + stack_size: 0x10000, + pcr_base, + tls_base: 0, + thread_handle: 0x2100, + guest_tid: 43, + create_suspended: false, + is_initial: false, + tls_slot_count: 0, + affinity_mask: 0b0000_0010, + priority: 0, + ideal_processor: None, + }; + state + .scheduler + .spawn(params, &mut crate::state::GuestMemoryPcr(&mut mem)) + .unwrap(); + ctx.gpr[3] = 0x2100; + ctx.gpr[4] = 0x10; // slot 4 + ctx.gpr[5] = 0; // NULL OUT-ptr + ke_set_affinity_thread(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS even with NULL OUT-ptr"); + let r = state.scheduler.find_by_handle(0x2100).expect("alive"); + assert_eq!(r.hw_id, 4); + } + + /// Axis 5: scheduler-level ideal-processor hint round-trip via + /// `Scheduler::set_ideal_ref` / `ideal_ref`. The previous test + /// exercised `ke_set_ideal_processor` / `ke_query_ideal_processor` + /// which were hallucinated functions at the wrong ordinals — those + /// bodies were removed in Phase C+6½. The underlying scheduler + /// state still backs `NtSetInformationThread` info-class + /// `ThreadIdealProcessor`. + #[test] + fn scheduler_ideal_processor_round_trips() { + let (_, _, mut state) = fresh(); + let r = state.scheduler.find_by_handle(0x1000).expect("main alive"); // Prior was 0xFF (unset sentinel). - assert_eq!(ctx.gpr[3], 0xFF); - ctx.gpr[3] = 0x1000; - ke_query_ideal_processor(&mut ctx, &mut mem, &mut state); - assert_eq!(ctx.gpr[3], 3); + let prev = state.scheduler.set_ideal_ref(r, 3); + assert_eq!(prev, 0xFF); + let queried = state.scheduler.ideal_ref(r); + assert_eq!(queried, Some(3)); + } + + /// Phase C+6½: `KeQueryInterruptTime` (ord 0x82) returns a + /// non-zero monotonic u64 in gpr[3]. Previously this ord was + /// mis-labeled `KeQueryIdealProcessor` and returned a 1-byte + /// processor index — guests querying the system interrupt-time + /// counter received the wrong value. + #[test] + fn ke_query_interrupt_time_returns_synthetic_u64() { + let (mut ctx, mut mem, mut state) = fresh(); + // Pre-clear gpr[3] so we know the function wrote it. + ctx.gpr[3] = 0; + ke_query_interrupt_time(&mut ctx, &mut mem, &mut state); + assert_ne!(ctx.gpr[3], 0, "interrupt time must be non-zero"); + // Should be 64-bit (above u32::MAX) to ensure it's not + // truncated to a processor-index byte. + assert!( + ctx.gpr[3] > 0xFFFF_FFFF, + "interrupt time must occupy 64 bits, got {:#x}", + ctx.gpr[3] + ); } /// Axis 5: `NtSetInformationThread` class `ThreadAffinityMask` @@ -4660,6 +5969,94 @@ mod tests { assert!(event_signaled(&state, evt), "write must signal too"); } + /// Phase C+5 — async-opened files (no `FILE_SYNCHRONOUS_IO_*` bit in + /// `create_options`) return `STATUS_PENDING` (0x103) from + /// `NtWriteFile`. The synchronous write still completes and + /// IO_STATUS_BLOCK still records STATUS_SUCCESS — only the function + /// return value flips. Mirrors canary + /// `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353`. + #[test] + fn nt_write_file_async_handle_returns_status_pending() { + let (mut ctx, mut mem, mut state) = fresh(); + // Pre-register an "async" file handle the same way `open_vfs_file` + // does for a file whose `create_options` omits sync bits. + let handle = state.alloc_handle_for(KernelObject::File { + path: "async.tmp".to_string(), + size: 0, + position: 0, + data: std::sync::Arc::new(Vec::new()), + dir_enum_pos: None, + host_path: None, + }); + state.async_file_handles.insert(handle); + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = 0; // no event + ctx.gpr[7] = SCRATCH_BASE as u64; // iosb at scratch base + ctx.gpr[9] = 8; // length + nt_write_file(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_PENDING, + "async-opened file: r3 must return STATUS_PENDING (0x103)" + ); + assert_eq!( + mem.read_u32(SCRATCH_BASE), + STATUS_SUCCESS as u32, + "IO_STATUS_BLOCK.status still records STATUS_SUCCESS" + ); + assert_eq!( + mem.read_u32(SCRATCH_BASE + 4), + 8, + "IO_STATUS_BLOCK.information records bytes written" + ); + } + + /// Sync-opened files (one of `FILE_SYNCHRONOUS_IO_*` bits set in + /// `create_options`) retain the legacy `STATUS_SUCCESS` return. + #[test] + fn nt_write_file_sync_handle_returns_status_success() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::File { + path: "sync.tmp".to_string(), + size: 0, + position: 0, + data: std::sync::Arc::new(Vec::new()), + dir_enum_pos: None, + host_path: None, + }); + // Not inserted into `async_file_handles` — sync handle by default. + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = 0; + ctx.gpr[7] = SCRATCH_BASE as u64; + ctx.gpr[9] = 8; + nt_write_file(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "sync-opened file: r3 must return STATUS_SUCCESS" + ); + } + + /// `nt_close` must prune the async-file side-table when the final + /// refcount drops to zero so a recycled handle isn't mis-classified. + #[test] + fn nt_close_prunes_async_file_set() { + let (mut ctx, mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::File { + path: "x.tmp".to_string(), + size: 0, + position: 0, + data: std::sync::Arc::new(Vec::new()), + dir_enum_pos: None, + host_path: None, + }); + state.async_file_handles.insert(handle); + ctx.gpr[3] = handle as u64; + nt_close(&mut ctx, &mem, &mut state); + assert!( + !state.async_file_handles.contains(&handle), + "nt_close must remove from async_file_handles" + ); + } + /// Verify `FileStandardInformation` reports `Directory=1` for empty-path /// (device-root) synthesized file handles. Sylpheed calls /// `NtCreateFile("game:\\")` then `NtQueryInformationFile` on the returned @@ -5023,8 +6420,13 @@ mod tests { write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // notification ctx.gpr[3] = kevent_ptr as u64; ke_reset_event(&mut ctx, &mut mem, &mut state); - // After reset, shadow exists and is unsignaled; gpr[3] reports previous=1. - assert_eq!(ctx.gpr[3], 1, "previous state must be reported"); + // After reset, shadow exists and is unsignaled. Post-C+8: gpr[3] + // reports canary-constant `1` on hit (xevent.cc:72-75 hardcodes + // `return 1`), NOT the prior signaled state — same value here by + // coincidence (prior state happens to be 1). The + // `ke_reset_event_returns_constant_one_on_unsignaled_*` tests below + // distinguish constant-return from prior-state-return. + assert_eq!(ctx.gpr[3], 1, "canary parity: KeResetEvent returns constant 1 on hit"); match state.objects.get(&kevent_ptr) { Some(KernelObject::Event { manual_reset, signaled, .. }) => { assert!(*manual_reset, "type=0 must be manual-reset"); @@ -5117,6 +6519,95 @@ mod tests { assert_eq!(mem.read_u32(ptr + 0x0C), 0); } + /// Phase C+17: first adoption of a guest dispatcher pointer via + /// `ensure_dispatcher_object` must seed `handle_refcount[ptr] = 1`, + /// mirroring canary's `ObjectTable::AddHandle` baseline + /// (object_table.cc:164). Symmetric to `alloc_handle_for` which + /// already does this for handle-based objects. + #[test] + fn ensure_dispatcher_object_initializes_handle_refcount_for_event() { + let (mut _ctx, mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0x800; + write_dispatcher_header(&mem, kevent_ptr, 1, 0); // synchronization + assert!(!state.handle_refcount.contains_key(&kevent_ptr)); + ensure_dispatcher_object(&mut state, &mem, kevent_ptr); + assert!(state.objects.contains_key(&kevent_ptr)); + assert_eq!( + state.handle_refcount.get(&kevent_ptr).copied(), + Some(1), + "fresh shadow must start with refcount 1" + ); + } + + /// Same baseline for semaphores. Header type=5 picks the + /// Semaphore branch; refcount is independent of count/max. + #[test] + fn ensure_dispatcher_object_initializes_handle_refcount_for_semaphore() { + let (mut _ctx, mem, mut state) = fresh(); + let sem_ptr = SCRATCH_BASE + 0x820; + write_dispatcher_header(&mem, sem_ptr, 5, 0); + mem.write_u32(sem_ptr + 0x10, 4); // Limit=4 + ensure_dispatcher_object(&mut state, &mem, sem_ptr); + assert!(matches!(state.objects.get(&sem_ptr), Some(KernelObject::Semaphore { .. }))); + assert_eq!(state.handle_refcount.get(&sem_ptr).copied(), Some(1)); + } + + /// Re-entry on the same pointer is a no-op: the early-return guard + /// at the top of `ensure_dispatcher_object` (contains_key check) + /// must NOT double-bump the refcount. Mirrors canary's + /// `kXObjSignature` short-circuit (xobject.cc:421-427). + #[test] + fn ensure_dispatcher_object_is_idempotent_on_repeated_touch() { + let (mut _ctx, mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0x840; + write_dispatcher_header(&mem, kevent_ptr, 0, 0); // notification + ensure_dispatcher_object(&mut state, &mem, kevent_ptr); + ensure_dispatcher_object(&mut state, &mem, kevent_ptr); + ensure_dispatcher_object(&mut state, &mem, kevent_ptr); + assert_eq!( + state.handle_refcount.get(&kevent_ptr).copied(), + Some(1), + "repeated ensure must not bump refcount" + ); + } + + /// Two distinct native pointers each get their own shadow and + /// their own refcount entry. Canary's `GetNativeObject` lazy-wraps + /// each dispatcher independently — there's no shared XObject for + /// distinct guest pointers. + #[test] + fn ensure_dispatcher_object_distinct_ptrs_get_distinct_refcount_entries() { + let (mut _ctx, mem, mut state) = fresh(); + let a = SCRATCH_BASE + 0x860; + let b = SCRATCH_BASE + 0x880; + write_dispatcher_header(&mem, a, 1, 0); + write_dispatcher_header(&mem, b, 5, 0); + mem.write_u32(b + 0x10, 2); + ensure_dispatcher_object(&mut state, &mem, a); + ensure_dispatcher_object(&mut state, &mem, b); + assert_eq!(state.handle_refcount.get(&a).copied(), Some(1)); + assert_eq!(state.handle_refcount.get(&b).copied(), Some(1)); + assert!(matches!(state.objects.get(&a), Some(KernelObject::Event { .. }))); + assert!(matches!(state.objects.get(&b), Some(KernelObject::Semaphore { .. }))); + } + + /// Unsupported dispatcher types (e.g., Mutant type=2 — canary's + /// `GetNativeObject` `assert_always`s on them) must leave both + /// `state.objects` AND `state.handle_refcount` untouched. The + /// early-return after the match guard prevents both insertions. + #[test] + fn ensure_dispatcher_object_unknown_type_does_not_touch_refcount() { + let (mut _ctx, mem, mut state) = fresh(); + let ptr = SCRATCH_BASE + 0x8A0; + write_dispatcher_header(&mem, ptr, 2, 0); // Mutant — unsupported + ensure_dispatcher_object(&mut state, &mem, ptr); + assert!(!state.objects.contains_key(&ptr)); + assert!( + !state.handle_refcount.contains_key(&ptr), + "no refcount entry for unsupported dispatcher type" + ); + } + /// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first /// adoption of a guest dispatcher, +0x08 must hold the 'X','E','N','\0' /// fourcc and +0x0C must hold the stash handle. @@ -6215,6 +7706,14 @@ mod tests { let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\rt.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; + // Phase C+5 — set sp so nt_create_file reads create_options from a + // committed scratch slot, and set the FILE_SYNCHRONOUS_IO_NONALERT + // bit so `NtWriteFile` returns `STATUS_SUCCESS` (legacy assertion). + // Files opened WITHOUT this bit return `STATUS_PENDING` after + // canary's xboxkrnl_io.cc:351-353 — covered by + // `nt_write_file_async_handle_returns_status_pending`. + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; @@ -6335,22 +7834,1537 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - /// `resolve_cache_path` rejects path-traversal attempts so a guest - /// can't escape the cache directory by passing `cache:\..\..\etc\foo`. + /// Phase C+11 Stage 2 — when a `cache:\` file already exists + /// on disk as a regular file, re-opening it with the + /// `FILE_DIRECTORY_FILE` bit set MUST still route through the file + /// branch (host_path = Some) — the on-disk type wins. Pre-fix: + /// `is_dir_open = want_dir || host_path.is_dir()` would force + /// re-opens with bit 0x1 set into the dir branch, dropping + /// host_path and blocking subsequent class-10 renames. #[test] - fn cache_resolve_strips_path_traversal() { - let dir = std::env::temp_dir().join(format!( - "xenia-rs-cache-test-trav-{}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - let mut state = KernelState::new(); - state.init_cache_root(dir.clone()).unwrap(); - let resolved = state - .resolve_cache_path("cache:\\..\\..\\etc\\foo") - .expect("must resolve"); - assert!(resolved.starts_with(&dir), "must stay inside cache root"); - assert!(resolved.ends_with("etc/foo")); - std::fs::remove_dir_all(&dir).ok(); + fn cache_existing_file_wins_over_directory_bit() { + let (mut ctx, mem, mut state) = fresh(); + let cache_root = state.cache_root.clone().unwrap(); + + // 1. FILE_CREATE without DIRECTORY bit → produces a real file. + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_CREATE as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + assert!(cache_root.join("foo.tmp").is_file()); + + // 2. Re-open with FILE_DIRECTORY_FILE bit set in r7. + // open_options bit 0x1 = FILE_DIRECTORY_FILE. + // open_options bit 0x20 = FILE_SYNCHRONOUS_IO_NONALERT (keeps + // the handle synchronous so NtWriteFile returns STATUS_SUCCESS). + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[7] = (0x1 | FILE_SYNCHRONOUS_IO_NONALERT) as u64; + nt_open_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + let handle = mem.read_u32(handle_out); + + // 3. The re-opened handle MUST be a file handle with a real + // host_path, not a directory handle with host_path=None. + let obj = state.objects.get(&handle).expect("handle must exist"); + match obj { + KernelObject::File { host_path, path, .. } => { + assert!( + host_path.is_some(), + "existing file re-open must keep host_path (got None) — bug #2 regression" + ); + assert!( + !path.ends_with('/'), + "existing file re-open path must NOT have trailing '/' (got dir-shape) — bug #2 regression" + ); + } + _ => panic!("expected File kernel object"), + } + } + + /// Phase C+11 Stage 2 — `cache:\access`, `cache:\ignore`, and + /// `cache:\recent` are TOP-LEVEL files in canary's cache (per + /// the canary-cache-listing.csv enumeration). Cold creation + /// through ours should produce files, not directories. + #[test] + fn cache_top_level_manifests_create_as_files() { + for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] { + let (mut ctx, mem, mut state) = fresh(); + let cache_root = state.cache_root.clone().unwrap(); + let leaf_name = path_str.strip_prefix("cache:\\").unwrap(); + + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + // Set FILE_NON_DIRECTORY_FILE explicitly so Sylpheed-style + // create paths produce host files. (If Sylpheed sets the + // DIRECTORY bit but no NON_DIRECTORY bit, the pre-fix code + // would mis-create as dirs; this test pins the + // bit-conflict-resolution policy.) + mem.write_u32( + SCRATCH_BASE + 0x700 + 0x54, + FILE_SYNCHRONOUS_IO_NONALERT | 0x40, // | FILE_NON_DIRECTORY_FILE + ); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_CREATE as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "FILE_CREATE on {} must succeed", + path_str + ); + assert!( + cache_root.join(leaf_name).is_file(), + "cache:\\{} must be a host file (got: dir or absent)", + leaf_name + ); + } + } + + /// Phase C+11.1 — Sylpheed's cold-boot probe pattern: open + /// `cache:\access` / `cache:\ignore` / `cache:\recent` with + /// disp=1 (FILE_OPEN) + opts=0x7 (DIRECTORY_FILE | WRITE_THROUGH + /// | SEQUENTIAL_ONLY) MUST return `STATUS_OBJECT_NAME_NOT_FOUND` + /// and MUST NOT create a host directory. Pre-fix the + /// `is_dir_open` branch unconditionally mkdir-p'd whenever + /// `want_dir`, which produced spurious `access`/`ignore`/`recent` + /// directories that then occluded later `disp=5 NON_DIRECTORY` + /// re-creates Sylpheed uses to populate the manifests. + /// Mirrors canary's `VirtualFileSystem::OpenFile` + /// (virtual_file_system.cc:265-273) which returns + /// `X_STATUS_OBJECT_NAME_NOT_FOUND` for `kOpen` on missing path, + /// regardless of `is_directory`. + #[test] + fn cache_open_directory_on_missing_path_returns_not_found() { + for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] { + let (mut ctx, mem, mut state) = fresh(); + let cache_root = state.cache_root.clone().unwrap(); + let leaf_name = path_str.strip_prefix("cache:\\").unwrap(); + + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + // Sylpheed's exact cold-boot bit pattern: FILE_DIRECTORY_FILE + // (0x1) | FILE_WRITE_THROUGH (0x2) | FILE_SEQUENTIAL_ONLY (0x4) + // = 0x7. Slot offset 0x54 per the `nt_create_file` + // arg-marshalling. + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + // Clear any pre-existing handle slot so the assert is honest. + mem.write_u32(handle_out, 0xDEAD_BEEF); + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND, + "FILE_OPEN+DIR on missing {} must return NOT_FOUND", + path_str + ); + assert_eq!( + mem.read_u32(handle_out), + 0, + "no handle on cold-boot dir-open miss for {}", + path_str + ); + assert!( + !cache_root.join(leaf_name).exists(), + "{} must NOT be created on disk by a non-create disp", + leaf_name + ); + } + } + + /// Phase C+11.1 — after the cold-boot NOT_FOUND probe (see + /// `cache_open_directory_on_missing_path_returns_not_found`), + /// Sylpheed re-issues `disp=FILE_OVERWRITE_IF (5)` with + /// `FILE_NON_DIRECTORY_FILE` set. That second call MUST produce + /// a regular file, not a directory. This pins the two-call + /// sequence canary actually executes on cold boot. + #[test] + fn cache_disp5_after_disp1_miss_creates_file() { + let (mut ctx, mem, mut state) = fresh(); + let cache_root = state.cache_root.clone().unwrap(); + + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\access"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + + // 1) Cold disp=1 + opts=0x7 → NOT_FOUND, no host-side entry. + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND); + assert!(!cache_root.join("access").exists()); + + // 2) disp=5 + opts=0x60 (FILE_NON_DIRECTORY_FILE | + // FILE_SYNCHRONOUS_IO_NONALERT) → FILE created. + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x60); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OVERWRITE_IF as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + assert!( + cache_root.join("access").is_file(), + "disp=5 with NON_DIRECTORY on cache:\\access must produce a host FILE" + ); + } + + /// Phase C+11 — write a `cache:\

.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" + ); + } + + /// Phase C+13 — `is_disc_prefix` recognises every alias canary maps + /// to the read-only disc partition: `game:\`, `d:\`/`D:\`, and the + /// raw NT device path `\Device\Cdrom0\`. Anything else (writable + /// partitions, raw paths) must return false so the synth-empty + /// fallback still fires. + #[test] + fn is_disc_prefix_recognises_disc_aliases() { + assert!(is_disc_prefix("game:\\dat\\files.tbl")); + assert!(is_disc_prefix("GAME:\\dat\\files.tbl")); + assert!(is_disc_prefix("d:\\default.xex")); + assert!(is_disc_prefix("D:\\default.xex")); + assert!(is_disc_prefix("\\Device\\Cdrom0\\dat\\files.tbl")); + assert!(is_disc_prefix("\\DEVICE\\CDROM0\\foo")); + // Non-disc prefixes must NOT count. + assert!(!is_disc_prefix("cache:\\d4ea4615\\e\\46ee8ca")); + assert!(!is_disc_prefix("\\Device\\Harddisk0\\Partition1\\x")); + assert!(!is_disc_prefix("\\??\\foo")); + assert!(!is_disc_prefix("\\Device\\Mass0\\foo")); + assert!(!is_disc_prefix("scripts/init.lua")); + assert!(!is_disc_prefix("")); + } + + /// Phase C+13 — `NtCreateFile` on a disc-prefixed path that the VFS + /// can't resolve returns `STATUS_OBJECT_NAME_NOT_FOUND` (mirrors + /// canary `xboxkrnl_io.cc:83-110` which forwards the lookup + /// status verbatim, idx 103862 first divergence). Sylpheed + /// handles NOT_FOUND via `RtlNtStatusToDosError` then continues + /// its boot validator. + #[test] + fn nt_create_file_game_prefix_missing_returns_not_found() { + let (mut ctx, mem, mut state) = fresh(); + // Install a stub VFS that doesn't resolve anything — mirrors a + // disc image that doesn't contain `dat/files.tbl`. + state.vfs = Some(Box::new(StubVfs { entries: vec![] })); + let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "game:\\dat\\files.tbl"); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND, + "missing disc file must return STATUS_OBJECT_NAME_NOT_FOUND" + ); + assert_eq!( + mem.read_u32(handle_out), + 0, + "no handle returned on NOT_FOUND" + ); + assert_eq!( + mem.read_u32(iosb), + STATUS_OBJECT_NAME_NOT_FOUND as u32, + "IOSB.status records NOT_FOUND" + ); + } + + /// Phase C+13 — same as above for the `\Device\Cdrom0\` NT-device + /// alias of the disc. + #[test] + fn nt_create_file_cdrom_prefix_missing_returns_not_found() { + let (mut ctx, mem, mut state) = fresh(); + state.vfs = Some(Box::new(StubVfs { entries: vec![] })); + let obj_attrs = write_obj_attrs( + &mem, + SCRATCH_BASE + 0x100, + "\\Device\\Cdrom0\\dat\\files.tbl", + ); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND); + } + + /// Phase C+13 — a non-disc prefix that misses the VFS still gets + /// the legacy zero-byte synth (preserves audit-006 / audit-018 + /// behaviour for writable system-partition opens that ours + /// doesn't host-mount). `\Device\Harddisk0\Partition1\` is the + /// canonical writable mount. + #[test] + fn nt_create_file_non_disc_prefix_missing_still_synthesizes() { + let (mut ctx, mem, mut state) = fresh(); + state.vfs = Some(Box::new(StubVfs { entries: vec![] })); + let obj_attrs = write_obj_attrs( + &mem, + SCRATCH_BASE + 0x100, + "\\Device\\Harddisk0\\Partition1\\sys.bin", + ); + let handle_out = SCRATCH_BASE + 0x300; + let iosb = SCRATCH_BASE + 0x310; + ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; + mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0); + ctx.gpr[3] = handle_out as u64; + ctx.gpr[5] = obj_attrs as u64; + ctx.gpr[6] = iosb as u64; + ctx.gpr[10] = FILE_OPEN as u64; + nt_create_file(&mut ctx, &mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "non-disc missing path keeps synth-empty" + ); + let handle = mem.read_u32(handle_out); + assert!(handle >= 0x1000, "synth handle must be allocated"); + assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32); + } + + /// `resolve_cache_path` rejects path-traversal attempts so a guest + /// can't escape the cache directory by passing `cache:\..\..\etc\foo`. + #[test] + fn cache_resolve_strips_path_traversal() { + let dir = std::env::temp_dir().join(format!( + "xenia-rs-cache-test-trav-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut state = KernelState::new(); + state.init_cache_root(dir.clone()).unwrap(); + let resolved = state + .resolve_cache_path("cache:\\..\\..\\etc\\foo") + .expect("must resolve"); + assert!(resolved.starts_with(&dir), "must stay inside cache root"); + assert!(resolved.ends_with("etc/foo")); + std::fs::remove_dir_all(&dir).ok(); + } + + // ===== Stage 2 Batch 2: Crypto handlers ===== + + #[test] + fn xe_crypt_sha_empty_input_writes_canonical_digest() { + let (mut ctx, mem, mut state) = fresh(); + let input_ptr = SCRATCH_BASE; + let output_ptr = SCRATCH_BASE + 0x100; + ctx.gpr[3] = input_ptr as u64; + ctx.gpr[4] = 0; // input_1_size = 0 (skips this buffer) + ctx.gpr[5] = 0; + ctx.gpr[6] = 0; + ctx.gpr[7] = 0; + ctx.gpr[8] = 0; + ctx.gpr[9] = output_ptr as u64; + ctx.gpr[10] = 20; + xe_crypt_sha(&mut ctx, &mem, &mut state); + let mut got = [0u8; 20]; + mem.read_bytes(output_ptr, &mut got); + // SHA-1 of empty input + let expected: [u8; 20] = [ + 0xDA, 0x39, 0xA3, 0xEE, 0x5E, 0x6B, 0x4B, 0x0D, 0x32, 0x55, 0xBF, 0xEF, 0x95, 0x60, + 0x18, 0x90, 0xAF, 0xD8, 0x07, 0x09, + ]; + assert_eq!(got, expected); + } + + #[test] + fn xe_crypt_sha_three_inputs_concatenate() { + let (mut ctx, mem, mut state) = fresh(); + let buf_a = SCRATCH_BASE; + let buf_b = SCRATCH_BASE + 0x10; + let buf_c = SCRATCH_BASE + 0x20; + let output_ptr = SCRATCH_BASE + 0x100; + mem.write_bytes(buf_a, b"abc"); + mem.write_bytes(buf_b, b"def"); + mem.write_bytes(buf_c, b"ghi"); + ctx.gpr[3] = buf_a as u64; + ctx.gpr[4] = 3; + ctx.gpr[5] = buf_b as u64; + ctx.gpr[6] = 3; + ctx.gpr[7] = buf_c as u64; + ctx.gpr[8] = 3; + ctx.gpr[9] = output_ptr as u64; + ctx.gpr[10] = 20; + xe_crypt_sha(&mut ctx, &mem, &mut state); + let mut got = [0u8; 20]; + mem.read_bytes(output_ptr, &mut got); + // SHA-1("abcdefghi") = c63b19f1e4c8b5f76b25c49b8b87f57d8e4872a1 + let expected: [u8; 20] = [ + 0xC6, 0x3B, 0x19, 0xF1, 0xE4, 0xC8, 0xB5, 0xF7, 0x6B, 0x25, 0xC4, 0x9B, 0x8B, 0x87, + 0xF5, 0x7D, 0x8E, 0x48, 0x72, 0xA1, + ]; + assert_eq!(got, expected); + } + + #[test] + fn xe_crypt_sha_truncates_output() { + let (mut ctx, mem, mut state) = fresh(); + let output_ptr = SCRATCH_BASE + 0x100; + // Pre-fill 0xFF so we can verify only 4 bytes were written. + mem.write_bytes(output_ptr, &[0xFFu8; 20]); + ctx.gpr[3] = 0; + ctx.gpr[4] = 0; + ctx.gpr[5] = 0; + ctx.gpr[6] = 0; + ctx.gpr[7] = 0; + ctx.gpr[8] = 0; + ctx.gpr[9] = output_ptr as u64; + ctx.gpr[10] = 4; // truncate to 4 bytes + xe_crypt_sha(&mut ctx, &mem, &mut state); + // First 4 bytes match SHA-1 of empty; next 16 stay 0xFF. + let mut got = [0u8; 20]; + mem.read_bytes(output_ptr, &mut got); + assert_eq!(&got[..4], &[0xDA, 0x39, 0xA3, 0xEE]); + assert_eq!(&got[4..], &[0xFFu8; 16]); + } + + #[test] + fn xe_keys_console_private_key_sign_writes_certificate_and_returns_one() { + let (mut ctx, mem, mut state) = fresh(); + let hash_ptr = SCRATCH_BASE; + let output_ptr = SCRATCH_BASE + 0x100; + ctx.gpr[3] = hash_ptr as u64; + ctx.gpr[4] = output_ptr as u64; + xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 1, "must return success"); + // console_type at 0x18 (u32 BE) = Retail (2) + assert_eq!(mem.read_u32(output_ptr + 0x18), 2); + // manufacture_date at 0x1C + let mut mfg = [0u8; 8]; + mem.read_bytes(output_ptr + 0x1C, &mut mfg); + assert_eq!(mfg, [2, 0, 0, 5, 1, 1, 2, 2]); + // XE_CONSOLE_ID byte 0 at offset 0x02 + assert_eq!(mem.read_u8(output_ptr + 0x02), 0x93); + // cert_size and console_part_number must remain zero (Zero() output) + assert_eq!(mem.read_u16(output_ptr), 0); + assert_eq!(mem.read_u8(output_ptr + 0x07), 0); + } + + // ===== Stage 2 Batch 6: ExGetXConfigSetting ===== + + #[test] + fn ex_get_xconfig_setting_user_language_returns_one() { + let (mut ctx, mem, mut state) = fresh(); + let buf = SCRATCH_BASE + 0x200; + let req = SCRATCH_BASE + 0x208; + mem.write_u32(buf, 0xDEAD_BEEF); + mem.write_u16(req, 0xFFFF); + ctx.gpr[3] = 0x03; // USER_CATEGORY + ctx.gpr[4] = 0x09; // USER_LANGUAGE + ctx.gpr[5] = buf as u64; + ctx.gpr[6] = 4; + ctx.gpr[7] = req as u64; + ex_get_xconfig_setting(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS"); + assert_eq!(mem.read_u32(buf), 1, "USER_LANGUAGE = en"); + assert_eq!(mem.read_u16(req), 4, "required_size = 4 bytes"); + } + + #[test] + fn ex_get_xconfig_setting_unknown_returns_invalid_parameter() { + let (mut ctx, mem, mut state) = fresh(); + let buf = SCRATCH_BASE + 0x200; + ctx.gpr[3] = 0xDEAD; + ctx.gpr[4] = 0xBEEF; + ctx.gpr[5] = buf as u64; + ctx.gpr[6] = 4; + ctx.gpr[7] = 0; + ex_get_xconfig_setting(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0xC000_00F0, "STATUS_INVALID_PARAMETER_2"); + } + + #[test] + fn ex_get_xconfig_setting_buffer_too_small_returns_error() { + let (mut ctx, mem, mut state) = fresh(); + let buf = SCRATCH_BASE + 0x200; + mem.write_u32(buf, 0xDEAD_BEEF); + ctx.gpr[3] = 0x03; // USER_CATEGORY + ctx.gpr[4] = 0x09; // USER_LANGUAGE (4 bytes) + ctx.gpr[5] = buf as u64; + ctx.gpr[6] = 2; // too small + ctx.gpr[7] = 0; + ex_get_xconfig_setting(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0xC000_0023, "STATUS_BUFFER_TOO_SMALL"); + // Buffer untouched + assert_eq!(mem.read_u32(buf), 0xDEAD_BEEF); + } + + // ===== Stage 2 Batch 5: IRQL pair ===== + + /// Stage 2 Batch 5: `KeRaiseIrqlToDpcLevel` reads PCR's current_irql, + /// returns it in r3, and writes DISPATCH_LEVEL=2 back. + #[test] + fn ke_raise_irql_to_dpc_level_returns_old_writes_dispatch_level() { + let (mut ctx, mem, mut state) = fresh(); + let pcr = SCRATCH_BASE + 0x500; + // Initial IRQL = PASSIVE_LEVEL (0). + mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0); + ctx.gpr[13] = pcr as u64; + ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0, "old IRQL = PASSIVE_LEVEL"); + assert_eq!( + mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), + 2, + "PCR.current_irql = DISPATCH_LEVEL" + ); + // Second Raise returns 2 (already at DPC). + ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 2); + assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2); + } + + /// Stage 2 Batch 5: Raise → Lower round-trip leaves PCR at the value + /// passed to Lower. Demonstrates the IRQL nesting invariant. + #[test] + fn ke_irql_raise_lower_round_trip() { + let (mut ctx, mem, mut state) = fresh(); + let pcr = SCRATCH_BASE + 0x500; + mem.write_u8(pcr + PCR_CURRENT_IRQL_OFFSET, 0); + ctx.gpr[13] = pcr as u64; + ke_raise_irql_to_dpc_level(&mut ctx, &mem, &mut state); + let prev = ctx.gpr[3] as u8; + assert_eq!(prev, 0); + assert_eq!(mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), 2); + // Restore. + ctx.gpr[3] = prev as u64; + kf_lower_irql(&mut ctx, &mem, &mut state); + assert_eq!( + mem.read_u8(pcr + PCR_CURRENT_IRQL_OFFSET), + 0, + "PCR.current_irql restored to PASSIVE_LEVEL" + ); + } + + #[test] + fn xe_keys_console_private_key_sign_rejects_null_inputs() { + let (mut ctx, mem, mut state) = fresh(); + let output_ptr = SCRATCH_BASE + 0x100; + // null hash + ctx.gpr[3] = 0; + ctx.gpr[4] = output_ptr as u64; + xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0, "must return failure on null hash"); + // null output + ctx.gpr[3] = 0x1234_5678; + ctx.gpr[4] = 0; + xe_keys_console_private_key_sign(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 0, "must return failure on null output"); + } + + // --------------------------------------------------------------- + // Phase C+7 — KeSetEvent / NtSetEvent canary-parity return value + // --------------------------------------------------------------- + + /// Canary parity: `KeSetEvent` on an unsignaled auto-reset event + /// must return constant `1` (NOT prior state). See investigation + /// for the `XEvent::Set` reference path. + #[test] + fn ke_set_event_returns_constant_one_on_unsignaled_auto_reset() { + let (mut ctx, mut mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0x900; + write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // auto-reset, unsignaled + ctx.gpr[3] = kevent_ptr as u64; + ke_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], 1, + "KeSetEvent must return constant 1 on success (canary parity, xevent.cc:60-64)" + ); + // Shadow must be signaled even though the return value is constant. + match state.objects.get(&kevent_ptr) { + Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), + _ => panic!("shadow not minted"), + } + } + + /// Canary parity: `KeSetEvent` on an already-signaled manual-reset + /// event also returns constant `1` (not prior `1`). Same constant. + #[test] + fn ke_set_event_returns_constant_one_on_already_signaled_manual_reset() { + let (mut ctx, mut mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0xA00; + write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // manual-reset, signaled + ctx.gpr[3] = kevent_ptr as u64; + ke_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], 1, + "KeSetEvent returns 1 regardless of prior state (canary parity)" + ); + match state.objects.get(&kevent_ptr) { + Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), + _ => panic!("shadow vanished"), + } + } + + /// Canary parity: `NtSetEvent` with null `PreviousState` ptr returns + /// STATUS_SUCCESS and performs no out-pointer write. + #[test] + fn nt_set_event_null_prev_ptr_returns_status_success_no_write() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::Event { + manual_reset: false, + signaled: false, + waiters: Vec::new(), + }); + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = 0; // null out-pointer + nt_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "NtSetEvent must return STATUS_SUCCESS" + ); + // Event must be signaled. + match state.objects.get(&handle) { + Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), + _ => panic!("handle lookup broken"), + } + } + + /// Canary parity: `NtSetEvent` with a valid out-pointer writes + /// **constant 1** (canary's `was_signalled = ev->Set()` always 1), + /// NOT the prior signaled state. See xboxkrnl_threading.cc:610-628. + #[test] + fn nt_set_event_valid_prev_ptr_writes_constant_one_and_returns_success() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::Event { + manual_reset: false, + signaled: false, + waiters: Vec::new(), + }); + let prev_ptr = SCRATCH_BASE + 0xB00; + mem.write_u32(prev_ptr, 0xDEAD_BEEF); // sentinel — overwrite expected + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = prev_ptr as u64; + nt_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "NtSetEvent must return STATUS_SUCCESS" + ); + assert_eq!( + mem.read_u32(prev_ptr), + 1, + "PreviousState out-ptr must receive constant 1 (canary parity)" + ); + } + + /// Canary parity: `NtSetEvent` on an already-signaled event still + /// writes constant `1` to the out-pointer (not the prior `1`, + /// though they happen to match here — distinguished from the + /// prior-state-write bug by the auto-reset/un-signaled case above). + #[test] + fn nt_set_event_on_signaled_event_writes_one() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::Event { + manual_reset: true, + signaled: true, + waiters: Vec::new(), + }); + let prev_ptr = SCRATCH_BASE + 0xC00; + mem.write_u32(prev_ptr, 0); + ctx.gpr[3] = handle as u64; + ctx.gpr[4] = prev_ptr as u64; + nt_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!(mem.read_u32(prev_ptr), 1); + // Event stays signaled (manual-reset). + match state.objects.get(&handle) { + Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), + _ => panic!("handle lookup broken"), + } + } + + /// Wake-cascade regression: KeSetEvent on a manual-reset event with + /// a parked waiter still wakes the waiter post-fix. The return-value + /// change is observation-only — internal wake plumbing uses the + /// `previous` read, not the return value. + #[test] + fn ke_set_event_post_fix_still_wakes_waiter() { + let (mut ctx, mut mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0xD00; + write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled + // Mint the shadow first by calling reset_event (no waiter yet). + ctx.gpr[3] = kevent_ptr as u64; + ke_reset_event(&mut ctx, &mut mem, &mut state); + // Park a fake waiter. + match state.objects.get_mut(&kevent_ptr) { + Some(KernelObject::Event { waiters, .. }) => { + waiters.push(ThreadRef { hw_id: 4, idx: 0, generation: 0 }); + } + _ => panic!("shadow not minted"), + } + // Signal. + ctx.gpr[3] = kevent_ptr as u64; + ke_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], 1, "constant 1 return preserved"); + // Manual-reset: waiter list drained after wake. + match state.objects.get(&kevent_ptr) { + Some(KernelObject::Event { signaled, waiters, .. }) => { + assert!(*signaled, "manual-reset stays signaled"); + assert!(waiters.is_empty(), "manual-reset wake drains all waiters"); + } + _ => panic!("shadow vanished"), + } + } + + // --------------------------------------------------------------- + // Phase C+8 — KeResetEvent canary-parity return value (sibling of C+7) + // --------------------------------------------------------------- + + /// Canary parity: `KeResetEvent` on an unsignaled manual-reset event + /// must return constant `1` on shadow hit (NOT prior `0`). Canary's + /// `XEvent::Reset` hardcodes `return 1` regardless of prior state + /// (xevent.cc:72-75), exactly mirroring `XEvent::Set`. This is the + /// case that triggered the Phase A divergence at idx=102164: prior + /// state was unsignaled (`0`) and the prior-state-return bug gave + /// `0` while canary returns `1`. + #[test] + fn ke_reset_event_returns_constant_one_on_unsignaled_manual_reset() { + let (mut ctx, mut mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0xE00; + write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled + ctx.gpr[3] = kevent_ptr as u64; + ke_reset_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], 1, + "KeResetEvent must return constant 1 on success (canary parity, xevent.cc:72-75)" + ); + // Shadow stays unsignaled (was already 0, reset is idempotent). + match state.objects.get(&kevent_ptr) { + Some(KernelObject::Event { signaled, .. }) => assert!(!*signaled), + _ => panic!("shadow not minted"), + } + } + + /// Canary parity: `KeResetEvent` on a signaled auto-reset event also + /// returns constant `1`. Distinguished from the prior-state-return + /// bug by the unsignaled case above (where they would differ: bug=0 + /// vs canary=1). + #[test] + fn ke_reset_event_returns_constant_one_on_signaled_auto_reset() { + let (mut ctx, mut mem, mut state) = fresh(); + let kevent_ptr = SCRATCH_BASE + 0xF00; + write_dispatcher_header(&mut mem, kevent_ptr, 1, 1); // auto-reset, signaled + ctx.gpr[3] = kevent_ptr as u64; + ke_reset_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], 1, + "KeResetEvent returns 1 regardless of prior state (canary parity)" + ); + match state.objects.get(&kevent_ptr) { + Some(KernelObject::Event { signaled, .. }) => { + assert!(!*signaled, "ke_reset_event must clear the shadow"); + } + _ => panic!("shadow vanished"), + } + } + + /// Canary parity: `KeResetEvent` on a non-existent shadow (and a + /// PKEVENT that doesn't match a dispatcher type the lazy-shadow can + /// mint) must return `0` — canary's `assert_always(); return 0` arm + /// for the no-XEvent-bound case (xboxkrnl_threading.cc:566-574). + /// We model this via a pointer below the dispatcher-shim threshold + /// (handle range, no kevent header pre-written). + #[test] + fn ke_reset_event_returns_zero_on_missing_object() { + let (mut ctx, mut mem, mut state) = fresh(); + // Use a low handle-range value with no allocated object — no + // shadow mint (handle path), no dispatcher header to lazy-mint + // from (ptr below 0x10000 means ensure_dispatcher_object skips). + ctx.gpr[3] = 0x4242; // arbitrary handle that doesn't exist + ke_reset_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], 0, + "KeResetEvent must return 0 when no event object is bound (canary's assert_always arm)" + ); + } + + /// `NtClearEvent` parity: returns `STATUS_SUCCESS` and resets the + /// shadow signaled flag. Unlike NtSetEvent, NtClearEvent has NO + /// PreviousState out-pointer (xboxkrnl_threading.cc:685-687 → + /// xeNtClearEvent calls XEvent::Clear which is void-returning). + /// Verified canary-parity; included for symmetry coverage. + #[test] + fn nt_clear_event_resets_shadow_and_returns_status_success() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle = state.alloc_handle_for(KernelObject::Event { + manual_reset: true, + signaled: true, + waiters: Vec::new(), + }); + ctx.gpr[3] = handle as u64; + nt_clear_event(&mut ctx, &mut mem, &mut state); + assert_eq!( + ctx.gpr[3], STATUS_SUCCESS, + "NtClearEvent must return STATUS_SUCCESS on hit" + ); + match state.objects.get(&handle) { + Some(KernelObject::Event { signaled, .. }) => { + assert!(!*signaled, "nt_clear_event must clear the shadow"); + } + _ => panic!("handle lookup broken"), + } + } + + /// Phase C+16: `ExCreateThread` must install a thread self-reference + /// (handle refcount = 2 post-spawn). Mirrors canary's + /// `XThread::Create::RetainHandle()` at xthread.cc:414. Without + /// this, a guest `NtClose` on the thread handle destroys it + /// prematurely while the spawned thread is still live — the + /// original C+16 divergence at Phase A idx=102168. + #[test] + fn ex_create_thread_installs_self_reference() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle_ptr = SCRATCH_BASE + 0x100; + let thread_id_ptr = SCRATCH_BASE + 0x108; + ctx.gpr[3] = handle_ptr as u64; + ctx.gpr[4] = 0x10000; // stack_size + ctx.gpr[5] = thread_id_ptr as u64; + ctx.gpr[6] = 0; // xapi_startup + ctx.gpr[7] = 0x8200_1000; // start_address + ctx.gpr[8] = 0; // start_context + ctx.gpr[9] = 0; // creation_flags (not suspended, affinity = 0) + ex_create_thread(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "ExCreateThread must succeed"); + let handle = mem.read_u32(handle_ptr); + assert_eq!( + state.handle_refcount.get(&handle).copied(), + Some(2), + "ExCreateThread must install self-ref (refcount = creator + self = 2)" + ); + } + + /// Phase C+16: `ExTerminateThread` releases the self-reference. The + /// thread terminates from inside its own context, so we spawn a + /// worker via `ex_create_thread`, switch to its slot, and then + /// terminate. Post-terminate: refcount = 1 (creator-only, handle + /// still alive). Mirrors canary's `XThread::Exit::ReleaseHandle()` + /// at xthread.cc:524. + #[test] + fn ex_terminate_thread_releases_self_reference() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle_ptr = SCRATCH_BASE + 0x100; + let thread_id_ptr = SCRATCH_BASE + 0x108; + ctx.gpr[3] = handle_ptr as u64; + ctx.gpr[4] = 0x10000; + ctx.gpr[5] = thread_id_ptr as u64; + ctx.gpr[6] = 0; + ctx.gpr[7] = 0x8200_1000; + ctx.gpr[8] = 0; + ctx.gpr[9] = 0; + ex_create_thread(&mut ctx, &mut mem, &mut state); + let handle = mem.read_u32(handle_ptr); + assert_eq!(state.handle_refcount.get(&handle).copied(), Some(2)); + + // Switch to the spawned thread's slot so `exit_current` sees it. + let r = state + .scheduler + .find_by_handle(handle) + .expect("spawned thread must be findable"); + state.scheduler.current = Some(r); + + let mut term_ctx = PpcContext::default(); + term_ctx.gpr[3] = 0; // exit_code + ex_terminate_thread(&mut term_ctx, &mem, &mut state); + + // self-ref dropped → refcount = 1 (creator still holds). + assert_eq!( + state.handle_refcount.get(&handle).copied(), + Some(1), + "ex_terminate_thread must release the self-ref" + ); + assert!( + state.objects.contains_key(&handle), + "object must survive (creator-ref still held)" + ); + } + + /// Phase C+16: end-to-end refcount lifecycle balance. Spawn → + /// user closes → thread exits → object destroyed. No leak. + #[test] + fn ex_create_then_close_then_exit_balances_refcount() { + let (mut ctx, mut mem, mut state) = fresh(); + let handle_ptr = SCRATCH_BASE + 0x100; + let thread_id_ptr = SCRATCH_BASE + 0x108; + ctx.gpr[3] = handle_ptr as u64; + ctx.gpr[4] = 0x10000; + ctx.gpr[5] = thread_id_ptr as u64; + ctx.gpr[6] = 0; + ctx.gpr[7] = 0x8200_1000; + ctx.gpr[8] = 0; + ctx.gpr[9] = 0; + ex_create_thread(&mut ctx, &mut mem, &mut state); + let handle = mem.read_u32(handle_ptr); + + // User NtClose: refcount 2 → 1, object survives. + let mut close_ctx = PpcContext::default(); + close_ctx.gpr[3] = handle as u64; + nt_close(&mut close_ctx, &mem, &mut state); + assert!(state.objects.contains_key(&handle)); + assert_eq!(state.handle_refcount.get(&handle).copied(), Some(1)); + + // Thread exits: refcount 1 → 0, object destroyed. + let r = state + .scheduler + .find_by_handle(handle) + .expect("must still be findable"); + state.scheduler.current = Some(r); + let mut term_ctx = PpcContext::default(); + term_ctx.gpr[3] = 0; + ex_terminate_thread(&mut term_ctx, &mem, &mut state); + + assert!( + !state.objects.contains_key(&handle), + "object must be destroyed at zero refcount" + ); + assert!( + !state.handle_refcount.contains_key(&handle), + "refcount entry must be scrubbed" + ); + } + + // ===== Phase C+19: NtDuplicateObject fresh-slot semantics ===== + + /// Helper: create an Event and duplicate it; return (source, dup, state). + fn create_event_and_dup( + mem: &GuestMemory, + state: &mut KernelState, + ) -> (u32, u32) { + let source = state.alloc_handle_for(KernelObject::Event { + manual_reset: false, + signaled: false, + waiters: Vec::new(), + }); + let mut ctx = PpcContext::default(); + ctx.gpr[3] = source as u64; + let out_ptr = SCRATCH_BASE + 0x100; + mem.write_u32(out_ptr, 0xDEAD_BEEF); + ctx.gpr[4] = out_ptr as u64; + ctx.gpr[5] = 0; // no DUPLICATE_CLOSE_SOURCE + nt_duplicate_object(&mut ctx, mem, state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + let dup = mem.read_u32(out_ptr); + (source, dup) + } + + /// Phase C+19: dup id is a *fresh* slot, NOT aliased to source. Mirrors + /// canary's `ObjectTable::DuplicateHandle` → `AddHandle` (object_table.cc:210). + #[test] + fn nt_duplicate_object_allocates_fresh_handle_id() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + assert_ne!(dup, source, "dup id must be distinct from source"); + assert_ne!(dup, 0, "dup id must be non-zero"); + } + + /// AUDIT-062 INVARIANT (signal-on-dup wakes wait-on-source): the dup + /// alias canonicalizes back to the source `state.objects` entry, so + /// signaling the dup mutates the same `KernelObject::Event` that the + /// source slot points at. This is THE load-bearing test — if it fails + /// the C+19 fix has broken the AUDIT-062 worker-cluster wedge. + #[test] + fn nt_duplicate_object_signal_on_dup_wakes_wait_on_source() { + let (mut ctx, mut mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + // Signal via dup. + ctx.gpr[3] = dup as u64; + ctx.gpr[4] = 0; + nt_set_event(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + + // Source's event entry must show signaled=true (shared underlying). + match state.objects.get(&source) { + Some(KernelObject::Event { signaled, .. }) => { + assert!(*signaled, "source event must be signaled by dup signal"); + } + _ => panic!("source lookup must hit the canonical Event"), + } + } + + /// Symmetric: signal-on-source wakes wait-on-dup. Both lookup paths + /// canonicalize to the same entry. + #[test] + fn nt_duplicate_object_signal_on_source_visible_via_dup() { + let (mut ctx, mut mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + ctx.gpr[3] = source as u64; + ctx.gpr[4] = 0; + nt_set_event(&mut ctx, &mut mem, &mut state); + + // Resolve dup → source and check signaled. + let canonical = state.resolve_handle(dup); + assert_eq!(canonical, source); + match state.objects.get(&canonical) { + Some(KernelObject::Event { signaled, .. }) => { + assert!(*signaled); + } + _ => panic!(), + } + } + + /// Refcount: both source and dup slots independently get + /// `handle_refcount = 1`. The canonical's `canonical_slot_count` rises + /// to 2 (one per slot). Mirrors canary AddHandle (one Retain per slot). + #[test] + fn nt_duplicate_object_refcount_lifecycle() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + assert_eq!(state.handle_refcount.get(&source).copied(), Some(1)); + assert_eq!(state.handle_refcount.get(&dup).copied(), Some(1)); + assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(2)); + assert_eq!(state.handle_aliases.get(&dup).copied(), Some(source)); + } + + /// Close the dup first: dup slot is gone, source slot remains, underlying + /// object remains. Symmetric to canary's per-slot `RemoveHandle` (the + /// underlying XObject survives until the last slot is gone). + #[test] + fn nt_duplicate_object_then_close_dup_keeps_source_live() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + let mut close_ctx = PpcContext::default(); + close_ctx.gpr[3] = dup as u64; + nt_close(&mut close_ctx, &mem, &mut state); + + assert!(!state.handle_refcount.contains_key(&dup)); + assert!(!state.handle_aliases.contains_key(&dup)); + assert!(state.objects.contains_key(&source)); + assert_eq!(state.handle_refcount.get(&source).copied(), Some(1)); + assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1)); + } + + /// Close source first: source slot is gone, dup slot remains, and + /// crucially the underlying object remains so the dup can still be + /// used. Sister of the above. + #[test] + fn nt_duplicate_object_then_close_source_keeps_dup_live() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + let mut close_ctx = PpcContext::default(); + close_ctx.gpr[3] = source as u64; + nt_close(&mut close_ctx, &mem, &mut state); + + assert!(!state.handle_refcount.contains_key(&source)); + // Underlying object survives (canonical entry alive through dup slot). + assert!(state.objects.contains_key(&source)); + // Dup still points at it. + assert_eq!(state.resolve_handle(dup), source); + // Slot count down to 1 (just the dup). + assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1)); + + // Signal through dup still works. + let mut set_ctx = PpcContext::default(); + let mut mem = mem; + set_ctx.gpr[3] = dup as u64; + set_ctx.gpr[4] = 0; + nt_set_event(&mut set_ctx, &mut mem, &mut state); + match state.objects.get(&source) { + Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), + _ => panic!(), + } + } + + /// Final close on the last surviving slot drops the canonical object. + #[test] + fn nt_duplicate_object_close_both_destroys_underlying() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup) = create_event_and_dup(&mem, &mut state); + + let mut close_dup = PpcContext::default(); + close_dup.gpr[3] = dup as u64; + nt_close(&mut close_dup, &mem, &mut state); + + let mut close_src = PpcContext::default(); + close_src.gpr[3] = source as u64; + nt_close(&mut close_src, &mem, &mut state); + + assert!(!state.objects.contains_key(&source)); + assert!(!state.handle_refcount.contains_key(&source)); + assert!(!state.canonical_slot_count.contains_key(&source)); + } + + /// DUPLICATE_CLOSE_SOURCE: dup happens AND source is closed atomically. + /// Net result: dup is live, source is gone. + #[test] + fn nt_duplicate_object_with_close_source_flag() { + let (mut ctx, mut mem, mut state) = fresh(); + let source = state.alloc_handle_for(KernelObject::Event { + manual_reset: false, + signaled: false, + waiters: Vec::new(), + }); + + let out_ptr = SCRATCH_BASE + 0x200; + mem.write_u32(out_ptr, 0); + ctx.gpr[3] = source as u64; + ctx.gpr[4] = out_ptr as u64; + ctx.gpr[5] = 0x1; // DUPLICATE_CLOSE_SOURCE + nt_duplicate_object(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + + let dup = mem.read_u32(out_ptr); + assert_ne!(dup, source); + + // Source slot scrubbed. + assert!(!state.handle_refcount.contains_key(&source)); + // But the canonical object is still alive through dup. + assert!(state.objects.contains_key(&source)); + // Slot count is exactly 1 (the dup). + assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(1)); + // Dup alias points at canonical. + assert_eq!(state.resolve_handle(dup), source); + } + + /// Invalid source handle: STATUS_INVALID_HANDLE + zero write to out_ptr. + #[test] + fn nt_duplicate_object_invalid_handle_returns_invalid_handle() { + let (mut ctx, mut mem, mut state) = fresh(); + let out_ptr = SCRATCH_BASE + 0x300; + mem.write_u32(out_ptr, 0xCAFE_BABE); + ctx.gpr[3] = 0x9999 as u64; // bogus + ctx.gpr[4] = out_ptr as u64; + ctx.gpr[5] = 0; + nt_duplicate_object(&mut ctx, &mut mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); + assert_eq!(mem.read_u32(out_ptr), 0); + } + + /// Double-dup: dup of a dup canonicalizes to the original source. + /// Mirrors canary's `LookupObject(TranslateHandle(handle), false)` which + /// resolves through nested dups by hitting the same `XObject*`. + #[test] + fn nt_duplicate_object_dup_of_dup_canonicalizes() { + let (_ctx, mem, mut state) = fresh(); + let (source, dup1) = create_event_and_dup(&mem, &mut state); + + // Now dup the dup. + let mut ctx = PpcContext::default(); + ctx.gpr[3] = dup1 as u64; + let out_ptr = SCRATCH_BASE + 0x400; + mem.write_u32(out_ptr, 0); + ctx.gpr[4] = out_ptr as u64; + ctx.gpr[5] = 0; + nt_duplicate_object(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], STATUS_SUCCESS); + let dup2 = mem.read_u32(out_ptr); + + assert_ne!(dup2, source); + assert_ne!(dup2, dup1); + // All three resolve to the same canonical source. + assert_eq!(state.resolve_handle(dup1), source); + assert_eq!(state.resolve_handle(dup2), source); + // Slot count reflects 3 live slots. + assert_eq!(state.canonical_slot_count.get(&source).copied(), Some(3)); + } + + /// Aliased dup with non-Event kernel objects also works. Mirrors + /// canary's `XObject::Type` codes (Event/Mutant/Semaphore/...). + #[test] + fn nt_duplicate_object_works_for_semaphore() { + let (_ctx, mem, mut state) = fresh(); + let source = state.alloc_handle_for(KernelObject::Semaphore { + count: 3, + max: 10, + waiters: Vec::new(), + }); + let mut ctx = PpcContext::default(); + ctx.gpr[3] = source as u64; + let out_ptr = SCRATCH_BASE + 0x600; + mem.write_u32(out_ptr, 0); + ctx.gpr[4] = out_ptr as u64; + ctx.gpr[5] = 0; + nt_duplicate_object(&mut ctx, &mem, &mut state); + + let dup = mem.read_u32(out_ptr); + assert_ne!(dup, source); + assert_eq!(state.resolve_handle(dup), source); + // Underlying count unchanged. + match state.objects.get(&source) { + Some(KernelObject::Semaphore { count, max, .. }) => { + assert_eq!(*count, 3); + assert_eq!(*max, 10); + } + _ => panic!(), + } + } + + /// Phase W: ensure `VdInitializeEngines` writes `r3=1` (canary's + /// literal return value, not `STATUS_SUCCESS=0`). Anchored on the + /// helper directly so the registration is exercised end-to-end via + /// a separate code-path check (no need to actually issue the import + /// call). The `// canary returns 1` invariant is the entirety of + /// the fix. + #[test] + fn vd_initialize_engines_returns_one() { + let (mut ctx, mem, mut state) = fresh(); + ctx.gpr[3] = 0xDEAD_BEEF; // sentinel — must be overwritten + stub_return_one(&mut ctx, &mem, &mut state); + assert_eq!(ctx.gpr[3], 1, "stub_return_one must put 1 in r3"); } } diff --git a/crates/xenia-kernel/src/xaudio.rs b/crates/xenia-kernel/src/xaudio.rs index c20fe94..cb09261 100644 --- a/crates/xenia-kernel/src/xaudio.rs +++ b/crates/xenia-kernel/src/xaudio.rs @@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333); /// queueing unbounded callbacks while injection is starved. pub const XAUDIO_QUEUE_CAP: usize = 16; +/// Phase HostAudioEager (2026-05-19): initial seeded fire count at +/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary +/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210) +/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment +/// canary's `RegisterClient` returns, its already-running host worker +/// thread has 8 buffer-complete fires queued to drain. +/// +/// In ours, the dedicated guest audio worker (spawned at the same +/// register call) can't be HOST-threaded; instead we seed the pending +/// FIFO so the round prologue's `try_inject_audio_callback` injects +/// the first callback on the very next round — well before tid=1 +/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15 +/// in canary, tid=9/10 in ours). This fixes the ordering issue where +/// the 48k-instruction ticker delay let tid=9/10 spawn and enter +/// their spin loop on the uninitialized voice struct before the +/// callback could modify it. +pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8; + #[derive(Debug, Clone, Copy)] pub struct XAudioClient { pub callback_pc: u32, @@ -155,6 +173,28 @@ impl XAudioState { } } + /// Phase HostAudioEager: enqueue `n` buffer-complete fires for a + /// specific client slot. Used by `XAudioRegisterRenderDriverClient` + /// to mirror canary's `client_semaphore->Release(queued_frames_)` + /// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid + /// unbounded growth if the caller seeds aggressively. Returns the + /// actual number of fires enqueued. + pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize { + if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() { + return 0; + } + let mut queued = 0; + for _ in 0..n { + if self.pending.len() >= XAUDIO_QUEUE_CAP { + self.dropped += 1; + break; + } + self.pending.push_back(index); + queued += 1; + } + queued + } + pub fn peek_next(&self) -> Option { self.pending.front().copied() } @@ -320,6 +360,51 @@ mod tests { assert!(s.last_instant.is_some()); } + #[test] + fn seed_fires_for_registered_slot_enqueues_n() { + let mut s = XAudioState::default(); + let i = s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES); + assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES); + assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES); + // All enqueued fires reference our slot. + for _ in 0..XAUDIO_REGISTER_SEED_FIRES { + assert_eq!(s.take_next(), Some(i)); + } + assert!(s.pending.is_empty()); + } + + #[test] + fn seed_fires_for_unregistered_slot_is_noop() { + let mut s = XAudioState::default(); + // Slot 3 is empty. + let queued = s.seed_fires_for(3, 8); + assert_eq!(queued, 0); + assert!(s.pending.is_empty()); + assert_eq!(s.dropped, 0); + } + + #[test] + fn seed_fires_for_caps_at_queue_cap_and_counts_drops() { + let mut s = XAudioState::default(); + let i = s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4); + assert_eq!(queued, XAUDIO_QUEUE_CAP); + assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP); + // Excess fires are counted as dropped (per + // existing `enqueue_all_active` discipline). + assert!(s.dropped >= 1); + } + + #[test] + fn seed_fires_for_out_of_range_index_is_noop() { + let mut s = XAudioState::default(); + s.register(dummy_client(1)).unwrap(); + let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4); + assert_eq!(queued, 0); + assert!(s.pending.is_empty()); + } + #[test] fn tick_wallclock_fires_after_period() { let mut s = XAudioState::default();