//! HLE kernel export implementations (xboxkrnl.exe). //! Each export mirrors a function from xboxkrnl_table.inc. use crate::objects::KernelObject; use crate::state::{GuestMemoryPcr, KernelState, ModuleId}; use crate::thread::allocate_thread_image; use xenia_cpu::scheduler::{BlockReason, SpawnParams}; use xenia_cpu::{PpcContext, ThreadRef}; use xenia_memory::{GuestMemory, MemoryAccess}; // NTSTATUS constants used by wait/sync paths. const STATUS_TIMEOUT: u64 = 0x0000_0102; pub fn register_exports(state: &mut KernelState) { use ModuleId::Xboxkrnl; // Debug state.register_export(Xboxkrnl, 0x01, "DbgBreakPoint", dbg_break_point); // 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); state.register_export(Xboxkrnl, 0x10, "ExGetXConfigSetting", ex_get_xconfig_setting); state.register_export(Xboxkrnl, 0x15, "ExRegisterTitleTerminateNotification", stub_success); state.register_export(Xboxkrnl, 0x19, "ExTerminateThread", ex_terminate_thread); // Hal state.register_export(Xboxkrnl, 0x28, "HalReturnToFirmware", hal_return_to_firmware); // I/O // 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); state.register_export(Xboxkrnl, 0x52, "KeBugCheck", ke_bug_check); state.register_export(Xboxkrnl, 0x53, "KeBugCheckEx", ke_bug_check_ex); state.register_export(Xboxkrnl, 0x5A, "KeDelayExecutionThread", ke_delay_execution_thread); state.register_export(Xboxkrnl, 0x5D, "KeEnableFpuExceptions", stub_success); state.register_export(Xboxkrnl, 0x5F, "KeEnterCriticalRegion", stub_success); state.register_export(Xboxkrnl, 0x66, "KeGetCurrentProcessType", ke_get_current_process_type); state.register_export(Xboxkrnl, 0x6B, "KeLockL2", stub_success); state.register_export(Xboxkrnl, 0x6C, "KeUnlockL2", stub_success); state.register_export(Xboxkrnl, 0x74, "KeInitializeSemaphore", ke_initialize_semaphore); 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); // 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); // 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); // 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); state.register_export(Xboxkrnl, 0xAE, "KeTryToAcquireSpinLockAtRaisedIrql", ke_try_acquire_spinlock); 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_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); state.register_export(Xboxkrnl, 0x0154, "KeTlsGetValue", ke_tls_get_value); state.register_export(Xboxkrnl, 0x0155, "KeTlsSetValue", ke_tls_set_value); state.register_export(Xboxkrnl, 0x01DF, "KiApcNormalRoutineNop", stub_success); // Memory state.register_export(Xboxkrnl, 0xBA, "MmAllocatePhysicalMemoryEx", mm_allocate_physical_memory_ex); state.register_export(Xboxkrnl, 0xBB, "MmCreateKernelStack", mm_create_kernel_stack); state.register_export(Xboxkrnl, 0xBC, "MmDeleteKernelStack", stub_success); state.register_export(Xboxkrnl, 0xBD, "MmFreePhysicalMemory", stub_success); state.register_export(Xboxkrnl, 0xBE, "MmGetPhysicalAddress", mm_get_physical_address); state.register_export(Xboxkrnl, 0xC4, "MmQueryAddressProtect", mm_query_address_protect); state.register_export(Xboxkrnl, 0xC6, "MmQueryStatistics", mm_query_statistics); // Nt* state.register_export(Xboxkrnl, 0xCC, "NtAllocateVirtualMemory", nt_allocate_virtual_memory); state.register_export(Xboxkrnl, 0xCD, "NtCancelTimer", nt_cancel_timer); state.register_export(Xboxkrnl, 0xCE, "NtClearEvent", nt_clear_event); state.register_export(Xboxkrnl, 0xCF, "NtClose", nt_close); state.register_export(Xboxkrnl, 0xD1, "NtCreateEvent", nt_create_event); state.register_export(Xboxkrnl, 0xD2, "NtCreateFile", nt_create_file); state.register_export(Xboxkrnl, 0xD5, "NtCreateSemaphore", nt_create_semaphore); state.register_export(Xboxkrnl, 0xD7, "NtCreateTimer", nt_create_timer); state.register_export(Xboxkrnl, 0xD9, "NtDeviceIoControlFile", nt_device_io_control_file); state.register_export(Xboxkrnl, 0xDA, "NtDuplicateObject", nt_duplicate_object); state.register_export(Xboxkrnl, 0xDB, "NtFlushBuffersFile", stub_success); state.register_export(Xboxkrnl, 0xDC, "NtFreeVirtualMemory", stub_success); state.register_export(Xboxkrnl, 0xDF, "NtOpenFile", nt_open_file); state.register_export(Xboxkrnl, 0xE2, "NtPulseEvent", nt_pulse_event); state.register_export(Xboxkrnl, 0xE4, "NtQueryDirectoryFile", nt_query_directory_file); state.register_export(Xboxkrnl, 0xE7, "NtQueryFullAttributesFile", nt_query_full_attributes_file); state.register_export(Xboxkrnl, 0xE8, "NtQueryInformationFile", nt_query_information_file); state.register_export(Xboxkrnl, 0xEE, "NtQueryVirtualMemory", stub_success); state.register_export(Xboxkrnl, 0xEF, "NtQueryVolumeInformationFile", nt_query_volume_information_file); state.register_export(Xboxkrnl, 0xF0, "NtReadFile", nt_read_file); state.register_export(Xboxkrnl, 0xF3, "NtReleaseSemaphore", nt_release_semaphore); state.register_export(Xboxkrnl, 0xF5, "NtResumeThread", nt_resume_thread); state.register_export(Xboxkrnl, 0xF6, "NtSetEvent", nt_set_event); state.register_export(Xboxkrnl, 0xF7, "NtSetInformationFile", nt_set_information_file); state.register_export(Xboxkrnl, 0xFA, "NtSetTimerEx", nt_set_timer_ex); // NOTE: `NtSetInformationThread` is NOT in xboxkrnl_table.inc on // Xbox 360 — canary confirms ordinal 0xFB is // `NtSignalAndWaitForSingleObjectEx`. The prior registration at 0xFB // was silently overwritten by the registration below; the // `nt_set_information_thread` body is retained for the direct-call // unit test but no longer exposed as an ordinal. state.register_export(Xboxkrnl, 0xFC, "NtSuspendThread", nt_suspend_thread); state.register_export(Xboxkrnl, 0xFB, "NtSignalAndWaitForSingleObjectEx", nt_signal_and_wait_for_single_object_ex); state.register_export(Xboxkrnl, 0xFD, "NtWaitForSingleObjectEx", nt_wait_for_single_object_ex); state.register_export(Xboxkrnl, 0xFE, "NtWaitForMultipleObjectsEx", nt_wait_for_multiple_objects_ex); state.register_export(Xboxkrnl, 0xFF, "NtWriteFile", nt_write_file); state.register_export(Xboxkrnl, 0x0101, "NtYieldExecution", nt_yield_execution); // Object state.register_export(Xboxkrnl, 0x0103, "ObCreateSymbolicLink", stub_success); state.register_export(Xboxkrnl, 0x0104, "ObDeleteSymbolicLink", stub_success); state.register_export(Xboxkrnl, 0x0105, "ObDereferenceObject", stub_success); state.register_export(Xboxkrnl, 0x010B, "ObLookupThreadByThreadId", stub_success); state.register_export(Xboxkrnl, 0x010E, "ObOpenObjectByPointer", stub_success); state.register_export(Xboxkrnl, 0x0110, "ObReferenceObjectByHandle", ob_reference_object_by_handle); // RTL // 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_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); state.register_export(Xboxkrnl, 0x0130, "RtlLeaveCriticalSection", rtl_leave_critical_section); 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); // 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); // 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 // 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); state.register_export(Xboxkrnl, 0x01B4, "VdEnableDisableClockGating", stub_success); state.register_export(Xboxkrnl, 0x01B6, "VdEnableRingBufferRPtrWriteBack", vd_enable_ring_buffer_rptr_writeback); 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_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); state.register_export(Xboxkrnl, 0x01C7, "VdPersistDisplay", stub_success); state.register_export(Xboxkrnl, 0x01C9, "VdQueryVideoFlags", vd_query_video_flags); state.register_export(Xboxkrnl, 0x01CA, "VdQueryVideoMode", vd_query_video_mode); state.register_export(Xboxkrnl, 0x0269, "VdRetrainEDRAM", stub_success); state.register_export(Xboxkrnl, 0x026A, "VdRetrainEDRAMWorker", stub_success); state.register_export(Xboxkrnl, 0x01D3, "VdSetDisplayMode", stub_success); state.register_export(Xboxkrnl, 0x01D5, "VdSetGraphicsInterruptCallback", vd_set_graphics_interrupt_callback); state.register_export(Xboxkrnl, 0x01D9, "VdSetSystemCommandBufferGpuIdentifierAddress", stub_success); state.register_export(Xboxkrnl, 0x01DC, "VdShutdownEngines", stub_success); state.register_export(Xboxkrnl, 0x025B, "VdSwap", vd_swap); // Audio state.register_export(Xboxkrnl, 0x01F3, "XAudioRegisterRenderDriverClient", xaudio_register_render_driver); state.register_export(Xboxkrnl, 0x01F4, "XAudioUnregisterRenderDriverClient", xaudio_unregister_render_driver); state.register_export(Xboxkrnl, 0x01F5, "XAudioSubmitRenderDriverFrame", xaudio_submit_render_driver_frame); state.register_export(Xboxkrnl, 0x01F7, "XAudioGetVoiceCategoryVolumeChangeMask", stub_return_zero); state.register_export(Xboxkrnl, 0x01F8, "XAudioGetVoiceCategoryVolume", stub_success); state.register_export(Xboxkrnl, 0x0224, "XMACreateContext", xma_create_context); state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", stub_success); // Crypto 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); state.register_export(Xboxkrnl, 0x0195, "XexGetModuleHandle", xex_get_module_handle); state.register_export(Xboxkrnl, 0x0197, "XexGetProcedureAddress", xex_get_procedure_address); // Exception handling // 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 ===== fn stub_success(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { ctx.gpr[3] = 0; // STATUS_SUCCESS } fn stub_return_zero(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { 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) { tracing::warn!("DbgBreakPoint hit"); } fn dbg_print(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let str_ptr = ctx.gpr[3] as u32; if str_ptr != 0 { let s = read_cstring(mem, str_ptr); tracing::info!("DbgPrint: {}", s); } ctx.gpr[3] = 0; } // ===== Threading ===== /// `ExCreateThread(handle_ptr, stack_size, thread_id_ptr, xapi_startup, /// start_address, start_context, creation_flags)` — /// signature per xenia-canary's xboxkrnl_threading.cc. Creation flags bit 0 = /// CREATE_SUSPENDED; top 8 bits encode the affinity mask (logged, not /// enforced under Model B with 1-instr quantum). fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { let handle_ptr = ctx.gpr[3] as u32; let stack_size = ctx.gpr[4] as u32; let thread_id_ptr = ctx.gpr[5] as u32; let start_address = ctx.gpr[7] as u32; let start_context = ctx.gpr[8] as u32; let creation_flags = ctx.gpr[9] as u32; let create_suspended = (creation_flags & 0x1) != 0; let affinity = (creation_flags >> 24) & 0xFF; let Some(image) = allocate_thread_image(state, mem, stack_size, 0) else { tracing::error!("ExCreateThread: failed to allocate thread image"); ctx.gpr[3] = 0xC000_009A; // STATUS_INSUFFICIENT_RESOURCES return; }; use std::sync::atomic::Ordering; let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed); let handle = state.alloc_handle_for(KernelObject::Thread { id: tid, hw_id: None, exit_code: None, waiters: Vec::new(), }); let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed); let params = SpawnParams { entry: start_address, start_context, stack_base: image.stack_base, stack_size: image.stack_size, pcr_base: image.pcr_base, tls_base: image.tls_base, thread_handle: handle, guest_tid: tid, create_suspended, is_initial: false, tls_slot_count, affinity_mask: affinity as u8, priority: 0, ideal_processor: None, }; let result = state.scheduler.spawn(params, &mut GuestMemoryPcr(mem)); match result { Ok(hw_id) => { metrics::counter!("scheduler.spawn.ok").increment(1); 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); } if thread_id_ptr != 0 { mem.write_u32(thread_id_ptr, tid); } tracing::info!( "ExCreateThread: tid={} handle={:#x} hw={} entry={:#010x} start_ctx={:#010x} suspended={} aff={:#04x}", tid, handle, hw_id, start_address, start_context, 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(_) => { metrics::counter!("scheduler.spawn.rejected").increment(1); tracing::error!("ExCreateThread: no free HW thread slot"); ctx.gpr[3] = 0xC000_009A; } } } // ========================================================================= // review-a Step 1 — `--force-spawn-workers` crowbar // // Diagnostic-only, default-OFF. Synthesizes the 4 `sub_825070F0` worker // spawns that canary tid=6 emits at host_ns ≈ 10.383 s but ours never // reaches (the AUDIT-049 wedge cycle). See // `xenia-rs/audit-runs/review-a-step1-crowbar/investigation.md`. // // **This is NOT a fix** — the natural-activation path remains broken. // Use the cvar `XENIA_CROWBAR_WORKERS=1` to enable. The crowbar fires // once when `KernelState::crowbar_workers_fired` flips from false to // true (handled by the per-round trigger in `xenia-app/src/main.rs`). // ========================================================================= const CROWBAR_WORKER_ENTRIES: [u32; 4] = [0x82506528, 0x82506558, 0x82506588, 0x825065B8]; const CROWBAR_VTABLE_BASE: u32 = 0x8200_A1E8; const CROWBAR_STACK_SIZE: u32 = 65_536; /// Drop-in host-side spawn for one of the four `sub_825070F0` workers. /// Returns the new thread's handle on success, or `None` if either the /// thread-image allocation or scheduler spawn failed. The thread is /// created **suspended** to mirror canary's parameters; the caller is /// expected to resume via `nt_resume_thread`-equivalent or directly via /// `scheduler.resume_ref` once all 4 workers exist. fn crowbar_spawn_one_worker( state: &mut KernelState, mem: &GuestMemory, entry: u32, ctx_ptr: u32, ) -> Option { let image = allocate_thread_image(state, mem, CROWBAR_STACK_SIZE, 0)?; use std::sync::atomic::Ordering; let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed); let handle = state.alloc_handle_for(KernelObject::Thread { id: tid, hw_id: None, exit_code: None, waiters: Vec::new(), }); let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed); let params = SpawnParams { entry, start_context: ctx_ptr, stack_base: image.stack_base, stack_size: image.stack_size, pcr_base: image.pcr_base, tls_base: image.tls_base, thread_handle: handle, guest_tid: tid, create_suspended: true, is_initial: false, tls_slot_count, affinity_mask: 0, priority: 0, ideal_processor: None, }; match state.scheduler.spawn(params, &mut GuestMemoryPcr(mem)) { Ok(hw_id) => { metrics::counter!("scheduler.spawn.ok").increment(1); if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) { *slot = Some(hw_id); } state.retain_handle(handle); tracing::warn!( "CROWBAR: spawn worker tid={} handle={:#x} hw={} entry={:#010x} ctx={:#010x}", tid, handle, hw_id, entry, ctx_ptr, ); Some(handle) } Err(_) => { metrics::counter!("scheduler.spawn.rejected").increment(1); tracing::error!( "CROWBAR: no free HW slot for worker entry={:#010x}", entry ); None } } } /// Crowbar v2 Step 0 — dump `len` bytes of guest memory starting at `addr` /// to the tracing log, plus parse the first few u32 slots as fn-pointer /// candidates. Read-only. Used to verify whether the `0x8200A1E8` vtable /// region is populated in ours's cold-boot state. fn crowbar_dump_vtable_region(mem: &GuestMemory, addr: u32, len: u32) { let mut buf = vec![0u8; len as usize]; mem.read_bytes(addr, &mut buf); // Summary stats first. let nonzero = buf.iter().filter(|b| **b != 0).count(); tracing::warn!( "CROWBAR-DIAG: vtable region @{:#010x}..+{} — nonzero bytes={}/{}", addr, len, nonzero, len, ); // Dump as u32 big-endian slots (vtable[i]) for the first 64 slots. let max_slots = (len as usize) / 4; let mut nonzero_slots = 0u32; for i in 0..max_slots { let v = mem.read_u32(addr + (i as u32) * 4); if v != 0 { nonzero_slots += 1; } // Only log the first 48 slots fully; that covers offset 140..152 (slots 35-38). if i < 48 { tracing::warn!( "CROWBAR-DIAG: slot[{:>2}] @ +{:>3} (={:#010x}) = {:#010x}", i, i * 4, addr + (i as u32) * 4, v, ); } } tracing::warn!( "CROWBAR-DIAG: nonzero u32 slots in first {}={}; worker stub reads slots 35-38 (offsets 140/144/148/152)", max_slots, nonzero_slots, ); } /// Crowbar v2 Step 2 — optionally install vtable contents at `vtable_base` /// from a binary file specified by `XENIA_CROWBAR_VTABLE_BIN`. Bytes are /// written verbatim (no byte-swap) via `write_u8` because the file is /// expected to be a raw guest-endian (big-endian) dump captured from /// canary's runtime memory at the same VA. Logs a verification re-read of /// slot 35 (offset 140) after writing. If the env var is unset, this is a /// no-op so v1 behaviour is preserved exactly. fn crowbar_maybe_install_vtable_from_file(mem: &GuestMemory, vtable_base: u32) { let path = match std::env::var("XENIA_CROWBAR_VTABLE_BIN") { Ok(p) if !p.is_empty() => p, _ => { tracing::warn!( "CROWBAR: XENIA_CROWBAR_VTABLE_BIN not set — skipping vtable install \ (v1 behaviour; workers will likely fault if vtable[35] is null)" ); return; } }; let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => { tracing::error!( "CROWBAR: failed to read vtable bin {:?}: {} — skipping install", path, e, ); return; } }; let n = bytes.len().min(256); for (i, b) in bytes.iter().take(n).enumerate() { mem.write_u8(vtable_base + i as u32, *b); } tracing::warn!( "CROWBAR: installed {} bytes at vtable {:#010x} from {:?}", n, vtable_base, path, ); // Verify by re-reading the slot the worker stub actually dispatches through. let slot35 = mem.read_u32(vtable_base + 140); let slot36 = mem.read_u32(vtable_base + 144); let slot37 = mem.read_u32(vtable_base + 148); let slot38 = mem.read_u32(vtable_base + 152); tracing::warn!( "CROWBAR: post-install verify — vtable[35]={:#010x} vtable[36]={:#010x} \ vtable[37]={:#010x} vtable[38]={:#010x}", slot35, slot36, slot37, slot38, ); } /// Crowbar v3 Step 2 — optionally install full ctx bytes at `ctx_ptr` /// from a binary file specified by `XENIA_CROWBAR_CTX_BIN`. Bytes are /// written verbatim (no byte-swap) via `write_u8` because the file is /// expected to be a raw guest-endian (big-endian) capture of the ctx /// layout from canary's runtime memory. Logs the first 16 u32 slots /// after install for verification. If the env var is unset, this is a /// no-op so v2 behaviour is preserved exactly. /// /// Captured via canary's `audit_68_host_mem_read_probe` cvar — see /// `xenia-rs/audit-runs/review-a-step1c-crowbar-v3/canary-probe-run1.log`. /// /// **Option γ (per v3 brief)**: install verbatim, including canary-VA /// pointer fields like `[ctx+44]=0xBCE25640`. These VAs may be unmapped /// in ours's address space — if a worker dereferences one and faults, /// that confirms the case-(C) recursion is required (v4 work). fn crowbar_maybe_install_ctx_from_file(mem: &GuestMemory, ctx_ptr: u32) { let path = match std::env::var("XENIA_CROWBAR_CTX_BIN") { Ok(p) if !p.is_empty() => p, _ => { tracing::warn!( "CROWBAR: XENIA_CROWBAR_CTX_BIN not set — skipping ctx install \ (v2 behaviour; only +0/+4/+8/+12 are populated; \ workers will likely fault on [ctx+44] dispatch)" ); return; } }; let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => { tracing::error!( "CROWBAR: failed to read ctx bin {:?}: {} — skipping install", path, e, ); return; } }; let n = bytes.len().min(256); for (i, b) in bytes.iter().take(n).enumerate() { mem.write_u8(ctx_ptr + i as u32, *b); } tracing::warn!( "CROWBAR: installed {} bytes at ctx_ptr={:#010x} from {:?}", n, ctx_ptr, path, ); // Verify: log the first 16 u32 slots after install. for slot in 0..16u32 { let off = slot * 4; let v = mem.read_u32(ctx_ptr + off); tracing::warn!( "CROWBAR: post-ctx-install ctx[+{:>3}] (={:#010x}) = {:#010x}", off, ctx_ptr + off, v, ); } } /// Crowbar entry point — allocate the worker ctx, install the vtable /// + self-pointer doubly-linked-list head pattern that AUDIT-068 S3 /// captured, spawn all 4 workers suspended, then resume each one. /// Returns the number of workers successfully resumed (0..=4). /// /// **Reading-error #37 discipline**: the value written at `ctx+0` is the /// vtable BASE `0x8200A1E8`, NOT the slot-N address `0x8200A208` cited /// in older audits. Per AUDIT-068 S3 measurement. pub fn crowbar_force_spawn_workers(state: &mut KernelState, mem: &GuestMemory) -> u32 { // 0. Crowbar v2 Step 0 diagnostic — dump 256 bytes at the vtable base // BEFORE doing anything else. Distinguishes case (A) vtable .rdata // is missing/zero in ours vs case (B) .rdata present but vtable[35] // is not statically populated (= runtime install needed). Per // Reading-error #37: 0x8200A1E8 is vtable BASE; slot N is at base+4*N. // For workers we care about slots 35/36/37/38 (offsets 140/144/148/152). // Bump dump to 512 bytes (128 slots) so we see vtable[64] which is read // by the slot-35 callee `sub_82506B08` at +256. crowbar_dump_vtable_region(mem, CROWBAR_VTABLE_BASE, 512); // 1. Allocate ctx struct (one heap page is plenty; the real struct is // much smaller but we never overlap because heap_alloc bumps // page-aligned). let ctx_ptr = match state.heap_alloc(0x1000, mem) { Some(p) => p, None => { tracing::error!("CROWBAR: heap_alloc(ctx) failed — out of heap region"); return 0; } }; // 2. Initialise ctx per AUDIT-068 S3 12-byte POD-copy signature plus // refcount=1 at +0x0C. `write_u32` here goes through the GuestMemory // BE-store path, so the on-guest u32 lanes are correctly byte-swapped. mem.write_u32(ctx_ptr, CROWBAR_VTABLE_BASE); mem.write_u32(ctx_ptr + 4, ctx_ptr); mem.write_u32(ctx_ptr + 8, ctx_ptr); mem.write_u32(ctx_ptr + 12, 1); tracing::warn!( "CROWBAR: ctx allocated at {:#010x}, vtable={:#010x}, self-links + refcount=1 installed", ctx_ptr, CROWBAR_VTABLE_BASE, ); // 2b. Crowbar v2 Step 2 — vtable-contents install. // If the cvar `XENIA_CROWBAR_VTABLE_BIN=` is set AND the file // exists, read up to 256 bytes from it and write them at // CROWBAR_VTABLE_BASE. Bytes in the file are expected to be the // GUEST-endian (big-endian) raw vtable contents as captured from // canary's runtime memory at the same VA. No byte-swap is performed — // they are written via `write_u8` so they go onto the guest as-is. // If no file is provided, we still proceed (so v1 behaviour is // preserved exactly when the env var is unset). crowbar_maybe_install_vtable_from_file(mem, CROWBAR_VTABLE_BASE); // 2c. Crowbar v3 Step 2 — full ctx-bytes install. // If the cvar `XENIA_CROWBAR_CTX_BIN=` is set AND the file // exists, read up to 256 bytes from it and write them at ctx_ptr. // The file should be a raw guest-endian (big-endian) capture of the // ctx layout — see canary's `audit_68_host_mem_read_probe` cvar. // The v2 init at +0/+4/+8/+12 above is intentionally retained as a // fallback when the env var is unset; the file install overwrites // those four slots verbatim (the bytes match the v2 pattern). // // **Option γ (per v3 brief)**: canary-VA pointer fields like // `[ctx+44]=0xBCE25640` are written as-is even if unmapped in // ours — diagnostic intent is to OBSERVE the fault PC, not avoid // it. crowbar_maybe_install_ctx_from_file(mem, ctx_ptr); // 3. Spawn the 4 workers suspended (matching canary jitter sample). let mut handles: [u32; 4] = [0; 4]; let mut spawned = 0u32; for (i, entry) in CROWBAR_WORKER_ENTRIES.iter().enumerate() { if let Some(h) = crowbar_spawn_one_worker(state, mem, *entry, ctx_ptr) { handles[i] = h; spawned += 1; } } // 4. Resume each spawned worker directly through the scheduler. // Mirrors the natural canary path which calls NtResumeThread soon // after the create burst (not captured in the jsonl excerpt). let mut resumed = 0u32; for (i, h) in handles.iter().enumerate() { if *h == 0 { tracing::warn!("CROWBAR: skipping resume of handle[{}] (spawn failed)", i); continue; } if let Some(r) = state.scheduler.find_by_handle(*h) { let prev = state.scheduler.resume_ref(r); tracing::warn!( "CROWBAR: resumed handle[{}]={:#x} -> r=(hw={}, idx={}) prev_suspend_count={}", i, h, r.hw_id, r.idx, prev, ); resumed += 1; } else { tracing::warn!( "CROWBAR: find_by_handle({:#x}) returned None for handle[{}]", h, i, ); } } tracing::warn!( "CROWBAR: fired — ctx={:#010x} spawned={}/4 resumed={}/4", ctx_ptr, spawned, resumed, ); resumed } /// `ExTerminateThread(exit_code)` — terminates the current guest thread. The /// thread transitions to Exited and the main loop unschedules it. Joiners /// 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={}", tid, hw_id, exit_code ); if let Some(handle) = handle_opt { if let Some(KernelObject::Thread { exit_code: ec, waiters, .. }) = state.objects.get_mut(&handle) { *ec = Some(exit_code); let to_wake: Vec = std::mem::take(waiters); for w in to_wake { 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; } fn hal_return_to_firmware(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { tracing::warn!("HalReturnToFirmware: reason={:#x}", ctx.gpr[3]); ctx.gpr[3] = 0; } // ===== Ke* ===== /// `KeSetBasePriorityThread(thread_handle, priority) -> i32 old_priority` — /// Axis 1 wiring. Sylpheed calls this from its worker-init prologue on /// newly-created threads to bump them to time-critical / high. Storing the /// value on the `GuestThread` makes `HwSlot::pick_runnable` honor it. fn ke_set_base_priority_thread( ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState, ) { let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let new_pri = ctx.gpr[4] as i32; let prev = state .scheduler .find_by_handle(handle) .map(|r| state.scheduler.set_priority_ref(r, new_pri)) .unwrap_or(0); ctx.gpr[3] = prev as u32 as u64; } fn ke_query_base_priority_thread( ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState, ) { let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let pri = state .scheduler .find_by_handle(handle) .map(|r| state.scheduler.priority_ref(r)) .unwrap_or(0); ctx.gpr[3] = pri as u32 as u64; } /// 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, ) { // 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; } /// 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 /// classes. Other classes return `STATUS_INVALID_INFO_CLASS`. /// /// Not registered as an ordinal: Xbox 360's `xboxkrnl.exe` doesn't export /// this function — canary's table assigns `0xFB` to /// `NtSignalAndWaitForSingleObjectEx`. The body is retained only for the /// direct-call unit test below. #[allow(dead_code)] fn nt_set_information_thread( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003; let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let info_class = ctx.gpr[4] as u32; let info_ptr = ctx.gpr[5] as u32; let info_len = ctx.gpr[6] as u32; let Some(r) = state.scheduler.find_by_handle(handle) else { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; }; match info_class { 2 /* ThreadPriority */ if info_len >= 4 => { let pri = mem.read_u32(info_ptr) as i32; state.scheduler.set_priority_ref(r, pri); ctx.gpr[3] = STATUS_SUCCESS; } 3 /* ThreadAffinityMask */ if info_len >= 4 => { let mask = mem.read_u32(info_ptr) as u8; state.set_affinity(handle, mask, mem); ctx.gpr[3] = STATUS_SUCCESS; } 13 /* ThreadIdealProcessor */ if info_len >= 4 => { let ideal = mem.read_u32(info_ptr) as u8; state.scheduler.set_ideal_ref(r, ideal); ctx.gpr[3] = STATUS_SUCCESS; } _ => { ctx.gpr[3] = STATUS_INVALID_INFO_CLASS; } } } /// `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 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); 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) { tracing::error!("KeBugCheck: code={:#x}", ctx.gpr[3]); ctx.gpr[3] = 0; } fn ke_bug_check_ex(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { tracing::error!("KeBugCheckEx: code={:#x} p1={:#x} p2={:#x} p3={:#x}", ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.gpr[6]); ctx.gpr[3] = 0; } fn ke_get_current_process_type(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { ctx.gpr[3] = 1; // PROC_USER } fn ke_query_performance_frequency(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { ctx.gpr[3] = 50_000_000; // 50 MHz } fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let time_ptr = ctx.gpr[3] as u32; if time_ptr != 0 { let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME mem.write_u32(time_ptr, (fake_time >> 32) as u32); mem.write_u32(time_ptr + 4, fake_time as u32); } } /// 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 // (xboxkrnl_threading.cc:692). `ensure_dispatcher_object` (below) // reads type@+0, signal_state@+4, and limit@+0x10 to mint the // kernel-side shadow on first wait/release — so dropping the count // and limit args (the prior zero-fill) silently produced // `Semaphore { count: 0, max: 1 }` regardless of caller intent. let sem_ptr = ctx.gpr[3] as u32; let count = ctx.gpr[4] as u32; let limit = ctx.gpr[5] as u32; if sem_ptr == 0 { return; } // DISPATCHER_HEADER: type=5 (Semaphore), absolute=0, size=5 u32s, // inserted=0, signal_state=count, then 8-byte wait_list_head, then // limit at +0x10. mem.write_u8(sem_ptr, 5); mem.write_u8(sem_ptr + 0x01, 0); mem.write_u8(sem_ptr + 0x02, 5); mem.write_u8(sem_ptr + 0x03, 0); mem.write_u32(sem_ptr + 0x04, count); mem.write_u32(sem_ptr + 0x08, 0); mem.write_u32(sem_ptr + 0x0C, 0); mem.write_u32(sem_ptr + 0x10, limit); } fn ke_try_acquire_spinlock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = KSPIN_LOCK*. Returns 1 (TRUE) on success. Single-threaded HLE // mirrors canary's `KeTryToAcquireSpinLockAtRaisedIrql`: write 1 to // the lock value (mark held) and return success. Under `--parallel` // the coarse Arc> already serializes us. let lock_ptr = ctx.gpr[3] as u32; if lock_ptr != 0 { mem.write_u32(lock_ptr, 1); } ctx.gpr[3] = 1; } /// `KfAcquireSpinLock(KSPIN_LOCK *SpinLock)` — returns previous IRQL. /// Per canary `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc` /// the function raises IRQL to DISPATCH_LEVEL (2), spins on the lock, /// then sets it to 1. Pre-fix this was `stub_return_zero` — guest code /// could enter critical regions without ever taking the lock, leading /// to subtle races even in lockstep when the same code path was /// re-entered before the matching release fired. KRNBUG-017. fn kf_acquire_spin_lock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let lock_ptr = ctx.gpr[3] as u32; if lock_ptr != 0 { mem.write_u32(lock_ptr, 1); } // Old IRQL = PASSIVE_LEVEL (0). The new IRQL is DISPATCH_LEVEL (2), // tracked implicitly by the kf_release path. Returning 0 matches the // common-case "called from a passive-level routine" entry path. ctx.gpr[3] = 0; } /// `KfReleaseSpinLock(KSPIN_LOCK *SpinLock, KIRQL OldIrql)`. /// Releases the spinlock and lowers IRQL to OldIrql. KRNBUG-017. fn kf_release_spin_lock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let lock_ptr = ctx.gpr[3] as u32; if lock_ptr != 0 { mem.write_u32(lock_ptr, 0); } ctx.gpr[3] = 0; } /// `KeReleaseSpinLockFromRaisedIrql(KSPIN_LOCK *SpinLock)`. /// Releases the spinlock without changing IRQL. KRNBUG-017. fn ke_release_spinlock_from_raised_irql( ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState, ) { let lock_ptr = ctx.gpr[3] as u32; if lock_ptr != 0 { mem.write_u32(lock_ptr, 0); } ctx.gpr[3] = 0; } fn ke_tls_alloc(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { ctx.gpr[3] = state.tls_alloc() as u64; } fn ke_tls_get_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let index = ctx.gpr[3] as u32; ctx.gpr[3] = state.tls_get(index); } fn ke_tls_set_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let index = ctx.gpr[3] as u32; let value = ctx.gpr[4]; state.tls_set(index, value); ctx.gpr[3] = 1; // TRUE } /// 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 ===== fn nt_allocate_virtual_memory(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = base_addr_ptr (in/out), r4 = region_size_ptr (in/out) // r5 = alloc_type, r6 = protect let base_ptr = ctx.gpr[3] as u32; let size_ptr = ctx.gpr[4] as u32; let requested_base = mem.read_u32(base_ptr); let requested_size = mem.read_u32(size_ptr); let aligned_size = (requested_size + 0xFFF) & !0xFFF; if aligned_size == 0 { ctx.gpr[3] = 0xC000_0010; // STATUS_INVALID_PARAMETER return; } let base = if requested_base != 0 { // Try to allocate at the requested address let protect = xenia_memory::page_table::MemoryProtect::READ | xenia_memory::page_table::MemoryProtect::WRITE; if mem.alloc(requested_base, aligned_size, protect).is_ok() { requested_base } else { // Already allocated? Treat as success (common for re-commit) requested_base } } else { // Allocate from heap match state.heap_alloc(aligned_size, mem) { Some(addr) => addr, None => { tracing::warn!("NtAllocateVirtualMemory: heap exhausted (size={:#x})", aligned_size); ctx.gpr[3] = 0xC000_0017; // STATUS_NO_MEMORY return; } } }; mem.write_u32(base_ptr, base); mem.write_u32(size_ptr, aligned_size); tracing::info!("NtAllocateVirtualMemory: base={:#010x} size={:#x}", base, aligned_size); ctx.gpr[3] = 0; // STATUS_SUCCESS } fn mm_allocate_physical_memory_ex(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // Matches xenia-canary `MmAllocatePhysicalMemoryEx_entry` — see // `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_memory.cc:489-494`. // r3 = flags, r4 = region_size, r5 = protect_bits, // r6 = min_addr_range, r7 = max_addr_range, r8 = alignment // Return value is the guest address; 0 indicates failure (Xbox ABI). let flags = ctx.gpr[3] as u32; let size = ctx.gpr[4] as u32; let protect_bits = ctx.gpr[5] as u32; if size == 0 { tracing::warn!(flags, "MmAllocatePhysicalMemoryEx: zero-size request → returning 0"); ctx.gpr[3] = 0; return; } // Iterate 2.H — bucket routing. Canary `xeMmAllocatePhysicalMemoryEx` // (`xboxkrnl_memory.cc:436-455`) picks `page_size` from `protect_bits`: // X_MEM_LARGE_PAGES (0x20000000) → 64KB → vA0000000 (0xA0000000-0xBFFFFFFF) // X_MEM_16MB_PAGES (0x80000000) → 16MB → vC0000000 (deferred to 2.I) // default (4KB) → vE0000000 (deferred to 2.I) // For 2.H we only wire the 64KB bucket; the others still fall through // to the legacy `heap_alloc` at 0x40000000 (incorrect bucket, but // preserves prior behavior for non-large-page calls). const X_MEM_LARGE_PAGES: u32 = 0x2000_0000; let result = if protect_bits & X_MEM_LARGE_PAGES != 0 { state.physical_heap_alloc(size, mem) } else { state.heap_alloc(size, mem) }; match result { Some(addr) => { tracing::debug!( flags, size = format_args!("{size:#x}"), protect = format_args!("{protect_bits:#x}"), addr = format_args!("{addr:#010x}"), "MmAllocatePhysicalMemoryEx" ); ctx.gpr[3] = addr as u64; } None => { tracing::warn!( flags, size = format_args!("{size:#x}"), protect = format_args!("{protect_bits:#x}"), "MmAllocatePhysicalMemoryEx: heap exhausted" ); ctx.gpr[3] = 0; } } } fn mm_create_kernel_stack(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // xenia-canary `MmCreateKernelStack_entry(stack_size, r4)`; returns stack top. // `xboxkrnl_threading.cc` — see DECLARE_XBOXKRNL_EXPORT on MmCreateKernelStack. let requested = ctx.gpr[3] as u32; let size = std::cmp::max(requested, 0x4000); // Min 16KB per canary match state.stack_alloc(size, mem) { Some(top) => { tracing::info!( top = format_args!("{top:#010x}"), size = format_args!("{size:#x}"), "MmCreateKernelStack" ); ctx.gpr[3] = top as u64; } None => { tracing::warn!(size = format_args!("{size:#x}"), "MmCreateKernelStack: stack heap exhausted"); ctx.gpr[3] = 0; } } } fn mm_get_physical_address(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { // r3 = virtual address -> return physical address ctx.gpr[3] &= 0x1FFF_FFFF; // Mask to 512MB physical } fn mm_query_address_protect(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { // Return PAGE_READWRITE (0x04) ctx.gpr[3] = 0x04; } fn mm_query_statistics(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = stats_ptr — write fake memory statistics let ptr = ctx.gpr[3] as u32; if ptr != 0 { // Total physical = 512MB mem.write_u32(ptr + 0x04, 512 * 1024 * 1024); // TotalPhysicalPages (in bytes) mem.write_u32(ptr + 0x10, 256 * 1024 * 1024); // AvailablePages } ctx.gpr[3] = 0; } // ===== File I/O ===== /// NT error codes the file handlers need. Keeping them inline avoids pulling /// in a whole NTSTATUS module for a single file. const STATUS_SUCCESS: u64 = 0x0000_0000; const STATUS_END_OF_FILE: u64 = 0xC000_0011; const STATUS_INVALID_HANDLE: u64 = 0xC000_0008; const STATUS_OBJECT_NAME_NOT_FOUND: u64 = 0xC000_0034; const STATUS_NO_MORE_FILES: u64 = 0x8000_0006; 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; /// 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 { return; } mem.write_u32(ptr, status); mem.write_u32(ptr + 4, information); } /// NT `CreateDisposition` values — the only ones that matter for cache: /// opens. From canary `xboxkrnl_io.cc` / NT documentation. const FILE_SUPERSEDE: u32 = 0; const FILE_OPEN: u32 = 1; const FILE_CREATE: u32 = 2; #[allow(dead_code)] const FILE_OPEN_IF: u32 = 3; // open-or-create; honoured implicitly (no must_exist branch) const FILE_OVERWRITE: u32 = 4; const FILE_OVERWRITE_IF: u32 = 5; /// AUDIT-038 — given a normalised guest path (post `path::normalize_path`), /// return the host-FS path inside `state.cache_root` if and only if the /// guest path lives on a writable cache mount. Wrapper around /// [`KernelState::resolve_cache_path`] that also handles the /// already-normalised `cache:/` form (forward-slashed by normalize_path). fn cache_path_for(state: &KernelState, normalised: &str) -> Option { // After normalize_path, backslashes have been converted to forward // slashes — but resolve_cache_path expects either form. Pass through. state.resolve_cache_path(normalised) } /// AUDIT-038 — open a `cache:/*` path against the host FS. Honours NT /// create-disposition semantics (open/create/overwrite/supersede) and /// records the host_path on the returned `File` object so subsequent /// `NtReadFile`/`NtWriteFile` go through real I/O. Mirrors the behaviour /// of canary's `HostPathDevice::Open` (xenia-canary/src/xenia/vfs/devices/ /// host_path_device.cc) once the symbolic link in xenia_main.cc:649 is /// applied. fn open_cache_file( state: &mut KernelState, guest_path: &str, host_path: &std::path::Path, create_disposition: u32, create_options: u32, mem: &GuestMemory, handle_out: u32, io_status_block: u32, ) -> u64 { // FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE per // xboxkrnl_io.cc:29-34. The guest may set these to discriminate // a "create directory" call from a "create file" call when the // host filesystem can't infer it from the path shape (e.g. the // hash-only paths Sylpheed builds in `cache:\` — without // the bit, AUDIT-053 found we were creating a 0-byte file at // `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; 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 { // 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_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 // path-shape probe reports Directory=1. let dir_path = if guest_path.ends_with('/') || guest_path.ends_with(':') { guest_path.to_string() } 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, position: 0, data: std::sync::Arc::new(Vec::new()), 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); } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!( "cache open (dir) path={:?} host={:?} disp={} opts={:#x} handle={:#x}", guest_path, host_path, create_disposition, create_options, handle ); return STATUS_SUCCESS; } let exists = host_path.is_file(); let must_exist = matches!(create_disposition, FILE_OPEN | FILE_OVERWRITE); let must_not_exist = create_disposition == FILE_CREATE; let truncate = matches!( create_disposition, FILE_SUPERSEDE | FILE_OVERWRITE | FILE_OVERWRITE_IF ); if must_exist && !exists { 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!( "cache open MISS path={:?} disp={} -> NOT_FOUND", guest_path, create_disposition ); return STATUS_OBJECT_NAME_NOT_FOUND; } if must_not_exist && exists { if handle_out != 0 { mem.write_u32(handle_out, 0); } write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_COLLISION as u32, 0); tracing::info!( "cache open COLLISION path={:?} disp={} -> NAME_COLLISION", guest_path, create_disposition ); return STATUS_OBJECT_NAME_COLLISION; } // Ensure parent dir exists for create dispositions. if !exists || truncate { if let Some(parent) = host_path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { tracing::warn!( "cache create_dir_all({:?}) failed: {} — falling back to STATUS_UNSUCCESSFUL", parent, 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; } } } // Truncate / create empty file if needed. Read remaining bytes only // when the disposition keeps existing content. if truncate || !exists { if let Err(e) = std::fs::File::create(host_path) { tracing::warn!( "cache File::create({:?}) 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; } } 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, position: 0, // Empty in-memory data; reads/writes go through host_path. data: std::sync::Arc::new(Vec::new()), 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); } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!( "cache open OK path={:?} host={:?} disp={} opts={:#x} size={} handle={:#x}", guest_path, host_path, create_disposition, create_options, size, handle ); STATUS_SUCCESS } /// 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 /// remains read-only. /// /// `create_disposition` is honoured for `cache:` paths only: /// - `FILE_OPEN` (1) / default for `NtOpenFile`: must exist, else /// STATUS_OBJECT_NAME_NOT_FOUND. /// - `FILE_CREATE` (2): must NOT exist, else STATUS_OBJECT_NAME_COLLISION. /// - `FILE_OPEN_IF` (3): open or create. /// - `FILE_SUPERSEDE` (0) / `FILE_OVERWRITE_IF` (5): create or truncate. /// - `FILE_OVERWRITE` (4): must exist, then truncate. fn open_vfs_file( mem: &GuestMemory, state: &mut KernelState, handle_out: u32, io_status_block: u32, obj_attrs_ptr: u32, create_disposition: u32, create_options: u32, ) -> u64 { // Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as // a valid "open the partition/device root" request — Canary's // `NtCreateFile_entry` in xboxkrnl_io.cc:39 lets empty paths through // to the VFS, which resolves them as a directory handle on the root. // Sylpheed opens `game:\` near the end of its boot as a disc-validation // probe; returning `STATUS_OBJECT_NAME_NOT_FOUND` makes the async worker // 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); } write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_NOT_FOUND as u32, 0); return STATUS_OBJECT_NAME_NOT_FOUND; } if path.is_empty() { // Empty path after prefix strip is the "open the device/partition // root" case (e.g. `NtCreateFile("game:\")`). Canary's // `NtCreateFile_entry` resolves these through the VFS and returns // a directory handle. We don't model directory entries, so synth // a zero-byte "file" whose `path` is empty; `nt_query_information_file` // then reports `Directory=1` / `FILE_ATTRIBUTE_DIRECTORY` based on // the path shape, which is how Sylpheed's disc-validation probe // decides it found a directory and proceeds. let handle = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); maybe_mark_async_file(state, handle, create_options); if handle_out != 0 { mem.write_u32(handle_out, handle); } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); return STATUS_SUCCESS; } // AUDIT-038 — cache:/* routing. `path` here is the post-normalize form // of `cache:\foo` (forward-slashed: `cache:/foo`); `cache:` isn't in // DEVICE_PREFIXES so the prefix survives normalisation. Resolve to a // host-FS path under `state.cache_root` and apply NT create-disposition // semantics. This replaces the "Synthesized empty file" stub for this // mountpoint specifically — other mountpoints (game:/, dat:/, etc.) // keep the legacy behaviour to avoid disturbing audit-006 / audit-018 // disc-validation probes. if let Some(host_path) = cache_path_for(state, &path) { return open_cache_file(state, &path, &host_path, create_disposition, create_options, mem, handle_out, io_status_block); } let vfs = match state.vfs.as_ref() { Some(v) => v, None => { tracing::warn!("NtCreateFile/NtOpenFile for {:?}: no VFS mounted", 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); return STATUS_OBJECT_NAME_NOT_FOUND; } }; match vfs.read_file(&path) { Ok(bytes) => { let size = bytes.len() as u64; let handle = state.alloc_handle_for(KernelObject::File { path: path.clone(), size, position: 0, data: std::sync::Arc::new(bytes), 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); } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!("File opened: path={:?} size={} handle={:#x}", path, size, handle); STATUS_SUCCESS } Err(e) => { // 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. // // 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, position: 0, data: std::sync::Arc::new(Vec::new()), 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); } write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); tracing::info!( "Synthesized empty file for missing path: path={:?} err={} handle={:#x}", path, e, handle ); STATUS_SUCCESS } } } fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle_out, r4 = desired_access, r5 = obj_attrs, r6 = io_status_block, // r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition, // [sp+0x54] = create_options (9th arg, spilled per shim_utils.h:49-50). 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 create_disposition = ctx.gpr[10] as u32; let sp = ctx.gpr[1] as u32; let create_options = mem.read_u32(sp + 0x54); ctx.gpr[3] = open_vfs_file( mem, state, handle_out, io_status_block, obj_attrs_ptr, create_disposition, create_options, ); } fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // 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 + 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[7] as u32; ctx.gpr[3] = open_vfs_file( mem, state, handle_out, io_status_block, obj_attrs_ptr, FILE_OPEN, open_options, ); } /// Signal an NT-style completion event on synchronous I/O completion. /// /// `NtReadFile` / `NtWriteFile` take an event handle at r4. The NT contract /// is: on a real async driver, the event pulses when the I/O finishes. /// Games that use the common "issue I/O then wait on the event" idiom will /// deadlock if we return `STATUS_SUCCESS` without signaling — observed on /// Sylpheed with four stuck threads parked on `WaitAny { handles: [evt] }` /// that nothing else could wake. We finish I/O synchronously so we signal /// immediately on *every* completion path (success, EOF, invalid-handle). /// No-op when the caller passes a null handle (synchronous-wait style). fn signal_io_completion_event(state: &mut KernelState, event_handle: u32) { if event_handle == 0 { return; } let prev = if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&event_handle) { let was = *signaled; *signaled = true; was as u64 } else { 0 }; state.audit_signal(event_handle, 0, "signal_io_completion_event", prev); wake_eligible_waiters(state, event_handle); } 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 // 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; let byte_offset_ptr = ctx.gpr[10] as u32; let Some(KernelObject::File { path, size, position, data, host_path, .. }) = state.objects.get_mut(&handle) else { tracing::warn!("NtReadFile: invalid handle {:#x}", handle); ctx.gpr[3] = STATUS_INVALID_HANDLE; write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); signal_io_completion_event(state, event_handle); return; }; // If the caller supplied an explicit byte offset (not 0xFFFFFFFFFFFFFFFE) // seek to it; otherwise continue from the stored cursor. let start_pos = if byte_offset_ptr != 0 { let offset = mem.read_u64(byte_offset_ptr); if offset != FILE_USE_FILE_POINTER_POSITION && offset != u64::MAX { *position = offset; } *position } else { *position }; // AUDIT-038 — host-backed cache read. Refresh `size` from the live FS // entry first (writers on the same handle may have grown the file // since open). Use std::fs::File seek/read for the actual transfer. if let Some(hp) = host_path.clone() { use std::io::{Read, Seek, SeekFrom}; let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0); *size = live_size; if start_pos >= live_size { write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0); ctx.gpr[3] = STATUS_END_OF_FILE; signal_io_completion_event(state, event_handle); return; } let avail = (live_size - start_pos).min(length as u64) as usize; let mut buf = vec![0u8; avail]; let res = (|| -> std::io::Result { let mut f = std::fs::File::open(&hp)?; f.seek(SeekFrom::Start(start_pos))?; f.read_exact(&mut buf)?; Ok(buf.len()) })(); match res { Ok(n) => { mem.write_bulk(buffer, &buf[..n]); *position = start_pos + n as u64; write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, n as u32); ctx.gpr[3] = STATUS_SUCCESS; tracing::info!( "NtReadFile cache: {} bytes from {:?} @ {} (handle={:#x})", n, path, start_pos, handle ); } Err(e) => { tracing::warn!("NtReadFile cache I/O error path={:?}: {}", path, e); write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); ctx.gpr[3] = STATUS_UNSUCCESSFUL; } } signal_io_completion_event(state, event_handle); return; } let total = *size; // Synthesized empty files (system partition opens like // `\Device\Harddisk0\partition0` that miss the disc-VFS) act as // NullDevice handles. Canary's `NullFile::ReadSync` returns // `X_STATUS_SUCCESS` with `bytes_read=0` and never touches the buffer // ([null_file.cc:24-31](xenia-canary/src/xenia/vfs/devices/null_file.cc)); // Sylpheed's cache loader at `sub_824A9710` reads 1024 B from offset // 2048, expects success, then validates a `"Josh"` magic — falling back // to the recreate path if the buffer (already zeroed by the caller via // `memset(sp+208, 0, 1024)`) doesn't match. if data.is_empty() && total == 0 { write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0); ctx.gpr[3] = STATUS_SUCCESS; signal_io_completion_event(state, event_handle); return; } if start_pos >= total { write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0); ctx.gpr[3] = STATUS_END_OF_FILE; signal_io_completion_event(state, event_handle); return; } let avail = (total - start_pos).min(length as u64) as usize; if avail == 0 { write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0); ctx.gpr[3] = STATUS_END_OF_FILE; signal_io_completion_event(state, event_handle); return; } let start = start_pos as usize; let end = start + avail; let slice = &data[start..end]; mem.write_bulk(buffer, slice); *position = start_pos + avail as u64; tracing::info!( "NtReadFile: {} bytes from {:?} @ {} (handle={:#x})", avail, path, start_pos, handle, ); write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, avail as u32); ctx.gpr[3] = STATUS_SUCCESS; signal_io_completion_event(state, event_handle); } fn nt_write_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. // For cache:/* (host_path Some) writes go to disk; everything else // is still discarded (matches legacy read-only behaviour for game:/). // 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; let byte_offset_ptr = ctx.gpr[10] as u32; let Some(KernelObject::File { path, size, position, host_path, .. }) = state.objects.get_mut(&handle) else { tracing::warn!("NtWriteFile: invalid handle {:#x}", handle); ctx.gpr[3] = STATUS_INVALID_HANDLE; write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); signal_io_completion_event(state, event_handle); return; }; let start_pos = if byte_offset_ptr != 0 { let offset = mem.read_u64(byte_offset_ptr); if offset != FILE_USE_FILE_POINTER_POSITION && offset != u64::MAX { *position = offset; } *position } else { *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]; mem.read_bulk(buffer, &mut buf); let res = (|| -> std::io::Result<()> { let mut f = std::fs::OpenOptions::new() .create(true) .write(true) .open(&hp)?; f.seek(SeekFrom::Start(start_pos))?; f.write_all(&buf)?; f.flush()?; Ok(()) })(); match res { Ok(()) => { *position = start_pos + length as u64; let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0); *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 ); } Err(e) => { tracing::warn!("NtWriteFile cache I/O error path={:?}: {}", path, e); write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0); ctx.gpr[3] = STATUS_UNSUCCESSFUL; } } } else { // 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); } /// Mirrors canary `NullDevice::IoControl` via /// [xboxkrnl_io.cc:645-678](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc). /// Used by `XMountUtilityDrive` cache-mount probes; Sylpheed issues both /// `0x70000` (drive geometry) and `0x74004` (partition info) inside /// `sub_824ABD88`. The OUT-buffer fields it writes are the gate that /// keeps `sub_824A9710` from synthesizing `STATUS_OBJECT_NAME_NOT_FOUND`. fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { const X_IOCTL_DISK_GET_DRIVE_GEOMETRY: u32 = 0x70000; const X_IOCTL_DISK_GET_PARTITION_INFO: u32 = 0x74004; const STATUS_BUFFER_TOO_SMALL: u64 = 0xC000_0023; const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D; const CACHE_SIZE: u64 = 0xFF000; // 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; let output_buffer = mem.read_u32(sp + 0x54); let output_buffer_len = mem.read_u32(sp + 0x5C); let status: u64 = match io_control_code { X_IOCTL_DISK_GET_DRIVE_GEOMETRY => { if output_buffer_len < 0x8 { STATUS_BUFFER_TOO_SMALL } else { mem.write_u32(output_buffer, (CACHE_SIZE / 512) as u32); mem.write_u32(output_buffer + 4, 512); STATUS_SUCCESS } } X_IOCTL_DISK_GET_PARTITION_INFO => { if output_buffer_len < 0x10 { STATUS_BUFFER_TOO_SMALL } else { mem.write_u64(output_buffer, 0); mem.write_u64(output_buffer + 8, CACHE_SIZE); STATUS_SUCCESS } } _ => { tracing::warn!( io_control_code = format!("0x{:X}", io_control_code), "NtDeviceIoControlFile: unhandled IOCTL" ); STATUS_INVALID_PARAMETER } }; let info = if status == STATUS_SUCCESS { output_buffer_len } else { 0 }; write_io_status_block(mem, io_status_block, status as u32, info); ctx.gpr[3] = status; signal_io_completion_event(state, event_handle); } /// Minimal `NtQueryInformationFile`. The only classes Sylpheed (and most /// games) use are `FileStandardInformation` (5) and `FilePositionInformation` /// (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 // 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; let class = ctx.gpr[7] as u32; let Some(KernelObject::File { size, position, path, host_path, .. }) = state.objects.get(&handle) else { ctx.gpr[3] = STATUS_INVALID_HANDLE; write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0); return; }; // AUDIT-038 — refresh size from the live host file when this is a // cache-backed handle so post-write queries see accurate EOF. let live_size = if let Some(hp) = host_path.as_ref() { std::fs::metadata(hp).map(|m| m.len()).unwrap_or(*size) } else { *size }; // Root-of-device opens (`game:\`, `cache:\`, `partition0`) strip to // an empty string post-prefix — see `open_vfs_file`'s synth path. // Games query these as directories (DirectoryObject probe), and // reporting `Directory=0` makes Sylpheed treat the open as "found a // non-directory where I expected a directory" and call // `XamShowDirtyDiscErrorUI`. Canary's `NtQueryInformationFile` pulls // the real file-system entry's kind; we key on path shape since we // don't model directory entries. let is_directory = path.is_empty() || path.ends_with('/') || path.ends_with(':'); let size = live_size; let position = *position; // `FILE_ATTRIBUTE_DIRECTORY` (NT / Xbox) — advertised in // `FileNetworkOpenInformation.FileAttributes`; Sylpheed's async-I/O // worker queries with class=34 and the calling code checks this bit // to decide whether the open resolved to a directory before // continuing down the non-error path. const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; let written: u32 = match class { // FileStandardInformation: AllocationSize(i64), EndOfFile(i64), NumberOfLinks(u32), DeletePending(u8), Directory(u8), pad(u16) 5 if length >= 24 => { mem.write_u64(file_info, size); mem.write_u64(file_info + 8, size); mem.write_u32(file_info + 16, 1); mem.write_u8(file_info + 20, 0); mem.write_u8(file_info + 21, if is_directory { 1 } else { 0 }); mem.write_u16(file_info + 22, 0); 24 } // FilePositionInformation: CurrentByteOffset(i64) 14 if length >= 8 => { mem.write_u64(file_info, position); 8 } // FileNetworkOpenInformation: timestamps(4x i64) @ 0..32, // AllocationSize(i64) @ 32, EndOfFile(i64) @ 40, FileAttributes(u32) @ 48 // Sylpheed's async-validation worker asks for this (`length=56`) // and the caller checks `FileAttributes & FILE_ATTRIBUTE_DIRECTORY` // right after. Without populating the attributes the bit is // clear, the caller decides the open "found a non-directory // where a directory was expected", and the outer routine calls // `XamShowDirtyDiscErrorUI` → `XamLoaderLaunchTitle` → garbage. 34 if length >= 56 => { // Zero timestamps (we don't track real times). for off in (0..32).step_by(8) { mem.write_u64(file_info + off, 0); } mem.write_u64(file_info + 32, size); mem.write_u64(file_info + 40, size); let attrs = if is_directory { FILE_ATTRIBUTE_DIRECTORY } else { FILE_ATTRIBUTE_NORMAL }; mem.write_u32(file_info + 48, attrs); mem.write_u32(file_info + 52, 0); // pad 56 } _ => { // Zero out whatever the caller asked for — conservative default. for i in 0..length { mem.write_u8(file_info + i, 0); } length } }; write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, written); 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). /// /// 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. 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. // 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; let info_class = ctx.gpr[7] as u32; // Matches Canary's `GetSetFileInfoMinimumLength`. A return of 0 means // "class we don't recognise for SetInfo" → STATUS_INVALID_INFO_CLASS. let min_length = match info_class { 4 => 40, // XFileBasicInformation (times + attributes) 10 => 16, // XFileRenameInformation 13 => 4, // XFileDispositionInformation (delete_file u32) 14 => 8, // XFilePositionInformation (i64 current offset) 16 | 31 => 4, // XFileModeInformation / XFileIoPriorityInformation 19 | 20 | 23 => 8, // XFileAllocationInformation / EndOfFileInformation / MountPartitionInformation 11 => 16, // XFileLinkInformation 24 => 152, // XFileMountPartitionsInformation 30 => 8, // XFileCompletionInformation (handle + key, 2 dwords) _ => 0, }; if min_length == 0 { ctx.gpr[3] = STATUS_INVALID_INFO_CLASS; return; } if info_length < min_length { ctx.gpr[3] = STATUS_INFO_LENGTH_MISMATCH; 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; return; }; let (status, out_length): (u64, u32) = match info_class { // XFilePositionInformation (14): i64 new byte offset. 14 => { let new_offset = mem.read_u64(info_ptr); // Canary clamps nothing — it assigns directly. Game is // responsible for staying within the file; reads past EOF // return STATUS_END_OF_FILE from NtReadFile. *position = new_offset; (STATUS_SUCCESS, 8) } // XFileEndOfFileInformation (20): i64 new length. // For cache:/* (host_path Some): real `set_len` against the // backing file. Disc-VFS / synth: only a no-op truncate-to-same // succeeds (read-only). 20 => { let new_eof = mem.read_u64(info_ptr); if let Some(hp) = host_path.clone() { match std::fs::OpenOptions::new() .write(true) .open(&hp) .and_then(|f| f.set_len(new_eof).map(|()| f)) { Ok(_) => { *size = new_eof; (STATUS_SUCCESS, 8) } Err(e) => { tracing::warn!("set_len({:?}, {}): {}", hp, new_eof, e); (STATUS_UNSUCCESSFUL, 8) } } } else if new_eof == *size { (STATUS_SUCCESS, 8) } else { (STATUS_UNSUCCESSFUL, 8) } } // XFileAllocationInformation (19): pre-allocation hint. Canary // explicitly `XELOGW`s and reports out_length=8; we do the same. 19 => (STATUS_SUCCESS, 8), // XFileBasicInformation (4): times + attributes. Read-only VFS // can't persist these, but acknowledge the write to match Canary's // behaviour on a read-only entry. 4 => (STATUS_SUCCESS, 40), // XFileDispositionInformation (13): delete-on-close. Read-only VFS // → log the bit and succeed; the file is never actually removed. 13 => { let delete_flag = mem.read_u32(info_ptr) != 0; tracing::debug!( handle = format_args!("{handle:#x}"), delete = delete_flag, "NtSetInformationFile: disposition (read-only VFS, no-op)" ); (STATUS_SUCCESS, 0) } // Other recognised classes: accept and report back the minimum // length so callers don't bail on zero-information. _ => (STATUS_SUCCESS, min_length), }; if iosb_ptr != 0 { write_io_status_block(mem, iosb_ptr, status as u32, out_length); } 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; let out = ctx.gpr[4] as u32; let path = match crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr) { Some(p) if !p.is_empty() => p, _ => { ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; return; } }; // 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_NO_SUCH_FILE; return; } let Some(vfs) = state.vfs.as_ref() else { ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; return; }; match vfs.stat(&path) { Ok(entry) => { 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(_) => { ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; } } } fn nt_query_volume_information_file(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = handle, r4 = io_status_block, r5 = info, r6 = length, r7 = class let io_status_block = ctx.gpr[4] as u32; let info = ctx.gpr[5] as u32; let length = ctx.gpr[6] as u32; let class = ctx.gpr[7] as u32; // FileFsSizeInformation (class 3): 24 bytes // TotalAllocationUnits(i64), AvailableAllocationUnits(i64), // SectorsPerAllocationUnit(u32), BytesPerSector(u32) let written: u32 = match class { 3 if length >= 24 => { mem.write_u64(info, 0x10); mem.write_u64(info + 8, 0x10); mem.write_u32(info + 16, 0x80); mem.write_u32(info + 20, 0x200); 24 } _ => { for i in 0..length { mem.write_u8(info + i, 0); } length } }; write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, written); ctx.gpr[3] = STATUS_SUCCESS; } /// Enumerate the immediate children of a directory handle, writing /// `X_FILE_DIRECTORY_INFORMATION` entries into the caller's buffer. /// Mirrors Canary [xboxkrnl_io.cc:516-557](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc) /// and the entry layout in /// [xfile.h:35-73](xenia-canary/src/xenia/kernel/xfile.h). /// /// Pagination: each call consumes `dir_enum_pos` on the File handle. /// `None` = fresh handle → start at index 0; `Some(N)` = resume from /// N-th matching entry. On exhaustion the cursor stays past the end /// and subsequent calls return `STATUS_NO_MORE_FILES`. The `restart_scan` /// flag (9th arg, on the stack) is not yet threaded through; callers /// that want to rescan must close and re-open the directory handle. fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // 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. // 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; // Canary requires at least one fixed prefix + some filename room. const ENTRY_FIXED_SIZE: u32 = 0x40; // bytes 0..64 fixed fields const CANARY_MIN_LENGTH: u32 = 72; // xboxkrnl_io.cc:521 const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; const FILE_ATTRIBUTE_NORMAL: u32 = 0x80; if length < CANARY_MIN_LENGTH { ctx.gpr[3] = STATUS_INFO_LENGTH_MISMATCH; signal_io_completion_event(state, event_handle); return; } // Look up the handle and snapshot the directory prefix. let dir_path = match state.objects.get(&handle) { Some(KernelObject::File { path, .. }) => path.clone(), _ => { if iosb_ptr != 0 { write_io_status_block(mem, iosb_ptr, STATUS_INVALID_HANDLE as u32, 0); } ctx.gpr[3] = STATUS_INVALID_HANDLE; signal_io_completion_event(state, event_handle); return; } }; // Gather the directory's immediate children from the VFS. An empty // `dir_path` refers to the disc root; non-empty paths match entries // whose name starts with `dir_path + "/"` and whose suffix (relative // to that prefix) contains no further slashes. let prefix: String = if dir_path.is_empty() { String::new() } else if dir_path.ends_with('/') { dir_path.clone() } else { format!("{}/", dir_path) }; let entries: Vec = match state.vfs.as_ref() { Some(vfs) => vfs .list_root() .unwrap_or_default() .into_iter() .filter_map(|e| { let relative: &str = if prefix.is_empty() { e.name.as_str() } else { match e.name.strip_prefix(prefix.as_str()) { Some(s) => s, None => return None, } }; if relative.is_empty() || relative.contains('/') { return None; } Some(xenia_vfs::VfsEntry { name: relative.to_string(), is_directory: e.is_directory, size: e.size, offset: e.offset, }) }) .collect(), None => Vec::new(), }; // Load / initialise the enumeration cursor. let start_index = match state.objects.get_mut(&handle) { Some(KernelObject::File { dir_enum_pos, .. }) => { let pos = dir_enum_pos.unwrap_or(0); *dir_enum_pos = Some(pos); pos } _ => 0, }; if start_index >= entries.len() { if iosb_ptr != 0 { write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0); } ctx.gpr[3] = STATUS_NO_MORE_FILES; signal_io_completion_event(state, event_handle); return; } // Pack as many entries as fit into `length`. `NextEntryOffset` is the // byte distance to the next entry from the start of the current one; // 0 marks the last entry. Entries are 8-byte aligned per Canary. let mut cursor: u32 = 0; let mut emitted: usize = 0; let mut last_entry_offset: Option = None; for (i, entry) in entries.iter().enumerate().skip(start_index) { let name_bytes = entry.name.as_bytes(); let name_len = name_bytes.len() as u32; let raw_size = ENTRY_FIXED_SIZE + name_len; let aligned_size = (raw_size + 7) & !7; if cursor + raw_size > length { // Entry wouldn't fit — leave the buffer truncated and stop. break; } let base = info_ptr + cursor; mem.write_u32(base + 0x00, 0); // next_entry_offset (patched later) mem.write_u32(base + 0x04, i as u32); // file_index // Timestamps zeroed — xenia-rs doesn't track them. mem.write_u64(base + 0x08, 0); mem.write_u64(base + 0x10, 0); mem.write_u64(base + 0x18, 0); mem.write_u64(base + 0x20, 0); mem.write_u64(base + 0x28, entry.size); mem.write_u64(base + 0x30, entry.size); let attrs = if entry.is_directory { FILE_ATTRIBUTE_DIRECTORY } else { FILE_ATTRIBUTE_NORMAL }; mem.write_u32(base + 0x38, attrs); mem.write_u32(base + 0x3C, name_len); for (k, &b) in name_bytes.iter().enumerate() { mem.write_u8(base + ENTRY_FIXED_SIZE + k as u32, b); } // Patch the previous entry's next_entry_offset to point here. if let Some(prev_base) = last_entry_offset { mem.write_u32(prev_base + 0x00, cursor - (prev_base - info_ptr)); } last_entry_offset = Some(base); cursor = std::cmp::min(cursor + aligned_size, length); emitted += 1; if cursor + ENTRY_FIXED_SIZE > length { // No room for another fixed header; stop before truncating. break; } } // Advance cursor on the handle. if let Some(KernelObject::File { dir_enum_pos, .. }) = state.objects.get_mut(&handle) { *dir_enum_pos = Some(start_index + emitted); } if emitted == 0 { if iosb_ptr != 0 { write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0); } ctx.gpr[3] = STATUS_NO_MORE_FILES; } else { if iosb_ptr != 0 { write_io_status_block(mem, iosb_ptr, STATUS_SUCCESS as u32, cursor); } ctx.gpr[3] = STATUS_SUCCESS; } signal_io_completion_event(state, event_handle); } fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let handle = ctx.gpr[3] as u32; 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) .map(|c| { *c = c.saturating_sub(1); *c }) .unwrap_or(0); if remaining == 0 { state.handle_refcount.remove(&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); } } } fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle_ptr, r4 = obj_attrs, r5 = event_type, r6 = initial_state. // // 2.AI — Xenon DISPATCHER_HEADER `Type` (NT convention): // 0 = NotificationEvent (manual-reset) // 1 = SynchronizationEvent (auto-reset) // Canary mirrors this at `xboxkrnl_threading.cc:620` // (`ev->Initialize(!event_type, !!initial_state)`) and our own // `ensure_dispatcher_object` (above, type=0→manual, type=1→auto). // // The prior polarity here was inverted (`event_type != 0` → manual), // which silently mis-classified Sylpheed's per-frame VSync gate as // manual-reset+initial-signaled. `handle_consume` is a no-op for // manual-reset events, so the wait fast-path returned SUCCESS every // call (1.05 M iterations on the wedge handle 0x10e8 in 2.AF, zero // signal.match) instead of blocking ~17 ms for the next VSync. let handle_ptr = ctx.gpr[3] as u32; let manual_reset = ctx.gpr[5] == 0; let signaled = ctx.gpr[6] != 0; let handle = state.alloc_handle_for(KernelObject::Event { manual_reset, signaled, waiters: Vec::new(), }); state.audit_create_with_ctx( handle, if manual_reset { "Event/Manual" } else { "Event/Auto" }, ctx, mem, "NtCreateEvent", ); if handle_ptr != 0 { mem.write_u32(handle_ptr, handle); } ctx.gpr[3] = 0; } fn nt_create_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle_ptr, r4 = obj_attrs, r5 = initial_count, r6 = max_count let handle_ptr = ctx.gpr[3] as u32; let count = ctx.gpr[5] as i32; let max = ctx.gpr[6] as i32; let handle = state.alloc_handle_for(KernelObject::Semaphore { count, max, waiters: Vec::new(), }); state.audit_create_with_ctx(handle, "Semaphore", ctx, mem, "NtCreateSemaphore"); if handle_ptr != 0 { mem.write_u32(handle_ptr, handle); } ctx.gpr[3] = 0; } /// `NtCreateTimer(OUT handle_ptr, obj_attributes, timer_type)` — mint a /// Timer kernel object in the handle table. `timer_type` selects between /// NotificationTimer (0, manual-reset) and SynchronizationTimer (1, /// auto-reset); any other value returns `STATUS_INVALID_PARAMETER` /// matching Canary's `assert_always` on bad types (xtimer.cc:32). /// Named-object dedup (Canary's `LookupNamedObject`) is out of /// scope — Sylpheed uses anonymous timers. fn nt_create_timer(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D; let handle_ptr = ctx.gpr[3] as u32; let timer_type = ctx.gpr[5] as u32; if timer_type > 1 { ctx.gpr[3] = STATUS_INVALID_PARAMETER; return; } let handle = state.alloc_handle_for(KernelObject::Timer { manual_reset: timer_type == 0, signaled: false, deadline: None, period_ticks: 0, period_ms: 0, callback_routine: 0, callback_arg: 0, waiters: Vec::new(), }); state.audit_create_with_ctx( handle, if timer_type == 0 { "Timer/Manual" } else { "Timer/Auto" }, ctx, mem, "NtCreateTimer", ); if handle_ptr != 0 { mem.write_u32(handle_ptr, handle); } ctx.gpr[3] = STATUS_SUCCESS; } /// `NtSetTimerEx(handle, due_time_ptr, routine, mode, routine_arg, resume, /// period_ms, unk_zero)` — arm a Timer object. Mirrors Canary's /// [`NtSetTimerEx_entry`](xboxkrnl_threading.cc:897): reads i64 `due_time` /// (100ns units; negative = relative), converts to an absolute deadline /// on our tick timebase (same `/100` scale as `parse_timeout`), stores /// `period_ms` for periodic rearm, and registers the fire in /// `state.pending_timer_fires` via `arm_timer`. /// /// APC delivery (`routine != 0`) is deferred — the timer still signals /// itself on fire, and any `Wait*`-on-the-timer-handle waiter wakes /// correctly. If a real-world probe shows `timer_apc` warns firing, /// that's the signal to lift the APC subsystem into its own PR. fn nt_set_timer_ex(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { const STATUS_INVALID_HANDLE: u64 = 0xC000_0008; let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let due_time_ptr = ctx.gpr[4] as u32; let routine = ctx.gpr[5] as u32; let _mode = ctx.gpr[6] as u32; let routine_arg = ctx.gpr[7] as u32; let _resume = ctx.gpr[8] as u32; let period_ms = ctx.gpr[9] as u32; // Look up handle + confirm it's a Timer. We pull the current hw's // timebase separately (immutable borrow) before any mutation of the // object to keep the borrow-checker happy. let hw_id = state.scheduler.current_hw_id().unwrap_or(0); let now = state.scheduler.ctx(hw_id).timebase; // Read signed i64 due_time (big-endian hi/lo — same pattern as // parse_timeout). Negative = relative-from-now, positive = absolute // (FILETIME). We treat magnitude as relative for both signs; games on // Xbox 360 overwhelmingly pass negative values for timers, and the // positive-absolute path is handled best-effort for bring-up. let hi = mem.read_u32(due_time_ptr) as i32; let lo = mem.read_u32(due_time_ptr + 4); let raw = ((hi as i64) << 32) | (lo as i64 & 0xFFFF_FFFF); let magnitude = raw.unsigned_abs().max(1); let abs_deadline = now.saturating_add(magnitude / 100); // period_ms → ticks: ms × 1,000,000 ns / 100 ns-per-tick-divisor = // ms × 10_000 (raw ticks) ÷ 100 (our scale factor) = ms × 100. Matches // the same divisor `parse_timeout` applies. let period_ticks = (period_ms as u64) * 100; match state.objects.get_mut(&handle) { Some(KernelObject::Timer { signaled, deadline, period_ticks: obj_period_ticks, period_ms: obj_period_ms, callback_routine, callback_arg, .. }) => { *signaled = false; *deadline = Some(abs_deadline); *obj_period_ticks = period_ticks; *obj_period_ms = period_ms; *callback_routine = routine; *callback_arg = routine_arg; } _ => { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } } if routine != 0 { tracing::warn!( target: "timer_apc", routine = format_args!("{:#010x}", routine), arg = format_args!("{:#010x}", routine_arg), handle = format_args!("{:#010x}", handle), "NtSetTimerEx: routine != 0 — APC delivery deferred; timer self-signal still works" ); } state.arm_timer(handle, abs_deadline); ctx.gpr[3] = STATUS_SUCCESS; } /// `NtCancelTimer(handle, OUT current_state_ptr)` — disarm a Timer. The /// OUT pointer receives `0` per Canary's /// [`NtCancelTimer_entry`](xboxkrnl_threading.cc:938-940), regardless of /// prior signaled state. The Timer object stays in the handle table /// (closed via NtClose); subsequent rearm via `NtSetTimerEx` is fine. fn nt_cancel_timer(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { const STATUS_INVALID_HANDLE: u64 = 0xC000_0008; let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let current_state_ptr = ctx.gpr[4] as u32; match state.objects.get_mut(&handle) { Some(KernelObject::Timer { deadline, .. }) => { *deadline = None; } _ => { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } } state.disarm_timer(handle); if current_state_ptr != 0 { mem.write_u32(current_state_ptr, 0); } ctx.gpr[3] = STATUS_SUCCESS; } // ===== RTL ===== // ----- RTL_CRITICAL_SECTION layout (Xbox 360 NT): ----- // +0x00 DebugInfo (unused here) // +0x04 LockCount (signed) // +0x08 RecursionCount (signed; -1 while unlocked) // +0x0C OwningThread (guest thread id, 0 when free) // +0x10 LockSemaphore (unused) // +0x14 SpinCount // // We enforce real mutual exclusion by reading/writing OwningThread and // RecursionCount. Parked HW ids live in `KernelState::cs_waiters[cs_ptr]`. // X_RTL_CRITICAL_SECTION layout (28 bytes, Canary `xboxkrnl_rtl.cc:536-543`): // +0x00: X_DISPATCH_HEADER (16 bytes) // +0x00: type (u8) = 1 (EventSynchronizationObject / auto-reset) // +0x01: absolute (u8) = spin-count/256 // +0x02: size (u8) // +0x03: inserted (u8) // +0x04: signal_state (i32) // +0x08: WaitListHead (two u32 pointers) // +0x10: lock_count (i32) — starts at -1; first acquire → 0 // +0x14: recursion_count (i32) — starts at 0; first acquire → 1 // +0x18: owning_thread (u32) — 0 unless held const CS_OFFS_TYPE: u32 = 0x00; const CS_OFFS_LOCK_COUNT: u32 = 0x10; const CS_OFFS_RECURSION_COUNT: u32 = 0x14; const CS_OFFS_OWNING_THREAD: u32 = 0x18; const CS_STRUCT_SIZE: u32 = 0x1C; fn rtl_initialize_critical_section( ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState, ) { let cs_ptr = ctx.gpr[3] as u32; if cs_ptr != 0 { // Zero the whole struct, then set dispatcher type=1 and // lock_count=-1 per Canary `xeRtlInitializeCriticalSection`. for i in (0..CS_STRUCT_SIZE).step_by(4) { mem.write_u32(cs_ptr + i, 0); } mem.write_u8(cs_ptr + CS_OFFS_TYPE, 1); mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, 0xFFFF_FFFF_u32); // -1 } ctx.gpr[3] = 0; } fn rtl_enter_critical_section( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { let cs_ptr = ctx.gpr[3] as u32; if cs_ptr == 0 { ctx.gpr[3] = 0; 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 // thread, the CS memory is either uninitialized (.data junk from the XEX // image) or the previous owner already exited. Treat it as free. let owner_is_live = owner != 0 && state.scheduler.find_by_tid(owner).is_some(); if owner == 0 || !owner_is_live { if owner != 0 { tracing::debug!( "rtl_enter_cs: cs={:#010x} stored owner={} has no live HW thread — claiming", cs_ptr, owner ); } mem.write_u32(cs_ptr + CS_OFFS_OWNING_THREAD, current_tid); mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, 0); // -1 → 0 on first lock mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, 1); ctx.gpr[3] = 0; return; } if owner == current_tid { 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 rc = mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT) as i32; mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, (rc + 1) as u32); ctx.gpr[3] = 0; return; } // Truly contended against a live peer — park. 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!( "rtl_enter_cs: hw={} park on cs={:#010x} owner_tid={}", current_ref.hw_id, cs_ptr, owner ); ctx.gpr[3] = 0; state .scheduler .park_current(BlockReason::CriticalSection(cs_ptr)); } fn rtl_leave_critical_section( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { let cs_ptr = ctx.gpr[3] as u32; if cs_ptr == 0 { ctx.gpr[3] = 0; return; } let lc = mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32; let rc = mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT) as i32; if rc > 1 { // Still nested; decrement both counts and keep ownership. mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, (lc - 1) as u32); mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, (rc - 1) as u32); ctx.gpr[3] = 0; return; } // Fully releasing — wake the next waiter (if any) and transfer ownership. mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, (lc - 1) as u32); mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, 0); mem.write_u32(cs_ptr + CS_OFFS_OWNING_THREAD, 0); if let Some(queue) = state.cs_waiters.get_mut(&cs_ptr) && !queue.is_empty() { let next_ref = queue.remove(0); // Find the woken thread's guest tid and hand it the lock. let next_tid = state.scheduler.thread(next_ref).tid; mem.write_u32(cs_ptr + CS_OFFS_OWNING_THREAD, next_tid); mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, 1); state.scheduler.wake_ref(next_ref); } ctx.gpr[3] = 0; } fn rtl_try_enter_critical_section( ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState, ) { let cs_ptr = ctx.gpr[3] as u32; if cs_ptr == 0 { ctx.gpr[3] = 0; return; } let current_tid = ctx.thread_id; let owner = mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD); if owner == 0 { mem.write_u32(cs_ptr + CS_OFFS_OWNING_THREAD, current_tid); mem.write_u32(cs_ptr + CS_OFFS_LOCK_COUNT, 0); mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, 1); ctx.gpr[3] = 1; return; } if owner == current_tid { 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 rc = mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT) as i32; mem.write_u32(cs_ptr + CS_OFFS_RECURSION_COUNT, (rc + 1) as u32); ctx.gpr[3] = 1; return; } ctx.gpr[3] = 0; } fn rtl_init_ansi_string(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let dest_ptr = ctx.gpr[3] as u32; let src_ptr = ctx.gpr[4] as u32; if src_ptr != 0 { let mut len: u16 = 0; let mut addr = src_ptr; while mem.read_u8(addr) != 0 { len += 1; addr += 1; } mem.write_u16(dest_ptr, len); mem.write_u16(dest_ptr + 2, len + 1); mem.write_u32(dest_ptr + 4, src_ptr); } else { mem.write_u16(dest_ptr, 0); mem.write_u16(dest_ptr + 2, 0); mem.write_u32(dest_ptr + 4, 0); } } fn rtl_init_unicode_string(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let dest_ptr = ctx.gpr[3] as u32; let src_ptr = ctx.gpr[4] as u32; if src_ptr != 0 { let mut len: u16 = 0; let mut addr = src_ptr; while mem.read_u16(addr) != 0 { len += 2; addr += 2; } mem.write_u16(dest_ptr, len); mem.write_u16(dest_ptr + 2, len + 2); mem.write_u32(dest_ptr + 4, src_ptr); } else { mem.write_u16(dest_ptr, 0); mem.write_u16(dest_ptr + 2, 0); mem.write_u32(dest_ptr + 4, 0); } } fn rtl_capture_context(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = context_ptr — write CPU registers to CONTEXT structure let ptr = ctx.gpr[3] as u32; if ptr != 0 { // Write GPRs at offset 0 (simplified) for i in 0..32 { mem.write_u64(ptr + (i * 8) as u32, ctx.gpr[i]); } } } fn rtl_compare_memory_ulong(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = source, r4 = length, r5 = pattern let source = ctx.gpr[3] as u32; let length = ctx.gpr[4] as u32; let pattern = ctx.gpr[5] as u32; let mut matched: u32 = 0; let count = length / 4; for i in 0..count { let val = mem.read_u32(source + i * 4); if val != pattern { break; } matched += 4; } ctx.gpr[3] = matched as u64; } fn rtl_fill_memory_ulong(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = destination, r4 = length, r5 = pattern let dest = ctx.gpr[3] as u32; let length = ctx.gpr[4] as u32; let pattern = ctx.gpr[5] as u32; let count = length / 4; for i in 0..count { mem.write_u32(dest + i * 4, pattern); } } 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) { // r3 = unicode_str, r4 = max_bytes_out, r5 = bytes_written_ptr // r6 = multi_byte_str, r7 = multi_byte_len let uni_ptr = ctx.gpr[3] as u32; let max_bytes = ctx.gpr[4] as u32; let written_ptr = ctx.gpr[5] as u32; let mb_ptr = ctx.gpr[6] as u32; let mb_len = ctx.gpr[7] as u32; let max_chars = max_bytes / 2; let count = std::cmp::min(mb_len, max_chars); for i in 0..count { let byte = mem.read_u8(mb_ptr + i); mem.write_u16(uni_ptr + i * 2, byte as u16); } if written_ptr != 0 { mem.write_u32(written_ptr, count * 2); } ctx.gpr[3] = 0; } fn rtl_nt_status_to_dos_error(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { // 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 { 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 }; } fn rtl_raise_exception(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // X_EXCEPTION_RECORD layout (big-endian, Xbox; mirrors // xenia-canary/src/xenia/kernel/kernel.h:227-236, total 0x50 bytes): // +0x00 DWORD ExceptionCode // +0x04 DWORD ExceptionFlags // +0x08 PVOID ExceptionRecord (chain) // +0x0C PVOID ExceptionAddress // +0x10 DWORD NumberParameters // +0x14 ULONG_PTR ExceptionInformation[15] <-- info[0] starts here // // For MSVC C++ throws (code = 0xE06D7363) the parameter convention is: // info[0] = magic (0x19930520) // info[1] = thrown object pointer // info[2] = ThrowInfo* (TI descriptor in .rdata) let record_ptr = ctx.gpr[3] as u32; if record_ptr == 0 { tracing::warn!(tid = ctx.thread_id, "RtlRaiseException: null record"); return; } let code = mem.read_u32(record_ptr); let flags = mem.read_u32(record_ptr + 0x04); let addr = mem.read_u32(record_ptr + 0x0C); let nparams = mem.read_u32(record_ptr + 0x10); let info0 = if nparams > 0 { mem.read_u32(record_ptr + 0x14) } else { 0 }; let info1 = if nparams > 1 { mem.read_u32(record_ptr + 0x18) } else { 0 }; let info2 = if nparams > 2 { mem.read_u32(record_ptr + 0x1C) } else { 0 }; tracing::warn!( tid = ctx.thread_id, record = format_args!("{record_ptr:#010x}"), code = format_args!("{code:#010x}"), flags = format_args!("{flags:#010x}"), exception_addr = format_args!("{addr:#010x}"), caller_lr = format_args!("{:#010x}", ctx.lr as u32), nparams, info0 = format_args!("{info0:#010x}"), info1 = format_args!("{info1:#010x}"), info2 = format_args!("{info2:#010x}"), "RtlRaiseException (stubbed return)", ); // One-shot deep diagnostic for MSVC C++ throws. Mirrors the latch // pattern used elsewhere (see render.rs:693-707 first_dispatch_logged). // Fires once per process start; subsequent throws still log the // header line above but don't repeat the expensive stack walk + decode. if code == 0xE06D_7363 && !state.cxx_throw_logged { state.cxx_throw_logged = true; // Walk the PPC frame chain ~6 levels back from r1. // PPC/EABI prologue: `mflr r12; stw r12, -8(r1); stwu r1, -F(r1)`. // After prologue, [r1] = back-chain to old_r1, and the LR saved // in *that* frame's prologue lives at [old_r1 - 8]. // Walking up: prev_sp = mem.read_u32(sp); // saved_lr_for_that_frame = mem.read_u32(prev_sp - 8); // Level 0 is the live frame: its return address is in ctx.lr // (no need to read the stack). let mut frames: Vec<(u32, u32)> = Vec::with_capacity(8); frames.push((ctx.gpr[1] as u32, ctx.lr as u32)); let mut sp = ctx.gpr[1] as u32; for _ in 0..6 { if sp == 0 || sp == 0xFFFF_FFFF { break; } let prev_sp = mem.read_u32(sp); if prev_sp == 0 || prev_sp == sp || prev_sp == 0xFFFF_FFFF { break; } let saved_lr = mem.read_u32(prev_sp.wrapping_sub(8)); frames.push((prev_sp, saved_lr)); sp = prev_sp; } for (i, (fp, lr)) in frames.iter().enumerate() { tracing::warn!( level = i, frame_ptr = format_args!("{fp:#010x}"), saved_lr = format_args!("{lr:#010x}"), "cxx_throw stack frame", ); } // Extract lhs — the "not valid instance" pointer — from __CxxThrow wrapper's // saved r30. sub_825F23D8 (__CxxThrow) does `std r30, -24(r1)` in its prologue // where r1 = sub_82454770's current SP = frames[2].0 (L2 frame pointer). // `std` is a 64-bit big-endian store; the 32-bit guest address is in the // lower 4 bytes at [frames[2].0 - 24 + 4] = [frames[2].0 - 20]. if frames.len() >= 3 { let l2_fp = frames[2].0; let lhs = mem.read_u32(l2_fp.wrapping_sub(20)); tracing::warn!( l2_fp = format_args!("{l2_fp:#010x}"), lhs = format_args!("{lhs:#010x}"), "cxx_throw lhs (not-registered instance)", ); // Walk the instance registry BST at 0x828F3DA8 to show what IS registered. // Layout: [+0..+27]=CriticalSection (28 bytes), [+28..+31]=some field, // [+32]=sentinel heap ptr, [+36]=node count. // Sentinel (heap-allocated): [+0]=left,[+4]=next,[+8]=right,[+12]=key,[+17]=is_valid(1). // A real node has is_valid=0. let registry_base = 0x828F3DA8_u32; let sentinel_ptr = mem.read_u32(registry_base + 32); let node_count = mem.read_u32(registry_base + 36); tracing::warn!( sentinel = format_args!("{sentinel_ptr:#010x}"), node_count, "cxx_throw registry state", ); if sentinel_ptr != 0 { // Replicate validator sub_82454600's BST ceil search: // Find min key >= lhs. If candidate_key == lhs → should be valid. let root = mem.read_u32(sentinel_ptr.wrapping_add(4)); let mut node = root; let mut candidate = sentinel_ptr; // "no candidate" marker let mut steps = 0_u32; loop { if mem.read_u8(node.wrapping_add(17)) != 0 { break; // sentinel (is_valid != 0) } if steps >= 128 { break; // guard against runaway } let key = mem.read_u32(node.wrapping_add(12)); if key >= lhs { candidate = node; node = mem.read_u32(node); // go left (node[+0]) } else { node = mem.read_u32(node.wrapping_add(8)); // go right (node[+8]) } steps += 1; } let (candidate_key, candidate_is_sentinel) = if candidate != sentinel_ptr { (mem.read_u32(candidate.wrapping_add(12)), false) } else { (0, true) }; tracing::warn!( root = format_args!("{root:#010x}"), root_key = format_args!("{:#010x}", mem.read_u32(root.wrapping_add(12))), lhs = format_args!("{lhs:#010x}"), candidate = format_args!("{candidate:#010x}"), candidate_key = format_args!("{candidate_key:#010x}"), candidate_is_sentinel, steps, match_found = (candidate_key == lhs && !candidate_is_sentinel), "cxx_throw BST ceil search", ); } else { tracing::warn!("cxx_throw registry: sentinel_ptr is null"); } } // Decode runtime_error::what() — verified layout via the // destructor at sub_8216DBC0 (it does `addi r3, obj, 12` // before calling the std::string destructor). MSVC layout // for this CRT: // +0x00 vtbl* // +0x04 char* _Mywhat (lazy; set by what(); often 0 at throw) // +0x08 uint8_t _Mydofree // +0x0C std::string _Mystr { // union _Bx { char _Buf[16]; char* _Ptr; } (+0x0C..+0x1C) // size_t _Mysize (+0x1C) // size_t _Myres (+0x20) capacity // } // SSO: when _Myres < 16, chars are inline at +0x0C; otherwise // +0x0C is a heap char*. Log BOTH interpretations + raw // _Mysize/_Myres so the right one is obvious from the values. if info1 != 0 { let mut sso_buf = [0u8; 16]; mem.read_bytes(info1.wrapping_add(0x0C), &mut sso_buf); let nul = sso_buf.iter().position(|&b| b == 0).unwrap_or(16); let sso_msg = String::from_utf8_lossy(&sso_buf[..nul]).into_owned(); let heap_ptr = mem.read_u32(info1.wrapping_add(0x0C)); let heap_msg = if heap_ptr != 0 && heap_ptr != info1.wrapping_add(0x0C) && (0x10000..0xC000_0000).contains(&heap_ptr) { read_cstring(mem, heap_ptr) } else { String::new() }; let mysize = mem.read_u32(info1.wrapping_add(0x1C)); let myres = mem.read_u32(info1.wrapping_add(0x20)); let mywhat = mem.read_u32(info1.wrapping_add(0x04)); let mywhat_str = if mywhat != 0 && (0x10000..0xC000_0000).contains(&mywhat) { read_cstring(mem, mywhat) } else { String::new() }; tracing::warn!( obj = format_args!("{info1:#010x}"), throwinfo = format_args!("{info2:#010x}"), magic = format_args!("{info0:#010x}"), mysize, myres, heap_ptr = format_args!("{heap_ptr:#010x}"), mywhat_ptr = format_args!("{mywhat:#010x}"), mywhat = %mywhat_str, sso_msg = %sso_msg, heap_msg = %heap_msg, "cxx_throw runtime_error decoded", ); } } // Keep the existing stub-return semantics: Canary's RtlRaiseException // also returns rather than unwinds (xboxkrnl_debug.cc:131-151 — the // TODO comment there reads "unwinding. This is going to suck."). // The Canary-aligned path is to fix the upstream HLE that triggered // the throw, not to implement SEH dispatch here. } fn rtl_unwind(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { tracing::warn!("RtlUnwind: target_frame={:#010x}", ctx.gpr[3]); // Stub — in a real implementation this would walk the stack } fn stub_sprintf(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let dest = ctx.gpr[3] as u32; let fmt = ctx.gpr[4] as u32; if fmt != 0 && dest != 0 { let mut addr = fmt; let mut daddr = dest; loop { let c = mem.read_u8(addr); mem.write_u8(daddr, c); if c == 0 { break; } addr += 1; daddr += 1; } } ctx.gpr[3] = 0; } fn stub_vsnprintf(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { // r3 = buffer, r4 = count, r5 = format, r6 = va_list let dest = ctx.gpr[3] as u32; let fmt = ctx.gpr[5] as u32; if fmt != 0 && dest != 0 { let mut addr = fmt; let mut daddr = dest; loop { let c = mem.read_u8(addr); mem.write_u8(daddr, c); if c == 0 { break; } addr += 1; daddr += 1; } } ctx.gpr[3] = 0; } // ===== Video ===== /// `VdGetCurrentDisplayGamma(type_ptr, power_ptr)` — matches Canary's /// impl (xboxkrnl_video.cc:119). Writes the active gamma ramp kind and /// its power exponent. Returning without writing leaves stack garbage for /// the game to consume; Sylpheed's boot sequence branches on the type and, /// with uninitialized bytes, takes the "unknown gamma → abort init" exit /// path — `main()` then returns to the CRT entry and the title terminates /// before the render loop starts. fn vd_get_current_display_gamma( ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState, ) { let type_ptr = ctx.gpr[3] as u32; let power_ptr = ctx.gpr[4] as u32; if type_ptr != 0 { mem.write_u32(type_ptr, 2); // BT.709 / TV gamma — the Xbox 360 default } if power_ptr != 0 { // float 2.22222 ≈ 0x4011C720, matches Canary's // `kernel_display_gamma_power` cvar default. mem.write_u32(power_ptr, 0x4011_C720); } ctx.gpr[3] = 0; } fn vd_query_video_mode(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { let mode_ptr = ctx.gpr[3] as u32; if mode_ptr != 0 { mem.write_u32(mode_ptr, 1280); mem.write_u32(mode_ptr + 4, 720); mem.write_u32(mode_ptr + 8, 0); // is_interlaced mem.write_u32(mode_ptr + 12, 1); // is_widescreen mem.write_u32(mode_ptr + 16, 60); // refresh_rate } ctx.gpr[3] = 0; } /// Phase C+23: mirror canary's `VdQueryVideoFlags_entry` /// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc:231-241`). /// /// Canary computes a bitmask from the queried video mode: /// bit 0 (0x1) — `is_widescreen` (cvar `widescreen`, default true) /// bit 1 (0x2) — `display_width >= 1280` (HD) /// bit 2 (0x4) — `display_width >= 1920` (Full HD) /// /// Ours's `vd_query_video_mode` reports `display_width=1280` and /// `is_widescreen=1` (the canary defaults), so the canary-equivalent /// return value is `0x1 | 0x2 = 3`. This matches the cold-vs-cold /// observation at main matched-prefix idx 105,138 (canary returns `3`). /// /// A future Vd-subsystem session can swap this for actual cvar-driven /// logic; for now the constant return value mirrors canary 1:1 under /// the shipping defaults. fn vd_query_video_flags(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { // is_widescreen=1, display_width=1280 → bits 0 + 1 = 3 ctx.gpr[3] = 0x3; } fn vd_get_system_command_buffer( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // Matches `VdGetSystemCommandBuffer_entry` in // `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc:330-334`: // void VdGetSystemCommandBuffer_entry(lpunknown_t p0_ptr, lpunknown_t p1_ptr) { // p0_ptr.Zero(0x94); // xe::store_and_swap(p0_ptr, 0xBEEF0000); // xe::store_and_swap(p1_ptr, 0xBEEF0001); // } // Games pass two out-pointers; the first points at a 148-byte block they // expect zeroed, and the first dword of each block is a "token" that // xenia-canary hard-codes. The tokens aren't further dereferenced — they // are later fed back to Vd* calls and checked for non-zero. let p0_ptr = ctx.gpr[3] as u32; let p1_ptr = ctx.gpr[4] as u32; if p0_ptr != 0 { for i in (0..0x94u32).step_by(4) { mem.write_u32(p0_ptr + i, 0); } mem.write_u32(p0_ptr, 0xBEEF_0000); } if p1_ptr != 0 { mem.write_u32(p1_ptr, 0xBEEF_0001); } state.gpu_command_buffer = p0_ptr; // kept for informational use in --ui HUD ctx.gpr[3] = 0; } fn vd_is_hsio_training_succeeded(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { ctx.gpr[3] = 1; // TRUE } fn vd_initialize_ring_buffer(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { // Matches `VdInitializeRingBuffer_entry` at // `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc:313-319`: // r3 = ring buffer guest address (physical, WRITE_COMBINE) // r4 = log2(size) in bytes let ptr = ctx.gpr[3] as u32; let size_log2 = ctx.gpr[4] as u32; state.gpu.initialize_ring_buffer(ptr, size_log2); // Cache the ring layout on KernelState so `vd_swap` can write PM4 // packets directly into ring memory at the current WPTR (the GPU // backend lives on a worker thread under `--gpu-thread` so we can't // read its `ring.base` from the kernel side without a channel hop). // Per canary: size_log2 is log2(size in BYTES), so size in dwords = // 2^size_log2 / 4 = 1 << (size_log2 - 2). state.ring_base = ptr; state.ring_size_dwords = if size_log2 >= 2 { 1u32 << (size_log2 - 2) } else { 0 }; ctx.gpr[3] = 0; } fn vd_enable_ring_buffer_rptr_writeback( ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState, ) { // Matches `VdEnableRingBufferRPtrWriteBack_entry` at // `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc:322-326`. let ptr = ctx.gpr[3] as u32; let block_log2 = ctx.gpr[4] as u32; state.gpu.enable_rptr_writeback(ptr, block_log2); ctx.gpr[3] = 0; } fn vd_set_graphics_interrupt_callback( ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState, ) { // r3 = callback, r4 = user_data. P6: store the callback so the synthetic // v-sync ticker + PM4_INTERRUPT path can invoke it. Zero means "unregister". let cb = ctx.gpr[3] as u32; let user = ctx.gpr[4] as u32; if cb == 0 { state.interrupts.callback = None; tracing::info!("VdSetGraphicsInterruptCallback: unregistered"); } else { state.interrupts.set_callback(cb, user); tracing::info!( "VdSetGraphicsInterruptCallback({:#010x}, {:#010x}) — callback armed", cb, user ); } ctx.gpr[3] = 0; } fn vd_swap(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // Argument order from xenia-canary VdSwap_entry: // r3 = buffer_ptr (slot the game reserved in the primary ring) // r4 = fetch_ptr (6-dword D3D9 texture fetch header) // r5 = unk2 (system writeback ptr — ignored here) // r6 = unk3 (system cmd buf — ignored) // r7 = unk4 (system cmd buf — ignored) // r8 = frontbuffer_ptr (*u32, guest writes its virtual FB address) // r9 = texture_format_ptr(*u32) // r10 = color_space_ptr (*u32) // stack[0] = width_ptr (*u32) — we decode from fetch instead // stack[1] = height_ptr (*u32) — same let buffer_ptr = ctx.gpr[3] as u32; let fetch_ptr = ctx.gpr[4] as u32; let frontbuffer_ptr = ctx.gpr[8] as u32; let texture_format_ptr = ctx.gpr[9] as u32; let color_space_ptr = ctx.gpr[10] as u32; // Decode the D3D9 texture fetch header — 6 dwords. The interesting bits // are base_address (dword_1) and size_2d (dword_2). Mirrors // xenia-canary/src/xenia/gpu/xenos.h xe_gpu_texture_fetch_t. let mut fetch_dwords = [0u32; 6]; if fetch_ptr != 0 { for (i, slot) in fetch_dwords.iter_mut().enumerate() { *slot = mem.read_u32(fetch_ptr + (i as u32) * 4); } } // dword_1 bits 12:31 hold base_address shifted right by 12. let frontbuffer_virt = (fetch_dwords[1] >> 12) << 12; // dword_2: width in bits 0..12 (width-1), height in bits 13..25 (height-1). // Fall back to the reported video mode when the fetch is empty. let (width, height) = if fetch_dwords[2] != 0 { let w = (fetch_dwords[2] & 0x1FFF) + 1; let h = ((fetch_dwords[2] >> 13) & 0x1FFF) + 1; (w, h) } else { (1280, 720) }; // Translate frontbuffer virtual → physical. Per canary VdSwap_entry // (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc:468-471), // the GPU consumes physical addresses; the fetch header carries a // virtual address. KRNBUG-Mm-04: our MmGetPhysicalAddress is a masked // stub; a `virt & 0x1FFF_FFFF` is the equivalent translation today. let phys_mask: u32 = 0x1FFF_FFFF; let frontbuffer_addr_virt = if frontbuffer_virt != 0 { frontbuffer_virt } else if frontbuffer_ptr != 0 { mem.read_u32(frontbuffer_ptr) } else { 0 }; let frontbuffer_addr = frontbuffer_addr_virt & phys_mask; let texture_format = if texture_format_ptr != 0 { mem.read_u32(texture_format_ptr) } else { 0 }; let color_space = if color_space_ptr != 0 { mem.read_u32(color_space_ptr) } else { 0 }; // GPUBUG-FETCH-PATCH-001 (deferred): if/when the PM4_TYPE0 injection // path is re-enabled, also patch `fetch_dwords[1]` here: // fetch_dwords[1] = (fetch_dwords[1] & 0x0000_0FFF) | ((frontbuffer_addr >> 12) << 12); // That carries the slot-0 fetch-constant for the Sylpheed bloom/blur // "sample frame N for frame N+1" path. Mirrors `xenia-canary` at // xboxkrnl_video.cc:479. Currently skipped (see below). let _ = fetch_dwords; // silence unused — will be live again under the deferred path // The original M2b path zero-filled buffer_ptr (in the system command // buffer) and bumped WPTR by 64 to expose the game's own ring writes. // Keep that untouched — the game still expects buffer_ptr to be a // skippable scratch area, and the bump still exposes any game-batched // PM4 packets for the drain. if buffer_ptr != 0 { for i in 0..64u32 { mem.write_u32(buffer_ptr + i * 4, xenia_gpu::pm4::make_packet_type2()); } } state.gpu.extend_write_ptr_by(64); // GPUBUG-DRAIN-001: notify the swap directly. // // Per xenia-canary `VdSwap_entry` (xboxkrnl_video.cc:438-521), the // textbook approach is to inject `PM4_TYPE0(SHADER_CONSTANT_FETCH_00_0)` // (fetch-constant slot-0 patch for the Sylpheed bloom/blur "frame N+1" // sample) followed by `PM4_TYPE3(PM4_XE_SWAP)` directly into the // primary ring at WPTR, then let the natural drain consume them. // // That works in **pure lockstep** (drain runs at every kernel callback // boundary, ring has at most a few hundred packets pending). It // **does not** work under `--parallel` (CPU + GPU ring contention) — // observed empirically: vd_swap's `drain_to_current_wptr` consumes // 8-10 million game-batched IB packets in the 900 ms inline-deadline // window without reaching our tail-injected PM4_XE_SWAP. Under // threaded backend the worker has the same deadline. Either: // (a) the safety-net direct notify (below) fires and gets the swap // counted — but if the worker *eventually* drains past our // injected packet later it would double-count, // (b) we extend the deadline so far that vd_swap blocks for many // seconds — unreasonable for a kernel callback. // // Skip the ring injection unconditionally and post `notify_xe_swap` // directly. The drain still runs (game packets execute as normal). // **Trade-off**: the slot-0 fetch-constant patch is deferred — // tracked as GPUBUG-FETCH-PATCH-001. Sylpheed currently has draws=0, // so a stale slot 0 has no observable effect. let drained = state.gpu.drain_to_current_wptr(mem); tracing::debug!(drained, "VdSwap: drained PM4 packets"); // Direct swap notification. Inline mode bumps `swaps_seen` // synchronously; threaded mode posts a `GpuCommand::NotifyXeSwap` // and the worker bumps it asynchronously. if frontbuffer_addr != 0 && width > 0 && height > 0 { state.gpu.notify_xe_swap(frontbuffer_addr, width, height); } // The remaining vd_swap work (UI publish: shader blobs, constants, // texture cache, frontbuffer detile, ui.notify_swap) reads // `state.gpu`'s internal state directly. In threaded mode that state // lives on the worker thread; the UI bridge itself is `None` under // `--gpu-thread` today (run_with_ui panics if both flags are set), so // the early-return below is exact rather than a workaround. let Some(gpu_inline) = state.gpu.as_inline_mut() else { ctx.gpr[3] = 0; return; }; // Prefer the swap info the executor learned from PM4_XE_SWAP (that's // the source of truth after draining). let swap = gpu_inline.last_swap.unwrap_or(xenia_gpu::SwapNotification { frame_index: gpu_inline.swap_counter, frontbuffer_phys: frontbuffer_addr, width, height, }); // P3b: publish the shader blob map + constants snapshot to the UI so // the Xenos uber-shader has what it needs to execute captured draws. // Do this before `notify_swap` so by the time the UI processes the // SwapInfo the matching assets are visible through `UiHandles`. if let Some(ref ui) = state.ui { let blobs: std::collections::HashMap> = gpu_inline .shader_blobs .iter() .map(|(k, b)| (*k, b.dwords.clone())) .collect(); let constants = xenia_gpu::xenos_constants::XenosConstantsBlock::snapshot( &gpu_inline.register_file, ); ui.publish_assets(blobs, constants); // P5: try to decode the primary texture (fetch constant slot 0). // Slot 0 is the convention most games use for their main bound // texture at draw time; full N-slot binding waits for P6+. If the // slot is unset or the format isn't supported (magenta stub kicks // in host-side), we skip. // // Texture fetch constants live at `CONST_BASE_FETCH + slot*6` in // the register file; we read the 6 dwords, decode the key, hit // the CPU cache (with page-version freshness), and clone the // decoded bytes across the bridge. const TEX_SLOT: u32 = 0; let mut fetch6 = [0u32; 6]; for (i, slot) in fetch6.iter_mut().enumerate() { *slot = gpu_inline .register_file .read(xenia_gpu::gpu_system::CONST_BASE_FETCH + TEX_SLOT * 6 + i as u32); } let published = if let Some(key) = xenia_gpu::texture_cache::decode_fetch_constant(fetch6) { // Span over the entire tiled texture footprint to pick the // max page version covering it. let bi = key.format.block_info(); let span_bytes = (key.pitch_texels as u32) * (key.height as u32) * (bi.bytes_per_block as u32) / (bi.block_w as u32); let version = mem.max_page_version(key.base_address, span_bytes.max(4)); match gpu_inline.texture_cache.ensure_cached(key, version, mem) { Ok(entry) => Some((entry.key, entry.bytes.clone())), Err(e) => { metrics::counter!( "gpu.texture.reject", "reason" => format!("{:?}", e), ) .increment(1); None } } } else { None }; metrics::gauge!("gpu.texture_cache.entries") .set(gpu_inline.texture_cache.len() as f64); ui.publish_texture(published); } // Notify the UI. if let Some(ui) = state.ui.clone() { let (last_prim, last_verts) = match gpu_inline.last_draw { Some(ds) => { // PrimitiveType variants without Display; encode as raw bits. let code = match ds.primitive { xenia_gpu::draw_state::PrimitiveType::None => 0, xenia_gpu::draw_state::PrimitiveType::PointList => 1, xenia_gpu::draw_state::PrimitiveType::LineList => 2, xenia_gpu::draw_state::PrimitiveType::LineStrip => 3, xenia_gpu::draw_state::PrimitiveType::TriangleList => 4, xenia_gpu::draw_state::PrimitiveType::TriangleFan => 5, xenia_gpu::draw_state::PrimitiveType::TriangleStrip => 6, xenia_gpu::draw_state::PrimitiveType::RectangleList => 8, xenia_gpu::draw_state::PrimitiveType::QuadList => 13, xenia_gpu::draw_state::PrimitiveType::Unknown(x) => x as u32, }; (code, ds.vertex_count) } None => (0, 0), }; let instructions_total: u64 = state .scheduler .slots .iter() .flat_map(|slot| slot.runqueue.iter()) .map(|t| t.ctx.cycle_count) .sum(); // P4: CPU-side detile of the guest frontbuffer. We treat the // frontbuffer as a tiled k_8_8_8_8 image (the overwhelmingly // common format games resolve to), read it out of guest memory, // run it through `tiled_2d` / `detile_2d`, and hand the resulting // linear RGBA8 bytes to the UI via a dedicated bridge closure. // The UI upgrades the previous "no frontbuffer content" placeholder // path to real game output. Failures (OOB reads, malformed fetch // headers) silently skip the publish. if swap.frontbuffer_phys != 0 && swap.width > 0 && swap.height > 0 { let pitch_aligned = xenia_gpu::tiled_address::align_pitch_to_macro_tile(swap.width); let total_tiled_bytes = (pitch_aligned * swap.height * 4) as usize; // The guest address is 32-bit virtual but in the physical heap; // safer to cap the read at the known total size to avoid OOB. let mut tiled = Vec::with_capacity(total_tiled_bytes); let mut ok = true; for i in 0..total_tiled_bytes { // read_u8 is cheap — the VirtualMemory handler returns 0 // for unmapped pages so we get a recognisable dark frame // rather than a crash if the address turned out bogus. let addr = swap.frontbuffer_phys.wrapping_add(i as u32); tiled.push(mem.read_u8(addr)); if addr < swap.frontbuffer_phys { ok = false; break; } } if ok { let mut linear = vec![0u8; (swap.width * swap.height * 4) as usize]; if xenia_gpu::tiled_address::detile_2d( &tiled, &mut linear, swap.width, swap.height, pitch_aligned, 4, ) .is_ok() { ui.publish_frontbuffer(swap.width, swap.height, linear); } } } ui.notify_swap( crate::ui_bridge::SwapInfo { frontbuffer_addr: swap.frontbuffer_phys, width: swap.width, height: swap.height, texture_format, color_space, frame_index: swap.frame_index, draws_total: gpu_inline.stats.draws_seen, packets_total: gpu_inline.stats.packets_executed, last_draw_prim: last_prim, last_draw_vertex_count: last_verts, indirect_buffer_jumps: gpu_inline.stats.indirect_buffer_jumps, wait_reg_mem_blocks: gpu_inline.stats.wait_reg_mem_blocks, instructions_total, vs_blob_key: gpu_inline.active_vs_key.unwrap_or(0), ps_blob_key: gpu_inline.active_ps_key.unwrap_or(0), resolves_total: gpu_inline.stats.resolves_total, resolves_copied_total: gpu_inline.stats.resolves_copied_total, resolves_skipped_total: gpu_inline.stats.resolves_skipped_total, unique_render_targets: gpu_inline.stats.unique_render_targets, interrupts_delivered: state.interrupts.delivered, interrupts_dropped: state.interrupts.dropped, }, mem, ); } tracing::info!( frame = swap.frame_index, fb = format_args!("{:#010x}", swap.frontbuffer_phys), width = swap.width, height = swap.height, fmt = texture_format, cs = color_space, drained, buffer_ptr = format_args!("{buffer_ptr:#010x}"), fetch_ptr = format_args!("{fetch_ptr:#010x}"), "VdSwap complete" ); ctx.gpr[3] = 0; } // ===== Audio ===== const X_E_INVALIDARG: u64 = 0x8007_0057; const XAUDIO_DRIVER_TAG: u32 = 0x4155_0000; const XAUDIO_DRIVER_INDEX_MASK: u32 = 0x0000_FFFF; fn xaudio_register_render_driver(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { let callback_ptr = ctx.gpr[3] as u32; let driver_ptr = ctx.gpr[4] as u32; if callback_ptr == 0 { ctx.gpr[3] = X_E_INVALIDARG; return; } let callback_pc = mem.read_u32(callback_ptr); if callback_pc == 0 { ctx.gpr[3] = X_E_INVALIDARG; return; } let callback_arg = mem.read_u32(callback_ptr.wrapping_add(4)); let Some(wrapped) = state.heap_alloc(4, mem) else { tracing::warn!("XAudioRegisterRenderDriverClient: heap_alloc(4) failed"); ctx.gpr[3] = X_E_INVALIDARG; return; }; mem.write_u32(wrapped, callback_arg); let client = crate::xaudio::XAudioClient { callback_pc, callback_arg, wrapped_callback_arg: wrapped, }; let Some(index) = state.xaudio.register(client) else { tracing::warn!("XAudioRegisterRenderDriverClient: client table full"); ctx.gpr[3] = X_E_INVALIDARG; return; }; let driver_id = XAUDIO_DRIVER_TAG | (index as u32 & XAUDIO_DRIVER_INDEX_MASK); if driver_ptr != 0 { mem.write_u32(driver_ptr, driver_id); } // AUDIT-032 Plan B: spawn a dedicated audio-worker guest thread for // this client and park it on a synthetic `WaitAny` handle so // `try_inject_audio_callback` can flip it to `ServicingIrq` when // a buffer-complete fire is queued. Mirrors xenia-canary's // `apu/audio_system.cc:84-159` host worker without spawning a host OS // thread. Failure here is non-fatal (the client is still registered; // the periodic ticker will queue fires that the round prologue // simply drops with `dropped += 1` because there's no worker to pump). let worker_stack = 0x10_000u32; // 64 KiB — half of canary's 128 KiB. let worker_ref_handle = if let Some(image) = crate::thread::allocate_thread_image(state, mem, worker_stack, 0) { use std::sync::atomic::Ordering; let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed); let handle = state.alloc_handle_for(KernelObject::Thread { id: tid, hw_id: None, exit_code: None, waiters: Vec::new(), }); let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed); let params = SpawnParams { entry: callback_pc, start_context: wrapped, stack_base: image.stack_base, stack_size: image.stack_size, pcr_base: image.pcr_base, tls_base: image.tls_base, thread_handle: handle, guest_tid: tid, create_suspended: true, is_initial: false, tls_slot_count, affinity_mask: 0, priority: 0, ideal_processor: None, }; match state.scheduler.spawn(params, &mut GuestMemoryPcr(mem)) { Ok(hw_id) => { if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) { *slot = Some(hw_id); } // Flip from `Blocked(Suspended)` (set by spawn for // create_suspended=true) to a `Blocked(WaitAny)` on a // synthetic handle never owned by any kernel object. // `wake_eligible_waiters` looks the handle up in // `state.objects` and returns early on miss, so this // park-state is only released by audio-callback injection. let park_handle = crate::xaudio::synthetic_park_handle(index); let target = ThreadRef::new( hw_id, (state.scheduler.slots[hw_id as usize].runqueue.len() - 1) as u16, ); // Both Blocked(Suspended) (set by spawn) and // Blocked(WaitAny) are non-runnable, so the // `non_empty_runnable` bitmask is unchanged — no need to // call the private `recompute_slot_runnable` helper. state.scheduler.thread_mut(target).state = xenia_cpu::scheduler::HwState::Blocked(BlockReason::WaitAny { handles: vec![park_handle], deadline: None, }); Some((handle, target)) } Err(_) => { tracing::warn!("XAudioRegisterRenderDriverClient: spawn failed for worker idx={}", index); None } } } else { tracing::warn!("XAudioRegisterRenderDriverClient: allocate_thread_image failed for worker idx={}", index); None }; if let Some((h, r)) = worker_ref_handle { state.xaudio.worker_handles[index] = Some(h); 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={:?} seeded_fires={}", index, callback_pc, callback_arg, wrapped, driver_id, state.xaudio.worker_handles[index], seeded, ); ctx.gpr[3] = 0; } fn xaudio_unregister_render_driver(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let driver_id = ctx.gpr[3] as u32; let index = (driver_id & XAUDIO_DRIVER_INDEX_MASK) as usize; state.xaudio.unregister(index); tracing::info!( "XAudioUnregisterRenderDriverClient: driver={:#010x} index={}", driver_id, index, ); ctx.gpr[3] = 0; } fn xaudio_submit_render_driver_frame( ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState, ) { ctx.gpr[3] = 0; } fn xma_create_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let handle = state.alloc_handle(); tracing::info!("XMACreateContext: handle={:#x}", handle); 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` /// (xboxkrnl_modules.cc:22-39): returns whether bit `privilege` of the /// loaded executable module's `XEX_HEADER_SYSTEM_FLAGS` (key 0x00030000) /// is set. Privilege ≥ 32 returns 0 (matches `1 << priv` UB-via-overflow /// behavior — canary's mask becomes 0 once the shift saturates the /// uint32_t range, and `(flags & 0)` is always 0). fn xex_check_executable_privilege(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let privilege = ctx.gpr[3] as u32; let result = if privilege < 32 { (state.xex_system_flags >> privilege) & 1 } else { 0 }; if state.xex_priv_logged.insert(privilege) { tracing::info!( priv = privilege, flags = format_args!("{:#010x}", state.xex_system_flags), result, lr = format_args!("{:#010x}", ctx.lr), "XexCheckExecutablePrivilege", ); } ctx.gpr[3] = result as u64; } fn xex_get_procedure_address(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // Mirrors xenia-canary XexGetProcedureAddress_entry // (xboxkrnl_modules.cc:195): r3 = hmodule, r4 = ordinal, // r5 = lpdword_t out_function_ptr. Returns NTSTATUS in r3; on success // writes the resolved thunk address to *out_function_ptr. let hmodule = ctx.gpr[3] as u32; let ordinal = ctx.gpr[4] as u32; let out_ptr = ctx.gpr[5] as u32; if out_ptr != 0 { mem.write_u32(out_ptr, 0); } let Some(module) = state.module_id_from_hmodule(hmodule) else { tracing::warn!( "XexGetProcedureAddress: unknown hmodule={:#x} ordinal={:#x}", hmodule, ordinal, ); ctx.gpr[3] = STATUS_INVALID_HANDLE; return; }; match state.resolve_thunk(module, ordinal as u16) { Some(addr) => { if out_ptr != 0 { mem.write_u32(out_ptr, addr); } ctx.gpr[3] = STATUS_SUCCESS; } None => { tracing::warn!( "XexGetProcedureAddress: ordinal {:#x} not registered for {:?}", ordinal, module, ); // STATUS_DRIVER_ENTRYPOINT_NOT_FOUND == 0xC000_0034. ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND; } } } // ===== Exception handling ===== fn c_specific_handler(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { tracing::warn!("__C_specific_handler called (exception handling stub)"); ctx.gpr[3] = 1; // ExceptionContinueSearch } // ===== Synchronization (events / semaphores / waits) ===== /// Is the handle currently signaled / acquirable? For events and semaphores /// this tests the counting state; for thread handles it's true once the /// thread has exited. pub(crate) fn handle_signaled(state: &KernelState, handle: u32) -> bool { match state.objects.get(&handle) { Some(KernelObject::Event { signaled, .. }) => *signaled, Some(KernelObject::Timer { signaled, .. }) => *signaled, Some(KernelObject::Semaphore { count, .. }) => *count > 0, Some(KernelObject::Thread { exit_code, .. }) => exit_code.is_some(), _ => false, } } /// Refresh a PKEVENT/PKSEMAPHORE shadow from the guest's dispatcher /// struct. Handle-keyed Nt objects (small integer keys) are managed /// entirely by the kernel and don't need this — but pointer-keyed Ke /// shadows can desync when the guest signals the dispatcher via a direct /// memory write (e.g. Sylpheed's graphics-interrupt callback writes /// `SignalState = 1` into its user_data struct instead of going through /// `KeSetEvent`). Before a wait check, we re-load byte +4 and reconcile /// the shadow's `signaled` / `count` with guest memory so the wait /// reflects the current dispatcher state. /// /// Without this, tid=5's render-dispatcher poll loop on the Sylpheed /// intro spun 4.5M times per 100M instructions with only 11K resolved /// wakes — the callback was firing but the shadow stayed unsignaled, /// so every wait deadlined to `STATUS_TIMEOUT` and the worker looped /// without ever running its real render path. fn refresh_pkevent_shadow_from_guest(state: &mut KernelState, mem: &GuestMemory, ptr: u32) { if ptr < 0x1_0000 { return; } let Some(obj) = state.objects.get_mut(&ptr) else { return; }; let signal_state = mem.read_u32(ptr + 4); match obj { KernelObject::Event { signaled, .. } | KernelObject::Timer { signaled, .. } => { if signal_state != 0 { *signaled = true; } // Intentionally only pull the rising edge from guest // memory. If the guest wrote 0 but the shadow says // signaled=true because a `KeSetEvent` hasn't been // consumed yet, we'd spuriously clear; leave clearing // to `KeResetEvent` / auto-reset `handle_consume`. } KernelObject::Semaphore { count, .. } => { let guest_count = signal_state as i32; if guest_count > *count { *count = guest_count; } } _ => {} } } /// Consume one signal slot on a handle (auto-reset events, semaphore /// decrement, mutex-ish). Assumes `handle_signaled` just returned true. pub(crate) fn handle_consume(state: &mut KernelState, handle: u32) { match state.objects.get_mut(&handle) { Some(KernelObject::Event { manual_reset, signaled, .. }) | Some(KernelObject::Timer { manual_reset, signaled, .. }) => { if !*manual_reset { *signaled = false; } } Some(KernelObject::Semaphore { count, .. }) => { if *count > 0 { *count -= 1; } } _ => {} } } /// Register a guest thread as a waiter on a handle (for later wake). pub(crate) fn handle_enqueue_waiter(state: &mut KernelState, handle: u32, r: ThreadRef) { match state.objects.get_mut(&handle) { Some(KernelObject::Event { waiters, .. }) | Some(KernelObject::Semaphore { waiters, .. }) | Some(KernelObject::Thread { waiters, .. }) | Some(KernelObject::Timer { waiters, .. }) | Some(KernelObject::Mutex { waiters, .. }) => { if !waiters.contains(&r) { waiters.push(r); } } _ => {} } } /// Remove a ThreadRef from every waiter list it might be on. Called on wake /// so a thread woken on one of its WaitAny handles doesn't linger as a /// waiter on the others. pub(crate) fn handle_remove_waiter_everywhere(state: &mut KernelState, r: ThreadRef) { for obj in state.objects.values_mut() { if let Some(waiters) = obj.waiters_mut() { waiters.retain(|&w| w != r); } } for list in state.cs_waiters.values_mut() { list.retain(|&w| w != r); } } /// Parse a PowerPC-style LARGE_INTEGER timeout pointer. /// Returns `None` for "wait forever" (null pointer), `Some(0)` for /// "poll / don't block" (timeout value 0), else `Some(abs_deadline)`. /// Xbox 360 timeouts are signed 100-ns units; negative = relative. /// We convert to an absolute deadline on the current thread's timebase. pub(crate) fn parse_timeout(state: &KernelState, timeout_ptr: u32, mem: &GuestMemory) -> Option> { if timeout_ptr == 0 { return Some(None); // wait infinitely } let hi = mem.read_u32(timeout_ptr) as i32; let lo = mem.read_u32(timeout_ptr + 4); let raw = ((hi as i64) << 32) | (lo as i64 & 0xFFFF_FFFF); if raw == 0 { return Some(Some(0)); // poll } let hw_id = state.scheduler.current_hw_id().unwrap_or(0); let now = state.scheduler.ctx(hw_id).timebase; // Negative = relative, positive = absolute wall-clock. Our timebase is a // plain instruction counter, so we treat all timeouts as "time-units // after now" regardless of sign, using the magnitude. let magnitude = raw.unsigned_abs(); // Scale: 100-ns units → ~1 tick per ns is fine for emulation (games just // want monotonic progress). Divide by 100 so multi-millisecond timeouts // don't exceed u64 and wake quickly. let deadline = now.saturating_add(magnitude.max(1) / 100); Some(Some(deadline)) } /// Resolve NT pseudo-handles to real kernel handles, matching Canary's /// [`ObjectTable::TranslateHandle`](https://github.com/xenia-canary/xenia-canary/blob/canary/src/xenia/kernel/util/object_table.cc): /// /// * `0xFFFFFFFE` — `NtCurrentThread()` → the currently running thread's handle /// * `0xFFFFFFFF` — `NtCurrentProcess()` → 0 (not meaningful in our HLE) /// * anything else passes through untouched /// /// Every kernel function that accepts a handle argument should translate /// first. Canary does this centrally in `LookupObject` — we don't have the /// same chokepoint, so the pattern is "call this at the top of each Ob/Ke/Nt /// entry point that consumes a handle". /// /// Without this, Sylpheed's worker-thread prologue calls /// `ObReferenceObjectByHandle((HANDLE)-2, ...)` (= "get my own thread"), /// gets `STATUS_INVALID_HANDLE`, and proceeds with a null "thread object /// pointer" through `KeSetAffinityThread` — the worker then exits without /// running its real body, leaving the main thread parked forever on the /// completion event. fn resolve_pseudo_handle(state: &KernelState, handle: u32) -> u32 { 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` /// pointer on first touch from a `Ke*` sync function. /// /// Background: on Xenon the `Nt*` family takes `HANDLE` integers (allocated /// by us via `alloc_handle`), but the `Ke*` family takes pointers to /// dispatcher structs in guest memory. `KeInitializeEvent` is an inline /// helper baked into the game's code — it writes the DISPATCHER_HEADER in /// place and we never see the call. As a result, when the game later calls /// e.g. `KeSetEvent(&kevent)`, our handle-lookup misses and the operation /// silently no-ops, leaving waiters parked forever. That was the root cause /// of Sylpheed's 562K/50M `KeResetEvent` poll-loop on pointer `0x42450b5c`. /// /// We mint a shadow [`KernelObject`] in `state.objects` keyed by the guest /// pointer (pointers live above the handle range — `next_handle` starts at /// `0x1000` and bumps by 4, so collisions with a real handle are impossible /// for any sane pointer). Subsequent Ke/Nt operations hit the shadow. /// /// Xenon DISPATCHER_HEADER layout (big-endian): /// +0 Type (u8) 0=NotificationEvent, 1=SynchronizationEvent, /// 5=Semaphore. Others unsupported (Mutant/Timer /// paths fall back to the prior no-op behavior). /// +1 Absolute (u8) /// +2 Size (u8) in u32 words /// +3 Inserted (u8) /// +4 SignalState (i32) /// +8 WaitListHead (2 × u32) LIST_ENTRY /// For KSEMAPHORE, `Limit` (i32) follows at +0x10. /// /// Caveat: the shadow is authoritative once created. If the guest writes /// directly into the dispatcher struct bypassing the kernel API, the shadow /// drifts — but well-behaved NT code never does that. fn ensure_dispatcher_object(state: &mut KernelState, mem: &GuestMemory, ptr: u32) { // Pointer-vs-handle discriminator: our handles are small (<= low // tens of thousands for any realistic session). Anything higher is // almost certainly a guest pointer. Also bail if already registered. if ptr < 0x1_0000 || state.objects.contains_key(&ptr) { return; } let ty = mem.read_u8(ptr); let signal_state = mem.read_u32(ptr + 4); let obj = match ty { 0 => KernelObject::Event { manual_reset: true, signaled: signal_state != 0, waiters: Vec::new(), }, 1 => KernelObject::Event { manual_reset: false, signaled: signal_state != 0, waiters: Vec::new(), }, 5 => { let limit = mem.read_u32(ptr + 0x10) as i32; KernelObject::Semaphore { count: signal_state as i32, max: limit.max(1), waiters: Vec::new(), } } // KTIMER DISPATCHER_HEADER: type=8 NotificationTimer (manual-reset), // type=9 SynchronizationTimer (auto-reset). Mint a disarmed shadow — // deadline/period live in KTIMER's extended fields (+0x20 onward) // which we don't mirror; games that want the timer armed go through // NtSetTimerEx / KeSetTimer (handle-based), and Sylpheed uses the // handle path exclusively. 8 | 9 => KernelObject::Timer { manual_reset: ty == 8, signaled: signal_state != 0, deadline: None, period_ticks: 0, period_ms: 0, callback_routine: 0, callback_arg: 0, waiters: Vec::new(), }, _ => 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 /// wait set was the one that fired. Canary's `WaitMultiple` returns /// `STATUS_WAIT_0 + index` on WaitAny success; games branch on it. The /// default pre-populated status is `STATUS_SUCCESS` (== WAIT_0), which only /// matches when the first handle is the signaling one — anything else /// looks like a spurious index-0 wake to the caller. fn set_wake_status_for_waitany(state: &mut KernelState, r: ThreadRef, signaled_handle: u32) { use xenia_cpu::scheduler::{BlockReason, HwState}; let Some(t) = state.scheduler.try_thread_mut(r) else { return; }; let idx = match &t.state { HwState::Blocked(BlockReason::WaitAny { handles, .. }) | HwState::ServicingIrq(BlockReason::WaitAny { handles, .. }) => { handles.iter().position(|&h| h == signaled_handle) } _ => None, }; if let Some(i) = idx { t.ctx.gpr[3] = i as u64; } } /// Iterate 2.T: classify a `HwState` for `wake.requested`. Pure read. fn wake_classify_state(s: &xenia_cpu::scheduler::HwState) -> (&'static str, &'static str) { use xenia_cpu::scheduler::{BlockReason, HwState}; let kind = match s { HwState::Blocked(BlockReason::WaitAny { .. }) | HwState::ServicingIrq(BlockReason::WaitAny { .. }) => "WaitAny", HwState::Blocked(BlockReason::WaitAll { .. }) | HwState::ServicingIrq(BlockReason::WaitAll { .. }) => "WaitAll", HwState::Blocked(_) | HwState::ServicingIrq(_) => "WaitSingle", _ => "Other", }; let name = match s { HwState::Ready => "Ready", HwState::Blocked(_) => "Blocked", HwState::Exited(_) => "Exited", HwState::ServicingIrq(_) => "ServicingIrq", HwState::Idle => "Idle", }; (kind, name) } /// Iterate 2.T: capture (signaling_tid, cycle) at wake-loop entry from /// the currently-executing HW thread (the signal-call caller). fn wake_signaling_ctx(state: &KernelState) -> (u32, u64) { if let Some(r) = state.scheduler.current { let t = state.scheduler.thread(r); (t.tid, t.ctx.timebase) } else { (0u32, 0u64) } } /// Iterate 2.T: capture pre-wake snapshot of a waiter — its tid, hw_id, /// and wait-kind classification — then emit a `wake.requested` event /// after the wake call has produced its post-state. Pure observability; /// no behavior change. Cvar-gated default-off via `event_log::is_enabled`. fn emit_wake_requested_for( state: &KernelState, signaling_tid: u32, cycle: u64, target: ThreadRef, handle: u32, prior_wait_kind: &'static str, prior_state_name: &'static str, ) { if !crate::event_log::is_enabled() { return; } let Some(slot) = state.scheduler.slots.get(target.hw_id as usize) else { return; }; let Some(t) = slot.runqueue.get(target.idx as usize) else { return; }; let (_post_kind, post_name) = wake_classify_state(&t.state); let transitioned = prior_state_name == "Blocked" && post_name == "Ready"; let new_state = if prior_state_name == "Ready" { "AlreadyReady" } else if post_name == "Ready" { "Ready" } else if post_name == "Blocked" { "StillBlocked" } else { post_name }; crate::event_log::emit_wake_requested( signaling_tid, cycle, t.tid, handle, prior_wait_kind, transitioned, new_state, Some(target.hw_id), ); } /// Wake all waiters whose predicate now holds on the given handle (manual /// reset fans out; auto-reset/semaphore wakes one and consumes). pub(crate) fn wake_eligible_waiters(state: &mut KernelState, handle: u32) { // Iterate 2.T: capture signaler tid + cycle ONCE at entry. The wake // loop below may iterate multiple times for semaphores; we want every // wake.requested event in this fan-out attributed to the same caller. let (signaling_tid, signaling_cycle) = wake_signaling_ctx(state); loop { let Some(obj) = state.objects.get_mut(&handle) else { return; }; let (manual_reset, should_signal, consume) = match obj { KernelObject::Event { manual_reset, signaled, waiters, } | KernelObject::Timer { manual_reset, signaled, waiters, .. } => { if *signaled && !waiters.is_empty() { (*manual_reset, true, !*manual_reset) } else { return; } } KernelObject::Semaphore { count, waiters, .. } => { if *count > 0 && !waiters.is_empty() { (false, true, true) } else { return; } } KernelObject::Thread { exit_code, waiters, .. } => { if exit_code.is_some() && !waiters.is_empty() { (true, true, false) } else { return; } } _ => return, }; if !should_signal { return; } let winner = match obj { KernelObject::Event { waiters, .. } | KernelObject::Timer { waiters, .. } | KernelObject::Semaphore { waiters, .. } | KernelObject::Thread { waiters, .. } => { if manual_reset { // Take the whole queue at once; manual-reset fires once // and stays signaled so every parked waiter clears. let list = std::mem::take(waiters); for w in list { // Iterate 2.T: snapshot prior state BEFORE the wake. let (prior_kind, prior_name) = state .scheduler .slots .get(w.hw_id as usize) .and_then(|s| s.runqueue.get(w.idx as usize)) .map(|t| wake_classify_state(&t.state)) .unwrap_or(("Other", "Other")); set_wake_status_for_waitany(state, w, handle); state.scheduler.wake_ref(w); handle_remove_waiter_everywhere(state, w); // scheduler.wake_ref also loses timed-waits entry if state.audit.enabled { // Record one wake per thread woken. `aux` carries // the resolved status (gpr[3]) we just set. let status = state.scheduler.thread(w).ctx.gpr[3]; state.audit_wake(handle, 0, "wake_eligible_waiters/manual", status); } emit_wake_requested_for( state, signaling_tid, signaling_cycle, w, handle, prior_kind, prior_name, ); } return; } else { waiters.remove(0) } } _ => return, }; // Iterate 2.T: snapshot prior state of the auto-wake winner. let (prior_kind, prior_name) = state .scheduler .slots .get(winner.hw_id as usize) .and_then(|s| s.runqueue.get(winner.idx as usize)) .map(|t| wake_classify_state(&t.state)) .unwrap_or(("Other", "Other")); if consume { handle_consume(state, handle); } set_wake_status_for_waitany(state, winner, handle); state.scheduler.wake_ref(winner); handle_remove_waiter_everywhere(state, winner); if state.audit.enabled { let status = state.scheduler.thread(winner).ctx.gpr[3]; state.audit_wake(handle, 0, "wake_eligible_waiters/auto", status); } emit_wake_requested_for( state, signaling_tid, signaling_cycle, winner, handle, prior_kind, prior_name, ); // continue loop for semaphores that may wake more } } /// Iterate 2.Q: snapshot the (tids, count) of guest threads currently /// parked on `handle`'s waiter list, BEFORE any signal-driven wake fans /// out. Returns `(Vec, count)`. Empty when the handle is unknown, /// doesn't carry a waiter list (File), or has no waiters. Pure read — /// no behavior change. Used solely to feed `emit_signal_match`. fn snapshot_waiters_for_signal(state: &KernelState, handle: u32) -> (Vec, usize) { let obj = match state.objects.get(&handle) { Some(o) => o, None => return (Vec::new(), 0), }; let waiters: &[ThreadRef] = match obj { KernelObject::Event { waiters, .. } | KernelObject::Semaphore { waiters, .. } | KernelObject::Thread { waiters, .. } | KernelObject::Timer { waiters, .. } | KernelObject::Mutex { waiters, .. } | KernelObject::NotifyListener { waiters, .. } => waiters.as_slice(), KernelObject::File { .. } => return (Vec::new(), 0), }; let tids: Vec = waiters .iter() .map(|r| state.scheduler.thread(*r).tid) .collect(); let n = tids.len(); (tids, n) } /// Iterate 2.Q: signal-emit shim — gather waiter snapshot + cycle and /// emit a `signal.match` event. No-op when `event_log` is disabled or /// when zero waiters are parked (per 2.Q scope: don't pollute the trace /// with spurious-target signals). fn emit_signal_match_if_waiters( state: &KernelState, signal_call: &'static str, target_handle: u32, ) { if !crate::event_log::is_enabled() { return; } let (tids, n) = snapshot_waiters_for_signal(state, target_handle); if n == 0 { return; } 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_signal_match(tid, cycle, signal_call, target_handle, n, &tids); } /// AUDIT-2AU Option β: re-signal the XAudio render loop's frame-event /// pair, emulating the host XAudio2 OnBufferEnd callback firing once per /// audio period. Called from the round prologue gated by the same /// instruction-count audio cadence that drives `tick_instr`, so timing /// is deterministic (never host_ns). Mirrors `ke_set_event`'s signal + /// wake sequence for each captured event handle (see /// `do_wait_multiple` capture site + `XAudioState::frame_events`). pub fn pulse_xaudio_frame_events(state: &mut KernelState) { if state.xaudio.frame_events.is_empty() { return; } let events = state.xaudio.frame_events.clone(); for h in events { if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&h) { *signaled = true; emit_signal_match_if_waiters(state, "XAudioFramePulse", h); wake_eligible_waiters(state, h); } } } fn ke_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = PKEVENT on Ke* (guest pointer). See `ensure_dispatcher_object` // for why we need the lazy-shadow step here. let h = ctx.gpr[3] as u32; ensure_dispatcher_object(state, mem, 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, true) } _ => (0u32, false), }; state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64); emit_signal_match_if_waiters(state, "KeSetEvent", h); wake_eligible_waiters(state, h); 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); // 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, true) } _ => (0u32, false), }; 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) { // 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; // 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, true) } _ => (0u32, false), }; state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64); emit_signal_match_if_waiters(state, "NtSetEvent", handle); wake_eligible_waiters(state, handle); 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) { // 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; } ctx.gpr[3] = STATUS_SUCCESS; } /// Pulse an event: wake current waiters as if signaled, then leave the event /// in the non-signaled state. For manual-reset events this wakes *all* /// parked waiters at once; for auto-reset events it wakes at most one (the /// first in the FIFO) and implicitly consumes the pulse. /// /// Canary impl: [xboxkrnl_threading.cc::KePulseEvent_entry](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc) /// → [xevent.cc::XEvent::Pulse](xenia-canary/src/xenia/kernel/xevent.cc). fn pulse_event_on_object(state: &mut KernelState, key: u32) -> u32 { // Capture previous state; then temporarily mark the event signaled so // `wake_eligible_waiters` does the right wake-all vs wake-one split. let previous = match state.objects.get_mut(&key) { Some(KernelObject::Event { signaled, .. }) => { let prev = *signaled; *signaled = true; prev as u32 } _ => return 0, }; wake_eligible_waiters(state, key); // Pulse leaves the event non-signaled regardless of type — manual-reset // would otherwise stay latched after `wake_eligible_waiters`, and auto- // reset with no waiters would linger signaled until the first wait. if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&key) { *signaled = false; } previous } fn ke_pulse_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = PKEVENT (guest pointer), r4 = increment, r5 = wait (ignored). let h = ctx.gpr[3] as u32; ensure_dispatcher_object(state, mem, h); let previous = pulse_event_on_object(state, h); state.audit_signal(h, ctx.lr as u32, "KePulseEvent", previous as u64); ctx.gpr[3] = previous as u64; } fn nt_pulse_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = previous_state_ptr (optional). let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let prev_ptr = ctx.gpr[4] as u32; if !state.objects.contains_key(&handle) { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } let previous = pulse_event_on_object(state, handle); state.audit_signal(handle, ctx.lr as u32, "NtPulseEvent", previous as u64); if prev_ptr != 0 { mem.write_u32(prev_ptr, previous); } ctx.gpr[3] = STATUS_SUCCESS; } /// Attempt `*count += adjust` with the cap at `max`. Returns `(previous, /// updated)` where `updated == false` means the adjustment would have /// exceeded `max` (or overflowed `i32`) and the count was left untouched. fn try_release_semaphore(count: &mut i32, max: i32, adjust: i32) -> (i32, bool) { let prev = *count; match count.checked_add(adjust) { Some(new) if new <= max => { *count = new; (prev, true) } _ => (prev, false), } } fn ke_release_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = PKSEMAPHORE, r4 = adjustment. Ke-form returns the previous // count directly (never a status); if the release would exceed // `Limit` the count silently stays put — Canary `xeKeReleaseSemaphore` // at xboxkrnl_threading.cc:707-722 marks the success return of // `ReleaseSemaphore` `[[maybe_unused]]`. let h = ctx.gpr[3] as u32; ensure_dispatcher_object(state, mem, h); let adjust = ctx.gpr[4] as i32; let previous = match state.objects.get_mut(&h) { Some(KernelObject::Semaphore { count, max, .. }) => { let (prev, _updated) = try_release_semaphore(count, *max, adjust); prev } _ => 0, }; state.audit_signal(h, ctx.lr as u32, "KeReleaseSemaphore", previous as u64); emit_signal_match_if_waiters(state, "KeReleaseSemaphore", h); wake_eligible_waiters(state, h); ctx.gpr[3] = previous as u64; } fn nt_release_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = release_count, r5 = previous_count* (optional). // Canary `NtReleaseSemaphore_entry` (xboxkrnl_threading.cc:771-797) // returns `X_STATUS_SEMAPHORE_LIMIT_EXCEEDED` (0xC000_0047) when the // post-release count would exceed `Limit`, AND does NOT update the // count in that case. `previous_count` is written regardless. let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let release = ctx.gpr[4] as i32; let prev_ptr = ctx.gpr[5] as u32; let (previous, status) = match state.objects.get_mut(&handle) { Some(KernelObject::Semaphore { count, max, .. }) => { let (prev, updated) = try_release_semaphore(count, *max, release); if updated { (prev, STATUS_SUCCESS) } else { (prev, STATUS_SEMAPHORE_LIMIT_EXCEEDED) } } Some(_) | None => { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } }; state.audit_signal(handle, ctx.lr as u32, "NtReleaseSemaphore", previous as u64); if status == STATUS_SUCCESS { emit_signal_match_if_waiters(state, "NtReleaseSemaphore", handle); wake_eligible_waiters(state, handle); } if prev_ptr != 0 { mem.write_u32(prev_ptr, previous as u32); } ctx.gpr[3] = status; } /// Single-handle wait with timeout. If the handle is already signaled, consume /// and return success. Otherwise park the current HW thread and set ctx.gpr[3] /// to STATUS_SUCCESS — when a waker arrives the thread resumes at its caller's /// return address with success already in r3. Timeout=0 never parks. fn do_wait_single(ctx: &mut PpcContext, state: &mut KernelState, handle: u32, timeout_ptr: u32, mem: &GuestMemory) { state.audit_wait(handle, ctx.lr as u32, "do_wait_single", 0); if handle_signaled(state, handle) { handle_consume(state, handle); ctx.gpr[3] = STATUS_SUCCESS; return; } let deadline_opt = parse_timeout(state, timeout_ptr, mem); let deadline = match deadline_opt { Some(Some(0)) => { ctx.gpr[3] = STATUS_TIMEOUT; return; } Some(Some(d)) => Some(d), Some(None) => None, None => None, }; let current_ref = state.scheduler.current_ref(); handle_enqueue_waiter(state, handle, current_ref); tracing::debug!( "wait_single: hw={} handle={:#x} park{}", current_ref.hw_id, handle, match deadline { Some(d) => format!(" until_tick={}", d), None => " forever".into(), } ); // Pre-populate the return code — most wakes resolve as STATUS_SUCCESS; // timeouts overwrite via the scheduler's deadline-wake path. ctx.gpr[3] = STATUS_SUCCESS; state.scheduler.park_current(BlockReason::WaitAny { handles: vec![handle], deadline, }); } /// Multi-handle wait. `wait_type` 0 = WaitAll, 1 = WaitAny (NT convention). fn do_wait_multiple( ctx: &mut PpcContext, state: &mut KernelState, handles: Vec, wait_all: bool, timeout_ptr: u32, mem: &GuestMemory, ) { if state.audit.enabled { // Pack (wait_all flag) | (handle_count << 1) into aux for the trail. let aux = (wait_all as u64) | ((handles.len() as u64) << 1); for &h in &handles { state.audit_wait(h, ctx.lr as u32, "do_wait_multiple", aux); } } let already_ok = if wait_all { handles.iter().all(|&h| handle_signaled(state, h)) } else { handles.iter().any(|&h| handle_signaled(state, h)) }; if already_ok { // Canary's `XObject::WaitMultiple` returns the **index** of the // first-signaled handle for WaitAny (`STATUS_WAIT_0 + n`), not // plain `STATUS_SUCCESS`. `STATUS_WAIT_0` is numerically 0, so // index 0 still looks like success, but index 1+ matters: games // commonly dispatch on the index. Sylpheed's worker prologue does // `wait_any([start_event, work_sem])` and branches on the result: // 0 means "start-event fired" (cleanup/exit), 1 means "sem fired" // (run user proc then signal completion). Returning 0 for a sem // wake made the worker always take the cleanup branch and exit // without ever signaling the completion event. if wait_all { for &h in &handles { handle_consume(state, h); } ctx.gpr[3] = STATUS_SUCCESS; } else if let Some((idx, &h)) = handles .iter() .enumerate() .find(|&(_, &h)| handle_signaled(state, h)) { handle_consume(state, h); ctx.gpr[3] = idx as u64; // STATUS_WAIT_0 + idx } else { ctx.gpr[3] = STATUS_SUCCESS; } return; } let deadline_opt = parse_timeout(state, timeout_ptr, mem); let deadline = match deadline_opt { Some(Some(0)) => { ctx.gpr[3] = STATUS_TIMEOUT; return; } Some(Some(d)) => Some(d), Some(None) => None, None => None, }; // AUDIT-2AU Option β: capture the XAudio render loop's frame-event // pair at the wait site. Sylpheed's render-driver thread (tid=11, // entry 0x824d2a94 = canary tid=4) blocks here on a WaitAny over two // guest-address Events (the "buffer ready" manual-reset + "frame // done" auto-reset pair). In canary these are signaled every audio // period by the host XAudio2 OnBufferEnd callback; in ours nothing // signals them after the first fast-path consumes the auto-reset // member, so the loop wedges forever (2.AL). Record the pair (no // hardcoded addresses) so the round-prologue audio-cadence ticker // re-signals them. Discriminator: a *multi*-handle WaitAny whose // members are all guest-address Events, with at least one XAudio // client registered — tid=2's lone guest-Event wait goes through // do_wait_single, so this won't catch it. if !wait_all && handles.len() >= 2 && state.xaudio.any_registered() && handles.iter().all(|&h| { h >= 0x8000_0000 && matches!(state.objects.get(&h), Some(KernelObject::Event { .. })) }) { for &h in &handles { state.xaudio.note_frame_event(h); } } let current_ref = state.scheduler.current_ref(); for &h in &handles { handle_enqueue_waiter(state, h, current_ref); } ctx.gpr[3] = STATUS_SUCCESS; let reason = if wait_all { BlockReason::WaitAll { handles: handles.clone(), deadline, } } else { BlockReason::WaitAny { handles, deadline } }; state.scheduler.park_current(reason); } fn nt_wait_for_single_object_ex( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // 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` /// (xboxkrnl_threading.cc:1103). Common producer/consumer handshake primitive: /// producer calls `NSAWFSO(work_done, work_free)` so the consumer's wait /// resolves at the same instant the producer starts waiting for the next /// bucket. /// /// Before this export existed games that relied on the primitive saw the /// call surface as `unimplemented kernel export`, their threads proceeded /// without the signal being fired, and the paired consumer-thread wait /// would block indefinitely. Sylpheed's I/O dispatcher uses this for its /// async file-query completion signaling. fn nt_signal_and_wait_for_single_object_ex( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // r3 = signal_handle, r4 = wait_handle, r5 = wait_mode, r6 = alertable, r7 = timeout_ptr let signal_handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let wait_handle = resolve_pseudo_handle(state, ctx.gpr[4] as u32); let timeout_ptr = ctx.gpr[7] as u32; // Signal phase — mirror `nt_set_event` for Event handles; if the // handle is unknown we return `STATUS_INVALID_HANDLE` without waiting, // matching Canary's "lookup both, fail fast if either missing" guard. let signal_prev: u64 = match state.objects.get_mut(&signal_handle) { Some(KernelObject::Event { signaled, .. }) => { let was = *signaled; *signaled = true; was as u64 } Some(KernelObject::Semaphore { count, .. }) => { let was = *count as u64; *count = count.saturating_add(1); was } _ => { ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } }; state.audit_signal( signal_handle, ctx.lr as u32, "NtSignalAndWaitForSingleObjectEx", signal_prev, ); wake_eligible_waiters(state, signal_handle); // Then fall into the normal single-wait path on wait_handle. do_wait_single(ctx, state, wait_handle, timeout_ptr, mem); } fn ke_wait_for_single_object( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // r3 = PKEVENT (guest pointer), r4 = wait_reason, r5 = wait_mode, // r6 = alertable, r7 = timeout_ptr 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); } fn nt_wait_for_multiple_objects_ex( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // r3 = count, r4 = handles_ptr, r5 = wait_type (0=All, 1=Any), // r6 = wait_mode, r7 = alertable, r8 = timeout_ptr let count = ctx.gpr[3] as u32; let handles_ptr = ctx.gpr[4] as u32; let wait_type = ctx.gpr[5] as u32; let timeout_ptr = ctx.gpr[8] as u32; let handles: Vec = (0..count) .map(|i| resolve_pseudo_handle(state, mem.read_u32(handles_ptr + i * 4))) .collect(); let wait_all = wait_type == 0; do_wait_multiple(ctx, state, handles, wait_all, timeout_ptr, mem); } fn ke_wait_for_multiple_objects( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // r3 = count, r4 = objects_ptr (array of PKEVENT/PKSEMAPHORE pointers), // r5 = wait_type, r6 = wait_reason, r7 = wait_mode, r8 = alertable, // r9 = timeout_ptr, r10 = wait_blocks (ignored) let count = ctx.gpr[3] as u32; let handles_ptr = ctx.gpr[4] as u32; let wait_type = ctx.gpr[5] as u32; let timeout_ptr = ctx.gpr[9] as u32; let handles: Vec = (0..count) .map(|i| resolve_pseudo_handle(state, mem.read_u32(handles_ptr + i * 4))) .collect(); for &h in &handles { ensure_dispatcher_object(state, mem, h); refresh_pkevent_shadow_from_guest(state, mem, h); } let wait_all = wait_type == 0; do_wait_multiple(ctx, state, handles, wait_all, timeout_ptr, mem); } fn ke_delay_execution_thread( ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState, ) { // r3 = wait_mode, r4 = alertable, r5 = interval_ptr (LARGE_INTEGER 100-ns) let interval_ptr = ctx.gpr[5] as u32; let deadline_opt = parse_timeout(state, interval_ptr, mem); let deadline = match deadline_opt { Some(Some(0)) => { // Yield-like — return immediately. ctx.gpr[3] = STATUS_SUCCESS; return; } Some(Some(d)) => d, Some(None) => u64::MAX, // KeDelayExecution with NULL interval = sleep forever (unusual) None => u64::MAX, }; ctx.gpr[3] = STATUS_SUCCESS; state .scheduler .park_current(BlockReason::DelayUntil(deadline)); } fn nt_yield_execution(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) { // The next round of the scheduler already hands control to another HW // thread, so we don't need to park. Just return success. ctx.gpr[3] = STATUS_SUCCESS; } fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) { let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); match state.scheduler.find_by_handle(handle) { Some(r) => { state.scheduler.resume_ref(r); ctx.gpr[3] = STATUS_SUCCESS; } None => { ctx.gpr[3] = STATUS_INVALID_HANDLE; } } } fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = prev_suspend_count_ptr // 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 .find_by_handle(handle) .map(|r| state.scheduler.resume_ref(r)) .unwrap_or(0); if prev_ptr != 0 { mem.write_u32(prev_ptr, prev); } ctx.gpr[3] = STATUS_SUCCESS; } fn nt_suspend_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = prev_suspend_count_ptr // 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 .find_by_handle(handle) .map(|r| state.scheduler.suspend_ref(r)) .unwrap_or(0); if prev_ptr != 0 { mem.write_u32(prev_ptr, prev); } ctx.gpr[3] = STATUS_SUCCESS; } // ===== Object & module lookup ===== fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // Mirrors xenia-canary XexGetModuleHandle_entry // (xboxkrnl_modules.cc:42): r3 = lpstring_t module_name, // r4 = lpdword_t hmodule_ptr. Returns NTSTATUS in r3; writes the // resolved handle to *hmodule_ptr. `X_ERROR_NOT_FOUND` for unknown // names. Distinct pseudo-handles for kernel modules so a follow-up // `XexGetProcedureAddress` can route to the right ordinal table. let name_ptr = ctx.gpr[3] as u32; let out_ptr = ctx.gpr[4] as u32; if out_ptr != 0 { mem.write_u32(out_ptr, 0); } let resolved: Option = if name_ptr == 0 { Some(state.image_base) } else { let name = read_cstring(mem, name_ptr); if name.is_empty() || name.eq_ignore_ascii_case("default.xex") { Some(state.image_base) } else if name.eq_ignore_ascii_case("xboxkrnl.exe") { Some(crate::state::HMODULE_XBOXKRNL) } else if name.eq_ignore_ascii_case("xam.xex") { Some(crate::state::HMODULE_XAM) } else { None } }; match resolved { Some(h) => { if out_ptr != 0 { mem.write_u32(out_ptr, h); } ctx.gpr[3] = STATUS_SUCCESS; } None => ctx.gpr[3] = X_ERROR_NOT_FOUND, } } /// `NtDuplicateObject(handle, new_handle_ptr, options)` — per Canary's /// `NtDuplicateObject_entry`: /// * r3 = source handle (pseudo-handles like `(HANDLE)-2` are common — the /// Canary comment explicitly notes "this function seems to be used to get /// the current thread handle") /// * r4 = new_handle_ptr (if zero, the call is actually a close) /// * r5 = options (bit 0 = DUPLICATE_CLOSE_SOURCE) /// /// 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 /// `dup` to the worker, and the worker does `NtSetEvent(dup)` to signal /// 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 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(&canonical) { if out_ptr != 0 { mem.write_u32(out_ptr, 0); } ctx.gpr[3] = STATUS_INVALID_HANDLE; return; } // 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) { 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; } fn ob_reference_object_by_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { // r3 = handle, r4 = object_type, r5 = out_object_ptr let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32); let out_ptr = ctx.gpr[5] as u32; if handle == 0 || !state.objects.contains_key(&handle) { ctx.gpr[3] = STATUS_INVALID_HANDLE; if out_ptr != 0 { mem.write_u32(out_ptr, 0); } return; } if out_ptr != 0 { // We don't maintain real KTHREAD/KEVENT structs in guest memory, so // pass back the handle as a stable cookie — downstream Ke* calls // that take a "thread pointer" (e.g. KeSetAffinityThread) then look // up the same handle via `state.objects`. Matches Canary semantics // for our HLE without requiring a host-visible object-struct backing. mem.write_u32(out_ptr, handle); } ctx.gpr[3] = STATUS_SUCCESS; } // ===== Helpers ===== fn read_cstring(mem: &GuestMemory, addr: u32) -> String { let mut s = String::new(); let mut a = addr; loop { let c = mem.read_u8(a); if c == 0 { break; } s.push(c as char); a += 1; if s.len() > 512 { break; } // Safety limit } s } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use xenia_memory::page_table::MemoryProtect; /// Scratch region the nt_read_file/nt_write_file tests write into /// (iosb + buffer). A single committed page is plenty. const SCRATCH_BASE: u32 = 0x4000_0000; fn fresh() -> (PpcContext, GuestMemory, KernelState) { let mut mem = GuestMemory::new().expect("memory init"); 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 // etc.) don't touch it and are unaffected. 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); (PpcContext::default(), mem, state) } fn make_file(state: &mut KernelState, bytes: Vec) -> u32 { let size = bytes.len() as u64; state.alloc_handle_for(KernelObject::File { path: "test.bin".to_string(), size, position: 0, data: Arc::new(bytes), dir_enum_pos: None, host_path: None, }) } fn make_event(state: &mut KernelState) -> u32 { state.alloc_handle_for(KernelObject::Event { manual_reset: true, signaled: false, waiters: Vec::new(), }) } fn event_signaled(state: &KernelState, h: u32) -> bool { match state.objects.get(&h) { Some(KernelObject::Event { signaled, .. }) => *signaled, _ => panic!("expected Event at handle {:#x}", h), } } /// Axis 4: `KeSetAffinityThread` actually migrates between slots /// now. Spawn a secondary thread with affinity 0x02 (slot 1 only), /// then call the export to move it to slot 4. #[test] fn ke_set_affinity_thread_migrates_and_returns_old() { let (mut ctx, mut mem, mut state) = fresh(); // Pre-fresh() set up the main thread on slot 0. Spawn a worker // on slot 1 via ex_create_thread so the handle / PCR are real. // Simpler: inject directly via scheduler.spawn. use xenia_cpu::scheduler::SpawnParams; let pcr_base = SCRATCH_BASE + 0x500; mem.write_u32(pcr_base + 0x2C, 0xDEAD_BEEF); // sentinel let params = SpawnParams { entry: 0x8200_0000, start_context: 0, stack_base: 0x7200_0000, stack_size: 0x10000, pcr_base, tls_base: 0, thread_handle: 0x2000, guest_tid: 42, 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(); // 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, // 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); 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. let r = state.scheduler.find_by_handle(0x2000).expect("still alive"); assert_eq!(r.hw_id, 5); } /// 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_affinity_thread_null_out_ptr_still_succeeds() { let (mut ctx, mut mem, mut state) = fresh(); 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). 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` /// routes through `KernelState::set_affinity` and actually migrates. #[test] fn nt_set_information_thread_affinity_migrates() { let (mut ctx, mut mem, mut state) = fresh(); // Park info buffer in scratch. let info_ptr = SCRATCH_BASE + 0x40; mem.write_u32(info_ptr, 0x08); // mask = slot 3 ctx.gpr[3] = 0x1000; // main handle ctx.gpr[4] = 3; // ThreadAffinityMask ctx.gpr[5] = info_ptr as u64; ctx.gpr[6] = 4; // info_len nt_set_information_thread(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); // Main should have migrated to slot 3. let r = state.scheduler.find_by_handle(0x1000).expect("still alive"); assert_eq!(r.hw_id, 3); } /// Priority wiring — `KeSetBasePriorityThread` stores on the /// `GuestThread` and `KeQueryBasePriorityThread` reads it back. #[test] fn ke_set_base_priority_round_trips() { let (mut ctx, mut mem, mut state) = fresh(); // fresh() installs the main thread with handle 0x1000. // Query the current priority first — default 0. ctx.gpr[3] = 0x1000; ke_query_base_priority_thread(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0); // Set priority to 7 (high-ish). ctx.gpr[3] = 0x1000; ctx.gpr[4] = 7u64; ke_set_base_priority_thread(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "old priority was 0"); // Query again — now 7. ctx.gpr[3] = 0x1000; ke_query_base_priority_thread(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 7); } /// `KeResumeThread` resolves the KTHREAD-pointer-as-handle, decrements the /// target's suspend count, and unblocks once it hits zero. Mirrors /// xboxkrnl_threading.cc:216-227 (XObject::GetNativeObject + /// thread->Resume()). #[test] fn ke_resume_thread_unblocks_suspended_worker() { use xenia_cpu::scheduler::{BlockReason, HwState, SpawnParams}; let (mut ctx, mut mem, mut state) = fresh(); 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: 0x2000, guest_tid: 42, create_suspended: true, 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(); let r = state.scheduler.find_by_handle(0x2000).expect("spawned"); assert_eq!( state.scheduler.thread(r).state, HwState::Blocked(BlockReason::Suspended) ); ctx.gpr[3] = 0x2000; ke_resume_thread(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let r = state.scheduler.find_by_handle(0x2000).expect("still alive"); assert_eq!(state.scheduler.thread(r).state, HwState::Ready); ctx.gpr[3] = 0xDEAD_BEEF; ke_resume_thread(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } /// The regression we're guarding against: Sylpheed parks a thread on the /// event it handed to `NtReadFile`. Historically our HLE ignored r4 and /// left the event unsignaled — the wait never released. Completion must /// signal the event regardless of whether the read succeeds. #[test] fn nt_read_file_signals_completion_event_on_success() { let (mut ctx, mut mem, mut state) = fresh(); let file = make_file(&mut state, vec![0x11, 0x22, 0x33, 0x44]); let evt = make_event(&mut state); let iosb: u32 = 0x4000_0000; let buf: u32 = 0x4000_0100; // r3 = file, r4 = event, r7 = iosb, r8 = buf, r9 = len, r10 = 0 (use cursor) ctx.gpr[3] = file as u64; ctx.gpr[4] = evt as u64; ctx.gpr[7] = iosb as u64; ctx.gpr[8] = buf as u64; ctx.gpr[9] = 4; ctx.gpr[10] = 0; nt_read_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected"); assert!(event_signaled(&state, evt), "event must be signaled on success"); } #[test] fn nt_read_file_signals_event_on_eof() { let (mut ctx, mut mem, mut state) = fresh(); let file = make_file(&mut state, vec![0x01, 0x02]); // Seek cursor past end by issuing a first read that drains it. if let Some(KernelObject::File { position, .. }) = state.objects.get_mut(&file) { *position = 2; } let evt = make_event(&mut state); ctx.gpr[3] = file as u64; ctx.gpr[4] = evt as u64; ctx.gpr[7] = 0x4000_0000; ctx.gpr[8] = 0x4000_0100; ctx.gpr[9] = 4; ctx.gpr[10] = 0; nt_read_file(&mut ctx, &mut mem, &mut state); assert!(event_signaled(&state, evt), "EOF path must still signal"); } #[test] fn nt_read_file_signals_event_on_invalid_handle() { let (mut ctx, mut mem, mut state) = fresh(); let evt = make_event(&mut state); ctx.gpr[3] = 0xDEAD_BEEF; // bogus file handle ctx.gpr[4] = evt as u64; ctx.gpr[7] = 0x4000_0000; ctx.gpr[8] = 0x4000_0100; ctx.gpr[9] = 4; ctx.gpr[10] = 0; nt_read_file(&mut ctx, &mut mem, &mut state); assert!(event_signaled(&state, evt), "invalid-handle path must still signal"); } /// Many callers pass r4 = 0 (synchronous-wait style). The signal helper /// must no-op rather than corrupt the handle table or panic. #[test] fn nt_read_file_accepts_null_event_handle() { let (mut ctx, mut mem, mut state) = fresh(); let file = make_file(&mut state, vec![0xAA; 8]); ctx.gpr[3] = file as u64; ctx.gpr[4] = 0; ctx.gpr[7] = 0x4000_0000; ctx.gpr[8] = 0x4000_0100; ctx.gpr[9] = 8; ctx.gpr[10] = 0; nt_read_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected with null event"); } /// Synthesized empty files (system-partition opens like /// `\Device\Harddisk0\partition0` that miss the disc-VFS) act as /// canary's `NullDevice`: any `NtReadFile` returns `STATUS_SUCCESS` /// with `information=0` and the buffer untouched. Sylpheed's /// cache-loader at `sub_824A9710` reads 1024 B from offset 2048 then /// validates a `"Josh"` magic — falling back to the recreate path /// when the (caller-zeroed) buffer doesn't match. #[test] fn nt_read_file_synth_empty_file_returns_success_with_zero_bytes() { let (mut ctx, mut mem, mut state) = fresh(); let synth = make_file(&mut state, Vec::new()); // Pre-fill the buffer with a sentinel; canary's NullDevice never // touches it, so the post-read bytes must be unchanged. let buf: u32 = 0x4000_0100; for i in 0..16u32 { mem.write_u8(buf + i, 0xAB); } let evt = make_event(&mut state); // Read 1024 B from offset 2048 — exactly the cache-catalog read. let offset_ptr: u32 = 0x4000_0080; mem.write_u64(offset_ptr, 2048); ctx.gpr[3] = synth as u64; ctx.gpr[4] = evt as u64; ctx.gpr[7] = 0x4000_0000; ctx.gpr[8] = buf as u64; ctx.gpr[9] = 1024; ctx.gpr[10] = offset_ptr as u64; nt_read_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS for synth-empty read"); for i in 0..16u32 { assert_eq!(mem.read_u8(buf + i), 0xAB, "buffer at +{} must be untouched", i); } // IOSB.information must be 0 (matches NullFile bytes_read). assert_eq!(mem.read_u32(0x4000_0000), 0, "iosb.status = 0"); assert_eq!(mem.read_u32(0x4000_0004), 0, "iosb.information = 0"); assert!(event_signaled(&state, evt), "synth-empty read must signal completion"); } #[test] fn nt_write_file_signals_completion_event() { let (mut ctx, mut mem, mut state) = fresh(); let evt = make_event(&mut state); ctx.gpr[3] = 0x1234; // file handle not consulted on the discard path ctx.gpr[4] = evt as u64; ctx.gpr[7] = 0x4000_0000; ctx.gpr[9] = 16; nt_write_file(&mut ctx, &mut mem, &mut state); 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 /// handle as a disc-validation probe — seeing `Directory=0` triggers its /// `XamShowDirtyDiscErrorUI` path. #[test] fn nt_query_information_file_reports_directory_for_root_synth() { let (mut ctx, mut mem, mut state) = fresh(); // Synth a "game:\" style empty-path file, matching what `open_vfs_file` // produces when the prefix-strip leaves nothing behind. let h = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); let info_buf = SCRATCH_BASE + 0x600; ctx.gpr[3] = h as u64; // handle ctx.gpr[4] = SCRATCH_BASE as u64; // iosb ctx.gpr[5] = info_buf as u64; // file_info ctx.gpr[6] = 24; // length ctx.gpr[7] = 5; // FileStandardInformation nt_query_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected"); assert_eq!( mem.read_u8(info_buf + 21), 1, "Directory byte must be 1 for root-of-device synth" ); } /// `NtQueryDirectoryFile` takes an optional completion event at r4 /// (Canary `xboxkrnl_io.cc:516`). The handler must signal that event /// so waiters wake up, and must write the IOSB at r7 (the prior stub /// mis-used r4, clobbering low guest memory). Without a VFS mounted /// the handler finds no children and reports /// `STATUS_NO_MORE_FILES`; the event still has to fire. #[test] fn nt_query_directory_file_signals_completion_event_and_uses_correct_iosb_reg() { let (mut ctx, mut mem, mut state) = fresh(); let evt = make_event(&mut state); // A root-shaped synth directory — exactly what `NtCreateFile("game:\\")` // produces when the prefix-strip leaves nothing behind. let handle = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); let buf = SCRATCH_BASE + 0x100; ctx.gpr[3] = handle as u64; ctx.gpr[4] = evt as u64; ctx.gpr[7] = SCRATCH_BASE as u64; // IOSB must land here ctx.gpr[8] = buf as u64; ctx.gpr[9] = 128; // length >= 72 (Canary minimum) ctx.gpr[10] = 0; nt_query_directory_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_NO_MORE_FILES); assert_eq!(mem.read_u32(SCRATCH_BASE), STATUS_NO_MORE_FILES as u32); assert!(event_signaled(&state, evt), "completion event must be signaled"); } /// Info-length-mismatch (Canary: length < 72 → STATUS_INFO_LENGTH_MISMATCH). #[test] fn nt_query_directory_file_rejects_short_buffer() { let (mut ctx, mut mem, mut state) = fresh(); let handle = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[7] = 0; ctx.gpr[8] = SCRATCH_BASE as u64; ctx.gpr[9] = 16; // below 72 ctx.gpr[10] = 0; nt_query_directory_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INFO_LENGTH_MISMATCH); } /// Minimal `VfsDevice` impl that returns a hard-coded entry list — /// lets us drive `NtQueryDirectoryFile` through a real enumeration /// path without needing a disc image on disk. struct StubVfs { entries: Vec, } impl xenia_vfs::VfsDevice for StubVfs { fn name(&self) -> &str { "stub" } fn list_root(&self) -> Result, xenia_vfs::VfsError> { Ok(self.entries.clone()) } fn read_file(&self, _path: &str) -> Result, xenia_vfs::VfsError> { Err(xenia_vfs::VfsError::NotFound("stub".into())) } fn stat(&self, _path: &str) -> Result { Err(xenia_vfs::VfsError::NotFound("stub".into())) } } /// Real enumeration of the root directory. The stub VFS exposes two /// top-level entries and one nested entry; `NtQueryDirectoryFile` /// must return the two top-level ones and skip the grandchild. #[test] fn nt_query_directory_file_enumerates_root_children() { let (mut ctx, mut mem, mut state) = fresh(); state.vfs = Some(Box::new(StubVfs { entries: vec![ xenia_vfs::VfsEntry { name: "default.xex".into(), is_directory: false, size: 0x1000, offset: 0, }, xenia_vfs::VfsEntry { name: "dat".into(), is_directory: true, size: 0, offset: 0, }, // A grandchild — must NOT appear in root enumeration. xenia_vfs::VfsEntry { name: "dat/tables.pak".into(), is_directory: false, size: 0x2000, offset: 0, }, ], })); let handle = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); let buf = SCRATCH_BASE + 0x100; ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[7] = SCRATCH_BASE as u64; ctx.gpr[8] = buf as u64; ctx.gpr[9] = 512; ctx.gpr[10] = 0; nt_query_directory_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); // First entry header lives at `buf`: file_name_length at +0x3C, // attributes at +0x38, name bytes starting at +0x40. Verify both // entries land in the buffer by walking the linked list via // NextEntryOffset. let mut cursor: u32 = 0; let mut names: Vec = Vec::new(); loop { let entry_base = buf + cursor; let name_len = mem.read_u32(entry_base + 0x3C) as usize; let mut bytes = Vec::with_capacity(name_len); for i in 0..name_len as u32 { bytes.push(mem.read_u8(entry_base + 0x40 + i)); } names.push(String::from_utf8(bytes).unwrap()); let next = mem.read_u32(entry_base); if next == 0 { break; } cursor += next; } assert_eq!(names, vec!["default.xex", "dat"]); // A second call on the same handle must return NO_MORE_FILES — // the cursor has advanced past the end. ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[7] = SCRATCH_BASE as u64; ctx.gpr[8] = buf as u64; ctx.gpr[9] = 512; nt_query_directory_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_NO_MORE_FILES); } /// Invalid handle → STATUS_INVALID_HANDLE, IOSB gets the error, and /// the completion event still fires so callers don't hang. #[test] fn nt_query_directory_file_invalid_handle_still_signals() { let (mut ctx, mut mem, mut state) = fresh(); let evt = make_event(&mut state); ctx.gpr[3] = 0xDEAD_BEEF; ctx.gpr[4] = evt as u64; ctx.gpr[7] = SCRATCH_BASE as u64; ctx.gpr[8] = SCRATCH_BASE as u64 + 0x100; ctx.gpr[9] = 128; ctx.gpr[10] = 0; nt_query_directory_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); assert_eq!(mem.read_u32(SCRATCH_BASE), STATUS_INVALID_HANDLE as u32); assert!(event_signaled(&state, evt)); } /// `NtSignalAndWaitForSingleObjectEx` signals handle A, then does a /// single wait on handle B. If A is already signaled via the atomic /// set, any waiter on A wakes immediately; the caller then parks on /// B (or returns success if B is already signaled). Canary reference: /// `xboxkrnl_threading.cc:1103` — `XObject::SignalAndWait`. #[test] fn nt_signal_and_wait_signals_first_then_waits() { let (mut ctx, mut mem, mut state) = fresh(); // Pre-signaled event we'll wait on — so the whole call returns success. let wait_h = state.alloc_handle_for(KernelObject::Event { manual_reset: true, signaled: true, waiters: Vec::new(), }); let signal_h = make_event(&mut state); // starts unsignaled ctx.gpr[3] = signal_h as u64; ctx.gpr[4] = wait_h as u64; ctx.gpr[7] = 0; // timeout_ptr = null → infinite, but wait-handle already signaled nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "wait returns STATUS_SUCCESS"); assert!(event_signaled(&state, signal_h), "signal handle set"); } /// An unknown signal handle must return `STATUS_INVALID_HANDLE` and /// NOT fall through to the wait — matches Canary's early-return guard. #[test] fn nt_signal_and_wait_rejects_unknown_signal_handle() { let (mut ctx, mut mem, mut state) = fresh(); ctx.gpr[3] = 0xDEAD_BEEF; ctx.gpr[4] = 0x1234_5678; ctx.gpr[7] = 0; nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } /// `FileNetworkOpenInformation` (class 34) — 56 bytes, `FileAttributes` /// at +48 must carry `FILE_ATTRIBUTE_DIRECTORY` (0x10) for root synths. /// Sylpheed's async worker asks for this class and the caller dispatches /// on the attributes bits; a zeroed buffer meant `Directory` was clear /// and forced the dirty-disc path. #[test] fn nt_query_information_file_network_open_sets_dir_attribute() { let (mut ctx, mut mem, mut state) = fresh(); let h = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); let info_buf = SCRATCH_BASE + 0x200; ctx.gpr[3] = h as u64; ctx.gpr[4] = SCRATCH_BASE as u64; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 56; ctx.gpr[7] = 34; // FileNetworkOpenInformation nt_query_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0); let attrs = mem.read_u32(info_buf + 48); assert_eq!(attrs, 0x10, "FILE_ATTRIBUTE_DIRECTORY expected for root synth"); } /// Normal file paths must still report `Directory=0` so games reading /// actual files (`dat/tables.pak`, `config.ini`) don't see them as /// directories. #[test] fn nt_query_information_file_reports_file_for_normal_path() { let (mut ctx, mut mem, mut state) = fresh(); let h = state.alloc_handle_for(KernelObject::File { path: "dat/tables.pak".to_string(), size: 964, position: 0, data: std::sync::Arc::new(vec![0; 964]), dir_enum_pos: None, host_path: None, }); let info_buf = SCRATCH_BASE + 0x700; ctx.gpr[3] = h as u64; ctx.gpr[4] = SCRATCH_BASE as u64; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 24; ctx.gpr[7] = 5; nt_query_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u8(info_buf + 21), 0, "normal file not directory"); } #[test] fn nt_query_volume_information_file_class3_returns_64k_alloc_unit() { let (mut ctx, mut mem, mut state) = fresh(); let h = state.alloc_handle_for(KernelObject::File { path: String::new(), size: 0, position: 0, data: std::sync::Arc::new(Vec::new()), dir_enum_pos: None, host_path: None, }); let iosb = SCRATCH_BASE; let info_buf = SCRATCH_BASE + 0x100; ctx.gpr[3] = h as u64; ctx.gpr[4] = iosb as u64; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 24; ctx.gpr[7] = 3; // FileFsSizeInformation nt_query_volume_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS as u64); let sectors_per_unit = mem.read_u32(info_buf + 16); let bytes_per_sector = mem.read_u32(info_buf + 20); assert_eq!(sectors_per_unit, 0x80); assert_eq!(bytes_per_sector, 0x200); assert_eq!( sectors_per_unit * bytes_per_sector, 0x10000, "alloc unit must be 64 KiB to match canary NullDevice", ); } // ===== PKEVENT shim ===== /// Write a DISPATCHER_HEADER at the given guest pointer. /// ty: 0 = Notification (manual-reset), 1 = Synchronization (auto-reset), /// 5 = Semaphore. fn write_dispatcher_header(mem: &GuestMemory, ptr: u32, ty: u8, signal_state: u32) { mem.write_u8(ptr, ty); mem.write_u8(ptr + 1, 0); // Absolute mem.write_u8(ptr + 2, 4); // Size (u32 words) — four words is plausible mem.write_u8(ptr + 3, 0); // Inserted mem.write_u32(ptr + 4, signal_state); // WaitListHead (8 bytes) — zero-init is fine; shadow owns waiters. mem.write_u32(ptr + 8, 0); mem.write_u32(ptr + 12, 0); } #[test] fn ke_set_event_shadows_pkevent_pointer() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x100; write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization, unsignaled ctx.gpr[3] = kevent_ptr as u64; ke_set_event(&mut ctx, &mut mem, &mut state); // Shadow must have been minted AND signaled. match state.objects.get(&kevent_ptr) { Some(KernelObject::Event { manual_reset, signaled, .. }) => { assert!(!*manual_reset, "type=1 must be auto-reset"); assert!(*signaled, "ke_set_event must signal the shadow"); } other => panic!("expected Event shadow at pkevent_ptr, got {:?}", other), } } #[test] fn ke_reset_event_shadows_pkevent_pointer() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x200; // Initial signal state = 1 in guest memory → shadow starts signaled. 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. 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"); assert!(!*signaled, "ke_reset_event must clear the shadow"); } other => panic!("expected Event shadow, got {:?}", other), } } /// End-to-end: set + wait across the same PKEVENT pointer. This is the /// exact contract Sylpheed relies on — without the shim, KeWait parks /// on a nonexistent handle and KeSet no-ops, so the wait never resolves. #[test] fn ke_set_then_wait_on_pkevent_returns_success() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x300; write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization // First signal the event. ctx.gpr[3] = kevent_ptr as u64; ke_set_event(&mut ctx, &mut mem, &mut state); // Now wait with timeout = 0 (poll). Since it's signaled, the auto- // reset consumes the signal and we should get STATUS_SUCCESS. // Timeout pointer at scratch top: LARGE_INTEGER = 0. let timeout_ptr = SCRATCH_BASE + 0x800; mem.write_u32(timeout_ptr, 0); mem.write_u32(timeout_ptr + 4, 0); ctx.gpr[3] = kevent_ptr as u64; ctx.gpr[7] = timeout_ptr as u64; ke_wait_for_single_object(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected on signaled wait"); // Auto-reset: signal must have been consumed. match state.objects.get(&kevent_ptr) { Some(KernelObject::Event { signaled, .. }) => assert!(!*signaled), other => panic!("expected Event shadow, got {:?}", other), } } /// Semaphore shim: header type 5, Limit at +0x10. #[test] fn ke_release_semaphore_shadows_pksemaphore_pointer() { let (mut ctx, mut mem, mut state) = fresh(); let ksem_ptr = SCRATCH_BASE + 0x400; write_dispatcher_header(&mut mem, ksem_ptr, 5, 2); // initial count 2 mem.write_u32(ksem_ptr + 0x10, 10); // Limit ctx.gpr[3] = ksem_ptr as u64; ctx.gpr[4] = 1; // adjust = +1 ke_release_semaphore(&mut ctx, &mut mem, &mut state); match state.objects.get(&ksem_ptr) { Some(KernelObject::Semaphore { count, max, .. }) => { assert_eq!(*count, 3, "count was 2, +1 → 3"); assert_eq!(*max, 10); } other => panic!("expected Semaphore shadow, got {:?}", other), } } /// Regression guard: genuine Nt handles must still work unchanged — /// the shim's lower-bound check (`ptr < 0x1_0000`) skips our handle /// range (0x1000 + 4·N). #[test] fn ke_set_event_leaves_nt_handles_intact() { let (mut ctx, mut mem, mut state) = fresh(); let handle = state.alloc_handle_for(KernelObject::Event { manual_reset: true, signaled: false, waiters: Vec::new(), }); assert!(handle < 0x1_0000, "handle must be in low range"); ctx.gpr[3] = handle as u64; ke_set_event(&mut ctx, &mut mem, &mut state); // Shadow must NOT have been created at the handle key (already exists); // the existing Event just flips to signaled. match state.objects.get(&handle) { Some(KernelObject::Event { signaled, .. }) => assert!(*signaled), _ => panic!("handle lookup broken"), } } /// Type bytes we don't understand (e.g., Mutant=2, Timer=8) must leave /// the handle table untouched rather than conjuring wrong-typed shadows. #[test] fn ensure_dispatcher_object_ignores_unknown_type() { let (mut _ctx, mut mem, mut state) = fresh(); let ptr = SCRATCH_BASE + 0x500; write_dispatcher_header(&mut mem, ptr, 2, 0); // Mutant — unsupported ensure_dispatcher_object(&mut state, &mem, ptr); assert!(!state.objects.contains_key(&ptr), "no shadow for unknown type"); // No StashHandle stamp on an ignored dispatcher. assert_eq!(mem.read_u32(ptr + 0x08), 0); 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. #[test] fn ensure_dispatcher_object_stamps_xen_signature_and_handle() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x700; write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization // Pre-condition: zeros at +0x08 / +0x0C. assert_eq!(mem.read_u32(kevent_ptr + 0x08), 0); assert_eq!(mem.read_u32(kevent_ptr + 0x0C), 0); ctx.gpr[3] = kevent_ptr as u64; ke_set_event(&mut ctx, &mut mem, &mut state); // Post-condition: kXObjSignature ('X','E','N','\0') + stash handle. assert_eq!( mem.read_u32(kevent_ptr + 0x08), 0x58454E00, "wait_list.flink_ptr must hold kXObjSignature 'XEN\\0'" ); assert_eq!( mem.read_u32(kevent_ptr + 0x0C), kevent_ptr, "wait_list.blink_ptr must hold stash handle (== guest dispatcher ptr)" ); } /// `KePulseEvent` on a manual-reset event must wake every parked waiter /// and leave the event unsignaled afterwards. This models the transient- /// signal idiom that `NtSetEvent`+`NtClearEvent` cannot express atomically. #[test] fn ke_pulse_event_manual_reset_wakes_all_and_leaves_unsignaled() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x600; write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled // Mint the shadow and park two fake waiters. ctx.gpr[3] = kevent_ptr as u64; ke_reset_event(&mut ctx, &mut mem, &mut state); match state.objects.get_mut(&kevent_ptr) { Some(KernelObject::Event { waiters, .. }) => { // Fake waiter refs — wake_ref silently no-ops on // out-of-bounds so the test only observes list drainage. waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 }); waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 }); } _ => panic!("shadow not minted"), } // Pulse. ctx.gpr[3] = kevent_ptr as u64; ke_pulse_event(&mut ctx, &mut mem, &mut state); // Previous state = 0 (unsignaled). assert_eq!(ctx.gpr[3], 0); // Event must be unsignaled post-pulse, and waiter list drained. match state.objects.get(&kevent_ptr) { Some(KernelObject::Event { signaled, waiters, .. }) => { assert!(!*signaled, "pulse leaves event non-signaled"); assert!(waiters.is_empty(), "all manual-reset waiters must be woken"); } _ => panic!("shadow vanished"), } } /// Auto-reset pulse wakes exactly one waiter (the head of the FIFO) and /// consumes the transient signal, matching `NtSetEvent` on an auto-reset /// event with no linger. #[test] fn ke_pulse_event_auto_reset_wakes_one() { let (mut ctx, mut mem, mut state) = fresh(); let kevent_ptr = SCRATCH_BASE + 0x700; write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // auto-reset, unsignaled ctx.gpr[3] = kevent_ptr as u64; ke_reset_event(&mut ctx, &mut mem, &mut state); match state.objects.get_mut(&kevent_ptr) { Some(KernelObject::Event { waiters, .. }) => { // Fake waiter refs — wake_ref silently no-ops on // out-of-bounds so the test only observes list drainage. waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 }); waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 }); } _ => panic!("shadow not minted"), } ctx.gpr[3] = kevent_ptr as u64; ke_pulse_event(&mut ctx, &mut mem, &mut state); match state.objects.get(&kevent_ptr) { Some(KernelObject::Event { signaled, waiters, .. }) => { assert!(!*signaled, "pulse leaves auto-reset event non-signaled"); assert_eq!(waiters.len(), 1, "auto-reset pulse wakes exactly one waiter"); } _ => panic!("shadow vanished"), } } /// `NtPulseEvent` must return `STATUS_SUCCESS` + write prior state to /// the optional `previous_state_ptr` (r4). If the handle is invalid, /// it must return `STATUS_INVALID_HANDLE` without touching memory. #[test] fn nt_pulse_event_writes_previous_state_and_clears() { let (mut ctx, mut mem, mut state) = fresh(); let handle = state.alloc_handle_for(KernelObject::Event { manual_reset: true, signaled: true, // initially signaled → prior = 1 waiters: Vec::new(), }); let prev_ptr = SCRATCH_BASE + 0x10; mem.write_u32(prev_ptr, 0xFFFF_FFFF); // sentinel ctx.gpr[3] = handle as u64; ctx.gpr[4] = prev_ptr as u64; nt_pulse_event(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(prev_ptr), 1, "previous state was signaled=1"); match state.objects.get(&handle) { Some(KernelObject::Event { signaled, .. }) => { assert!(!*signaled, "nt_pulse_event must leave event cleared"); } _ => panic!("handle lost"), } } #[test] fn nt_pulse_event_invalid_handle_returns_status() { let (mut ctx, mut mem, mut state) = fresh(); ctx.gpr[3] = 0xDEAD_BEEF; // not in object table ctx.gpr[4] = 0; nt_pulse_event(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } /// `NtReleaseSemaphore` must return `STATUS_SEMAPHORE_LIMIT_EXCEEDED` /// (0xC000_0047) when the post-release count would exceed `Limit`, /// and must *not* update the count in that case. The prior /// saturating-add behaviour silently clamped to i32::MAX, masking /// overflow from games that key work-queue logic on the status code. #[test] fn nt_release_semaphore_rejects_over_limit() { let (mut ctx, mut mem, mut state) = fresh(); let handle = state.alloc_handle_for(KernelObject::Semaphore { count: 3, max: 5, waiters: Vec::new(), }); let prev_ptr = SCRATCH_BASE + 0x40; mem.write_u32(prev_ptr, 0xFFFF_FFFF); ctx.gpr[3] = handle as u64; ctx.gpr[4] = 10; // 3 + 10 = 13 > max=5 → reject ctx.gpr[5] = prev_ptr as u64; nt_release_semaphore(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SEMAPHORE_LIMIT_EXCEEDED); assert_eq!(mem.read_u32(prev_ptr), 3, "previous count written even on reject"); match state.objects.get(&handle) { Some(KernelObject::Semaphore { count, .. }) => { assert_eq!(*count, 3, "count must not change on reject"); } _ => panic!("handle lost"), } } /// A normal release inside the limit increments `count` and returns /// `STATUS_SUCCESS` with the previous count written out. #[test] fn nt_release_semaphore_normal_path_updates_count() { let (mut ctx, mut mem, mut state) = fresh(); let handle = state.alloc_handle_for(KernelObject::Semaphore { count: 2, max: 5, waiters: Vec::new(), }); let prev_ptr = SCRATCH_BASE + 0x50; ctx.gpr[3] = handle as u64; ctx.gpr[4] = 2; // 2 + 2 = 4 <= 5 → ok ctx.gpr[5] = prev_ptr as u64; nt_release_semaphore(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(prev_ptr), 2); match state.objects.get(&handle) { Some(KernelObject::Semaphore { count, .. }) => assert_eq!(*count, 4), _ => panic!("handle lost"), } } /// Invalid handle path: Canary returns `STATUS_INVALID_HANDLE` /// without touching any state. Previous behaviour silently returned /// `STATUS_SUCCESS` with `previous = 0`, which games couldn't tell /// from a genuine release. #[test] fn nt_release_semaphore_invalid_handle_returns_status() { let (mut ctx, mut mem, mut state) = fresh(); ctx.gpr[3] = 0xDEAD_BEEF; ctx.gpr[4] = 1; ctx.gpr[5] = 0; nt_release_semaphore(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } /// `RtlInitializeCriticalSection` must lay out the guest-visible /// X_RTL_CRITICAL_SECTION per Canary `xboxkrnl_rtl.cc:536-553`: /// dispatcher-header type=1 at +0x00, lock_count=-1 at +0x10, /// recursion_count=0 at +0x14, owning_thread=0 at +0x18. Prior to /// this fix xenia-rs wrote lock_count at +0x04 (landing inside the /// dispatcher header's signal_state field) and owning_thread at /// +0x0C (landing inside the WaitListHead). Any game that reads a /// pre-initialized CS from its `.data` segment — Canary's comment /// at line 533-534 notes this is common — would see garbage. #[test] fn rtl_initialize_critical_section_lays_out_canary_struct() { let (mut ctx, mut mem, mut state) = fresh(); let cs_ptr = SCRATCH_BASE + 0x100; // Pre-fill with a sentinel so we can see every byte we touch. for i in (0..28).step_by(4) { mem.write_u32(cs_ptr + i, 0xDEAD_BEEF); } ctx.gpr[3] = cs_ptr as u64; rtl_initialize_critical_section(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u8(cs_ptr + CS_OFFS_TYPE), 1, "type = synchronization"); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT), 0xFFFF_FFFF, "lock_count = -1"); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 0); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), 0); } /// End-to-end: init → enter → nested enter → leave → leave. The /// CS must roll back through `(lc=0,rc=2) → (lc=0,rc=1) → (lc=-1, /// rc=0,owner=0)` with the correct field offsets, and owner must /// land at +0x18 — not anywhere else. #[test] fn rtl_critical_section_nested_enter_leave_roundtrip() { let (mut ctx, mut mem, mut state) = fresh(); // Install a live guest TID on the current HW slot so // `rtl_enter_critical_section`'s `find_by_tid` sees us as a // genuine owner. `find_by_tid` filters out `HwState::Idle` — // the default placeholder state — so we also flip to Ready. // Without both, `owner_is_live` stays false on self-recursion // and the nested-enter branch is never taken. let tid: u32 = 42; // Update the live thread planted by `fresh()` on slot 0 so // `find_by_tid(42)` resolves it. state.scheduler.slots[0].runqueue[0].tid = tid; state.scheduler.slots[0].runqueue[0].state = xenia_cpu::scheduler::HwState::Ready; ctx.thread_id = tid; let cs_ptr = SCRATCH_BASE + 0x200; ctx.gpr[3] = cs_ptr as u64; rtl_initialize_critical_section(&mut ctx, &mut mem, &mut state); // First enter → owner = tid, LC = 0, RC = 1. ctx.gpr[3] = cs_ptr as u64; rtl_enter_critical_section(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), tid); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 0); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 1); // Nested enter (same tid) → LC = 1, RC = 2. ctx.gpr[3] = cs_ptr as u64; rtl_enter_critical_section(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 1); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 2); // First leave → LC = 0, RC = 1, owner stays. ctx.gpr[3] = cs_ptr as u64; rtl_leave_critical_section(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), tid); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 0); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 1); // Second leave → LC = -1, RC = 0, owner cleared. ctx.gpr[3] = cs_ptr as u64; rtl_leave_critical_section(&mut ctx, &mut mem, &mut state); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), 0); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, -1); assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 0); } /// `NtSetInformationFile` class 14 (`XFilePositionInformation`) must /// update the file cursor. Read back via `NtQueryInformationFile` /// class 14 — round-trip proves both sides agree on the layout. #[test] fn nt_set_information_file_position_updates_cursor() { let (mut ctx, mut mem, mut state) = fresh(); let handle = make_file(&mut state, vec![0u8; 0x100]); let info_ptr = SCRATCH_BASE + 0x20; let iosb_ptr = SCRATCH_BASE + 0x40; mem.write_u64(info_ptr, 0x40); ctx.gpr[3] = handle as u64; ctx.gpr[4] = iosb_ptr as u64; ctx.gpr[5] = info_ptr as u64; ctx.gpr[6] = 8; ctx.gpr[7] = 14; // XFilePositionInformation nt_set_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(iosb_ptr), STATUS_SUCCESS as u32); assert_eq!(mem.read_u32(iosb_ptr + 4), 8); match state.objects.get(&handle) { Some(KernelObject::File { position, .. }) => assert_eq!(*position, 0x40), _ => panic!("file handle lost"), } } /// Read-only VFS — truncating to a different size must fail with /// `STATUS_UNSUCCESSFUL`, matching Canary's error path when /// `file->SetLength(...)` can't honour the request. #[test] fn nt_set_information_file_truncate_to_different_size_fails() { let (mut ctx, mut mem, mut state) = fresh(); let handle = make_file(&mut state, vec![0u8; 0x100]); let info_ptr = SCRATCH_BASE + 0x80; mem.write_u64(info_ptr, 0x200); // new EOF != current 0x100 ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[5] = info_ptr as u64; ctx.gpr[6] = 8; ctx.gpr[7] = 20; // XFileEndOfFileInformation nt_set_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_UNSUCCESSFUL); } #[test] fn nt_set_information_file_invalid_class_returns_status() { let (mut ctx, mut mem, mut state) = fresh(); let handle = make_file(&mut state, Vec::new()); ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[5] = 0; ctx.gpr[6] = 0; ctx.gpr[7] = 999; // not a defined class nt_set_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_INFO_CLASS); } #[test] fn nt_set_information_file_short_buffer_returns_length_mismatch() { let (mut ctx, mut mem, mut state) = fresh(); let handle = make_file(&mut state, Vec::new()); ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[5] = SCRATCH_BASE as u64; ctx.gpr[6] = 4; // class 14 needs 8 ctx.gpr[7] = 14; nt_set_information_file(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INFO_LENGTH_MISMATCH); } /// `KeReleaseSemaphore` is lenient: it never reports errors, but the /// count must still cap at `Limit` (Canary's underlying primitive /// `XSemaphore::ReleaseSemaphore` enforces the cap, even though the /// Ke wrapper discards the success bool). #[test] fn ke_release_semaphore_silently_caps_at_limit() { let (mut ctx, mut mem, mut state) = fresh(); let ksem_ptr = SCRATCH_BASE + 0x60; // Dispatcher header: type=5 (Semaphore), signal_state/count=4, Limit=5. write_dispatcher_header(&mut mem, ksem_ptr, 5, 4); mem.write_u32(ksem_ptr + 0x10, 5); // Limit ctx.gpr[3] = ksem_ptr as u64; ctx.gpr[4] = 10; // 4 + 10 > 5 → reject silently ke_release_semaphore(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 4, "Ke returns previous count even on cap"); match state.objects.get(&ksem_ptr) { Some(KernelObject::Semaphore { count, .. }) => { assert_eq!(*count, 4, "count must not exceed Limit even via Ke-form"); } _ => panic!("shadow missing"), } } // ===== Timer subsystem ===== /// Helper: write a LARGE_INTEGER (i64 in big-endian hi/lo u32 pair) to /// guest memory. Matches the format `parse_timeout` / `nt_set_timer_ex` /// read from. fn write_large_integer(mem: &GuestMemory, ptr: u32, raw: i64) { mem.write_u32(ptr, (raw >> 32) as u32); mem.write_u32(ptr + 4, raw as u32); } #[test] fn nt_create_timer_sync_type_creates_auto_reset() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[4] = 0; // obj_attributes — ignored ctx.gpr[5] = 1; // SynchronizationTimer nt_create_timer(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let handle = mem.read_u32(handle_ptr); match state.objects.get(&handle) { Some(KernelObject::Timer { manual_reset, signaled, deadline, waiters, .. }) => { assert!(!*manual_reset, "type=1 is SynchronizationTimer (auto-reset)"); assert!(!*signaled); assert!(deadline.is_none()); assert!(waiters.is_empty()); } other => panic!("expected Timer at handle {:#x}, got {:?}", handle, other), } } #[test] fn nt_create_timer_notification_type_creates_manual_reset() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 0; // NotificationTimer nt_create_timer(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let handle = mem.read_u32(handle_ptr); match state.objects.get(&handle) { Some(KernelObject::Timer { manual_reset, .. }) => assert!(*manual_reset), _ => panic!("expected Timer"), } } #[test] fn nt_create_timer_invalid_type_returns_invalid_parameter() { let (mut ctx, mut mem, mut state) = fresh(); ctx.gpr[3] = (SCRATCH_BASE + 0x20) as u64; ctx.gpr[5] = 42; // invalid nt_create_timer(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], 0xC000_000D); // STATUS_INVALID_PARAMETER assert!( state.objects.is_empty() || state .objects .values() .all(|o| !matches!(o, KernelObject::Timer { .. })), "no Timer object must be minted on invalid type" ); } #[test] fn nt_set_timer_ex_schedules_pending_fire() { let (mut ctx, mut mem, mut state) = fresh(); // Create the timer first. let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); // Arm with -1_000_000 (= 100ms) relative. let due_time_ptr = SCRATCH_BASE + 0x40; write_large_integer(&mut mem, due_time_ptr, -1_000_000); ctx.gpr[3] = handle as u64; ctx.gpr[4] = due_time_ptr as u64; ctx.gpr[5] = 0; // routine ctx.gpr[6] = 1; // mode ctx.gpr[7] = 0; // routine_arg ctx.gpr[8] = 0; // resume ctx.gpr[9] = 0; // period_ms nt_set_timer_ex(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(state.pending_timer_fires.len(), 1); let (deadline, h) = state.pending_timer_fires[0]; assert_eq!(h, handle); assert!(deadline > 0, "deadline must advance past now"); match state.objects.get(&handle) { Some(KernelObject::Timer { deadline: obj_d, signaled, .. }) => { assert_eq!(*obj_d, Some(deadline)); assert!(!*signaled, "arm clears any stale signaled flag"); } _ => panic!("Timer vanished"), } } #[test] fn nt_set_timer_ex_rearm_replaces_entry() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); let due_time_ptr = SCRATCH_BASE + 0x40; // First arm. write_large_integer(&mut mem, due_time_ptr, -1_000_000); ctx.gpr[3] = handle as u64; ctx.gpr[4] = due_time_ptr as u64; ctx.gpr[5] = 0; ctx.gpr[6] = 1; ctx.gpr[7] = 0; ctx.gpr[8] = 0; ctx.gpr[9] = 0; nt_set_timer_ex(&mut ctx, &mut mem, &mut state); // Second arm (later). write_large_integer(&mut mem, due_time_ptr, -5_000_000); ctx.gpr[3] = handle as u64; ctx.gpr[4] = due_time_ptr as u64; nt_set_timer_ex(&mut ctx, &mut mem, &mut state); assert_eq!( state.pending_timer_fires.len(), 1, "rearm must replace, not duplicate" ); } #[test] fn nt_cancel_timer_disarms_and_writes_zero() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); let due_time_ptr = SCRATCH_BASE + 0x40; write_large_integer(&mut mem, due_time_ptr, -1_000_000); ctx.gpr[3] = handle as u64; ctx.gpr[4] = due_time_ptr as u64; ctx.gpr[5] = 0; ctx.gpr[6] = 1; ctx.gpr[7] = 0; ctx.gpr[8] = 0; ctx.gpr[9] = 0; nt_set_timer_ex(&mut ctx, &mut mem, &mut state); let prev_ptr = SCRATCH_BASE + 0x60; mem.write_u32(prev_ptr, 0xDEAD_BEEF); // sentinel — must be overwritten to 0 ctx.gpr[3] = handle as u64; ctx.gpr[4] = prev_ptr as u64; nt_cancel_timer(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(prev_ptr), 0, "canary always writes 0"); assert!(state.pending_timer_fires.is_empty()); match state.objects.get(&handle) { Some(KernelObject::Timer { deadline, .. }) => assert!(deadline.is_none()), _ => panic!("Timer gone after cancel — must stay in table"), } } #[test] fn nt_cancel_timer_invalid_handle_returns_status() { let (mut ctx, mut mem, mut state) = fresh(); ctx.gpr[3] = 0xDEAD_BEEF; ctx.gpr[4] = 0; nt_cancel_timer(&mut ctx, &mut mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } #[test] fn timer_fire_wakes_auto_reset_waiter_and_consumes_signal() { let (mut ctx, mut mem, mut state) = fresh(); // Arm an auto-reset timer with deadline slightly in the future. let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); // Deadline: now + 1000 ticks. Directly set the state to avoid // dependence on parse_timeout's divisor. let now = state.scheduler.ctx(0).timebase; let deadline = now + 1000; match state.objects.get_mut(&handle) { Some(KernelObject::Timer { deadline: obj_d, .. }) => *obj_d = Some(deadline), _ => panic!("no timer"), } state.arm_timer(handle, deadline); // Park the current (initial) thread on a WaitForSingleObject of the // timer handle. `do_wait_single` sees signaled=false, enqueues the // current ref, and parks via `park_current`. ctx.gpr[3] = handle as u64; ctx.gpr[6] = 0; // NULL timeout → wait forever nt_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state); let initial_ref = state.scheduler.current_ref(); match state.scheduler.thread(initial_ref).state { xenia_cpu::scheduler::HwState::Blocked(_) => {} ref other => panic!("expected Blocked after wait, got {:?}", other), } // Advance time past the deadline; fire_due_timers should signal and // wake the waiter. state.scheduler.advance_all_timebases_to(deadline); let fired = state.fire_due_timers(); assert!(fired); // After fire on auto-reset: signaled cleared via handle_consume, no // pending entry, waiter promoted to Ready. match state.objects.get(&handle) { Some(KernelObject::Timer { signaled, waiters, .. }) => { assert!(!*signaled, "auto-reset consumed on single-waiter wake"); assert!(waiters.is_empty(), "waiter dequeued by wake_eligible_waiters"); } _ => panic!("timer lost"), } assert!(state.pending_timer_fires.is_empty()); match state.scheduler.thread(initial_ref).state { xenia_cpu::scheduler::HwState::Ready => {} ref other => panic!("expected Ready after fire, got {:?}", other), } } #[test] fn timer_fire_manual_reset_wakes_all_and_stays_signaled() { let (mut ctx, mut mem, mut state) = fresh(); // Manual-reset timer. let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 0; // NotificationTimer (manual-reset) nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); let now = state.scheduler.ctx(0).timebase; let deadline = now + 1000; match state.objects.get_mut(&handle) { Some(KernelObject::Timer { deadline: obj_d, .. }) => *obj_d = Some(deadline), _ => unreachable!(), } state.arm_timer(handle, deadline); // Park two synthetic waiters (out-of-bounds refs — `wake_ref` // silently no-ops on them; we only care about the drain-all // semantics of manual-reset.) match state.objects.get_mut(&handle) { Some(KernelObject::Timer { waiters, .. }) => { waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 }); waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 }); } _ => unreachable!(), } state.scheduler.advance_all_timebases_to(deadline); assert!(state.fire_due_timers()); match state.objects.get(&handle) { Some(KernelObject::Timer { signaled, waiters, .. }) => { assert!(*signaled, "manual-reset stays signaled after fire"); assert!(waiters.is_empty(), "manual-reset drains all waiters"); } _ => unreachable!(), } } #[test] fn periodic_timer_rearms_after_fire() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); let now = state.scheduler.ctx(0).timebase; let deadline = now + 1000; let period_ticks = 500; match state.objects.get_mut(&handle) { Some(KernelObject::Timer { deadline: obj_d, period_ticks: obj_p, .. }) => { *obj_d = Some(deadline); *obj_p = period_ticks; } _ => unreachable!(), } state.arm_timer(handle, deadline); state.scheduler.advance_all_timebases_to(deadline); assert!(state.fire_due_timers()); // After fire, a new entry must sit at deadline + period_ticks. assert_eq!(state.pending_timer_fires.len(), 1); let (new_deadline, h) = state.pending_timer_fires[0]; assert_eq!(h, handle); assert_eq!(new_deadline, deadline + period_ticks); match state.objects.get(&handle) { Some(KernelObject::Timer { deadline: obj_d, .. }) => { assert_eq!(*obj_d, Some(new_deadline)); } _ => unreachable!(), } } #[test] fn nt_close_scrubs_pending_timer_fires() { let (mut ctx, mut mem, mut state) = fresh(); let handle_ptr = SCRATCH_BASE + 0x20; ctx.gpr[3] = handle_ptr as u64; ctx.gpr[5] = 1; nt_create_timer(&mut ctx, &mut mem, &mut state); let handle = mem.read_u32(handle_ptr); // Arm. let due_time_ptr = SCRATCH_BASE + 0x40; write_large_integer(&mut mem, due_time_ptr, -1_000_000); ctx.gpr[3] = handle as u64; ctx.gpr[4] = due_time_ptr as u64; ctx.gpr[5] = 0; ctx.gpr[6] = 1; ctx.gpr[7] = 0; ctx.gpr[8] = 0; ctx.gpr[9] = 0; nt_set_timer_ex(&mut ctx, &mut mem, &mut state); assert_eq!(state.pending_timer_fires.len(), 1); // Close. ctx.gpr[3] = handle as u64; nt_close(&mut ctx, &mut mem, &mut state); assert!( state.pending_timer_fires.is_empty(), "nt_close must scrub pending timer entry" ); assert!(!state.objects.contains_key(&handle)); } #[test] fn advance_to_next_wake_returns_ref_and_reason_for_timeout_path() { let (mut ctx, mut mem, mut state) = fresh(); // Create an event (unsignaled), park current thread on it with a // finite deadline via NtWaitForSingleObjectEx. let ev = state.alloc_handle_for(KernelObject::Event { manual_reset: false, signaled: false, waiters: Vec::new(), }); let timeout_ptr = SCRATCH_BASE + 0x80; write_large_integer(&mut mem, timeout_ptr, -1_000_000); ctx.gpr[3] = ev as u64; ctx.gpr[6] = timeout_ptr as u64; nt_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state); let initial_ref = state.scheduler.current_ref(); // Current thread must be parked with that handle in its waiter list. match state.objects.get(&ev) { Some(KernelObject::Event { waiters, .. }) => { assert!(waiters.contains(&initial_ref), "waiter enqueued"); } _ => unreachable!(), } // Advance past the deadline. `advance_to_next_wake` returns the // woken ref + its block reason; the main loop would then stamp // STATUS_TIMEOUT and scrub waiter lists via `handle_timeout_wake`. let (r, reason) = state .scheduler .advance_to_next_wake() .expect("deadline exists"); assert_eq!(r, initial_ref); state.handle_timeout_wake(r, reason); // Post-wake: gpr[3] == STATUS_TIMEOUT (0x102) AND the waiter list // scrubbed. Prior code returned 0 and left the waiter stranded. assert_eq!(state.scheduler.ctx_mut_ref(r).gpr[3], 0x0000_0102); match state.objects.get(&ev) { Some(KernelObject::Event { waiters, .. }) => { assert!( !waiters.contains(&initial_ref), "waiter scrubbed from handle list on timeout" ); } _ => unreachable!(), } } /// Ordinal 0xFB must resolve to `NtSignalAndWaitForSingleObjectEx` /// (canary's table) — the former `NtSetInformationThread` /// registration collided and was removed. #[test] fn ordinal_0xfb_maps_to_nt_signal_and_wait() { let state = KernelState::new(); let name = state .export_name(crate::state::ModuleId::Xboxkrnl, 0xFB) .expect("0xFB must be registered"); assert_eq!(name, "NtSignalAndWaitForSingleObjectEx"); } /// `KeInitializeSemaphore` must seed the count and limit fields in /// guest memory so that `ensure_dispatcher_object` later mints the /// kernel-side shadow with the caller's parameters — not the /// zero-fill default of `count=0, max=1`. #[test] fn ke_initialize_semaphore_seeds_count_and_limit() { let (mut ctx, mem, mut state) = fresh(); let sem_ptr = SCRATCH_BASE + 0x500; ctx.gpr[3] = sem_ptr as u64; ctx.gpr[4] = 3; ctx.gpr[5] = 7; ke_initialize_semaphore(&mut ctx, &mem, &mut state); assert_eq!(mem.read_u8(sem_ptr), 5, "type=5 (semaphore)"); assert_eq!(mem.read_u32(sem_ptr + 0x04), 3, "signal_state=count"); assert_eq!(mem.read_u32(sem_ptr + 0x10), 7, "limit"); // Round-trip: KeReleaseSemaphore mints the shadow via // `ensure_dispatcher_object`, which reads the fields we just wrote. ctx.gpr[3] = sem_ptr as u64; ctx.gpr[4] = 1; ke_release_semaphore(&mut ctx, &mem, &mut state); match state.objects.get(&sem_ptr) { Some(KernelObject::Semaphore { count, max, .. }) => { assert_eq!(*count, 4, "3 + 1 = 4"); assert_eq!(*max, 7, "limit must propagate from r5, not default to 1"); } other => panic!("expected Semaphore shadow, got {:?}", other), } assert_eq!(ctx.gpr[3], 3, "previous count must be 3 (post-init, pre-release)"); } /// `XexGetProcedureAddress` must honor r3=hmodule, look up the /// (module, ordinal) in the thunk reverse-map, and write the address /// to *r5. Three branches: success, unknown ordinal, unknown hmodule. #[test] fn xex_get_procedure_address_resolves_registered_thunk() { let (mut ctx, mem, mut state) = fresh(); state.register_thunk(crate::state::ModuleId::Xboxkrnl, 0x12, 0x8200_1234); let out_ptr = SCRATCH_BASE + 0x600; // Success path. mem.write_u32(out_ptr, 0xDEAD_BEEF); ctx.gpr[3] = crate::state::HMODULE_XBOXKRNL as u64; ctx.gpr[4] = 0x12; ctx.gpr[5] = out_ptr as u64; xex_get_procedure_address(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS"); assert_eq!(mem.read_u32(out_ptr), 0x8200_1234, "thunk address written"); // Unknown ordinal: STATUS_OBJECT_NAME_NOT_FOUND, *out cleared. // Reset r3 because the prior call overwrote it with the status code. mem.write_u32(out_ptr, 0xDEAD_BEEF); ctx.gpr[3] = crate::state::HMODULE_XBOXKRNL as u64; ctx.gpr[4] = 0x99; xex_get_procedure_address(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0xC000_0034); assert_eq!(mem.read_u32(out_ptr), 0); // Unknown hmodule: STATUS_INVALID_HANDLE. ctx.gpr[3] = 0xCAFE_BABE; ctx.gpr[4] = 0x12; xex_get_procedure_address(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0xC000_0008); } /// `XexGetModuleHandle` must return distinct pseudo-handles for the /// main image, xboxkrnl.exe, and xam.xex; write the handle to *r4 /// (not r3); and return NTSTATUS in r3 (`X_ERROR_NOT_FOUND` for /// unknown names). #[test] fn xex_get_module_handle_distinguishes_modules() { let (mut ctx, mem, mut state) = fresh(); state.image_base = 0x8200_0000; let out_ptr = SCRATCH_BASE + 0x700; let scratch_str = SCRATCH_BASE + 0x780; let mut call = |name: Option<&str>, st: &mut KernelState, mem: &GuestMemory, ctx: &mut PpcContext| -> (u64, u32) { match name { Some(s) => { for (i, b) in s.as_bytes().iter().enumerate() { mem.write_u8(scratch_str + i as u32, *b); } mem.write_u8(scratch_str + s.len() as u32, 0); ctx.gpr[3] = scratch_str as u64; } None => ctx.gpr[3] = 0, } ctx.gpr[4] = out_ptr as u64; mem.write_u32(out_ptr, 0xDEAD_BEEF); xex_get_module_handle(ctx, mem, st); (ctx.gpr[3], mem.read_u32(out_ptr)) }; let (s_main, h_main) = call(Some(""), &mut state, &mem, &mut ctx); let (s_krnl, h_krnl) = call(Some("xboxkrnl.exe"), &mut state, &mem, &mut ctx); let (s_xam, h_xam) = call(Some("xam.xex"), &mut state, &mem, &mut ctx); let (s_bad, h_bad) = call(Some("nope.xex"), &mut state, &mem, &mut ctx); assert_eq!(s_main, 0); assert_eq!(h_main, 0x8200_0000); assert_eq!(s_krnl, 0); assert_eq!(h_krnl, crate::state::HMODULE_XBOXKRNL); assert_eq!(s_xam, 0); assert_eq!(h_xam, crate::state::HMODULE_XAM); assert_eq!(s_bad, 0x0000_048B); assert_eq!(h_bad, 0, "out cleared on miss"); assert_ne!(h_main, h_krnl, "main module distinct from xboxkrnl"); assert_ne!(h_krnl, h_xam, "xboxkrnl distinct from xam"); } /// `XexCheckExecutablePrivilege` must return bit `priv` of the loaded /// XEX's `XEX_HEADER_SYSTEM_FLAGS` bitmap. Mirrors canary /// [xboxkrnl_modules.cc:22-39](../../../xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_modules.cc#L22-L39). #[test] fn xex_check_executable_privilege_reads_system_flags_bitmap() { let (mut ctx, mem, mut state) = fresh(); // bit 10 set, bit 11 clear (matches Sylpheed's actual bitmap value // `0x00000400` / XEX_SYSTEM_PAL50_INCOMPATIBLE). state.xex_system_flags = 0x0000_0400; ctx.gpr[3] = 10; xex_check_executable_privilege(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 1, "priv 10 set in flags 0x0400"); ctx.gpr[3] = 11; xex_check_executable_privilege(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "priv 11 clear in flags 0x0400"); ctx.gpr[3] = 0; xex_check_executable_privilege(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "priv 0 clear in flags 0x0400"); ctx.gpr[3] = 64; xex_check_executable_privilege(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "priv >= 32 returns 0"); // With no flags set, every priv reads 0. state.xex_system_flags = 0; ctx.gpr[3] = 10; xex_check_executable_privilege(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "priv 10 clear with no flags"); } /// `XAudioRegisterRenderDriverClient` records the (callback, arg) pair, /// allocates a 4-byte heap buffer holding `callback_arg` in big-endian, /// and writes `0x4155_xxxx` to `*driver_ptr`. Mirrors canary /// [audio_system.cc:202-237](../../../xenia-canary/src/xenia/apu/audio_system.cc#L202-L237). #[test] fn xaudio_register_records_client_and_writes_driver_id() { let (mut ctx, mem, mut state) = fresh(); let cb_block = SCRATCH_BASE + 0x100; let driver_out = SCRATCH_BASE + 0x200; mem.write_u32(cb_block, 0x8200_BEEF); mem.write_u32(cb_block + 4, 0xDEAD_F00D); ctx.gpr[3] = cb_block as u64; ctx.gpr[4] = driver_out as u64; xaudio_register_render_driver(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS"); let driver_id = mem.read_u32(driver_out); assert_eq!(driver_id & 0xFFFF_0000, 0x4155_0000); let index = (driver_id & 0x0000_FFFF) as usize; let client = state.xaudio.get(index).expect("client must be registered"); assert_eq!(client.callback_pc, 0x8200_BEEF); assert_eq!(client.callback_arg, 0xDEAD_F00D); assert_ne!(client.wrapped_callback_arg, 0); assert_eq!( mem.read_u32(client.wrapped_callback_arg), 0xDEAD_F00D, "wrapped buffer must hold callback_arg big-endian" ); } /// Null `callback_ptr` or null callback function returns `X_E_INVALIDARG` /// without registering — canary /// [xboxkrnl_audio.cc:58-66](../../../xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_audio.cc#L58-L66). #[test] fn xaudio_register_rejects_null_inputs() { let (mut ctx, mem, mut state) = fresh(); ctx.gpr[3] = 0; ctx.gpr[4] = (SCRATCH_BASE + 0x300) as u64; xaudio_register_render_driver(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], X_E_INVALIDARG); assert!(!state.xaudio.any_registered()); let cb_block = SCRATCH_BASE + 0x400; mem.write_u32(cb_block, 0); // callback function = null mem.write_u32(cb_block + 4, 0xCAFE); ctx.gpr[3] = cb_block as u64; ctx.gpr[4] = (SCRATCH_BASE + 0x500) as u64; xaudio_register_render_driver(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], X_E_INVALIDARG); assert!(!state.xaudio.any_registered()); } /// Unregister clears the slot identified by the lower 16 bits of the /// driver token. #[test] fn xaudio_unregister_clears_slot() { let (mut ctx, mem, mut state) = fresh(); let cb_block = SCRATCH_BASE + 0x100; let driver_out = SCRATCH_BASE + 0x200; mem.write_u32(cb_block, 0x8200_AAAA); mem.write_u32(cb_block + 4, 0xBBBB_BBBB); ctx.gpr[3] = cb_block as u64; ctx.gpr[4] = driver_out as u64; xaudio_register_render_driver(&mut ctx, &mem, &mut state); let driver_id = mem.read_u32(driver_out); let index = (driver_id & 0x0000_FFFF) as usize; assert!(state.xaudio.get(index).is_some()); ctx.gpr[3] = driver_id as u64; xaudio_unregister_render_driver(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], 0); assert!(state.xaudio.get(index).is_none()); } /// FsCtlCode 0x70000 (drive geometry): canary writes /// `cache_size/512` at OUT+0 and `512` at OUT+4 (both u32 BE). #[test] fn nt_device_io_control_file_drive_geometry() { let (mut ctx, mem, mut state) = fresh(); let sp = SCRATCH_BASE + 0x800; let iosb = SCRATCH_BASE + 0x100; let out_buf = SCRATCH_BASE + 0x200; ctx.gpr[1] = sp as u64; ctx.gpr[3] = 0xF800_0010; ctx.gpr[4] = 0; ctx.gpr[7] = iosb as u64; ctx.gpr[8] = 0x70000; mem.write_u32(sp + 0x54, out_buf); mem.write_u32(sp + 0x5C, 0x8); nt_device_io_control_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(out_buf), 0xFF000 / 512); assert_eq!(mem.read_u32(out_buf + 4), 512); assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32); assert_eq!(mem.read_u32(iosb + 4), 0x8); } /// FsCtlCode 0x74004 (partition info): canary writes `0` at OUT+0 and /// `cache_size = 0xFF000` at OUT+8 (both u64 BE). Sub_824ABD88 at /// `0x824abe9c` reads OUT+8 and synthesizes `0xC0000034` if zero — a /// non-zero value at OUT+8 is the entire fix. #[test] fn nt_device_io_control_file_partition_info_unblocks_gate() { let (mut ctx, mem, mut state) = fresh(); let sp = SCRATCH_BASE + 0x800; let iosb = SCRATCH_BASE + 0x100; let out_buf = SCRATCH_BASE + 0x200; ctx.gpr[1] = sp as u64; ctx.gpr[3] = 0xF800_0010; ctx.gpr[4] = 0; ctx.gpr[7] = iosb as u64; ctx.gpr[8] = 0x74004; mem.write_u32(sp + 0x54, out_buf); mem.write_u32(sp + 0x5C, 0x10); nt_device_io_control_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u64(out_buf), 0); assert_eq!(mem.read_u64(out_buf + 8), 0xFF000); assert_ne!(mem.read_u64(out_buf + 8), 0, "OUT+8 must be non-zero"); assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32); assert_eq!(mem.read_u32(iosb + 4), 0x10); } /// Once a client is registered, the lockstep ticker eventually queues /// a fire — proves the producer pipeline is wired end-to-end through /// the kernel state. #[test] fn xaudio_register_then_tick_instr_queues_callback() { let (mut ctx, mem, mut state) = fresh(); let cb_block = SCRATCH_BASE + 0x100; let driver_out = SCRATCH_BASE + 0x200; mem.write_u32(cb_block, 0x8200_C0DE); mem.write_u32(cb_block + 4, 0xFEED_FACE); ctx.gpr[3] = cb_block as u64; ctx.gpr[4] = driver_out as u64; xaudio_register_render_driver(&mut ctx, &mem, &mut state); assert!(state.xaudio.tick_instr(crate::xaudio::XAUDIO_INSTR_PERIOD)); let i = state.xaudio.peek_next().expect("must queue a fire"); let client = state.xaudio.get(i).unwrap(); assert_eq!(client.callback_pc, 0x8200_C0DE); } // ===== AUDIT-038: cache:/* persistent VFS ===== /// Lay out an OBJECT_ATTRIBUTES + ANSI_STRING + buffer at a chosen /// guest base and return the obj_attrs pointer. Matches the layout /// `crate::path::object_attributes_to_vfs_path` expects: u32 /// RootDirectory @ +0, u32 NameStringPtr @ +4, u32 Attributes @ +8; /// the ANSI_STRING is u16 Length @ +0, u16 MaximumLength @ +2, /// u32 Buffer @ +4. fn write_obj_attrs(mem: &GuestMemory, base: u32, path_str: &str) -> u32 { let obj_attrs = base; let ansi_string = base + 0x40; let buf = base + 0x80; // OBJECT_ATTRIBUTES. mem.write_u32(obj_attrs, 0); // RootDirectory mem.write_u32(obj_attrs + 4, ansi_string); // Name -> ANSI_STRING mem.write_u32(obj_attrs + 8, 0); // Attributes // ANSI_STRING. mem.write_u16(ansi_string, path_str.len() as u16); // Length mem.write_u16(ansi_string + 2, path_str.len() as u16); // MaximumLength mem.write_u32(ansi_string + 4, buf); // Buffer // Path bytes. for (i, b) in path_str.bytes().enumerate() { mem.write_u8(buf + i as u32, b); } obj_attrs } /// Round-trip: create with FILE_CREATE, write bytes, read them back /// from the same handle. Verifies persistence within a single run /// (host_path drives both directions). #[test] fn cache_create_write_read_roundtrip() { let (mut ctx, mem, mut state) = fresh(); 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; 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); assert!(handle >= 0x1000, "handle must come from kernel allocator"); // NtWriteFile: 4 bytes "abcd" 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; // event_handle = none ctx.gpr[7] = iosb as u64; ctx.gpr[8] = write_buf as u64; ctx.gpr[9] = 4; ctx.gpr[10] = 0; // byte_offset_ptr null = use position nt_write_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(iosb + 4), 4, "wrote 4 bytes"); // Position should be 4. Reset to 0 with NtSetInformationFile (class 14). let pos_buf = SCRATCH_BASE + 0x500; mem.write_u64(pos_buf, 0); ctx.gpr[3] = handle as u64; ctx.gpr[4] = iosb as u64; ctx.gpr[5] = pos_buf as u64; ctx.gpr[6] = 8; ctx.gpr[7] = 14; nt_set_information_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); // NtReadFile: 4 bytes back from position 0. let read_buf = SCRATCH_BASE + 0x600; for i in 0..4 { mem.write_u8(read_buf + i, 0); } ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[7] = iosb as u64; ctx.gpr[8] = read_buf as u64; ctx.gpr[9] = 4; ctx.gpr[10] = 0; nt_read_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert_eq!(mem.read_u32(iosb + 4), 4); let mut got = [0u8; 4]; for i in 0..4 { got[i] = mem.read_u8(read_buf + i as u32); } assert_eq!(&got, b"abcd"); } /// FILE_CREATE on an already-existing path returns /// STATUS_OBJECT_NAME_COLLISION; the existing file is not truncated. #[test] fn cache_file_create_collision() { let (mut ctx, mem, mut state) = fresh(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\dup.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; 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); // Second FILE_CREATE on the same path: collide. 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_OBJECT_NAME_COLLISION); } /// FILE_OPEN on a path that doesn't exist returns /// STATUS_OBJECT_NAME_NOT_FOUND (canary `HostPathDevice::Open` mirror). #[test] fn cache_file_open_missing() { let (mut ctx, mem, mut state) = fresh(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\missing.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; 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_eq!(mem.read_u32(handle_out), 0, "no handle on miss"); } /// `init_cache_root` clears the directory before reuse, so a fresh /// kernel never sees stale cache from a previous run. Determinism /// gate for the lockstep `sylpheed_n*m.json` digest. #[test] fn cache_root_cleared_on_init() { let dir = std::env::temp_dir().join(format!( "xenia-rs-cache-test-clear-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .subsec_nanos(), )); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("stale.tmp"), b"stale").unwrap(); let mut state = KernelState::new(); state.init_cache_root(dir.clone()).unwrap(); assert!(!dir.join("stale.tmp").exists(), "stale must be cleared"); assert!(dir.exists(), "root must be re-created"); // Cleanup. std::fs::remove_dir_all(&dir).ok(); } /// Phase C+11 Stage 2 — when a `cache:\` file already exists /// on disk as a regular file, re-opening it with the /// `FILE_DIRECTORY_FILE` bit set MUST still route through the file /// branch (host_path = Some) — the on-disk type wins. Pre-fix: /// `is_dir_open = want_dir || host_path.is_dir()` would force /// re-opens with bit 0x1 set into the dir branch, dropping /// host_path and blocking subsequent class-10 renames. #[test] fn cache_existing_file_wins_over_directory_bit() { let (mut ctx, mem, mut state) = fresh(); let cache_root = state.cache_root.clone().unwrap(); // 1. FILE_CREATE without DIRECTORY bit → produces a real file. let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_CREATE as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert!(cache_root.join("foo.tmp").is_file()); // 2. Re-open with FILE_DIRECTORY_FILE bit set in r7. // open_options bit 0x1 = FILE_DIRECTORY_FILE. // open_options bit 0x20 = FILE_SYNCHRONOUS_IO_NONALERT (keeps // the handle synchronous so NtWriteFile returns STATUS_SUCCESS). ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[7] = (0x1 | FILE_SYNCHRONOUS_IO_NONALERT) as u64; nt_open_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let handle = mem.read_u32(handle_out); // 3. The re-opened handle MUST be a file handle with a real // host_path, not a directory handle with host_path=None. let obj = state.objects.get(&handle).expect("handle must exist"); match obj { KernelObject::File { host_path, path, .. } => { assert!( host_path.is_some(), "existing file re-open must keep host_path (got None) — bug #2 regression" ); assert!( !path.ends_with('/'), "existing file re-open path must NOT have trailing '/' (got dir-shape) — bug #2 regression" ); } _ => panic!("expected File kernel object"), } } /// Phase C+11 Stage 2 — `cache:\access`, `cache:\ignore`, and /// `cache:\recent` are TOP-LEVEL files in canary's cache (per /// the canary-cache-listing.csv enumeration). Cold creation /// through ours should produce files, not directories. #[test] fn cache_top_level_manifests_create_as_files() { for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] { let (mut ctx, mem, mut state) = fresh(); let cache_root = state.cache_root.clone().unwrap(); let leaf_name = path_str.strip_prefix("cache:\\").unwrap(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; // Set FILE_NON_DIRECTORY_FILE explicitly so Sylpheed-style // create paths produce host files. (If Sylpheed sets the // DIRECTORY bit but no NON_DIRECTORY bit, the pre-fix code // would mis-create as dirs; this test pins the // bit-conflict-resolution policy.) mem.write_u32( SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT | 0x40, // | FILE_NON_DIRECTORY_FILE ); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_CREATE as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!( ctx.gpr[3], STATUS_SUCCESS, "FILE_CREATE on {} must succeed", path_str ); assert!( cache_root.join(leaf_name).is_file(), "cache:\\{} must be a host file (got: dir or absent)", leaf_name ); } } /// Phase C+11.1 — Sylpheed's cold-boot probe pattern: open /// `cache:\access` / `cache:\ignore` / `cache:\recent` with /// disp=1 (FILE_OPEN) + opts=0x7 (DIRECTORY_FILE | WRITE_THROUGH /// | SEQUENTIAL_ONLY) MUST return `STATUS_OBJECT_NAME_NOT_FOUND` /// and MUST NOT create a host directory. Pre-fix the /// `is_dir_open` branch unconditionally mkdir-p'd whenever /// `want_dir`, which produced spurious `access`/`ignore`/`recent` /// directories that then occluded later `disp=5 NON_DIRECTORY` /// re-creates Sylpheed uses to populate the manifests. /// Mirrors canary's `VirtualFileSystem::OpenFile` /// (virtual_file_system.cc:265-273) which returns /// `X_STATUS_OBJECT_NAME_NOT_FOUND` for `kOpen` on missing path, /// regardless of `is_directory`. #[test] fn cache_open_directory_on_missing_path_returns_not_found() { for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] { let (mut ctx, mem, mut state) = fresh(); let cache_root = state.cache_root.clone().unwrap(); let leaf_name = path_str.strip_prefix("cache:\\").unwrap(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; // Sylpheed's exact cold-boot bit pattern: FILE_DIRECTORY_FILE // (0x1) | FILE_WRITE_THROUGH (0x2) | FILE_SEQUENTIAL_ONLY (0x4) // = 0x7. Slot offset 0x54 per the `nt_create_file` // arg-marshalling. mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_OPEN as u64; // Clear any pre-existing handle slot so the assert is honest. mem.write_u32(handle_out, 0xDEAD_BEEF); nt_create_file(&mut ctx, &mem, &mut state); assert_eq!( ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND, "FILE_OPEN+DIR on missing {} must return NOT_FOUND", path_str ); assert_eq!( mem.read_u32(handle_out), 0, "no handle on cold-boot dir-open miss for {}", path_str ); assert!( !cache_root.join(leaf_name).exists(), "{} must NOT be created on disk by a non-create disp", leaf_name ); } } /// Phase C+11.1 — after the cold-boot NOT_FOUND probe (see /// `cache_open_directory_on_missing_path_returns_not_found`), /// Sylpheed re-issues `disp=FILE_OVERWRITE_IF (5)` with /// `FILE_NON_DIRECTORY_FILE` set. That second call MUST produce /// a regular file, not a directory. This pins the two-call /// sequence canary actually executes on cold boot. #[test] fn cache_disp5_after_disp1_miss_creates_file() { let (mut ctx, mem, mut state) = fresh(); let cache_root = state.cache_root.clone().unwrap(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\access"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; // 1) Cold disp=1 + opts=0x7 → NOT_FOUND, no host-side entry. mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_OPEN as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND); assert!(!cache_root.join("access").exists()); // 2) disp=5 + opts=0x60 (FILE_NON_DIRECTORY_FILE | // FILE_SYNCHRONOUS_IO_NONALERT) → FILE created. mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x60); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_OVERWRITE_IF as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); assert!( cache_root.join("access").is_file(), "disp=5 with NON_DIRECTORY on cache:\\access must produce a host FILE" ); } /// Phase C+11 — write a `cache:\

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

