use std::collections::HashMap;
use xenia_cpu::scheduler::{PcrWriter, Scheduler};
use xenia_cpu::{PpcContext, ThreadRef};
use xenia_memory::{GuestMemory, MemoryAccess};
use xenia_vfs::VfsDevice;

use crate::audit::{HandleAudit, HandleAuditEntry};
use crate::objects::KernelObject;
use crate::ui_bridge::UiBridge;

/// Adapter: write PCR+0x2C on guest memory. Lets `Scheduler::spawn` and
/// Axis 4's migration call through without `xenia-cpu` depending on the
/// memory crate.
pub struct GuestMemoryPcr<'a>(pub &'a GuestMemory);
impl PcrWriter for GuestMemoryPcr<'_> {
    fn write_pcr_id(&mut self, pcr_base: u32, hw_id: u8) {
        // `GuestMemory::write_u32` takes `&self` post-M2 trait flip; the
        // wrapping `&'a GuestMemory` is sufficient.
        self.0.write_u32(pcr_base + 0x2C, hw_id as u32);
        // PRCB.current_cpu byte at PCR+0x10C (prcb_data@0x100 + current_cpu@0xC).
        // Canary writes `GetFakeCpuNumber(affinity)` here (xthread.cc:847
        // `pcr->prcb_data.current_cpu = cpu_index`), which equals the HW thread
        // id we already compute. Guest spin-barriers (e.g. sub_824D1328, used by
        // the audio/update pump threads at entries 0x824D2878/0x824D2940) index a
        // per-HW-thread occupancy array by `lbz r11, 268(r13)` = this byte. Left
        // unwritten it stayed 0 for every thread, so all threads collided on
        // slot 0 and the multi-thread rendezvous signature never assembled —
        // the pump threads spun forever and never fired their KeSetEvent loops.
        self.0.write_u8(pcr_base + 0x10C, hw_id);
    }
}

/// Function signature for HLE kernel exports.
///
/// The first argument is the **currently running** HW thread's `PpcContext`,
/// which the caller has temporarily moved out of the scheduler slot to avoid
/// aliasing. Exports that only touch register/GPR state use `ctx` directly;
/// exports that need scheduler state (spawn/park/wake/tls/etc.) reach
/// through `state.scheduler` — note that `state.scheduler.hw_threads[current]`
/// holds a placeholder `PpcContext` for the duration of the call, not the
/// live one passed as `ctx`.
pub type KernelExportFn = fn(&mut PpcContext, &GuestMemory, &mut KernelState);

/// Module identifier for kernel exports.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ModuleId {
    Xboxkrnl,
    Xam,
    Xbdm,
}

/// Pseudo-`HMODULE` values returned by `XexGetModuleHandle` and accepted by
/// `XexGetProcedureAddress`. Distinct from real loaded-image bases
/// (>=0x82000000) and from kernel handles (0x1000+, allocated by
/// `alloc_handle`). The 0xFFFE_xxxx prefix is unused by both guest segments
/// and our handle allocator.
pub const HMODULE_XBOXKRNL: u32 = 0xFFFE_0001;
pub const HMODULE_XAM: u32 = 0xFFFE_0002;

