//! HLE kernel export implementations (xboxkrnl.exe).
//! Each export mirrors a function from xboxkrnl_table.inc.

use crate::objects::KernelObject;
use crate::state::{GuestMemoryPcr, KernelState, ModuleId};
use crate::thread::allocate_thread_image;
use xenia_cpu::scheduler::{BlockReason, SpawnParams};
use xenia_cpu::{PpcContext, ThreadRef};
use xenia_memory::{GuestMemory, MemoryAccess};

// NTSTATUS constants used by wait/sync paths.
const STATUS_TIMEOUT: u64 = 0x0000_0102;

pub fn register_exports(state: &mut KernelState) {
    use ModuleId::Xboxkrnl;

    // Debug
    state.register_export(Xboxkrnl, 0x01, "DbgBreakPoint", dbg_break_point);
    state.register_export(Xboxkrnl, 0x03, "DbgPrint", dbg_print);

    // ExCreateThread and friends
    state.register_export(Xboxkrnl, 0x0D, "ExCreateThread", ex_create_thread);
    state.register_export(Xboxkrnl, 0x10, "ExGetXConfigSetting", ex_get_xconfig_setting);
    state.register_export(Xboxkrnl, 0x15, "ExRegisterTitleTerminateNotification", stub_success);
    state.register_export(Xboxkrnl, 0x19, "ExTerminateThread", ex_terminate_thread);

    // Hal
    state.register_export(Xboxkrnl, 0x28, "HalReturnToFirmware", hal_return_to_firmware);

    // I/O
    state.register_export(Xboxkrnl, 0x3C, "IoDismountVolumeByFileHandle", stub_success);

    // Ke* Threading/Sync
    state.register_export(Xboxkrnl, 0x4D, "KeAcquireSpinLockAtRaisedIrql", stub_return_zero);
    state.register_export(Xboxkrnl, 0x52, "KeBugCheck", ke_bug_check);
    state.register_export(Xboxkrnl, 0x53, "KeBugCheckEx", ke_bug_check_ex);
    state.register_export(Xboxkrnl, 0x5A, "KeDelayExecutionThread", ke_delay_execution_thread);
    state.register_export(Xboxkrnl, 0x5D, "KeEnableFpuExceptions", stub_success);
    state.register_export(Xboxkrnl, 0x5F, "KeEnterCriticalRegion", stub_success);
    state.register_export(Xboxkrnl, 0x66, "KeGetCurrentProcessType", ke_get_current_process_type);
    state.register_export(Xboxkrnl, 0x6B, "KeLockL2", stub_success);
    state.register_export(Xboxkrnl, 0x6C, "KeUnlockL2", stub_success);
    state.register_export(Xboxkrnl, 0x74, "KeInitializeSemaphore", ke_initialize_semaphore);
    state.register_export(Xboxkrnl, 0x7D, "KeLeaveCriticalRegion", stub_success);
    state.register_export(Xboxkrnl, 0x7F, "KePulseEvent", ke_pulse_event);
    state.register_export(Xboxkrnl, 0x81, "KeQueryBasePriorityThread", ke_query_base_priority_thread);
    state.register_export(Xboxkrnl, 0x82, "KeQueryIdealProcessor", ke_query_ideal_processor);
    state.register_export(Xboxkrnl, 0x83, "KeQueryPerformanceFrequency", ke_query_performance_frequency);
    state.register_export(Xboxkrnl, 0x84, "KeQuerySystemTime", ke_query_system_time);
    state.register_export(Xboxkrnl, 0x85, "KeRaiseIrqlToDpcLevel", stub_return_zero);
    state.register_export(Xboxkrnl, 0x88, "KeReleaseSemaphore", ke_release_semaphore);
    state.register_export(Xboxkrnl, 0x89, "KeReleaseSpinLockFromRaisedIrql", ke_release_spinlock_from_raised_irql);
    state.register_export(Xboxkrnl, 0x8F, "KeResetEvent", ke_reset_event);
    state.register_export(Xboxkrnl, 0x92, "KeResumeThread", ke_resume_thread);
    state.register_export(Xboxkrnl, 0x97, "KeSetAffinityThread", ke_set_affinity_thread);
    state.register_export(Xboxkrnl, 0x98, "KeSetIdealProcessor", ke_set_ideal_processor);
    state.register_export(Xboxkrnl, 0x99, "KeSetBasePriorityThread", ke_set_base_priority_thread);
    state.register_export(Xboxkrnl, 0x9B, "KeSetCurrentStackPointers", stub_success);
    state.register_export(Xboxkrnl, 0x9D, "KeSetEvent", ke_set_event);
    state.register_export(Xboxkrnl, 0xAE, "KeTryToAcquireSpinLockAtRaisedIrql", ke_try_acquire_spinlock);
    state.register_export(Xboxkrnl, 0xAF, "KeWaitForMultipleObjects", ke_wait_for_multiple_objects);
    state.register_export(Xboxkrnl, 0xB0, "KeWaitForSingleObject", ke_wait_for_single_object);
    state.register_export(Xboxkrnl, 0xB1, "KfAcquireSpinLock", kf_acquire_spin_lock);
    state.register_export(Xboxkrnl, 0xB3, "KfLowerIrql", stub_success);
    state.register_export(Xboxkrnl, 0xB4, "KfReleaseSpinLock", kf_release_spin_lock);
    state.register_export(Xboxkrnl, 0x0152, "KeTlsAlloc", ke_tls_alloc);
    state.register_export(Xboxkrnl, 0x0153, "KeTlsFree", stub_success);
    state.register_export(Xboxkrnl, 0x0154, "KeTlsGetValue", ke_tls_get_value);
    state.register_export(Xboxkrnl, 0x0155, "KeTlsSetValue", ke_tls_set_value);
    state.register_export(Xboxkrnl, 0x01DF, "KiApcNormalRoutineNop", stub_success);

    // Memory
    state.register_export(Xboxkrnl, 0xBA, "MmAllocatePhysicalMemoryEx", mm_allocate_physical_memory_ex);
    state.register_export(Xboxkrnl, 0xBB, "MmCreateKernelStack", mm_create_kernel_stack);
    state.register_export(Xboxkrnl, 0xBC, "MmDeleteKernelStack", stub_success);
    state.register_export(Xboxkrnl, 0xBD, "MmFreePhysicalMemory", stub_success);
    state.register_export(Xboxkrnl, 0xBE, "MmGetPhysicalAddress", mm_get_physical_address);
    state.register_export(Xboxkrnl, 0xC4, "MmQueryAddressProtect", mm_query_address_protect);
    state.register_export(Xboxkrnl, 0xC6, "MmQueryStatistics", mm_query_statistics);

    // Nt*
    state.register_export(Xboxkrnl, 0xCC, "NtAllocateVirtualMemory", nt_allocate_virtual_memory);
    state.register_export(Xboxkrnl, 0xCD, "NtCancelTimer", nt_cancel_timer);
    state.register_export(Xboxkrnl, 0xCE, "NtClearEvent", nt_clear_event);
    state.register_export(Xboxkrnl, 0xCF, "NtClose", nt_close);
    state.register_export(Xboxkrnl, 0xD1, "NtCreateEvent", nt_create_event);
    state.register_export(Xboxkrnl, 0xD2, "NtCreateFile", nt_create_file);
    state.register_export(Xboxkrnl, 0xD5, "NtCreateSemaphore", nt_create_semaphore);
    state.register_export(Xboxkrnl, 0xD7, "NtCreateTimer", nt_create_timer);
    state.register_export(Xboxkrnl, 0xD9, "NtDeviceIoControlFile", nt_device_io_control_file);
    state.register_export(Xboxkrnl, 0xDA, "NtDuplicateObject", nt_duplicate_object);
    state.register_export(Xboxkrnl, 0xDB, "NtFlushBuffersFile", stub_success);
    state.register_export(Xboxkrnl, 0xDC, "NtFreeVirtualMemory", stub_success);
    state.register_export(Xboxkrnl, 0xDF, "NtOpenFile", nt_open_file);
    state.register_export(Xboxkrnl, 0xE2, "NtPulseEvent", nt_pulse_event);
    state.register_export(Xboxkrnl, 0xE4, "NtQueryDirectoryFile", nt_query_directory_file);
    state.register_export(Xboxkrnl, 0xE7, "NtQueryFullAttributesFile", nt_query_full_attributes_file);
    state.register_export(Xboxkrnl, 0xE8, "NtQueryInformationFile", nt_query_information_file);
    state.register_export(Xboxkrnl, 0xEE, "NtQueryVirtualMemory", stub_success);
    state.register_export(Xboxkrnl, 0xEF, "NtQueryVolumeInformationFile", nt_query_volume_information_file);
    state.register_export(Xboxkrnl, 0xF0, "NtReadFile", nt_read_file);
    state.register_export(Xboxkrnl, 0xF3, "NtReleaseSemaphore", nt_release_semaphore);
    state.register_export(Xboxkrnl, 0xF5, "NtResumeThread", nt_resume_thread);
    state.register_export(Xboxkrnl, 0xF6, "NtSetEvent", nt_set_event);
    state.register_export(Xboxkrnl, 0xF7, "NtSetInformationFile", nt_set_information_file);
    state.register_export(Xboxkrnl, 0xFA, "NtSetTimerEx", nt_set_timer_ex);
    // NOTE: `NtSetInformationThread` is NOT in xboxkrnl_table.inc on
    // Xbox 360 — canary confirms ordinal 0xFB is
    // `NtSignalAndWaitForSingleObjectEx`. The prior registration at 0xFB
    // was silently overwritten by the registration below; the
    // `nt_set_information_thread` body is retained for the direct-call
    // unit test but no longer exposed as an ordinal.
    state.register_export(Xboxkrnl, 0xFC, "NtSuspendThread", nt_suspend_thread);
    state.register_export(Xboxkrnl, 0xFB, "NtSignalAndWaitForSingleObjectEx", nt_signal_and_wait_for_single_object_ex);
    state.register_export(Xboxkrnl, 0xFD, "NtWaitForSingleObjectEx", nt_wait_for_single_object_ex);
    state.register_export(Xboxkrnl, 0xFE, "NtWaitForMultipleObjectsEx", nt_wait_for_multiple_objects_ex);
    state.register_export(Xboxkrnl, 0xFF, "NtWriteFile", nt_write_file);
    state.register_export(Xboxkrnl, 0x0101, "NtYieldExecution", nt_yield_execution);

    // Object
    state.register_export(Xboxkrnl, 0x0103, "ObCreateSymbolicLink", stub_success);
    state.register_export(Xboxkrnl, 0x0104, "ObDeleteSymbolicLink", stub_success);
    state.register_export(Xboxkrnl, 0x0105, "ObDereferenceObject", stub_success);
    state.register_export(Xboxkrnl, 0x010B, "ObLookupThreadByThreadId", stub_success);
    state.register_export(Xboxkrnl, 0x010E, "ObOpenObjectByPointer", stub_success);
    state.register_export(Xboxkrnl, 0x0110, "ObReferenceObjectByHandle", ob_reference_object_by_handle);

    // RTL
    state.register_export(Xboxkrnl, 0x0119, "RtlCaptureContext", rtl_capture_context);
    state.register_export(Xboxkrnl, 0x011B, "RtlCompareMemoryUlong", rtl_compare_memory_ulong);
    state.register_export(Xboxkrnl, 0x0125, "RtlEnterCriticalSection", rtl_enter_critical_section);
    state.register_export(Xboxkrnl, 0x0126, "RtlFillMemoryUlong", rtl_fill_memory_ulong);
    state.register_export(Xboxkrnl, 0x0127, "RtlFreeAnsiString", stub_success);
    state.register_export(Xboxkrnl, 0x012B, "RtlImageXexHeaderField", rtl_image_xex_header_field);
    state.register_export(Xboxkrnl, 0x012C, "RtlInitAnsiString", rtl_init_ansi_string);
    state.register_export(Xboxkrnl, 0x012D, "RtlInitUnicodeString", rtl_init_unicode_string);
    state.register_export(Xboxkrnl, 0x012E, "RtlInitializeCriticalSection", rtl_initialize_critical_section);
    state.register_export(Xboxkrnl, 0x012F, "RtlInitializeCriticalSectionAndSpinCount", rtl_initialize_critical_section);
    state.register_export(Xboxkrnl, 0x0130, "RtlLeaveCriticalSection", rtl_leave_critical_section);
    state.register_export(Xboxkrnl, 0x0133, "RtlMultiByteToUnicodeN", rtl_multi_byte_to_unicode_n);
    state.register_export(Xboxkrnl, 0x0135, "RtlNtStatusToDosError", rtl_nt_status_to_dos_error);
    state.register_export(Xboxkrnl, 0x0136, "RtlRaiseException", rtl_raise_exception);
    state.register_export(Xboxkrnl, 0x013B, "sprintf", stub_sprintf);
    state.register_export(Xboxkrnl, 0x013F, "RtlTimeFieldsToTime", stub_success);
    state.register_export(Xboxkrnl, 0x0140, "RtlTimeToTimeFields", stub_success);
    state.register_export(Xboxkrnl, 0x0141, "RtlTryEnterCriticalSection", rtl_try_enter_critical_section);
    state.register_export(Xboxkrnl, 0x0142, "RtlUnicodeStringToAnsiString", stub_success);
    state.register_export(Xboxkrnl, 0x0143, "RtlUnicodeToMultiByteN", stub_success);
    state.register_export(Xboxkrnl, 0x0147, "RtlUnwind", rtl_unwind);
    state.register_export(Xboxkrnl, 0x014D, "_vsnprintf", stub_vsnprintf);

    // Stfs
    state.register_export(Xboxkrnl, 0x0259, "StfsCreateDevice", stub_success);
    state.register_export(Xboxkrnl, 0x025A, "StfsControlDevice", stub_success);

    // Video
    state.register_export(Xboxkrnl, 0x01B1, "VdCallGraphicsNotificationRoutines", stub_success);
    state.register_export(Xboxkrnl, 0x01B4, "VdEnableDisableClockGating", stub_success);
    state.register_export(Xboxkrnl, 0x01B6, "VdEnableRingBufferRPtrWriteBack", vd_enable_ring_buffer_rptr_writeback);
    state.register_export(Xboxkrnl, 0x01B9, "VdGetCurrentDisplayGamma", vd_get_current_display_gamma);
    state.register_export(Xboxkrnl, 0x01BA, "VdGetCurrentDisplayInformation", stub_success);
    state.register_export(Xboxkrnl, 0x01BD, "VdGetSystemCommandBuffer", vd_get_system_command_buffer);
    state.register_export(Xboxkrnl, 0x01C2, "VdInitializeEngines", stub_success);
    state.register_export(Xboxkrnl, 0x01C3, "VdInitializeRingBuffer", vd_initialize_ring_buffer);
    state.register_export(Xboxkrnl, 0x01C5, "VdInitializeScalerCommandBuffer", stub_success);
    state.register_export(Xboxkrnl, 0x01C6, "VdIsHSIOTrainingSucceeded", vd_is_hsio_training_succeeded);
    state.register_export(Xboxkrnl, 0x01C7, "VdPersistDisplay", stub_success);
    state.register_export(Xboxkrnl, 0x01C9, "VdQueryVideoFlags", stub_return_zero);
    state.register_export(Xboxkrnl, 0x01CA, "VdQueryVideoMode", vd_query_video_mode);
    state.register_export(Xboxkrnl, 0x0269, "VdRetrainEDRAM", stub_success);
    state.register_export(Xboxkrnl, 0x026A, "VdRetrainEDRAMWorker", stub_success);
    state.register_export(Xboxkrnl, 0x01D3, "VdSetDisplayMode", stub_success);
    state.register_export(Xboxkrnl, 0x01D5, "VdSetGraphicsInterruptCallback", vd_set_graphics_interrupt_callback);
    state.register_export(Xboxkrnl, 0x01D9, "VdSetSystemCommandBufferGpuIdentifierAddress", stub_success);
    state.register_export(Xboxkrnl, 0x01DC, "VdShutdownEngines", stub_success);
    state.register_export(Xboxkrnl, 0x025B, "VdSwap", vd_swap);

    // Audio
    state.register_export(Xboxkrnl, 0x01F3, "XAudioRegisterRenderDriverClient", xaudio_register_render_driver);
    state.register_export(Xboxkrnl, 0x01F4, "XAudioUnregisterRenderDriverClient", xaudio_unregister_render_driver);
    state.register_export(Xboxkrnl, 0x01F5, "XAudioSubmitRenderDriverFrame", xaudio_submit_render_driver_frame);
    state.register_export(Xboxkrnl, 0x01F7, "XAudioGetVoiceCategoryVolumeChangeMask", stub_return_zero);
    state.register_export(Xboxkrnl, 0x01F8, "XAudioGetVoiceCategoryVolume", stub_success);
    state.register_export(Xboxkrnl, 0x0224, "XMACreateContext", xma_create_context);
    state.register_export(Xboxkrnl, 0x0226, "XMAReleaseContext", xma_release_context);

    // Crypto
    state.register_export(Xboxkrnl, 0x0192, "XeCryptSha", stub_success);
    state.register_export(Xboxkrnl, 0x0256, "XeKeysConsolePrivateKeySign", stub_success);
    state.register_export(Xboxkrnl, 0x0257, "XeKeysConsoleSignatureVerification", stub_success);

    // Xex module
    state.register_export(Xboxkrnl, 0x0194, "XexCheckExecutablePrivilege", xex_check_executable_privilege);
    state.register_export(Xboxkrnl, 0x0195, "XexGetModuleHandle", xex_get_module_handle);
    state.register_export(Xboxkrnl, 0x0197, "XexGetProcedureAddress", xex_get_procedure_address);

    // Exception handling
    state.register_export(Xboxkrnl, 0x01A5, "__C_specific_handler", c_specific_handler);
}

// ===== Generic stubs =====

fn stub_success(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    ctx.gpr[3] = 0; // STATUS_SUCCESS
}

fn stub_return_zero(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    ctx.gpr[3] = 0;
}

// ===== Debug =====

fn dbg_break_point(_ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    tracing::warn!("DbgBreakPoint hit");
}

fn dbg_print(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    let str_ptr = ctx.gpr[3] as u32;
    if str_ptr != 0 {
        let s = read_cstring(mem, str_ptr);
        tracing::info!("DbgPrint: {}", s);
    }
    ctx.gpr[3] = 0;
}

// ===== Threading =====

/// `ExCreateThread(handle_ptr, stack_size, thread_id_ptr, xapi_startup,
///                 start_address, start_context, creation_flags)` —
/// signature per xenia-canary's xboxkrnl_threading.cc. Creation flags bit 0 =
/// CREATE_SUSPENDED; top 8 bits encode the affinity mask (logged, not
/// enforced under Model B with 1-instr quantum).
fn ex_create_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let handle_ptr = ctx.gpr[3] as u32;
    let stack_size = ctx.gpr[4] as u32;
    let thread_id_ptr = ctx.gpr[5] as u32;
    let start_address = ctx.gpr[7] as u32;
    let start_context = ctx.gpr[8] as u32;
    let creation_flags = ctx.gpr[9] as u32;

    let create_suspended = (creation_flags & 0x1) != 0;
    let affinity = (creation_flags >> 24) & 0xFF;

    let Some(image) = allocate_thread_image(state, mem, stack_size, 0) else {
        tracing::error!("ExCreateThread: failed to allocate thread image");
        ctx.gpr[3] = 0xC000_009A; // STATUS_INSUFFICIENT_RESOURCES
        return;
    };

    use std::sync::atomic::Ordering;
    let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed);
    let handle = state.alloc_handle_for(KernelObject::Thread {
        id: tid,
        hw_id: None,
        exit_code: None,
        waiters: Vec::new(),
    });

    let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed);
    let params = SpawnParams {
        entry: start_address,
        start_context,
        stack_base: image.stack_base,
        stack_size: image.stack_size,
        pcr_base: image.pcr_base,
        tls_base: image.tls_base,
        thread_handle: handle,
        guest_tid: tid,
        create_suspended,
        is_initial: false,
        tls_slot_count,
        affinity_mask: affinity as u8,
        priority: 0,
        ideal_processor: None,
    };
    let result = state.scheduler.spawn(params, &mut GuestMemoryPcr(mem));
    match result {
        Ok(hw_id) => {
            metrics::counter!("scheduler.spawn.ok").increment(1);
            if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
                *slot = Some(hw_id);
            }
            if handle_ptr != 0 {
                mem.write_u32(handle_ptr, handle);
            }
            if thread_id_ptr != 0 {
                mem.write_u32(thread_id_ptr, tid);
            }
            tracing::info!(
                "ExCreateThread: tid={} handle={:#x} hw={} entry={:#010x} start_ctx={:#010x} suspended={} aff={:#04x}",
                tid,
                handle,
                hw_id,
                start_address,
                start_context,
                create_suspended,
                affinity,
            );
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        Err(_) => {
            metrics::counter!("scheduler.spawn.rejected").increment(1);
            tracing::error!("ExCreateThread: no free HW thread slot");
            ctx.gpr[3] = 0xC000_009A;
        }
    }
}

/// `ExTerminateThread(exit_code)` — terminates the current guest thread. The
/// thread transitions to Exited and the main loop unschedules it. Joiners
/// waiting on the thread handle are woken with STATUS_SUCCESS.
fn ex_terminate_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let exit_code = ctx.gpr[3] as u32;
    let (hw_id, tid, handle_opt) = state.scheduler.exit_current(exit_code);
    tracing::info!(
        "ExTerminateThread: tid={:?} hw={} exit_code={}",
        tid,
        hw_id,
        exit_code
    );
    if let Some(handle) = handle_opt
        && let Some(KernelObject::Thread {
            exit_code: ec,
            waiters,
            ..
        }) = state.objects.get_mut(&handle)
        {
            *ec = Some(exit_code);
            let to_wake: Vec<ThreadRef> = std::mem::take(waiters);
            for w in to_wake {
                state.scheduler.wake_ref(w);
            }
        }
    tracing::debug!("ExTerminateThread: exit_status={:#x}", ctx.gpr[3]);
    ctx.gpr[3] = 0;
}

fn hal_return_to_firmware(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    tracing::warn!("HalReturnToFirmware: reason={:#x}", ctx.gpr[3]);
    ctx.gpr[3] = 0;
}

// ===== Ke* =====

/// `KeSetBasePriorityThread(thread_handle, priority) -> i32 old_priority` —
/// Axis 1 wiring. Sylpheed calls this from its worker-init prologue on
/// newly-created threads to bump them to time-critical / high. Storing the
/// value on the `GuestThread` makes `HwSlot::pick_runnable` honor it.
fn ke_set_base_priority_thread(
    ctx: &mut PpcContext,
    _mem: &GuestMemory,
    state: &mut KernelState,
) {
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let new_pri = ctx.gpr[4] as i32;
    let prev = state
        .scheduler
        .find_by_handle(handle)
        .map(|r| state.scheduler.set_priority_ref(r, new_pri))
        .unwrap_or(0);
    ctx.gpr[3] = prev as u32 as u64;
}

fn ke_query_base_priority_thread(
    ctx: &mut PpcContext,
    _mem: &GuestMemory,
    state: &mut KernelState,
) {
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let pri = state
        .scheduler
        .find_by_handle(handle)
        .map(|r| state.scheduler.priority_ref(r))
        .unwrap_or(0);
    ctx.gpr[3] = pri as u32 as u64;
}

/// `KeSetIdealProcessor(thread_handle, proc_number) -> u8 old_ideal` —
/// Axis 5. Stores the hint on the `GuestThread` for future spawn-sibling
/// placement; does NOT migrate a live thread (use `KeSetAffinityThread`
/// for that).
fn ke_set_ideal_processor(
    ctx: &mut PpcContext,
    _mem: &GuestMemory,
    state: &mut KernelState,
) {
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let ideal = ctx.gpr[4] as u8;
    let prev = state
        .scheduler
        .find_by_handle(handle)
        .map(|r| state.scheduler.set_ideal_ref(r, ideal))
        .unwrap_or(0xFF);
    ctx.gpr[3] = prev as u64;
}

fn ke_query_ideal_processor(
    ctx: &mut PpcContext,
    _mem: &GuestMemory,
    state: &mut KernelState,
) {
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let ideal = state
        .scheduler
        .find_by_handle(handle)
        .and_then(|r| state.scheduler.ideal_ref(r))
        .unwrap_or(0);
    ctx.gpr[3] = ideal as u64;
}

/// `NtSetInformationThread(handle, info_class, info_ptr, info_len)` —
/// minimal Axis 5 wiring for priority / affinity / ideal-processor
/// classes. Other classes return `STATUS_INVALID_INFO_CLASS`.
///
/// Not registered as an ordinal: Xbox 360's `xboxkrnl.exe` doesn't export
/// this function — canary's table assigns `0xFB` to
/// `NtSignalAndWaitForSingleObjectEx`. The body is retained only for the
/// direct-call unit test below.
#[allow(dead_code)]
fn nt_set_information_thread(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003;
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let info_class = ctx.gpr[4] as u32;
    let info_ptr = ctx.gpr[5] as u32;
    let info_len = ctx.gpr[6] as u32;
    let Some(r) = state.scheduler.find_by_handle(handle) else {
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        return;
    };
    match info_class {
        2 /* ThreadPriority */ if info_len >= 4 => {
            let pri = mem.read_u32(info_ptr) as i32;
            state.scheduler.set_priority_ref(r, pri);
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        3 /* ThreadAffinityMask */ if info_len >= 4 => {
            let mask = mem.read_u32(info_ptr) as u8;
            state.set_affinity(handle, mask, mem);
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        13 /* ThreadIdealProcessor */ if info_len >= 4 => {
            let ideal = mem.read_u32(info_ptr) as u8;
            state.scheduler.set_ideal_ref(r, ideal);
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        _ => {
            ctx.gpr[3] = STATUS_INVALID_INFO_CLASS;
        }
    }
}

/// `KeSetAffinityThread(thread_handle, new_mask) -> old_mask` — Axis 4.
/// Drives `KernelState::set_affinity` which delegates to the scheduler
/// and then fixes up every outstanding `ThreadRef` held in waiter lists.
fn ke_set_affinity_thread(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let new_mask = (ctx.gpr[4] as u32) as u8;
    let old = state.set_affinity(handle, new_mask, mem);
    ctx.gpr[3] = old as u64;
}

fn ke_bug_check(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    tracing::error!("KeBugCheck: code={:#x}", ctx.gpr[3]);
    ctx.gpr[3] = 0;
}

fn ke_bug_check_ex(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    tracing::error!("KeBugCheckEx: code={:#x} p1={:#x} p2={:#x} p3={:#x}",
        ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.gpr[6]);
    ctx.gpr[3] = 0;
}

fn ke_get_current_process_type(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    ctx.gpr[3] = 1; // PROC_USER
}

fn ke_query_performance_frequency(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    ctx.gpr[3] = 50_000_000; // 50 MHz
}

fn ke_query_system_time(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let time_ptr = ctx.gpr[3] as u32;
    if time_ptr != 0 {
        // ITERATE-2J — advance with the same deterministic clock the
        // KeTimeStampBundle uses (1 global_clock unit ≈ 100 ns) so a guest
        // that polls KeQuerySystemTime for elapsed time also sees forward
        // progress instead of a frozen constant. FILETIME base (~2021) +
        // 100-ns-unit clock.
        const FILETIME_BASE: u64 = 132_500_000_000_000_000;
        let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
        let now = state.now_basis_at(hw_id);
        let system_time = FILETIME_BASE.wrapping_add(now);
        mem.write_u32(time_ptr, (system_time >> 32) as u32);
        mem.write_u32(time_ptr + 4, system_time as u32);
    }
}

fn ke_initialize_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    // r3 = PKSEMAPHORE, r4 = initial count, r5 = limit.
    // Mirrors xenia-canary KeInitializeSemaphore_entry
    // (xboxkrnl_threading.cc:692). `ensure_dispatcher_object` (below)
    // reads type@+0, signal_state@+4, and limit@+0x10 to mint the
    // kernel-side shadow on first wait/release — so dropping the count
    // and limit args (the prior zero-fill) silently produced
    // `Semaphore { count: 0, max: 1 }` regardless of caller intent.
    let sem_ptr = ctx.gpr[3] as u32;
    let count = ctx.gpr[4] as u32;
    let limit = ctx.gpr[5] as u32;
    if sem_ptr == 0 {
        return;
    }
    // DISPATCHER_HEADER: type=5 (Semaphore), absolute=0, size=5 u32s,
    // inserted=0, signal_state=count, then 8-byte wait_list_head, then
    // limit at +0x10.
    mem.write_u8(sem_ptr, 5);
    mem.write_u8(sem_ptr + 0x01, 0);
    mem.write_u8(sem_ptr + 0x02, 5);
    mem.write_u8(sem_ptr + 0x03, 0);
    mem.write_u32(sem_ptr + 0x04, count);
    mem.write_u32(sem_ptr + 0x08, 0);
    mem.write_u32(sem_ptr + 0x0C, 0);
    mem.write_u32(sem_ptr + 0x10, limit);
}

fn ke_try_acquire_spinlock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    // r3 = KSPIN_LOCK*. Returns 1 (TRUE) on success. Single-threaded HLE
    // mirrors canary's `KeTryToAcquireSpinLockAtRaisedIrql`: write 1 to
    // the lock value (mark held) and return success. Under `--parallel`
    // the coarse Arc<Mutex<KernelState>> already serializes us.
    let lock_ptr = ctx.gpr[3] as u32;
    if lock_ptr != 0 {
        mem.write_u32(lock_ptr, 1);
    }
    ctx.gpr[3] = 1;
}

/// `KfAcquireSpinLock(KSPIN_LOCK *SpinLock)` — returns previous IRQL.
/// Per canary `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc`
/// the function raises IRQL to DISPATCH_LEVEL (2), spins on the lock,
/// then sets it to 1. Pre-fix this was `stub_return_zero` — guest code
/// could enter critical regions without ever taking the lock, leading
/// to subtle races even in lockstep when the same code path was
/// re-entered before the matching release fired. KRNBUG-017.
fn kf_acquire_spin_lock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    let lock_ptr = ctx.gpr[3] as u32;
    if lock_ptr != 0 {
        mem.write_u32(lock_ptr, 1);
    }
    // Old IRQL = PASSIVE_LEVEL (0). The new IRQL is DISPATCH_LEVEL (2),
    // tracked implicitly by the kf_release path. Returning 0 matches the
    // common-case "called from a passive-level routine" entry path.
    ctx.gpr[3] = 0;
}

