Files
xenia-rs/crates/xenia-kernel/src/exports.rs
MechaCat02 b20c99f141 [Subsystem-fixes] 6 verified ours-vs-canary divergence fixes
From the 2026-06-12 5-subsystem differential audit. All verified against
canary as oracle; 660/660 workspace tests green (655 + 5 new).

1. nt_create_event polarity (exports.rs) — `manual_reset = gpr[5] != 0`
   was INVERTED. Canary xboxkrnl_threading.cc:668 `Initialize(!event_type,..)`
   + xevent.cc:41 (type 0 = NotificationEvent = manual, type 1 = Sync = auto).
   Now `== 0`. Was the dormant 2.AI fix on chore/portable-snapshot, never
   merged. The Ke-path was already correct; only the Nt-path was wrong.

2. 2.AF deadline drain (main.rs coord_pre_round) — expired KeWait/KeDelay
   deadlines never fired under load because advance_to_next_wake_if_due was
   only called in coord_idle_advance (no-Ready-threads path). Added a
   per-round drain loop; covers BOTH lockstep and parallel outer loops since
   both call coord_pre_round. Was the dormant 2.AF fix, never merged.

3. handle slab-recycle ABA guard (state.rs + scheduler.rs) — release_handle_slot
   (my round-34 regression) recycled a closed slot even with a thread still
   parked on it, risking a stale-waiter wake when the slot is re-minted. Added
   Scheduler::any_thread_waiting_on; decline to recycle a still-waited slot.

4. vpkpx pixel-pack (vmx.rs) — wrong field mapping (~100% mismatch). Now
   exact canary ppc_emit_altivec.cc:1795 shift/mask (red 6b out[15:10] from
   w[24:19], green out[9:5] from w[14:10], blue out[4:0] from w[7:3]; no
   fabricated alpha bit). +unit test.