/// Central kernel state tracking all guest OS state.
pub struct KernelState {
    exports: HashMap<(ModuleId, u32), (&'static str, KernelExportFn)>,
    /// M2.4: bump allocator for kernel handles. `AtomicU32` so concurrent
    /// HLE calls under M3 can `fetch_add` without a lock. `Relaxed` is
    /// fine — the allocated value is a fresh ID with no prior payload to
    /// publish; observers (the kernel object table) are guarded by
    /// their own synchronization.
    next_handle: std::sync::atomic::AtomicU32,
    /// AUDIT-059 R34: FIFO free list of closed handle slots, mirroring
    /// canary's slab/free-list `ObjectTable`. Without this, ours' bump
    /// allocator monotonically grows so a recycled slot in canary
    /// (e.g. `F8000098` reused 130× per 30s) corresponds to a fresh,
    /// never-reused slot in ours — the kernel-object identity drifts.
    /// Recycling closes that gap and (per AUDIT-042 / R30) may
    /// side-effect-unwedge γ-cluster #2 by letting silph signals land
    /// on the same handle slot the wait registered for. Population is
    /// gated on `KernelState::release_handle_slot` (only IDs in
    /// `[HANDLE_BASE, 0xF000_0000)` are recycled — synthetic XAudio
    /// handles at `0xF000_0000+` are reserved and must never be reused).
    free_handles: std::collections::VecDeque<u32>,
    /// Scheduler managing all emulated HW threads + their per-slot
    /// runqueues. Starts empty — the app installs the initial guest thread
    /// on slot 0 via `KernelState::install_initial_thread` once it has the
    /// entry address.
    pub scheduler: Scheduler,
    /// TLS slot allocator — index counter only. Per-thread *values* live on
    /// `GuestThread::tls_values` (see scheduler). M2.4: `AtomicU32`.
    pub next_tls_index: std::sync::atomic::AtomicU32,
    /// Critical-section waiter map: guest `cs_ptr` → guest threads parked
    /// on it. Critical sections are in guest memory (not kernel objects),
    /// so their waiter list lives here rather than on an object.
    pub cs_waiters: HashMap<u32, Vec<ThreadRef>>,
    /// Kernel object table: handle → object
    pub objects: HashMap<u32, KernelObject>,
    /// Bump allocator for guest heap (NtAllocateVirtualMemory etc.).
    /// M2.4: `AtomicU32` for lock-free concurrent allocation.
    pub heap_cursor: std::sync::atomic::AtomicU32,
    /// Stack allocator cursor for MmCreateKernelStack. M2.4: atomic.
    pub stack_cursor: std::sync::atomic::AtomicU32,
    /// GPU command buffer address (set by VdGetSystemCommandBuffer)
    pub gpu_command_buffer: u32,
    /// GPU backend. M1.4: was `xenia_gpu::GpuSystem` directly, now a
    /// [`xenia_gpu::GpuBackend`] enum so the kernel can hold either an
    /// inline `GpuSystem` (synchronous, default) or a `GpuHandle` proxy
    /// pointing at a worker thread (`--gpu-thread`). Forwarding methods
    /// on the enum keep call sites in [`crate::exports`] terse.
    pub gpu: xenia_gpu::GpuBackend,
    /// Monotonic packet number returned by `XamInputGetState`. Games detect
    /// input changes by watching this increment.
    pub input_packet_number: u32,
    /// Previous gamepad snapshot; `input_packet_number` only advances when
    /// the state bytes actually change, matching host XInput semantics.
    pub last_input_bytes: u128,
    /// Image base of the loaded XEX (for XexExecutableModuleHandle etc.)
    pub image_base: u32,
    /// `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) parsed from the loaded
    /// XEX header. Queried by `XexCheckExecutablePrivilege`: privilege bit
    /// `n` is set iff `(xex_system_flags & (1 << n)) != 0`. Zero before the
    /// app installs the loaded image — that matches canary's behavior when
    /// no executable module is registered (returns 0).
    pub xex_system_flags: u32,
    /// One-shot log gate for `XexCheckExecutablePrivilege`: tracks which
    /// privilege numbers have already produced a `tracing::info!` line so
    /// the import-hot path doesn't spam at -n 500M.
    pub xex_priv_logged: std::collections::HashSet<u32>,
    /// Whether the first listener whose mask covers `kXNotifySystem` has
    /// been registered — gate for the two startup system notifications
    /// per `kernel_state.cc:1020-1025`.
    pub has_notified_startup: bool,
    /// Same for `kXNotifyLive` per `kernel_state.cc:1026-1032`.
    pub has_notified_live_startup: bool,
    /// Next thread ID. M2.4: atomic.
    pub next_thread_id: std::sync::atomic::AtomicU32,
    /// Virtual file system for NtCreateFile/NtReadFile/etc. The app mounts
    /// the disc image or host directory into this slot; file I/O handlers
    /// route all reads through it.
    pub vfs: Option<Box<dyn VfsDevice>>,
    /// AUDIT-038 — host directory backing the persistent `cache:` mount
    /// (mirrors canary's `cache:` → `\CACHE` symlink in xenia_main.cc:649,
    /// implemented atop `HostPathDevice`). When `Some`, opens of `cache:\*`
    /// paths go through real `std::fs` I/O against this directory; when
    /// `None`, they fall back to the legacy "Synthesized empty file" stub
    /// (which doesn't persist writes — see audit-037 for the record-layout
    /// divergence that motivated this fix). Set up by [`init_cache_root`]
    /// at startup; cleared at the same time so lockstep digests stay
    /// reproducible across reruns.
    pub cache_root: Option<std::path::PathBuf>,
    /// Bridge to the host UI. `None` when running headless. Installed by
    /// `cmd_exec` when the user passes `--ui`.
    pub ui: Option<UiBridge>,
    /// P6 — graphics interrupt + synthetic v-sync bookkeeping. Registers
    /// the callback set by `VdSetGraphicsInterruptCallback` and tracks
    /// the paused-context snapshot while HW thread 0 is running it.
    pub interrupts: crate::interrupts::InterruptState,
    /// XAudio render-driver clients + buffer-complete callback ticker.
    /// Mirrors canary's [`xenia/apu/audio_system.cc`] worker — registered
    /// guest callbacks can fire at the audio frame rate so guest threads
    /// parked on audio-buffer events get woken (APUBUG-PRODUCER-001).
    /// Shares the [`crate::interrupts::InterruptState::saved`] /
    /// `injected_ref` slot at injection time; mutual exclusion with
    /// graphics interrupts is enforced by the injector's
    /// `is_in_callback()` guard.
    pub xaudio: crate::xaudio::XAudioState,
    /// Register-mapped XMA context array (apu stage 1). Shared with the
    /// `0x7FEA0000` MMIO region installed by the app and with the
    /// `XMACreateContext`/`XMAReleaseContext` exports, so it lives behind an
    /// `Arc<Mutex<…>>`. Stage 1 records kicks; stage 3 will decode them.
    pub xma: std::sync::Arc<std::sync::Mutex<xenia_apu::XmaDecoder>>,
    /// AUDIT-032 Plan B (default true). When true, the round prologue
    /// runs the XAudio ticker + `try_inject_audio_callback`. Pre-fix this
    /// was off by default because injection used random-victim selection
    /// (APUBUG-PRODUCER-001 HW-thread hijack) which corrupted unrelated
    /// state. With dedicated per-client audio workers spawned at
    /// `XAudioRegisterRenderDriverClient`, injection only ever runs on
    /// the registered worker so it is safe to leave on. Lockstep goldens
    /// `sylpheed_n*m.json` will drift on this fix and need re-baselining
    /// (handled out-of-band). The `--xaudio-tick` flag / `XENIA_XAUDIO_TICK=1`
    /// env var now act as explicit-override; flipping it off restores the
    /// pre-fix path (no audio callbacks fire at all).
    pub xaudio_tick_enabled: bool,
    /// Per-handle refcount. Since `NtDuplicateObject` aliases (returns the
    /// source handle value as the "new" handle rather than minting a fresh
    /// id), a single handle commonly has multiple logical references. This
    /// map tracks that count so a stray `NtClose` on one reference doesn't
    /// destroy the object while another reference is still live. Canary's
    /// `ObjectTable::ReleaseHandle` (object_table.cc:189) is the parity
    /// reference. Initialized to 1 in `alloc_handle_for`; incremented in
    /// `nt_duplicate_object` when `DUPLICATE_CLOSE_SOURCE` is absent;
    /// decremented in `nt_close` which drops the underlying object only
    /// when the count reaches zero.
    pub handle_refcount: HashMap<u32, u32>,
    /// Pending timer expirations — `(deadline, handle)` sorted ascending by
    /// deadline. Pushed by `arm_timer`, popped by `fire_due_timers`. Kept in
    /// lockstep with the per-`Timer` object's `deadline` field via the
    /// `arm_timer`/`disarm_timer` helpers. See the plan's step 3/6 for the
    /// design rationale — timer deadlines coexist with
    /// `Scheduler::timed_waits` but track a different class (signaled object
    /// fires, not thread wake-ups).
    pub pending_timer_fires: Vec<(u64, u32)>,
    /// Per-handle signal/wait/wake audit trail. Default `enabled=false` →
    /// every record method is a no-op. Flip via `--trace-handles`/
    /// `XENIA_TRACE_HANDLES` to diagnose missing-signal deadlocks (handles
    /// 0x10FC / 0x1014 / 0x1104 / 0x10DC / 0x10F0 specifically). See
    /// [`crate::audit`] for layout.
    pub audit: HandleAudit,
    /// M2.2 — banked reservation table for `lwarx`/`stwcx.` under M3's
    /// per-HW-thread parallelism. Always allocated. Consulted by the
    /// interpreter when `reservations.is_enabled()` is true; otherwise
    /// the legacy per-`PpcContext` fields drive observable behavior.
    /// Settable via `--reservations-table` / `XENIA_RESERVATIONS_TABLE=1`
    /// for golden verification, or implicitly under `--parallel`.
    /// See [`xenia_cpu::ReservationTable`] for the concurrency model.
    pub reservations: std::sync::Arc<xenia_cpu::ReservationTable>,
    /// True when the runtime was started with `--parallel`. Read by the
    /// v-sync ticker (KRNBUG-D08): lockstep uses the deterministic
    /// instruction-count proxy so the `sylpheed_n*m.json` goldens stay
    /// bit-stable; `--parallel` uses wall-clock so the rate doesn't
    /// drop to ~2 v-syncs / 100M as the instruction-count proxy did.
    /// Set once at startup and never mutated.
    pub parallel_active: bool,
    /// Map from `(module, ordinal)` to the guest-side import-thunk address
    /// resolved at load time. Reverse of `xenia-app/src/main.rs`'s
    /// `thunk_map`. Populated from xenia-app's Phase 1 (record_type==1
    /// only). Used by `xex_get_procedure_address` to resolve ordinals back
    /// to callable thunks.
    thunks_by_ordinal: HashMap<(ModuleId, u16), u32>,

    /// Perf (Tier-A #4): inclusive [min, max] guest-address band that
    /// contains every registered import thunk. Import thunks sit in a
    /// small contiguous region of the XEX; almost every executing PC is
    /// ordinary guest code OUTSIDE this band. The per-slot-visit prologue
    /// looks up `thunk_map.get(&pc)` (a `HashMap<u32,…>` → `hash_one` per
    /// call, ~3.2M visits boot-to-splash). Range-rejecting against this
    /// band first turns the common (non-thunk) case into a pair of integer
    /// compares and skips the hash entirely. `None` until the first thunk
    /// is registered (no band → reject everything, matching an empty map).
    thunk_addr_band: Option<(u32, u32)>,
    /// First-Pixels diagnostic latch. Set the first time
    /// `RtlRaiseException` fires with code `0xE06D7363` (MSVC C++ throw)
    /// so the deep stack-walk + `runtime_error` decode in
    /// `rtl_raise_exception` only emits once per run, regardless of how
    /// many subsequent throws fire. Reset on each fresh process start.
    pub cxx_throw_logged: bool,
    /// Cached primary ring base/size, set during `VdInitializeRingBuffer`.
    /// Used by `vd_swap` (KRNBUG-Vd-04) so the kernel can write PM4
    /// packets directly into ring memory without going through the GPU
    /// backend (which lives on the worker thread under `--gpu-thread`).
    pub ring_base: u32,
    pub ring_size_dwords: u32,
    /// Diagnostic. PCs at which the worker prologue fires a one-shot
    /// stack/back-chain dump capturing live `r3` (= `this` in MSVC
    /// PPC ctors), `lr` (= return site), and the cycle/tid that hit
    /// the PC. Populated from `--ctor-probe=0x8217C850,0x...` /
    /// `XENIA_CTOR_PROBE`. Empty by default → check is a single
    /// `is_empty()` test, no extra cost on the unprobed hot path.
    /// Read-only diagnostic — no guest state is mutated, so the
    /// `sylpheed_n*m.json` lockstep digest is preserved.
    ///
    /// **Why a per-PC probe instead of per-handle?** The MSVC ctors
    /// at `sub_8217C850` (and friends) don't preserve `this` in r31
    /// across the inner `bl` to `silph::Event::Construct`, so the
    /// AUDIT-002 multi-frame back-chain at `NtCreateEvent` only
    /// recovers stack-relative pointers — never the pool-element
    /// `this`. Hooking the ctor's PRE-prologue PC captures r3 = this
    /// before any save/restore can clobber it.
    pub ctor_probe_pcs: std::collections::HashSet<u32>,
    /// Diagnostic. Optional per-PC dispatcher snapshot. Maps a probe PC
    /// to a `(dispatcher_addr, offset)` pair; when the PC fires, the
    /// helper additionally logs the value of `[dispatcher_addr +
    /// offset]` — i.e. exactly what the producer's `lwz r3, OFF(r3)`
    /// is about to read after the `bl outer_getter` returns the
    /// dispatcher pointer in r3. Populated from the `PC@DISP:OFF`
    /// extended syntax of `--pc-probe` / `--ctor-probe`. Read-only
    /// load — does not mutate guest state.
    pub pc_probe_consumers: HashMap<u32, (u32, u32)>,
    /// Diagnostic. Comma-separated set of guest PCs that, when reached,
    /// emit a single compact one-line `BRANCH-PROBE` record. The line
    /// includes (pc, tid, hw, cycle, r3, lr, cr0.{lt,gt,eq}, cr6.{lt,gt,eq})
    /// — designed for tracing every conditional-branch fire inside a
    /// candidate-gate function (sub_824A9710 etc.) so the LAST PC
    /// reached before function epilogue identifies the exit branch.
    /// Distinct from `ctor_probe_pcs` because that helper emits 8
    /// frames of back-chain per hit — too noisy for branch tracing.
    pub branch_probe_pcs: std::collections::HashSet<u32>,
    /// AUDIT-2BF — diagnostic. PCs at which to emit a structured one-line
    /// `AUDIT-PC-PROBE` record on every fire, designed for the silph init
    /// chain virtual-dispatch site at `sub_82172BA0+0x1E8` (PC
    /// `0x82172D88`, a `bctrl` after a 3-deep load of vtable slot 6). The
    /// emitted line carries (pc, tid, hw, cycle, lr, r3, r11) plus four
    /// guest-memory dereferences off `r3`: `[r3+0]` (vtable), `[[r3+0]+24]`
    /// (slot 6 method pointer = the bctrl target), `[r3+0x0C]` (audit-059
    /// round-9 canary-known auxiliary handle `0xF80000D8`), and `[r3+0x30]`
    /// (canary-known embedded sub-object vtable `0x820A1870`). Distinct
    /// from `branch_probe_pcs` because that helper only logs registers (no
    /// memory) and from `lr_trace_pcs` because that emits JSON intended
    /// for canary diffing, not the four hard-coded indirect dereferences
    /// needed here. Read-only — no guest state mutation. Lockstep
    /// digest unaffected. Settable via `--audit-pc-probe-hex` /
    /// `XENIA_AUDIT_PC_PROBE`.
    pub audit_pc_probe_pcs: std::collections::HashSet<u32>,
    /// AUDIT-2BF round 14 — diagnostic. Optional guest VA. When set, each
    /// `AUDIT-PC-PROBE` fire emits a paired `AUDIT-MEM-READ` line with
    /// `addr`, `*addr` (singleton value), `**addr` (vtable), `***addr+0`
    /// (vtable[0] = first virtual method), and `***addr+24` (vtable[6]
    /// in 4-byte stride = slot 6 = silph chain bctrl target). Three-deep
    /// dereference to resolve the vtable[0] target at the bctrl site
    /// `0x822F1B4C` inside `sub_822F1AA8`. Read-only; lockstep digest
    /// unaffected. Settable via `--audit-mem-read-hex` /
    /// `XENIA_AUDIT_MEM_READ`.
    pub audit_mem_read_addr: Option<u32>,
    /// AUDIT-052 — diagnostic. When set, each `AUDIT-PC-PROBE` fire
    /// additionally emits an `AUDIT-R3-DUMP` line with N bytes of guest
    /// memory dumped from `r3` as `u32` lanes (4-byte aligned only).
    /// Sized for audit-051's 80-byte stack-local struct at `r31+96`
    /// inside `sub_82452DC0` (probe `sub_8245B000` entry where
    /// `r3 == parent's r31+96`). Read-only; lockstep digest unaffected.
    /// Settable via `--audit-r3-dump-bytes` /
    /// `XENIA_AUDIT_R3_DUMP_BYTES`.
    pub audit_r3_dump_bytes: Option<u32>,
    /// iterate-2E — diagnostic pointer-chase. `(reg, off)`: on every
    /// `AUDIT-PC-PROBE` fire, treat `gpr[reg]` as a base object pointer,
    /// dump its first 64 bytes, then follow `[base+off]` to a sub-object
    /// (e.g. a stream/file object held in a work item), dump ITS first 64
    /// bytes, then follow `[[base+off]+0]` to the sub-object's vtable and
    /// dump the first 48 u32 slots. Designed to capture the live work-item
    /// + stream object + vtable at `sub_824510E0` entry (r4 = work item,
    /// stream at +36, vtable[28] = the "is-read-done?" predicate) BEFORE
    /// the pool recycles the slot. Read-only; lockstep digest unaffected.
    /// Settable via `XENIA_AUDIT_DEREF=<reg>:<off>` (e.g. `4:36`).
    pub audit_deref: Option<(u8, u32)>,
    /// milestone-2 demux trace — `(lo, hi, tid)`. When set, every
    /// block-entry whose PC is in `[lo, hi)` and (if `tid != 0`) whose
    /// running thread id equals `tid` emits a compact one-line
    /// `AUDIT-TRACE` record with `(pc, tid, cycle, lr, r3, r4, r5, r30,
    /// r31)`. Because every basic block begins at a branch target, this
    /// yields a BB-granularity control-flow trace of a single thread's
    /// path through a function range — exactly what's needed to see WHICH
    /// branch a demux read takes (e.g. the no-data exit) and how two
    /// thread instances of the same code diverge. Read-only; lockstep
    /// digest unaffected. Settable via `XENIA_AUDIT_PC_TRACE=lo:hi:tid`
    /// (hex addrs, decimal tid; tid 0 = all threads).
    pub audit_pc_trace: Option<(u32, u32, u32)>,
    /// M12 — diagnostic. PCs at which to emit a structured JSONL record
    /// per fire, designed for diffing against xenia-canary's
    /// `--log_lr_on_pc` patch output. Each line carries
    /// `(pc, tid, hw, cycle, r3, r4, r5, r6, lr)` — a superset of what
    /// canary logs. Settable via `--lr-trace` / `XENIA_LR_TRACE`. Stdout
    /// by default; redirect with `--lr-trace-out=PATH`. Read-only;
    /// lockstep digest unaffected.
    pub lr_trace_pcs: std::collections::HashSet<u32>,
    /// M12 — optional file writer for `--lr-trace` output. `None` means
    /// stdout.
    pub lr_trace_writer: Option<std::sync::Mutex<std::fs::File>>,
    /// Diagnostic. Guest addresses to dump (64 bytes each, hex + u32
    /// lanes) at end-of-run. Populated from `--dump-addr=0x828F3D08,
    /// 0x828F4070`. Used to inspect static dispatcher / job-queue /
    /// pool struct layouts identified by AUDIT-003. Read-only — the
    /// dump is performed by `dump_thread_diagnostic`, never during
    /// the hot interpreter loop, so lockstep determinism is unaffected.
    pub dump_addrs: Vec<u32>,
    /// `--dump-section=BASE:LEN:PATH` end-of-run snapshot, page-gated by `is_mapped`.
    pub dump_section: Option<(u32, u32, std::path::PathBuf)>,
    /// AUDIT-2.BF — synthetic silph::WorkerCtx spawn one-shot latch. Set on
    /// first call to [`crate::silph_synth::spawn_silph_workers`] (triggered
    /// by the first observation of a load-bearing VFS path such as
    /// `dat/movie`), then reused — subsequent triggers are no-ops.
    pub silph_synth_done: bool,
    /// AUDIT-2.BF — VA of the synthesised silph::WorkerCtx. Zero before the
    /// first spawn; set to the ctx base by `spawn_silph_workers`. Held on
    /// the kernel state so future export hooks can find it (no caller does
    /// yet — placeholder for round 19+ wiring).
    pub silph_synth_ctx: u32,
    /// AUDIT-2.BF — kernel handles for the 4 synthetic worker threads.
    pub silph_synth_handles: [Option<u32>; 4],
    /// AUDIT-2.BF — `ThreadRef` cache for the 4 synthetic workers.
    pub silph_synth_refs: [Option<xenia_cpu::ThreadRef>; 4],
    /// ITERATE-2C Phase D — auto-signal delay for silph::UImpl
    /// `NtCreateEvent` calls (see [`Self::maybe_register_silph_autosignal`]).
    /// `None` = feature disabled; populated once from
    /// `XENIA_SILPH_UI_AUTOSIGNAL_DELAY=<u64>` at construction.
    pub silph_autosignal_delay: Option<u64>,
    /// ITERATE-2C Phase D — pending auto-signal queue. Drained each
    /// outer round by [`Self::fire_due_silph_autosignals`].
    pub silph_autosignal_pending: Vec<AutoSignalPending>,
    /// ITERATE-2C Phase D — most recent `stats.instruction_count`
    /// deposited by the scheduler loop (see
    /// [`Self::set_now_cycle_hint`]). Used by
    /// [`Self::maybe_register_silph_autosignal`] to compute absolute
    /// deadlines, since `nt_create_event` doesn't see `ExecStats`.
    pub last_cycle_hint: u64,
    /// ITERATE-2C Phase D — one-shot diagnostic latch. Flipped by
    /// [`Self::fire_due_silph_autosignals`] on the first visit where
    /// the pending queue is non-empty but no entry is due yet.
    pub silph_autosignal_diag_logged: bool,
    /// ITERATE-2J — guest VA of the `KeTimeStampBundle` block (xboxkrnl
    /// data export ordinal 0x00AD). Set during the import-patch pass in
    /// `xenia-app`. Zero until then. The guest's worker-hub channel
    /// dispatch loop polls `[block+0x10]` (`tick_count`, milliseconds) and
    /// gates dispatch on a `tick_count + 66` deadline; if the block is
    /// never re-written that deadline never elapses and the hub spins
    /// forever (the tid14 0x109c starvation gate). The run loop ticks this
    /// block every round from the deterministic `global_clock` via
    /// [`Self::update_timestamp_bundle`].
    pub timestamp_bundle_addr: u32,

    /// Perf (Tier-B #5) throttle state for [`Self::update_timestamp_bundle`].
    /// Holds the `clock` value at which the bundle was last actually written;
    /// `u64::MAX` is the "never written" sentinel (forces the first write).
    /// `AtomicU64` (not `Cell`) so the `&self` update path stays `Sync` for
    /// the parallel `Arc<Mutex<KernelState>>` usage. Only ever advanced
    /// forward under the kernel lock, so `Relaxed` ordering is sufficient and
    /// the sequence is deterministic.
    timestamp_bundle_last_clock: std::sync::atomic::AtomicU64,
}

/// ITERATE-2C Phase D — one queued auto-signal. `deadline_cycle` is
/// absolute (cycle hint at register time + configured delay).
#[derive(Debug, Clone, Copy)]
pub struct AutoSignalPending {
    pub handle: u32,
    pub deadline_cycle: u64,
}

impl KernelState {
    /// Construct a kernel with the supplied GPU backend.
    ///
    /// The caller (typically `cmd_exec_inner`) decides whether to install
    /// an inline backend (default) or a threaded one (`--gpu-thread`).
    /// Most existing call sites build via [`Self::new`], which defaults to
    /// an inline backend; the threaded constructor lives at
    /// [`Self::with_gpu`].
    pub fn with_gpu(gpu: xenia_gpu::GpuBackend) -> Self {
        // Scheduler starts empty; the app installs the initial thread on
        // slot 0 via `install_initial_thread` right after construction.
        let mut scheduler = Scheduler::new();
        use std::sync::atomic::AtomicU32;
        let reservations = std::sync::Arc::new(xenia_cpu::ReservationTable::new());
        // M3.7 — wire the reservation table to the scheduler so
        // `spawn`/`install_initial_thread` populate every PpcContext's
        // `reservation_table` clone. The table is `disabled` by
        // default; `--reservations-table` / `XENIA_RESERVATIONS_TABLE`
        // / M3 spawn flip it on.
        scheduler.set_reservation_table(Some(reservations.clone()));
        let mut state = Self {
            exports: HashMap::new(),
            next_handle: AtomicU32::new(0x1000),
            free_handles: std::collections::VecDeque::new(),
            scheduler,
            next_tls_index: AtomicU32::new(0),
            cs_waiters: HashMap::new(),
            objects: HashMap::new(),
            heap_cursor: AtomicU32::new(0x4000_0000), // Start of user heap region
            stack_cursor: AtomicU32::new(0x7100_0000), // Above main stack
            gpu_command_buffer: 0,
            gpu,
            input_packet_number: 0,
            last_input_bytes: 0,
            image_base: 0,
            xex_system_flags: 0,
            xex_priv_logged: std::collections::HashSet::new(),
            has_notified_startup: false,
            has_notified_live_startup: false,
            next_thread_id: AtomicU32::new(1),
            vfs: None,
            cache_root: None,
            ui: None,
            interrupts: crate::interrupts::InterruptState::default(),
            xaudio: crate::xaudio::XAudioState::default(),
            // apu stage 1 — un-initialized until the app reserves the context
            // array and calls `xma.lock().init(va, phys)`.
            xma: std::sync::Arc::new(std::sync::Mutex::new(xenia_apu::XmaDecoder::new())),
            // AUDIT-032: dedicated audio worker per client (Plan B in
            // `xaudio_register_render_driver`) — not victim hijack, so safe
            // to enable by default. Previously gated off because the
            // random-victim selection corrupted unrelated thread state.
            xaudio_tick_enabled: true,
            handle_refcount: HashMap::new(),
            pending_timer_fires: Vec::new(),
            audit: HandleAudit::default(),
            reservations,
            thunks_by_ordinal: HashMap::new(),
            thunk_addr_band: None,
            cxx_throw_logged: false,
            ring_base: 0,
            ring_size_dwords: 0,
            parallel_active: false,
            ctor_probe_pcs: std::collections::HashSet::new(),
            pc_probe_consumers: HashMap::new(),
            branch_probe_pcs: std::collections::HashSet::new(),
            audit_pc_probe_pcs: std::collections::HashSet::new(),
            audit_mem_read_addr: None,
            audit_r3_dump_bytes: None,
            audit_deref: None,
            audit_pc_trace: None,
            lr_trace_pcs: std::collections::HashSet::new(),
            lr_trace_writer: None,
            dump_addrs: Vec::new(),
            dump_section: None,
            silph_synth_done: false,
            silph_synth_ctx: 0,
            silph_synth_handles: [None; 4],
            silph_synth_refs: [None; 4],
            silph_autosignal_delay: std::env::var("XENIA_SILPH_UI_AUTOSIGNAL_DELAY")
                .ok()
                .and_then(|v| v.parse::<u64>().ok()),
            silph_autosignal_pending: Vec::new(),
            last_cycle_hint: 0,
            silph_autosignal_diag_logged: false,
            timestamp_bundle_addr: 0,
            timestamp_bundle_last_clock: std::sync::atomic::AtomicU64::new(u64::MAX),
        };
        crate::exports::register_exports(&mut state);
        crate::xam::register_exports(&mut state);
        // AUDIT-054 — cache root selection. Defaults to AUDIT-038's
        // per-process tmpdir + wipe (lockstep determinism + avoids
        // the journal-append-on-reboot self-inconsistency Sylpheed's
        // `<hash>.tmp` writes produce). Opt-in to persistence via
        // `XENIA_CACHE_ROOT=<path>` (explicit) or
        // `XENIA_CACHE_PERSIST=1` (`$XDG_DATA_HOME/xenia-rs/cache`).
        // Errors are non-fatal (cache I/O degrades to the synth-stub
        // fallback) but logged.
        let (root, wipe) = resolve_default_cache_root();
        let init_result = if wipe {
            state.init_cache_root(root.clone())
        } else {
            std::fs::create_dir_all(&root).map(|()| {
                state.cache_root = Some(root.clone());
            })
        };
        if let Err(e) = init_result {
            tracing::warn!(
                "Failed to initialise cache root at {:?}: {} — cache:/* opens \
                 will fall back to the synth-empty-file stub",
                root,
                e
            );
        }
        state
    }