/// `KfReleaseSpinLock(KSPIN_LOCK *SpinLock, KIRQL OldIrql)`.
/// Releases the spinlock and lowers IRQL to OldIrql. KRNBUG-017.
fn kf_release_spin_lock(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    let lock_ptr = ctx.gpr[3] as u32;
    if lock_ptr != 0 {
        mem.write_u32(lock_ptr, 0);
    }
    ctx.gpr[3] = 0;
}

/// `KeReleaseSpinLockFromRaisedIrql(KSPIN_LOCK *SpinLock)`.
/// Releases the spinlock without changing IRQL. KRNBUG-017.
fn ke_release_spinlock_from_raised_irql(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    _state: &mut KernelState,
) {
    let lock_ptr = ctx.gpr[3] as u32;
    if lock_ptr != 0 {
        mem.write_u32(lock_ptr, 0);
    }
    ctx.gpr[3] = 0;
}

fn ke_tls_alloc(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    ctx.gpr[3] = state.tls_alloc() as u64;
}

fn ke_tls_get_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let index = ctx.gpr[3] as u32;
    ctx.gpr[3] = state.tls_get(index);
}

fn ke_tls_set_value(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let index = ctx.gpr[3] as u32;
    let value = ctx.gpr[4];
    state.tls_set(index, value);
    ctx.gpr[3] = 1; // TRUE
}

fn ex_get_xconfig_setting(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    ctx.gpr[3] = 0; // STATUS_SUCCESS (writes nothing)
}

// ===== Memory =====

fn nt_allocate_virtual_memory(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = base_addr_ptr (in/out), r4 = region_size_ptr (in/out)
    // r5 = alloc_type, r6 = protect
    let base_ptr = ctx.gpr[3] as u32;
    let size_ptr = ctx.gpr[4] as u32;

    let requested_base = mem.read_u32(base_ptr);
    let requested_size = mem.read_u32(size_ptr);

    let aligned_size = (requested_size + 0xFFF) & !0xFFF;
    if aligned_size == 0 {
        ctx.gpr[3] = 0xC000_0010; // STATUS_INVALID_PARAMETER
        return;
    }

    let base = if requested_base != 0 {
        // Try to allocate at the requested address
        let protect = xenia_memory::page_table::MemoryProtect::READ
            | xenia_memory::page_table::MemoryProtect::WRITE;
        if mem.alloc(requested_base, aligned_size, protect).is_ok() {
            requested_base
        } else {
            // Already allocated? Treat as success (common for re-commit)
            requested_base
        }
    } else {
        // Allocate from heap
        match state.heap_alloc(aligned_size, mem) {
            Some(addr) => addr,
            None => {
                tracing::warn!("NtAllocateVirtualMemory: heap exhausted (size={:#x})", aligned_size);
                ctx.gpr[3] = 0xC000_0017; // STATUS_NO_MEMORY
                return;
            }
        }
    };

    mem.write_u32(base_ptr, base);
    mem.write_u32(size_ptr, aligned_size);
    tracing::info!("NtAllocateVirtualMemory: base={:#010x} size={:#x}", base, aligned_size);
    ctx.gpr[3] = 0; // STATUS_SUCCESS
}

fn mm_allocate_physical_memory_ex(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // Matches xenia-canary `MmAllocatePhysicalMemoryEx_entry` — see
    // `xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_memory.cc:489-494`.
    //   r3 = flags, r4 = region_size, r5 = protect_bits,
    //   r6 = min_addr_range, r7 = max_addr_range, r8 = alignment
    // Return value is the guest address; 0 indicates failure (Xbox ABI).
    let flags = ctx.gpr[3] as u32;
    let size = ctx.gpr[4] as u32;
    if size == 0 {
        tracing::warn!(flags, "MmAllocatePhysicalMemoryEx: zero-size request → returning 0");
        ctx.gpr[3] = 0;
        return;
    }
    match state.heap_alloc(size, mem) {
        Some(addr) => {
            tracing::debug!(
                flags,
                size = format_args!("{size:#x}"),
                addr = format_args!("{addr:#010x}"),
                "MmAllocatePhysicalMemoryEx"
            );
            ctx.gpr[3] = addr as u64;
        }
        None => {
            tracing::warn!(
                flags,
                size = format_args!("{size:#x}"),
                "MmAllocatePhysicalMemoryEx: heap exhausted"
            );
            ctx.gpr[3] = 0;
        }
    }
}

fn mm_create_kernel_stack(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // xenia-canary `MmCreateKernelStack_entry(stack_size, r4)`; returns stack top.
    // `xboxkrnl_threading.cc` — see DECLARE_XBOXKRNL_EXPORT on MmCreateKernelStack.
    let requested = ctx.gpr[3] as u32;
    let size = std::cmp::max(requested, 0x4000); // Min 16KB per canary
    match state.stack_alloc(size, mem) {
        Some(top) => {
            tracing::info!(
                top = format_args!("{top:#010x}"),
                size = format_args!("{size:#x}"),
                "MmCreateKernelStack"
            );
            ctx.gpr[3] = top as u64;
        }
        None => {
            tracing::warn!(size = format_args!("{size:#x}"), "MmCreateKernelStack: stack heap exhausted");
            ctx.gpr[3] = 0;
        }
    }
}

/// Region-aware guest-virtual → physical translation, matching canary's
/// `Memory::GetPhysicalAddress` + `PhysicalHeap::GetPhysicalAddress`
/// (`xenia-canary/src/xenia/memory.cc:528-545` and `:2317-2326`).
///
/// Canary `PhysicalHeap::GetPhysicalAddress`:
/// ```c
///   address -= heap_base_;
///   if (heap_base_ >= 0xE0000000) { address += 0x1000; }
///   return address;
/// ```
/// The three physical heap bases (0xA0000000 / 0xC0000000 / 0xE0000000) all
/// alias the same 512 MB physical window, so `address - heap_base ==
/// address & 0x1FFFFFFF` for each. The only region-specific delta is the
/// `+0x1000` host-address-offset for the 0xE0000000+ 4 KB mirror — see
/// `memory.h:368-372` (`host_address_offset` for `heap_base >= 0xE0000000`).
/// For non-physical / sub-0x1FFFFFFF virtual addresses canary returns the
/// address unchanged, which equals `address & 0x1FFFFFFF` there too.
pub(crate) fn translate_physical_address(virt: u32) -> u32 {
    let phys = virt & 0x1FFF_FFFF;
    if virt >= 0xE000_0000 {
        phys + 0x1000
    } else {
        phys
    }
}

fn mm_get_physical_address(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    // r3 = virtual address -> return physical address.
    // Region-aware, mirroring canary (see `translate_physical_address`).
    ctx.gpr[3] = translate_physical_address(ctx.gpr[3] as u32) as u64;
}

fn mm_query_address_protect(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    // Return PAGE_READWRITE (0x04)
    ctx.gpr[3] = 0x04;
}

fn mm_query_statistics(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    // r3 = stats_ptr — write fake memory statistics
    let ptr = ctx.gpr[3] as u32;
    if ptr != 0 {
        // Total physical = 512MB
        mem.write_u32(ptr + 0x04, 512 * 1024 * 1024); // TotalPhysicalPages (in bytes)
        mem.write_u32(ptr + 0x10, 256 * 1024 * 1024); // AvailablePages
    }
    ctx.gpr[3] = 0;
}

// ===== File I/O =====

/// NT error codes the file handlers need. Keeping them inline avoids pulling
/// in a whole NTSTATUS module for a single file.
const STATUS_SUCCESS: u64 = 0x0000_0000;
const STATUS_END_OF_FILE: u64 = 0xC000_0011;
const STATUS_INVALID_HANDLE: u64 = 0xC000_0008;
const STATUS_OBJECT_NAME_NOT_FOUND: u64 = 0xC000_0034;
const STATUS_NO_MORE_FILES: u64 = 0x8000_0006;
const STATUS_SEMAPHORE_LIMIT_EXCEEDED: u64 = 0xC000_0047;
const STATUS_UNSUCCESSFUL: u64 = 0xC000_0001;
const STATUS_INVALID_INFO_CLASS: u64 = 0xC000_0003;
const STATUS_INFO_LENGTH_MISMATCH: u64 = 0xC000_0004;
/// `X_ERROR_NOT_FOUND` from xenia-canary `xenia/xbox.h`. Returned by
/// `XexGetModuleHandle` for unknown module names.
const X_ERROR_NOT_FOUND: u64 = 0x0000_048B;

/// A sentinel byte-offset value meaning "read at current file position".
const FILE_USE_FILE_POINTER_POSITION: u64 = 0xFFFF_FFFF_FFFF_FFFE;

/// Write an `IO_STATUS_BLOCK { status, information }` if the pointer is non-null.
fn write_io_status_block(mem: &GuestMemory, ptr: u32, status: u32, information: u32) {
    if ptr == 0 {
        return;
    }
    mem.write_u32(ptr, status);
    mem.write_u32(ptr + 4, information);
}

/// NT `CreateDisposition` values — the only ones that matter for cache:
/// opens. From canary `xboxkrnl_io.cc` / NT documentation.
const FILE_SUPERSEDE: u32 = 0;
const FILE_OPEN: u32 = 1;
const FILE_CREATE: u32 = 2;
#[allow(dead_code)]
const FILE_OPEN_IF: u32 = 3; // open-or-create; honoured implicitly (no must_exist branch)
const FILE_OVERWRITE: u32 = 4;
const FILE_OVERWRITE_IF: u32 = 5;

/// AUDIT-038 — given a normalised guest path (post `path::normalize_path`),
/// return the host-FS path inside `state.cache_root` if and only if the
/// guest path lives on a writable cache mount. Wrapper around
/// [`KernelState::resolve_cache_path`] that also handles the
/// already-normalised `cache:/` form (forward-slashed by normalize_path).
fn cache_path_for(state: &KernelState, normalised: &str) -> Option<std::path::PathBuf> {
    // After normalize_path, backslashes have been converted to forward
    // slashes — but resolve_cache_path expects either form. Pass through.
    state.resolve_cache_path(normalised)
}

/// AUDIT-038 — open a `cache:/*` path against the host FS. Honours NT
/// create-disposition semantics (open/create/overwrite/supersede) and
/// records the host_path on the returned `File` object so subsequent
/// `NtReadFile`/`NtWriteFile` go through real I/O. Mirrors the behaviour
/// of canary's `HostPathDevice::Open` (xenia-canary/src/xenia/vfs/devices/
/// host_path_device.cc) once the symbolic link in xenia_main.cc:649 is
/// applied.
fn open_cache_file(
    state: &mut KernelState,
    guest_path: &str,
    host_path: &std::path::Path,
    create_disposition: u32,
    create_options: u32,
    mem: &GuestMemory,
    handle_out: u32,
    io_status_block: u32,
) -> u64 {
    // FILE_DIRECTORY_FILE / FILE_NON_DIRECTORY_FILE per
    // xboxkrnl_io.cc:29-34. The guest may set these to discriminate
    // a "create directory" call from a "create file" call when the
    // host filesystem can't infer it from the path shape (e.g. the
    // hash-only paths Sylpheed builds in `cache:\<hash>` — without
    // the bit, AUDIT-053 found we were creating a 0-byte file at
    // `cache:\d4ea4615` which then blocked subsequent hierarchical
    // creates of `cache:\d4ea4615\e\46ee8ca` with NAME_COLLISION).
    const FILE_DIRECTORY_FILE: u32 = 0x0000_0001;
    let want_dir = (create_options & FILE_DIRECTORY_FILE) != 0;

    // Root-of-mount case: `cache:\`, `cache:/`, `cache:` resolve to the
    // cache root directory itself. Mirror canary's HostPathDevice.Open
    // which returns a directory handle (success, attributes = DIR).
    // Empty `path.file_name()` after our resolve_cache_path strip means
    // the guest asked for the mount root.
    let is_dir_open = host_path == state.cache_root.as_deref().unwrap_or(host_path)
        || host_path.is_dir()
        || want_dir;
    if is_dir_open {
        // For non-existent paths the guest wants us to create as a
        // directory, mkdir-p; canary's HostPathDevice does the same
        // when FILE_DIRECTORY_FILE is set on a kCreate disposition.
        if want_dir && !host_path.exists() {
            if let Err(e) = std::fs::create_dir_all(host_path) {
                tracing::warn!(
                    "cache create_dir_all({:?}) failed: {} — STATUS_UNSUCCESSFUL",
                    host_path,
                    e
                );
                if handle_out != 0 {
                    mem.write_u32(handle_out, 0);
                }
                write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
                return STATUS_UNSUCCESSFUL;
            }
        }
        // Stored path ends with '/' so nt_query_information_file's
        // path-shape probe reports Directory=1.
        let dir_path = if guest_path.ends_with('/') || guest_path.ends_with(':') {
            guest_path.to_string()
        } else {
            format!("{}/", guest_path)
        };
        let handle = state.alloc_handle_for(KernelObject::File {
            path: dir_path,
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        if handle_out != 0 {
            mem.write_u32(handle_out, handle);
        }
        write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
        tracing::info!(
            "cache open (dir) path={:?} host={:?} disp={} opts={:#x} handle={:#x}",
            guest_path,
            host_path,
            create_disposition,
            create_options,
            handle
        );
        return STATUS_SUCCESS;
    }

    let exists = host_path.is_file();
    let must_exist = matches!(create_disposition, FILE_OPEN | FILE_OVERWRITE);
    let must_not_exist = create_disposition == FILE_CREATE;
    let truncate = matches!(
        create_disposition,
        FILE_SUPERSEDE | FILE_OVERWRITE | FILE_OVERWRITE_IF
    );

    if must_exist && !exists {
        if handle_out != 0 {
            mem.write_u32(handle_out, 0);
        }
        write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_NOT_FOUND as u32, 0);
        tracing::info!(
            "cache open MISS path={:?} disp={} -> NOT_FOUND",
            guest_path,
            create_disposition
        );
        return STATUS_OBJECT_NAME_NOT_FOUND;
    }
    if must_not_exist && exists {
        if handle_out != 0 {
            mem.write_u32(handle_out, 0);
        }
        write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_COLLISION as u32, 0);
        tracing::info!(
            "cache open COLLISION path={:?} disp={} -> NAME_COLLISION",
            guest_path,
            create_disposition
        );
        return STATUS_OBJECT_NAME_COLLISION;
    }

    // Ensure parent dir exists for create dispositions.
    if !exists || truncate {
        if let Some(parent) = host_path.parent() {
            if let Err(e) = std::fs::create_dir_all(parent) {
                tracing::warn!(
                    "cache create_dir_all({:?}) failed: {} — falling back to STATUS_UNSUCCESSFUL",
                    parent,
                    e
                );
                if handle_out != 0 {
                    mem.write_u32(handle_out, 0);
                }
                write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
                return STATUS_UNSUCCESSFUL;
            }
        }
    }

    // Truncate / create empty file if needed. Read remaining bytes only
    // when the disposition keeps existing content.
    if truncate || !exists {
        if let Err(e) = std::fs::File::create(host_path) {
            tracing::warn!(
                "cache File::create({:?}) failed: {} — STATUS_UNSUCCESSFUL",
                host_path,
                e
            );
            if handle_out != 0 {
                mem.write_u32(handle_out, 0);
            }
            write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
            return STATUS_UNSUCCESSFUL;
        }
    }
    let size = host_path
        .metadata()
        .map(|m| m.len())
        .unwrap_or(0);
    let handle = state.alloc_handle_for(KernelObject::File {
        path: guest_path.to_string(),
        size,
        position: 0,
        // Empty in-memory data; reads/writes go through host_path.
        data: std::sync::Arc::new(Vec::new()),
        dir_enum_pos: None,
        host_path: Some(host_path.to_path_buf()),
    });
    if handle_out != 0 {
        mem.write_u32(handle_out, handle);
    }
    write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
    tracing::info!(
        "cache open OK path={:?} host={:?} disp={} opts={:#x} size={} handle={:#x}",
        guest_path,
        host_path,
        create_disposition,
        create_options,
        size,
        handle
    );
    STATUS_SUCCESS
}

/// AUDIT-038 — additional NTSTATUS used by the cache-backed open path.
const STATUS_OBJECT_NAME_COLLISION: u64 = 0xC000_0035;

/// Open a VFS-backed file. Shared between NtCreateFile and NtOpenFile — the
/// create/open distinction only matters for writable volumes (cache:/),
/// which we now back with a host directory (audit-038). The disc image
/// remains read-only.
///
/// `create_disposition` is honoured for `cache:` paths only:
/// - `FILE_OPEN` (1) / default for `NtOpenFile`: must exist, else
///   STATUS_OBJECT_NAME_NOT_FOUND.
/// - `FILE_CREATE` (2): must NOT exist, else STATUS_OBJECT_NAME_COLLISION.
/// - `FILE_OPEN_IF` (3): open or create.
/// - `FILE_SUPERSEDE` (0) / `FILE_OVERWRITE_IF` (5): create or truncate.
/// - `FILE_OVERWRITE` (4): must exist, then truncate.
fn open_vfs_file(
    mem: &GuestMemory,
    state: &mut KernelState,
    handle_out: u32,
    io_status_block: u32,
    obj_attrs_ptr: u32,
    create_disposition: u32,
    create_options: u32,
) -> u64 {
    // Accept the empty-after-prefix case (e.g. `NtCreateFile("game:\")`) as
    // a valid "open the partition/device root" request — Canary's
    // `NtCreateFile_entry` in xboxkrnl_io.cc:39 lets empty paths through
    // to the VFS, which resolves them as a directory handle on the root.
    // Sylpheed opens `game:\` near the end of its boot as a disc-validation
    // probe; returning `STATUS_OBJECT_NAME_NOT_FOUND` makes the async worker
    // see a null handle later and trigger `XamShowDirtyDiscErrorUI`.
    let path = crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr)
        .unwrap_or_default();
    // AUDIT-2.BF — synthetic silph::WorkerCtx spawn. AUDIT-058/059
    // identified that ours never activates the 6-level static caller
    // ladder that ends in `sub_825070F0`, so the four worker threads
    // it would normally spawn (entries 0x82506528/58/88/B8) never run.
    // Canary's chain originally fires right after `DiscImageDevice::
    // ResolvePath("\\dat\\movie")` (audit-058); ours never opens
    // `dat/movie` because tid=13 wedges before reaching it. We
    // therefore trigger on the first `dat/*` open — the earliest
    // such open in ours is `dat/files.tbl` (immediately preceding
    // tid=12/13 spawn at audit-059 round 1).
    //
    // **Round 18 finding** (this commit): when the workers are
    // spawned runnable, they fault almost immediately (`PC=0` at
    // cycle ~5.5M on the hw thread carrying worker_3), preempting
    // ours' boot before the normal guest threads even spawn. The
    // ctx layout from audit-059 round 5 is incomplete — at least
    // one of `[+0x28]`/`[+0x2C]`/`[+0x30]` (the three foreign-
    // arena pointers) must be populated for the worker bodies to
    // run. Synthesising those is a fresh investigation (round 19+).
    //
    // Until then the synth path is **opt-in**: set
    // `XENIA_SILPH_SYNTH=1` to enable the runnable spawn (will
    // crash boot), or `XENIA_SILPH_SYNTH=suspend` to spawn but keep
    // them in `Blocked(Suspended)` (lets boot complete with the
    // ctx materialised in memory for downstream probes). Default:
    // disabled — preserves the existing boot trajectory.
    if !state.silph_synth_done && path.starts_with("dat/") {
        match std::env::var("XENIA_SILPH_SYNTH").as_deref() {
            Ok("1") | Ok("run") | Ok("runnable") => {
                let _ = crate::silph_synth::spawn_silph_workers(state, mem, false);
            }
            Ok("suspend") | Ok("suspended") => {
                let _ = crate::silph_synth::spawn_silph_workers(state, mem, true);
            }
            _ => {}
        }
    }
    if path.is_empty() && obj_attrs_ptr == 0 {
        if handle_out != 0 {
            mem.write_u32(handle_out, 0);
        }
        write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_NOT_FOUND as u32, 0);
        return STATUS_OBJECT_NAME_NOT_FOUND;
    }
    if path.is_empty() {
        // Empty path after prefix strip is the "open the device/partition
        // root" case (e.g. `NtCreateFile("game:\")`). Canary's
        // `NtCreateFile_entry` resolves these through the VFS and returns
        // a directory handle. We don't model directory entries, so synth
        // a zero-byte "file" whose `path` is empty; `nt_query_information_file`
        // then reports `Directory=1` / `FILE_ATTRIBUTE_DIRECTORY` based on
        // the path shape, which is how Sylpheed's disc-validation probe
        // decides it found a directory and proceeds.
        let handle = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        if handle_out != 0 {
            mem.write_u32(handle_out, handle);
        }
        write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
        return STATUS_SUCCESS;
    }

    // AUDIT-038 — cache:/* routing. `path` here is the post-normalize form
    // of `cache:\foo` (forward-slashed: `cache:/foo`); `cache:` isn't in
    // DEVICE_PREFIXES so the prefix survives normalisation. Resolve to a
    // host-FS path under `state.cache_root` and apply NT create-disposition
    // semantics. This replaces the "Synthesized empty file" stub for this
    // mountpoint specifically — other mountpoints (game:/, dat:/, etc.)
    // keep the legacy behaviour to avoid disturbing audit-006 / audit-018
    // disc-validation probes.
    if let Some(host_path) = cache_path_for(state, &path) {
        return open_cache_file(state, &path, &host_path, create_disposition,
            create_options, mem, handle_out, io_status_block);
    }

    let vfs = match state.vfs.as_ref() {
        Some(v) => v,
        None => {
            tracing::warn!("NtCreateFile/NtOpenFile for {:?}: no VFS mounted", path);
            if handle_out != 0 {
                mem.write_u32(handle_out, 0);
            }
            write_io_status_block(mem, io_status_block, STATUS_OBJECT_NAME_NOT_FOUND as u32, 0);
            return STATUS_OBJECT_NAME_NOT_FOUND;
        }
    };

    match vfs.read_file(&path) {
        Ok(bytes) => {
            let size = bytes.len() as u64;
            let handle = state.alloc_handle_for(KernelObject::File {
                path: path.clone(),
                size,
                position: 0,
                data: std::sync::Arc::new(bytes),
                dir_enum_pos: None,
                host_path: None,
            });
            if handle_out != 0 {
                mem.write_u32(handle_out, handle);
            }
            write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
            tracing::info!("File opened: path={:?} size={} handle={:#x}", path, size, handle);
            STATUS_SUCCESS
        }
        Err(e) => {
            // When the VFS can't resolve a path we synthesize a zero-byte
            // virtual file rather than returning NOT_FOUND. Two rationales:
            //
            //   1. **Writable system partitions** (`cache:/`, `cache0:`,
            //      `cache1:`, `partition0:`, `partition1:`) aren't backed by
            //      the disc — Canary mounts them on host directories
            //      ([xenia_main.cc:612-651](xenia-canary/src/xenia/app/xenia_main.cc)).
            //      We skip the host mount for now, so opens there always miss
            //      without this fallback.
            //
            //   2. **Disc files that didn't make it into the ISO rip** (e.g.,
            //      Sylpheed's `dat/files.tbl`, which the retail disc shipped
            //      but our dump doesn't contain). Returning NOT_FOUND makes
            //      Sylpheed's boot validator call `XamShowDirtyDiscErrorUI`
            //      → dashboard exit; see Canary's `XamShowDirtyDiscErrorUI`
            //      at xam_ui.cc:562 for the "bad or unimplemented file IO
            //      calls" framing.
            //
            // A zero-byte file lets the game's existence probe succeed, its
            // read return EOF, and its "is the content here" sanity checks
            // pass. If the game actually needs the bytes for gameplay we'll
            // see a fresh failure downstream and can decide what to stub next.
            let handle = state.alloc_handle_for(KernelObject::File {
                path: path.clone(),
                size: 0,
                position: 0,
                data: std::sync::Arc::new(Vec::new()),
                dir_enum_pos: None,
                host_path: None,
            });
            if handle_out != 0 {
                mem.write_u32(handle_out, handle);
            }
            write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
            tracing::info!(
                "Synthesized empty file for missing path: path={:?} err={} handle={:#x}",
                path,
                e,
                handle
            );
            STATUS_SUCCESS
        }
    }
}

fn nt_create_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle_out, r4 = desired_access, r5 = obj_attrs, r6 = io_status_block,
    // r7 = allocation_size, r8 = file_attributes, r9 = share_access, r10 = create_disposition,
    // [sp+0x54] = create_options (9th arg, spilled per shim_utils.h:49-50).
    let handle_out = ctx.gpr[3] as u32;
    let obj_attrs_ptr = ctx.gpr[5] as u32;
    let io_status_block = ctx.gpr[6] as u32;
    let create_disposition = ctx.gpr[10] as u32;
    let sp = ctx.gpr[1] as u32;
    let create_options = mem.read_u32(sp + 0x54);
    ctx.gpr[3] = open_vfs_file(
        mem,
        state,
        handle_out,
        io_status_block,
        obj_attrs_ptr,
        create_disposition,
        create_options,
    );
}

fn nt_open_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle_out, r4 = desired_access, r5 = obj_attrs,
    // r6 = io_status_block, r7 = share_access, r8 = open_options.
    // `NtOpenFile` is FILE_OPEN-only (no create) — file must exist.
    // Per xboxkrnl_io.cc:99-122, NtOpenFile forwards `open_options`
    // straight into NtCreateFile's `create_options` slot, so the
    // FILE_DIRECTORY_FILE bit applies the same way.
    let handle_out = ctx.gpr[3] as u32;
    let obj_attrs_ptr = ctx.gpr[5] as u32;
    let io_status_block = ctx.gpr[6] as u32;
    let open_options = ctx.gpr[8] as u32;
    ctx.gpr[3] = open_vfs_file(
        mem,
        state,
        handle_out,
        io_status_block,
        obj_attrs_ptr,
        FILE_OPEN,
        open_options,
    );
}

/// Signal an NT-style completion event on synchronous I/O completion.
///
/// `NtReadFile` / `NtWriteFile` take an event handle at r4. The NT contract
/// is: on a real async driver, the event pulses when the I/O finishes.
/// Games that use the common "issue I/O then wait on the event" idiom will
/// deadlock if we return `STATUS_SUCCESS` without signaling — observed on
/// Sylpheed with four stuck threads parked on `WaitAny { handles: [evt] }`
/// that nothing else could wake. We finish I/O synchronously so we signal
/// immediately on *every* completion path (success, EOF, invalid-handle).
/// No-op when the caller passes a null handle (synchronous-wait style).
fn signal_io_completion_event(state: &mut KernelState, event_handle: u32) {
    if event_handle == 0 {
        return;
    }
    let prev = if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&event_handle) {
        let was = *signaled;
        *signaled = true;
        was as u64
    } else {
        0
    };
    state.audit_signal(event_handle, 0, "signal_io_completion_event", prev);
    wake_eligible_waiters(state, event_handle);
}

fn nt_read_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx,
    // r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr
    let handle = ctx.gpr[3] as u32;
    let event_handle = ctx.gpr[4] as u32;
    let io_status_block = ctx.gpr[7] as u32;
    let buffer = ctx.gpr[8] as u32;
    let length = ctx.gpr[9] as u32;
    let byte_offset_ptr = ctx.gpr[10] as u32;

    let Some(KernelObject::File { path, size, position, data, host_path, .. }) = state.objects.get_mut(&handle) else {
        tracing::warn!("NtReadFile: invalid handle {:#x}", handle);
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
        signal_io_completion_event(state, event_handle);
        return;
    };

    // If the caller supplied an explicit byte offset (not 0xFFFFFFFFFFFFFFFE)
    // seek to it; otherwise continue from the stored cursor.
    let start_pos = if byte_offset_ptr != 0 {
        let offset = mem.read_u64(byte_offset_ptr);
        if offset != FILE_USE_FILE_POINTER_POSITION && offset != u64::MAX {
            *position = offset;
        }
        *position
    } else {
        *position
    };

    // AUDIT-038 — host-backed cache read. Refresh `size` from the live FS
    // entry first (writers on the same handle may have grown the file
    // since open). Use std::fs::File seek/read for the actual transfer.
    if let Some(hp) = host_path.clone() {
        use std::io::{Read, Seek, SeekFrom};
        let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0);
        *size = live_size;
        if start_pos >= live_size {
            write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0);
            ctx.gpr[3] = STATUS_END_OF_FILE;
            signal_io_completion_event(state, event_handle);
            return;
        }
        let avail = (live_size - start_pos).min(length as u64) as usize;
        let mut buf = vec![0u8; avail];
        let res = (|| -> std::io::Result<usize> {
            let mut f = std::fs::File::open(&hp)?;
            f.seek(SeekFrom::Start(start_pos))?;
            f.read_exact(&mut buf)?;
            Ok(buf.len())
        })();
        match res {
            Ok(n) => {
                mem.write_bulk(buffer, &buf[..n]);
                *position = start_pos + n as u64;
                write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, n as u32);
                ctx.gpr[3] = STATUS_SUCCESS;
                tracing::info!(
                    "NtReadFile cache: {} bytes from {:?} @ {} (handle={:#x})",
                    n, path, start_pos, handle
                );
            }
            Err(e) => {
                tracing::warn!("NtReadFile cache I/O error path={:?}: {}", path, e);
                write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
                ctx.gpr[3] = STATUS_UNSUCCESSFUL;
            }
        }
        signal_io_completion_event(state, event_handle);
        return;
    }

    let total = *size;

    // Synthesized empty files (system partition opens like
    // `\Device\Harddisk0\partition0` that miss the disc-VFS) act as
    // NullDevice handles. Canary's `NullFile::ReadSync` returns
    // `X_STATUS_SUCCESS` with `bytes_read=0` and never touches the buffer
    // ([null_file.cc:24-31](xenia-canary/src/xenia/vfs/devices/null_file.cc));
    // Sylpheed's cache loader at `sub_824A9710` reads 1024 B from offset
    // 2048, expects success, then validates a `"Josh"` magic — falling back
    // to the recreate path if the buffer (already zeroed by the caller via
    // `memset(sp+208, 0, 1024)`) doesn't match.
    if data.is_empty() && total == 0 {
        write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, 0);
        ctx.gpr[3] = STATUS_SUCCESS;
        signal_io_completion_event(state, event_handle);
        return;
    }

    if start_pos >= total {
        write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0);
        ctx.gpr[3] = STATUS_END_OF_FILE;
        signal_io_completion_event(state, event_handle);
        return;
    }

    let avail = (total - start_pos).min(length as u64) as usize;
    if avail == 0 {
        write_io_status_block(mem, io_status_block, STATUS_END_OF_FILE as u32, 0);
        ctx.gpr[3] = STATUS_END_OF_FILE;
        signal_io_completion_event(state, event_handle);
        return;
    }

    let start = start_pos as usize;
    let end = start + avail;
    let slice = &data[start..end];
    mem.write_bulk(buffer, slice);
    *position = start_pos + avail as u64;

    {
        // STEP 51 — tag NtReadFile with the calling tid + cycle so the
        // window-prefetch thread can be correlated against tid24's demux read.
        let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
        let rtid = state.scheduler.tid(hw_id).unwrap_or(0);
        tracing::info!(
            "NtReadFile: {} bytes from {:?} @ {}  (handle={:#x}) tid={} cycle={}",
            avail, path, start_pos, handle, rtid, ctx.cycle_count,
        );
    }
    write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, avail as u32);
    ctx.gpr[3] = STATUS_SUCCESS;
    signal_io_completion_event(state, event_handle);
}