5. VFS GDFX attribute plumbing (vfs/*, exports.rs query fns) — VfsEntry now
   carries the real on-disc attribute byte (GDFX dirent +12, canary
   disc_image_device.cc:136/154) instead of inferring directory-ness from
   path shape. Query exports report the real FILE_ATTRIBUTE_* bits. Candidate
   driver of the XamShowDirtyDiscErrorUI gate. +tests.

6. MmGetPhysicalAddress region-aware mirror (exports.rs) — flat 0x1FFFFFFF
   mask missed canary's +0x1000 host_address_offset for 0xE0000000+ mirror
   (memory.cc:2317). Read-only query; proven byte-identical 50M digest. +test.

Investigated and intentionally NOT changed:
- zero-on-recommit: no-op; ours has no region-reuse path (bump allocators,
  free is a stub).
- 32-bit ALU writeback truncation (PPCBUG-020): documented-deliberate; premise
  (MSR.SF=0) is questionable but flipping it is out of scope here.
- KeSetEvent/NtSetEvent return value: ours returns true previous state
  (hardware-faithful); canary returns constant 1 — NOT an ours bug.

sylpheed_n50m golden will need re-baselining (legit behavior change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 14:57:38 +02:00

6504 lines
267 KiB
Rust
Raw 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);
state.register_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
state.register_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);
state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor);
state.register_export(Xboxkrnl, 0x83, "KeQueryPerformanceFrequency", ke_query_performance_frequency);
state.register_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", stub_return_zero);
state.register_export(Xboxkrnl, 0x88, "KeReleaseSemaphore", ke_release_semaphore);
state.register_export(Xboxkrnl, 0x89, "KeReleaseSpinLockFromRaisedIrql", ke_release_spinlock_from_raised_irql);
state.register_export(Xboxkrnl, 0x8F, "KeResetEvent", ke_reset_event);
state.register_export(Xboxkrnl, 0x92, "KeResumeThread", ke_resume_thread);
state.register_export(Xboxkrnl, 0x97, "KeSetAffinityThread", ke_set_affinity_thread);
state.register_export(Xboxkrnl, 0x98, "KeSetIdealProcessor", ke_set_ideal_processor);
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_export(Xboxkrnl, 0xB3, "KfLowerIrql", stub_success);
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
state.register_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
state.register_export(Xboxkrnl, 0x011B, "RtlCompareMemoryUlong", rtl_compare_memory_ulong);
state.register_export(Xboxkrnl, 0x0125, "RtlEnterCriticalSection", rtl_enter_critical_section);
state.register_export(Xboxkrnl, 0x0126, "RtlFillMemoryUlong", rtl_fill_memory_ulong);
state.register_export(Xboxkrnl, 0x0127, "RtlFreeAnsiString", stub_success);
state.register_export(Xboxkrnl, 0x012B, "RtlImageXexHeaderField", rtl_image_xex_header_field);
state.register_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
state.register_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);
state.register_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
state.register_export(Xboxkrnl, 0x013F, "RtlTimeFieldsToTime", stub_success);
state.register_export(Xboxkrnl, 0x0140, "RtlTimeToTimeFields", stub_success);
state.register_export(Xboxkrnl, 0x0141, "RtlTryEnterCriticalSection", rtl_try_enter_critical_section);
state.register_export(Xboxkrnl, 0x0142, "RtlUnicodeStringToAnsiString", stub_success);
state.register_export(Xboxkrnl, 0x0143, "RtlUnicodeToMultiByteN", stub_success);
state.register_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
state.register_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);
// Stfs
state.register_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
state.register_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_success);
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", stub_return_zero);
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_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success);
state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", stub_success);
state.register_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
state.register_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;
}
// ===== 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);
}
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,
);
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;
}
}
}
/// `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;
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
&& 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);
}
}
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;
}
/// `KeSetIdealProcessor(thread_handle, proc_number) -> u8 old_ideal` —
/// Axis 5. Stores the hint on the `GuestThread` for future spawn-sibling
/// placement; does NOT migrate a live thread (use `KeSetAffinityThread`
/// for that).
fn ke_set_ideal_processor(
ctx: &mut PpcContext,
_mem: &GuestMemory,
state: &mut KernelState,
) {
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let ideal = ctx.gpr[4] as u8;
let prev = state
.scheduler
.find_by_handle(handle)
.map(|r| state.scheduler.set_ideal_ref(r, ideal))
.unwrap_or(0xFF);
ctx.gpr[3] = prev as u64;
}
fn ke_query_ideal_processor(
ctx: &mut PpcContext,
_mem: &GuestMemory,
state: &mut KernelState,
) {
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let ideal = state
.scheduler
.find_by_handle(handle)
.and_then(|r| state.scheduler.ideal_ref(r))
.unwrap_or(0);
ctx.gpr[3] = ideal as u64;
}
/// `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_handle, new_mask) -> old_mask` — Axis 4.
/// Drives `KernelState::set_affinity` which delegates to the scheduler
/// and then fixes up every outstanding `ThreadRef` held in waiter lists.
fn ke_set_affinity_thread(
ctx: &mut PpcContext,
mem: &GuestMemory,
state: &mut KernelState,
) {
let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let new_mask = (ctx.gpr[4] as u32) as u8;
let old = state.set_affinity(handle, new_mask, mem);
ctx.gpr[3] = old as u64;
}
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);
}
}
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
}
fn ex_get_xconfig_setting(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
ctx.gpr[3] = 0; // STATUS_SUCCESS (writes nothing)
}
// ===== 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;
if size == 0 {
tracing::warn!(flags, "MmAllocatePhysicalMemoryEx: zero-size request → returning 0");
ctx.gpr[3] = 0;
return;
}
match state.heap_alloc(size, mem) {
Some(addr) => {
tracing::debug!(
flags,
size = format_args!("{size:#x}"),
addr = format_args!("{addr:#010x}"),
"MmAllocatePhysicalMemoryEx"
);
ctx.gpr[3] = addr as u64;
}
None => {
tracing::warn!(
flags,
size = format_args!("{size:#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;
}
}
}
/// Region-aware guest-virtual → physical translation, matching canary's
/// `Memory::GetPhysicalAddress` + `PhysicalHeap::GetPhysicalAddress`
/// (`xenia-canary/src/xenia/memory.cc:528-545` and `:2317-2326`).
///
/// Canary `PhysicalHeap::GetPhysicalAddress`:
/// ```c
/// address -= heap_base_;
/// if (heap_base_ >= 0xE0000000) { address += 0x1000; }
/// return address;
/// ```
/// The three physical heap bases (0xA0000000 / 0xC0000000 / 0xE0000000) all
/// alias the same 512 MB physical window, so `address - heap_base ==
/// address & 0x1FFFFFFF` for each. The only region-specific delta is the
/// `+0x1000` host-address-offset for the 0xE0000000+ 4 KB mirror — see
/// `memory.h:368-372` (`host_address_offset` for `heap_base >= 0xE0000000`).
/// For non-physical / sub-0x1FFFFFFF virtual addresses canary returns the
/// address unchanged, which equals `address & 0x1FFFFFFF` there too.
pub(crate) fn translate_physical_address(virt: u32) -> u32 {
let phys = virt & 0x1FFF_FFFF;
if virt >= 0xE000_0000 {
phys + 0x1000
} else {
phys
}
}
fn mm_get_physical_address(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
// r3 = virtual address -> return physical address.
// Region-aware, mirroring canary (see `translate_physical_address`).
ctx.gpr[3] = translate_physical_address(ctx.gpr[3] as u32) as u64;
}
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;
/// `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;
/// 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;
let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0;
// Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
// cache root directory itself. Mirror canary's HostPathDevice.Open
// which returns a directory handle (success, attributes = DIR).
// Empty `path.file_name()` after our resolve_cache_path strip means
// the guest asked for the mount root.
let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
|| host_path.is_dir()
|| want_dir;
if is_dir_open {
// For non-existent paths the guest wants us to create as a
// directory, mkdir-p; canary's HostPathDevice does the same
// when FILE_DIRECTORY_FILE is set on a kCreate disposition.
if want_dir && !host_path.exists() {
if let Err(e) = std::fs::create_dir_all(host_path) {
tracing::warn!(
"cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
host_path,
e
);
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)
};
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,
});
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 size = host_path
.metadata()
.map(|m| m.len())
.unwrap_or(0);
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()),
});
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;
/// 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();
// AUDIT-2.BF — synthetic silph::WorkerCtx spawn. AUDIT-058/059
// identified that ours never activates the 6-level static caller
// ladder that ends in `sub_825070F0`, so the four worker threads
// it would normally spawn (entries 0x82506528/58/88/B8) never run.
// Canary's chain originally fires right after `DiscImageDevice::
// ResolvePath("\\dat\\movie")` (audit-058); ours never opens
// `dat/movie` because tid=13 wedges before reaching it. We
// therefore trigger on the first `dat/*` open — the earliest
// such open in ours is `dat/files.tbl` (immediately preceding
// tid=12/13 spawn at audit-059 round 1).
//
// **Round 18 finding** (this commit): when the workers are
// spawned runnable, they fault almost immediately (`PC=0` at
// cycle ~5.5M on the hw thread carrying worker_3), preempting
// ours' boot before the normal guest threads even spawn. The
// ctx layout from audit-059 round 5 is incomplete — at least
// one of `[+0x28]`/`[+0x2C]`/`[+0x30]` (the three foreign-
// arena pointers) must be populated for the worker bodies to
// run. Synthesising those is a fresh investigation (round 19+).
//
// Until then the synth path is **opt-in**: set
// `XENIA_SILPH_SYNTH=1` to enable the runnable spawn (will
// crash boot), or `XENIA_SILPH_SYNTH=suspend` to spawn but keep
// them in `Blocked(Suspended)` (lets boot complete with the
// ctx materialised in memory for downstream probes). Default:
// disabled — preserves the existing boot trajectory.
if !state.silph_synth_done && path.starts_with("dat/") {
match std::env::var("XENIA_SILPH_SYNTH").as_deref() {
Ok("1") | Ok("run") | Ok("runnable") => {
let _ = crate::silph_synth::spawn_silph_workers(state, mem, false);
}
Ok("suspend") | Ok("suspended") => {
let _ = crate::silph_synth::spawn_silph_workers(state, mem, true);
}
_ => {}
}
}
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,
});
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,
});
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) => {
// When the VFS can't resolve a path we synthesize a zero-byte
// virtual file rather than returning NOT_FOUND. Two rationales:
//
// 1. **Writable system partitions** (`cache:/`, `cache0:`,
// `cache1:`, `partition0:`, `partition1:`) aren't backed by
// the disc — Canary mounts them on host directories
// ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)).
// We skip the host mount for now, so opens there always miss
// without this fallback.
//
// 2. **Disc files that didn't make it into the ISO rip** (e.g.,
// Sylpheed's `dat/files.tbl`, which the retail disc shipped
// but our dump doesn't contain). Returning NOT_FOUND makes
// Sylpheed's boot validator call `XamShowDirtyDiscErrorUI`
// → dashboard exit; see Canary's `XamShowDirtyDiscErrorUI`
// at xam_ui.cc:562 for the "bad or unimplemented file IO
// calls" framing.
//
// A zero-byte file lets the game's existence probe succeed, its
// read return EOF, and its "is the content here" sanity checks
// pass. If the game actually needs the bytes for gameplay we'll
// see a fresh failure downstream and can decide what to stub next.
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,
});
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) {
// r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
// r6 = io_status_block, r7 = share_access, r8 = open_options.
// `NtOpenFile` is FILE_OPEN-only (no create) — file must exist.
// Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options`
// straight into NtCreateFile's `create_options` slot, so the
// FILE_DIRECTORY_FILE bit applies the same way.
let handle_out = ctx.gpr[3] as u32;
let obj_attrs_ptr = ctx.gpr[5] as u32;
let io_status_block = ctx.gpr[6] as u32;
let open_options = ctx.gpr[8] as u32;
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
let handle = ctx.gpr[3] as u32;
let event_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:/).
let handle = ctx.gpr[3] as u32;
let event_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
};
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;
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;
}
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;
let event_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
let 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
};
// Snapshot what we need from the handle, then drop the borrow so we can
// re-resolve the path against the VFS for its real attribute byte.
let path = path.clone();
let size = live_size;
let position = *position;
// Pull the REAL GDFX attribute byte (canary `disc_image_device.cc:154`)
// for disc-backed handles by re-resolving the stored path. Root-of-device
// opens (`game:\`, `cache:\`, `partition0`) strip to an empty string and
// synth-stub opens have no VFS entry — for those we fall back to the
// path-shape heuristic. 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`.
let vfs_attributes: Option<u32> = if path.is_empty() {
None
} else {
state
.vfs
.as_ref()
.and_then(|vfs| vfs.stat(&path).ok())
.map(|e| e.attributes)
.filter(|&a| a != 0)
};
let is_directory = match vfs_attributes {
Some(a) => (a & 0x10) != 0,
None => path.is_empty() || path.ends_with('/') || path.ends_with(':'),
};
// `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);
// Prefer the real GDFX attribute byte; fall back to the
// DIRECTORY/NORMAL split for root-of-device and synth-stub
// handles that have no VFS entry.
let attrs = match vfs_attributes {
Some(a) => a,
None if is_directory => FILE_ATTRIBUTE_DIRECTORY,
None => 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;
}
/// `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. The only class with real side-effects in xenia-rs is
/// `XFilePositionInformation` (14) — seek updates the file's cursor.
/// Read-only VFS means `XFileEndOfFileInformation` (20, truncate) can
/// only succeed if the new length equals the current size, otherwise
/// returns `STATUS_UNSUCCESSFUL`. Other classes acknowledge the write
/// but have no backing store.
fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle, r4 = io_status_block, r5 = info_ptr,
// r6 = info_length, r7 = info_class.
let handle = ctx.gpr[3] as u32;
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;
}
// 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;
}
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;
}
};
// AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly
// so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`)
// see real attributes for files we just created and miss for files we
// haven't.
if let Some(hp) = state.resolve_cache_path(&path) {
let entry = std::fs::metadata(&hp);
match entry {
Ok(md) => {
let filetime: u64 = 132_500_000_000_000_000;
if out != 0 {
for off in (0..32).step_by(4) {
mem.write_u32(out + off, if off & 4 == 0 {
(filetime >> 32) as u32
} else {
filetime as u32
});
}
mem.write_u64(out + 32, md.len());
mem.write_u64(out + 40, md.len());
let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 };
mem.write_u32(out + 48, attrs);
mem.write_u32(out + 52, 0);
}
ctx.gpr[3] = STATUS_SUCCESS;
return;
}
Err(_) => {
ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
return;
}
}
}
let Some(vfs) = state.vfs.as_ref() else {
ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
return;
};
match vfs.stat(&path) {
Ok(entry) => {
// FILE_NETWORK_OPEN_INFORMATION (56 bytes): 4 × FILETIME,
// AllocationSize(i64), EndOfFile(i64), FileAttributes(u32), pad(u32)
let filetime: u64 = 132_500_000_000_000_000;
if out != 0 {
mem.write_u32(out, (filetime >> 32) as u32);
mem.write_u32(out + 4, filetime as u32);
mem.write_u32(out + 8, (filetime >> 32) as u32);
mem.write_u32(out + 12, filetime as u32);
mem.write_u32(out + 16, (filetime >> 32) as u32);
mem.write_u32(out + 20, filetime as u32);
mem.write_u32(out + 24, (filetime >> 32) as u32);
mem.write_u32(out + 28, filetime as u32);
mem.write_u64(out + 32, entry.size);
mem.write_u64(out + 40, entry.size);
// Use the REAL GDFX attribute byte forwarded by the VFS
// (canary `disc_image_device.cc:154`) instead of a
// path-shape guess. Disc rips never carry a 0-attribute
// entry, but guard anyway so a synthesised/legacy entry
// still advertises a sane DIRECTORY/NORMAL split.
let attrs: u32 = if entry.attributes != 0 {
entry.attributes
} else if entry.is_directory {
0x10
} else {
0x80
};
mem.write_u32(out + 48, attrs);
mem.write_u32(out + 52, 0);
}
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.
let handle = ctx.gpr[3] as u32;
let event_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,
attributes: e.attributes,
})
})
.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);
// Real GDFX attribute byte (canary `disc_image_device.cc:154`);
// fall back to the directory/normal split only for legacy entries
// that carry no attribute bits.
let attrs = if entry.attributes != 0 {
entry.attributes
} else 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;
// Aliased refcount: `NtDuplicateObject` returns the *source* handle as the
// "new" handle (we don't mint fresh values), so the game commonly holds
// two logical references to the same handle value. Without refcount, the
// first `NtClose` wipes the object while the second reference is still
// live, which traps any later wait on that handle (Sylpheed's
// create→dup(SAME_ACCESS)→set→close pattern at 0x8246079c manifests this
// — main thread then parks forever on the closed handle). Mirror Canary's
// `ObjectTable::ReleaseHandle` (object_table.cc:189): decrement the
// per-handle refcount and only drop the object when it reaches zero.
let remaining = state
.handle_refcount
.get_mut(&handle)
.map(|c| {
*c = c.saturating_sub(1);
*c
})
.unwrap_or(0);
if remaining == 0 {
state.objects.remove(&handle);
state.handle_refcount.remove(&handle);
// If the object was an armed Timer, strip its pending-fire entry
// so a later scheduler round doesn't try to signal a dead handle.
// `disarm_timer` is a no-op for non-timer handles.
state.disarm_timer(handle);
// AUDIT-059 R34: return the slot to the recycle FIFO so a later
// `alloc_handle` mints the same ID (matching canary's slab).
state.release_handle_slot(handle);
}
ctx.gpr[3] = 0;
}
fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle_ptr, r4 = obj_attrs, r5 = event_type, r6 = initial_state.
//
// Xenon DISPATCHER_HEADER `Type` (NT convention):
// 0 = NotificationEvent (manual-reset)
// 1 = SynchronizationEvent (auto-reset)
// Canary: `xboxkrnl_threading.cc:668` `ev->Initialize(!event_type, !!initial_state)`
// with `XEvent::Initialize(bool manual_reset, ...)` (xevent.cc:25) and
// `InitializeNative` (xevent.cc:41 `case 0x00: manual_reset_ = true`).
// So `manual_reset = (event_type == 0)`. The Ke-path
// (`ensure_dispatcher_object`) was already correct; the Nt-path here was
// inverted, mis-classifying Sylpheed's per-frame VSync gate (type=1 auto +
// initial=1) as manual-reset+signaled → it stayed signaled forever and
// tid=1's main loop spun ~2800x canary's 60Hz.
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",
);
// ITERATE-2C Phase D — audit-049 auto-signal POC. Env-gated; no-op
// when `XENIA_SILPH_UI_AUTOSIGNAL_DELAY` is unset.
state.maybe_register_silph_autosignal(handle, ctx, mem);
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;
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_ptr, r4 = field_id
// Return 0 for all fields
ctx.gpr[3] = 0;
}
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) {
// Simple mapping for common cases
let status = ctx.gpr[3] as u32;
ctx.gpr[3] = match status {
0 => 0, // ERROR_SUCCESS
0xC000_0034 => 2, // ERROR_FILE_NOT_FOUND
0xC000_0011 => 38, // ERROR_HANDLE_EOF
_ => status as u64, // Pass through
};
}
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;
}
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);
}
tracing::info!(
"XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}",
index, callback_pc, callback_arg, wrapped, driver_id,
state.xaudio.worker_handles[index],
);
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;
}
// ===== 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 {
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,
}
}
/// 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,
};
state.objects.insert(ptr, obj);
// 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);
}
/// 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;
}
}
/// 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) {
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 {
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);
}
}
return;
} else {
waiters.remove(0)
}
}
_ => return,
};
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);
}
// continue loop for semaphores that may wake more
}
}
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);
let previous = match state.objects.get_mut(&h) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = true;
prev as u32
}
_ => 0,
};
state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64);
wake_eligible_waiters(state, h);
ctx.gpr[3] = previous as u64;
}
fn ke_reset_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let h = ctx.gpr[3] as u32;
ensure_dispatcher_object(state, mem, h);
let previous = match state.objects.get_mut(&h) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = false;
prev as u32
}
_ => 0,
};
ctx.gpr[3] = previous as u64;
}
fn nt_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let handle = ctx.gpr[3] as u32;
let prev_ptr = ctx.gpr[4] as u32;
let previous = match state.objects.get_mut(&handle) {
Some(KernelObject::Event { signaled, .. }) => {
let prev = *signaled;
*signaled = true;
prev as u32
}
_ => 0,
};
state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64);
wake_eligible_waiters(state, handle);
if prev_ptr != 0 {
mem.write_u32(prev_ptr, previous);
}
ctx.gpr[3] = STATUS_SUCCESS;
}
fn nt_clear_event(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let 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);
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 {
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,
};
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 timeout_ptr = ctx.gpr[6] as u32;
do_wait_single(ctx, state, handle, timeout_ptr, mem);
}
/// `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 timeout_ptr = ctx.gpr[7] as u32;
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
let 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
let 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 allocates a fresh handle id that refcounts the same underlying
/// `XObject`. We don't refcount, so we alias: write the *source* handle back
/// as the "new" handle. The game then uses it interchangeably, and both ids
/// resolve to the same `KernelObject` entry.
///
/// 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 source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
let out_ptr = ctx.gpr[4] as u32;
let options = ctx.gpr[5] as u32;
if !state.objects.contains_key(&source) {
if out_ptr != 0 {
mem.write_u32(out_ptr, 0);
}
ctx.gpr[3] = STATUS_INVALID_HANDLE;
return;
}
if out_ptr != 0 {
mem.write_u32(out_ptr, source);
}
// Aliased-handle refcount: since we return the source handle as the "new"
// handle (no fresh id), every duplicate must bump the per-handle refcount
// so the later `NtClose` pair (one for source, one for dup) doesn't
// destroy the object mid-flight. `DUPLICATE_CLOSE_SOURCE` (bit 0) closes
// the source in Canary (xboxkrnl_ob.cc:389), so in our aliased model the
// source-close cancels the dup-gain: net refcount is unchanged. Without
// `CLOSE_SOURCE`, both the source and the dup are separately live and we
// need +1.
const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
if options & DUPLICATE_CLOSE_SOURCE == 0
&& let Some(c) = state.handle_refcount.get_mut(&source)
{
*c += 1;
}
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();
// 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).
ctx.gpr[3] = 0x2000;
ctx.gpr[4] = 0x20; // slot 5 only
ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
// Return value = previous mask = 0x02.
assert_eq!(ctx.gpr[3], 0x02);
// 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);
}
/// Axis 5: `KeSetIdealProcessor` stores a hint on the thread
/// without migrating it; query round-trips.
#[test]
fn ke_set_ideal_processor_round_trips() {
let (mut ctx, mut mem, mut state) = fresh();
// Main thread handle is 0x1000.
ctx.gpr[3] = 0x1000;
ctx.gpr[4] = 3;
ke_set_ideal_processor(&mut ctx, &mut mem, &mut state);
// Prior was 0xFF (unset sentinel).
assert_eq!(ctx.gpr[3], 0xFF);
ctx.gpr[3] = 0x1000;
ke_query_ideal_processor(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], 3);
}
/// 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");
}
/// 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,
attributes: 0x81, // NORMAL | READONLY
},
xenia_vfs::VfsEntry {
name: "dat".into(),
is_directory: true,
size: 0,
offset: 0,
attributes: 0x11, // DIRECTORY | READONLY
},
// A grandchild — must NOT appear in root enumeration.
xenia_vfs::VfsEntry {
name: "dat/tables.pak".into(),
is_directory: false,
size: 0x2000,
offset: 0,
attributes: 0x81,
},
],
}));
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();
let mut attrs: Vec<u32> = Vec::new();
loop {
let entry_base = buf + cursor;
let name_len = mem.read_u32(entry_base + 0x3C) as usize;
attrs.push(mem.read_u32(entry_base + 0x38));
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"]);
// The real GDFX attribute byte must be forwarded verbatim: the file
// reports NORMAL|READONLY (no DIRECTORY bit), the directory reports
// DIRECTORY|READONLY.
assert_eq!(attrs, vec![0x81, 0x11]);
assert_eq!(attrs[0] & 0x10, 0, "file must not advertise DIRECTORY");
assert_ne!(attrs[1] & 0x10, 0, "dir must advertise DIRECTORY");
// 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; gpr[3] reports previous=1.
assert_eq!(ctx.gpr[3], 1, "previous state must be reported");
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);
}
/// 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;
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();
}
/// `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();
}
/// `MmGetPhysicalAddress` must be region-aware, matching canary's
/// `PhysicalHeap::GetPhysicalAddress`: the 0xE0000000+ 4 KB mirror gets a
/// `+0x1000` host-address-offset; every other region is a flat
/// `& 0x1FFFFFFF` mask.
#[test]
fn mm_get_physical_address_region_aware() {
// 0xE0000000 mirror: canary `address - heap_base (==addr & 0x1FFFFFFF)`
// then `+ 0x1000`.
assert_eq!(translate_physical_address(0xE000_0000), 0x0000_1000);
assert_eq!(translate_physical_address(0xE000_5000), 0x0000_6000);
assert_eq!(translate_physical_address(0xFFFF_F000), 0x1FFF_F000 + 0x1000);
// 0xA0000000 / 0xC0000000 physical heaps: flat mask, no offset.
assert_eq!(translate_physical_address(0xA000_0000), 0x0000_0000);
assert_eq!(translate_physical_address(0xC012_3000), 0x0012_3000);
// Virtual / already-physical (< 0x20000000): unchanged.
assert_eq!(translate_physical_address(0x0012_3000), 0x0012_3000);
assert_eq!(translate_physical_address(0x4012_3000), 0x0012_3000);
}
}