    /// Default constructor — installs an inline `GpuSystem`. Kept for
    /// callers that don't (yet) thread a `GpuBackend` choice through.
    pub fn new() -> Self {
        Self::with_gpu(xenia_gpu::GpuBackend::Inline(xenia_gpu::GpuSystem::new()))
    }

    pub fn register_export(
        &mut self,
        module: ModuleId,
        ordinal: u32,
        name: &'static str,
        func: KernelExportFn,
    ) {
        self.exports.insert((module, ordinal), (name, func));
    }

    /// AUDIT-038 — install a host directory as the backing store for the
    /// `cache:` mount. The directory is unconditionally cleared (and then
    /// re-created) on entry so two consecutive runs see byte-identical
    /// initial state — required for the `sylpheed_n*m.json` lockstep
    /// goldens. Mirrors canary's `xenia_main.cc:611-651` setup, which
    /// `RegisterSymbolicLink("cache:", "\\CACHE")` against a per-emulator
    /// host path.
    ///
    /// Returns `Ok(())` on success; bubbles up any I/O error from the
    /// clear/create dance so the caller can surface it.
    pub fn init_cache_root(&mut self, root: std::path::PathBuf) -> std::io::Result<()> {
        // Clear-then-recreate. Determinism beats incremental persistence
        // here: Sylpheed's cache subsystem treats a missing/empty cache
        // identically to a stale one (cache-miss → reconstruct), so
        // wiping is safe and gives reproducible boots.
        if root.exists() {
            std::fs::remove_dir_all(&root)?;
        }
        std::fs::create_dir_all(&root)?;
        self.cache_root = Some(root);
        Ok(())
    }

    /// AUDIT-054 — direct (non-wiping) cache-root install for tests
    /// that want byte-for-byte control over what's already on disk
    /// when the kernel boots. Skips the `init_cache_root` clear pass.
    pub fn set_cache_root(&mut self, root: std::path::PathBuf) {
        self.cache_root = Some(root);
    }

    /// Resolve a guest VFS path (e.g. `cache:\d4ea4615e46ee8ca.tmp`) to
    /// the host-FS path that backs it. Returns `None` if the path doesn't
    /// have a `cache:` prefix or if no cache root is mounted (legacy
    /// synth-stub fallback).
    ///
    /// Path-traversal guard: leading `..\` components are stripped so a
    /// malicious guest can't escape the cache directory. Backslashes are
    /// normalised to host separators on Linux.
    pub fn resolve_cache_path(&self, raw: &str) -> Option<std::path::PathBuf> {
        let root = self.cache_root.as_ref()?;
        let lower = raw.to_ascii_lowercase();
        // Match any of the writable cache prefixes (case-insensitive).
        // canary uses separate `\CACHE0`/`\CACHE1` host dirs for cache0:/
        // cache1:, but Sylpheed only references `cache:`; collapse all
        // three to one backing root until a future game splits them.
        let after_prefix = if let Some(rest) = lower.strip_prefix("cache:\\") {
            &raw[raw.len() - rest.len()..]
        } else if let Some(rest) = lower.strip_prefix("cache:/") {
            &raw[raw.len() - rest.len()..]
        } else if let Some(rest) = lower.strip_prefix("cache0:\\")
            .or_else(|| lower.strip_prefix("cache0:/"))
            .or_else(|| lower.strip_prefix("cache1:\\"))
            .or_else(|| lower.strip_prefix("cache1:/"))
        {
            &raw[raw.len() - rest.len()..]
        } else {
            return None;
        };
        let normalised = after_prefix.replace('\\', "/");
        // Strip leading slashes + path-traversal segments.
        let clean: std::path::PathBuf = normalised
            .split('/')
            .filter(|s| !s.is_empty() && *s != "." && *s != "..")
            .collect();
        Some(root.join(clean))
    }

    /// Record an import-thunk address resolved at load time. Called once
    /// per `record_type==1` import in xenia-app's Phase 1. Idempotent: a
    /// duplicate ordinal overwrites (later wins; in practice the loader
    /// emits each ordinal once per module).
    pub fn register_thunk(&mut self, module: ModuleId, ordinal: u16, address: u32) {
        self.thunks_by_ordinal.insert((module, ordinal), address);
        // Widen the thunk address band (Tier-A #4) so the hot prologue can
        // range-reject non-thunk PCs before hashing the thunk map.
        self.thunk_addr_band = Some(match self.thunk_addr_band {
            Some((lo, hi)) => (lo.min(address), hi.max(address)),
            None => (address, address),
        });
    }

    /// Perf (Tier-A #4). Cheap pre-filter for the per-slot-visit import-thunk
    /// dispatch: `false` guarantees `pc` is NOT a registered thunk (so the
    /// caller can skip the `thunk_map.get(&pc)` hash). `true` means `pc` lies
    /// within the registered thunk address band and the map must be consulted
    /// for an exact match. Conservative — never a false negative.
    #[inline]
    pub fn pc_in_thunk_band(&self, pc: u32) -> bool {
        match self.thunk_addr_band {
            Some((lo, hi)) => pc >= lo && pc <= hi,
            None => false,
        }
    }

    /// The import-thunk address band `(lo, hi)` (inclusive), or `None` if no
    /// thunks are registered. Exposed so the JIT superblock compiler can refuse
    /// to chain into the thunk band (those PCs need the full worker-prologue
    /// dispatch, so the chain must stop and hand back to the round).
    pub fn thunk_band(&self) -> Option<(u32, u32)> {
        self.thunk_addr_band
    }

    /// Resolve a `(module, ordinal)` to its registered thunk address.
    pub fn resolve_thunk(&self, module: ModuleId, ordinal: u16) -> Option<u32> {
        self.thunks_by_ordinal.get(&(module, ordinal)).copied()
    }

    /// Map a pseudo-`HMODULE` (as returned by `XexGetModuleHandle`) back
    /// to its `ModuleId`. Returns `None` for unknown handles, including
    /// the loaded XEX's `image_base` (which is *not* a kernel module).
    pub fn module_id_from_hmodule(&self, handle: u32) -> Option<ModuleId> {
        match handle {
            HMODULE_XBOXKRNL => Some(ModuleId::Xboxkrnl),
            HMODULE_XAM => Some(ModuleId::Xam),
            _ => None,
        }
    }

    /// Dispatch a kernel export on the current HW thread. Uses `mem::replace`
    /// to temporarily move the active `PpcContext` out of its scheduler slot,
    /// so the export function can receive `&mut ctx` while also getting
    /// `&mut self` (which contains the scheduler). Without this, the export
    /// signature would have to avoid aliasing via a bundle struct — see the
    /// approved plan's ExportCtx section for the alternative we rejected.
    ///
    /// While the export runs, `scheduler.hw_threads[current_hw_id].ctx` holds
    /// a freshly-constructed placeholder. Exports that reach through
    /// `state.scheduler` must not touch the current slot's `ctx` field.
    ///
    /// **Perf note (First-Pixels M1):** this function fires ~250K/s on
    /// Sylpheed (1 import per 40 guest instructions). A former
    /// `#[tracing::instrument]` attribute + two `tracing::info!` call
    /// sites made up ~28% of `run_execution` wall time on a post-Tier-3
    /// profile — most of it in `tracing::span::Span::new` +
    /// `Layered::new_span` + `ErrorLayer::on_new_span`. The span was at
    /// `level = "debug"` but the span **construction** happened
    /// unconditionally; only the emit was level-gated. Removing the
    /// attribute + the two `info!` lines recovers the overhead without
    /// losing any observability — the `metrics::counter!("kernel.calls",
    /// "name" => name)` below still tracks per-export counts, and
    /// unimplemented lookups still emit a `warn!`.
    pub fn call_export(
        &mut self,
        module: ModuleId,
        ordinal: u32,
        mem: &GuestMemory,
    ) -> bool {
        // The thread whose ctx we're swapping out must be addressed by
        // `ThreadRef`, not `hw_id` — under per-slot runqueues a bare
        // `hw_id` alone can't distinguish multiple threads on the same
        // slot, and Axis 4 migration can change the slot underneath us.
        let r = self
            .scheduler
            .current
            .expect("call_export: no current thread");
        let mut ctx = std::mem::replace(
            self.scheduler.ctx_mut_ref(r),
            PpcContext::new(),
        );

        let result = if let Some(&(name, func)) = self.exports.get(&(module, ordinal)) {
            metrics::counter!("kernel.calls", "name" => name).increment(1);
            tracing::trace!(target: "probe_calls", "hw={} call={} r3={:#x} r4={:#x} r5={:#x} lr={:#x}",
                r.hw_id, name, ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.lr);
            func(&mut ctx, mem, self);
            true
        } else {
            metrics::counter!("kernel.unimplemented").increment(1);
            tracing::warn!(
                module = ?module,
                ordinal = format_args!("{:#x}", ordinal),
                "unimplemented kernel export"
            );
            ctx.gpr[3] = 0;
            false
        };

        // Restore the (possibly mutated) ctx by ThreadRef. Axis 4
        // self-migration (KeSetAffinityThread(NtCurrentThread, ...))
        // updates `scheduler.current` in place; re-read here so we
        // restore onto the thread's new slot, not its old one.
        let final_ref = self.scheduler.current.unwrap_or(r);
        *self.scheduler.ctx_mut_ref(final_ref) = ctx;
        result
    }

    /// Axis 4: `KeSetAffinityThread` orchestration. Drives the scheduler's
    /// migration and fixes up every `ThreadRef` held outside the
    /// scheduler (kernel object waiter lists, critical-section waiters,
    /// `interrupts.injected_ref`). Returns the previous mask.
    pub fn set_affinity(&mut self, handle: u32, new_mask: u8, mem: &GuestMemory) -> u8 {
        let Some(r) = self.scheduler.find_by_handle(handle) else {
            return 0;
        };
        let (old_mask, _new_ref, fixup) = self.scheduler.set_affinity_ref(
            r,
            new_mask,
            &mut GuestMemoryPcr(mem),
        );
        if let Some(fx) = fixup {
            use crate::objects::KernelObject;
            for obj in self.objects.values_mut() {
                match obj {
                    KernelObject::Event { waiters, .. }
                    | KernelObject::Semaphore { waiters, .. }
                    | KernelObject::Thread { waiters, .. }
                    | KernelObject::Mutex { waiters, .. } => {
                        for w in waiters.iter_mut() {
                            fx.apply(w);
                        }
                    }
                    _ => {}
                }
            }
            for list in self.cs_waiters.values_mut() {
                for w in list.iter_mut() {
                    fx.apply(w);
                }
            }
            if let Some(ref mut ir) = self.interrupts.injected_ref {
                fx.apply(ir);
            }
        }
        old_mask
    }

