Files
xenia-rs/crates/xenia-kernel/src/exports.rs
MechaCat02 07068e7616 feat(audio): APUBUG-PRODUCER-001 — XAudio register driver client + opt-in callback ticker
Replace the three XAudio kernel-export stubs (Register/Unregister/SubmitFrame)
with canary-faithful implementations and add a periodic buffer-complete
callback ticker reusing the existing SavedCallbackCtx injection machinery.

Canary parity:
- xboxkrnl_audio.cc:56-93 — read callback_ptr[0..1], wrap callback_arg in a
  4-byte big-endian guest heap buffer (`wrapped_callback_arg`), write
  `0x4155_xxxx` to *driver_ptr.
- audio_system.cc:139-141 — guest callback receives r3 = wrapped pointer,
  not raw callback_arg.
- audio_driver.h:21-24 — frame rate 256 samples / 48 kHz ≈ 5.33 ms.

Implementation:
- New `crates/xenia-kernel/src/xaudio.rs` — `XAudioClient`, `XAudioState`
  (8-slot table, pending FIFO, dual-mode ticker), `XAUDIO_INSTR_PERIOD =
  48_000` (lockstep) and `XAUDIO_PERIOD = 5.333 ms` (--parallel), same
  pattern as KRNBUG-D08 v-sync.
- `try_inject_audio_callback` in xenia-app mirrors `try_inject_graphics_interrupt`,
  shares `interrupts.saved` slot for mutex with graphics callbacks.