fn nt_write_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = event, r5 = apc_routine, r6 = apc_ctx,
    // r7 = io_status_block, r8 = buffer, r9 = length, r10 = byte_offset_ptr.
    // For cache:/* (host_path Some) writes go to disk; everything else
    // is still discarded (matches legacy read-only behaviour for game:/).
    let handle = ctx.gpr[3] as u32;
    let event_handle = ctx.gpr[4] as u32;
    let io_status_block = ctx.gpr[7] as u32;
    let buffer = ctx.gpr[8] as u32;
    let length = ctx.gpr[9] as u32;
    let byte_offset_ptr = ctx.gpr[10] as u32;

    let Some(KernelObject::File { path, size, position, host_path, .. }) =
        state.objects.get_mut(&handle)
    else {
        tracing::warn!("NtWriteFile: invalid handle {:#x}", handle);
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
        signal_io_completion_event(state, event_handle);
        return;
    };

    let start_pos = if byte_offset_ptr != 0 {
        let offset = mem.read_u64(byte_offset_ptr);
        if offset != FILE_USE_FILE_POINTER_POSITION && offset != u64::MAX {
            *position = offset;
        }
        *position
    } else {
        *position
    };

    if let Some(hp) = host_path.clone() {
        use std::io::{Seek, SeekFrom, Write};
        let mut buf = vec![0u8; length as usize];
        mem.read_bulk(buffer, &mut buf);
        let res = (|| -> std::io::Result<()> {
            let mut f = std::fs::OpenOptions::new()
                .create(true)
                .write(true)
                .open(&hp)?;
            f.seek(SeekFrom::Start(start_pos))?;
            f.write_all(&buf)?;
            f.flush()?;
            Ok(())
        })();
        match res {
            Ok(()) => {
                *position = start_pos + length as u64;
                let live_size = std::fs::metadata(&hp).map(|m| m.len()).unwrap_or(0);
                *size = live_size;
                write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
                ctx.gpr[3] = STATUS_SUCCESS;
                tracing::info!(
                    "NtWriteFile cache: {} bytes to {:?} @ {} (handle={:#x})",
                    length, path, start_pos, handle
                );
            }
            Err(e) => {
                tracing::warn!("NtWriteFile cache I/O error path={:?}: {}", path, e);
                write_io_status_block(mem, io_status_block, STATUS_UNSUCCESSFUL as u32, 0);
                ctx.gpr[3] = STATUS_UNSUCCESSFUL;
            }
        }
    } else {
        // Legacy: discard but report full-length-written so caller proceeds.
        write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, length);
        ctx.gpr[3] = STATUS_SUCCESS;
    }
    signal_io_completion_event(state, event_handle);
}

/// Mirrors canary `NullDevice::IoControl` via
/// [xboxkrnl_io.cc:645-678](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc).
/// Used by `XMountUtilityDrive` cache-mount probes; Sylpheed issues both
/// `0x70000` (drive geometry) and `0x74004` (partition info) inside
/// `sub_824ABD88`. The OUT-buffer fields it writes are the gate that
/// keeps `sub_824A9710` from synthesizing `STATUS_OBJECT_NAME_NOT_FOUND`.
fn nt_device_io_control_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    const X_IOCTL_DISK_GET_DRIVE_GEOMETRY: u32 = 0x70000;
    const X_IOCTL_DISK_GET_PARTITION_INFO: u32 = 0x74004;
    const STATUS_BUFFER_TOO_SMALL: u64 = 0xC000_0023;
    const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D;
    const CACHE_SIZE: u64 = 0xFF000;

    let event_handle = ctx.gpr[4] as u32;
    let io_status_block = ctx.gpr[7] as u32;
    let io_control_code = ctx.gpr[8] as u32;
    let sp = ctx.gpr[1] as u32;
    let output_buffer = mem.read_u32(sp + 0x54);
    let output_buffer_len = mem.read_u32(sp + 0x5C);

    let status: u64 = match io_control_code {
        X_IOCTL_DISK_GET_DRIVE_GEOMETRY => {
            if output_buffer_len < 0x8 {
                STATUS_BUFFER_TOO_SMALL
            } else {
                mem.write_u32(output_buffer, (CACHE_SIZE / 512) as u32);
                mem.write_u32(output_buffer + 4, 512);
                STATUS_SUCCESS
            }
        }
        X_IOCTL_DISK_GET_PARTITION_INFO => {
            if output_buffer_len < 0x10 {
                STATUS_BUFFER_TOO_SMALL
            } else {
                mem.write_u64(output_buffer, 0);
                mem.write_u64(output_buffer + 8, CACHE_SIZE);
                STATUS_SUCCESS
            }
        }
        _ => {
            tracing::warn!(
                io_control_code = format!("0x{:X}", io_control_code),
                "NtDeviceIoControlFile: unhandled IOCTL"
            );
            STATUS_INVALID_PARAMETER
        }
    };

    let info = if status == STATUS_SUCCESS {
        output_buffer_len
    } else {
        0
    };
    write_io_status_block(mem, io_status_block, status as u32, info);
    ctx.gpr[3] = status;
    signal_io_completion_event(state, event_handle);
}

/// Minimal `NtQueryInformationFile`. The only classes Sylpheed (and most
/// games) use are `FileStandardInformation` (5) and `FilePositionInformation`
/// (14). Anything else gets zeros + success.
fn nt_query_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = io_status_block, r5 = file_info, r6 = length, r7 = class
    let handle = ctx.gpr[3] as u32;
    let io_status_block = ctx.gpr[4] as u32;
    let file_info = ctx.gpr[5] as u32;
    let length = ctx.gpr[6] as u32;
    let class = ctx.gpr[7] as u32;

    let Some(KernelObject::File { size, position, path, host_path, .. }) = state.objects.get(&handle) else {
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        write_io_status_block(mem, io_status_block, STATUS_INVALID_HANDLE as u32, 0);
        return;
    };

    // AUDIT-038 — refresh size from the live host file when this is a
    // cache-backed handle so post-write queries see accurate EOF.
    let live_size = if let Some(hp) = host_path.as_ref() {
        std::fs::metadata(hp).map(|m| m.len()).unwrap_or(*size)
    } else {
        *size
    };

    // Snapshot what we need from the handle, then drop the borrow so we can
    // re-resolve the path against the VFS for its real attribute byte.
    let path = path.clone();
    let size = live_size;
    let position = *position;

    // Pull the REAL GDFX attribute byte (canary `disc_image_device.cc:154`)
    // for disc-backed handles by re-resolving the stored path. Root-of-device
    // opens (`game:\`, `cache:\`, `partition0`) strip to an empty string and
    // synth-stub opens have no VFS entry — for those we fall back to the
    // path-shape heuristic. Games query these as directories (DirectoryObject
    // probe), and reporting `Directory=0` makes Sylpheed treat the open as
    // "found a non-directory where I expected a directory" and call
    // `XamShowDirtyDiscErrorUI`.
    let vfs_attributes: Option<u32> = if path.is_empty() {
        None
    } else {
        state
            .vfs
            .as_ref()
            .and_then(|vfs| vfs.stat(&path).ok())
            .map(|e| e.attributes)
            .filter(|&a| a != 0)
    };
    let is_directory = match vfs_attributes {
        Some(a) => (a & 0x10) != 0,
        None => path.is_empty() || path.ends_with('/') || path.ends_with(':'),
    };

    // `FILE_ATTRIBUTE_DIRECTORY` (NT / Xbox) — advertised in
    // `FileNetworkOpenInformation.FileAttributes`; Sylpheed's async-I/O
    // worker queries with class=34 and the calling code checks this bit
    // to decide whether the open resolved to a directory before
    // continuing down the non-error path.
    const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
    const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
    let written: u32 = match class {
        // FileStandardInformation: AllocationSize(i64), EndOfFile(i64), NumberOfLinks(u32), DeletePending(u8), Directory(u8), pad(u16)
        5 if length >= 24 => {
            mem.write_u64(file_info, size);
            mem.write_u64(file_info + 8, size);
            mem.write_u32(file_info + 16, 1);
            mem.write_u8(file_info + 20, 0);
            mem.write_u8(file_info + 21, if is_directory { 1 } else { 0 });
            mem.write_u16(file_info + 22, 0);
            24
        }
        // FilePositionInformation: CurrentByteOffset(i64)
        14 if length >= 8 => {
            mem.write_u64(file_info, position);
            8
        }
        // FileNetworkOpenInformation: timestamps(4x i64) @ 0..32,
        // AllocationSize(i64) @ 32, EndOfFile(i64) @ 40, FileAttributes(u32) @ 48
        // Sylpheed's async-validation worker asks for this (`length=56`)
        // and the caller checks `FileAttributes & FILE_ATTRIBUTE_DIRECTORY`
        // right after. Without populating the attributes the bit is
        // clear, the caller decides the open "found a non-directory
        // where a directory was expected", and the outer routine calls
        // `XamShowDirtyDiscErrorUI` → `XamLoaderLaunchTitle` → garbage.
        34 if length >= 56 => {
            // Zero timestamps (we don't track real times).
            for off in (0..32).step_by(8) {
                mem.write_u64(file_info + off, 0);
            }
            mem.write_u64(file_info + 32, size);
            mem.write_u64(file_info + 40, size);
            // Prefer the real GDFX attribute byte; fall back to the
            // DIRECTORY/NORMAL split for root-of-device and synth-stub
            // handles that have no VFS entry.
            let attrs = match vfs_attributes {
                Some(a) => a,
                None if is_directory => FILE_ATTRIBUTE_DIRECTORY,
                None => FILE_ATTRIBUTE_NORMAL,
            };
            mem.write_u32(file_info + 48, attrs);
            mem.write_u32(file_info + 52, 0); // pad
            56
        }
        _ => {
            // Zero out whatever the caller asked for — conservative default.
            for i in 0..length {
                mem.write_u8(file_info + i, 0);
            }
            length
        }
    };

    write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, written);
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// `NtSetInformationFile(FileHandle, IoStatusBlock*, FileInformation,
/// Length, FileInformationClass)`. Mirrors Canary
/// [xboxkrnl_io_info.cc:180-304](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io_info.cc).
///
/// Validates `info_class` (must have a defined minimum size) and
/// `info_length` (must meet that minimum); returns
/// `STATUS_INVALID_INFO_CLASS` / `STATUS_INFO_LENGTH_MISMATCH` in those
/// cases. The only class with real side-effects in xenia-rs is
/// `XFilePositionInformation` (14) — seek updates the file's cursor.
/// Read-only VFS means `XFileEndOfFileInformation` (20, truncate) can
/// only succeed if the new length equals the current size, otherwise
/// returns `STATUS_UNSUCCESSFUL`. Other classes acknowledge the write
/// but have no backing store.
fn nt_set_information_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = io_status_block, r5 = info_ptr,
    // r6 = info_length, r7 = info_class.
    let handle = ctx.gpr[3] as u32;
    let iosb_ptr = ctx.gpr[4] as u32;
    let info_ptr = ctx.gpr[5] as u32;
    let info_length = ctx.gpr[6] as u32;
    let info_class = ctx.gpr[7] as u32;

    // Matches Canary's `GetSetFileInfoMinimumLength`. A return of 0 means
    // "class we don't recognise for SetInfo" → STATUS_INVALID_INFO_CLASS.
    let min_length = match info_class {
        4 => 40,  // XFileBasicInformation (times + attributes)
        10 => 16, // XFileRenameInformation
        13 => 4,  // XFileDispositionInformation (delete_file u32)
        14 => 8,  // XFilePositionInformation (i64 current offset)
        16 | 31 => 4, // XFileModeInformation / XFileIoPriorityInformation
        19 | 20 | 23 => 8, // XFileAllocationInformation / EndOfFileInformation / MountPartitionInformation
        11 => 16, // XFileLinkInformation
        24 => 152, // XFileMountPartitionsInformation
        30 => 8,  // XFileCompletionInformation (handle + key, 2 dwords)
        _ => 0,
    };
    if min_length == 0 {
        ctx.gpr[3] = STATUS_INVALID_INFO_CLASS;
        return;
    }
    if info_length < min_length {
        ctx.gpr[3] = STATUS_INFO_LENGTH_MISMATCH;
        return;
    }

    // XFileRenameInformation (10): move the backing file to a new path.
    // Sylpheed's asset-cache decompresses each packed resource to a staging
    // `cache:\<hash><tail>.tmp` then renames it into its final nested path
    // `cache:\<hash>\<dir>\<file>`. Without an actual host-FS rename the
    // nested target stays empty, the later read-back of the decompressed
    // asset (e.g. the title logo texture `\69d8e45c\e\534ffea`) misses, and
    // the logo never loads. Mirror canary `xboxkrnl_io_info.cc:226`
    // (`X_FILE_RENAME_INFORMATION{ replace_existing@0, root_dir_handle@4,
    // ansi_string@8 }` → `file->Rename(TranslateAnsiPath(ansi_string))`).
    if info_class == 10 {
        // Read the target path from the embedded ANSI_STRING at info_ptr+8.
        let target_raw = match crate::path::read_ansi_string(mem, info_ptr + 8) {
            Some(s) if !s.is_empty() => s,
            _ => {
                const STATUS_OBJECT_NAME_INVALID: u64 = 0xC000_0033;
                ctx.gpr[3] = STATUS_OBJECT_NAME_INVALID;
                return;
            }
        };
        // Resolve the destination against the host cache backing dir. We only
        // support renames within the writable `cache:` mount (the only place
        // a guest can create files); disc/synth entries are read-only.
        let new_host = state.resolve_cache_path(&target_raw);
        // Current backing host path of the handle.
        let old_host = match state.objects.get(&handle) {
            Some(KernelObject::File { host_path: Some(hp), .. }) => Some(hp.clone()),
            Some(KernelObject::File { .. }) => None,
            _ => {
                ctx.gpr[3] = STATUS_INVALID_HANDLE;
                return;
            }
        };
        let status: u64 = match (old_host, new_host) {
            (Some(old), Some(new)) => {
                if let Some(parent) = new.parent() {
                    let _ = std::fs::create_dir_all(parent);
                }
                match std::fs::rename(&old, &new) {
                    Ok(()) => {
                        // Update the handle so subsequent I/O targets the new
                        // host path + guest path.
                        if let Some(KernelObject::File { path, host_path, .. }) =
                            state.objects.get_mut(&handle)
                        {
                            *path = crate::path::normalize_path(&target_raw);
                            *host_path = Some(new.clone());
                        }
                        tracing::info!(
                            "NtSetInformationFile rename cache {:?} -> {:?} ({:?})",
                            old, new, target_raw
                        );
                        STATUS_SUCCESS
                    }
                    Err(e) => {
                        tracing::warn!(
                            "NtSetInformationFile rename {:?} -> {:?} failed: {}",
                            old, new, e
                        );
                        STATUS_UNSUCCESSFUL
                    }
                }
            }
            // Non-cache (read-only VFS) source/target: acknowledge without a
            // host move, matching the prior permissive behaviour.
            _ => STATUS_SUCCESS,
        };
        if iosb_ptr != 0 {
            write_io_status_block(mem, iosb_ptr, status as u32, info_length);
        }
        ctx.gpr[3] = status;
        return;
    }

    // Handle lookup.
    let Some(KernelObject::File { size, position, host_path, .. }) = state.objects.get_mut(&handle) else {
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        return;
    };

    let (status, out_length): (u64, u32) = match info_class {
        // XFilePositionInformation (14): i64 new byte offset.
        14 => {
            let new_offset = mem.read_u64(info_ptr);
            // Canary clamps nothing — it assigns directly. Game is
            // responsible for staying within the file; reads past EOF
            // return STATUS_END_OF_FILE from NtReadFile.
            *position = new_offset;
            (STATUS_SUCCESS, 8)
        }
        // XFileEndOfFileInformation (20): i64 new length.
        // For cache:/* (host_path Some): real `set_len` against the
        // backing file. Disc-VFS / synth: only a no-op truncate-to-same
        // succeeds (read-only).
        20 => {
            let new_eof = mem.read_u64(info_ptr);
            if let Some(hp) = host_path.clone() {
                match std::fs::OpenOptions::new()
                    .write(true)
                    .open(&hp)
                    .and_then(|f| f.set_len(new_eof).map(|()| f))
                {
                    Ok(_) => {
                        *size = new_eof;
                        (STATUS_SUCCESS, 8)
                    }
                    Err(e) => {
                        tracing::warn!("set_len({:?}, {}): {}", hp, new_eof, e);
                        (STATUS_UNSUCCESSFUL, 8)
                    }
                }
            } else if new_eof == *size {
                (STATUS_SUCCESS, 8)
            } else {
                (STATUS_UNSUCCESSFUL, 8)
            }
        }
        // XFileAllocationInformation (19): pre-allocation hint. Canary
        // explicitly `XELOGW`s and reports out_length=8; we do the same.
        19 => (STATUS_SUCCESS, 8),
        // XFileBasicInformation (4): times + attributes. Read-only VFS
        // can't persist these, but acknowledge the write to match Canary's
        // behaviour on a read-only entry.
        4 => (STATUS_SUCCESS, 40),
        // XFileDispositionInformation (13): delete-on-close. Read-only VFS
        // → log the bit and succeed; the file is never actually removed.
        13 => {
            let delete_flag = mem.read_u32(info_ptr) != 0;
            tracing::debug!(
                handle = format_args!("{handle:#x}"),
                delete = delete_flag,
                "NtSetInformationFile: disposition (read-only VFS, no-op)"
            );
            (STATUS_SUCCESS, 0)
        }
        // Other recognised classes: accept and report back the minimum
        // length so callers don't bail on zero-information.
        _ => (STATUS_SUCCESS, min_length),
    };

    if iosb_ptr != 0 {
        write_io_status_block(mem, iosb_ptr, status as u32, out_length);
    }
    ctx.gpr[3] = status;
}

fn nt_query_full_attributes_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = obj_attrs, r4 = network_open_info
    let obj_attrs_ptr = ctx.gpr[3] as u32;
    let out = ctx.gpr[4] as u32;

    let path = match crate::path::object_attributes_to_vfs_path(mem, obj_attrs_ptr) {
        Some(p) if !p.is_empty() => p,
        _ => {
            ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
            return;
        }
    };

    // AUDIT-038 — cache:/* short-circuit: stat the host-FS file directly
    // so existence probes (Sylpheed's pre-open `NtQueryFullAttributesFile`)
    // see real attributes for files we just created and miss for files we
    // haven't.
    if let Some(hp) = state.resolve_cache_path(&path) {
        let entry = std::fs::metadata(&hp);
        match entry {
            Ok(md) => {
                let filetime: u64 = 132_500_000_000_000_000;
                if out != 0 {
                    for off in (0..32).step_by(4) {
                        mem.write_u32(out + off, if off & 4 == 0 {
                            (filetime >> 32) as u32
                        } else {
                            filetime as u32
                        });
                    }
                    mem.write_u64(out + 32, md.len());
                    mem.write_u64(out + 40, md.len());
                    let attrs: u32 = if md.is_dir() { 0x10 } else { 0x80 };
                    mem.write_u32(out + 48, attrs);
                    mem.write_u32(out + 52, 0);
                }
                ctx.gpr[3] = STATUS_SUCCESS;
                return;
            }
            Err(_) => {
                ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
                return;
            }
        }
    }

    let Some(vfs) = state.vfs.as_ref() else {
        ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
        return;
    };

    match vfs.stat(&path) {
        Ok(entry) => {
            // FILE_NETWORK_OPEN_INFORMATION (56 bytes): 4 × FILETIME,
            // AllocationSize(i64), EndOfFile(i64), FileAttributes(u32), pad(u32)
            let filetime: u64 = 132_500_000_000_000_000;
            if out != 0 {
                mem.write_u32(out, (filetime >> 32) as u32);
                mem.write_u32(out + 4, filetime as u32);
                mem.write_u32(out + 8, (filetime >> 32) as u32);
                mem.write_u32(out + 12, filetime as u32);
                mem.write_u32(out + 16, (filetime >> 32) as u32);
                mem.write_u32(out + 20, filetime as u32);
                mem.write_u32(out + 24, (filetime >> 32) as u32);
                mem.write_u32(out + 28, filetime as u32);
                mem.write_u64(out + 32, entry.size);
                mem.write_u64(out + 40, entry.size);
                // Use the REAL GDFX attribute byte forwarded by the VFS
                // (canary `disc_image_device.cc:154`) instead of a
                // path-shape guess. Disc rips never carry a 0-attribute
                // entry, but guard anyway so a synthesised/legacy entry
                // still advertises a sane DIRECTORY/NORMAL split.
                let attrs: u32 = if entry.attributes != 0 {
                    entry.attributes
                } else if entry.is_directory {
                    0x10
                } else {
                    0x80
                };
                mem.write_u32(out + 48, attrs);
                mem.write_u32(out + 52, 0);
            }
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        Err(_) => {
            ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
        }
    }
}

fn nt_query_volume_information_file(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
    // r3 = handle, r4 = io_status_block, r5 = info, r6 = length, r7 = class
    let io_status_block = ctx.gpr[4] as u32;
    let info = ctx.gpr[5] as u32;
    let length = ctx.gpr[6] as u32;
    let class = ctx.gpr[7] as u32;

    // FileFsSizeInformation (class 3): 24 bytes
    //   TotalAllocationUnits(i64), AvailableAllocationUnits(i64),
    //   SectorsPerAllocationUnit(u32), BytesPerSector(u32)
    let written: u32 = match class {
        3 if length >= 24 => {
            mem.write_u64(info, 0x10);
            mem.write_u64(info + 8, 0x10);
            mem.write_u32(info + 16, 0x80);
            mem.write_u32(info + 20, 0x200);
            24
        }
        _ => {
            for i in 0..length {
                mem.write_u8(info + i, 0);
            }
            length
        }
    };

    write_io_status_block(mem, io_status_block, STATUS_SUCCESS as u32, written);
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// Enumerate the immediate children of a directory handle, writing
/// `X_FILE_DIRECTORY_INFORMATION` entries into the caller's buffer.
/// Mirrors Canary [xboxkrnl_io.cc:516-557](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc)
/// and the entry layout in
/// [xfile.h:35-73](xenia-canary/src/xenia/kernel/xfile.h).
///
/// Pagination: each call consumes `dir_enum_pos` on the File handle.
/// `None` = fresh handle → start at index 0; `Some(N)` = resume from
/// N-th matching entry. On exhaustion the cursor stays past the end
/// and subsequent calls return `STATUS_NO_MORE_FILES`. The `restart_scan`
/// flag (9th arg, on the stack) is not yet threaded through; callers
/// that want to rescan must close and re-open the directory handle.
fn nt_query_directory_file(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3=file_handle, r4=event_handle, r5=apc_routine, r6=apc_context,
    // r7=io_status_block, r8=file_info_ptr, r9=length, r10=file_name,
    // sp+... = restart_scan.
    let handle = ctx.gpr[3] as u32;
    let event_handle = ctx.gpr[4] as u32;
    let iosb_ptr = ctx.gpr[7] as u32;
    let info_ptr = ctx.gpr[8] as u32;
    let length = ctx.gpr[9] as u32;

    // Canary requires at least one fixed prefix + some filename room.
    const ENTRY_FIXED_SIZE: u32 = 0x40; // bytes 0..64 fixed fields
    const CANARY_MIN_LENGTH: u32 = 72; // xboxkrnl_io.cc:521
    const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
    const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
    if length < CANARY_MIN_LENGTH {
        ctx.gpr[3] = STATUS_INFO_LENGTH_MISMATCH;
        signal_io_completion_event(state, event_handle);
        return;
    }

    // Look up the handle and snapshot the directory prefix.
    let dir_path = match state.objects.get(&handle) {
        Some(KernelObject::File { path, .. }) => path.clone(),
        _ => {
            if iosb_ptr != 0 {
                write_io_status_block(mem, iosb_ptr, STATUS_INVALID_HANDLE as u32, 0);
            }
            ctx.gpr[3] = STATUS_INVALID_HANDLE;
            signal_io_completion_event(state, event_handle);
            return;
        }
    };

    // Gather the directory's immediate children from the VFS. An empty
    // `dir_path` refers to the disc root; non-empty paths match entries
    // whose name starts with `dir_path + "/"` and whose suffix (relative
    // to that prefix) contains no further slashes.
    let prefix: String = if dir_path.is_empty() {
        String::new()
    } else if dir_path.ends_with('/') {
        dir_path.clone()
    } else {
        format!("{}/", dir_path)
    };
    let entries: Vec<xenia_vfs::VfsEntry> = match state.vfs.as_ref() {
        Some(vfs) => vfs
            .list_root()
            .unwrap_or_default()
            .into_iter()
            .filter_map(|e| {
                let relative: &str = if prefix.is_empty() {
                    e.name.as_str()
                } else {
                    match e.name.strip_prefix(prefix.as_str()) {
                        Some(s) => s,
                        None => return None,
                    }
                };
                if relative.is_empty() || relative.contains('/') {
                    return None;
                }
                Some(xenia_vfs::VfsEntry {
                    name: relative.to_string(),
                    is_directory: e.is_directory,
                    size: e.size,
                    offset: e.offset,
                    attributes: e.attributes,
                })
            })
            .collect(),
        None => Vec::new(),
    };

    // Load / initialise the enumeration cursor.
    let start_index = match state.objects.get_mut(&handle) {
        Some(KernelObject::File { dir_enum_pos, .. }) => {
            let pos = dir_enum_pos.unwrap_or(0);
            *dir_enum_pos = Some(pos);
            pos
        }
        _ => 0,
    };

    if start_index >= entries.len() {
        if iosb_ptr != 0 {
            write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0);
        }
        ctx.gpr[3] = STATUS_NO_MORE_FILES;
        signal_io_completion_event(state, event_handle);
        return;
    }

    // Pack as many entries as fit into `length`. `NextEntryOffset` is the
    // byte distance to the next entry from the start of the current one;
    // 0 marks the last entry. Entries are 8-byte aligned per Canary.
    let mut cursor: u32 = 0;
    let mut emitted: usize = 0;
    let mut last_entry_offset: Option<u32> = None;
    for (i, entry) in entries.iter().enumerate().skip(start_index) {
        let name_bytes = entry.name.as_bytes();
        let name_len = name_bytes.len() as u32;
        let raw_size = ENTRY_FIXED_SIZE + name_len;
        let aligned_size = (raw_size + 7) & !7;
        if cursor + raw_size > length {
            // Entry wouldn't fit — leave the buffer truncated and stop.
            break;
        }
        let base = info_ptr + cursor;
        mem.write_u32(base + 0x00, 0); // next_entry_offset (patched later)
        mem.write_u32(base + 0x04, i as u32); // file_index
        // Timestamps zeroed — xenia-rs doesn't track them.
        mem.write_u64(base + 0x08, 0);
        mem.write_u64(base + 0x10, 0);
        mem.write_u64(base + 0x18, 0);
        mem.write_u64(base + 0x20, 0);
        mem.write_u64(base + 0x28, entry.size);
        mem.write_u64(base + 0x30, entry.size);
        // Real GDFX attribute byte (canary `disc_image_device.cc:154`);
        // fall back to the directory/normal split only for legacy entries
        // that carry no attribute bits.
        let attrs = if entry.attributes != 0 {
            entry.attributes
        } else if entry.is_directory {
            FILE_ATTRIBUTE_DIRECTORY
        } else {
            FILE_ATTRIBUTE_NORMAL
        };
        mem.write_u32(base + 0x38, attrs);
        mem.write_u32(base + 0x3C, name_len);
        for (k, &b) in name_bytes.iter().enumerate() {
            mem.write_u8(base + ENTRY_FIXED_SIZE + k as u32, b);
        }
        // Patch the previous entry's next_entry_offset to point here.
        if let Some(prev_base) = last_entry_offset {
            mem.write_u32(prev_base + 0x00, cursor - (prev_base - info_ptr));
        }
        last_entry_offset = Some(base);
        cursor = std::cmp::min(cursor + aligned_size, length);
        emitted += 1;
        if cursor + ENTRY_FIXED_SIZE > length {
            // No room for another fixed header; stop before truncating.
            break;
        }
    }

    // Advance cursor on the handle.
    if let Some(KernelObject::File { dir_enum_pos, .. }) = state.objects.get_mut(&handle) {
        *dir_enum_pos = Some(start_index + emitted);
    }

    if emitted == 0 {
        if iosb_ptr != 0 {
            write_io_status_block(mem, iosb_ptr, STATUS_NO_MORE_FILES as u32, 0);
        }
        ctx.gpr[3] = STATUS_NO_MORE_FILES;
    } else {
        if iosb_ptr != 0 {
            write_io_status_block(mem, iosb_ptr, STATUS_SUCCESS as u32, cursor);
        }
        ctx.gpr[3] = STATUS_SUCCESS;
    }
    signal_io_completion_event(state, event_handle);
}