    /// Install the initial (main) guest thread on HW slot 0. Called once at
    /// startup after the app allocates the main stack/PCR/TLS blocks.
    pub fn install_initial_thread(
        &mut self,
        ctx: PpcContext,
        stack_base: u32,
        stack_size: u32,
        pcr_base: u32,
        tls_base: u32,
        thread_handle: u32,
        mem: &GuestMemory,
    ) {
        self.scheduler.install_initial_thread(
            ctx,
            stack_base,
            stack_size,
            pcr_base,
            tls_base,
            thread_handle,
            &mut GuestMemoryPcr(mem),
        );
    }

    pub fn export_name(&self, module: ModuleId, ordinal: u32) -> Option<&'static str> {
        self.exports.get(&(module, ordinal)).map(|&(name, _)| name)
    }

    pub fn alloc_handle(&mut self) -> u32 {
        // AUDIT-059 R34: prefer recycling a closed slot (FIFO, matching
        // canary's `ObjectTable` slab) before bumping. The Arc<Mutex<
        // KernelState>> already serializes us; no extra synchronization.
        if let Some(slot) = self.free_handles.pop_front() {
            return slot;
        }
        // M2.4: lock-free fetch_add. Relaxed is sufficient — IDs are
        // opaque tokens; no payload is sequenced against the counter.
        self.next_handle
            .fetch_add(4, std::sync::atomic::Ordering::Relaxed)
    }

    /// AUDIT-059 R34. Return a freshly-closed handle slot to the FIFO
    /// recycle queue. No-op for the synthetic XAudio range (`>= 0xF000_0000`,
    /// AUDIT-048) and the reserved `< 0x1000` band. Call site: `nt_close`'s
    /// `objects.remove` branch when refcount reaches zero.
    ///
    /// ABA guard (subsystem-audit 2026-06-12): never recycle a slot that a
    /// thread is still parked on. Without this, a closed slot could be
    /// re-minted for a new object and a signal on that new object would wake
    /// the stale waiter that was blocked on the OLD object at the same slot.
    /// Such a slot is simply leaked (it stays out of `free_handles`),
    /// reproducing the pre-R34 bump-only behaviour for that rare case.
    pub fn release_handle_slot(&mut self, handle: u32) {
        if handle < 0x1000 || handle >= 0xF000_0000 {
            return;
        }
        if self.scheduler.any_thread_waiting_on(handle) {
            return;
        }
        self.free_handles.push_back(handle);
    }

    pub fn alloc_handle_for(&mut self, obj: KernelObject) -> u32 {
        let h = self.alloc_handle();
        self.objects.insert(h, obj);
        // Each fresh handle starts with one logical reference (the creator).
        // `NtDuplicateObject` bumps this; `NtClose` decrements; the object is
        // only dropped when the count reaches zero. See `nt_close` for the
        // aliased-handle rationale.
        self.handle_refcount.insert(h, 1);
        h
    }

    // ===== Handle audit hooks =====
    //
    // These are no-ops when `audit.enabled == false`, so call sites can
    // unconditionally invoke them without a hot-path branch in release builds
    // (the `inline` `if !enabled return` short-circuits before any work).

    /// Build a [`HandleAuditEntry`] describing the *current* call-site —
    /// captures cycle (slot-0 timebase), current `tid`, and `lr` from the
    /// passed `PpcContext`.
    fn audit_entry(&self, lr: u32, source: &'static str, aux: u64) -> HandleAuditEntry {
        let hw_id = self.scheduler.current_hw_id().unwrap_or(0);
        let cycle = self.scheduler.ctx(hw_id).timebase;
        let tid = self.scheduler.tid(hw_id).unwrap_or(0);
        HandleAuditEntry { cycle, tid, lr, source, aux }
    }

    /// Record the creation of a fresh handle. `kind` is one of the stable
    /// labels documented on [`crate::audit::HandleAuditTrail::kind`].
    pub fn audit_create(&mut self, handle: u32, kind: &'static str, lr: u32, source: &'static str) {
        if !self.audit.enabled {
            return;
        }
        let entry = self.audit_entry(lr, source, 0);
        self.audit.record_create(handle, kind, entry);
    }

    /// KRNBUG-AUDIT-002. Variant of `audit_create` that additionally
    /// captures a 6-frame guest stack trace at allocation time when the
    /// handle is in `audit.focus`. Outside the focus set this falls back
    /// to plain `audit_create` (no stack walk → no extra cost on the hot
    /// path of unfocused handle creation).
    ///
    /// The walk reads the PPC EABI back-chain: `[r1] = prev_sp`, and the
    /// LR saved by *that* prev frame's prologue lives at `[prev_sp - 8]`.
    /// Frame 0 is the live frame `(ctx.gpr[1], ctx.lr)`. Frames 1..N walk
    /// upward. A read returning 0 / 0xFFFF_FFFF, or a self-loop, ends the
    /// walk early. This is read-only — guest memory and CPU state are not
    /// mutated, so lockstep determinism is unaffected (a parallel run with
    /// no focus is byte-identical to one without this code path).
    pub fn audit_create_with_ctx(
        &mut self,
        handle: u32,
        kind: &'static str,
        ctx: &PpcContext,
        mem: &GuestMemory,
        source: &'static str,
    ) {
        if !self.audit.enabled {
            return;
        }
        let lr = ctx.lr as u32;
        let entry = self.audit_entry(lr, source, 0);
        if !self.audit.focus.contains(&handle) {
            self.audit.record_create(handle, kind, entry);
            return;
        }
        let stack = walk_guest_back_chain(ctx.gpr[1] as u32, lr, mem, 6);
        let probes = probe_create_stack_classes(ctx, &stack, mem);
        self.audit
            .record_create_with_stack_and_probes(handle, kind, entry, stack, probes);
    }

    /// Record a Set/Pulse/Release/etc. call against a handle. `aux` is the
    /// previous signal state (or per-export-specific data).
    pub fn audit_signal(&mut self, handle: u32, lr: u32, source: &'static str, aux: u64) {
        // RE diagnostic (milestone-2 intro-video). `XENIA_LOG_SIGNAL=0x<va>`
        // (or `0xffffffff` for all) logs every event signal/pulse targeting
        // that object — to pin who, if anyone, signals the engine event
        // 0x40D10214 that tid23 waits on (the worker-resume gate). Read-only.
        {
            use std::sync::OnceLock;
            static SIG_T: OnceLock<u32> = OnceLock::new();
            let t = *SIG_T.get_or_init(|| {
                std::env::var("XENIA_LOG_SIGNAL")
                    .ok()
                    .and_then(|s| u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok())
                    .unwrap_or(0)
            });
            if t != 0 && (t == u32::MAX || t == handle) {
                let tid = self
                    .scheduler
                    .current_hw_id()
                    .and_then(|h| self.scheduler.tid(h))
                    .unwrap_or(0);
                tracing::info!(
                    "SIGNAL src={source} handle={handle:#010x} tid={tid} lr={lr:#010x} prev_signaled={aux}"
                );
            }
        }
        if !self.audit.enabled {
            return;
        }
        let entry = self.audit_entry(lr, source, aux);
        self.audit.record_signal(handle, entry);
    }

    /// Record a `Wait*` call against a handle. `aux` packs `(alertable as u64)
    /// | (timeout_kind << 8)` etc. — schema is informal; the dump just prints
    /// it.
    pub fn audit_wait(&mut self, handle: u32, lr: u32, source: &'static str, aux: u64) {
        if !self.audit.enabled {
            return;
        }
        let entry = self.audit_entry(lr, source, aux);
        self.audit.record_wait(handle, entry);
    }

    /// Record a wake event (called from `wake_eligible_waiters`). `aux`
    /// is the status code stamped into the woken thread's `gpr[3]`.
    pub fn audit_wake(&mut self, handle: u32, lr: u32, source: &'static str, aux: u64) {
        if !self.audit.enabled {
            return;
        }
        let entry = self.audit_entry(lr, source, aux);
        self.audit.record_wake(handle, entry);
    }

    /// ITERATE-2C Phase D — deposit the latest scheduler instruction
    /// count so `nt_create_event` can compute absolute auto-signal
    /// deadlines. Called once per outer round from the app's
    /// `coord_pre_round` site. No-op when the feature env is unset.
    pub fn set_now_cycle_hint(&mut self, now_cycle: u64) {
        self.last_cycle_hint = now_cycle;
    }

    /// ITERATE-2J — tick the `KeTimeStampBundle` block (xboxkrnl ordinal
    /// 0x00AD) from the deterministic monotonic clock so the guest sees a
    /// clock that *advances*.
    ///
    /// `clock` is the scheduler's `global_clock` — a pure function of
    /// retired guest instructions (see [`Self::now_basis_at`] /
    /// `Scheduler::global_clock`). Lockstep floors it up to
    /// `stats.instruction_count` each round; parallel sums per-block
    /// retired counts. Using it (rather than wall-clock) keeps every
    /// guest-visible time value a deterministic function of guest progress,
    /// so lockstep stays byte-reproducible.
    ///
    /// ## Cadence
    /// The existing kernel time math (`parse_timeout` in `exports.rs`)
    /// already treats **1 `global_clock` unit ≈ 100 ns**: it converts a
    /// signed 100-ns `LARGE_INTEGER` timeout to a deadline by dividing the
    /// magnitude by 100 and adding it to `now` (= `global_clock`). To stay
    /// coherent with that, this method uses the same scale:
    ///
    /// * `interrupt_time` / `system_time` (100-ns units): `clock` (with a
    ///   FILETIME epoch base added to `system_time`).
    /// * `tick_count` (milliseconds): `clock / INSTRUCTIONS_PER_MS` where
    ///   `INSTRUCTIONS_PER_MS = 10_000` (10_000 × 100 ns = 1 ms).
    ///
    /// At 10_000 clock-units/ms, the guest's `tick_count + 66` ms hub
    /// deadline elapses by ~660_000 retired instructions — very early in a
    /// ~1 B-instruction boot — while a 16 ms `KeWait` timeout
    /// (`parse_timeout`: 160_000 units) still resolves to 16 ms of
    /// tick_count, so no timeout collapses to "instant". The two readers
    /// share one scale.
    pub fn update_timestamp_bundle(&self, mem: &GuestMemory, clock: u64) {
        let block = self.timestamp_bundle_addr;
        if block == 0 {
            return;
        }
        // tick_count(ms) scale = retired global_clock units per guest ms.
        //
        // 1_000_000 models ~1000 MIPS. The prior 10_000 (~10 MIPS, 1 unit≈100ns)
        // ran the guest clock so fast that the intro-movie's software video
        // decoder — a legitimate ~20M-instruction / ~720p-YUV420-frame job that
        // is ~6 ms on the real ~3.2 GHz console — was perceived by the guest as
        // >2000 ms and tripped the movie handler's 2000 ms decode deadline
        // (`sub_821B4968` @0x821b68c0), which sets an abort bit and never plays
        // the video (canary never even enters that wait loop). The all-thread-
        // SUM `global_clock` is further inflated ~58× by the serialized
        // scheduler counting busy-spin, so a HW-faithful per-thread rate isn't
        // usable directly; 1_000_000 sits in the empirically-validated window
        // [~600k, ~2.77M] that fits BOTH the movie (decode ≤2000 ms) AND boot
        // (the worker-hub `tick_count + 66 ms` gate still elapses before the
        // movie at ~66M instr; boot render goldens stay healthy — see
        // tests/golden/sylpheed_n50m.json). `XENIA_INSTR_PER_MS=<n>` overrides
        // for diagnostics. See project memory STEP 73-83 for the full trace.
        let instructions_per_ms: u64 = {
            use std::sync::OnceLock;
            static IPM: OnceLock<u64> = OnceLock::new();
            *IPM.get_or_init(|| {
                std::env::var("XENIA_INSTR_PER_MS")
                    .ok()
                    .and_then(|s| s.trim().parse::<u64>().ok())
                    .filter(|&n| n > 0)
                    .unwrap_or(1_000_000)
            })
        };
        #[allow(non_snake_case)]
        let INSTRUCTIONS_PER_MS: u64 = instructions_per_ms;
        // Perf (Tier-B #5): the bundle is updated once per scheduler round
        // (~every 7 retired instructions), but the four guest BE memory
        // writes are ~8.6% of boot-to-splash. `clock` is the retired-
        // instruction count, so consecutive rounds rewrite essentially the
        // same staircase. Throttle to a 0.25 ms quantum: only re-write when
        // `clock` advanced by >= INSTRUCTIONS_PER_MS/4 (2500 units) since the
        // last write. This keeps `tick_count` (ms, changes every 10_000
        // units) ALWAYS fresh and `interrupt_time`/`system_time` monotone at
        // 0.25 ms granularity — finer than any guest deadline math needs
        // (`parse_timeout` works in whole ms; the hub gate is `+66 ms`). The
        // fade-in (3AH-proven vsync-counter driven, NOT this bundle) is
        // untouched. Throttle threshold is well below 1 ms so no guest-
        // visible ms boundary is ever skipped.
        let bundle_quantum: u64 = INSTRUCTIONS_PER_MS / 4; // 0.25 ms in units
        {
            use std::sync::atomic::Ordering;
            let last = self.timestamp_bundle_last_clock.load(Ordering::Relaxed);
            // Always allow the first write (last == u64::MAX sentinel) and any
            // write that crosses the quantum. Never go backwards.
            if last != u64::MAX && clock < last.saturating_add(bundle_quantum) {
                return;
            }
            self.timestamp_bundle_last_clock
                .store(clock, Ordering::Relaxed);
        }
        // FILETIME epoch base (~2021) so `system_time` is a plausible
        // absolute wall-clock; matches the constant used by
        // `ke_query_system_time`. interrupt_time is "since boot" so it
        // starts at the clock origin (no epoch offset).
        const FILETIME_BASE: u64 = 132_500_000_000_000_000;
        let interrupt_time: u64 = clock;
        let system_time: u64 = FILETIME_BASE.wrapping_add(clock);
        let tick_count: u32 = (clock / INSTRUCTIONS_PER_MS) as u32;
        // BE writes (write_u64/write_u32 use to_be_bytes) — guest is BE.
        mem.write_u64(block, interrupt_time); // +0x00 interrupt_time
        mem.write_u64(block + 0x08, system_time); // +0x08 system_time
        mem.write_u32(block + 0x10, tick_count); // +0x10 tick_count (ms)
        mem.write_u32(block + 0x14, 0); // +0x14 padding
    }