Gating: ticker + injector run only when `--xaudio-tick` /
`XENIA_XAUDIO_TICK=1`. Default off because Sylpheed's audio callback
enters an infinite `KeWaitForSingleObject` loop on first invocation
(canary's host worker thread provides the buffer-completion fence we
don't model), which hijacks a guest HW thread and regresses
`swaps=2 → 1`. Default-off preserves the lockstep `sylpheed_n*m.json`
goldens exactly.

Producer hunt outcome (FALSIFIED for parked handles 0x1004/0x100c/0x15e4):
at `-n 500M --xaudio-tick` all 3 handles still show
`signal_attempts=0 (primary=0, ghost=0)`. Audio callback is not the
missing producer. Next candidate per audit-findings.md is Timer DPC
delivery (KeSetTimer / KeInsertQueueDpc).

Tests: 562 → 576 green (10 in `xaudio.rs`, 4 in `exports.rs`).
Lockstep `--stable-digest -n 100M` default-off: instructions=100000002,
swaps=2 (matches pre-change baseline byte-for-byte).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:50:22 +02:00

5338 lines
218 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", stub_success);
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", stub_return_zero);
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;
}
}
}
fn mm_get_physical_address(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
// r3 = virtual address -> return physical address
ctx.gpr[3] &= 0x1FFF_FFFF; // Mask to 512MB physical
}
fn mm_query_address_protect(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
// Return PAGE_READWRITE (0x04)
ctx.gpr[3] = 0x04;
}
fn mm_query_statistics(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = stats_ptr — write fake memory statistics
let ptr = ctx.gpr[3] as u32;
if ptr != 0 {
// Total physical = 512MB
mem.write_u32(ptr + 0x04, 512 * 1024 * 1024); // TotalPhysicalPages (in bytes)
mem.write_u32(ptr + 0x10, 256 * 1024 * 1024); // AvailablePages
}
ctx.gpr[3] = 0;
}
// ===== File I/O =====
/// NT error codes the file handlers need. Keeping them inline avoids pulling
/// in a whole NTSTATUS module for a single file.
const STATUS_SUCCESS: u64 = 0x0000_0000;
const STATUS_END_OF_FILE: u64 = 0xC000_0011;
const STATUS_INVALID_HANDLE: u64 = 0xC000_0008;
const STATUS_OBJECT_NAME_NOT_FOUND: u64 = 0xC000_0034;
const STATUS_NO_MORE_FILES: u64 = 0x8000_0006;
const STATUS_SEMAPHORE_LIMIT_EXCEEDED: u64 = 0xC000_0047;
const STATUS_UNSUCCESSFUL: u64 = 0xC000_0001;
const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003;
const STATUS_INFO_LENGTH_MISMATCH: u64 = 0xC000_0004;
/// `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);
}
/// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the
/// create/open distinction only matters for writable volumes, which the disc
/// image isn't.
fn open_vfs_file(
mem: &GuestMemory,
state: &mut KernelState,
handle_out: u32,
io_status_block: u32,
obj_attrs_ptr: 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();
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,
});
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;
}
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,
});
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,
});
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
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;
ctx.gpr[3] = open_vfs_file(mem, state, handle_out, io_status_block, obj_attrs_ptr);
}
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
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;
ctx.gpr[3] = open_vfs_file(mem, state, handle_out, io_status_block, obj_attrs_ptr);
}
/// 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, .. }) = 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
};
let total = *size;
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) {
// We don't back anything writable, so discard. Still report the full
// length as written via IO_STATUS_BLOCK so the caller doesn't retry.
let event_handle = ctx.gpr[4] as u32;
let io_status_block = ctx.gpr[7] as u32;
let length = ctx.gpr[9] as u32;
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);
}
/// 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, .. }) = 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;
};
// Root-of-device opens (`game:\`, `cache:\`, `partition0`) strip to
// an empty string post-prefix — see `open_vfs_file`'s synth path.
// Games query these as directories (DirectoryObject probe), and
// reporting `Directory=0` makes Sylpheed treat the open as "found a
// non-directory where I expected a directory" and call
// `XamShowDirtyDiscErrorUI`. Canary's `NtQueryInformationFile` pulls
// the real file-system entry's kind; we key on path shape since we
// don't model directory entries.
let is_directory = path.is_empty()
|| path.ends_with('/')
|| path.ends_with(':');
let size = *size;
let position = *position;
// `FILE_ATTRIBUTE_DIRECTORY` (NT / Xbox) — advertised in
// `FileNetworkOpenInformation.FileAttributes`; Sylpheed's async-I/O
// worker queries with class=34 and the calling code checks this bit
// to decide whether the open resolved to a directory before
// continuing down the non-error path.
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
let written: u32 = match class {
// FileStandardInformation: AllocationSize(i64), EndOfFile(i64), NumberOfLinks(u32), DeletePending(u8), Directory(u8), pad(u16)
5 if length >= 24 => {
mem.write_u64(file_info, size);
mem.write_u64(file_info + 8, size);
mem.write_u32(file_info + 16, 1);
mem.write_u8(file_info + 20, 0);
mem.write_u8(file_info + 21, if is_directory { 1 } else { 0 });
mem.write_u16(file_info + 22, 0);
24
}
// FilePositionInformation: CurrentByteOffset(i64)
14 if length >= 8 => {
mem.write_u64(file_info, position);
8
}
// FileNetworkOpenInformation: timestamps(4x i64) @ 0..32,
// AllocationSize(i64) @ 32, EndOfFile(i64) @ 40, FileAttributes(u32) @ 48
// Sylpheed's async-validation worker asks for this (`length=56`)
// and the caller checks `FileAttributes & FILE_ATTRIBUTE_DIRECTORY`
// right after. Without populating the attributes the bit is
// clear, the caller decides the open "found a non-directory
// where a directory was expected", and the outer routine calls
// `XamShowDirtyDiscErrorUI` → `XamLoaderLaunchTitle` → garbage.
34 if length >= 56 => {
// Zero timestamps (we don't track real times).
for off in (0..32).step_by(8) {
mem.write_u64(file_info + off, 0);
}
mem.write_u64(file_info + 32, size);
mem.write_u64(file_info + 40, size);
let attrs = if is_directory {
FILE_ATTRIBUTE_DIRECTORY
} else {
FILE_ATTRIBUTE_NORMAL
};
mem.write_u32(file_info + 48, attrs);
mem.write_u32(file_info + 52, 0); // pad
56
}
_ => {
// Zero out whatever the caller asked for — conservative default.
for i in 0..length {
mem.write_u8(file_info + i, 0);
}
length
}
};
write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, written);
ctx.gpr[3] = STATUS_SUCCESS;
}
/// `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, .. }) = 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. Read-only VFS
// → only a no-op truncate-to-same-size succeeds.
20 => {
let new_eof = mem.read_u64(info_ptr);
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;
}
};
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);
let attrs: u32 = 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_0000); // ~2GB at 2KB sectors
mem.write_u64(info + 8, 0);
mem.write_u32(info + 16, 1);
mem.write_u32(info + 20, 2048);
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,
})
})
.collect(),
None => Vec::new(),
};
// Load / initialise the enumeration cursor.
let start_index = match state.objects.get_mut(&handle) {
Some(KernelObject::File { dir_enum_pos, .. }) => {
let pos = dir_enum_pos.unwrap_or(0);
*dir_enum_pos = Some(pos);
pos
}
_ => 0,
};
if start_index >= entries.len() {
if iosb_ptr != 0 {
write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0);
}
ctx.gpr[3] = STATUS_NO_MORE_FILES;
signal_io_completion_event(state, event_handle);
return;
}
// Pack as many entries as fit into `length`. `NextEntryOffset` is the
// byte distance to the next entry from the start of the current one;
// 0 marks the last entry. Entries are 8-byte aligned per Canary.
let mut cursor: u32 = 0;
let mut emitted: usize = 0;
let mut last_entry_offset: Option<u32> = None;
for (i, entry) in entries.iter().enumerate().skip(start_index) {
let name_bytes = entry.name.as_bytes();
let name_len = name_bytes.len() as u32;
let raw_size = ENTRY_FIXED_SIZE + name_len;
let aligned_size = (raw_size + 7) & !7;
if cursor + raw_size > length {
// Entry wouldn't fit — leave the buffer truncated and stop.
break;
}
let base = info_ptr + cursor;
mem.write_u32(base + 0x00, 0); // next_entry_offset (patched later)
mem.write_u32(base + 0x04, i as u32); // file_index
// Timestamps zeroed — xenia-rs doesn't track them.
mem.write_u64(base + 0x08, 0);
mem.write_u64(base + 0x10, 0);
mem.write_u64(base + 0x18, 0);
mem.write_u64(base + 0x20, 0);
mem.write_u64(base + 0x28, entry.size);
mem.write_u64(base + 0x30, entry.size);
let attrs = if entry.is_directory {
FILE_ATTRIBUTE_DIRECTORY
} else {
FILE_ATTRIBUTE_NORMAL
};
mem.write_u32(base + 0x38, attrs);
mem.write_u32(base + 0x3C, name_len);
for (k, &b) in name_bytes.iter().enumerate() {
mem.write_u8(base + ENTRY_FIXED_SIZE + k as u32, b);
}
// Patch the previous entry's next_entry_offset to point here.
if let Some(prev_base) = last_entry_offset {
mem.write_u32(prev_base + 0x00, cursor - (prev_base - info_ptr));
}
last_entry_offset = Some(base);
cursor = std::cmp::min(cursor + aligned_size, length);
emitted += 1;
if cursor + ENTRY_FIXED_SIZE > length {
// No room for another fixed header; stop before truncating.
break;
}
}
// Advance cursor on the handle.
if let Some(KernelObject::File { dir_enum_pos, .. }) = state.objects.get_mut(&handle) {
*dir_enum_pos = Some(start_index + emitted);
}
if emitted == 0 {
if iosb_ptr != 0 {
write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0);
}
ctx.gpr[3] = STATUS_NO_MORE_FILES;
} else {
if iosb_ptr != 0 {
write_io_status_block(mem, iosb_ptr, STATUS_SUCCESS as u32, cursor);
}
ctx.gpr[3] = STATUS_SUCCESS;
}
signal_io_completion_event(state, event_handle);
}
fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
let handle = ctx.gpr[3] as u32;
// 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);
}
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
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(
handle,
if manual_reset { "Event/Manual" } else { "Event/Auto" },
ctx.lr as u32,
"NtCreateEvent",
);
if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle);
}
ctx.gpr[3] = 0;
}
fn nt_create_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// r3 = handle_ptr, r4 = obj_attrs, r5 = initial_count, r6 = max_count
let handle_ptr = ctx.gpr[3] as u32;
let count = ctx.gpr[5] as i32;
let max = ctx.gpr[6] as i32;
let handle = state.alloc_handle_for(KernelObject::Semaphore {
count,
max,
waiters: Vec::new(),
});
state.audit_create(handle, "Semaphore", ctx.lr as u32, "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(
handle,
if timer_type == 0 { "Timer/Manual" } else { "Timer/Auto" },
ctx.lr as u32,
"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);
}
tracing::info!(
"XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x}",
index, callback_pc, callback_arg, wrapped, driver_id,
);
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 =====
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);
}
/// 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) {
// r3 = thread_ptr (KTHREAD). We don't track KTHREAD ↔ HW mapping through
// guest memory addresses, so accept and succeed. Real NtResumeThread
// below handles the handle-based path properly.
ctx.gpr[3] = 0;
let _ = state;
}
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,
})
}
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);
}
/// 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");
}
#[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,
});
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,
});
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,
});
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = 0;
ctx.gpr[7] = 0;
ctx.gpr[8] = SCRATCH_BASE as u64;
ctx.gpr[9] = 16; // below 72
ctx.gpr[10] = 0;
nt_query_directory_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_INFO_LENGTH_MISMATCH);
}
/// Minimal `VfsDevice` impl that returns a hard-coded entry list —
/// lets us drive `NtQueryDirectoryFile` through a real enumeration
/// path without needing a disc image on disk.
struct StubVfs {
entries: Vec<xenia_vfs::VfsEntry>,
}
impl xenia_vfs::VfsDevice for StubVfs {
fn name(&self) -> &str { "stub" }
fn list_root(&self) -> Result<Vec<xenia_vfs::VfsEntry>, xenia_vfs::VfsError> {
Ok(self.entries.clone())
}
fn read_file(&self, _path: &str) -> Result<Vec<u8>, xenia_vfs::VfsError> {
Err(xenia_vfs::VfsError::NotFound("stub".into()))
}
fn stat(&self, _path: &str) -> Result<xenia_vfs::VfsEntry, xenia_vfs::VfsError> {
Err(xenia_vfs::VfsError::NotFound("stub".into()))
}
}
/// Real enumeration of the root directory. The stub VFS exposes two
/// top-level entries and one nested entry; `NtQueryDirectoryFile`
/// must return the two top-level ones and skip the grandchild.
#[test]
fn nt_query_directory_file_enumerates_root_children() {
let (mut ctx, mut mem, mut state) = fresh();
state.vfs = Some(Box::new(StubVfs {
entries: vec![
xenia_vfs::VfsEntry {
name: "default.xex".into(),
is_directory: false,
size: 0x1000,
offset: 0,
},
xenia_vfs::VfsEntry {
name: "dat".into(),
is_directory: true,
size: 0,
offset: 0,
},
// A grandchild — must NOT appear in root enumeration.
xenia_vfs::VfsEntry {
name: "dat/tables.pak".into(),
is_directory: false,
size: 0x2000,
offset: 0,
},
],
}));
let handle = state.alloc_handle_for(KernelObject::File {
path: String::new(),
size: 0,
position: 0,
data: std::sync::Arc::new(Vec::new()),
dir_enum_pos: None,
});
let buf = SCRATCH_BASE + 0x100;
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = 0;
ctx.gpr[7] = SCRATCH_BASE as u64;
ctx.gpr[8] = buf as u64;
ctx.gpr[9] = 512;
ctx.gpr[10] = 0;
nt_query_directory_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
// First entry header lives at `buf`: file_name_length at +0x3C,
// attributes at +0x38, name bytes starting at +0x40. Verify both
// entries land in the buffer by walking the linked list via
// NextEntryOffset.
let mut cursor: u32 = 0;
let mut names: Vec<String> = Vec::new();
loop {
let entry_base = buf + cursor;
let name_len = mem.read_u32(entry_base + 0x3C) as usize;
let mut bytes = Vec::with_capacity(name_len);
for i in 0..name_len as u32 {
bytes.push(mem.read_u8(entry_base + 0x40 + i));
}
names.push(String::from_utf8(bytes).unwrap());
let next = mem.read_u32(entry_base);
if next == 0 {
break;
}
cursor += next;
}
assert_eq!(names, vec!["default.xex", "dat"]);
// A second call on the same handle must return NO_MORE_FILES —
// the cursor has advanced past the end.
ctx.gpr[3] = handle as u64;
ctx.gpr[4] = 0;
ctx.gpr[7] = SCRATCH_BASE as u64;
ctx.gpr[8] = buf as u64;
ctx.gpr[9] = 512;
nt_query_directory_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_NO_MORE_FILES);
}
/// Invalid handle → STATUS_INVALID_HANDLE, IOSB gets the error, and
/// the completion event still fires so callers don't hang.
#[test]
fn nt_query_directory_file_invalid_handle_still_signals() {
let (mut ctx, mut mem, mut state) = fresh();
let evt = make_event(&mut state);
ctx.gpr[3] = 0xDEAD_BEEF;
ctx.gpr[4] = evt as u64;
ctx.gpr[7] = SCRATCH_BASE as u64;
ctx.gpr[8] = SCRATCH_BASE as u64 + 0x100;
ctx.gpr[9] = 128;
ctx.gpr[10] = 0;
nt_query_directory_file(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
assert_eq!(mem.read_u32(SCRATCH_BASE), STATUS_INVALID_HANDLE as u32);
assert!(event_signaled(&state, evt));
}
/// `NtSignalAndWaitForSingleObjectEx` signals handle A, then does a
/// single wait on handle B. If A is already signaled via the atomic
/// set, any waiter on A wakes immediately; the caller then parks on
/// B (or returns success if B is already signaled). Canary reference:
/// `xboxkrnl_threading.cc:1103` — `XObject::SignalAndWait`.
#[test]
fn nt_signal_and_wait_signals_first_then_waits() {
let (mut ctx, mut mem, mut state) = fresh();
// Pre-signaled event we'll wait on — so the whole call returns success.
let wait_h = state.alloc_handle_for(KernelObject::Event {
manual_reset: true,
signaled: true,
waiters: Vec::new(),
});
let signal_h = make_event(&mut state); // starts unsignaled
ctx.gpr[3] = signal_h as u64;
ctx.gpr[4] = wait_h as u64;
ctx.gpr[7] = 0; // timeout_ptr = null → infinite, but wait-handle already signaled
nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], 0, "wait returns STATUS_SUCCESS");
assert!(event_signaled(&state, signal_h), "signal handle set");
}
/// An unknown signal handle must return `STATUS_INVALID_HANDLE` and
/// NOT fall through to the wait — matches Canary's early-return guard.
#[test]
fn nt_signal_and_wait_rejects_unknown_signal_handle() {
let (mut ctx, mut mem, mut state) = fresh();
ctx.gpr[3] = 0xDEAD_BEEF;
ctx.gpr[4] = 0x1234_5678;
ctx.gpr[7] = 0;
nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
}
/// `FileNetworkOpenInformation` (class 34) — 56 bytes, `FileAttributes`
/// at +48 must carry `FILE_ATTRIBUTE_DIRECTORY` (0x10) for root synths.
/// Sylpheed's async worker asks for this class and the caller dispatches
/// on the attributes bits; a zeroed buffer meant `Directory` was clear
/// and forced the dirty-disc path.
#[test]
fn nt_query_information_file_network_open_sets_dir_attribute() {
let (mut ctx, mut mem, mut state) = fresh();
let h = state.alloc_handle_for(KernelObject::File {
path: String::new(),
size: 0,
position: 0,
data: std::sync::Arc::new(Vec::new()),
dir_enum_pos: None,
});
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,
});
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");
}
// ===== 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");
}
/// `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");
}
/// `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());
}
/// 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);
}
}