fn nt_close(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let handle = ctx.gpr[3] as u32;
    // Aliased refcount: `NtDuplicateObject` returns the *source* handle as the
    // "new" handle (we don't mint fresh values), so the game commonly holds
    // two logical references to the same handle value. Without refcount, the
    // first `NtClose` wipes the object while the second reference is still
    // live, which traps any later wait on that handle (Sylpheed's
    // create→dup(SAME_ACCESS)→set→close pattern at 0x8246079c manifests this
    // — main thread then parks forever on the closed handle). Mirror Canary's
    // `ObjectTable::ReleaseHandle` (object_table.cc:189): decrement the
    // per-handle refcount and only drop the object when it reaches zero.
    let remaining = state
        .handle_refcount
        .get_mut(&handle)
        .map(|c| {
            *c = c.saturating_sub(1);
            *c
        })
        .unwrap_or(0);
    if remaining == 0 {
        state.objects.remove(&handle);
        state.handle_refcount.remove(&handle);
        // If the object was an armed Timer, strip its pending-fire entry
        // so a later scheduler round doesn't try to signal a dead handle.
        // `disarm_timer` is a no-op for non-timer handles.
        state.disarm_timer(handle);
        // AUDIT-059 R34: return the slot to the recycle FIFO so a later
        // `alloc_handle` mints the same ID (matching canary's slab).
        state.release_handle_slot(handle);
    }
    ctx.gpr[3] = 0;
}

fn nt_create_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle_ptr, r4 = obj_attrs, r5 = event_type, r6 = initial_state.
    //
    // Xenon DISPATCHER_HEADER `Type` (NT convention):
    //   0 = NotificationEvent   (manual-reset)
    //   1 = SynchronizationEvent (auto-reset)
    // Canary: `xboxkrnl_threading.cc:668` `ev->Initialize(!event_type, !!initial_state)`
    // with `XEvent::Initialize(bool manual_reset, ...)` (xevent.cc:25) and
    // `InitializeNative` (xevent.cc:41 `case 0x00: manual_reset_ = true`).
    // So `manual_reset = (event_type == 0)`. The Ke-path
    // (`ensure_dispatcher_object`) was already correct; the Nt-path here was
    // inverted, mis-classifying Sylpheed's per-frame VSync gate (type=1 auto +
    // initial=1) as manual-reset+signaled → it stayed signaled forever and
    // tid=1's main loop spun ~2800x canary's 60Hz.
    let handle_ptr = ctx.gpr[3] as u32;
    let manual_reset = ctx.gpr[5] == 0;
    let signaled = ctx.gpr[6] != 0;
    let handle = state.alloc_handle_for(KernelObject::Event {
        manual_reset,
        signaled,
        waiters: Vec::new(),
    });
    state.audit_create_with_ctx(
        handle,
        if manual_reset { "Event/Manual" } else { "Event/Auto" },
        ctx,
        mem,
        "NtCreateEvent",
    );
    // ITERATE-2C Phase D — audit-049 auto-signal POC. Env-gated; no-op
    // when `XENIA_SILPH_UI_AUTOSIGNAL_DELAY` is unset.
    state.maybe_register_silph_autosignal(handle, ctx, mem);
    if handle_ptr != 0 {
        mem.write_u32(handle_ptr, handle);
    }
    ctx.gpr[3] = 0;
}

fn nt_create_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle_ptr, r4 = obj_attrs, r5 = initial_count, r6 = max_count
    let handle_ptr = ctx.gpr[3] as u32;
    let count = ctx.gpr[5] as i32;
    let max = ctx.gpr[6] as i32;
    let handle = state.alloc_handle_for(KernelObject::Semaphore {
        count,
        max,
        waiters: Vec::new(),
    });
    state.audit_create_with_ctx(handle, "Semaphore", ctx, mem, "NtCreateSemaphore");
    if handle_ptr != 0 {
        mem.write_u32(handle_ptr, handle);
    }
    ctx.gpr[3] = 0;
}

/// `NtCreateTimer(OUT handle_ptr, obj_attributes, timer_type)` — mint a
/// Timer kernel object in the handle table. `timer_type` selects between
/// NotificationTimer (0, manual-reset) and SynchronizationTimer (1,
/// auto-reset); any other value returns `STATUS_INVALID_PARAMETER`
/// matching Canary's `assert_always` on bad types (xtimer.cc:32).
/// Named-object dedup (Canary's `LookupNamedObject<XTimer>`) is out of
/// scope — Sylpheed uses anonymous timers.
fn nt_create_timer(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    const STATUS_INVALID_PARAMETER: u64 = 0xC000_000D;
    let handle_ptr = ctx.gpr[3] as u32;
    let timer_type = ctx.gpr[5] as u32;
    if timer_type > 1 {
        ctx.gpr[3] = STATUS_INVALID_PARAMETER;
        return;
    }
    let handle = state.alloc_handle_for(KernelObject::Timer {
        manual_reset: timer_type == 0,
        signaled: false,
        deadline: None,
        period_ticks: 0,
        period_ms: 0,
        callback_routine: 0,
        callback_arg: 0,
        waiters: Vec::new(),
    });
    state.audit_create_with_ctx(
        handle,
        if timer_type == 0 { "Timer/Manual" } else { "Timer/Auto" },
        ctx,
        mem,
        "NtCreateTimer",
    );
    if handle_ptr != 0 {
        mem.write_u32(handle_ptr, handle);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// `NtSetTimerEx(handle, due_time_ptr, routine, mode, routine_arg, resume,
/// period_ms, unk_zero)` — arm a Timer object. Mirrors Canary's
/// [`NtSetTimerEx_entry`](xboxkrnl_threading.cc:897): reads i64 `due_time`
/// (100ns units; negative = relative), converts to an absolute deadline
/// on our tick timebase (same `/100` scale as `parse_timeout`), stores
/// `period_ms` for periodic rearm, and registers the fire in
/// `state.pending_timer_fires` via `arm_timer`.
///
/// APC delivery (`routine != 0`) is deferred — the timer still signals
/// itself on fire, and any `Wait*`-on-the-timer-handle waiter wakes
/// correctly. If a real-world probe shows `timer_apc` warns firing,
/// that's the signal to lift the APC subsystem into its own PR.
fn nt_set_timer_ex(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    const STATUS_INVALID_HANDLE: u64 = 0xC000_0008;
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let due_time_ptr = ctx.gpr[4] as u32;
    let routine = ctx.gpr[5] as u32;
    let _mode = ctx.gpr[6] as u32;
    let routine_arg = ctx.gpr[7] as u32;
    let _resume = ctx.gpr[8] as u32;
    let period_ms = ctx.gpr[9] as u32;

    // Look up handle + confirm it's a Timer. We pull the current hw's
    // timebase separately (immutable borrow) before any mutation of the
    // object to keep the borrow-checker happy.
    let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
    let now = state.now_basis_at(hw_id);

    // 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 `CommandProcessor::InitializeRingBuffer`: the ring is
    // `1 << (size_log2 + 3)` bytes = `1 << (size_log2 + 1)` dwords (`r4` is
    // log2 of the size in quadwords). Kept in sync with
    // `GpuSystem::initialize_ring_buffer`. (Currently bookkeeping-only.)
    state.ring_base = ptr;
    state.ring_size_dwords = 1u32 << (size_log2 + 1);
    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

    // iterate-2V: mirror xenia-canary `VdSwap_entry` (xboxkrnl_video.cc:518-548)
    // FAITHFULLY. The game reserves 64 dwords (256 bytes) in the primary ring
    // at `buffer_ptr`; canary writes a `PM4_TYPE0(SHADER_CONSTANT_FETCH_00_0)`
    // fetch-constant patch followed by `PM4_TYPE3(PM4_XE_SWAP)`, then pads with
    // NOPs — and **NEVER touches `CP_RB_WPTR`**. The game advances the primary
    // ring write-pointer itself via its own doorbell once it has finished
    // populating the reserved slot, so VdSwap only fills the bytes.
    //
    // iterate-2V FIX (the bug this removes): a prior revision bumped the
    // primary ring `CP_RB_WPTR` out-of-band here (`extend_write_ptr_by(64)`).
    // But `buffer_ptr` (~0x4add6efc) is NOT inside the primary ring (base
    // ~0x4adcd000, 8192 dwords) — it lives ~10k dwords past it, in the
    // renderer indirect-buffer region. The bogus WPTR bump pushed the GPU
    // read-pointer PAST the guest's real write-pointer, the drain treated the
    // overshoot as a circular wrap, and **re-executed the splash's draw
    // indirect-buffers ~2×** — inflating draws to 78 (real splash ≈ 28; 12
    // INDIRECT_BUFFERs vs the real 6). Canary's `VdSwap_entry` writes the
    // block and returns; the swap-complete CP interrupt comes only from the
    // game's own in-stream `PM4_INTERRUPT` packets, never from VdSwap.
    if buffer_ptr != 0 {
        let mut off = 0u32;
        let mut put = |i: &mut u32, v: u32| {
            mem.write_u32(buffer_ptr + *i * 4, v);
            *i += 1;
        };
        // PM4_TYPE0 fetch-constant slot-0 patch (6 dwords payload). The
        // base_address field is patched to the physical frontbuffer so the
        // bloom/blur "sample frame N for frame N+1" path reads the right page.
        let mut patched = fetch_dwords;
        patched[1] = (patched[1] & 0x0000_0FFF) | ((frontbuffer_addr >> 12) << 12);
        put(
            &mut off,
            xenia_gpu::pm4::make_packet_type0(
                xenia_gpu::gpu_system::CONST_BASE_FETCH as u16,
                6,
            ),
        );
        for d in patched {
            put(&mut off, d);
        }
        // PM4_TYPE3(PM4_XE_SWAP, 4 dwords): signature, frontbuffer_phys, w, h.
        put(
            &mut off,
            xenia_gpu::pm4::make_packet_type3(xenia_gpu::pm4::PM4_XE_SWAP, 4),
        );
        put(&mut off, xenia_gpu::pm4::SWAP_SIGNATURE);
        put(&mut off, frontbuffer_addr);
        put(&mut off, width);
        put(&mut off, height);
        // Pad the remainder with NOP (Type-2) packets.
        while off < 64 {
            put(&mut off, xenia_gpu::pm4::make_packet_type2());
        }
    }
    // NOTE: We deliberately do NOT bump `CP_RB_WPTR` here (see the iterate-2V
    // comment above). The drain below consumes only the packets the game has
    // legitimately advanced the write-pointer over.

    // Drain the ring up to whatever the game has actually submitted; any
    // in-stream `PM4_INTERRUPT` / draw packets execute in order. The
    // reserved-slot PM4_XE_SWAP is consumed by the GPU only once the game
    // advances its own doorbell over it. The swap-counter safety net below
    // keeps host swap bookkeeping live in the meantime.
    let drained = state.gpu.drain_to_current_wptr(mem);
    tracing::debug!(drained, "VdSwap: drained PM4 packets");

    // Safety net: if the drain did NOT reach our PM4_XE_SWAP this call (e.g.
    // an undersized inline deadline left game-batched packets pending), still
    // bump the host swap counter so the UI present + swap stats stay live.
    // Skip when the in-stream PM4_XE_SWAP already recorded this frontbuffer
    // (avoids double-counting). This path does NOT raise a CP interrupt.
    if frontbuffer_addr != 0 && width > 0 && height > 0 {
        let already_swapped = state
            .gpu
            .as_inline_mut()
            .map(|g| g.last_swap.map(|s| s.frontbuffer_phys) == Some(frontbuffer_addr))
            .unwrap_or(false);
        if !already_swapped {
            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,
        );
        if std::env::var("XENIA_CONST_LOG").is_ok() {
            let nz: Vec<usize> = constants
                .alu
                .iter()
                .enumerate()
                .filter(|(_, v)| v.iter().any(|c| *c != 0.0))
                .map(|(i, _)| i)
                .collect();
            eprintln!(
                "CONST-LOG alu_nonzero={} idxs={:?} c254={:?} c255={:?} c510={:?} c511={:?}",
                nz.len(), nz, constants.alu[254], constants.alu[255],
                constants.alu[510], constants.alu[511]
            );
        }
        ui.publish_assets(blobs, constants);

        // P5b: publish the texture the last draw's *active pixel shader*
        // actually sampled. The GPU draw handler decodes the PS's real
        // `tfetch` fetch-constant slots into `last_draw_textures`; we publish
        // the first (the UI binds a single texture today). When the last draw
        // used a flat (no-tfetch) shader the list is empty, so we fall back to
        // the legacy slot-0 probe to preserve behavior on flat-only frames.
        // The legacy single-texture `publish_texture` bridge wants
        // `(TextureKey, bytes)`; `last_draw_textures` now also carries the
        // content version (for the per-draw host-cache re-upload). Drop it here.
        let published = gpu_inline
            .last_draw_textures
            .first()
            .map(|(_slot, k, _v, b)| (*k, b.clone()))
            .or_else(|| {
            // Fallback: probe fetch constant slot 0 directly. Texture fetch
            // constants live at `CONST_BASE_FETCH + slot*6` in the register
            // file; read 6 dwords, decode the key, hit the CPU cache with
            // page-version freshness, clone the 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 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
                }
            }
        });
        metrics::gauge!("gpu.texture_cache.entries")
            .set(gpu_inline.texture_cache.len() as f64);
        ui.publish_texture(published);

        // iterate-3O: publish this frame's captured per-draw geometry and
        // reset the accumulator for the next frame. The UI replays these as
        // real guest draws (real vertices + prim type) instead of synthetic
        // placeholder shapes. `frame_captures` is `Some` only under `--ui`.
        if let Some(caps) = gpu_inline.frame_captures.as_mut() {
            let drained = std::mem::take(caps);
            metrics::counter!("gpu.geometry.published").increment(drained.len() as u64);
            ui.publish_geometry(drained);
        }
    }
    // 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;
            // The frontbuffer is a guest *physical* address; project onto the
            // committed backing window (see `xenia_gpu::physical_to_backing`)
            // so the present reads the pixels the GPU resolved, not a stale /
            // zero mirror page.
            let fb_backing = xenia_gpu::physical_to_backing(swap.frontbuffer_phys);
            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 = fb_backing.wrapping_add(i as u32);
                tiled.push(mem.read_u8(addr));
                if addr < fb_backing {
                    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,
        submitted_frames: 0,
    };
    let Some(index) = state.xaudio.register(client) else {
        tracing::warn!("XAudioRegisterRenderDriverClient: client table full");
        ctx.gpr[3] = X_E_INVALIDARG;
        return;
    };
    let driver_id = XAUDIO_DRIVER_TAG | (index as u32 & XAUDIO_DRIVER_INDEX_MASK);
    if driver_ptr != 0 {
        mem.write_u32(driver_ptr, driver_id);
    }

    // AUDIT-032 Plan B: spawn a dedicated audio-worker guest thread for
    // this client and park it on a synthetic `WaitAny` handle so
    // `try_inject_audio_callback` can flip it to `ServicingIrq` when
    // a buffer-complete fire is queued. Mirrors xenia-canary's
    // `apu/audio_system.cc:84-159` host worker without spawning a host OS
    // thread. Failure here is non-fatal (the client is still registered;
    // the periodic ticker will queue fires that the round prologue
    // simply drops with `dropped += 1` because there's no worker to pump).
    let worker_stack = 0x10_000u32; // 64 KiB — half of canary's 128 KiB.
    let worker_ref_handle = if let Some(image) =
        crate::thread::allocate_thread_image(state, mem, worker_stack, 0)
    {
        use std::sync::atomic::Ordering;
        let tid = state.next_thread_id.fetch_add(1, Ordering::Relaxed);
        let handle = state.alloc_handle_for(KernelObject::Thread {
            id: tid,
            hw_id: None,
            exit_code: None,
            waiters: Vec::new(),
        });
        let tls_slot_count = state.next_tls_index.load(Ordering::Relaxed);
        let params = SpawnParams {
            entry: callback_pc,
            start_context: wrapped,
            stack_base: image.stack_base,
            stack_size: image.stack_size,
            pcr_base: image.pcr_base,
            tls_base: image.tls_base,
            thread_handle: handle,
            guest_tid: tid,
            create_suspended: true,
            is_initial: false,
            tls_slot_count,
            affinity_mask: 0,
            priority: 0,
            ideal_processor: None,
        };
        match state.scheduler.spawn(params, &mut GuestMemoryPcr(mem)) {
            Ok(hw_id) => {
                if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
                    *slot = Some(hw_id);
                }
                // Flip from `Blocked(Suspended)` (set by spawn for
                // create_suspended=true) to a `Blocked(WaitAny)` on a
                // synthetic handle never owned by any kernel object.
                // `wake_eligible_waiters` looks the handle up in
                // `state.objects` and returns early on miss, so this
                // park-state is only released by audio-callback injection.
                let park_handle = crate::xaudio::synthetic_park_handle(index);
                let target = ThreadRef::new(
                    hw_id,
                    (state.scheduler.slots[hw_id as usize].runqueue.len() - 1) as u16,
                );
                // Both Blocked(Suspended) (set by spawn) and
                // Blocked(WaitAny) are non-runnable, so the
                // `non_empty_runnable` bitmask is unchanged — no need to
                // call the private `recompute_slot_runnable` helper.
                state.scheduler.thread_mut(target).state =
                    xenia_cpu::scheduler::HwState::Blocked(BlockReason::WaitAny {
                        handles: vec![park_handle],
                        deadline: None,
                    });
                Some((handle, target))
            }
            Err(_) => {
                tracing::warn!("XAudioRegisterRenderDriverClient: spawn failed for worker idx={}", index);
                None
            }
        }
    } else {
        tracing::warn!("XAudioRegisterRenderDriverClient: allocate_thread_image failed for worker idx={}", index);
        None
    };
    if let Some((h, r)) = worker_ref_handle {
        state.xaudio.worker_handles[index] = Some(h);
        state.xaudio.worker_refs[index] = Some(r);
    }

    tracing::info!(
        "XAudioRegisterRenderDriverClient: index={} callback={:#010x} arg={:#010x} wrapped={:#010x} driver={:#010x} worker_handle={:?}",
        index, callback_pc, callback_arg, wrapped, driver_id,
        state.xaudio.worker_handles[index],
    );
    ctx.gpr[3] = 0;
}

fn xaudio_unregister_render_driver(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let driver_id = ctx.gpr[3] as u32;
    let index = (driver_id & XAUDIO_DRIVER_INDEX_MASK) as usize;
    state.xaudio.unregister(index);
    tracing::info!(
        "XAudioUnregisterRenderDriverClient: driver={:#010x} index={}",
        driver_id, index,
    );
    ctx.gpr[3] = 0;
}

/// Mirrors canary `XAudioSubmitRenderDriverFrame_entry` →
/// `AudioSystem::SubmitFrame(driver_ptr & 0xFFFF, samples)`:
/// the guest render-driver mixer (`sub_824DC350`) calls this once per audio
/// frame with `r3 = driver_id` (`0x4155_xxxx`) and `r4 = sample buffer`.
/// Canary forwards `samples` to the client's `AudioDriver`; the driver's
/// playback-completion callback later releases the client semaphore, which is
/// the buffer-consumed pacing our XAudio callback ticker
/// (`tick_instr` + `try_inject_audio_callback`) already drives. SubmitFrame
/// returns void and the caller discards r3 / reads no field SubmitFrame
/// writes, so faithfully we validate the client index and account the frame
/// (observational; never read back by the guest). Always returns
/// `X_ERROR_SUCCESS`, matching canary. Deterministic: only this guest-driven
/// export mutates state; no wall-clock, no host thread.
fn xaudio_submit_render_driver_frame(
    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;
    let registered = state.xaudio.record_submit(index);
    if !registered {
        // Canary logs and submits silence to keep the callback chain alive
        // for an unregistered/invalid index; our ticker keeps the chain
        // alive independently, so a debug log suffices.
        tracing::debug!(
            driver_id = format_args!("{driver_id:#010x}"),
            index,
            "XAudioSubmitRenderDriverFrame: unregistered client index"
        );
    } else if state.xaudio.submitted_frames(index) == 1 {
        tracing::info!(
            driver_id = format_args!("{driver_id:#010x}"),
            index,
            "XAudioSubmitRenderDriverFrame: first frame submitted by guest mixer"
        );
    }
    ctx.gpr[3] = 0;
}

/// Mirrors xenia-canary `XMACreateContext_entry(lpdword_t context_out_ptr)`:
/// allocate a context from the register-mapped array, write its guest pointer
/// to `*context_out_ptr`, and return `X_STATUS_SUCCESS` (or `X_STATUS_NO_MEMORY`
/// when the 320-slot array is exhausted).
fn xma_create_context(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let out_ptr = ctx.gpr[3] as u32;
    let context_ptr = state.xma.lock().unwrap().allocate_context();
    if out_ptr != 0 {
        mem.write_u32(out_ptr, context_ptr);
    }
    tracing::info!(
        out_ptr = format_args!("{out_ptr:#010x}"),
        context_ptr = format_args!("{context_ptr:#010x}"),
        "XMACreateContext"
    );
    ctx.gpr[3] = if context_ptr == 0 {
        0xC000_0017 // X_STATUS_NO_MEMORY
    } else {
        0 // X_STATUS_SUCCESS
    };
}

/// Mirrors xenia-canary `XMAReleaseContext_entry(lpvoid_t context_ptr)`:
/// free the context slot and return 0.
fn xma_release_context(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let context_ptr = ctx.gpr[3] as u32;
    state.xma.lock().unwrap().release_context(context_ptr);
    tracing::info!(context_ptr = format_args!("{context_ptr:#010x}"), "XMAReleaseContext");
    ctx.gpr[3] = 0;
}

// ===== Xex =====

/// Mirrors xenia-canary `XexCheckExecutablePrivilege_entry`
/// (xboxkrnl_modules.cc:22-39): returns whether bit `privilege` of the
/// loaded executable module's `XEX_HEADER_SYSTEM_FLAGS` (key 0x00030000)
/// is set. Privilege ≥ 32 returns 0 (matches `1 << priv` UB-via-overflow
/// behavior — canary's mask becomes 0 once the shift saturates the
/// uint32_t range, and `(flags & 0)` is always 0).
fn xex_check_executable_privilege(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let privilege = ctx.gpr[3] as u32;
    let result = if privilege < 32 {
        (state.xex_system_flags >> privilege) & 1
    } else {
        0
    };
    if state.xex_priv_logged.insert(privilege) {
        tracing::info!(
            priv = privilege,
            flags = format_args!("{:#010x}", state.xex_system_flags),
            result,
            lr = format_args!("{:#010x}", ctx.lr),
            "XexCheckExecutablePrivilege",
        );
    }
    ctx.gpr[3] = result as u64;
}