    /// ITERATE-2C Phase D — register a freshly-allocated event for
    /// auto-signal after the configured delay, **iff** the creating
    /// thread matches the silph::UImpl tid=13 chain that wedges in
    /// audit-049. Filter:
    ///
    /// * Env `XENIA_SILPH_UI_AUTOSIGNAL_DELAY` set (= delay non-None)
    /// * Frame-1 LR (the guest caller's post-bl PC, walked one step up
    ///   from the live thunk-wrapper frame) is in
    ///   `[0x821CB15C, 0x821CB160]` — this is the `NtCreateEvent` call
    ///   site inside `sub_821CB030+0x128`. The live `ctx.lr` is the
    ///   thunk wrapper's return slot (e.g. `0x824a9f6c`), so we walk
    ///   one back-chain step to reach the actual guest caller.
    /// * Creating thread's `start_entry == 0x821748F0` (silph trampoline)
    /// * Creating thread's `start_context == 0x4024a840`
    ///
    /// On match, the handle is queued with `deadline = last_cycle_hint +
    /// delay`. Drained by [`Self::fire_due_silph_autosignals`] from the
    /// outer scheduler loop.
    pub fn maybe_register_silph_autosignal(
        &mut self,
        handle: u32,
        ctx: &PpcContext,
        mem: &GuestMemory,
    ) {
        let Some(delay) = self.silph_autosignal_delay else {
            return;
        };
        let Some((entry, start_ctx)) = self.scheduler.current_thread_entry_and_ctx() else {
            return;
        };
        if entry != 0x821748F0 || start_ctx != 0x4024_a840 {
            return;
        }
        let frames = walk_guest_back_chain(ctx.gpr[1] as u32, ctx.lr as u32, mem, 2);
        let caller_lr = match frames.get(1) {
            Some((_, lr)) => *lr,
            None => return,
        };
        if !(0x821CB15C..=0x821CB160).contains(&caller_lr) {
            return;
        }
        let deadline = self.last_cycle_hint.saturating_add(delay);
        self.silph_autosignal_pending
            .push(AutoSignalPending { handle, deadline_cycle: deadline });
        tracing::info!(
            "silph autosignal: scheduled handle={:#x} caller_lr={:#x} for cycle {} (now={}, delay={})",
            handle,
            caller_lr,
            deadline,
            self.last_cycle_hint,
            delay,
        );
    }

    /// ITERATE-2C Phase D — drain pending entries whose deadline has
    /// passed. Each fires by setting `Event { signaled = true }` and
    /// invoking the existing `wake_eligible_waiters` to release blocked
    /// waiters. No-op when the queue is empty (the common case).
    pub fn fire_due_silph_autosignals(&mut self, now_cycle: u64) {
        if self.silph_autosignal_pending.is_empty() {
            return;
        }
        let any_due = self
            .silph_autosignal_pending
            .iter()
            .any(|p| p.deadline_cycle <= now_cycle);
        if !any_due {
            // Diagnostic for the Phase D POC: log first time we visit
            // with a non-empty queue but nothing due yet.
            if !self.silph_autosignal_diag_logged {
                self.silph_autosignal_diag_logged = true;
                if let Some(first) = self.silph_autosignal_pending.first() {
                    tracing::info!(
                        "silph autosignal: tick (first visit, none due) now={} pending={} first_deadline={}",
                        now_cycle,
                        self.silph_autosignal_pending.len(),
                        first.deadline_cycle,
                    );
                }
            }
        }
        let mut i = 0;
        while i < self.silph_autosignal_pending.len() {
            if self.silph_autosignal_pending[i].deadline_cycle <= now_cycle {
                let p = self.silph_autosignal_pending.swap_remove(i);
                let prev = match self.objects.get_mut(&p.handle) {
                    Some(KernelObject::Event { signaled, .. }) => {
                        let was = *signaled;
                        *signaled = true;
                        Some(was)
                    }
                    _ => None,
                };
                tracing::info!(
                    "silph autosignal: firing handle={:#x} prev_signaled={:?} at cycle {}",
                    p.handle,
                    prev,
                    now_cycle,
                );
                self.audit_signal(p.handle, 0, "silph_autosignal", prev.unwrap_or(false) as u64);
                crate::exports::wake_eligible_waiters(self, p.handle);
                // do not advance i — swap_remove pulled a new entry into i
            } else {
                i += 1;
            }
        }
    }

    /// Perf gate (Tier-A quick-win #3). `true` iff any of the four
    /// per-slot-visit diagnostic probe registries
    /// (`ctor_probe_pcs` / `branch_probe_pcs` / `audit_pc_probe_pcs`
    /// / `lr_trace_pcs`) holds at least one PC. The common headless
    /// run leaves all four empty, so the prologue can skip the four
    /// `fire_*_if_match` calls entirely with this single predicted
    /// branch — avoiding 4× call overhead per slot-visit (~3.2M
    /// visits over boot-to-splash) when no probe is configured.
    /// Purely a fast-path guard; each `fire_*` still re-checks its own
    /// set, so behaviour is identical whether or not the caller gates.
    #[inline]
    pub fn any_probe_active(&self) -> bool {
        !self.ctor_probe_pcs.is_empty()
            || !self.branch_probe_pcs.is_empty()
            || !self.audit_pc_probe_pcs.is_empty()
            || !self.lr_trace_pcs.is_empty()
            || self.audit_pc_trace.is_some()
    }

    /// Diagnostic. If the live PC for HW slot `hw_id` is in
    /// `self.ctor_probe_pcs`, emit a single `CTOR-PROBE` line with
    /// the current cycle, tid, hw_id, sp, r3, lr, plus an 8-frame
    /// back-chain walk. Read-only — no guest state is mutated, so a
    /// run with the probe set is byte-identical to one without (the
    /// probe only adds println noise).
    ///
    /// Intended call site: top of `worker_prologue`, after `pc` has
    /// been read but before any thunk-dispatch / step-block branch.
    /// Fires once per hit — if the same PC is reached again (e.g.
    /// the bridge ctor sub_8217C850 called 8 times by the static-
    /// init driver), it fires 8 times, which is exactly what we want
    /// for pool-element identification.
    pub fn fire_ctor_probe_if_match(&self, hw_id: u8, mem: &GuestMemory) {
        if self.ctor_probe_pcs.is_empty() {
            return;
        }
        let ctx = self.scheduler.ctx(hw_id);
        let pc = ctx.pc;
        if !self.ctor_probe_pcs.contains(&pc) {
            return;
        }
        let tid = self.scheduler.tid(hw_id).unwrap_or(0);
        let r3 = ctx.gpr[3] as u32;
        let lr = ctx.lr as u32;
        let sp = ctx.gpr[1] as u32;
        let cycle = ctx.cycle_count;
        let frames = walk_guest_back_chain(sp, lr, mem, 8);
        println!(
            "CTOR-PROBE pc={:#010x} tid={} hw={} cycle={} sp={:#010x} r3={:#010x} lr={:#010x}",
            pc, tid, hw_id, cycle, sp, r3, lr,
        );
        if let Some(&(disp, off)) = self.pc_probe_consumers.get(&pc) {
            let field_addr = disp.wrapping_add(off);
            let field_val = mem.read_u32(field_addr);
            println!(
                "  CTOR-PROBE consumer disp={:#010x} off={} field={:#010x} (= [disp+off])",
                disp, off, field_val,
            );
        }
        for (i, (fp, frame_lr)) in frames.iter().enumerate() {
            let saved_r31 = mem.read_u32(fp.wrapping_sub(12));
            let saved_r30 = mem.read_u32(fp.wrapping_sub(16));
            println!(
                "  CTOR-PROBE frame={} fp={:#010x} lr={:#010x} saved-r31={:#010x} saved-r30={:#010x}",
                i, fp, frame_lr, saved_r31, saved_r30,
            );
        }
    }

    /// Diagnostic. If the live PC for HW slot `hw_id` is in
    /// `self.branch_probe_pcs`, emit one compact `BRANCH-PROBE` line
    /// with (pc, tid, hw, cycle, r3, lr, cr0.{lt,gt,eq}, cr6.{lt,gt,eq}).
    /// No back-chain walk — designed for tracing every conditional
    /// branch fire inside a candidate-gate function. Read-only.
    /// Lockstep digest unaffected.
    pub fn fire_branch_probe_if_match(&self, hw_id: u8) {
        if self.branch_probe_pcs.is_empty() {
            return;
        }
        let ctx = self.scheduler.ctx(hw_id);
        let pc = ctx.pc;
        if !self.branch_probe_pcs.contains(&pc) {
            return;
        }
        let tid = self.scheduler.tid(hw_id).unwrap_or(0);
        let r3 = ctx.gpr[3] as u32;
        let lr = ctx.lr as u32;
        let cycle = ctx.cycle_count;
        let cr0 = &ctx.cr[0];
        let cr6 = &ctx.cr[6];
        println!(
            "BRANCH-PROBE pc={:#010x} tid={} hw={} cycle={} r3={:#010x} lr={:#010x} cr0={}{}{} cr6={}{}{}",
            pc, tid, hw_id, cycle, r3, lr,
            if cr0.lt { 'L' } else { '.' },
            if cr0.gt { 'G' } else { '.' },
            if cr0.eq { 'E' } else { '.' },
            if cr6.lt { 'L' } else { '.' },
            if cr6.gt { 'G' } else { '.' },
            if cr6.eq { 'E' } else { '.' },
        );
    }