\\

` via /// NtSetInformationFile class 10 (XFileRenameInformation). After the /// rename, the flat file must be gone and the leaf must contain the /// original bytes. This is the .tmp-to-leaf promotion that Sylpheed /// relies on for cache build. #[test] fn cache_rename_information_promotes_tmp_to_leaf() { let (mut ctx, mem, mut state) = fresh(); // Create cache:\foo.tmp with FILE_CREATE. let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_CREATE as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let handle = mem.read_u32(handle_out); // Write 4 bytes. let write_buf = SCRATCH_BASE + 0x400; for (i, b) in b"abcd".iter().enumerate() { mem.write_u8(write_buf + i as u32, *b); } ctx.gpr[3] = handle as u64; ctx.gpr[4] = 0; ctx.gpr[7] = iosb as u64; ctx.gpr[8] = write_buf as u64; ctx.gpr[9] = 4; ctx.gpr[10] = 0; nt_write_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); // Confirm the flat .tmp exists. let cache_root = state.cache_root.clone().expect("must have cache root"); assert!(cache_root.join("foo.tmp").exists(), ".tmp must exist pre-rename"); assert!(!cache_root.join("bar").exists(), "leaf must NOT exist yet"); // Build XFileRenameInformation buffer at SCRATCH_BASE+0x500: // offset 0: be replace_existing = 1 // offset 4: be root_dir_handle = 0 // offset 8: ANSI_STRING { Length, MaxLength, BufferPtr } // offset 16: path bytes let info_buf = SCRATCH_BASE + 0x500; let target = "cache:\\bar"; mem.write_u32(info_buf, 1); // replace_existing mem.write_u32(info_buf + 4, 0); // root_dir_handle mem.write_u16(info_buf + 8, target.len() as u16); // ANSI_STRING.Length mem.write_u16(info_buf + 10, target.len() as u16); // ANSI_STRING.MaxLength mem.write_u32(info_buf + 12, info_buf + 16); // ANSI_STRING.Buffer for (i, b) in target.bytes().enumerate() { mem.write_u8(info_buf + 16 + i as u32, b); } // NtSetInformationFile class 10 (rename). ctx.gpr[3] = handle as u64; ctx.gpr[4] = iosb as u64; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 16 + target.len() as u64; // info_length ctx.gpr[7] = 10; // info_class = XFileRenameInformation nt_set_information_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "rename must succeed"); // After rename: .tmp gone, leaf present with the original bytes. assert!(!cache_root.join("foo.tmp").exists(), ".tmp must be gone"); assert!(cache_root.join("bar").exists(), "leaf must exist"); assert_eq!( std::fs::read(cache_root.join("bar")).unwrap(), b"abcd", "leaf must have the original bytes" ); } /// Phase C+11 — rename also creates intermediate parent directories /// (Sylpheed's leaf paths are `cache:\