fn xex_get_procedure_address(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // Mirrors xenia-canary XexGetProcedureAddress_entry
    // (xboxkrnl_modules.cc:195): r3 = hmodule, r4 = ordinal,
    // r5 = lpdword_t out_function_ptr. Returns NTSTATUS in r3; on success
    // writes the resolved thunk address to *out_function_ptr.
    let hmodule = ctx.gpr[3] as u32;
    let ordinal = ctx.gpr[4] as u32;
    let out_ptr = ctx.gpr[5] as u32;
    if out_ptr != 0 {
        mem.write_u32(out_ptr, 0);
    }

    let Some(module) = state.module_id_from_hmodule(hmodule) else {
        tracing::warn!(
            "XexGetProcedureAddress: unknown hmodule={:#x} ordinal={:#x}",
            hmodule,
            ordinal,
        );
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        return;
    };
    match state.resolve_thunk(module, ordinal as u16) {
        Some(addr) => {
            if out_ptr != 0 {
                mem.write_u32(out_ptr, addr);
            }
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        None => {
            tracing::warn!(
                "XexGetProcedureAddress: ordinal {:#x} not registered for {:?}",
                ordinal,
                module,
            );
            // STATUS_DRIVER_ENTRYPOINT_NOT_FOUND == 0xC000_0034.
            ctx.gpr[3] = STATUS_OBJECT_NAME_NOT_FOUND;
        }
    }
}

// ===== Exception handling =====

fn c_specific_handler(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    tracing::warn!("__C_specific_handler called (exception handling stub)");
    ctx.gpr[3] = 1; // ExceptionContinueSearch
}

// ===== Synchronization (events / semaphores / waits) =====

/// Is the handle currently signaled / acquirable? For events and semaphores
/// this tests the counting state; for thread handles it's true once the
/// thread has exited.
pub(crate) fn handle_signaled(state: &KernelState, handle: u32) -> bool {
    match state.objects.get(&handle) {
        Some(KernelObject::Event { signaled, .. }) => *signaled,
        Some(KernelObject::Timer { signaled, .. }) => *signaled,
        Some(KernelObject::Semaphore { count, .. }) => *count > 0,
        Some(KernelObject::Thread { exit_code, .. }) => exit_code.is_some(),
        _ => false,
    }
}

/// Refresh a PKEVENT/PKSEMAPHORE shadow from the guest's dispatcher
/// struct. Handle-keyed Nt objects (small integer keys) are managed
/// entirely by the kernel and don't need this — but pointer-keyed Ke
/// shadows can desync when the guest signals the dispatcher via a direct
/// memory write (e.g. Sylpheed's graphics-interrupt callback writes
/// `SignalState = 1` into its user_data struct instead of going through
/// `KeSetEvent`). Before a wait check, we re-load byte +4 and reconcile
/// the shadow's `signaled` / `count` with guest memory so the wait
/// reflects the current dispatcher state.
///
/// Without this, tid=5's render-dispatcher poll loop on the Sylpheed
/// intro spun 4.5M times per 100M instructions with only 11K resolved
/// wakes — the callback was firing but the shadow stayed unsignaled,
/// so every wait deadlined to `STATUS_TIMEOUT` and the worker looped
/// without ever running its real render path.
fn refresh_pkevent_shadow_from_guest(state: &mut KernelState, mem: &GuestMemory, ptr: u32) {
    if ptr < 0x1_0000 {
        return;
    }
    let Some(obj) = state.objects.get_mut(&ptr) else {
        return;
    };
    let signal_state = mem.read_u32(ptr + 4);
    match obj {
        KernelObject::Event { signaled, .. } | KernelObject::Timer { signaled, .. } => {
            if signal_state != 0 {
                *signaled = true;
            }
            // Intentionally only pull the rising edge from guest
            // memory. If the guest wrote 0 but the shadow says
            // signaled=true because a `KeSetEvent` hasn't been
            // consumed yet, we'd spuriously clear; leave clearing
            // to `KeResetEvent` / auto-reset `handle_consume`.
        }
        KernelObject::Semaphore { count, .. } => {
            let guest_count = signal_state as i32;
            if guest_count > *count {
                *count = guest_count;
            }
        }
        _ => {}
    }
}

/// Consume one signal slot on a handle (auto-reset events, semaphore
/// decrement, mutex-ish). Assumes `handle_signaled` just returned true.
pub(crate) fn handle_consume(state: &mut KernelState, handle: u32) {
    match state.objects.get_mut(&handle) {
        Some(KernelObject::Event {
            manual_reset,
            signaled,
            ..
        })
        | Some(KernelObject::Timer {
            manual_reset,
            signaled,
            ..
        }) => {
            if !*manual_reset {
                *signaled = false;
            }
        }
        Some(KernelObject::Semaphore { count, .. }) => {
            if *count > 0 {
                *count -= 1;
            }
        }
        _ => {}
    }
}

/// Register a guest thread as a waiter on a handle (for later wake).
pub(crate) fn handle_enqueue_waiter(state: &mut KernelState, handle: u32, r: ThreadRef) {
    match state.objects.get_mut(&handle) {
        Some(KernelObject::Event { waiters, .. })
        | Some(KernelObject::Semaphore { waiters, .. })
        | Some(KernelObject::Thread { waiters, .. })
        | Some(KernelObject::Timer { waiters, .. })
        | Some(KernelObject::Mutex { waiters, .. }) => {
            if !waiters.contains(&r) {
                waiters.push(r);
            }
        }
        _ => {}
    }
}

/// Remove a ThreadRef from every waiter list it might be on. Called on wake
/// so a thread woken on one of its WaitAny handles doesn't linger as a
/// waiter on the others.
pub(crate) fn handle_remove_waiter_everywhere(state: &mut KernelState, r: ThreadRef) {
    for obj in state.objects.values_mut() {
        if let Some(waiters) = obj.waiters_mut() {
            waiters.retain(|&w| w != r);
        }
    }
    for list in state.cs_waiters.values_mut() {
        list.retain(|&w| w != r);
    }
}

/// Parse a PowerPC-style LARGE_INTEGER timeout pointer.
/// Returns `None` for "wait forever" (null pointer), `Some(0)` for
/// "poll / don't block" (timeout value 0), else `Some(abs_deadline)`.
/// Xbox 360 timeouts are signed 100-ns units; negative = relative.
/// We convert to an absolute deadline on the current thread's timebase.
pub(crate) fn parse_timeout(state: &KernelState, timeout_ptr: u32, mem: &GuestMemory) -> Option<Option<u64>> {
    if timeout_ptr == 0 {
        return Some(None); // wait infinitely
    }
    let hi = mem.read_u32(timeout_ptr) as i32;
    let lo = mem.read_u32(timeout_ptr + 4);
    let raw = ((hi as i64) << 32) | (lo as i64 & 0xFFFF_FFFF);
    if raw == 0 {
        return Some(Some(0)); // poll
    }
    let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
    let now = state.now_basis_at(hw_id);
    // Negative = relative, positive = absolute wall-clock. Our timebase is a
    // plain instruction counter, so we treat all timeouts as "time-units
    // after now" regardless of sign, using the magnitude.
    let magnitude = raw.unsigned_abs();
    // Scale: 100-ns units → ~1 tick per ns is fine for emulation (games just
    // want monotonic progress). Divide by 100 so multi-millisecond timeouts
    // don't exceed u64 and wake quickly.
    let deadline = now.saturating_add(magnitude.max(1) / 100);
    Some(Some(deadline))
}

/// Resolve NT pseudo-handles to real kernel handles, matching Canary's
/// [`ObjectTable::TranslateHandle`](https://github.com/xenia-canary/xenia-canary/blob/canary/src/xenia/kernel/util/object_table.cc):
///
/// * `0xFFFFFFFE` — `NtCurrentThread()` → the currently running thread's handle
/// * `0xFFFFFFFF` — `NtCurrentProcess()` → 0 (not meaningful in our HLE)
/// * anything else passes through untouched
///
/// Every kernel function that accepts a handle argument should translate
/// first. Canary does this centrally in `LookupObject` — we don't have the
/// same chokepoint, so the pattern is "call this at the top of each Ob/Ke/Nt
/// entry point that consumes a handle".
///
/// Without this, Sylpheed's worker-thread prologue calls
/// `ObReferenceObjectByHandle((HANDLE)-2, ...)` (= "get my own thread"),
/// gets `STATUS_INVALID_HANDLE`, and proceeds with a null "thread object
/// pointer" through `KeSetAffinityThread` — the worker then exits without
/// running its real body, leaving the main thread parked forever on the
/// completion event.
fn resolve_pseudo_handle(state: &KernelState, handle: u32) -> u32 {
    match handle {
        0xFFFF_FFFF => 0,
        0xFFFF_FFFE => {
            let hw_id = state.scheduler.current_hw_id().unwrap_or(0);
            state.scheduler.thread_handle(hw_id).unwrap_or(0)
        }
        h => h,
    }
}

/// Lazily register a shadow kernel object for a guest `PKEVENT` / `PKSEMAPHORE`
/// pointer on first touch from a `Ke*` sync function.
///
/// Background: on Xenon the `Nt*` family takes `HANDLE` integers (allocated
/// by us via `alloc_handle`), but the `Ke*` family takes pointers to
/// dispatcher structs in guest memory. `KeInitializeEvent` is an inline
/// helper baked into the game's code — it writes the DISPATCHER_HEADER in
/// place and we never see the call. As a result, when the game later calls
/// e.g. `KeSetEvent(&kevent)`, our handle-lookup misses and the operation
/// silently no-ops, leaving waiters parked forever. That was the root cause
/// of Sylpheed's 562K/50M `KeResetEvent` poll-loop on pointer `0x42450b5c`.
///
/// We mint a shadow [`KernelObject`] in `state.objects` keyed by the guest
/// pointer (pointers live above the handle range — `next_handle` starts at
/// `0x1000` and bumps by 4, so collisions with a real handle are impossible
/// for any sane pointer). Subsequent Ke/Nt operations hit the shadow.
///
/// Xenon DISPATCHER_HEADER layout (big-endian):
///   +0  Type         (u8)  0=NotificationEvent, 1=SynchronizationEvent,
///                          5=Semaphore. Others unsupported (Mutant/Timer
///                          paths fall back to the prior no-op behavior).
///   +1  Absolute     (u8)
///   +2  Size         (u8)  in u32 words
///   +3  Inserted     (u8)
///   +4  SignalState  (i32)
///   +8  WaitListHead (2 × u32)  LIST_ENTRY
/// For KSEMAPHORE, `Limit` (i32) follows at +0x10.
///
/// Caveat: the shadow is authoritative once created. If the guest writes
/// directly into the dispatcher struct bypassing the kernel API, the shadow
/// drifts — but well-behaved NT code never does that.
fn ensure_dispatcher_object(state: &mut KernelState, mem: &GuestMemory, ptr: u32) {
    // Pointer-vs-handle discriminator: our handles are small (<= low
    // tens of thousands for any realistic session). Anything higher is
    // almost certainly a guest pointer. Also bail if already registered.
    if ptr < 0x1_0000 || state.objects.contains_key(&ptr) {
        return;
    }
    let ty = mem.read_u8(ptr);
    let signal_state = mem.read_u32(ptr + 4);
    let obj = match ty {
        0 => KernelObject::Event {
            manual_reset: true,
            signaled: signal_state != 0,
            waiters: Vec::new(),
        },
        1 => KernelObject::Event {
            manual_reset: false,
            signaled: signal_state != 0,
            waiters: Vec::new(),
        },
        5 => {
            let limit = mem.read_u32(ptr + 0x10) as i32;
            KernelObject::Semaphore {
                count: signal_state as i32,
                max: limit.max(1),
                waiters: Vec::new(),
            }
        }
        // KTIMER DISPATCHER_HEADER: type=8 NotificationTimer (manual-reset),
        // type=9 SynchronizationTimer (auto-reset). Mint a disarmed shadow —
        // deadline/period live in KTIMER's extended fields (+0x20 onward)
        // which we don't mirror; games that want the timer armed go through
        // NtSetTimerEx / KeSetTimer (handle-based), and Sylpheed uses the
        // handle path exclusively.
        8 | 9 => KernelObject::Timer {
            manual_reset: ty == 8,
            signaled: signal_state != 0,
            deadline: None,
            period_ticks: 0,
            period_ms: 0,
            callback_routine: 0,
            callback_arg: 0,
            waiters: Vec::new(),
        },
        _ => return,
    };
    state.objects.insert(ptr, obj);
    // Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
    // adoption, stamp the X_DISPATCH_HEADER's wait_list with the kXObjSignature
    // fourcc 'X','E','N','\0' (flink_ptr) and the stash handle (blink_ptr).
    // Game code reads these to recognize already-adopted dispatchers.
    mem.write_u32(ptr + 0x08, 0x58454E00);
    mem.write_u32(ptr + 0x0C, ptr);
}

/// Set `gpr[3]` on a just-woken HW thread to reflect which handle in its
/// wait set was the one that fired. Canary's `WaitMultiple` returns
/// `STATUS_WAIT_0 + index` on WaitAny success; games branch on it. The
/// default pre-populated status is `STATUS_SUCCESS` (== WAIT_0), which only
/// matches when the first handle is the signaling one — anything else
/// looks like a spurious index-0 wake to the caller.
fn set_wake_status_for_waitany(state: &mut KernelState, r: ThreadRef, signaled_handle: u32) {
    use xenia_cpu::scheduler::{BlockReason, HwState};
    let Some(t) = state.scheduler.try_thread_mut(r) else {
        return;
    };
    let idx = match &t.state {
        HwState::Blocked(BlockReason::WaitAny { handles, .. })
        | HwState::ServicingIrq(BlockReason::WaitAny { handles, .. }) => {
            handles.iter().position(|&h| h == signaled_handle)
        }
        _ => None,
    };
    if let Some(i) = idx {
        t.ctx.gpr[3] = i as u64;
    }
}

/// Wake all waiters whose predicate now holds on the given handle (manual
/// reset fans out; auto-reset/semaphore wakes one and consumes).
pub(crate) fn wake_eligible_waiters(state: &mut KernelState, handle: u32) {
    loop {
        let Some(obj) = state.objects.get_mut(&handle) else {
            return;
        };
        let (manual_reset, should_signal, consume) = match obj {
            KernelObject::Event {
                manual_reset,
                signaled,
                waiters,
            }
            | KernelObject::Timer {
                manual_reset,
                signaled,
                waiters,
                ..
            } => {
                if *signaled && !waiters.is_empty() {
                    (*manual_reset, true, !*manual_reset)
                } else {
                    return;
                }
            }
            KernelObject::Semaphore {
                count, waiters, ..
            } => {
                if *count > 0 && !waiters.is_empty() {
                    (false, true, true)
                } else {
                    return;
                }
            }
            KernelObject::Thread {
                exit_code, waiters, ..
            } => {
                if exit_code.is_some() && !waiters.is_empty() {
                    (true, true, false)
                } else {
                    return;
                }
            }
            _ => return,
        };
        if !should_signal {
            return;
        }
        let winner = match obj {
            KernelObject::Event { waiters, .. }
            | KernelObject::Timer { waiters, .. }
            | KernelObject::Semaphore { waiters, .. }
            | KernelObject::Thread { waiters, .. } => {
                if manual_reset {
                    // Take the whole queue at once; manual-reset fires once
                    // and stays signaled so every parked waiter clears.
                    let list = std::mem::take(waiters);
                    for w in list {
                        set_wake_status_for_waitany(state, w, handle);
                        state.scheduler.wake_ref(w);
                        handle_remove_waiter_everywhere(state, w);
                        // scheduler.wake_ref also loses timed-waits entry
                        if state.audit.enabled {
                            // Record one wake per thread woken. `aux` carries
                            // the resolved status (gpr[3]) we just set.
                            let status = state.scheduler.thread(w).ctx.gpr[3];
                            state.audit_wake(handle, 0, "wake_eligible_waiters/manual", status);
                        }
                    }
                    return;
                } else {
                    waiters.remove(0)
                }
            }
            _ => return,
        };
        if consume {
            handle_consume(state, handle);
        }
        set_wake_status_for_waitany(state, winner, handle);
        state.scheduler.wake_ref(winner);
        handle_remove_waiter_everywhere(state, winner);
        if state.audit.enabled {
            let status = state.scheduler.thread(winner).ctx.gpr[3];
            state.audit_wake(handle, 0, "wake_eligible_waiters/auto", status);
        }
        // continue loop for semaphores that may wake more
    }
}

fn ke_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = PKEVENT on Ke* (guest pointer). See `ensure_dispatcher_object`
    // for why we need the lazy-shadow step here.
    let h = ctx.gpr[3] as u32;
    ensure_dispatcher_object(state, mem, h);
    let previous = match state.objects.get_mut(&h) {
        Some(KernelObject::Event { signaled, .. }) => {
            let prev = *signaled;
            *signaled = true;
            prev as u32
        }
        _ => 0,
    };
    state.audit_signal(h, ctx.lr as u32, "KeSetEvent", previous as u64);
    wake_eligible_waiters(state, h);
    ctx.gpr[3] = previous as u64;
}

fn ke_reset_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let h = ctx.gpr[3] as u32;
    ensure_dispatcher_object(state, mem, h);
    let previous = match state.objects.get_mut(&h) {
        Some(KernelObject::Event { signaled, .. }) => {
            let prev = *signaled;
            *signaled = false;
            prev as u32
        }
        _ => 0,
    };
    ctx.gpr[3] = previous as u64;
}