    /// AUDIT-2BF — diagnostic. If the live PC for HW slot `hw_id` is in
    /// `self.audit_pc_probe_pcs`, emit a single one-line
    /// `AUDIT-PC-PROBE` record with (pc, tid, hw, cycle, lr, r3, r11)
    /// plus four guest-memory dereferences off r3: `[r3+0]` (vtable),
    /// `[[r3+0]+24]` (slot 6 method = bctrl target), `[r3+0x0C]`
    /// (auxiliary handle field), `[r3+0x30]` (embedded sub-object
    /// vtable field). Tuned for the silph init chain virtual-dispatch
    /// site at `sub_82172BA0+0x1E8` (PC `0x82172D88`).
    ///
    /// Read-only. No guest-state mutation; lockstep digest unaffected.
    /// Empty set is the common case → single `is_empty()` test on the
    /// hot path.
    pub fn fire_audit_pc_probe_if_match(&self, hw_id: u8, mem: &GuestMemory) {
        // milestone-2 demux BB-trace. Independent of the pc-set probe
        // below; fires at every block-entry inside the configured PC
        // range for the configured thread. Read-only.
        if let Some((lo, hi, want_tid)) = self.audit_pc_trace {
            let ctx = self.scheduler.ctx(hw_id);
            let pc = ctx.pc;
            if pc >= lo && pc < hi {
                let tid = self.scheduler.tid(hw_id).unwrap_or(0);
                if want_tid == 0 || tid == want_tid {
                    println!(
                        "AUDIT-TRACE pc={:#010x} tid={} cycle={} lr={:#010x} \
                         r3={:#010x} r4={:#010x} r5={:#010x} r30={:#010x} r31={:#010x}",
                        pc, tid, ctx.cycle_count, ctx.lr as u32,
                        ctx.gpr[3] as u32, ctx.gpr[4] as u32, ctx.gpr[5] as u32,
                        ctx.gpr[30] as u32, ctx.gpr[31] as u32,
                    );
                }
            }
        }
        if self.audit_pc_probe_pcs.is_empty() {
            return;
        }
        let ctx = self.scheduler.ctx(hw_id);
        let pc = ctx.pc;
        if !self.audit_pc_probe_pcs.contains(&pc) {
            return;
        }
        let tid = self.scheduler.tid(hw_id).unwrap_or(0);
        let r3 = ctx.gpr[3] as u32;
        let r11 = ctx.gpr[11] as u32;
        let lr = ctx.lr as u32;
        let cycle = ctx.cycle_count;
        // Memory dereferences. Guest pointers may be unmapped/garbage;
        // `read_u32` returns 0 for unmapped pages (heap.rs:510 returns
        // a default), so an all-zero block in the output reliably
        // indicates an invalid `r3`.
        let vtable = mem.read_u32(r3);
        let slot6_method = if vtable != 0 {
            mem.read_u32(vtable.wrapping_add(24))
        } else {
            0
        };
        let aux_handle = mem.read_u32(r3.wrapping_add(0x0C));
        let sub_vt = mem.read_u32(r3.wrapping_add(0x30));
        println!(
            "AUDIT-PC-PROBE pc={:#010x} tid={} hw={} cycle={} lr={:#010x} r3={:#010x} r11={:#010x} \
             [r3+0]={:#010x} [[r3+0]+24]={:#010x} [r3+0x0C]={:#010x} [r3+0x30]={:#010x}",
            pc, tid, hw_id, cycle, lr, r3, r11,
            vtable, slot6_method, aux_handle, sub_vt,
        );
        // AUDIT-2BF round 14 — paired memory-read. When
        // `audit_mem_read_addr` is set, dereference 3 deep: singleton
        // pointer → vtable → vtable[0] / vtable[24]. Defensively
        // null-checks each level. `read_u32` returns 0 for unmapped
        // pages so all-zero output is the unmapped/uninitialized
        // signature.
        if let Some(addr) = self.audit_mem_read_addr {
            let val = mem.read_u32(addr);
            let vt = if val != 0 { mem.read_u32(val) } else { 0 };
            let m0 = if vt != 0 { mem.read_u32(vt) } else { 0 };
            let m6 = if vt != 0 { mem.read_u32(vt.wrapping_add(24)) } else { 0 };
            println!(
                "AUDIT-MEM-READ addr={:#010x} val={:#010x} vtable={:#010x} \
                 vtable[0]={:#010x} vtable[24]={:#010x} pc={:#010x} tid={} cycle={}",
                addr, val, vt, m0, m6, pc, tid, cycle,
            );
        }
        // AUDIT-052 — dump N bytes of guest memory from r3 as u32 lanes
        // when `audit_r3_dump_bytes` is set. Sized for the 80-byte
        // stack-local struct at sub_82452DC0's `r31+96` (probe is
        // sub_8245B000 entry where r3 IS the struct ptr). Output
        // format: `AUDIT-R3-DUMP pc=… r3=… +0x00=… +0x04=… …`.
        if let Some(n) = self.audit_r3_dump_bytes {
            let n = n.min(256) & !3u32; // cap 256B, 4-byte align
            let mut out = String::with_capacity(64 + (n as usize) * 16);
            use std::fmt::Write as _;
            let _ = write!(
                &mut out,
                "AUDIT-R3-DUMP pc={:#010x} tid={} cycle={} r3={:#010x}",
                pc, tid, cycle, r3,
            );
            let mut off: u32 = 0;
            while off < n {
                let v = mem.read_u32(r3.wrapping_add(off));
                let _ = write!(&mut out, " +0x{:02x}={:#010x}", off, v);
                off = off.wrapping_add(4);
            }
            println!("{}", out);
        }
        // iterate-2E — pointer-chase: dump base object (gpr[reg]), the
        // sub-object it holds at [base+off], and that sub-object's vtable
        // slots. Captures the live work-item + stream + vtable[28] at
        // sub_824510E0 before the pool recycles the slot. Read-only.
        if let Some((reg, deref_off)) = self.audit_deref {
            use std::fmt::Write as _;
            let base = ctx.gpr[reg as usize] as u32;
            let dumpn = |label: &str, p: u32, len: u32| {
                let mut s = String::with_capacity(256 + (len as usize) * 4);
                let _ = write!(&mut s, "AUDIT-DEREF {} ptr={:#010x}", label, p);
                let mut o: u32 = 0;
                while o < len {
                    let _ = write!(&mut s, " +0x{:02x}={:#010x}", o, mem.read_u32(p.wrapping_add(o)));
                    o += 4;
                }
                println!("{}", s);
            };
            println!("AUDIT-DEREF-HEAD pc={:#010x} tid={} cycle={} reg=r{} off=0x{:x}", pc, tid, cycle, reg, deref_off);
            dumpn("item", base, 64);
            let sub = mem.read_u32(base.wrapping_add(deref_off));
            // STEP 42 — dump the demux-state object deep (to +0xfc): cursors
            // [+0x10/0x18/0x20], state [+0x50], source handle [+0x94], count
            // [+0x98], gate flags [+0xb8/bc/cc/d4]. Then follow the buffer
            // descriptor [sub+4] (bounds at +0x10/+0x14) which defines the
            // window of file bytes available to the demux at the cursor.
            dumpn("sub", sub, 0x100);
            let bufdesc = mem.read_u32(sub.wrapping_add(4));
            dumpn("bufdesc", bufdesc, 0x40);
            // STEP 43 — follow the byte-source sub-objects that actually hold
            // the windowed file bytes + read position: [sub+0] (byte-source /
            // embedded vtable obj 0x40553640), [sub+0x30] (0x40688560),
            // [sub+0x94] (source handle 0x41073e40). One holds the data ptr +
            // cursor; an empty/short buffer here is the no-data root.
            let src0 = mem.read_u32(sub);
            dumpn("src0", src0, 0x80);
            dumpn("src30", mem.read_u32(sub.wrapping_add(0x30)), 0x80);
            dumpn("src94", mem.read_u32(sub.wrapping_add(0x94)), 0x80);
            // STEP 44 — the byte-source's buffer/cursor holder [src0+0x2c]
            // (=0x40930f60): vtable[4]=0x82524080 reads its file-offset cursor
            // [+0x28], cached-range [+0x80]/[+0x88], flags [+0x40], and maps
            // offsets to memory blocks via 0x82522118 (the windowed/chunk
            // buffer). An empty cached range / null block ptr here = the data
            // byte-source never fed → 0x80500000. Follow its block ptr [+0x18].
            let cur = mem.read_u32(src0.wrapping_add(0x2c));
            dumpn("src0_2c", cur, 0x100);
            // STEP 45 — walk the cached-block linked list exactly as the
            // mapper 0x82522118 does: container=[cur+0x10]; node=[container+8];
            // each node: block=[node+0] (block[+8]=start off64, block[+4]=size),
            // next=[node+8]. Print each block's 64-bit start + size so we can
            // see whether ANY cached block covers file offset 5868 (the first
            // ASF data packet). Empty list / no covering block = the root.
            let container = mem.read_u32(cur.wrapping_add(0x10));
            dumpn("blkcont", container, 0x20);
            let mut node = mem.read_u32(container.wrapping_add(8));
            let mut k = 0;
            while node != 0 && k < 8 {
                let block = mem.read_u32(node);
                let start_hi = mem.read_u32(block.wrapping_add(8));
                let start_lo = mem.read_u32(block.wrapping_add(0xc));
                let size = mem.read_u32(block.wrapping_add(4));
                let dptr = mem.read_u32(block);
                println!(
                    "AUDIT-BLK k={} node={:#010x} block={:#010x} start={:#010x}_{:#010x} size={:#010x} dataptr={:#010x}",
                    k, node, block, start_hi, start_lo, size, dptr,
                );
                node = mem.read_u32(node.wrapping_add(8));
                k += 1;
            }
            println!("AUDIT-BLK-END count={}", k);
            let vt = mem.read_u32(sub); // [sub+0] = vtable
            // Dump 48 vtable slots so slot 28 (+0x70) and slot 36 (+0x90) show.
            let mut s = String::with_capacity(512);
            let _ = write!(&mut s, "AUDIT-DEREF vtable={:#010x}", vt);
            let mut slot: u32 = 0;
            while slot < 48 {
                let _ = write!(&mut s, " [{}]={:#010x}", slot, mem.read_u32(vt.wrapping_add(slot * 4)));
                slot += 1;
            }
            println!("{}", s);
        }
    }

    /// M12 — diagnostic. If the live PC for HW slot `hw_id` is in
    /// `self.lr_trace_pcs`, emit one JSONL record. Format mirrors what
    /// xenia-canary's `--log_lr_on_pc` patch emits, plus the cycle
    /// counter. Read-only; lockstep digest unaffected.
    pub fn fire_lr_trace_if_match(&self, hw_id: u8) {
        if self.lr_trace_pcs.is_empty() {
            return;
        }
        let ctx = self.scheduler.ctx(hw_id);
        let pc = ctx.pc;
        if !self.lr_trace_pcs.contains(&pc) {
            return;
        }
        let tid = self.scheduler.tid(hw_id).unwrap_or(0);
        let r3 = ctx.gpr[3] as u32;
        let r4 = ctx.gpr[4] as u32;
        let r5 = ctx.gpr[5] as u32;
        let r6 = ctx.gpr[6] as u32;
        let lr = ctx.lr as u32;
        let cycle = ctx.cycle_count;
        let line = format!(
            "{{\"pc\":\"{:#010x}\",\"tid\":{},\"hw\":{},\"cycle\":{},\
             \"r3\":\"{:#010x}\",\"r4\":\"{:#010x}\",\"r5\":\"{:#010x}\",\
             \"r6\":\"{:#010x}\",\"lr\":\"{:#010x}\"}}\n",
            pc, tid, hw_id, cycle, r3, r4, r5, r6, lr,
        );
        match &self.lr_trace_writer {
            Some(mu) => {
                if let Ok(mut f) = mu.lock() {
                    use std::io::Write;
                    let _ = f.write_all(line.as_bytes());
                }
            }
            None => {
                // Stdout path; small alloc, fine for diagnostic use.
                print!("{line}");
            }
        }
    }

    /// Read a TLS slot for the currently running HW thread.
    pub fn tls_get(&self, index: u32) -> u64 {
        self.scheduler.tls_get(index)
    }

    /// Write a TLS slot for the currently running HW thread.
    pub fn tls_set(&mut self, index: u32, value: u64) {
        self.scheduler.tls_set(index, value);
    }

    /// Allocate a new global TLS slot index. Grows every HW thread's
    /// `tls_values` array to match.
    pub fn tls_alloc(&mut self) -> u32 {
        use std::sync::atomic::Ordering;
        // M2.4: atomic bump. The Scheduler::tls_grow_to call still needs
        // a coherent post-bump value, so we read the new size from the
        // fetch_add return.
        let idx = self.next_tls_index.fetch_add(1, Ordering::Relaxed);
        let new_size = idx + 1;
        self.scheduler.tls_grow_to(new_size as usize);
        idx
    }

    /// Allocate guest memory from the heap bump allocator.
    /// Returns the base address of the allocated region.
    pub fn heap_alloc(&mut self, size: u32, mem: &GuestMemory) -> Option<u32> {
        use std::sync::atomic::Ordering;
        let aligned_size = (size + 0xFFF) & !0xFFF; // Page-align
        // M2.4: atomic bump, then verify post-bump invariants. If the
        // bump pushed us past the heap-region ceiling, the cursor stays
        // advanced — subsequent allocations also fail, matching the
        // pre-M2 sequential semantics. We don't try to "undo" the bump
        // because that opens a CAS-loop race for marginal benefit (a
        // failing alloc near the limit is already game-over).
        let base = self.heap_cursor.fetch_add(aligned_size, Ordering::Relaxed);
        let new_top = base.checked_add(aligned_size)?;
        if new_top > 0x6FFF_FFFF {
            return None;
        }
        let protect = xenia_memory::page_table::MemoryProtect::READ
            | xenia_memory::page_table::MemoryProtect::WRITE;
        mem.alloc(base, aligned_size, protect).ok()?;
        Some(base)
    }

    /// Allocate a kernel stack.
    pub fn stack_alloc(&mut self, size: u32, mem: &GuestMemory) -> Option<u32> {
        use std::sync::atomic::Ordering;
        let aligned_size = (size + 0xFFF) & !0xFFF;
        let base = self.stack_cursor.fetch_add(aligned_size, Ordering::Relaxed);
        let protect = xenia_memory::page_table::MemoryProtect::READ
            | xenia_memory::page_table::MemoryProtect::WRITE;
        mem.alloc(base, aligned_size, protect).ok()?;
        Some(base + aligned_size) // Return top of stack
    }

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

    /// Idempotent arm — removes any prior entry for `handle`, then inserts
    /// the new `(deadline, handle)` pair and re-sorts by deadline ascending.
    /// The per-`Timer` object's `deadline` field must be set separately by
    /// the caller (see `NtSetTimerEx` in exports.rs) — this helper only
    /// manages the central pending-fires list so `fire_due_timers` has a
    /// sorted head to peek.
    pub fn arm_timer(&mut self, handle: u32, deadline: u64) {
        self.pending_timer_fires.retain(|&(_, h)| h != handle);
        self.pending_timer_fires.push((deadline, handle));
        self.pending_timer_fires.sort_by_key(|&(d, _)| d);
    }

    /// Idempotent disarm — strip any entry for `handle`. Safe to call
    /// regardless of prior state; `NtClose`, `NtCancelTimer`, and the
    /// periodic-rearm guard all invoke this.
    pub fn disarm_timer(&mut self, handle: u32) {
        self.pending_timer_fires.retain(|&(_, h)| h != handle);
    }

    /// Peek the earliest pending timer deadline. Paired with
    /// `Scheduler::earliest_wait_deadline` by the main loop's "advance to
    /// next event" coordination — the earlier of the two drives
    /// `advance_all_timebases_to`.
    pub fn earliest_timer_deadline(&self) -> Option<u64> {
        self.pending_timer_fires.first().map(|&(d, _)| d)
    }

    /// Coherent "now" basis for deadline arithmetic — the scheduler's
    /// single monotonic `global_clock`, in BOTH execution modes.
    ///
    /// Per-thread `ctx(hw_id).timebase` is NOT a sound "now" for deadline
    /// arithmetic: in `--parallel` workers extract/zero their slots while
    /// stepping unlocked, and in **lockstep** a parked/poll thread has
    /// `running_idx == None` so `ctx()` returns `idle_ctx` (timebase 0).
    /// Either way a `parse_timeout` reading the per-thread basis can see 0
    /// (or a stale value) and register `deadline = 0 + relative`, a value
    /// permanently in the past, which `coord_idle_advance` then re-arms
    /// forever (the timebase-desync livelock; the render-gate root). The
    /// `global_clock` is a deterministic function of retired guest
    /// instructions (per-round `stats.instruction_count` floor-ups in
    /// lockstep, per-block retired counts in parallel), so it is coherent,
    /// monotonic, never zero after boot, and bit-reproducible across two
    /// cold lockstep runs.
    ///
    /// The `hw_id` argument is retained for call-site clarity (which slot a
    /// caller would conceptually be "asking about") but is no longer read —
    /// the basis is global.
    pub fn now_basis_at(&self, _hw_id: u8) -> u64 {
        self.scheduler.global_clock()
    }

    /// Fire every timer whose deadline is `<= now` (derived from slot 0's
    /// timebase, matching `parse_timeout`'s "current thread" fallback).
    /// For each fire: mark the timer `signaled=true`, clear its
    /// `deadline`, rearm if periodic, then wake eligible waiters via
    /// `exports::wake_eligible_waiters`. Returns `true` iff any timer
    /// fired — the caller uses this to decide whether the scheduler round
    /// needs a follow-up `advance_to_next_wake_if_due` step.
    pub fn fire_due_timers(&mut self) -> bool {
        let now = self.now_basis_at(0);
        let mut fired = false;
        loop {
            let Some(&(deadline, handle)) = self.pending_timer_fires.first() else {
                break;
            };
            if deadline > now {
                break;
            }
            self.pending_timer_fires.remove(0);
            // Mark signaled + capture period before any rearm so we don't
            // double-borrow the object while calling wake_eligible_waiters.
            let periodic_next =
                if let Some(KernelObject::Timer {
                    signaled,
                    deadline: obj_deadline,
                    period_ticks,
                    ..
                }) = self.objects.get_mut(&handle)
                {
                    *signaled = true;
                    *obj_deadline = None;
                    if *period_ticks > 0 {
                        Some(now + *period_ticks)
                    } else {
                        None
                    }
                } else {
                    // Closed handle — its entry lingered because disarm on
                    // NtClose was missed, OR fire_due_timers picked up a
                    // race. Skip silently; nothing to wake.
                    None
                };
            if let Some(next) = periodic_next {
                if let Some(KernelObject::Timer { deadline, .. }) =
                    self.objects.get_mut(&handle)
                {
                    *deadline = Some(next);
                }
                self.arm_timer(handle, next);
            }
            crate::exports::wake_eligible_waiters(self, handle);
            fired = true;
        }
        fired
    }