\\

` form; a /// host-fs `rename` would fail without `create_dir_all` on parent). #[test] fn cache_rename_creates_parent_directories() { let (mut ctx, mem, mut state) = fresh(); // Create cache:\src.tmp. let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\src.tmp"); let handle_out = SCRATCH_BASE + 0x300; let iosb = SCRATCH_BASE + 0x310; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_CREATE as u64; nt_create_file(&mut ctx, &mem, &mut state); let handle = mem.read_u32(handle_out); // Rename to cache:\d4ea4615\e\46ee8ca (depth-3 hierarchical leaf). let info_buf = SCRATCH_BASE + 0x500; let target = "cache:\\d4ea4615\\e\\46ee8ca"; mem.write_u32(info_buf, 1); mem.write_u32(info_buf + 4, 0); mem.write_u16(info_buf + 8, target.len() as u16); mem.write_u16(info_buf + 10, target.len() as u16); mem.write_u32(info_buf + 12, info_buf + 16); for (i, b) in target.bytes().enumerate() { mem.write_u8(info_buf + 16 + i as u32, b); } ctx.gpr[3] = handle as u64; ctx.gpr[4] = iosb as u64; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 16 + target.len() as u64; ctx.gpr[7] = 10; nt_set_information_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let cache_root = state.cache_root.clone().unwrap(); assert!(cache_root.join("d4ea4615/e/46ee8ca").exists()); } /// Phase C+11 — rename of a non-existent / closed handle returns /// STATUS_INVALID_HANDLE (canary parity). #[test] fn cache_rename_invalid_handle_returns_status() { let (mut ctx, mem, mut state) = fresh(); let info_buf = SCRATCH_BASE + 0x500; let target = "cache:\\target"; mem.write_u32(info_buf, 1); mem.write_u32(info_buf + 4, 0); mem.write_u16(info_buf + 8, target.len() as u16); mem.write_u16(info_buf + 10, target.len() as u16); mem.write_u32(info_buf + 12, info_buf + 16); for (i, b) in target.bytes().enumerate() { mem.write_u8(info_buf + 16 + i as u32, b); } ctx.gpr[3] = 0xDEADBEEF; // bogus handle ctx.gpr[4] = 0; ctx.gpr[5] = info_buf as u64; ctx.gpr[6] = 16 + target.len() as u64; ctx.gpr[7] = 10; nt_set_information_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE); } /// Phase C+12 — helper. Pins the wire-format of /// `X_FILE_NETWORK_OPEN_INFORMATION` produced by /// `nt_query_full_attributes_file`. Issues the query for `path` and /// asserts the 8-DWord OUT struct fields (all big-endian). fn assert_query_attrs_struct( state: &mut KernelState, mem: &GuestMemory, path: &str, expected_attrs: u32, expected_size: u64, ) -> u64 { let mut ctx = PpcContext::default(); let obj_attrs = write_obj_attrs(mem, SCRATCH_BASE + 0x100, path); let out = SCRATCH_BASE + 0x300; for off in (0..56).step_by(4) { mem.write_u32(out + off as u32, 0xCDCD_CDCD); } ctx.gpr[3] = obj_attrs as u64; ctx.gpr[4] = out as u64; nt_query_full_attributes_file(&mut ctx, mem, state); let status = ctx.gpr[3]; if status == STATUS_SUCCESS { assert_eq!( mem.read_u32(out + 48), expected_attrs, "FileAttributes mismatch at {}", path ); assert_eq!( mem.read_u64(out + 40), expected_size, "EndOfFile mismatch at {}", path ); assert_eq!( mem.read_u32(out + 52), 0, "Reserved field must be zero at {}", path ); // AllocationSize == round_up(size, 512) let expected_alloc = (expected_size + 511) & !511; assert_eq!( mem.read_u64(out + 32), expected_alloc, "AllocationSize mismatch at {}", path ); } status } /// Phase C+12 — `nt_query_full_attributes_file` returns /// `STATUS_NO_SUCH_FILE` for a path that's never been created. /// Mirrors canary's `NtQueryFullAttributesFile_entry` returning /// `X_STATUS_NO_SUCH_FILE` when `ResolvePath` returns null /// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:512`). #[test] fn nt_query_full_attributes_file_missing_returns_no_such_file() { let (_ctx, mem, mut state) = fresh(); let status = assert_query_attrs_struct(&mut state, &mem, "cache:\\never_existed", 0, 0); assert_eq!(status, STATUS_NO_SUCH_FILE); } /// Phase C+12 — after `NtCreateFile cache:\foo` succeeds (which /// canary's `Entry::CreateEntry` populates the in-memory tree), /// a follow-up `NtQueryFullAttributesFile` MUST resolve from the /// in-memory mirror and return SUCCESS with /// `FILE_ATTRIBUTE_NORMAL` (0x80) for a regular file. #[test] fn nt_query_full_attributes_file_after_create_returns_normal() { let (mut ctx, mem, mut state) = fresh(); // Create cache:\foo with FILE_OVERWRITE_IF (creates if missing). let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo"); let handle_out = SCRATCH_BASE + 0x400; let iosb = SCRATCH_BASE + 0x410; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_OVERWRITE_IF as u64; nt_create_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); // Now query. let status = assert_query_attrs_struct( &mut state, &mem, "cache:\\foo", crate::state::X_FILE_ATTRIBUTE_NORMAL, 0, ); assert_eq!(status, STATUS_SUCCESS); } /// Phase C+12 — mount-time scan picks up files that already exist /// on disk under the cache root (canary's `HostPathDevice:: /// PopulateEntry` analogue). The probe MUST succeed even though /// no `NtCreateFile` ran this boot — this is exactly the canary /// behaviour ours was missing at idx 102404. #[test] fn nt_query_full_attributes_file_resolves_preexisting_host_entry() { let mut state = KernelState::new(); let dir = std::env::temp_dir().join(format!( "xenia-rs-cache-test-c12pre-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .subsec_nanos() )); std::fs::create_dir_all(dir.join("d4ea4615").join("e")).unwrap(); std::fs::write(dir.join("d4ea4615").join("e").join("46ee8ca"), b"oracle").unwrap(); // `set_cache_root` performs the eager scan. state.set_cache_root(dir.clone()); // Wire up scratch + initial thread (mirrors `fresh()`). let mut mem = GuestMemory::new().expect("memory init"); mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) .expect("scratch page must commit"); state.install_initial_thread( PpcContext::default(), 0x7000_0000, 0x10_0000, SCRATCH_BASE + 0x800, SCRATCH_BASE + 0xC00, 0x1000, &mut mem, ); state.scheduler.begin_slot_visit(0); let status = assert_query_attrs_struct( &mut state, &mem, "cache:\\d4ea4615\\e\\46ee8ca", crate::state::X_FILE_ATTRIBUTE_NORMAL, 6, // strlen("oracle") ); assert_eq!(status, STATUS_SUCCESS); // Directory probe must also resolve (mount-time scan inserts // both files and dirs). let status_dir = assert_query_attrs_struct( &mut state, &mem, "cache:\\d4ea4615", crate::state::X_FILE_ATTRIBUTE_DIRECTORY, 0, ); assert_eq!(status_dir, STATUS_SUCCESS); std::fs::remove_dir_all(&dir).ok(); } /// Phase C+12 — pin the FILETIME conversion: a known Unix epoch /// value (`1_700_000_000` seconds = 2023-11-14 22:13:20 UTC) /// converts to the expected Windows FILETIME tick count. #[test] fn unix_to_filetime_known_value() { let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); let ft = crate::state::unix_to_filetime(t); // (1_700_000_000 + 11_644_473_600) * 10_000_000 = 133_444_736_000_000_000 assert_eq!(ft, 133_444_736_000_000_000); } /// Phase C+12 — `change_time` slot (offset 24) MUST equal /// `last_write_time` (offset 16), mirroring canary's /// `xboxkrnl_io.cc:504` line `file_info->change_time = /// entry->write_timestamp();`. This is the only field where the /// brief's "4 distinct FILETIMEs" framing differs from canary's /// actual semantics. #[test] fn nt_query_full_attributes_file_change_time_equals_write_time() { let (mut ctx, mem, mut state) = fresh(); let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\writeme"); let handle_out = SCRATCH_BASE + 0x400; let iosb = SCRATCH_BASE + 0x410; ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64; mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT); ctx.gpr[3] = handle_out as u64; ctx.gpr[5] = obj_attrs as u64; ctx.gpr[6] = iosb as u64; ctx.gpr[10] = FILE_OVERWRITE_IF as u64; nt_create_file(&mut ctx, &mem, &mut state); let out = SCRATCH_BASE + 0x300; ctx.gpr[3] = obj_attrs as u64; ctx.gpr[4] = out as u64; nt_query_full_attributes_file(&mut ctx, &mem, &mut state); assert_eq!(ctx.gpr[3], STATUS_SUCCESS); let last_write = mem.read_u64(out + 16); let change = mem.read_u64(out + 24); assert_eq!( change, last_write, "change_time must equal last_write_time per canary xboxkrnl_io.cc:504" ); } /// 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"); } /// Phase C+23: pin `VdQueryVideoFlags` at canary-equivalent `0x3`. /// Canary's bitmask is `(is_widescreen ? 1 : 0) | (width>=1280 ? 2 : 0) /// | (width>=1920 ? 4 : 0)`. With ours's `vd_query_video_mode` reporting /// `is_widescreen=1` and `display_width=1280` (and no Full HD bit), /// the canary-equivalent flags value is `1 | 2 = 3`. #[test] fn vd_query_video_flags_returns_three() { let (mut ctx, mem, mut state) = fresh(); ctx.gpr[3] = 0xDEAD_BEEF; // sentinel — must be overwritten vd_query_video_flags(&mut ctx, &mem, &mut state); assert_eq!( ctx.gpr[3], 0x3, "VdQueryVideoFlags must return canary-equivalent bitmask 0x3 \ (is_widescreen | width>=1280)" ); } /// Cross-check: the value must agree with what ours's /// `vd_query_video_mode` reports — otherwise the bitmask and the /// underlying mode struct disagree, which would break games that /// cross-check the two. The flags should equal: /// (is_widescreen ? 1 : 0) | (width>=1280 ? 2 : 0) | (width>=1920 ? 4 : 0) /// evaluated over the values vd_query_video_mode actually writes. #[test] fn vd_query_video_flags_matches_vd_query_video_mode_payload() { let (mut ctx, mem, mut state) = fresh(); // Allocate a scratch page for the mode struct. let mode_ptr = SCRATCH_BASE; ctx.gpr[3] = mode_ptr as u64; vd_query_video_mode(&mut ctx, &mem, &mut state); let display_width = mem.read_u32(mode_ptr); let is_widescreen = mem.read_u32(mode_ptr + 12); let expected = (if is_widescreen != 0 { 1 } else { 0 }) | (if display_width >= 1280 { 2 } else { 0 }) | (if display_width >= 1920 { 4 } else { 0 }); ctx.gpr[3] = 0xDEAD_BEEF; vd_query_video_flags(&mut ctx, &mem, &mut state); assert_eq!( ctx.gpr[3], expected, "VdQueryVideoFlags must equal the bitmask computed from \ VdQueryVideoMode's payload" ); } // ---- review-a Step 1 crowbar ----------------------------------------- /// The crowbar must: /// (a) allocate a ctx page, /// (b) write vtable BASE 0x8200A1E8 at +0, self at +4/+8, refcount=1 at +12, /// (c) spawn 4 threads at the canonical entries, /// (d) resume each of them (post-spawn `suspend_count == 0`). /// /// Test-setup wart: `fresh()` hard-codes the initial test thread's /// handle to `0x1000` (which equals `next_handle`'s initial value), /// so without intervention the first crowbar spawn would collide /// with that handle. Bump `next_handle` past `0x1000` here to /// mirror production, where the main thread's handle is itself /// minted via `alloc_handle_for`. #[test] fn crowbar_force_spawn_workers_spawns_and_resumes_4() { let (_ctx, mem, mut state) = fresh(); // Reserve a handle slot to push next_handle past 0x1000. let _ = state.alloc_handle(); let resumed = crowbar_force_spawn_workers(&mut state, &mem); assert_eq!(resumed, 4, "all 4 workers must resume on a fresh kernel"); // Find each thread by entry + verify start_context matches the // ctx we wrote and that all 4 share one ctx address. let entries: std::collections::HashSet = [ 0x82506528, 0x82506558, 0x82506588, 0x825065B8, ] .into_iter() .collect(); let mut seen_entries: std::collections::HashSet = std::collections::HashSet::new(); let mut ctx_addrs: std::collections::HashSet = std::collections::HashSet::new(); for (hw_id, slot) in state.scheduler.slots.iter().enumerate() { for (idx, t) in slot.runqueue.iter().enumerate() { if entries.contains(&t.ctx.pc) { seen_entries.insert(t.ctx.pc); ctx_addrs.insert(t.ctx.gpr[3] as u32); assert_eq!( t.suspend_count, 0, "crowbar must leave each worker resumed (suspend_count=0) — \ entry={:#010x} hw={} idx={} state={:?}", t.ctx.pc, hw_id, idx, t.state, ); } } } assert_eq!(seen_entries, entries, "all 4 entries must be present in scheduler"); assert_eq!(ctx_addrs.len(), 1, "all 4 workers must share one ctx_ptr"); // Verify ctx layout: vtable base + self + self + refcount. let ctx_ptr = *ctx_addrs.iter().next().expect("one ctx"); assert_eq!(mem.read_u32(ctx_ptr), 0x8200_A1E8, "vtable BASE at ctx+0"); assert_eq!(mem.read_u32(ctx_ptr + 4), ctx_ptr, "self at ctx+4"); assert_eq!(mem.read_u32(ctx_ptr + 8), ctx_ptr, "self at ctx+8"); assert_eq!(mem.read_u32(ctx_ptr + 12), 1, "refcount=1 at ctx+12"); } /// `try_fire_crowbar_workers` is no-op when the cvar is disabled. #[test] fn try_fire_crowbar_workers_noop_when_disabled() { let (_ctx, mem, mut state) = fresh(); let pre_thread_count: usize = state .scheduler .slots .iter() .map(|s| s.runqueue.len()) .sum(); // Cvar default-off. let resumed = state.try_fire_crowbar_workers(&mem, u64::MAX); assert_eq!(resumed, 0); let post: usize = state .scheduler .slots .iter() .map(|s| s.runqueue.len()) .sum(); assert_eq!(pre_thread_count, post, "no threads spawned when disabled"); assert!(!state.crowbar_workers_fired, "latch stays unset"); } /// Trigger threshold gates the fire — below threshold = no-op, at/over /// threshold = fires exactly once. #[test] fn try_fire_crowbar_workers_respects_threshold_and_latches_once() { let (_ctx, mem, mut state) = fresh(); // See `fresh()` test-setup wart in the spawn test above. let _ = state.alloc_handle(); state.crowbar_workers_enabled = true; state.crowbar_workers_trigger_instr = 1_000; // Below threshold — no fire. assert_eq!(state.try_fire_crowbar_workers(&mem, 999), 0); assert!(!state.crowbar_workers_fired); // At threshold — fires. assert_eq!(state.try_fire_crowbar_workers(&mem, 1_000), 4); assert!(state.crowbar_workers_fired); // Subsequent call — latched, returns 0. assert_eq!(state.try_fire_crowbar_workers(&mem, u64::MAX), 0); } }