fn nt_set_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let handle = ctx.gpr[3] as u32;
    let prev_ptr = ctx.gpr[4] as u32;
    let previous = match state.objects.get_mut(&handle) {
        Some(KernelObject::Event { signaled, .. }) => {
            let prev = *signaled;
            *signaled = true;
            prev as u32
        }
        _ => 0,
    };
    state.audit_signal(handle, ctx.lr as u32, "NtSetEvent", previous as u64);
    wake_eligible_waiters(state, handle);
    if prev_ptr != 0 {
        mem.write_u32(prev_ptr, previous);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

fn nt_clear_event(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let handle = ctx.gpr[3] as u32;
    if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&handle) {
        *signaled = false;
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// Pulse an event: wake current waiters as if signaled, then leave the event
/// in the non-signaled state. For manual-reset events this wakes *all*
/// parked waiters at once; for auto-reset events it wakes at most one (the
/// first in the FIFO) and implicitly consumes the pulse.
///
/// Canary impl: [xboxkrnl_threading.cc::KePulseEvent_entry](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_threading.cc)
/// → [xevent.cc::XEvent::Pulse](xenia-canary/src/xenia/kernel/xevent.cc).
fn pulse_event_on_object(state: &mut KernelState, key: u32) -> u32 {
    // Capture previous state; then temporarily mark the event signaled so
    // `wake_eligible_waiters` does the right wake-all vs wake-one split.
    let previous = match state.objects.get_mut(&key) {
        Some(KernelObject::Event { signaled, .. }) => {
            let prev = *signaled;
            *signaled = true;
            prev as u32
        }
        _ => return 0,
    };
    wake_eligible_waiters(state, key);
    // Pulse leaves the event non-signaled regardless of type — manual-reset
    // would otherwise stay latched after `wake_eligible_waiters`, and auto-
    // reset with no waiters would linger signaled until the first wait.
    if let Some(KernelObject::Event { signaled, .. }) = state.objects.get_mut(&key) {
        *signaled = false;
    }
    previous
}

fn ke_pulse_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = PKEVENT (guest pointer), r4 = increment, r5 = wait (ignored).
    let h = ctx.gpr[3] as u32;
    ensure_dispatcher_object(state, mem, h);
    let previous = pulse_event_on_object(state, h);
    state.audit_signal(h, ctx.lr as u32, "KePulseEvent", previous as u64);
    ctx.gpr[3] = previous as u64;
}

fn nt_pulse_event(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = previous_state_ptr (optional).
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let prev_ptr = ctx.gpr[4] as u32;
    if !state.objects.contains_key(&handle) {
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        return;
    }
    let previous = pulse_event_on_object(state, handle);
    state.audit_signal(handle, ctx.lr as u32, "NtPulseEvent", previous as u64);
    if prev_ptr != 0 {
        mem.write_u32(prev_ptr, previous);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// Attempt `*count += adjust` with the cap at `max`. Returns `(previous,
/// updated)` where `updated == false` means the adjustment would have
/// exceeded `max` (or overflowed `i32`) and the count was left untouched.
fn try_release_semaphore(count: &mut i32, max: i32, adjust: i32) -> (i32, bool) {
    let prev = *count;
    match count.checked_add(adjust) {
        Some(new) if new <= max => {
            *count = new;
            (prev, true)
        }
        _ => (prev, false),
    }
}

fn ke_release_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = PKSEMAPHORE, r4 = adjustment. Ke-form returns the previous
    // count directly (never a status); if the release would exceed
    // `Limit` the count silently stays put — Canary `xeKeReleaseSemaphore`
    // at xboxkrnl_threading.cc:707-722 marks the success return of
    // `ReleaseSemaphore` `[[maybe_unused]]`.
    let h = ctx.gpr[3] as u32;
    ensure_dispatcher_object(state, mem, h);
    let adjust = ctx.gpr[4] as i32;
    let previous = match state.objects.get_mut(&h) {
        Some(KernelObject::Semaphore { count, max, .. }) => {
            let (prev, _updated) = try_release_semaphore(count, *max, adjust);
            prev
        }
        _ => 0,
    };
    state.audit_signal(h, ctx.lr as u32, "KeReleaseSemaphore", previous as u64);
    wake_eligible_waiters(state, h);
    ctx.gpr[3] = previous as u64;
}

fn nt_release_semaphore(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = release_count, r5 = previous_count* (optional).
    // Canary `NtReleaseSemaphore_entry` (xboxkrnl_threading.cc:771-797)
    // returns `X_STATUS_SEMAPHORE_LIMIT_EXCEEDED` (0xC000_0047) when the
    // post-release count would exceed `Limit`, AND does NOT update the
    // count in that case. `previous_count` is written regardless.
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let release = ctx.gpr[4] as i32;
    let prev_ptr = ctx.gpr[5] as u32;
    let (previous, status) = match state.objects.get_mut(&handle) {
        Some(KernelObject::Semaphore { count, max, .. }) => {
            let (prev, updated) = try_release_semaphore(count, *max, release);
            if updated {
                (prev, STATUS_SUCCESS)
            } else {
                (prev, STATUS_SEMAPHORE_LIMIT_EXCEEDED)
            }
        }
        Some(_) | None => {
            ctx.gpr[3] = STATUS_INVALID_HANDLE;
            return;
        }
    };
    state.audit_signal(handle, ctx.lr as u32, "NtReleaseSemaphore", previous as u64);
    if status == STATUS_SUCCESS {
        wake_eligible_waiters(state, handle);
    }
    if prev_ptr != 0 {
        mem.write_u32(prev_ptr, previous as u32);
    }
    ctx.gpr[3] = status;
}

/// Single-handle wait with timeout. If the handle is already signaled, consume
/// and return success. Otherwise park the current HW thread and set ctx.gpr[3]
/// to STATUS_SUCCESS — when a waker arrives the thread resumes at its caller's
/// return address with success already in r3. Timeout=0 never parks.
fn do_wait_single(ctx: &mut PpcContext, state: &mut KernelState, handle: u32, timeout_ptr: u32, mem: &GuestMemory) {
    state.audit_wait(handle, ctx.lr as u32, "do_wait_single", 0);
    if handle_signaled(state, handle) {
        handle_consume(state, handle);
        ctx.gpr[3] = STATUS_SUCCESS;
        return;
    }
    let deadline_opt = parse_timeout(state, timeout_ptr, mem);
    let deadline = match deadline_opt {
        Some(Some(0)) => {
            ctx.gpr[3] = STATUS_TIMEOUT;
            return;
        }
        Some(Some(d)) => Some(d),
        Some(None) => None,
        None => None,
    };
    let current_ref = state.scheduler.current_ref();
    handle_enqueue_waiter(state, handle, current_ref);
    tracing::debug!(
        "wait_single: hw={} handle={:#x} park{}",
        current_ref.hw_id,
        handle,
        match deadline {
            Some(d) => format!(" until_tick={}", d),
            None => " forever".into(),
        }
    );
    // Pre-populate the return code — most wakes resolve as STATUS_SUCCESS;
    // timeouts overwrite via the scheduler's deadline-wake path.
    ctx.gpr[3] = STATUS_SUCCESS;
    state.scheduler.park_current(BlockReason::WaitAny {
        handles: vec![handle],
        deadline,
    });
}

/// Multi-handle wait. `wait_type` 0 = WaitAll, 1 = WaitAny (NT convention).
fn do_wait_multiple(
    ctx: &mut PpcContext,
    state: &mut KernelState,
    handles: Vec<u32>,
    wait_all: bool,
    timeout_ptr: u32,
    mem: &GuestMemory,
) {
    if state.audit.enabled {
        // Pack (wait_all flag) | (handle_count << 1) into aux for the trail.
        let aux = (wait_all as u64) | ((handles.len() as u64) << 1);
        for &h in &handles {
            state.audit_wait(h, ctx.lr as u32, "do_wait_multiple", aux);
        }
    }
    let already_ok = if wait_all {
        handles.iter().all(|&h| handle_signaled(state, h))
    } else {
        handles.iter().any(|&h| handle_signaled(state, h))
    };
    if already_ok {
        // Canary's `XObject::WaitMultiple` returns the **index** of the
        // first-signaled handle for WaitAny (`STATUS_WAIT_0 + n`), not
        // plain `STATUS_SUCCESS`. `STATUS_WAIT_0` is numerically 0, so
        // index 0 still looks like success, but index 1+ matters: games
        // commonly dispatch on the index. Sylpheed's worker prologue does
        // `wait_any([start_event, work_sem])` and branches on the result:
        // 0 means "start-event fired" (cleanup/exit), 1 means "sem fired"
        // (run user proc then signal completion). Returning 0 for a sem
        // wake made the worker always take the cleanup branch and exit
        // without ever signaling the completion event.
        if wait_all {
            for &h in &handles {
                handle_consume(state, h);
            }
            ctx.gpr[3] = STATUS_SUCCESS;
        } else if let Some((idx, &h)) = handles
            .iter()
            .enumerate()
            .find(|&(_, &h)| handle_signaled(state, h))
        {
            handle_consume(state, h);
            ctx.gpr[3] = idx as u64; // STATUS_WAIT_0 + idx
        } else {
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        return;
    }
    let deadline_opt = parse_timeout(state, timeout_ptr, mem);
    let deadline = match deadline_opt {
        Some(Some(0)) => {
            ctx.gpr[3] = STATUS_TIMEOUT;
            return;
        }
        Some(Some(d)) => Some(d),
        Some(None) => None,
        None => None,
    };
    let current_ref = state.scheduler.current_ref();
    for &h in &handles {
        handle_enqueue_waiter(state, h, current_ref);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
    let reason = if wait_all {
        BlockReason::WaitAll {
            handles: handles.clone(),
            deadline,
        }
    } else {
        BlockReason::WaitAny { handles, deadline }
    };
    state.scheduler.park_current(reason);
}

fn nt_wait_for_single_object_ex(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = handle, r4 = wait_mode, r5 = alertable, r6 = timeout_ptr
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let timeout_ptr = ctx.gpr[6] as u32;
    do_wait_single(ctx, state, handle, timeout_ptr, mem);
}

/// `NtSignalAndWaitForSingleObjectEx(signal_handle, wait_handle, wait_mode,
/// alertable, timeout_ptr)` — atomically signal one kernel object and wait on
/// another. Matches Canary's `NtSignalAndWaitForSingleObjectEx_entry`
/// (xboxkrnl_threading.cc:1103). Common producer/consumer handshake primitive:
/// producer calls `NSAWFSO(work_done, work_free)` so the consumer's wait
/// resolves at the same instant the producer starts waiting for the next
/// bucket.
///
/// Before this export existed games that relied on the primitive saw the
/// call surface as `unimplemented kernel export`, their threads proceeded
/// without the signal being fired, and the paired consumer-thread wait
/// would block indefinitely. Sylpheed's I/O dispatcher uses this for its
/// async file-query completion signaling.
fn nt_signal_and_wait_for_single_object_ex(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = signal_handle, r4 = wait_handle, r5 = wait_mode, r6 = alertable, r7 = timeout_ptr
    let signal_handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let wait_handle = resolve_pseudo_handle(state, ctx.gpr[4] as u32);
    let timeout_ptr = ctx.gpr[7] as u32;

    // Signal phase — mirror `nt_set_event` for Event handles; if the
    // handle is unknown we return `STATUS_INVALID_HANDLE` without waiting,
    // matching Canary's "lookup both, fail fast if either missing" guard.
    let signal_prev: u64 = match state.objects.get_mut(&signal_handle) {
        Some(KernelObject::Event { signaled, .. }) => {
            let was = *signaled;
            *signaled = true;
            was as u64
        }
        Some(KernelObject::Semaphore { count, .. }) => {
            let was = *count as u64;
            *count = count.saturating_add(1);
            was
        }
        _ => {
            ctx.gpr[3] = STATUS_INVALID_HANDLE;
            return;
        }
    };
    state.audit_signal(
        signal_handle,
        ctx.lr as u32,
        "NtSignalAndWaitForSingleObjectEx",
        signal_prev,
    );
    wake_eligible_waiters(state, signal_handle);

    // Then fall into the normal single-wait path on wait_handle.
    do_wait_single(ctx, state, wait_handle, timeout_ptr, mem);
}

fn ke_wait_for_single_object(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = PKEVENT (guest pointer), r4 = wait_reason, r5 = wait_mode,
    // r6 = alertable, r7 = timeout_ptr
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    ensure_dispatcher_object(state, mem, handle);
    refresh_pkevent_shadow_from_guest(state, mem, handle);
    let timeout_ptr = ctx.gpr[7] as u32;
    do_wait_single(ctx, state, handle, timeout_ptr, mem);
}

fn nt_wait_for_multiple_objects_ex(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = count, r4 = handles_ptr, r5 = wait_type (0=All, 1=Any),
    // r6 = wait_mode, r7 = alertable, r8 = timeout_ptr
    let count = ctx.gpr[3] as u32;
    let handles_ptr = ctx.gpr[4] as u32;
    let wait_type = ctx.gpr[5] as u32;
    let timeout_ptr = ctx.gpr[8] as u32;
    let handles: Vec<u32> = (0..count)
        .map(|i| resolve_pseudo_handle(state, mem.read_u32(handles_ptr + i * 4)))
        .collect();
    let wait_all = wait_type == 0;
    do_wait_multiple(ctx, state, handles, wait_all, timeout_ptr, mem);
}

fn ke_wait_for_multiple_objects(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = count, r4 = objects_ptr (array of PKEVENT/PKSEMAPHORE pointers),
    // r5 = wait_type, r6 = wait_reason, r7 = wait_mode, r8 = alertable,
    // r9 = timeout_ptr, r10 = wait_blocks (ignored)
    let count = ctx.gpr[3] as u32;
    let handles_ptr = ctx.gpr[4] as u32;
    let wait_type = ctx.gpr[5] as u32;
    let timeout_ptr = ctx.gpr[9] as u32;
    let handles: Vec<u32> = (0..count)
        .map(|i| resolve_pseudo_handle(state, mem.read_u32(handles_ptr + i * 4)))
        .collect();
    for &h in &handles {
        ensure_dispatcher_object(state, mem, h);
        refresh_pkevent_shadow_from_guest(state, mem, h);
    }
    let wait_all = wait_type == 0;
    do_wait_multiple(ctx, state, handles, wait_all, timeout_ptr, mem);
}

fn ke_delay_execution_thread(
    ctx: &mut PpcContext,
    mem: &GuestMemory,
    state: &mut KernelState,
) {
    // r3 = wait_mode, r4 = alertable, r5 = interval_ptr (LARGE_INTEGER 100-ns)
    let interval_ptr = ctx.gpr[5] as u32;
    let deadline_opt = parse_timeout(state, interval_ptr, mem);
    let deadline = match deadline_opt {
        Some(Some(0)) => {
            // Yield-like — return immediately.
            ctx.gpr[3] = STATUS_SUCCESS;
            return;
        }
        Some(Some(d)) => d,
        Some(None) => u64::MAX, // KeDelayExecution with NULL interval = sleep forever (unusual)
        None => u64::MAX,
    };
    ctx.gpr[3] = STATUS_SUCCESS;
    state
        .scheduler
        .park_current(BlockReason::DelayUntil(deadline));
}

fn nt_yield_execution(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &mut KernelState) {
    // The next round of the scheduler already hands control to another HW
    // thread, so we don't need to park. Just return success.
    ctx.gpr[3] = STATUS_SUCCESS;
}

/// RE diagnostic (milestone-2 intro-video): `XENIA_LOG_RESUMES=1` makes
/// `Ke/NtResumeThread` emit a `RESUME` line naming the resumed thread's
/// tid, start_entry (so decode workers — entries 0x82506588/0x825065b8 —
/// are identifiable), current pc and the prior suspend_count. Cold-path
/// (resume is rare); inert unless the env is set. Read-only diagnostic.
fn log_resumes_enabled() -> bool {
    use std::sync::OnceLock;
    static EN: OnceLock<bool> = OnceLock::new();
    *EN.get_or_init(|| std::env::var("XENIA_LOG_RESUMES").is_ok())
}

fn ke_resume_thread(ctx: &mut PpcContext, _mem: &GuestMemory, state: &mut KernelState) {
    let raw = ctx.gpr[3] as u32;
    let handle = resolve_pseudo_handle(state, raw);
    match state.scheduler.find_by_handle(handle) {
        Some(r) => {
            let prev = state.scheduler.resume_ref(r);
            if log_resumes_enabled() {
                let t = state.scheduler.thread(r);
                tracing::info!(
                    "RESUME(Ke) tid={} start_entry={:#010x} pc={:#010x} prev_suspend={}",
                    t.tid, t.start_entry, t.ctx.pc, prev,
                );
            }
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        None => {
            ctx.gpr[3] = STATUS_INVALID_HANDLE;
        }
    }
}

fn nt_resume_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = prev_suspend_count_ptr
    let handle = ctx.gpr[3] as u32;
    let prev_ptr = ctx.gpr[4] as u32;
    match state.scheduler.find_by_handle(handle) {
        Some(r) => {
            let prev = state.scheduler.resume_ref(r);
            if log_resumes_enabled() {
                let t = state.scheduler.thread(r);
                tracing::info!(
                    "RESUME(Nt) tid={} start_entry={:#010x} pc={:#010x} prev_suspend={}",
                    t.tid, t.start_entry, t.ctx.pc, prev,
                );
            }
            if prev_ptr != 0 {
                mem.write_u32(prev_ptr, prev);
            }
        }
        None => {
            if prev_ptr != 0 {
                mem.write_u32(prev_ptr, 0);
            }
        }
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

fn nt_suspend_thread(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = prev_suspend_count_ptr
    let handle = ctx.gpr[3] as u32;
    let prev_ptr = ctx.gpr[4] as u32;
    let prev = state
        .scheduler
        .find_by_handle(handle)
        .map(|r| state.scheduler.suspend_ref(r))
        .unwrap_or(0);
    if prev_ptr != 0 {
        mem.write_u32(prev_ptr, prev);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

// ===== Object & module lookup =====

fn xex_get_module_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // Mirrors xenia-canary XexGetModuleHandle_entry
    // (xboxkrnl_modules.cc:42): r3 = lpstring_t module_name,
    // r4 = lpdword_t hmodule_ptr. Returns NTSTATUS in r3; writes the
    // resolved handle to *hmodule_ptr. `X_ERROR_NOT_FOUND` for unknown
    // names. Distinct pseudo-handles for kernel modules so a follow-up
    // `XexGetProcedureAddress` can route to the right ordinal table.
    let name_ptr = ctx.gpr[3] as u32;
    let out_ptr = ctx.gpr[4] as u32;
    if out_ptr != 0 {
        mem.write_u32(out_ptr, 0);
    }

    let resolved: Option<u32> = if name_ptr == 0 {
        Some(state.image_base)
    } else {
        let name = read_cstring(mem, name_ptr);
        if name.is_empty() || name.eq_ignore_ascii_case("default.xex") {
            Some(state.image_base)
        } else if name.eq_ignore_ascii_case("xboxkrnl.exe") {
            Some(crate::state::HMODULE_XBOXKRNL)
        } else if name.eq_ignore_ascii_case("xam.xex") {
            Some(crate::state::HMODULE_XAM)
        } else {
            None
        }
    };

    match resolved {
        Some(h) => {
            if out_ptr != 0 {
                mem.write_u32(out_ptr, h);
            }
            ctx.gpr[3] = STATUS_SUCCESS;
        }
        None => ctx.gpr[3] = X_ERROR_NOT_FOUND,
    }
}

/// `NtDuplicateObject(handle, new_handle_ptr, options)` — per Canary's
/// `NtDuplicateObject_entry`:
///   * r3 = source handle (pseudo-handles like `(HANDLE)-2` are common — the
///     Canary comment explicitly notes "this function seems to be used to get
///     the current thread handle")
///   * r4 = new_handle_ptr (if zero, the call is actually a close)
///   * r5 = options (bit 0 = DUPLICATE_CLOSE_SOURCE)
///
/// Canary allocates a fresh handle id that refcounts the same underlying
/// `XObject`. We don't refcount, so we alias: write the *source* handle back
/// as the "new" handle. The game then uses it interchangeably, and both ids
/// resolve to the same `KernelObject` entry.
///
/// A prior `stub_success` left `*new_handle_ptr` uninitialized — Sylpheed's
/// thread-dispatch prologue does `NtDuplicateObject(event, &dup)` then passes
/// `dup` to the worker, and the worker does `NtSetEvent(dup)` to signal
/// completion. With the stub, `dup` was stack garbage → set-event lookup
/// failed silently → main thread blocked forever on the source event.
fn nt_duplicate_object(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    let source = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let out_ptr = ctx.gpr[4] as u32;
    let options = ctx.gpr[5] as u32;

    if !state.objects.contains_key(&source) {
        if out_ptr != 0 {
            mem.write_u32(out_ptr, 0);
        }
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        return;
    }
    if out_ptr != 0 {
        mem.write_u32(out_ptr, source);
    }
    // Aliased-handle refcount: since we return the source handle as the "new"
    // handle (no fresh id), every duplicate must bump the per-handle refcount
    // so the later `NtClose` pair (one for source, one for dup) doesn't
    // destroy the object mid-flight. `DUPLICATE_CLOSE_SOURCE` (bit 0) closes
    // the source in Canary (xboxkrnl_ob.cc:389), so in our aliased model the
    // source-close cancels the dup-gain: net refcount is unchanged. Without
    // `CLOSE_SOURCE`, both the source and the dup are separately live and we
    // need +1.
    const DUPLICATE_CLOSE_SOURCE: u32 = 0x0000_0001;
    if options & DUPLICATE_CLOSE_SOURCE == 0
        && let Some(c) = state.handle_refcount.get_mut(&source)
    {
        *c += 1;
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

fn ob_reference_object_by_handle(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
    // r3 = handle, r4 = object_type, r5 = out_object_ptr
    let handle = resolve_pseudo_handle(state, ctx.gpr[3] as u32);
    let out_ptr = ctx.gpr[5] as u32;
    if handle == 0 || !state.objects.contains_key(&handle) {
        ctx.gpr[3] = STATUS_INVALID_HANDLE;
        if out_ptr != 0 {
            mem.write_u32(out_ptr, 0);
        }
        return;
    }
    if out_ptr != 0 {
        // We don't maintain real KTHREAD/KEVENT structs in guest memory, so
        // pass back the handle as a stable cookie — downstream Ke* calls
        // that take a "thread pointer" (e.g. KeSetAffinityThread) then look
        // up the same handle via `state.objects`. Matches Canary semantics
        // for our HLE without requiring a host-visible object-struct backing.
        mem.write_u32(out_ptr, handle);
    }
    ctx.gpr[3] = STATUS_SUCCESS;
}

// ===== Helpers =====

fn read_cstring(mem: &GuestMemory, addr: u32) -> String {
    let mut s = String::new();
    let mut a = addr;
    loop {
        let c = mem.read_u8(a);
        if c == 0 { break; }
        s.push(c as char);
        a += 1;
        if s.len() > 512 { break; } // Safety limit
    }
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;
    use xenia_memory::page_table::MemoryProtect;

    /// Scratch region the nt_read_file/nt_write_file tests write into
    /// (iosb + buffer). A single committed page is plenty.
    const SCRATCH_BASE: u32 = 0x4000_0000;

    fn fresh() -> (PpcContext, GuestMemory, KernelState) {
        let mut mem = GuestMemory::new().expect("memory init");
        mem.alloc(SCRATCH_BASE, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("scratch page must commit");
        let mut state = KernelState::new();
        // Under per-slot runqueues, most kernel exports reach through
        // `scheduler.current` — tests that exercise those paths need a
        // live thread installed on slot 0 first. Older tests (file I/O
        // etc.) don't touch it and are unaffected.
        state.install_initial_thread(
            PpcContext::default(),
            0x7000_0000,
            0x10_0000,
            SCRATCH_BASE + 0x800,
            SCRATCH_BASE + 0xC00,
            0x1000,
            &mut mem,
        );
        state.scheduler.begin_slot_visit(0);
        (PpcContext::default(), mem, state)
    }

    fn make_file(state: &mut KernelState, bytes: Vec<u8>) -> u32 {
        let size = bytes.len() as u64;
        state.alloc_handle_for(KernelObject::File {
            path: "test.bin".to_string(),
            size,
            position: 0,
            data: Arc::new(bytes),
            dir_enum_pos: None,
            host_path: None,
        })
    }

    fn make_event(state: &mut KernelState) -> u32 {
        state.alloc_handle_for(KernelObject::Event {
            manual_reset: true,
            signaled: false,
            waiters: Vec::new(),
        })
    }

    fn event_signaled(state: &KernelState, h: u32) -> bool {
        match state.objects.get(&h) {
            Some(KernelObject::Event { signaled, .. }) => *signaled,
            _ => panic!("expected Event at handle {:#x}", h),
        }
    }

    /// Axis 4: `KeSetAffinityThread` actually migrates between slots
    /// now. Spawn a secondary thread with affinity 0x02 (slot 1 only),
    /// then call the export to move it to slot 4.
    #[test]
    fn ke_set_affinity_thread_migrates_and_returns_old() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Pre-fresh() set up the main thread on slot 0. Spawn a worker
        // on slot 1 via ex_create_thread so the handle / PCR are real.
        // Simpler: inject directly via scheduler.spawn.
        use xenia_cpu::scheduler::SpawnParams;
        let pcr_base = SCRATCH_BASE + 0x500;
        mem.write_u32(pcr_base + 0x2C, 0xDEAD_BEEF); // sentinel
        let params = SpawnParams {
            entry: 0x8200_0000,
            start_context: 0,
            stack_base: 0x7200_0000,
            stack_size: 0x10000,
            pcr_base,
            tls_base: 0,
            thread_handle: 0x2000,
            guest_tid: 42,
            create_suspended: false,
            is_initial: false,
            tls_slot_count: 0,
            affinity_mask: 0b0000_0010,
            priority: 0,
            ideal_processor: None,
        };
        state
            .scheduler
            .spawn(params, &mut crate::state::GuestMemoryPcr(&mut mem))
            .unwrap();
        // Confirm PCR was written by the spawn (sanity).
        assert_eq!(mem.read_u32(pcr_base + 0x2C), 1);

        // Now call KeSetAffinityThread(handle=0x2000, new_mask=0x20).
        ctx.gpr[3] = 0x2000;
        ctx.gpr[4] = 0x20; // slot 5 only
        ke_set_affinity_thread(&mut ctx, &mut mem, &mut state);
        // Return value = previous mask = 0x02.
        assert_eq!(ctx.gpr[3], 0x02);
        // PCR rewritten to 5.
        assert_eq!(mem.read_u32(pcr_base + 0x2C), 5);
        // Thread now on slot 5.
        let r = state.scheduler.find_by_handle(0x2000).expect("still alive");
        assert_eq!(r.hw_id, 5);
    }

    /// Axis 5: `KeSetIdealProcessor` stores a hint on the thread
    /// without migrating it; query round-trips.
    #[test]
    fn ke_set_ideal_processor_round_trips() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Main thread handle is 0x1000.
        ctx.gpr[3] = 0x1000;
        ctx.gpr[4] = 3;
        ke_set_ideal_processor(&mut ctx, &mut mem, &mut state);
        // Prior was 0xFF (unset sentinel).
        assert_eq!(ctx.gpr[3], 0xFF);
        ctx.gpr[3] = 0x1000;
        ke_query_ideal_processor(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 3);
    }

    /// Axis 5: `NtSetInformationThread` class `ThreadAffinityMask`
    /// routes through `KernelState::set_affinity` and actually migrates.
    #[test]
    fn nt_set_information_thread_affinity_migrates() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Park info buffer in scratch.
        let info_ptr = SCRATCH_BASE + 0x40;
        mem.write_u32(info_ptr, 0x08); // mask = slot 3
        ctx.gpr[3] = 0x1000; // main handle
        ctx.gpr[4] = 3; // ThreadAffinityMask
        ctx.gpr[5] = info_ptr as u64;
        ctx.gpr[6] = 4; // info_len
        nt_set_information_thread(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        // Main should have migrated to slot 3.
        let r = state.scheduler.find_by_handle(0x1000).expect("still alive");
        assert_eq!(r.hw_id, 3);
    }

    /// Priority wiring — `KeSetBasePriorityThread` stores on the
    /// `GuestThread` and `KeQueryBasePriorityThread` reads it back.
    #[test]
    fn ke_set_base_priority_round_trips() {
        let (mut ctx, mut mem, mut state) = fresh();
        // fresh() installs the main thread with handle 0x1000.
        // Query the current priority first — default 0.
        ctx.gpr[3] = 0x1000;
        ke_query_base_priority_thread(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0);
        // Set priority to 7 (high-ish).
        ctx.gpr[3] = 0x1000;
        ctx.gpr[4] = 7u64;
        ke_set_base_priority_thread(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "old priority was 0");
        // Query again — now 7.
        ctx.gpr[3] = 0x1000;
        ke_query_base_priority_thread(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 7);
    }

    /// `KeResumeThread` resolves the KTHREAD-pointer-as-handle, decrements the
    /// target's suspend count, and unblocks once it hits zero. Mirrors
    /// xboxkrnl_threading.cc:216-227 (XObject::GetNativeObject<XThread> +
    /// thread->Resume()).
    #[test]
    fn ke_resume_thread_unblocks_suspended_worker() {
        use xenia_cpu::scheduler::{BlockReason, HwState, SpawnParams};
        let (mut ctx, mut mem, mut state) = fresh();
        let pcr_base = SCRATCH_BASE + 0x500;
        let params = SpawnParams {
            entry: 0x8200_0000,
            start_context: 0,
            stack_base: 0x7200_0000,
            stack_size: 0x10000,
            pcr_base,
            tls_base: 0,
            thread_handle: 0x2000,
            guest_tid: 42,
            create_suspended: true,
            is_initial: false,
            tls_slot_count: 0,
            affinity_mask: 0b0000_0010,
            priority: 0,
            ideal_processor: None,
        };
        state
            .scheduler
            .spawn(params, &mut crate::state::GuestMemoryPcr(&mut mem))
            .unwrap();
        let r = state.scheduler.find_by_handle(0x2000).expect("spawned");
        assert_eq!(
            state.scheduler.thread(r).state,
            HwState::Blocked(BlockReason::Suspended)
        );

        ctx.gpr[3] = 0x2000;
        ke_resume_thread(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        let r = state.scheduler.find_by_handle(0x2000).expect("still alive");
        assert_eq!(state.scheduler.thread(r).state, HwState::Ready);

        ctx.gpr[3] = 0xDEAD_BEEF;
        ke_resume_thread(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
    }

    /// The regression we're guarding against: Sylpheed parks a thread on the
    /// event it handed to `NtReadFile`. Historically our HLE ignored r4 and
    /// left the event unsignaled — the wait never released. Completion must
    /// signal the event regardless of whether the read succeeds.
    #[test]
    fn nt_read_file_signals_completion_event_on_success() {
        let (mut ctx, mut mem, mut state) = fresh();
        let file = make_file(&mut state, vec![0x11, 0x22, 0x33, 0x44]);
        let evt = make_event(&mut state);
        let iosb: u32 = 0x4000_0000;
        let buf: u32 = 0x4000_0100;
        // r3 = file, r4 = event, r7 = iosb, r8 = buf, r9 = len, r10 = 0 (use cursor)
        ctx.gpr[3] = file as u64;
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = iosb as u64;
        ctx.gpr[8] = buf as u64;
        ctx.gpr[9] = 4;
        ctx.gpr[10] = 0;
        nt_read_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected");
        assert!(event_signaled(&state, evt), "event must be signaled on success");
    }

    #[test]
    fn nt_read_file_signals_event_on_eof() {
        let (mut ctx, mut mem, mut state) = fresh();
        let file = make_file(&mut state, vec![0x01, 0x02]);
        // Seek cursor past end by issuing a first read that drains it.
        if let Some(KernelObject::File { position, .. }) = state.objects.get_mut(&file) {
            *position = 2;
        }
        let evt = make_event(&mut state);
        ctx.gpr[3] = file as u64;
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = 0x4000_0000;
        ctx.gpr[8] = 0x4000_0100;
        ctx.gpr[9] = 4;
        ctx.gpr[10] = 0;
        nt_read_file(&mut ctx, &mut mem, &mut state);
        assert!(event_signaled(&state, evt), "EOF path must still signal");
    }

    #[test]
    fn nt_read_file_signals_event_on_invalid_handle() {
        let (mut ctx, mut mem, mut state) = fresh();
        let evt = make_event(&mut state);
        ctx.gpr[3] = 0xDEAD_BEEF; // bogus file handle
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = 0x4000_0000;
        ctx.gpr[8] = 0x4000_0100;
        ctx.gpr[9] = 4;
        ctx.gpr[10] = 0;
        nt_read_file(&mut ctx, &mut mem, &mut state);
        assert!(event_signaled(&state, evt), "invalid-handle path must still signal");
    }

    /// Many callers pass r4 = 0 (synchronous-wait style). The signal helper
    /// must no-op rather than corrupt the handle table or panic.
    #[test]
    fn nt_read_file_accepts_null_event_handle() {
        let (mut ctx, mut mem, mut state) = fresh();
        let file = make_file(&mut state, vec![0xAA; 8]);
        ctx.gpr[3] = file as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = 0x4000_0000;
        ctx.gpr[8] = 0x4000_0100;
        ctx.gpr[9] = 8;
        ctx.gpr[10] = 0;
        nt_read_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected with null event");
    }

    /// Synthesized empty files (system-partition opens like
    /// `\Device\Harddisk0\partition0` that miss the disc-VFS) act as
    /// canary's `NullDevice`: any `NtReadFile` returns `STATUS_SUCCESS`
    /// with `information=0` and the buffer untouched. Sylpheed's
    /// cache-loader at `sub_824A9710` reads 1024 B from offset 2048 then
    /// validates a `"Josh"` magic — falling back to the recreate path
    /// when the (caller-zeroed) buffer doesn't match.
    #[test]
    fn nt_read_file_synth_empty_file_returns_success_with_zero_bytes() {
        let (mut ctx, mut mem, mut state) = fresh();
        let synth = make_file(&mut state, Vec::new());
        // Pre-fill the buffer with a sentinel; canary's NullDevice never
        // touches it, so the post-read bytes must be unchanged.
        let buf: u32 = 0x4000_0100;
        for i in 0..16u32 {
            mem.write_u8(buf + i, 0xAB);
        }
        let evt = make_event(&mut state);
        // Read 1024 B from offset 2048 — exactly the cache-catalog read.
        let offset_ptr: u32 = 0x4000_0080;
        mem.write_u64(offset_ptr, 2048);
        ctx.gpr[3] = synth as u64;
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = 0x4000_0000;
        ctx.gpr[8] = buf as u64;
        ctx.gpr[9] = 1024;
        ctx.gpr[10] = offset_ptr as u64;
        nt_read_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS for synth-empty read");
        for i in 0..16u32 {
            assert_eq!(mem.read_u8(buf + i), 0xAB, "buffer at +{} must be untouched", i);
        }
        // IOSB.information must be 0 (matches NullFile bytes_read).
        assert_eq!(mem.read_u32(0x4000_0000), 0, "iosb.status = 0");
        assert_eq!(mem.read_u32(0x4000_0004), 0, "iosb.information = 0");
        assert!(event_signaled(&state, evt), "synth-empty read must signal completion");
    }

    #[test]
    fn nt_write_file_signals_completion_event() {
        let (mut ctx, mut mem, mut state) = fresh();
        let evt = make_event(&mut state);
        ctx.gpr[3] = 0x1234; // file handle not consulted on the discard path
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = 0x4000_0000;
        ctx.gpr[9] = 16;
        nt_write_file(&mut ctx, &mut mem, &mut state);
        assert!(event_signaled(&state, evt), "write must signal too");
    }

    /// Verify `FileStandardInformation` reports `Directory=1` for empty-path
    /// (device-root) synthesized file handles. Sylpheed calls
    /// `NtCreateFile("game:\\")` then `NtQueryInformationFile` on the returned
    /// handle as a disc-validation probe — seeing `Directory=0` triggers its
    /// `XamShowDirtyDiscErrorUI` path.
    #[test]
    fn nt_query_information_file_reports_directory_for_root_synth() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Synth a "game:\" style empty-path file, matching what `open_vfs_file`
        // produces when the prefix-strip leaves nothing behind.
        let h = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        let info_buf = SCRATCH_BASE + 0x600;
        ctx.gpr[3] = h as u64; // handle
        ctx.gpr[4] = SCRATCH_BASE as u64; // iosb
        ctx.gpr[5] = info_buf as u64; // file_info
        ctx.gpr[6] = 24; // length
        ctx.gpr[7] = 5; // FileStandardInformation
        nt_query_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected");
        assert_eq!(
            mem.read_u8(info_buf + 21),
            1,
            "Directory byte must be 1 for root-of-device synth"
        );
    }

    /// `NtQueryDirectoryFile` takes an optional completion event at r4
    /// (Canary `xboxkrnl_io.cc:516`). The handler must signal that event
    /// so waiters wake up, and must write the IOSB at r7 (the prior stub
    /// mis-used r4, clobbering low guest memory). Without a VFS mounted
    /// the handler finds no children and reports
    /// `STATUS_NO_MORE_FILES`; the event still has to fire.
    #[test]
    fn nt_query_directory_file_signals_completion_event_and_uses_correct_iosb_reg() {
        let (mut ctx, mut mem, mut state) = fresh();
        let evt = make_event(&mut state);
        // A root-shaped synth directory — exactly what `NtCreateFile("game:\\")`
        // produces when the prefix-strip leaves nothing behind.
        let handle = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        let buf = SCRATCH_BASE + 0x100;
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = SCRATCH_BASE as u64; // IOSB must land here
        ctx.gpr[8] = buf as u64;
        ctx.gpr[9] = 128; // length >= 72 (Canary minimum)
        ctx.gpr[10] = 0;
        nt_query_directory_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_NO_MORE_FILES);
        assert_eq!(mem.read_u32(SCRATCH_BASE), STATUS_NO_MORE_FILES as u32);
        assert!(event_signaled(&state, evt), "completion event must be signaled");
    }

    /// Info-length-mismatch (Canary: length < 72 → STATUS_INFO_LENGTH_MISMATCH).
    #[test]
    fn nt_query_directory_file_rejects_short_buffer() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = 0;
        ctx.gpr[8] = SCRATCH_BASE as u64;
        ctx.gpr[9] = 16; // below 72
        ctx.gpr[10] = 0;
        nt_query_directory_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INFO_LENGTH_MISMATCH);
    }

    /// Minimal `VfsDevice` impl that returns a hard-coded entry list —
    /// lets us drive `NtQueryDirectoryFile` through a real enumeration
    /// path without needing a disc image on disk.
    struct StubVfs {
        entries: Vec<xenia_vfs::VfsEntry>,
    }
    impl xenia_vfs::VfsDevice for StubVfs {
        fn name(&self) -> &str { "stub" }
        fn list_root(&self) -> Result<Vec<xenia_vfs::VfsEntry>, xenia_vfs::VfsError> {
            Ok(self.entries.clone())
        }
        fn read_file(&self, _path: &str) -> Result<Vec<u8>, xenia_vfs::VfsError> {
            Err(xenia_vfs::VfsError::NotFound("stub".into()))
        }
        fn stat(&self, _path: &str) -> Result<xenia_vfs::VfsEntry, xenia_vfs::VfsError> {
            Err(xenia_vfs::VfsError::NotFound("stub".into()))
        }
    }

    /// Real enumeration of the root directory. The stub VFS exposes two
    /// top-level entries and one nested entry; `NtQueryDirectoryFile`
    /// must return the two top-level ones and skip the grandchild.
    #[test]
    fn nt_query_directory_file_enumerates_root_children() {
        let (mut ctx, mut mem, mut state) = fresh();
        state.vfs = Some(Box::new(StubVfs {
            entries: vec![
                xenia_vfs::VfsEntry {
                    name: "default.xex".into(),
                    is_directory: false,
                    size: 0x1000,
                    offset: 0,
                    attributes: 0x81, // NORMAL | READONLY
                },
                xenia_vfs::VfsEntry {
                    name: "dat".into(),
                    is_directory: true,
                    size: 0,
                    offset: 0,
                    attributes: 0x11, // DIRECTORY | READONLY
                },
                // A grandchild — must NOT appear in root enumeration.
                xenia_vfs::VfsEntry {
                    name: "dat/tables.pak".into(),
                    is_directory: false,
                    size: 0x2000,
                    offset: 0,
                    attributes: 0x81,
                },
            ],
        }));
        let handle = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        let buf = SCRATCH_BASE + 0x100;
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = SCRATCH_BASE as u64;
        ctx.gpr[8] = buf as u64;
        ctx.gpr[9] = 512;
        ctx.gpr[10] = 0;
        nt_query_directory_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        // First entry header lives at `buf`: file_name_length at +0x3C,
        // attributes at +0x38, name bytes starting at +0x40. Verify both
        // entries land in the buffer by walking the linked list via
        // NextEntryOffset.
        let mut cursor: u32 = 0;
        let mut names: Vec<String> = Vec::new();
        let mut attrs: Vec<u32> = Vec::new();
        loop {
            let entry_base = buf + cursor;
            let name_len = mem.read_u32(entry_base + 0x3C) as usize;
            attrs.push(mem.read_u32(entry_base + 0x38));
            let mut bytes = Vec::with_capacity(name_len);
            for i in 0..name_len as u32 {
                bytes.push(mem.read_u8(entry_base + 0x40 + i));
            }
            names.push(String::from_utf8(bytes).unwrap());
            let next = mem.read_u32(entry_base);
            if next == 0 {
                break;
            }
            cursor += next;
        }
        assert_eq!(names, vec!["default.xex", "dat"]);
        // The real GDFX attribute byte must be forwarded verbatim: the file
        // reports NORMAL|READONLY (no DIRECTORY bit), the directory reports
        // DIRECTORY|READONLY.
        assert_eq!(attrs, vec![0x81, 0x11]);
        assert_eq!(attrs[0] & 0x10, 0, "file must not advertise DIRECTORY");
        assert_ne!(attrs[1] & 0x10, 0, "dir must advertise DIRECTORY");
        // A second call on the same handle must return NO_MORE_FILES —
        // the cursor has advanced past the end.
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = SCRATCH_BASE as u64;
        ctx.gpr[8] = buf as u64;
        ctx.gpr[9] = 512;
        nt_query_directory_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_NO_MORE_FILES);
    }

    /// Invalid handle → STATUS_INVALID_HANDLE, IOSB gets the error, and
    /// the completion event still fires so callers don't hang.
    #[test]
    fn nt_query_directory_file_invalid_handle_still_signals() {
        let (mut ctx, mut mem, mut state) = fresh();
        let evt = make_event(&mut state);
        ctx.gpr[3] = 0xDEAD_BEEF;
        ctx.gpr[4] = evt as u64;
        ctx.gpr[7] = SCRATCH_BASE as u64;
        ctx.gpr[8] = SCRATCH_BASE as u64 + 0x100;
        ctx.gpr[9] = 128;
        ctx.gpr[10] = 0;
        nt_query_directory_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
        assert_eq!(mem.read_u32(SCRATCH_BASE), STATUS_INVALID_HANDLE as u32);
        assert!(event_signaled(&state, evt));
    }

    /// `NtSignalAndWaitForSingleObjectEx` signals handle A, then does a
    /// single wait on handle B. If A is already signaled via the atomic
    /// set, any waiter on A wakes immediately; the caller then parks on
    /// B (or returns success if B is already signaled). Canary reference:
    /// `xboxkrnl_threading.cc:1103` — `XObject::SignalAndWait`.
    #[test]
    fn nt_signal_and_wait_signals_first_then_waits() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Pre-signaled event we'll wait on — so the whole call returns success.
        let wait_h = state.alloc_handle_for(KernelObject::Event {
            manual_reset: true,
            signaled: true,
            waiters: Vec::new(),
        });
        let signal_h = make_event(&mut state); // starts unsignaled
        ctx.gpr[3] = signal_h as u64;
        ctx.gpr[4] = wait_h as u64;
        ctx.gpr[7] = 0; // timeout_ptr = null → infinite, but wait-handle already signaled
        nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "wait returns STATUS_SUCCESS");
        assert!(event_signaled(&state, signal_h), "signal handle set");
    }

    /// An unknown signal handle must return `STATUS_INVALID_HANDLE` and
    /// NOT fall through to the wait — matches Canary's early-return guard.
    #[test]
    fn nt_signal_and_wait_rejects_unknown_signal_handle() {
        let (mut ctx, mut mem, mut state) = fresh();
        ctx.gpr[3] = 0xDEAD_BEEF;
        ctx.gpr[4] = 0x1234_5678;
        ctx.gpr[7] = 0;
        nt_signal_and_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
    }

    /// `FileNetworkOpenInformation` (class 34) — 56 bytes, `FileAttributes`
    /// at +48 must carry `FILE_ATTRIBUTE_DIRECTORY` (0x10) for root synths.
    /// Sylpheed's async worker asks for this class and the caller dispatches
    /// on the attributes bits; a zeroed buffer meant `Directory` was clear
    /// and forced the dirty-disc path.
    #[test]
    fn nt_query_information_file_network_open_sets_dir_attribute() {
        let (mut ctx, mut mem, mut state) = fresh();
        let h = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        let info_buf = SCRATCH_BASE + 0x200;
        ctx.gpr[3] = h as u64;
        ctx.gpr[4] = SCRATCH_BASE as u64;
        ctx.gpr[5] = info_buf as u64;
        ctx.gpr[6] = 56;
        ctx.gpr[7] = 34; // FileNetworkOpenInformation
        nt_query_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0);
        let attrs = mem.read_u32(info_buf + 48);
        assert_eq!(attrs, 0x10, "FILE_ATTRIBUTE_DIRECTORY expected for root synth");
    }

    /// Normal file paths must still report `Directory=0` so games reading
    /// actual files (`dat/tables.pak`, `config.ini`) don't see them as
    /// directories.
    #[test]
    fn nt_query_information_file_reports_file_for_normal_path() {
        let (mut ctx, mut mem, mut state) = fresh();
        let h = state.alloc_handle_for(KernelObject::File {
            path: "dat/tables.pak".to_string(),
            size: 964,
            position: 0,
            data: std::sync::Arc::new(vec![0; 964]),
            dir_enum_pos: None,
            host_path: None,
        });
        let info_buf = SCRATCH_BASE + 0x700;
        ctx.gpr[3] = h as u64;
        ctx.gpr[4] = SCRATCH_BASE as u64;
        ctx.gpr[5] = info_buf as u64;
        ctx.gpr[6] = 24;
        ctx.gpr[7] = 5;
        nt_query_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u8(info_buf + 21), 0, "normal file not directory");
    }

    #[test]
    fn nt_query_volume_information_file_class3_returns_64k_alloc_unit() {
        let (mut ctx, mut mem, mut state) = fresh();
        let h = state.alloc_handle_for(KernelObject::File {
            path: String::new(),
            size: 0,
            position: 0,
            data: std::sync::Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: None,
        });
        let iosb = SCRATCH_BASE;
        let info_buf = SCRATCH_BASE + 0x100;
        ctx.gpr[3] = h as u64;
        ctx.gpr[4] = iosb as u64;
        ctx.gpr[5] = info_buf as u64;
        ctx.gpr[6] = 24;
        ctx.gpr[7] = 3; // FileFsSizeInformation
        nt_query_volume_information_file(&mut ctx, &mut mem, &mut state);

        assert_eq!(ctx.gpr[3], STATUS_SUCCESS as u64);
        let sectors_per_unit = mem.read_u32(info_buf + 16);
        let bytes_per_sector = mem.read_u32(info_buf + 20);
        assert_eq!(sectors_per_unit, 0x80);
        assert_eq!(bytes_per_sector, 0x200);
        assert_eq!(
            sectors_per_unit * bytes_per_sector,
            0x10000,
            "alloc unit must be 64 KiB to match canary NullDevice",
        );
    }

    // ===== PKEVENT shim =====

    /// Write a DISPATCHER_HEADER at the given guest pointer.
    ///   ty: 0 = Notification (manual-reset), 1 = Synchronization (auto-reset),
    ///       5 = Semaphore.
    fn write_dispatcher_header(mem: &GuestMemory, ptr: u32, ty: u8, signal_state: u32) {
        mem.write_u8(ptr, ty);
        mem.write_u8(ptr + 1, 0); // Absolute
        mem.write_u8(ptr + 2, 4); // Size (u32 words) — four words is plausible
        mem.write_u8(ptr + 3, 0); // Inserted
        mem.write_u32(ptr + 4, signal_state);
        // WaitListHead (8 bytes) — zero-init is fine; shadow owns waiters.
        mem.write_u32(ptr + 8, 0);
        mem.write_u32(ptr + 12, 0);
    }

    #[test]
    fn ke_set_event_shadows_pkevent_pointer() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x100;
        write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization, unsignaled
        ctx.gpr[3] = kevent_ptr as u64;
        ke_set_event(&mut ctx, &mut mem, &mut state);
        // Shadow must have been minted AND signaled.
        match state.objects.get(&kevent_ptr) {
            Some(KernelObject::Event { manual_reset, signaled, .. }) => {
                assert!(!*manual_reset, "type=1 must be auto-reset");
                assert!(*signaled, "ke_set_event must signal the shadow");
            }
            other => panic!("expected Event shadow at pkevent_ptr, got {:?}", other),
        }
    }

    #[test]
    fn ke_reset_event_shadows_pkevent_pointer() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x200;
        // Initial signal state = 1 in guest memory → shadow starts signaled.
        write_dispatcher_header(&mut mem, kevent_ptr, 0, 1); // notification
        ctx.gpr[3] = kevent_ptr as u64;
        ke_reset_event(&mut ctx, &mut mem, &mut state);
        // After reset, shadow exists and is unsignaled; gpr[3] reports previous=1.
        assert_eq!(ctx.gpr[3], 1, "previous state must be reported");
        match state.objects.get(&kevent_ptr) {
            Some(KernelObject::Event { manual_reset, signaled, .. }) => {
                assert!(*manual_reset, "type=0 must be manual-reset");
                assert!(!*signaled, "ke_reset_event must clear the shadow");
            }
            other => panic!("expected Event shadow, got {:?}", other),
        }
    }

    /// End-to-end: set + wait across the same PKEVENT pointer. This is the
    /// exact contract Sylpheed relies on — without the shim, KeWait parks
    /// on a nonexistent handle and KeSet no-ops, so the wait never resolves.
    #[test]
    fn ke_set_then_wait_on_pkevent_returns_success() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x300;
        write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization
        // First signal the event.
        ctx.gpr[3] = kevent_ptr as u64;
        ke_set_event(&mut ctx, &mut mem, &mut state);
        // Now wait with timeout = 0 (poll). Since it's signaled, the auto-
        // reset consumes the signal and we should get STATUS_SUCCESS.
        // Timeout pointer at scratch top: LARGE_INTEGER = 0.
        let timeout_ptr = SCRATCH_BASE + 0x800;
        mem.write_u32(timeout_ptr, 0);
        mem.write_u32(timeout_ptr + 4, 0);
        ctx.gpr[3] = kevent_ptr as u64;
        ctx.gpr[7] = timeout_ptr as u64;
        ke_wait_for_single_object(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS expected on signaled wait");
        // Auto-reset: signal must have been consumed.
        match state.objects.get(&kevent_ptr) {
            Some(KernelObject::Event { signaled, .. }) => assert!(!*signaled),
            other => panic!("expected Event shadow, got {:?}", other),
        }
    }

    /// Semaphore shim: header type 5, Limit at +0x10.
    #[test]
    fn ke_release_semaphore_shadows_pksemaphore_pointer() {
        let (mut ctx, mut mem, mut state) = fresh();
        let ksem_ptr = SCRATCH_BASE + 0x400;
        write_dispatcher_header(&mut mem, ksem_ptr, 5, 2); // initial count 2
        mem.write_u32(ksem_ptr + 0x10, 10); // Limit
        ctx.gpr[3] = ksem_ptr as u64;
        ctx.gpr[4] = 1; // adjust = +1
        ke_release_semaphore(&mut ctx, &mut mem, &mut state);
        match state.objects.get(&ksem_ptr) {
            Some(KernelObject::Semaphore { count, max, .. }) => {
                assert_eq!(*count, 3, "count was 2, +1 → 3");
                assert_eq!(*max, 10);
            }
            other => panic!("expected Semaphore shadow, got {:?}", other),
        }
    }

    /// Regression guard: genuine Nt handles must still work unchanged —
    /// the shim's lower-bound check (`ptr < 0x1_0000`) skips our handle
    /// range (0x1000 + 4·N).
    #[test]
    fn ke_set_event_leaves_nt_handles_intact() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = state.alloc_handle_for(KernelObject::Event {
            manual_reset: true,
            signaled: false,
            waiters: Vec::new(),
        });
        assert!(handle < 0x1_0000, "handle must be in low range");
        ctx.gpr[3] = handle as u64;
        ke_set_event(&mut ctx, &mut mem, &mut state);
        // Shadow must NOT have been created at the handle key (already exists);
        // the existing Event just flips to signaled.
        match state.objects.get(&handle) {
            Some(KernelObject::Event { signaled, .. }) => assert!(*signaled),
            _ => panic!("handle lookup broken"),
        }
    }

    /// Type bytes we don't understand (e.g., Mutant=2, Timer=8) must leave
    /// the handle table untouched rather than conjuring wrong-typed shadows.
    #[test]
    fn ensure_dispatcher_object_ignores_unknown_type() {
        let (mut _ctx, mut mem, mut state) = fresh();
        let ptr = SCRATCH_BASE + 0x500;
        write_dispatcher_header(&mut mem, ptr, 2, 0); // Mutant — unsupported
        ensure_dispatcher_object(&mut state, &mem, ptr);
        assert!(!state.objects.contains_key(&ptr), "no shadow for unknown type");
        // No StashHandle stamp on an ignored dispatcher.
        assert_eq!(mem.read_u32(ptr + 0x08), 0);
        assert_eq!(mem.read_u32(ptr + 0x0C), 0);
    }

    /// Mirror canary `XObject::StashHandle` (xobject.h:253-256): on first
    /// adoption of a guest dispatcher, +0x08 must hold the 'X','E','N','\0'
    /// fourcc and +0x0C must hold the stash handle.
    #[test]
    fn ensure_dispatcher_object_stamps_xen_signature_and_handle() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x700;
        write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // synchronization
        // Pre-condition: zeros at +0x08 / +0x0C.
        assert_eq!(mem.read_u32(kevent_ptr + 0x08), 0);
        assert_eq!(mem.read_u32(kevent_ptr + 0x0C), 0);
        ctx.gpr[3] = kevent_ptr as u64;
        ke_set_event(&mut ctx, &mut mem, &mut state);
        // Post-condition: kXObjSignature ('X','E','N','\0') + stash handle.
        assert_eq!(
            mem.read_u32(kevent_ptr + 0x08),
            0x58454E00,
            "wait_list.flink_ptr must hold kXObjSignature 'XEN\\0'"
        );
        assert_eq!(
            mem.read_u32(kevent_ptr + 0x0C),
            kevent_ptr,
            "wait_list.blink_ptr must hold stash handle (== guest dispatcher ptr)"
        );
    }

    /// `KePulseEvent` on a manual-reset event must wake every parked waiter
    /// and leave the event unsignaled afterwards. This models the transient-
    /// signal idiom that `NtSetEvent`+`NtClearEvent` cannot express atomically.
    #[test]
    fn ke_pulse_event_manual_reset_wakes_all_and_leaves_unsignaled() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x600;
        write_dispatcher_header(&mut mem, kevent_ptr, 0, 0); // manual-reset, unsignaled
        // Mint the shadow and park two fake waiters.
        ctx.gpr[3] = kevent_ptr as u64;
        ke_reset_event(&mut ctx, &mut mem, &mut state);
        match state.objects.get_mut(&kevent_ptr) {
            Some(KernelObject::Event { waiters, .. }) => {
                // Fake waiter refs — wake_ref silently no-ops on
                // out-of-bounds so the test only observes list drainage.
                waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 });
                waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 });
            }
            _ => panic!("shadow not minted"),
        }
        // Pulse.
        ctx.gpr[3] = kevent_ptr as u64;
        ke_pulse_event(&mut ctx, &mut mem, &mut state);
        // Previous state = 0 (unsignaled).
        assert_eq!(ctx.gpr[3], 0);
        // Event must be unsignaled post-pulse, and waiter list drained.
        match state.objects.get(&kevent_ptr) {
            Some(KernelObject::Event { signaled, waiters, .. }) => {
                assert!(!*signaled, "pulse leaves event non-signaled");
                assert!(waiters.is_empty(), "all manual-reset waiters must be woken");
            }
            _ => panic!("shadow vanished"),
        }
    }

    /// Auto-reset pulse wakes exactly one waiter (the head of the FIFO) and
    /// consumes the transient signal, matching `NtSetEvent` on an auto-reset
    /// event with no linger.
    #[test]
    fn ke_pulse_event_auto_reset_wakes_one() {
        let (mut ctx, mut mem, mut state) = fresh();
        let kevent_ptr = SCRATCH_BASE + 0x700;
        write_dispatcher_header(&mut mem, kevent_ptr, 1, 0); // auto-reset, unsignaled
        ctx.gpr[3] = kevent_ptr as u64;
        ke_reset_event(&mut ctx, &mut mem, &mut state);
        match state.objects.get_mut(&kevent_ptr) {
            Some(KernelObject::Event { waiters, .. }) => {
                // Fake waiter refs — wake_ref silently no-ops on
                // out-of-bounds so the test only observes list drainage.
                waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 });
                waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 });
            }
            _ => panic!("shadow not minted"),
        }
        ctx.gpr[3] = kevent_ptr as u64;
        ke_pulse_event(&mut ctx, &mut mem, &mut state);
        match state.objects.get(&kevent_ptr) {
            Some(KernelObject::Event { signaled, waiters, .. }) => {
                assert!(!*signaled, "pulse leaves auto-reset event non-signaled");
                assert_eq!(waiters.len(), 1, "auto-reset pulse wakes exactly one waiter");
            }
            _ => panic!("shadow vanished"),
        }
    }

    /// `NtPulseEvent` must return `STATUS_SUCCESS` + write prior state to
    /// the optional `previous_state_ptr` (r4). If the handle is invalid,
    /// it must return `STATUS_INVALID_HANDLE` without touching memory.
    #[test]
    fn nt_pulse_event_writes_previous_state_and_clears() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = state.alloc_handle_for(KernelObject::Event {
            manual_reset: true,
            signaled: true, // initially signaled → prior = 1
            waiters: Vec::new(),
        });
        let prev_ptr = SCRATCH_BASE + 0x10;
        mem.write_u32(prev_ptr, 0xFFFF_FFFF); // sentinel
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = prev_ptr as u64;
        nt_pulse_event(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(prev_ptr), 1, "previous state was signaled=1");
        match state.objects.get(&handle) {
            Some(KernelObject::Event { signaled, .. }) => {
                assert!(!*signaled, "nt_pulse_event must leave event cleared");
            }
            _ => panic!("handle lost"),
        }
    }

    #[test]
    fn nt_pulse_event_invalid_handle_returns_status() {
        let (mut ctx, mut mem, mut state) = fresh();
        ctx.gpr[3] = 0xDEAD_BEEF; // not in object table
        ctx.gpr[4] = 0;
        nt_pulse_event(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
    }

    /// `NtReleaseSemaphore` must return `STATUS_SEMAPHORE_LIMIT_EXCEEDED`
    /// (0xC000_0047) when the post-release count would exceed `Limit`,
    /// and must *not* update the count in that case. The prior
    /// saturating-add behaviour silently clamped to i32::MAX, masking
    /// overflow from games that key work-queue logic on the status code.
    #[test]
    fn nt_release_semaphore_rejects_over_limit() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = state.alloc_handle_for(KernelObject::Semaphore {
            count: 3,
            max: 5,
            waiters: Vec::new(),
        });
        let prev_ptr = SCRATCH_BASE + 0x40;
        mem.write_u32(prev_ptr, 0xFFFF_FFFF);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 10; // 3 + 10 = 13 > max=5 → reject
        ctx.gpr[5] = prev_ptr as u64;
        nt_release_semaphore(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SEMAPHORE_LIMIT_EXCEEDED);
        assert_eq!(mem.read_u32(prev_ptr), 3, "previous count written even on reject");
        match state.objects.get(&handle) {
            Some(KernelObject::Semaphore { count, .. }) => {
                assert_eq!(*count, 3, "count must not change on reject");
            }
            _ => panic!("handle lost"),
        }
    }

    /// A normal release inside the limit increments `count` and returns
    /// `STATUS_SUCCESS` with the previous count written out.
    #[test]
    fn nt_release_semaphore_normal_path_updates_count() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = state.alloc_handle_for(KernelObject::Semaphore {
            count: 2,
            max: 5,
            waiters: Vec::new(),
        });
        let prev_ptr = SCRATCH_BASE + 0x50;
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 2; // 2 + 2 = 4 <= 5 → ok
        ctx.gpr[5] = prev_ptr as u64;
        nt_release_semaphore(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(prev_ptr), 2);
        match state.objects.get(&handle) {
            Some(KernelObject::Semaphore { count, .. }) => assert_eq!(*count, 4),
            _ => panic!("handle lost"),
        }
    }

    /// Invalid handle path: Canary returns `STATUS_INVALID_HANDLE`
    /// without touching any state. Previous behaviour silently returned
    /// `STATUS_SUCCESS` with `previous = 0`, which games couldn't tell
    /// from a genuine release.
    #[test]
    fn nt_release_semaphore_invalid_handle_returns_status() {
        let (mut ctx, mut mem, mut state) = fresh();
        ctx.gpr[3] = 0xDEAD_BEEF;
        ctx.gpr[4] = 1;
        ctx.gpr[5] = 0;
        nt_release_semaphore(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
    }

    /// `RtlInitializeCriticalSection` must lay out the guest-visible
    /// X_RTL_CRITICAL_SECTION per Canary `xboxkrnl_rtl.cc:536-553`:
    /// dispatcher-header type=1 at +0x00, lock_count=-1 at +0x10,
    /// recursion_count=0 at +0x14, owning_thread=0 at +0x18. Prior to
    /// this fix xenia-rs wrote lock_count at +0x04 (landing inside the
    /// dispatcher header's signal_state field) and owning_thread at
    /// +0x0C (landing inside the WaitListHead). Any game that reads a
    /// pre-initialized CS from its `.data` segment — Canary's comment
    /// at line 533-534 notes this is common — would see garbage.
    #[test]
    fn rtl_initialize_critical_section_lays_out_canary_struct() {
        let (mut ctx, mut mem, mut state) = fresh();
        let cs_ptr = SCRATCH_BASE + 0x100;
        // Pre-fill with a sentinel so we can see every byte we touch.
        for i in (0..28).step_by(4) {
            mem.write_u32(cs_ptr + i, 0xDEAD_BEEF);
        }
        ctx.gpr[3] = cs_ptr as u64;
        rtl_initialize_critical_section(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u8(cs_ptr + CS_OFFS_TYPE), 1, "type = synchronization");
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT), 0xFFFF_FFFF, "lock_count = -1");
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 0);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), 0);
    }

    /// End-to-end: init → enter → nested enter → leave → leave. The
    /// CS must roll back through `(lc=0,rc=2) → (lc=0,rc=1) → (lc=-1,
    /// rc=0,owner=0)` with the correct field offsets, and owner must
    /// land at +0x18 — not anywhere else.
    #[test]
    fn rtl_critical_section_nested_enter_leave_roundtrip() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Install a live guest TID on the current HW slot so
        // `rtl_enter_critical_section`'s `find_by_tid` sees us as a
        // genuine owner. `find_by_tid` filters out `HwState::Idle` —
        // the default placeholder state — so we also flip to Ready.
        // Without both, `owner_is_live` stays false on self-recursion
        // and the nested-enter branch is never taken.
        let tid: u32 = 42;
        // Update the live thread planted by `fresh()` on slot 0 so
        // `find_by_tid(42)` resolves it.
        state.scheduler.slots[0].runqueue[0].tid = tid;
        state.scheduler.slots[0].runqueue[0].state = xenia_cpu::scheduler::HwState::Ready;
        ctx.thread_id = tid;
        let cs_ptr = SCRATCH_BASE + 0x200;
        ctx.gpr[3] = cs_ptr as u64;
        rtl_initialize_critical_section(&mut ctx, &mut mem, &mut state);
        // First enter → owner = tid, LC = 0, RC = 1.
        ctx.gpr[3] = cs_ptr as u64;
        rtl_enter_critical_section(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), tid);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 0);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 1);
        // Nested enter (same tid) → LC = 1, RC = 2.
        ctx.gpr[3] = cs_ptr as u64;
        rtl_enter_critical_section(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 1);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 2);
        // First leave → LC = 0, RC = 1, owner stays.
        ctx.gpr[3] = cs_ptr as u64;
        rtl_leave_critical_section(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), tid);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, 0);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 1);
        // Second leave → LC = -1, RC = 0, owner cleared.
        ctx.gpr[3] = cs_ptr as u64;
        rtl_leave_critical_section(&mut ctx, &mut mem, &mut state);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_OWNING_THREAD), 0);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_LOCK_COUNT) as i32, -1);
        assert_eq!(mem.read_u32(cs_ptr + CS_OFFS_RECURSION_COUNT), 0);
    }

    /// `NtSetInformationFile` class 14 (`XFilePositionInformation`) must
    /// update the file cursor. Read back via `NtQueryInformationFile`
    /// class 14 — round-trip proves both sides agree on the layout.
    #[test]
    fn nt_set_information_file_position_updates_cursor() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = make_file(&mut state, vec![0u8; 0x100]);
        let info_ptr = SCRATCH_BASE + 0x20;
        let iosb_ptr = SCRATCH_BASE + 0x40;
        mem.write_u64(info_ptr, 0x40);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = iosb_ptr as u64;
        ctx.gpr[5] = info_ptr as u64;
        ctx.gpr[6] = 8;
        ctx.gpr[7] = 14; // XFilePositionInformation
        nt_set_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(iosb_ptr), STATUS_SUCCESS as u32);
        assert_eq!(mem.read_u32(iosb_ptr + 4), 8);
        match state.objects.get(&handle) {
            Some(KernelObject::File { position, .. }) => assert_eq!(*position, 0x40),
            _ => panic!("file handle lost"),
        }
    }

    /// `NtSetInformationFile` class 10 (`XFileRenameInformation`) must move
    /// the backing host file to the new `cache:` path and update the handle.
    /// Mirrors Sylpheed's asset-cache `.tmp` → `\<hash>\<dir>\<file>` move;
    /// without it the nested target stays empty and the decompressed asset
    /// (logo texture) never reads back. Faithful to canary `file->Rename`.
    #[test]
    fn nt_set_information_file_rename_moves_cache_file() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Real temp cache root + a staging `.tmp` file with known bytes.
        let root = std::env::temp_dir().join(format!("xenia-rs-rename-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        let old_host = root.join("69d8e45ce534ffea.tmp");
        std::fs::write(&old_host, b"LOGOTEX!").unwrap();
        state.cache_root = Some(root.clone());
        // Open handle whose backing host_path is the staging file.
        let handle = state.alloc_handle_for(KernelObject::File {
            path: "69d8e45ce534ffea.tmp".to_string(),
            size: 8,
            position: 0,
            data: Arc::new(Vec::new()),
            dir_enum_pos: None,
            host_path: Some(old_host.clone()),
        });
        // X_FILE_RENAME_INFORMATION { replace@0, root_dir@4, ANSI_STRING@8 }.
        // ANSI_STRING { len u16, max u16, buf u32 } at info_ptr+8; buffer holds
        // the target path "cache:\69d8e45c\e\534ffea".
        let info_ptr = SCRATCH_BASE + 0x100;
        let str_buf = SCRATCH_BASE + 0x200;
        let target = b"cache:\\69d8e45c\\e\\534ffea";
        for (i, b) in target.iter().enumerate() {
            mem.write_u8(str_buf + i as u32, *b);
        }
        mem.write_u32(info_ptr, 0); // replace_existing
        mem.write_u32(info_ptr + 4, 0); // root_dir_handle
        mem.write_u16(info_ptr + 8, target.len() as u16); // ANSI_STRING.Length
        mem.write_u16(info_ptr + 10, target.len() as u16); // MaximumLength
        mem.write_u32(info_ptr + 12, str_buf); // Buffer
        let iosb_ptr = SCRATCH_BASE + 0x140;
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = iosb_ptr as u64;
        ctx.gpr[5] = info_ptr as u64;
        ctx.gpr[6] = 16;
        ctx.gpr[7] = 10; // XFileRenameInformation
        nt_set_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        // Staging file gone; nested target exists with the same bytes.
        let new_host = root.join("69d8e45c").join("e").join("534ffea");
        assert!(!old_host.exists(), "staging .tmp should be moved away");
        assert_eq!(std::fs::read(&new_host).unwrap(), b"LOGOTEX!");
        // Handle now points at the new host + guest path.
        match state.objects.get(&handle) {
            Some(KernelObject::File { host_path: Some(hp), path, .. }) => {
                assert_eq!(hp, &new_host);
                assert_eq!(path, "cache:/69d8e45c/e/534ffea");
            }
            _ => panic!("file handle lost or host_path missing"),
        }
        let _ = std::fs::remove_dir_all(&root);
    }

    /// Read-only VFS — truncating to a different size must fail with
    /// `STATUS_UNSUCCESSFUL`, matching Canary's error path when
    /// `file->SetLength(...)` can't honour the request.
    #[test]
    fn nt_set_information_file_truncate_to_different_size_fails() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = make_file(&mut state, vec![0u8; 0x100]);
        let info_ptr = SCRATCH_BASE + 0x80;
        mem.write_u64(info_ptr, 0x200); // new EOF != current 0x100
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[5] = info_ptr as u64;
        ctx.gpr[6] = 8;
        ctx.gpr[7] = 20; // XFileEndOfFileInformation
        nt_set_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_UNSUCCESSFUL);
    }

    #[test]
    fn nt_set_information_file_invalid_class_returns_status() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = make_file(&mut state, Vec::new());
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[5] = 0;
        ctx.gpr[6] = 0;
        ctx.gpr[7] = 999; // not a defined class
        nt_set_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_INFO_CLASS);
    }

    #[test]
    fn nt_set_information_file_short_buffer_returns_length_mismatch() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle = make_file(&mut state, Vec::new());
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[5] = SCRATCH_BASE as u64;
        ctx.gpr[6] = 4; // class 14 needs 8
        ctx.gpr[7] = 14;
        nt_set_information_file(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INFO_LENGTH_MISMATCH);
    }

    /// `KeReleaseSemaphore` is lenient: it never reports errors, but the
    /// count must still cap at `Limit` (Canary's underlying primitive
    /// `XSemaphore::ReleaseSemaphore` enforces the cap, even though the
    /// Ke wrapper discards the success bool).
    #[test]
    fn ke_release_semaphore_silently_caps_at_limit() {
        let (mut ctx, mut mem, mut state) = fresh();
        let ksem_ptr = SCRATCH_BASE + 0x60;
        // Dispatcher header: type=5 (Semaphore), signal_state/count=4, Limit=5.
        write_dispatcher_header(&mut mem, ksem_ptr, 5, 4);
        mem.write_u32(ksem_ptr + 0x10, 5); // Limit
        ctx.gpr[3] = ksem_ptr as u64;
        ctx.gpr[4] = 10; // 4 + 10 > 5 → reject silently
        ke_release_semaphore(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 4, "Ke returns previous count even on cap");
        match state.objects.get(&ksem_ptr) {
            Some(KernelObject::Semaphore { count, .. }) => {
                assert_eq!(*count, 4, "count must not exceed Limit even via Ke-form");
            }
            _ => panic!("shadow missing"),
        }
    }

    // ===== Timer subsystem =====

    /// Helper: write a LARGE_INTEGER (i64 in big-endian hi/lo u32 pair) to
    /// guest memory. Matches the format `parse_timeout` / `nt_set_timer_ex`
    /// read from.
    fn write_large_integer(mem: &GuestMemory, ptr: u32, raw: i64) {
        mem.write_u32(ptr, (raw >> 32) as u32);
        mem.write_u32(ptr + 4, raw as u32);
    }

    #[test]
    fn nt_create_timer_sync_type_creates_auto_reset() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[4] = 0; // obj_attributes — ignored
        ctx.gpr[5] = 1; // SynchronizationTimer
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        let handle = mem.read_u32(handle_ptr);
        match state.objects.get(&handle) {
            Some(KernelObject::Timer {
                manual_reset,
                signaled,
                deadline,
                waiters,
                ..
            }) => {
                assert!(!*manual_reset, "type=1 is SynchronizationTimer (auto-reset)");
                assert!(!*signaled);
                assert!(deadline.is_none());
                assert!(waiters.is_empty());
            }
            other => panic!("expected Timer at handle {:#x}, got {:?}", handle, other),
        }
    }

    #[test]
    fn nt_create_timer_notification_type_creates_manual_reset() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 0; // NotificationTimer
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        let handle = mem.read_u32(handle_ptr);
        match state.objects.get(&handle) {
            Some(KernelObject::Timer { manual_reset, .. }) => assert!(*manual_reset),
            _ => panic!("expected Timer"),
        }
    }

    #[test]
    fn nt_create_timer_invalid_type_returns_invalid_parameter() {
        let (mut ctx, mut mem, mut state) = fresh();
        ctx.gpr[3] = (SCRATCH_BASE + 0x20) as u64;
        ctx.gpr[5] = 42; // invalid
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], 0xC000_000D); // STATUS_INVALID_PARAMETER
        assert!(
            state.objects.is_empty()
                || state
                    .objects
                    .values()
                    .all(|o| !matches!(o, KernelObject::Timer { .. })),
            "no Timer object must be minted on invalid type"
        );
    }

    #[test]
    fn nt_set_timer_ex_schedules_pending_fire() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Create the timer first.
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);

        // Arm with -1_000_000 (= 100ms) relative.
        let due_time_ptr = SCRATCH_BASE + 0x40;
        write_large_integer(&mut mem, due_time_ptr, -1_000_000);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = due_time_ptr as u64;
        ctx.gpr[5] = 0; // routine
        ctx.gpr[6] = 1; // mode
        ctx.gpr[7] = 0; // routine_arg
        ctx.gpr[8] = 0; // resume
        ctx.gpr[9] = 0; // period_ms
        nt_set_timer_ex(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);

        assert_eq!(state.pending_timer_fires.len(), 1);
        let (deadline, h) = state.pending_timer_fires[0];
        assert_eq!(h, handle);
        assert!(deadline > 0, "deadline must advance past now");
        match state.objects.get(&handle) {
            Some(KernelObject::Timer {
                deadline: obj_d,
                signaled,
                ..
            }) => {
                assert_eq!(*obj_d, Some(deadline));
                assert!(!*signaled, "arm clears any stale signaled flag");
            }
            _ => panic!("Timer vanished"),
        }
    }

    #[test]
    fn nt_set_timer_ex_rearm_replaces_entry() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);
        let due_time_ptr = SCRATCH_BASE + 0x40;
        // First arm.
        write_large_integer(&mut mem, due_time_ptr, -1_000_000);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = due_time_ptr as u64;
        ctx.gpr[5] = 0;
        ctx.gpr[6] = 1;
        ctx.gpr[7] = 0;
        ctx.gpr[8] = 0;
        ctx.gpr[9] = 0;
        nt_set_timer_ex(&mut ctx, &mut mem, &mut state);
        // Second arm (later).
        write_large_integer(&mut mem, due_time_ptr, -5_000_000);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = due_time_ptr as u64;
        nt_set_timer_ex(&mut ctx, &mut mem, &mut state);
        assert_eq!(
            state.pending_timer_fires.len(),
            1,
            "rearm must replace, not duplicate"
        );
    }

    #[test]
    fn nt_cancel_timer_disarms_and_writes_zero() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);
        let due_time_ptr = SCRATCH_BASE + 0x40;
        write_large_integer(&mut mem, due_time_ptr, -1_000_000);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = due_time_ptr as u64;
        ctx.gpr[5] = 0;
        ctx.gpr[6] = 1;
        ctx.gpr[7] = 0;
        ctx.gpr[8] = 0;
        ctx.gpr[9] = 0;
        nt_set_timer_ex(&mut ctx, &mut mem, &mut state);

        let prev_ptr = SCRATCH_BASE + 0x60;
        mem.write_u32(prev_ptr, 0xDEAD_BEEF); // sentinel — must be overwritten to 0
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = prev_ptr as u64;
        nt_cancel_timer(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(prev_ptr), 0, "canary always writes 0");
        assert!(state.pending_timer_fires.is_empty());
        match state.objects.get(&handle) {
            Some(KernelObject::Timer { deadline, .. }) => assert!(deadline.is_none()),
            _ => panic!("Timer gone after cancel — must stay in table"),
        }
    }

    #[test]
    fn nt_cancel_timer_invalid_handle_returns_status() {
        let (mut ctx, mut mem, mut state) = fresh();
        ctx.gpr[3] = 0xDEAD_BEEF;
        ctx.gpr[4] = 0;
        nt_cancel_timer(&mut ctx, &mut mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_INVALID_HANDLE);
    }

    #[test]
    fn timer_fire_wakes_auto_reset_waiter_and_consumes_signal() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Arm an auto-reset timer with deadline slightly in the future.
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);
        // Deadline: now + 1000 ticks. Directly set the state to avoid
        // dependence on parse_timeout's divisor.
        let now = state.scheduler.ctx(0).timebase;
        let deadline = now + 1000;
        match state.objects.get_mut(&handle) {
            Some(KernelObject::Timer {
                deadline: obj_d, ..
            }) => *obj_d = Some(deadline),
            _ => panic!("no timer"),
        }
        state.arm_timer(handle, deadline);

        // Park the current (initial) thread on a WaitForSingleObject of the
        // timer handle. `do_wait_single` sees signaled=false, enqueues the
        // current ref, and parks via `park_current`.
        ctx.gpr[3] = handle as u64;
        ctx.gpr[6] = 0; // NULL timeout → wait forever
        nt_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
        let initial_ref = state.scheduler.current_ref();
        match state.scheduler.thread(initial_ref).state {
            xenia_cpu::scheduler::HwState::Blocked(_) => {}
            ref other => panic!("expected Blocked after wait, got {:?}", other),
        }

        // Advance time past the deadline; fire_due_timers should signal and
        // wake the waiter.
        state.scheduler.advance_all_timebases_to(deadline);
        let fired = state.fire_due_timers();
        assert!(fired);

        // After fire on auto-reset: signaled cleared via handle_consume, no
        // pending entry, waiter promoted to Ready.
        match state.objects.get(&handle) {
            Some(KernelObject::Timer {
                signaled, waiters, ..
            }) => {
                assert!(!*signaled, "auto-reset consumed on single-waiter wake");
                assert!(waiters.is_empty(), "waiter dequeued by wake_eligible_waiters");
            }
            _ => panic!("timer lost"),
        }
        assert!(state.pending_timer_fires.is_empty());
        match state.scheduler.thread(initial_ref).state {
            xenia_cpu::scheduler::HwState::Ready => {}
            ref other => panic!("expected Ready after fire, got {:?}", other),
        }
    }

    #[test]
    fn timer_fire_manual_reset_wakes_all_and_stays_signaled() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Manual-reset timer.
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 0; // NotificationTimer (manual-reset)
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);

        let now = state.scheduler.ctx(0).timebase;
        let deadline = now + 1000;
        match state.objects.get_mut(&handle) {
            Some(KernelObject::Timer {
                deadline: obj_d, ..
            }) => *obj_d = Some(deadline),
            _ => unreachable!(),
        }
        state.arm_timer(handle, deadline);

        // Park two synthetic waiters (out-of-bounds refs — `wake_ref`
        // silently no-ops on them; we only care about the drain-all
        // semantics of manual-reset.)
        match state.objects.get_mut(&handle) {
            Some(KernelObject::Timer { waiters, .. }) => {
                waiters.push(ThreadRef { hw_id: 2, idx: 0, generation: 0 });
                waiters.push(ThreadRef { hw_id: 3, idx: 0, generation: 0 });
            }
            _ => unreachable!(),
        }

        state.scheduler.advance_all_timebases_to(deadline);
        assert!(state.fire_due_timers());
        match state.objects.get(&handle) {
            Some(KernelObject::Timer {
                signaled, waiters, ..
            }) => {
                assert!(*signaled, "manual-reset stays signaled after fire");
                assert!(waiters.is_empty(), "manual-reset drains all waiters");
            }
            _ => unreachable!(),
        }
    }

    #[test]
    fn periodic_timer_rearms_after_fire() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);

        let now = state.scheduler.ctx(0).timebase;
        let deadline = now + 1000;
        let period_ticks = 500;
        match state.objects.get_mut(&handle) {
            Some(KernelObject::Timer {
                deadline: obj_d,
                period_ticks: obj_p,
                ..
            }) => {
                *obj_d = Some(deadline);
                *obj_p = period_ticks;
            }
            _ => unreachable!(),
        }
        state.arm_timer(handle, deadline);

        state.scheduler.advance_all_timebases_to(deadline);
        assert!(state.fire_due_timers());

        // After fire, a new entry must sit at deadline + period_ticks.
        assert_eq!(state.pending_timer_fires.len(), 1);
        let (new_deadline, h) = state.pending_timer_fires[0];
        assert_eq!(h, handle);
        assert_eq!(new_deadline, deadline + period_ticks);
        match state.objects.get(&handle) {
            Some(KernelObject::Timer { deadline: obj_d, .. }) => {
                assert_eq!(*obj_d, Some(new_deadline));
            }
            _ => unreachable!(),
        }
    }

    #[test]
    fn nt_close_scrubs_pending_timer_fires() {
        let (mut ctx, mut mem, mut state) = fresh();
        let handle_ptr = SCRATCH_BASE + 0x20;
        ctx.gpr[3] = handle_ptr as u64;
        ctx.gpr[5] = 1;
        nt_create_timer(&mut ctx, &mut mem, &mut state);
        let handle = mem.read_u32(handle_ptr);
        // Arm.
        let due_time_ptr = SCRATCH_BASE + 0x40;
        write_large_integer(&mut mem, due_time_ptr, -1_000_000);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = due_time_ptr as u64;
        ctx.gpr[5] = 0;
        ctx.gpr[6] = 1;
        ctx.gpr[7] = 0;
        ctx.gpr[8] = 0;
        ctx.gpr[9] = 0;
        nt_set_timer_ex(&mut ctx, &mut mem, &mut state);
        assert_eq!(state.pending_timer_fires.len(), 1);
        // Close.
        ctx.gpr[3] = handle as u64;
        nt_close(&mut ctx, &mut mem, &mut state);
        assert!(
            state.pending_timer_fires.is_empty(),
            "nt_close must scrub pending timer entry"
        );
        assert!(!state.objects.contains_key(&handle));
    }

    #[test]
    fn advance_to_next_wake_returns_ref_and_reason_for_timeout_path() {
        let (mut ctx, mut mem, mut state) = fresh();
        // Create an event (unsignaled), park current thread on it with a
        // finite deadline via NtWaitForSingleObjectEx.
        let ev = state.alloc_handle_for(KernelObject::Event {
            manual_reset: false,
            signaled: false,
            waiters: Vec::new(),
        });
        let timeout_ptr = SCRATCH_BASE + 0x80;
        write_large_integer(&mut mem, timeout_ptr, -1_000_000);
        ctx.gpr[3] = ev as u64;
        ctx.gpr[6] = timeout_ptr as u64;
        nt_wait_for_single_object_ex(&mut ctx, &mut mem, &mut state);
        let initial_ref = state.scheduler.current_ref();

        // Current thread must be parked with that handle in its waiter list.
        match state.objects.get(&ev) {
            Some(KernelObject::Event { waiters, .. }) => {
                assert!(waiters.contains(&initial_ref), "waiter enqueued");
            }
            _ => unreachable!(),
        }

        // Advance past the deadline. `advance_to_next_wake` returns the
        // woken ref + its block reason; the main loop would then stamp
        // STATUS_TIMEOUT and scrub waiter lists via `handle_timeout_wake`.
        let (r, reason) = state
            .scheduler
            .advance_to_next_wake()
            .expect("deadline exists");
        assert_eq!(r, initial_ref);
        state.handle_timeout_wake(r, reason);

        // Post-wake: gpr[3] == STATUS_TIMEOUT (0x102) AND the waiter list
        // scrubbed. Prior code returned 0 and left the waiter stranded.
        assert_eq!(state.scheduler.ctx_mut_ref(r).gpr[3], 0x0000_0102);
        match state.objects.get(&ev) {
            Some(KernelObject::Event { waiters, .. }) => {
                assert!(
                    !waiters.contains(&initial_ref),
                    "waiter scrubbed from handle list on timeout"
                );
            }
            _ => unreachable!(),
        }
    }

    /// Ordinal 0xFB must resolve to `NtSignalAndWaitForSingleObjectEx`
    /// (canary's table) — the former `NtSetInformationThread`
    /// registration collided and was removed.
    #[test]
    fn ordinal_0xfb_maps_to_nt_signal_and_wait() {
        let state = KernelState::new();
        let name = state
            .export_name(crate::state::ModuleId::Xboxkrnl, 0xFB)
            .expect("0xFB must be registered");
        assert_eq!(name, "NtSignalAndWaitForSingleObjectEx");
    }

    /// `KeInitializeSemaphore` must seed the count and limit fields in
    /// guest memory so that `ensure_dispatcher_object` later mints the
    /// kernel-side shadow with the caller's parameters — not the
    /// zero-fill default of `count=0, max=1`.
    #[test]
    fn ke_initialize_semaphore_seeds_count_and_limit() {
        let (mut ctx, mem, mut state) = fresh();
        let sem_ptr = SCRATCH_BASE + 0x500;
        ctx.gpr[3] = sem_ptr as u64;
        ctx.gpr[4] = 3;
        ctx.gpr[5] = 7;
        ke_initialize_semaphore(&mut ctx, &mem, &mut state);
        assert_eq!(mem.read_u8(sem_ptr), 5, "type=5 (semaphore)");
        assert_eq!(mem.read_u32(sem_ptr + 0x04), 3, "signal_state=count");
        assert_eq!(mem.read_u32(sem_ptr + 0x10), 7, "limit");

        // Round-trip: KeReleaseSemaphore mints the shadow via
        // `ensure_dispatcher_object`, which reads the fields we just wrote.
        ctx.gpr[3] = sem_ptr as u64;
        ctx.gpr[4] = 1;
        ke_release_semaphore(&mut ctx, &mem, &mut state);
        match state.objects.get(&sem_ptr) {
            Some(KernelObject::Semaphore { count, max, .. }) => {
                assert_eq!(*count, 4, "3 + 1 = 4");
                assert_eq!(*max, 7, "limit must propagate from r5, not default to 1");
            }
            other => panic!("expected Semaphore shadow, got {:?}", other),
        }
        assert_eq!(ctx.gpr[3], 3, "previous count must be 3 (post-init, pre-release)");
    }

    /// `XexGetProcedureAddress` must honor r3=hmodule, look up the
    /// (module, ordinal) in the thunk reverse-map, and write the address
    /// to *r5. Three branches: success, unknown ordinal, unknown hmodule.
    #[test]
    fn xex_get_procedure_address_resolves_registered_thunk() {
        let (mut ctx, mem, mut state) = fresh();
        state.register_thunk(crate::state::ModuleId::Xboxkrnl, 0x12, 0x8200_1234);
        let out_ptr = SCRATCH_BASE + 0x600;

        // Success path.
        mem.write_u32(out_ptr, 0xDEAD_BEEF);
        ctx.gpr[3] = crate::state::HMODULE_XBOXKRNL as u64;
        ctx.gpr[4] = 0x12;
        ctx.gpr[5] = out_ptr as u64;
        xex_get_procedure_address(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS");
        assert_eq!(mem.read_u32(out_ptr), 0x8200_1234, "thunk address written");

        // Unknown ordinal: STATUS_OBJECT_NAME_NOT_FOUND, *out cleared.
        // Reset r3 because the prior call overwrote it with the status code.
        mem.write_u32(out_ptr, 0xDEAD_BEEF);
        ctx.gpr[3] = crate::state::HMODULE_XBOXKRNL as u64;
        ctx.gpr[4] = 0x99;
        xex_get_procedure_address(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0xC000_0034);
        assert_eq!(mem.read_u32(out_ptr), 0);

        // Unknown hmodule: STATUS_INVALID_HANDLE.
        ctx.gpr[3] = 0xCAFE_BABE;
        ctx.gpr[4] = 0x12;
        xex_get_procedure_address(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0xC000_0008);
    }

    /// `XexGetModuleHandle` must return distinct pseudo-handles for the
    /// main image, xboxkrnl.exe, and xam.xex; write the handle to *r4
    /// (not r3); and return NTSTATUS in r3 (`X_ERROR_NOT_FOUND` for
    /// unknown names).
    #[test]
    fn xex_get_module_handle_distinguishes_modules() {
        let (mut ctx, mem, mut state) = fresh();
        state.image_base = 0x8200_0000;
        let out_ptr = SCRATCH_BASE + 0x700;
        let scratch_str = SCRATCH_BASE + 0x780;

        let mut call = |name: Option<&str>,
                        st: &mut KernelState,
                        mem: &GuestMemory,
                        ctx: &mut PpcContext|
         -> (u64, u32) {
            match name {
                Some(s) => {
                    for (i, b) in s.as_bytes().iter().enumerate() {
                        mem.write_u8(scratch_str + i as u32, *b);
                    }
                    mem.write_u8(scratch_str + s.len() as u32, 0);
                    ctx.gpr[3] = scratch_str as u64;
                }
                None => ctx.gpr[3] = 0,
            }
            ctx.gpr[4] = out_ptr as u64;
            mem.write_u32(out_ptr, 0xDEAD_BEEF);
            xex_get_module_handle(ctx, mem, st);
            (ctx.gpr[3], mem.read_u32(out_ptr))
        };

        let (s_main, h_main) = call(Some(""), &mut state, &mem, &mut ctx);
        let (s_krnl, h_krnl) = call(Some("xboxkrnl.exe"), &mut state, &mem, &mut ctx);
        let (s_xam, h_xam) = call(Some("xam.xex"), &mut state, &mem, &mut ctx);
        let (s_bad, h_bad) = call(Some("nope.xex"), &mut state, &mem, &mut ctx);

        assert_eq!(s_main, 0);
        assert_eq!(h_main, 0x8200_0000);
        assert_eq!(s_krnl, 0);
        assert_eq!(h_krnl, crate::state::HMODULE_XBOXKRNL);
        assert_eq!(s_xam, 0);
        assert_eq!(h_xam, crate::state::HMODULE_XAM);
        assert_eq!(s_bad, 0x0000_048B);
        assert_eq!(h_bad, 0, "out cleared on miss");
        assert_ne!(h_main, h_krnl, "main module distinct from xboxkrnl");
        assert_ne!(h_krnl, h_xam, "xboxkrnl distinct from xam");
    }

    /// `XexCheckExecutablePrivilege` must return bit `priv` of the loaded
    /// XEX's `XEX_HEADER_SYSTEM_FLAGS` bitmap. Mirrors canary
    /// [xboxkrnl_modules.cc:22-39](../../../xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_modules.cc#L22-L39).
    #[test]
    fn xex_check_executable_privilege_reads_system_flags_bitmap() {
        let (mut ctx, mem, mut state) = fresh();
        // bit 10 set, bit 11 clear (matches Sylpheed's actual bitmap value
        // `0x00000400` / XEX_SYSTEM_PAL50_INCOMPATIBLE).
        state.xex_system_flags = 0x0000_0400;

        ctx.gpr[3] = 10;
        xex_check_executable_privilege(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 1, "priv 10 set in flags 0x0400");

        ctx.gpr[3] = 11;
        xex_check_executable_privilege(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "priv 11 clear in flags 0x0400");

        ctx.gpr[3] = 0;
        xex_check_executable_privilege(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "priv 0 clear in flags 0x0400");

        ctx.gpr[3] = 64;
        xex_check_executable_privilege(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "priv >= 32 returns 0");

        // With no flags set, every priv reads 0.
        state.xex_system_flags = 0;
        ctx.gpr[3] = 10;
        xex_check_executable_privilege(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0, "priv 10 clear with no flags");
    }

    /// `XAudioRegisterRenderDriverClient` records the (callback, arg) pair,
    /// allocates a 4-byte heap buffer holding `callback_arg` in big-endian,
    /// and writes `0x4155_xxxx` to `*driver_ptr`.  Mirrors canary
    /// [audio_system.cc:202-237](../../../xenia-canary/src/xenia/apu/audio_system.cc#L202-L237).
    #[test]
    fn xaudio_register_records_client_and_writes_driver_id() {
        let (mut ctx, mem, mut state) = fresh();
        let cb_block = SCRATCH_BASE + 0x100;
        let driver_out = SCRATCH_BASE + 0x200;
        mem.write_u32(cb_block, 0x8200_BEEF);
        mem.write_u32(cb_block + 4, 0xDEAD_F00D);
        ctx.gpr[3] = cb_block as u64;
        ctx.gpr[4] = driver_out as u64;

        xaudio_register_render_driver(&mut ctx, &mem, &mut state);

        assert_eq!(ctx.gpr[3], 0, "STATUS_SUCCESS");
        let driver_id = mem.read_u32(driver_out);
        assert_eq!(driver_id & 0xFFFF_0000, 0x4155_0000);
        let index = (driver_id & 0x0000_FFFF) as usize;
        let client = state.xaudio.get(index).expect("client must be registered");
        assert_eq!(client.callback_pc, 0x8200_BEEF);
        assert_eq!(client.callback_arg, 0xDEAD_F00D);
        assert_ne!(client.wrapped_callback_arg, 0);
        assert_eq!(
            mem.read_u32(client.wrapped_callback_arg),
            0xDEAD_F00D,
            "wrapped buffer must hold callback_arg big-endian"
        );
    }

    /// Null `callback_ptr` or null callback function returns `X_E_INVALIDARG`
    /// without registering — canary
    /// [xboxkrnl_audio.cc:58-66](../../../xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_audio.cc#L58-L66).
    #[test]
    fn xaudio_register_rejects_null_inputs() {
        let (mut ctx, mem, mut state) = fresh();
        ctx.gpr[3] = 0;
        ctx.gpr[4] = (SCRATCH_BASE + 0x300) as u64;
        xaudio_register_render_driver(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], X_E_INVALIDARG);
        assert!(!state.xaudio.any_registered());

        let cb_block = SCRATCH_BASE + 0x400;
        mem.write_u32(cb_block, 0); // callback function = null
        mem.write_u32(cb_block + 4, 0xCAFE);
        ctx.gpr[3] = cb_block as u64;
        ctx.gpr[4] = (SCRATCH_BASE + 0x500) as u64;
        xaudio_register_render_driver(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], X_E_INVALIDARG);
        assert!(!state.xaudio.any_registered());
    }

    /// Unregister clears the slot identified by the lower 16 bits of the
    /// driver token.
    #[test]
    fn xaudio_unregister_clears_slot() {
        let (mut ctx, mem, mut state) = fresh();
        let cb_block = SCRATCH_BASE + 0x100;
        let driver_out = SCRATCH_BASE + 0x200;
        mem.write_u32(cb_block, 0x8200_AAAA);
        mem.write_u32(cb_block + 4, 0xBBBB_BBBB);
        ctx.gpr[3] = cb_block as u64;
        ctx.gpr[4] = driver_out as u64;
        xaudio_register_render_driver(&mut ctx, &mem, &mut state);
        let driver_id = mem.read_u32(driver_out);
        let index = (driver_id & 0x0000_FFFF) as usize;
        assert!(state.xaudio.get(index).is_some());

        ctx.gpr[3] = driver_id as u64;
        xaudio_unregister_render_driver(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], 0);
        assert!(state.xaudio.get(index).is_none());
    }

    /// FsCtlCode 0x70000 (drive geometry): canary writes
    /// `cache_size/512` at OUT+0 and `512` at OUT+4 (both u32 BE).
    #[test]
    fn nt_device_io_control_file_drive_geometry() {
        let (mut ctx, mem, mut state) = fresh();
        let sp = SCRATCH_BASE + 0x800;
        let iosb = SCRATCH_BASE + 0x100;
        let out_buf = SCRATCH_BASE + 0x200;
        ctx.gpr[1] = sp as u64;
        ctx.gpr[3] = 0xF800_0010;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = iosb as u64;
        ctx.gpr[8] = 0x70000;
        mem.write_u32(sp + 0x54, out_buf);
        mem.write_u32(sp + 0x5C, 0x8);
        nt_device_io_control_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(out_buf), 0xFF000 / 512);
        assert_eq!(mem.read_u32(out_buf + 4), 512);
        assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
        assert_eq!(mem.read_u32(iosb + 4), 0x8);
    }

    /// FsCtlCode 0x74004 (partition info): canary writes `0` at OUT+0 and
    /// `cache_size = 0xFF000` at OUT+8 (both u64 BE). Sub_824ABD88 at
    /// `0x824abe9c` reads OUT+8 and synthesizes `0xC0000034` if zero — a
    /// non-zero value at OUT+8 is the entire fix.
    #[test]
    fn nt_device_io_control_file_partition_info_unblocks_gate() {
        let (mut ctx, mem, mut state) = fresh();
        let sp = SCRATCH_BASE + 0x800;
        let iosb = SCRATCH_BASE + 0x100;
        let out_buf = SCRATCH_BASE + 0x200;
        ctx.gpr[1] = sp as u64;
        ctx.gpr[3] = 0xF800_0010;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = iosb as u64;
        ctx.gpr[8] = 0x74004;
        mem.write_u32(sp + 0x54, out_buf);
        mem.write_u32(sp + 0x5C, 0x10);
        nt_device_io_control_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u64(out_buf), 0);
        assert_eq!(mem.read_u64(out_buf + 8), 0xFF000);
        assert_ne!(mem.read_u64(out_buf + 8), 0, "OUT+8 must be non-zero");
        assert_eq!(mem.read_u32(iosb), STATUS_SUCCESS as u32);
        assert_eq!(mem.read_u32(iosb + 4), 0x10);
    }

    /// Once a client is registered, the lockstep ticker eventually queues
    /// a fire — proves the producer pipeline is wired end-to-end through
    /// the kernel state.
    #[test]
    fn xaudio_register_then_tick_instr_queues_callback() {
        let (mut ctx, mem, mut state) = fresh();
        let cb_block = SCRATCH_BASE + 0x100;
        let driver_out = SCRATCH_BASE + 0x200;
        mem.write_u32(cb_block, 0x8200_C0DE);
        mem.write_u32(cb_block + 4, 0xFEED_FACE);
        ctx.gpr[3] = cb_block as u64;
        ctx.gpr[4] = driver_out as u64;
        xaudio_register_render_driver(&mut ctx, &mem, &mut state);

        assert!(state.xaudio.tick_instr(crate::xaudio::XAUDIO_INSTR_PERIOD));
        let i = state.xaudio.peek_next().expect("must queue a fire");
        let client = state.xaudio.get(i).unwrap();
        assert_eq!(client.callback_pc, 0x8200_C0DE);
    }

    // ===== AUDIT-038: cache:/* persistent VFS =====

    /// Lay out an OBJECT_ATTRIBUTES + ANSI_STRING + buffer at a chosen
    /// guest base and return the obj_attrs pointer. Matches the layout
    /// `crate::path::object_attributes_to_vfs_path` expects: u32
    /// RootDirectory @ +0, u32 NameStringPtr @ +4, u32 Attributes @ +8;
    /// the ANSI_STRING is u16 Length @ +0, u16 MaximumLength @ +2,
    /// u32 Buffer @ +4.
    fn write_obj_attrs(mem: &GuestMemory, base: u32, path_str: &str) -> u32 {
        let obj_attrs = base;
        let ansi_string = base + 0x40;
        let buf = base + 0x80;
        // OBJECT_ATTRIBUTES.
        mem.write_u32(obj_attrs, 0); // RootDirectory
        mem.write_u32(obj_attrs + 4, ansi_string); // Name -> ANSI_STRING
        mem.write_u32(obj_attrs + 8, 0); // Attributes
        // ANSI_STRING.
        mem.write_u16(ansi_string, path_str.len() as u16); // Length
        mem.write_u16(ansi_string + 2, path_str.len() as u16); // MaximumLength
        mem.write_u32(ansi_string + 4, buf); // Buffer
        // Path bytes.
        for (i, b) in path_str.bytes().enumerate() {
            mem.write_u8(buf + i as u32, b);
        }
        obj_attrs
    }

    /// Round-trip: create with FILE_CREATE, write bytes, read them back
    /// from the same handle. Verifies persistence within a single run
    /// (host_path drives both directions).
    #[test]
    fn cache_create_write_read_roundtrip() {
        let (mut ctx, mem, mut state) = fresh();
        let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\rt.tmp");
        let handle_out = SCRATCH_BASE + 0x300;
        let iosb = SCRATCH_BASE + 0x310;
        ctx.gpr[3] = handle_out as u64;
        ctx.gpr[5] = obj_attrs as u64;
        ctx.gpr[6] = iosb as u64;
        ctx.gpr[10] = FILE_CREATE as u64;
        nt_create_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        let handle = mem.read_u32(handle_out);
        assert!(handle >= 0x1000, "handle must come from kernel allocator");

        // NtWriteFile: 4 bytes "abcd"
        let write_buf = SCRATCH_BASE + 0x400;
        for (i, b) in b"abcd".iter().enumerate() {
            mem.write_u8(write_buf + i as u32, *b);
        }
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0; // event_handle = none
        ctx.gpr[7] = iosb as u64;
        ctx.gpr[8] = write_buf as u64;
        ctx.gpr[9] = 4;
        ctx.gpr[10] = 0; // byte_offset_ptr null = use position
        nt_write_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(iosb + 4), 4, "wrote 4 bytes");

        // Position should be 4. Reset to 0 with NtSetInformationFile (class 14).
        let pos_buf = SCRATCH_BASE + 0x500;
        mem.write_u64(pos_buf, 0);
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = iosb as u64;
        ctx.gpr[5] = pos_buf as u64;
        ctx.gpr[6] = 8;
        ctx.gpr[7] = 14;
        nt_set_information_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);

        // NtReadFile: 4 bytes back from position 0.
        let read_buf = SCRATCH_BASE + 0x600;
        for i in 0..4 {
            mem.write_u8(read_buf + i, 0);
        }
        ctx.gpr[3] = handle as u64;
        ctx.gpr[4] = 0;
        ctx.gpr[7] = iosb as u64;
        ctx.gpr[8] = read_buf as u64;
        ctx.gpr[9] = 4;
        ctx.gpr[10] = 0;
        nt_read_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);
        assert_eq!(mem.read_u32(iosb + 4), 4);
        let mut got = [0u8; 4];
        for i in 0..4 {
            got[i] = mem.read_u8(read_buf + i as u32);
        }
        assert_eq!(&got, b"abcd");
    }

    /// FILE_CREATE on an already-existing path returns
    /// STATUS_OBJECT_NAME_COLLISION; the existing file is not truncated.
    #[test]
    fn cache_file_create_collision() {
        let (mut ctx, mem, mut state) = fresh();
        let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\dup.tmp");
        let handle_out = SCRATCH_BASE + 0x300;
        let iosb = SCRATCH_BASE + 0x310;
        ctx.gpr[3] = handle_out as u64;
        ctx.gpr[5] = obj_attrs as u64;
        ctx.gpr[6] = iosb as u64;
        ctx.gpr[10] = FILE_CREATE as u64;
        nt_create_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_SUCCESS);

        // Second FILE_CREATE on the same path: collide.
        ctx.gpr[3] = handle_out as u64;
        ctx.gpr[5] = obj_attrs as u64;
        ctx.gpr[6] = iosb as u64;
        ctx.gpr[10] = FILE_CREATE as u64;
        nt_create_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_COLLISION);
    }

    /// FILE_OPEN on a path that doesn't exist returns
    /// STATUS_OBJECT_NAME_NOT_FOUND (canary `HostPathDevice::Open` mirror).
    #[test]
    fn cache_file_open_missing() {
        let (mut ctx, mem, mut state) = fresh();
        let obj_attrs = write_obj_attrs(&mem, SCRATCH_BASE + 0x100, "cache:\\missing.tmp");
        let handle_out = SCRATCH_BASE + 0x300;
        let iosb = SCRATCH_BASE + 0x310;
        ctx.gpr[3] = handle_out as u64;
        ctx.gpr[5] = obj_attrs as u64;
        ctx.gpr[6] = iosb as u64;
        ctx.gpr[10] = FILE_OPEN as u64;
        nt_create_file(&mut ctx, &mem, &mut state);
        assert_eq!(ctx.gpr[3], STATUS_OBJECT_NAME_NOT_FOUND);
        assert_eq!(mem.read_u32(handle_out), 0, "no handle on miss");
    }

    /// `init_cache_root` clears the directory before reuse, so a fresh
    /// kernel never sees stale cache from a previous run. Determinism
    /// gate for the lockstep `sylpheed_n*m.json` digest.
    #[test]
    fn cache_root_cleared_on_init() {
        let dir = std::env::temp_dir().join(format!(
            "xenia-rs-cache-test-clear-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .subsec_nanos(),
        ));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("stale.tmp"), b"stale").unwrap();
        let mut state = KernelState::new();
        state.init_cache_root(dir.clone()).unwrap();
        assert!(!dir.join("stale.tmp").exists(), "stale must be cleared");
        assert!(dir.exists(), "root must be re-created");
        // Cleanup.
        std::fs::remove_dir_all(&dir).ok();
    }

    /// `resolve_cache_path` rejects path-traversal attempts so a guest
    /// can't escape the cache directory by passing `cache:\..\..\etc\foo`.
    #[test]
    fn cache_resolve_strips_path_traversal() {
        let dir = std::env::temp_dir().join(format!(
            "xenia-rs-cache-test-trav-{}",
            std::process::id()
        ));
        std::fs::create_dir_all(&dir).unwrap();
        let mut state = KernelState::new();
        state.init_cache_root(dir.clone()).unwrap();
        let resolved = state
            .resolve_cache_path("cache:\\..\\..\\etc\\foo")
            .expect("must resolve");
        assert!(resolved.starts_with(&dir), "must stay inside cache root");
        assert!(resolved.ends_with("etc/foo"));
        std::fs::remove_dir_all(&dir).ok();
    }

    /// `MmGetPhysicalAddress` must be region-aware, matching canary's
    /// `PhysicalHeap::GetPhysicalAddress`: the 0xE0000000+ 4 KB mirror gets a
    /// `+0x1000` host-address-offset; every other region is a flat
    /// `& 0x1FFFFFFF` mask.
    #[test]
    fn mm_get_physical_address_region_aware() {
        // 0xE0000000 mirror: canary `address - heap_base (==addr & 0x1FFFFFFF)`
        // then `+ 0x1000`.
        assert_eq!(translate_physical_address(0xE000_0000), 0x0000_1000);
        assert_eq!(translate_physical_address(0xE000_5000), 0x0000_6000);
        assert_eq!(translate_physical_address(0xFFFF_F000), 0x1FFF_F000 + 0x1000);
        // 0xA0000000 / 0xC0000000 physical heaps: flat mask, no offset.
        assert_eq!(translate_physical_address(0xA000_0000), 0x0000_0000);
        assert_eq!(translate_physical_address(0xC012_3000), 0x0012_3000);
        // Virtual / already-physical (< 0x20000000): unchanged.
        assert_eq!(translate_physical_address(0x0012_3000), 0x0012_3000);
        assert_eq!(translate_physical_address(0x4012_3000), 0x0012_3000);
    }
}