    /// Handle deadline-expiry cleanup for a thread whose wait timed out.
    /// Called by the main loop right after `Scheduler::advance_to_next_wake`
    /// returns a `Some((ref, reason))`. Stamps `STATUS_TIMEOUT` into the
    /// woken thread's `gpr[3]` and scrubs its `ThreadRef` out of any
    /// handle's waiter list so a later signal can't consume the
    /// auto-reset slot into a stale waiter.
    ///
    /// `BlockReason::DelayUntil` is a pure sleep and expects
    /// `STATUS_SUCCESS` — the default pre-populated value in
    /// `ke_delay_execution_thread` — so we leave `gpr[3]` alone for it.
    pub fn handle_timeout_wake(
        &mut self,
        r: ThreadRef,
        reason: xenia_cpu::scheduler::BlockReason,
    ) {
        use xenia_cpu::scheduler::BlockReason;
        const STATUS_TIMEOUT: u64 = 0x0000_0102;
        match reason {
            BlockReason::WaitAny { handles, .. } | BlockReason::WaitAll { handles, .. } => {
                self.scheduler.ctx_mut_ref(r).gpr[3] = STATUS_TIMEOUT;
                for h in handles {
                    if let Some(obj) = self.objects.get_mut(&h) {
                        if let Some(waiters) = obj.waiters_mut() {
                            waiters.retain(|&w| w != r);
                        }
                    }
                }
            }
            BlockReason::DelayUntil(_) => {
                // Pure sleep → default STATUS_SUCCESS is correct; no handles
                // to scrub.
            }
            BlockReason::CriticalSection(cs_ptr) => {
                self.scheduler.ctx_mut_ref(r).gpr[3] = STATUS_TIMEOUT;
                if let Some(list) = self.cs_waiters.get_mut(&cs_ptr) {
                    list.retain(|&w| w != r);
                }
            }
            BlockReason::Suspended => {}
        }
    }
}

impl Default for KernelState {
    fn default() -> Self {
        Self::new()
    }
}

/// AUDIT-054 — pick the cache root path + wipe-on-init mode for a
/// fresh `KernelState`.
///
/// Default behaviour matches AUDIT-038: per-process tmpdir + full
/// wipe so two consecutive runs see byte-identical initial state
/// (lockstep / oracle determinism). AUDIT-054 found that Sylpheed's
/// `cache:\<hash>.tmp` journal-style writes append on each boot, so
/// a naive persistent root makes the on-disk state self-inconsistent
/// after the second boot (`runtime_error` throws from version-check
/// on reload). Opt-in to persistence via env:
///   * `XENIA_CACHE_ROOT=<path>` — explicit persistent path. Caller
///     is responsible for wiping when needed.
///   * `XENIA_CACHE_PERSIST=1` — use `$XDG_DATA_HOME/xenia-rs/cache`
///     (or `$HOME/.local/share/xenia-rs/cache`) without wiping.
///
/// Returns `(root, wipe)` where `wipe = true` triggers the
/// `init_cache_root` clear-then-recreate dance.
fn resolve_default_cache_root() -> (std::path::PathBuf, bool) {
    if let Ok(p) = std::env::var("XENIA_CACHE_ROOT") {
        if !p.is_empty() {
            return (std::path::PathBuf::from(p), false);
        }
    }
    let persist = std::env::var("XENIA_CACHE_PERSIST")
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false);
    if persist {
        if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
            if !xdg.is_empty() {
                return (
                    std::path::PathBuf::from(xdg).join("xenia-rs/cache"),
                    false,
                );
            }
        }
        if let Ok(home) = std::env::var("HOME") {
            if !home.is_empty() {
                return (
                    std::path::PathBuf::from(home).join(".local/share/xenia-rs/cache"),
                    false,
                );
            }
        }
    }
    static NEXT_CACHE_ID: std::sync::atomic::AtomicU64 =
        std::sync::atomic::AtomicU64::new(0);
    let id = NEXT_CACHE_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    (
        std::env::temp_dir().join(format!(
            "xenia-rs-cache-{}-{}",
            std::process::id(),
            id
        )),
        true,
    )
}

/// KRNBUG-AUDIT-003. Outcome of probing a guest pointer as the `this`
/// of a C++ object: read `[this]` as the vtable, then attempt MSVC
/// RTTI to recover the decorated class name. Pure read; lockstep-safe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClassReadout {
    /// MSVC RTTI was intact. `mangled` is the decorated name as stored
    /// in the TypeDescriptor (`.?AVEvent@silph@@` form).
    Named { vtable: u32, mangled: String },
    /// `[this]` looked like a vtable pointer but RTTI was stripped (or
    /// the COL/TypeDescriptor chain didn't yield a printable name).
    /// `virtuals` are the first 4 vtable slots — resolve via the
    /// analysis DB's `functions` table for offline class identification.
    VtableOnly { vtable: u32, virtuals: [u32; 4] },
    /// Either `this` itself isn't a plausible heap pointer or `[this]`
    /// doesn't land in the image's read-only-data range. Caller skips.
    NotAnObject,
}

/// Probe a candidate `this` pointer as a C++ object on the guest heap.
/// Read-only; safe to call from the diagnostic dump path. Behaviour:
///   1. Reject non-heap candidate pointers (anything outside the user/
///      image range).
///   2. Read `[this]` as vtable; reject if it's not in the image range
///      where MSVC stores read-only `vftable` symbols.
///   3. MSVC RTTI traversal:
///        vtable[-4 bytes] = RTTICompleteObjectLocator*
///        COL+0x0c        = TypeDescriptor*
///        TypeDescriptor+0x08 = mangled name (NUL-terminated ASCII)
///      If every link looks plausible AND the name starts with `.?A`
///      (the MSVC class-name prefix), return `Named`.
///   4. Otherwise return `VtableOnly` with the first 4 virtual slots
///      so the caller can resolve method names via the analysis DB.
pub fn read_class_at_this(this: u32, mem: &GuestMemory) -> ClassReadout {
    if !is_likely_guest_heap_ptr(this) {
        return ClassReadout::NotAnObject;
    }
    let vtable = mem.read_u32(this);
    if !is_likely_image_ptr(vtable) {
        return ClassReadout::NotAnObject;
    }
    let col = mem.read_u32(vtable.wrapping_sub(4));
    if is_likely_image_ptr(col) {
        let type_desc = mem.read_u32(col.wrapping_add(12));
        if is_likely_image_ptr(type_desc) {
            let name = read_ascii_cstring(mem, type_desc.wrapping_add(8), 128);
            if name.starts_with(".?A") {
                return ClassReadout::Named {
                    vtable,
                    mangled: name,
                };
            }
        }
    }
    let virtuals = [
        mem.read_u32(vtable),
        mem.read_u32(vtable.wrapping_add(4)),
        mem.read_u32(vtable.wrapping_add(8)),
        mem.read_u32(vtable.wrapping_add(12)),
    ];
    // False-positive guard: when [this] points at the entry of a
    // function (e.g. the CRT static-init iterator with r31 holding a
    // pointer into the init-fn array), `vtable` is the function PC and
    // the "first virtuals" are the function's prologue *instructions*
    // — words like 0x7D8802A6 (`mflr r12`) which are NOT in the image
    // pointer range. A real C++ vtable's first slot is always a member
    // function pointer in the image range. Require the first slot AND
    // the second slot to look like image-range function pointers,
    // else return `NotAnObject`.
    if !is_likely_image_ptr(virtuals[0]) || !is_likely_image_ptr(virtuals[1]) {
        return ClassReadout::NotAnObject;
    }
    ClassReadout::VtableOnly { vtable, virtuals }
}

