Files
xenia-rs/crates/xenia-kernel/src/exports.rs
MechaCat02 d6acb21b2e wip(probe): throwaway iterate-iterate-2AU-xaudio probe instrumentation
Uncommitted experimental probe code preserved for handoff. Per running
memory these probes are inert/throwaway diagnostics, not production fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:19:28 +02:00

10135 lines
424 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u32> {
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=<path>` 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=<path>` 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<ThreadRef> = 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<Mutex<KernelState>> 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<T>`; we hand-roll the BE bytes since values
// are constant).
#[derive(Clone, Copy)]
enum SettingValue {
U8(u8),
U16Be(u16),
U32Be(u32),
}
impl SettingValue {
fn size(&self) -> u16 {
match self {
SettingValue::U8(_) => 1,
SettingValue::U16Be(_) => 2,
SettingValue::U32Be(_) => 4,
}
}
fn write(&self, mem: &GuestMemory, addr: u32) {
match self {
SettingValue::U8(v) => mem.write_u8(addr, *v),
SettingValue::U16Be(v) => mem.write_u16(addr, *v),
SettingValue::U32Be(v) => mem.write_u32(addr, *v),
}
}
}
let value: Option<SettingValue> = match (category, setting) {
// XCONFIG_SECURED_CATEGORY = 0x02
(0x02, 0x02) => Some(SettingValue::U32Be(1)), // SECURED_AV_REGION = NTSCM
// XCONFIG_USER_CATEGORY = 0x03
(0x03, 0x01) // TIME_ZONE_BIAS
| (0x03, 0x02) // TIME_ZONE_STD_NAME
| (0x03, 0x03) // TIME_ZONE_DLT_NAME
| (0x03, 0x04) // TIME_ZONE_STD_DATE
| (0x03, 0x05) // TIME_ZONE_DLT_DATE
| (0x03, 0x06) // TIME_ZONE_STD_BIAS
| (0x03, 0x07) // TIME_ZONE_DLT_BIAS
=> Some(SettingValue::U32Be(0)),
(0x03, 0x09) => Some(SettingValue::U32Be(1)), // USER_LANGUAGE = en
(0x03, 0x0A) => Some(SettingValue::U32Be(0)), // USER_VIDEO_FLAGS = RatioNormal
(0x03, 0x0B) => Some(SettingValue::U32Be(0x00010001)), // USER_AUDIO_FLAGS
(0x03, 0x0C) => Some(SettingValue::U32Be(0x40)), // USER_RETAIL_FLAGS
(0x03, 0x0E) => Some(SettingValue::U8(103)), // USER_COUNTRY = US
(0x03, 0x0F) => Some(SettingValue::U8(0x03)), // USER_PC_FLAGS = XBL allowed
// XCONFIG_CONSOLE_CATEGORY = 0x07
(0x07, 0x02) => Some(SettingValue::U16Be(0)), // SCREEN_SAVER = Off
(0x07, 0x03) => Some(SettingValue::U16Be(0)), // AUTO_SHUT_OFF = Off
_ => None,
};
let v = match value {
Some(v) => v,
None => {
// Unknown category or setting. Match canary's per-category
// return code: invalid category vs invalid setting both
// surface as STATUS_INVALID_PARAMETER_x in canary; we use
// STATUS_INVALID_PARAMETER_2 as a single sentinel since the
// distinction is rarely consulted by guest code.
ctx.gpr[3] = 0xC000_00F0; // X_STATUS_INVALID_PARAMETER_2
return;
}
};
let setting_size = v.size();
if buffer_ptr != 0 {
if buffer_size < setting_size as u32 {
ctx.gpr[3] = 0xC000_0023; // X_STATUS_BUFFER_TOO_SMALL
return;
}
v.write(mem, buffer_ptr);
} else if buffer_size != 0 {
ctx.gpr[3] = 0xC000_00F1; // X_STATUS_INVALID_PARAMETER_3
return;
}
if required_size_ptr != 0 {
mem.write_u16(required_size_ptr, setting_size);
}
ctx.gpr[3] = 0; // STATUS_SUCCESS
}
// ===== Memory =====
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<std::path::PathBuf> {
// 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:\<hash>` — 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:\<H1><H2>.tmp`
// re-opens (the `.tmp` was already a file from a prior FILE_CREATE),
// which under the AUDIT-054 logic mis-routed to the directory branch
// and dropped `host_path` — blocking the subsequent class-10 rename
// with `STATUS_ACCESS_DENIED`. Also resolves Phase C+11's bug #2:
// `cache:\access`/`ignore`/`recent` end up as files on cold creation
// because `want_non_dir` (FILE_NON_DIRECTORY_FILE bit 0x40) takes
// precedence when set, even with FILE_DIRECTORY_FILE.
//
// Resolution order (mirrors canary):
// 1. Existing host entry: actual type wins (file ↔ dir).
// 2. `want_non_dir` set → file path (NON_DIRECTORY_FILE overrides).
// 3. `want_dir` set → directory path.
// 4. Default → file path.
//
// Root-of-mount case is captured by the existing-dir branch: the
// cache root always exists as a directory, so `host_path.is_dir()`
// is true.
let host_exists_as_dir = host_path.is_dir();
let host_exists_as_file = host_path.is_file();
let is_dir_open = host_exists_as_dir
|| (!host_exists_as_file && !want_non_dir && want_dir);
if is_dir_open {
// 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<usize> {
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:\<H1><H2>.tmp` flat journal files, then
/// renames them to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via this
/// info-class. Before this body landed, ours silently fell through to the
/// `_ => STATUS_SUCCESS` catch-all and the `.tmp` never became a leaf —
/// blocking `NtQueryFullAttributesFile` at idx 102404 in the Phase A diff.
///
/// Layout per canary `info/file.h:79-83` (16 bytes total):
/// offset 0 be<u32> replace_existing
/// offset 4 be<u32> root_dir_handle
/// offset 8 X_ANSI_STRING (u16 Length, u16 MaximumLength, u32 Buffer)
///
/// Pulled out of `nt_set_information_file`'s main `match` because it
/// needs an immutable read of `state.cache_root` (via
/// `resolve_cache_path`) BEFORE the mutable destructure of the file
/// handle — Rust's borrow checker can't see through `state.method()`
/// across both kinds of access.
fn handle_set_info_rename(
mem: &GuestMemory,
state: &mut KernelState,
handle: u32,
info_ptr: u32,
info_length: u32,
) -> (u64, u32) {
// Read the rename target ANSI_STRING. The raw-form helper trims
// whitespace but does NOT prefix-strip — we want the original
// `cache:\...` form so the path resolver sees it.
let target_raw =
match crate::path::file_rename_information_raw_target(mem, info_ptr, info_length) {
Some(s) if !s.is_empty() => s,
_ => return (STATUS_OBJECT_NAME_INVALID, 16),
};
// Translate target path. Sylpheed only renames inside `cache:\`; any
// other prefix is not in scope (canary's `IsValidPath` rejects
// anything that doesn't resolve to a writable mount).
let target_host_path = match state.resolve_cache_path(&target_raw) {
Some(p) => p,
None => return (STATUS_OBJECT_NAME_INVALID, 16),
};
// Look up the source handle. Note: ANY non-File handle (event,
// semaphore, etc.) is INVALID_HANDLE; a File without a
// `host_path` is VFS-backed (read-only) and can't be renamed.
let Some(KernelObject::File { path, size, host_path, .. }) = state.objects.get_mut(&handle)
else {
return (STATUS_INVALID_HANDLE, 16);
};
let Some(src_host_path) = host_path.clone() else {
// VFS-backed read-only handle (disc / synth stub). Canary's
// HostPathDevice mount is the only Rename-capable backend on
// Sylpheed; Disc/SVOD throws `kReadOnly`.
return (STATUS_ACCESS_DENIED, 16);
};
// Create parent directories for the destination (matches canary's
// `HostPathEntry::CreateEntryInternal` which calls
// `create_directories` before writing the file). Without this, the
// rename to `<root>/d4ea4615/e/46ee8ca` fails when `<root>/d4ea4615/e`
// doesn't yet exist (a common cold-cache scenario).
if let Some(parent) = target_host_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(
"NtSetInformationFile rename: create_dir_all({:?}): {}",
parent,
e
);
return (STATUS_UNSUCCESSFUL, 16);
}
}
// Perform the rename. `std::fs::rename` is atomic within a single
// filesystem on POSIX; cross-filesystem is the only failure path
// worth worrying about, and the entire cache lives under one root.
let old_path = path.clone();
let rename_outcome = match std::fs::rename(&src_host_path, &target_host_path) {
Ok(()) => {
// Update the in-engine handle to point at the new location.
// The handle stays valid (mirrors canary's `XFile::Rename`
// which keeps the file handle open at the new path).
*path = crate::path::normalize_path(&target_raw);
*host_path = Some(target_host_path.clone());
let new_size = std::fs::metadata(&target_host_path)
.map(|m| m.len())
.unwrap_or(*size);
*size = new_size;
Ok(())
}
Err(e) => {
tracing::warn!(
"NtSetInformationFile rename: rename({:?} -> {:?}): {}",
src_host_path,
target_host_path,
e
);
Err(())
}
};
// Drop the mutable borrow on `state.objects` before touching
// `state.cache_entries` via the helper methods. The `let
// Some(KernelObject::File { .. }) = state.objects.get_mut(...)`
// binding above holds it until the function returns otherwise.
match rename_outcome {
Ok(()) => {
// Phase C+12 — refresh the in-memory entry tree: drop the
// source mirror, install / refresh the target mirror.
state.forget_cache_entry(&old_path);
if let Ok(md) = std::fs::metadata(&target_host_path) {
state.register_cache_entry(&target_raw, &md);
}
(STATUS_SUCCESS, 16)
}
Err(()) => (STATUS_UNSUCCESSFUL, 16),
}
}
/// `NtSetInformationFile(FileHandle, IoStatusBlock*, FileInformation,
/// Length, FileInformationClass)`. Mirrors Canary
/// [xboxkrnl_io_info.cc:180-304](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io_info.cc).
///
/// 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<uint64_t>` / `be<uint32_t>` in the canary struct); our
/// `GuestMemory::write_u{32,64}` already byte-swaps via `to_be_bytes`,
/// so the writes naturally produce the BE layout the Xbox 360 expects.
///
/// Layout (offset / size / type / canary field):
/// ```text
/// 0 u64 CreationTime (FILETIME)
/// 8 u64 LastAccessTime
/// 16 u64 LastWriteTime
/// 24 u64 ChangeTime (= LastWriteTime per xboxkrnl_io.cc:504)
/// 32 u64 AllocationSize
/// 40 u64 EndOfFile
/// 48 u32 Attributes (FILE_ATTRIBUTE_*)
/// 52 u32 Reserved (= 0)
/// ```
fn write_file_network_open_information(
mem: &GuestMemory,
out: u32,
meta: &crate::state::CacheEntryMeta,
) {
if out == 0 {
return;
}
mem.write_u64(out, meta.create_time);
mem.write_u64(out + 8, meta.access_time);
mem.write_u64(out + 16, meta.write_time);
// change_time = write_time per canary `xboxkrnl_io.cc:504`.
mem.write_u64(out + 24, meta.write_time);
mem.write_u64(out + 32, meta.allocation_size);
mem.write_u64(out + 40, meta.size);
let attrs = if meta.is_directory {
crate::state::X_FILE_ATTRIBUTE_DIRECTORY
} else {
crate::state::X_FILE_ATTRIBUTE_NORMAL
};
mem.write_u32(out + 48, attrs);
mem.write_u32(out + 52, 0);
}
fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = obj_attrs, r4 = network_open_info
let obj_attrs_ptr = ctx.gpr[3] as u32;
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<xenia_vfs::VfsEntry> = 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<u32> = 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<XTimer>`) 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<uint32_t>(p0_ptr, 0xBEEF0000);
// xe::store_and_swap<uint32_t>(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<u32, Vec<u32>> = 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<Option<u64>> {
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<tid>, 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<u32>, 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<u32> = 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<u32>,
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<u32> = (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<u32> = (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<u32> = 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<u8>) -> 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<XThread> +
/// 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<xenia_vfs::VfsEntry>,
}
impl xenia_vfs::VfsDevice for StubVfs {
fn name(&self) -> &str { "stub" }
fn list_root(&self) -> Result<Vec<xenia_vfs::VfsEntry>, xenia_vfs::VfsError> {
Ok(self.entries.clone())
}
fn read_file(&self, _path: &str) -> Result<Vec<u8>, xenia_vfs::VfsError> {
Err(xenia_vfs::VfsError::NotFound("stub".into()))
}
fn stat(&self, _path: &str) -> Result<xenia_vfs::VfsEntry, xenia_vfs::VfsError> {
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<String> = 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:\<name>` file already exists
/// on disk as a regular file, re-opening it with the
/// `FILE_DIRECTORY_FILE` bit set MUST still route through the file
/// branch (host_path = Some) — the on-disk type wins. Pre-fix:
/// `is_dir_open = want_dir || host_path.is_dir()` would force
/// re-opens with bit 0x1 set into the dir branch, dropping
/// host_path and blocking subsequent class-10 renames.
#[test]
fn cache_existing_file_wins_over_directory_bit() {
let (mut ctx, mem, mut state) = fresh();
let cache_root = state.cache_root.clone().unwrap();
// 1. FILE_CREATE without DIRECTORY bit → produces a real file.
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_CREATE as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
assert!(cache_root.join("foo.tmp").is_file());
// 2. Re-open with FILE_DIRECTORY_FILE bit set in r7.
// open_options bit 0x1 = FILE_DIRECTORY_FILE.
// open_options bit 0x20 = FILE_SYNCHRONOUS_IO_NONALERT (keeps
// the handle synchronous so NtWriteFile returns STATUS_SUCCESS).
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[7] = (0x1 | FILE_SYNCHRONOUS_IO_NONALERT) as u64;
nt_open_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
let handle = mem.read_u32(handle_out);
// 3. The re-opened handle MUST be a file handle with a real
// host_path, not a directory handle with host_path=None.
let obj = state.objects.get(&handle).expect("handle must exist");
match obj {
KernelObject::File { host_path, path, .. } => {
assert!(
host_path.is_some(),
"existing file re-open must keep host_path (got None) — bug #2 regression"
);
assert!(
!path.ends_with('/'),
"existing file re-open path must NOT have trailing '/' (got dir-shape) — bug #2 regression"
);
}
_ => panic!("expected File kernel object"),
}
}
/// Phase C+11 Stage 2 — `cache:\access`, `cache:\ignore`, and
/// `cache:\recent` are TOP-LEVEL files in canary's cache (per
/// the canary-cache-listing.csv enumeration). Cold creation
/// through ours should produce files, not directories.
#[test]
fn cache_top_level_manifests_create_as_files() {
for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
let (mut ctx, mem, mut state) = fresh();
let cache_root = state.cache_root.clone().unwrap();
let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
// Set FILE_NON_DIRECTORY_FILE explicitly so Sylpheed-style
// create paths produce host files. (If Sylpheed sets the
// DIRECTORY bit but no NON_DIRECTORY bit, the pre-fix code
// would mis-create as dirs; this test pins the
// bit-conflict-resolution policy.)
mem.write_u32(
SCRATCH_BASE + 0x700 + 0x54,
FILE_SYNCHRONOUS_IO_NONALERT | 0x40, // | FILE_NON_DIRECTORY_FILE
);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_CREATE as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], STATUS_SUCCESS,
"FILE_CREATE on {} must succeed",
path_str
);
assert!(
cache_root.join(leaf_name).is_file(),
"cache:\\{} must be a host file (got: dir or absent)",
leaf_name
);
}
}
/// Phase C+11.1 — Sylpheed's cold-boot probe pattern: open
/// `cache:\access` / `cache:\ignore` / `cache:\recent` with
/// disp=1 (FILE_OPEN) + opts=0x7 (DIRECTORY_FILE | WRITE_THROUGH
/// | SEQUENTIAL_ONLY) MUST return `STATUS_OBJECT_NAME_NOT_FOUND`
/// and MUST NOT create a host directory. Pre-fix the
/// `is_dir_open` branch unconditionally mkdir-p'd whenever
/// `want_dir`, which produced spurious `access`/`ignore`/`recent`
/// directories that then occluded later `disp=5 NON_DIRECTORY`
/// re-creates Sylpheed uses to populate the manifests.
/// Mirrors canary's `VirtualFileSystem::OpenFile`
/// (virtual_file_system.cc:265-273) which returns
/// `X_STATUS_OBJECT_NAME_NOT_FOUND` for `kOpen` on missing path,
/// regardless of `is_directory`.
#[test]
fn cache_open_directory_on_missing_path_returns_not_found() {
for path_str in ["cache:\\access", "cache:\\ignore", "cache:\\recent"] {
let (mut ctx, mem, mut state) = fresh();
let cache_root = state.cache_root.clone().unwrap();
let leaf_name = path_str.strip_prefix("cache:\\").unwrap();
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, path_str);
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
// Sylpheed's exact cold-boot bit pattern: FILE_DIRECTORY_FILE
// (0x1) | FILE_WRITE_THROUGH (0x2) | FILE_SEQUENTIAL_ONLY (0x4)
// = 0x7. Slot offset 0x54 per the `nt_create_file`
// arg-marshalling.
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_OPEN as u64;
// Clear any pre-existing handle slot so the assert is honest.
mem.write_u32(handle_out, 0xDEAD_BEEF);
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND,
"FILE_OPEN+DIR on missing {} must return NOT_FOUND",
path_str
);
assert_eq!(
mem.read_u32(handle_out),
0,
"no handle on cold-boot dir-open miss for {}",
path_str
);
assert!(
!cache_root.join(leaf_name).exists(),
"{} must NOT be created on disk by a non-create disp",
leaf_name
);
}
}
/// Phase C+11.1 — after the cold-boot NOT_FOUND probe (see
/// `cache_open_directory_on_missing_path_returns_not_found`),
/// Sylpheed re-issues `disp=FILE_OVERWRITE_IF (5)` with
/// `FILE_NON_DIRECTORY_FILE` set. That second call MUST produce
/// a regular file, not a directory. This pins the two-call
/// sequence canary actually executes on cold boot.
#[test]
fn cache_disp5_after_disp1_miss_creates_file() {
let (mut ctx, mem, mut state) = fresh();
let cache_root = state.cache_root.clone().unwrap();
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\access");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
// 1) Cold disp=1 + opts=0x7 → NOT_FOUND, no host-side entry.
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x7);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_OPEN as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
assert!(!cache_root.join("access").exists());
// 2) disp=5 + opts=0x60 (FILE_NON_DIRECTORY_FILE |
// FILE_SYNCHRONOUS_IO_NONALERT) → FILE created.
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, 0x60);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
assert!(
cache_root.join("access").is_file(),
"disp=5 with NON_DIRECTORY on cache:\\access must produce a host FILE"
);
}
/// Phase C+11 — write a `cache:\<H1><H2>.tmp` flat journal, then
/// rename it to the hierarchical leaf `cache:\<H1>\<X>\<H2>` via
/// NtSetInformationFile class 10 (XFileRenameInformation). After the
/// rename, the flat file must be gone and the leaf must contain the
/// original bytes. This is the .tmp-to-leaf promotion that Sylpheed
/// relies on for cache build.
#[test]
fn cache_rename_information_promotes_tmp_to_leaf() {
let (mut ctx, mem, mut state) = fresh();
// Create cache:\foo.tmp with FILE_CREATE.
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo.tmp");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_CREATE as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
let handle = mem.read_u32(handle_out);
// Write 4 bytes.
let write_buf = SCRATCH_BASE + 0x400;
for (i, b) in b"abcd".iter().enumerate() {
mem.write_u8(write_buf + i as u32, *b);
}
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = 0;
ctx.gpr[7] = iosb as u64;
ctx.gpr[8] = write_buf as u64;
ctx.gpr[9] = 4;
ctx.gpr[10] = 0;
nt_write_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
// Confirm the flat .tmp exists.
let cache_root = state.cache_root.clone().expect("must have cache root");
assert!(cache_root.join("foo.tmp").exists(), ".tmp must exist pre-rename");
assert!(!cache_root.join("bar").exists(), "leaf must NOT exist yet");
// Build XFileRenameInformation buffer at SCRATCH_BASE+0x500:
// offset 0: be<u32> replace_existing = 1
// offset 4: be<u32> root_dir_handle = 0
// offset 8: ANSI_STRING { Length, MaxLength, BufferPtr }
// offset 16: path bytes
let info_buf = SCRATCH_BASE + 0x500;
let target = "cache:\\bar";
mem.write_u32(info_buf, 1); // replace_existing
mem.write_u32(info_buf + 4, 0); // root_dir_handle
mem.write_u16(info_buf + 8, target.len() as u16); // ANSI_STRING.Length
mem.write_u16(info_buf + 10, target.len() as u16); // ANSI_STRING.MaxLength
mem.write_u32(info_buf + 12, info_buf + 16); // ANSI_STRING.Buffer
for (i, b) in target.bytes().enumerate() {
mem.write_u8(info_buf + 16 + i as u32, b);
}
// NtSetInformationFile class 10 (rename).
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = iosb as u64;
ctx.gpr[5] = info_buf as u64;
ctx.gpr[6] = 16 + target.len() as u64; // info_length
ctx.gpr[7] = 10; // info_class = XFileRenameInformation
nt_set_information_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS, "rename must succeed");
// After rename: .tmp gone, leaf present with the original bytes.
assert!(!cache_root.join("foo.tmp").exists(), ".tmp must be gone");
assert!(cache_root.join("bar").exists(), "leaf must exist");
assert_eq!(
std::fs::read(cache_root.join("bar")).unwrap(),
b"abcd",
"leaf must have the original bytes"
);
}
/// Phase C+11 — rename also creates intermediate parent directories
/// (Sylpheed's leaf paths are `cache:\<H1>\<X>\<H2>` form; a
/// host-fs `rename` would fail without `create_dir_all` on parent).
#[test]
fn cache_rename_creates_parent_directories() {
let (mut ctx, mem, mut state) = fresh();
// Create cache:\src.tmp.
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\src.tmp");
let handle_out = SCRATCH_BASE + 0x300;
let iosb = SCRATCH_BASE + 0x310;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_CREATE as u64;
nt_create_file(&mut ctx, &mem, &mut state);
let handle = mem.read_u32(handle_out);
// Rename to cache:\d4ea4615\e\46ee8ca (depth-3 hierarchical leaf).
let info_buf = SCRATCH_BASE + 0x500;
let target = "cache:\\d4ea4615\\e\\46ee8ca";
mem.write_u32(info_buf, 1);
mem.write_u32(info_buf + 4, 0);
mem.write_u16(info_buf + 8, target.len() as u16);
mem.write_u16(info_buf + 10, target.len() as u16);
mem.write_u32(info_buf + 12, info_buf + 16);
for (i, b) in target.bytes().enumerate() {
mem.write_u8(info_buf + 16 + i as u32, b);
}
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = iosb as u64;
ctx.gpr[5] = info_buf as u64;
ctx.gpr[6] = 16 + target.len() as u64;
ctx.gpr[7] = 10;
nt_set_information_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
let cache_root = state.cache_root.clone().unwrap();
assert!(cache_root.join("d4ea4615/e/46ee8ca").exists());
}
/// Phase C+11 — rename of a non-existent / closed handle returns
/// STATUS_INVALID_HANDLE (canary parity).
#[test]
fn cache_rename_invalid_handle_returns_status() {
let (mut ctx, mem, mut state) = fresh();
let info_buf = SCRATCH_BASE + 0x500;
let target = "cache:\\target";
mem.write_u32(info_buf, 1);
mem.write_u32(info_buf + 4, 0);
mem.write_u16(info_buf + 8, target.len() as u16);
mem.write_u16(info_buf + 10, target.len() as u16);
mem.write_u32(info_buf + 12, info_buf + 16);
for (i, b) in target.bytes().enumerate() {
mem.write_u8(info_buf + 16 + i as u32, b);
}
ctx.gpr[3] = 0xDEADBEEF; // bogus handle
ctx.gpr[4] = 0;
ctx.gpr[5] = info_buf as u64;
ctx.gpr[6] = 16 + target.len() as u64;
ctx.gpr[7] = 10;
nt_set_information_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
}
/// Phase C+12 — helper. Pins the wire-format of
/// `X_FILE_NETWORK_OPEN_INFORMATION` produced by
/// `nt_query_full_attributes_file`. Issues the query for `path` and
/// asserts the 8-DWord OUT struct fields (all big-endian).
fn assert_query_attrs_struct(
state: &mut KernelState,
mem: &GuestMemory,
path: &str,
expected_attrs: u32,
expected_size: u64,
) -> u64 {
let mut ctx = PpcContext::default();
let obj_attrs = write_obj_attrs(mem, SCRATCH_BASE + 0x100, path);
let out = SCRATCH_BASE + 0x300;
for off in (0..56).step_by(4) {
mem.write_u32(out + off as u32, 0xCDCD_CDCD);
}
ctx.gpr[3] = obj_attrs as u64;
ctx.gpr[4] = out as u64;
nt_query_full_attributes_file(&mut ctx, mem, state);
let status = ctx.gpr[3];
if status == STATUS_SUCCESS {
assert_eq!(
mem.read_u32(out + 48),
expected_attrs,
"FileAttributes mismatch at {}",
path
);
assert_eq!(
mem.read_u64(out + 40),
expected_size,
"EndOfFile mismatch at {}",
path
);
assert_eq!(
mem.read_u32(out + 52),
0,
"Reserved field must be zero at {}",
path
);
// AllocationSize == round_up(size, 512)
let expected_alloc = (expected_size + 511) & !511;
assert_eq!(
mem.read_u64(out + 32),
expected_alloc,
"AllocationSize mismatch at {}",
path
);
}
status
}
/// Phase C+12 — `nt_query_full_attributes_file` returns
/// `STATUS_NO_SUCH_FILE` for a path that's never been created.
/// Mirrors canary's `NtQueryFullAttributesFile_entry` returning
/// `X_STATUS_NO_SUCH_FILE` when `ResolvePath` returns null
/// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:512`).
#[test]
fn nt_query_full_attributes_file_missing_returns_no_such_file() {
let (_ctx, mem, mut state) = fresh();
let status =
assert_query_attrs_struct(&mut state, &mem, "cache:\\never_existed", 0, 0);
assert_eq!(status, STATUS_NO_SUCH_FILE);
}
/// Phase C+12 — after `NtCreateFile cache:\foo` succeeds (which
/// canary's `Entry::CreateEntry` populates the in-memory tree),
/// a follow-up `NtQueryFullAttributesFile` MUST resolve from the
/// in-memory mirror and return SUCCESS with
/// `FILE_ATTRIBUTE_NORMAL` (0x80) for a regular file.
#[test]
fn nt_query_full_attributes_file_after_create_returns_normal() {
let (mut ctx, mem, mut state) = fresh();
// Create cache:\foo with FILE_OVERWRITE_IF (creates if missing).
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\foo");
let handle_out = SCRATCH_BASE + 0x400;
let iosb = SCRATCH_BASE + 0x410;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
nt_create_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
// Now query.
let status = assert_query_attrs_struct(
&mut state,
&mem,
"cache:\\foo",
crate::state::X_FILE_ATTRIBUTE_NORMAL,
0,
);
assert_eq!(status, STATUS_SUCCESS);
}
/// Phase C+12 — mount-time scan picks up files that already exist
/// on disk under the cache root (canary's `HostPathDevice::
/// PopulateEntry` analogue). The probe MUST succeed even though
/// no `NtCreateFile` ran this boot — this is exactly the canary
/// behaviour ours was missing at idx 102404.
#[test]
fn nt_query_full_attributes_file_resolves_preexisting_host_entry() {
let mut state = KernelState::new();
let dir = std::env::temp_dir().join(format!(
"xenia-rs-cache-test-c12pre-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
));
std::fs::create_dir_all(dir.join("d4ea4615").join("e")).unwrap();
std::fs::write(dir.join("d4ea4615").join("e").join("46ee8ca"), b"oracle").unwrap();
// `set_cache_root` performs the eager scan.
state.set_cache_root(dir.clone());
// Wire up scratch + initial thread (mirrors `fresh()`).
let mut mem = GuestMemory::new().expect("memory init");
mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
.expect("scratch page must commit");
state.install_initial_thread(
PpcContext::default(),
0x7000_0000,
0x10_0000,
SCRATCH_BASE + 0x800,
SCRATCH_BASE + 0xC00,
0x1000,
&mut mem,
);
state.scheduler.begin_slot_visit(0);
let status = assert_query_attrs_struct(
&mut state,
&mem,
"cache:\\d4ea4615\\e\\46ee8ca",
crate::state::X_FILE_ATTRIBUTE_NORMAL,
6, // strlen("oracle")
);
assert_eq!(status, STATUS_SUCCESS);
// Directory probe must also resolve (mount-time scan inserts
// both files and dirs).
let status_dir = assert_query_attrs_struct(
&mut state,
&mem,
"cache:\\d4ea4615",
crate::state::X_FILE_ATTRIBUTE_DIRECTORY,
0,
);
assert_eq!(status_dir, STATUS_SUCCESS);
std::fs::remove_dir_all(&dir).ok();
}
/// Phase C+12 — pin the FILETIME conversion: a known Unix epoch
/// value (`1_700_000_000` seconds = 2023-11-14 22:13:20 UTC)
/// converts to the expected Windows FILETIME tick count.
#[test]
fn unix_to_filetime_known_value() {
let t = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let ft = crate::state::unix_to_filetime(t);
// (1_700_000_000 + 11_644_473_600) * 10_000_000 = 133_444_736_000_000_000
assert_eq!(ft, 133_444_736_000_000_000);
}
/// Phase C+12 — `change_time` slot (offset 24) MUST equal
/// `last_write_time` (offset 16), mirroring canary's
/// `xboxkrnl_io.cc:504` line `file_info->change_time =
/// entry->write_timestamp();`. This is the only field where the
/// brief's "4 distinct FILETIMEs" framing differs from canary's
/// actual semantics.
#[test]
fn nt_query_full_attributes_file_change_time_equals_write_time() {
let (mut ctx, mem, mut state) = fresh();
let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\writeme");
let handle_out = SCRATCH_BASE + 0x400;
let iosb = SCRATCH_BASE + 0x410;
ctx.gpr[1] = (SCRATCH_BASE + 0x700) as u64;
mem.write_u32(SCRATCH_BASE + 0x700 + 0x54, FILE_SYNCHRONOUS_IO_NONALERT);
ctx.gpr[3] = handle_out as u64;
ctx.gpr[5] = obj_attrs as u64;
ctx.gpr[6] = iosb as u64;
ctx.gpr[10] = FILE_OVERWRITE_IF as u64;
nt_create_file(&mut ctx, &mem, &mut state);
let out = SCRATCH_BASE + 0x300;
ctx.gpr[3] = obj_attrs as u64;
ctx.gpr[4] = out as u64;
nt_query_full_attributes_file(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
let last_write = mem.read_u64(out + 16);
let change = mem.read_u64(out + 24);
assert_eq!(
change, last_write,
"change_time must equal last_write_time per canary xboxkrnl_io.cc:504"
);
}
/// 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<u32> = [
0x82506528, 0x82506558, 0x82506588, 0x825065B8,
]
.into_iter()
.collect();
let mut seen_entries: std::collections::HashSet<u32> =
std::collections::HashSet::new();
let mut ctx_addrs: std::collections::HashSet<u32> =
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);
}
}