/// KRNBUG-AUDIT-003. At handle creation time, walk the captured frames
/// and probe each frame's most-likely `this` candidates for an MSVC C++
/// class name. Returns one pre-formatted line per hit (Named or
/// VtableOnly); silent on `NotAnObject` so the noise floor stays low.
///
/// Candidates per frame:
///   * Frame 0 (live): ctx.gpr[31] (canonical C++ `this`), ctx.gpr[30]
///     (often a secondary captured `this` in nested method calls), and
///     ctx.gpr[3] (the live first arg — at the moment NtCreateEvent is
///     entered, this is `&Event` being constructed).
///   * Frame K ≥ 1: read `[fp - 12]` and `[fp - 16]` — the standard
///     PPC EABI `__savegprlr_NN` spill area where the callee's prologue
///     placed the caller's r31 / r30 just before its `stwu`. So those
///     slots hold the value of the function-at-frame-K's r31 / r30
///     captured at the moment IT made the bl into the next frame down.
///
/// Read-only; never mutates guest state.
pub fn probe_create_stack_classes(
    ctx: &PpcContext,
    frames: &[(u32, u32)],
    mem: &GuestMemory,
) -> Vec<String> {
    let mut out = Vec::new();
    for (idx, (fp, lr)) in frames.iter().enumerate() {
        let (raw_r31, raw_r30, raw_r3) = if idx == 0 {
            (ctx.gpr[31] as u32, ctx.gpr[30] as u32, ctx.gpr[3] as u32)
        } else {
            (
                mem.read_u32(fp.wrapping_sub(12)),
                mem.read_u32(fp.wrapping_sub(16)),
                0,
            )
        };
        // Emit one always-on raw line per frame so the back-chain plus
        // saved-register dump is captured even when the RTTI probe is
        // silent. Investigators can resolve the raw values offline via
        // the analysis DB (lookup of vtable / static-init iterator
        // pointers / etc. is otherwise impossible from logs alone).
        if idx == 0 {
            out.push(format!(
                "frame={} lr={:#010x} live r31={:#010x} r30={:#010x} r3={:#010x}",
                idx, lr, raw_r31, raw_r30, raw_r3,
            ));
        } else {
            out.push(format!(
                "frame={} lr={:#010x} saved-r31={:#010x} saved-r30={:#010x}",
                idx, lr, raw_r31, raw_r30,
            ));
        }
        let candidates: [(u32, &'static str); 3] = if idx == 0 {
            [(raw_r31, "r31"), (raw_r30, "r30"), (raw_r3, "r3")]
        } else {
            [
                (raw_r31, "saved-r31"),
                (raw_r30, "saved-r30"),
                (0, ""),
            ]
        };
        for (this_ptr, label) in candidates {
            if label.is_empty() {
                continue;
            }
            match read_class_at_this(this_ptr, mem) {
                ClassReadout::Named { vtable, mangled } => {
                    out.push(format!(
                        "  → frame={} {}={:#010x} vtable={:#010x} class={}",
                        idx, label, this_ptr, vtable, mangled,
                    ));
                }
                ClassReadout::VtableOnly { vtable, virtuals } => {
                    out.push(format!(
                        "  → frame={} {}={:#010x} vtable={:#010x} virtuals=[{:#010x},{:#010x},{:#010x},{:#010x}] (RTTI stripped)",
                        idx, label, this_ptr, vtable,
                        virtuals[0], virtuals[1], virtuals[2], virtuals[3],
                    ));
                }
                ClassReadout::NotAnObject => {}
            }
        }
    }
    out
}

/// Heap-pointer plausibility: Xbox 360 user heap is 0x40000000–0x50000000;
/// the image and read-only-data are 0x82000000–0x83000000. Allow both —
/// dispatcher objects in Sylpheed live in static-init pools (image rdata)
/// AND in heap-allocated singletons.
fn is_likely_guest_heap_ptr(p: u32) -> bool {
    matches!(p, 0x4000_0000..=0x4FFF_FFFF | 0x8200_0000..=0x82FF_FFFF)
}

/// Image-pointer plausibility: vtables and RTTI structures live in the
/// module's read-only image, which on Xbox 360 maps at 0x82000000.
fn is_likely_image_ptr(p: u32) -> bool {
    matches!(p, 0x8200_0000..=0x82FF_FFFF)
}

/// Read a NUL-terminated ASCII string from guest memory, capped at
/// `max` bytes. Returns the empty string on any non-printable byte
/// (a cheap signal that `addr` doesn't actually point at a name).
fn read_ascii_cstring(mem: &GuestMemory, addr: u32, max: usize) -> String {
    let mut s = String::with_capacity(max);
    for i in 0..max {
        let b = mem.read_u8(addr.wrapping_add(i as u32));
        if b == 0 {
            return s;
        }
        if !(0x20..=0x7E).contains(&b) {
            return String::new();
        }
        s.push(b as char);
    }
    s
}

/// Walk the PPC EABI back-chain starting from `sp` (the value in r1 at
/// the moment of capture). Returns up to `max_frames` entries of
/// `(frame_pointer, saved_lr)`. Index 0 is the live frame
/// `(sp, live_lr)` — `live_lr` is the caller-supplied current LR, since
/// it has not yet been spilled to memory by this frame's prologue.
///
/// PPC convention reminder: a function's prologue stores the caller's
/// LR at `[old_sp - 8]` *before* bumping `r1` down to the new frame. So
/// from the live `sp`, `prev_sp = mem[sp]` and the LR saved in the
/// frame above is at `mem[prev_sp - 8]`. The walk stops on a
/// 0/0xFFFFFFFF/self-loop sentinel — those guard against
/// uninitialized stacks and the topmost frame.
///
/// This is read-only; it never mutates guest memory or CPU state.
pub fn walk_guest_back_chain(
    sp: u32,
    live_lr: u32,
    mem: &GuestMemory,
    max_frames: usize,
) -> Vec<(u32, u32)> {
    let mut frames = Vec::with_capacity(max_frames);
    if max_frames == 0 {
        return frames;
    }
    frames.push((sp, live_lr));
    let mut cur = sp;
    while frames.len() < max_frames {
        if cur == 0 || cur == 0xFFFF_FFFF {
            break;
        }
        let prev = mem.read_u32(cur);
        if prev == 0 || prev == 0xFFFF_FFFF || prev == cur {
            break;
        }
        let saved_lr = mem.read_u32(prev.wrapping_sub(8));
        frames.push((prev, saved_lr));
        cur = prev;
    }
    frames
}

#[cfg(test)]
mod tests {
    use super::*;
    use xenia_memory::GuestMemory;

    /// Ten consecutive `heap_alloc(0x14)` calls must return distinct
    /// page-aligned addresses. A previous bug had kernel exports passing 0 as
    /// `size`, causing the bump allocator to return the same address every
    /// time — 10 "allocations" that all aliased 0x40105000 and silently
    /// corrupted the guest's static-constructor state.
    #[test]
    fn heap_alloc_advances_for_nonzero_size() {
        let mut mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let mut seen = Vec::new();
        for _ in 0..10 {
            let addr = state
                .heap_alloc(0x14, &mut mem)
                .expect("heap must have room for 0x14 bytes");
            assert_eq!(addr & 0xFFF, 0, "heap returns page-aligned addresses");
            assert!(!seen.contains(&addr), "heap returned duplicate address {addr:#x}");
            seen.push(addr);
        }
    }

    /// `heap_alloc(0)` must not advance the cursor (it has nothing to do).
    /// The kernel exports that previously hit this path did so because they
    /// read the wrong argument register; guarded at the export boundary now.
    #[test]
    fn heap_alloc_zero_is_noop_in_cursor() {
        use std::sync::atomic::Ordering;
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let before = state.heap_cursor.load(Ordering::Relaxed);
        let _ = state.heap_alloc(0, &mem);
        let after = state.heap_cursor.load(Ordering::Relaxed);
        assert_eq!(before, after, "zero-size alloc must not advance heap cursor");
    }

    /// M2.4: concurrent handle allocations must produce distinct values.
    /// Ten threads each allocate 100 handles via `alloc_handle`; the union
    /// must contain exactly 1000 distinct values, and the maximum equals
    /// `0x1000 + 4 * (1000 - 1)` (ascending step is 4 per the kernel
    /// allocator's policy).
    #[test]
    fn concurrent_alloc_handle_distinct() {
        use std::collections::HashSet;
        use std::sync::Mutex;
        use std::sync::atomic::{AtomicU32, Ordering};

        // Use a free-standing AtomicU32 mirroring `next_handle`'s semantics;
        // we can't easily share `&mut KernelState` across threads. The
        // production code uses the same `fetch_add(4, Relaxed)` recipe.
        let counter = std::sync::Arc::new(AtomicU32::new(0x1000));
        let collected: std::sync::Arc<Mutex<HashSet<u32>>> =
            std::sync::Arc::new(Mutex::new(HashSet::new()));

        let mut handles = Vec::new();
        for _ in 0..10 {
            let c = counter.clone();
            let s = collected.clone();
            handles.push(std::thread::spawn(move || {
                let mut local = Vec::with_capacity(100);
                for _ in 0..100 {
                    local.push(c.fetch_add(4, Ordering::Relaxed));
                }
                let mut g = s.lock().unwrap();
                for v in local {
                    g.insert(v);
                }
            }));
        }
        for h in handles {
            h.join().unwrap();
        }
        let set = collected.lock().unwrap();
        assert_eq!(
            set.len(),
            1000,
            "expected 1000 distinct handles, got {}",
            set.len()
        );
        assert!(set.iter().all(|h| (h - 0x1000) % 4 == 0));
    }

    /// KRNBUG-AUDIT-002: synthesize a 3-level back-chain in mapped guest
    /// memory and walk it. Verifies that frame 0 is the live-LR frame and
    /// that subsequent frames pull `prev_sp` from `[sp]` and the saved LR
    /// from `[prev_sp - 8]`.
    #[test]
    fn back_chain_walker_resolves_synthetic_frames() {
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let base = state.heap_alloc(0x4000, &mem).expect("scratch");
        // Lay out three frames inside the scratch page. Each frame gets
        // its own 0x100-byte slot. Frame N's `[sp + 0]` points at frame
        // N+1's sp, and frame N+1's `[sp - 8]` holds the LR saved by
        // that frame for the call into frame N.
        let sp0 = base + 0x100;
        let sp1 = base + 0x300;
        let sp2 = base + 0x500;
        // Back-chain pointers
        mem.write_u32(sp0, sp1);
        mem.write_u32(sp1, sp2);
        mem.write_u32(sp2, 0); // top of stack
        // Saved LRs (the LR of the call that reached the *next* frame
        // up are stored at the next frame's sp - 8)
        mem.write_u32(sp1.wrapping_sub(8), 0xAAAA_BBBB);
        mem.write_u32(sp2.wrapping_sub(8), 0xCCCC_DDDD);

        let frames = walk_guest_back_chain(sp0, 0x1111_2222, &mem, 6);
        assert_eq!(frames.len(), 3);
        assert_eq!(frames[0], (sp0, 0x1111_2222));
        assert_eq!(frames[1], (sp1, 0xAAAA_BBBB));
        assert_eq!(frames[2], (sp2, 0xCCCC_DDDD));
    }

    /// Walker must not loop on a self-referential back-chain (a corrupted
    /// frame where `[sp] == sp`).
    #[test]
    fn back_chain_walker_stops_on_self_loop() {
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let base = state.heap_alloc(0x1000, &mem).expect("scratch");
        let sp = base + 0x100;
        mem.write_u32(sp, sp); // self-loop
        let frames = walk_guest_back_chain(sp, 0x4242_4242, &mem, 6);
        assert_eq!(frames.len(), 1);
        assert_eq!(frames[0], (sp, 0x4242_4242));
    }

    /// Walker must terminate on the standard top-of-stack sentinel
    /// (`[sp] == 0`) without spilling a bogus frame.
    #[test]
    fn back_chain_walker_stops_on_zero_sentinel() {
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let base = state.heap_alloc(0x1000, &mem).expect("scratch");
        let sp = base + 0x100;
        mem.write_u32(sp, 0);
        let frames = walk_guest_back_chain(sp, 0x8242_0000, &mem, 6);
        assert_eq!(frames.len(), 1);
        assert_eq!(frames[0], (sp, 0x8242_0000));
    }

    /// KRNBUG-AUDIT-003: synthesize a C++ object with intact MSVC RTTI
    /// in mapped guest memory. The probe must traverse vtable[-4] →
    /// COL → TypeDescriptor and recover the decorated mangled name.
    #[test]
    fn read_class_at_this_resolves_intact_rtti() {
        use xenia_memory::page_table::MemoryProtect;
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let this = state.heap_alloc(0x40, &mem).expect("heap object");
        // Map an image-range page so vtable / COL / TypeDescriptor
        // pointers pass `is_likely_image_ptr`.
        let img = 0x8280_0000u32;
        mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("image-range page");
        let vtable = img + 0x40;
        let col = img + 0x80;
        let type_desc = img + 0xC0;
        // [this] = vtable
        mem.write_u32(this, vtable);
        // vtable[-4] = COL  (one word before the first virtual)
        mem.write_u32(vtable.wrapping_sub(4), col);
        // COL+0xC = TypeDescriptor
        mem.write_u32(col + 12, type_desc);
        // TypeDescriptor+8 = NUL-terminated mangled name
        let name = b".?AVAsyncQueue@silph@@\0";
        for (i, b) in name.iter().enumerate() {
            mem.write_u8(type_desc + 8 + i as u32, *b);
        }
        let r = read_class_at_this(this, &mem);
        match r {
            ClassReadout::Named { vtable: v, mangled } => {
                assert_eq!(v, vtable);
                assert_eq!(mangled, ".?AVAsyncQueue@silph@@");
            }
            other => panic!("expected Named, got {:?}", other),
        }
    }

    /// RTTI-stripped fallback: vtable looks plausible but vtable[-4] is
    /// zero. The probe must return `VtableOnly` with the first 4 virtual
    /// PCs so the caller can resolve method names via the analysis DB.
    #[test]
    fn read_class_at_this_falls_back_when_rtti_stripped() {
        use xenia_memory::page_table::MemoryProtect;
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        let this = state.heap_alloc(0x40, &mem).expect("heap object");
        let img = 0x8281_0000u32;
        mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("image-range page");
        let vtable = img + 0x100;
        mem.write_u32(this, vtable);
        // No COL — vtable[-4] left as zero, which fails `is_likely_image_ptr`.
        // Populate first four virtuals with image-range PCs.
        let virts = [0x8200_AAAA, 0x8201_BBBB, 0x8202_CCCC, 0x8203_DDDD];
        for (i, v) in virts.iter().enumerate() {
            mem.write_u32(vtable + (i as u32) * 4, *v);
        }
        match read_class_at_this(this, &mem) {
            ClassReadout::VtableOnly {
                vtable: v,
                virtuals,
            } => {
                assert_eq!(v, vtable);
                assert_eq!(virtuals, virts);
            }
            other => panic!("expected VtableOnly, got {:?}", other),
        }
    }

    /// `this` outside the heap/image range, or `[this]` not in the image
    /// range, must yield `NotAnObject` so the dump skips the candidate
    /// without printing noise.
    #[test]
    fn read_class_at_this_rejects_non_objects() {
        use xenia_memory::page_table::MemoryProtect;
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        // Out-of-range this.
        assert_eq!(
            read_class_at_this(0x0000_1234, &mem),
            ClassReadout::NotAnObject
        );
        assert_eq!(
            read_class_at_this(0xFFFF_FFFF, &mem),
            ClassReadout::NotAnObject
        );
        // In-range `this`, but [this] is zero (unmapped → reads as 0,
        // which is not a plausible image pointer).
        let this = state.heap_alloc(0x40, &mem).expect("heap object");
        assert_eq!(read_class_at_this(this, &mem), ClassReadout::NotAnObject);
        // In-range this, [this] points into the heap range — also rejected
        // because vtables live in the image rdata.
        mem.alloc(0x4500_0000, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("aux heap page");
        mem.write_u32(this, 0x4500_0080);
        assert_eq!(read_class_at_this(this, &mem), ClassReadout::NotAnObject);
    }

    /// `probe_create_stack_classes` is the integration of the back-chain
    /// walker output and the per-frame RTTI probe used at handle creation
    /// time. Build a minimal 2-frame scenario where frame 1's
    /// `[fp - 12]` saved-r31 slot points at a heap C++ object with intact
    /// MSVC RTTI, and verify the helper produces a `class=...` line.
    #[test]
    fn probe_create_stack_classes_recovers_saved_r31_class() {
        use xenia_memory::page_table::MemoryProtect;
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        // Heap-allocate a fake `this` and lay out vtable / COL / TD in
        // an image-range page.
        let this = state.heap_alloc(0x40, &mem).expect("heap object");
        let img = 0x8282_0000u32;
        mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("image-range page");
        let vtable = img + 0x40;
        let col = img + 0x80;
        let td = img + 0xC0;
        mem.write_u32(this, vtable);
        mem.write_u32(vtable.wrapping_sub(4), col);
        mem.write_u32(col + 12, td);
        for (i, b) in b".?AVDispatcher@silph@@\0".iter().enumerate() {
            mem.write_u8(td + 8 + i as u32, *b);
        }
        // Synthesize a 2-frame back-chain. Place the saved-r31 slot at
        // [frames[1].fp - 12] = `this`.
        let stack_base = state.heap_alloc(0x4000, &mem).expect("stack page");
        let sp0 = stack_base + 0x100;
        let sp1 = stack_base + 0x300;
        mem.write_u32(sp1.wrapping_sub(12), this);
        let frames = vec![(sp0, 0x824a_9f6c), (sp1, 0x8217_8500)];
        // Live ctx — r3 holds &Event (some random value, not a real
        // class), r31/r30 zero so frame 0 produces no hits.
        let mut ctx = PpcContext::new();
        ctx.gpr[3] = 0x4000_BEEF;
        let probes = probe_create_stack_classes(&ctx, &frames, &mem);
        assert!(probes.iter().any(|s| s.contains(".?AVDispatcher@silph@@")),
            "expected probes to contain the dispatcher class, got {:?}", probes);
        assert!(probes.iter().any(|s| s.contains("frame=1")),
            "expected at least one frame=1 line, got {:?}", probes);
    }

    /// A NUL-terminated ASCII string is read up to `max`; non-printable
    /// bytes mark the candidate as bogus (return empty string). The
    /// `.?A` prefix gating in `read_class_at_this` then rejects them.
    /// `fire_ctor_probe_if_match` only emits when `pc` matches a
    /// configured PC. We assert it's a no-op on miss and a no-panic
    /// on hit (the println goes to stdout; we just check the helper
    /// reads the back-chain without faulting).
    #[test]
    fn fire_ctor_probe_if_match_no_op_on_empty_set() {
        let mem = GuestMemory::new().expect("memory init");
        let state = KernelState::new();
        // No probes set → must be a no-op even when the scheduler
        // ctx has whatever PC.
        state.fire_ctor_probe_if_match(0, &mem);
        assert!(state.ctor_probe_pcs.is_empty());
    }

    #[test]
    fn fire_ctor_probe_if_match_only_fires_on_listed_pc() {
        // We can't easily redirect stdout under cargo-test, so this
        // test mostly verifies the membership check + that no panic
        // occurs when frame walking encounters zero/sentinel pages.
        // The empty-stack walk returns just `[(sp, lr)]`, exercising
        // the loop body once safely.
        let mem = GuestMemory::new().expect("memory init");
        let mut state = KernelState::new();
        state.ctor_probe_pcs.insert(0x8217_C850);
        // The default PpcContext on slot 0 has pc=0 (idle sentinel),
        // so the probe set membership test misses → no fire.
        state.fire_ctor_probe_if_match(0, &mem);
        // Sanity: an unrelated PC isn't claimed.
        assert!(!state.ctor_probe_pcs.contains(&0x8200_0000));
        assert!(state.ctor_probe_pcs.contains(&0x8217_C850));
    }

    #[test]
    fn read_ascii_cstring_handles_termination_and_garbage() {
        use xenia_memory::page_table::MemoryProtect;
        let mem = GuestMemory::new().expect("memory init");
        mem.alloc(0x4000_0000, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE)
            .expect("page");
        let addr = 0x4000_0100u32;
        // Plain NUL-terminated.
        mem.write_bytes(addr, b"hello\0world");
        assert_eq!(read_ascii_cstring(&mem, addr, 32), "hello");
        // Non-printable byte should reject the read.
        mem.write_u8(addr, 0x01);
        assert_eq!(read_ascii_cstring(&mem, addr, 32), "");
    }
}
