Files
xenia-rs/crates/xenia-kernel/src/state.rs
MechaCat02 ad45873a1b ITERATE-2.V: scheduler priority aging closes 18-day AUDIT-049 wedge
Priority aging in xenia-cpu/scheduler.rs:pick_runnable
(effective_priority = base + age_bonus(now_round - last_run_round),
capped at +31, AGING_ROUNDS_PER_BONUS=1). Strict-priority was parking
priority=0 threads behind CPU-bound priority=15 audio mixer
(sub_824D1328 guest spinwait at PC=0x824d1404 on CPU5). Aging
eventually picks the starved thread, breaking the producer-consumer
cycle that caused 5-tid wedge at PC=0x824ac578 since AUDIT-049 (10 May).

Cascade observed: tid=13 clean exit; events 121K -> 13M (107x); last
host_ns 767ms -> 51,011ms (66x); 8 new threads spawn; VdSwap 1 -> 2.

Complete two-day iterate sequence (2026-05-27 -> 2026-05-28):
- 2.F: VdSwap drain timeout 900ms -> 1ms (xenia-gpu/handle.rs); 876x
       perf win on VdSwap kernel callback
- 2.H: vA0000000 physical heap bucket added (state.rs, exports.rs);
       ctx_ptrs now in 0xA0000000-0xBFFFFFFF range matching canary
- 2.L: Phase-A diff harness categorized [return_value mismatch],
       [status mismatch], [args_resolved.path mismatch] tags
       (tools/diff-events/diff_events.py); closes reading-error #41
       (silent test-harness state leak invalidating trace diffs)
- 2.M: always-on exit-thread-state.json sibling to Phase-A JSONL
       (event_log.rs + xenia-app/main.rs); closes reading-error #42
       (Phase-A blind to blocked-forever waits)
- 2.Q: signal.match kernel instrumentation in NtSetEvent /
       NtReleaseSemaphore / KeSetEvent / KeReleaseSemaphore
       (exports.rs); emits target_handle + waiter_count + waiter_tids
- 2.T: wake.requested kernel instrumentation in wake_eligible_waiters
       (exports.rs); emits target_tid + transition + new_state
- 2.V: scheduler priority aging (xenia-cpu/scheduler.rs) [keystone]

Plus accumulated WIP from earlier May (contention_manifest,
phase_b_snapshot, xam/xaudio enhancements, analysis db, xex loader,
xenia-app main loop, etc.). Audit-runs/ artifacts remain untracked
per project convention.

Tests: 300 xenia-cpu / 227 xenia-kernel / 5 xenia-app / 19 xenia-path
/ 30+ smaller suites -- all PASS, 0 regressions. Determinism preserved
(2x cold runs bit-identical at 13,003,881 events post-2.V).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 07:27:26 +02:00

2523 lines
116 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
/// 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;
/// Phase C+12 — mirrors a single `xe::vfs::Entry` for the `cache:` mount.
/// Stored in [`KernelState::cache_entries`] keyed by the normalized guest
/// path (forward-slashed; see `crate::path::normalize_path`).
///
/// Field semantics match canary's `xe::vfs::Entry`
/// (`xenia-canary/src/xenia/vfs/entry.h:67-95`):
///
/// * `is_directory` — true for directories (Xbox attribute 0x10),
/// false for regular files (Xbox attribute 0x80).
/// * `size` — `entry->size()` (bytes; 0 for directories).
/// * `allocation_size`— `entry->allocation_size()` =
/// `round_up(size, bytes_per_sector)`. Canary's
/// `HostPathEntry::Create` uses
/// `device->bytes_per_sector()` which defaults to
/// 512 (`Device::bytes_per_sector_` ctor default;
/// cache: is a writable host-path device, no
/// override). We match that.
/// * `create_time` / `access_time` / `write_time` — Windows FILETIME
/// (100ns ticks since 1601-01-01 UTC). Populated
/// from `xe::filesystem::FileInfo::{create,
/// access, write}_timestamp` on canary
/// (`filesystem_win.cc:226-228`); on our Linux
/// host we derive the equivalent FILETIME from
/// `std::fs::Metadata::{created, accessed,
/// modified}` via [`unix_to_filetime`]. `change_
/// time` (the fourth FILETIME canary writes via
/// `entry->write_timestamp()`,
/// `xboxkrnl_io.cc:504`) reuses `write_time`.
#[derive(Debug, Clone)]
pub struct CacheEntryMeta {
pub is_directory: bool,
pub size: u64,
pub allocation_size: u64,
pub create_time: u64,
pub access_time: u64,
pub write_time: u64,
}
/// Phase C+12 — convert a [`std::time::SystemTime`] to a Windows FILETIME
/// value (100-ns ticks since 1601-01-01 UTC). Matches what canary's
/// Windows build emits via `COMBINE_TIME(ftCreationTime)` in
/// `xenia-canary/src/xenia/base/filesystem_win.cc:226`.
///
/// Conversion: Unix epoch = 1970-01-01 UTC. The Windows epoch is
/// 1601-01-01 UTC, which is `11_644_473_600` seconds earlier.
///
/// Pre-1970 inputs (rare on Linux, but `created()` can return them on
/// filesystems that lack a creation-time stamp) are clamped to 0,
/// which canary itself emits when the win32 `FILETIME` is zero — safer
/// than wrapping arithmetic.
pub fn unix_to_filetime(t: std::time::SystemTime) -> u64 {
const UNIX_TO_WINDOWS_EPOCH_SECS: u64 = 11_644_473_600;
match t.duration_since(std::time::UNIX_EPOCH) {
Ok(d) => {
let secs = d.as_secs();
let nanos = d.subsec_nanos() as u64;
secs.saturating_add(UNIX_TO_WINDOWS_EPOCH_SECS)
.saturating_mul(10_000_000)
.saturating_add(nanos / 100)
}
Err(_) => 0,
}
}
/// Phase C+12 — build a [`CacheEntryMeta`] from a host-FS metadata
/// snapshot. Mirrors `HostPathEntry::Create`
/// (`xenia-canary/src/xenia/vfs/devices/host_path_entry.cc:32-54`):
/// directory → attribute 0x10, size 0; file → attribute 0x80, size
/// from metadata, `allocation_size` rounded up to a 512-byte sector.
/// The `cache:` device is read-write so we never set the READONLY bit.
pub fn cache_entry_from_metadata(md: &std::fs::Metadata) -> CacheEntryMeta {
let is_directory = md.is_dir();
let size = if is_directory { 0 } else { md.len() };
let allocation_size = if is_directory {
0
} else {
// bytes_per_sector = 512 default (canary `Device::Device`).
(size + 511) & !511
};
let create_time = md
.created()
.map(unix_to_filetime)
.unwrap_or_else(|_| md.modified().map(unix_to_filetime).unwrap_or(0));
let access_time = md.accessed().map(unix_to_filetime).unwrap_or(0);
let write_time = md.modified().map(unix_to_filetime).unwrap_or(0);
CacheEntryMeta {
is_directory,
size,
allocation_size,
create_time,
access_time,
write_time,
}
}
/// Phase C+12 — `FILE_ATTRIBUTE_*` constants (NT semantics, Xbox 360
/// uses the same bitmask as Windows for `X_FILE_NETWORK_OPEN_
/// INFORMATION::attributes`). Source:
/// `xenia-canary/src/xenia/vfs/entry.h:67-73`.
pub const X_FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0010;
pub const X_FILE_ATTRIBUTE_NORMAL: u32 = 0x0080;
/// Central kernel state tracking all guest OS state.
pub struct KernelState {
exports: HashMap<(ModuleId, u32), (&'static str, KernelExportFn)>,
/// Phase A: kernel exports whose canary signature is `void` (no
/// dword_result_t / pointer_result_t). For symmetry with canary's
/// `if constexpr (std::is_void<R>::value)` trampoline branch
/// (see `xenia-canary/src/xenia/kernel/util/shim_utils.h`), the
/// Phase A `kernel.return` event for these exports emits
/// `return_value=0` instead of `gpr[3]` (which for void fns is
/// just the input arg pointer left untouched). Without this,
/// e.g. `KeQuerySystemTime` — declared `void` in canary, taking a
/// `lpqword_t time_ptr` — would report ours's r3=time_ptr but
/// canary's literal 0, producing a spurious diff. Cvar-OFF inert.
void_exports: std::collections::HashSet<(ModuleId, u32)>,
/// Phase C+6: kernel exports that have a table-entry in canary's
/// `xboxkrnl_table.inc` but NO `DECLARE_XBOXKRNL_EXPORT` / shim
/// implementation. Canary wires such imports to the syscall thunk
/// (`sc 2; blr`) which does NOT call any `Trampoline` and therefore
/// emits NO Phase A events (see `xenia-canary/src/xenia/cpu/
/// xex_module.cc:1316-1335` and `ppc_frontend.cc:83-92`). For ours
/// to match canary's event stream, we must skip
/// `import.call`/`kernel.call`/`kernel.return` emission for these
/// exports even though we still execute their stub body (typically
/// `stub_success` setting `r3=0`). Without this, every guest call
/// to e.g. `IoDismountVolumeByFileHandle` injects 3 spurious events
/// into ours's Phase A stream while canary's stays silent — causing
/// per-call alignment drift downstream. Cvar-OFF inert (this flag
/// is consumed only inside the Phase A `phase_a_on` guard in
/// `call_export`).
unimplemented_exports: std::collections::HashSet<(ModuleId, u32)>,
/// 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,
/// 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>,
/// Phase C+5 — set of file handles opened WITHOUT
/// `FILE_SYNCHRONOUS_IO_ALERT` (0x10) or `FILE_SYNCHRONOUS_IO_NONALERT`
/// (0x20). Canary's `NtWriteFile_entry`
/// (xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:351-353)
/// completes such writes synchronously but returns `STATUS_PENDING`
/// (0x103) instead of `STATUS_SUCCESS`. Mirrors `xfile.is_synchronous_`
/// in canary (xfile.h:177, xfile.cc:22). Populated by `open_vfs_file`
/// and `open_cache_file`; pruned by `nt_close` when the handle's
/// refcount drops to zero.
pub async_file_handles: std::collections::HashSet<u32>,
/// 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,
/// Iterate 2.H — top-down bump allocator for the canary `vA0000000`
/// physical heap (0xA0000000-0xBFFFFFFF, 64KB pages). This bucket
/// services `MmAllocatePhysicalMemoryEx` requests that pass
/// `X_MEM_LARGE_PAGES` (0x20000000) in `protect_bits` — matching
/// canary's `LookupHeapByType(true, 64*1024) -> heaps_.vA0000000`
/// (xenia-canary memory.cc:269-271, xboxkrnl_memory.cc:454-455).
/// Cursor is the top-exclusive frontier: each alloc decrements first,
/// then allocates `[cursor, cursor+aligned_size)`. Initialized to
/// `0xC000_0000`.
pub physical_heap_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,
/// Guest VA of the raw XEX header bytes copied into guest memory at
/// startup (mirrors canary's `UserModule::guest_xex_header_`,
/// allocated in `user_module.cc:224`). Used by `RtlImageXexHeaderField`
/// to compute return values that are offsets into the in-guest header
/// copy (canary's `xboxkrnl_rtl.cc:501-514` calls `UserModule::Get
/// OptHeader(memory, header, key, &field_value)` which iterates
/// `header->headers[]` and returns `HostToGuestVirtual(header) +
/// opt_header.offset` for "else"-class keys, key low byte != 0/1). Zero
/// when the executable hasn't been installed yet. Set once by
/// `xenia-app` after `mem.write_bulk(base, &image_data)`.
pub xex_header_guest_ptr: u32,
/// Guest VA of the 0x18-byte `X_TIME_STAMP_BUNDLE` block referenced by
/// the `KeTimeStampBundle` (ord 0x00AD) variable export. Layout matches
/// canary's `kernel_state.h:98-104`:
/// +0x00 u64 interrupt_time (100-ns ticks since boot)
/// +0x08 u64 system_time (100-ns ticks, Windows FILETIME epoch)
/// +0x10 u32 tick_count (monotonic milliseconds since boot)
/// +0x14 u32 padding
/// Zero before the patcher allocates it. Stashed so the host-side 1 ms
/// repeating updater (spawned in `xenia-app`) can find the block.
/// Mirrors canary's `HighResolutionTimer::CreateRepeating(1 ms,
/// UpdateKeTimestampBundle)` at `kernel_state.cc:1272-1295`.
pub ke_timestamp_bundle_ptr: 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>,
/// Phase C+12 — in-memory VFS entry tracker for the `cache:` mount,
/// mirroring canary's `HostPathDevice` entry tree. Keyed by the
/// normalized guest path (e.g. `cache:/d4ea4615/e/46ee8ca`,
/// post-`normalize_path` form with forward slashes). Populated at
/// mount time by [`Self::populate_cache_entries`] (analogue of
/// canary's `HostPathDevice::PopulateEntry`,
/// `xenia-canary/src/xenia/vfs/devices/host_path_device.cc:63`) and
/// per-NtCreateFile success by [`Self::register_cache_entry`]
/// (analogue of `Entry::CreateEntry` /
/// `HostPathEntry::CreateEntryInternal`,
/// `xenia-canary/src/xenia/vfs/devices/host_path_entry.cc:78`).
///
/// Consulted by `nt_query_full_attributes_file` BEFORE any
/// `std::fs::metadata` host-FS call, mirroring canary's
/// `NtQueryFullAttributesFile_entry`
/// (`xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_io.cc:498-512`)
/// which only walks the in-memory entry tree via
/// `VirtualFileSystem::ResolvePath` and never re-stats the host.
///
/// This resolves Phase C+11.1's main-chain divergence at idx
/// 102404 (NtQueryFullAttributesFile on `cache:\d4ea4615\e\46ee8ca`)
/// where canary's mount-time scan + in-memory tree allowed the
/// probe to succeed even before the file existed on disk this
/// boot, while ours's direct `std::fs::metadata` reported NOT_FOUND.
pub cache_entries: HashMap<String, CacheEntryMeta>,
/// 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,
/// 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>,
/// Phase C+19: alias map from duplicated handle id → canonical
/// (source) handle id. `NtDuplicateObject` mirrors canary's
/// `ObjectTable::DuplicateHandle` (object_table.cc:210) by
/// allocating a fresh slot id, but the underlying kernel object
/// (`KernelObject::Event`, `Semaphore`, etc.) stays a single
/// instance keyed in `state.objects` by the canonical id. Whenever
/// the guest passes a handle to an Nt*/Ke* call, `resolve_handle`
/// canonicalizes through this map before indexing `state.objects`.
///
/// Why: AUDIT-062 (worker-cluster wedge resolution) depends on
/// "signal on dup wakes wait on source". With aliasing we relied on
/// `dup_id == source_id`; with a fresh dup id we rely on both ids
/// canonicalizing to the same `state.objects` entry, preserving the
/// shared `waiters` list and `signaled` flag.
///
/// Lifecycle: inserted in `nt_duplicate_object`; entries point at
/// the *current* canonical id which never changes once minted (we
/// don't transitively rewrite aliases on partial close). When all
/// slot ids referring to a canonical have been closed,
/// `canonical_slot_count` reaches zero and the canonical entry in
/// `state.objects` is removed.
pub handle_aliases: HashMap<u32, u32>,
/// Phase C+19: number of live handle slots referring to a canonical
/// kernel object. Bumped at `alloc_handle_for` (1 = the source
/// slot) and at `nt_duplicate_object` (each fresh dup slot). On
/// `nt_close` of any slot id `h` whose `handle_refcount[h]`
/// reaches zero, this counter for `resolve_handle(h)` is
/// decremented; if it reaches zero, `state.objects[canonical]` is
/// removed. Mirrors canary's `XObject::handle_ref_count_` (xobject.cc:73)
/// where every `AddHandle` retains and every final `RemoveHandle`
/// releases — the object dies only when the last slot is gone.
pub canonical_slot_count: 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>,
/// 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>,
/// 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)>,
/// Phase B initial-state snapshot — directory under which a
/// `ours/{cpu_state,memory,kernel,vfs,config}.json` + `manifest.json`
/// snapshot is written at the moment immediately before the first
/// guest PPC instruction of the XEX entry_point. `None` (default) =
/// disabled, zero overhead. See
/// `xenia-rs/audit-runs/phase-b-state-equivalence/`.
pub phase_b_snapshot_dir: Option<std::path::PathBuf>,
/// Phase B: after writing the snapshot, exit the process immediately
/// so re-runs are byte-deterministic. Default false.
pub phase_b_snapshot_and_exit: bool,
/// Phase B: include raw bytes in `memory.json`'s `section_contents`.
/// Default false — per-region SHA-256 is enough for the routine diff.
pub phase_b_dump_section_content: bool,
/// Phase B: the XEX entry_point address — captured by the app at
/// `install_initial_thread` time and consulted by the snapshot hook
/// to validate the firing thread is the entry thread.
pub entry_pc: u32,
/// Phase D Stage 3: optional contention-replay manifest. When
/// loaded (typically via `XENIA_CONTENTION_MANIFEST_PATH`),
/// `rtl_enter_critical_section` consults it before its fast-path
/// claim and forces a park whenever the manifest says canary saw
/// real contention at the same `(tid, tid_event_idx)`. `None` =
/// disabled, zero overhead. The manifest itself is read-only after
/// load except for the per-entry `consume` removal, which is a
/// fast HashMap::remove behind a Mutex.
pub contention_manifest:
Option<std::sync::Arc<crate::contention_manifest::ContentionManifest>>,
/// review-a Step 1 crowbar — when `crowbar_workers_enabled` is set
/// (via `--force-spawn-workers` / `XENIA_CROWBAR_WORKERS=1`), the
/// per-round helper `try_fire_crowbar_workers` synthesises the 4
/// `sub_825070F0` worker spawns once `instruction_count` crosses
/// `crowbar_workers_trigger_instr` (default 20_000_000). Default
/// OFF: zero behaviour change in normal runs. See
/// `xenia-rs/audit-runs/review-a-step1-crowbar/investigation.md`.
pub crowbar_workers_enabled: bool,
/// Instruction-count threshold for the one-shot crowbar fire. Picked
/// to land well after the 10-thread initial spawn burst and the
/// boot-init `VdSwap`, but with plenty of head-room for the workers
/// to execute before any reasonable `-n` cap.
pub crowbar_workers_trigger_instr: u64,
/// Latch — flipped to `true` on the first successful crowbar fire so
/// the helper is at-most-once. Read-only after the flip.
pub crowbar_workers_fired: bool,
}
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(),
void_exports: std::collections::HashSet::new(),
unimplemented_exports: std::collections::HashSet::new(),
next_handle: AtomicU32::new(0x1000),
scheduler,
next_tls_index: AtomicU32::new(0),
cs_waiters: HashMap::new(),
objects: HashMap::new(),
async_file_handles: std::collections::HashSet::new(),
heap_cursor: AtomicU32::new(0x4000_0000), // Start of user heap region
stack_cursor: AtomicU32::new(0x7100_0000), // Above main stack
// Iterate 2.H: top-exclusive cursor for the vA0000000 physical
// heap. Decrements down toward 0xA0000000 (the bucket floor).
physical_heap_cursor: AtomicU32::new(0xC000_0000),
gpu_command_buffer: 0,
gpu,
input_packet_number: 0,
last_input_bytes: 0,
image_base: 0,
xex_header_guest_ptr: 0,
ke_timestamp_bundle_ptr: 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,
cache_entries: HashMap::new(),
ui: None,
interrupts: crate::interrupts::InterruptState::default(),
xaudio: crate::xaudio::XAudioState::default(),
// 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(),
handle_aliases: HashMap::new(),
canonical_slot_count: HashMap::new(),
pending_timer_fires: Vec::new(),
audit: HandleAudit::default(),
reservations,
thunks_by_ordinal: HashMap::new(),
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(),
lr_trace_pcs: std::collections::HashSet::new(),
lr_trace_writer: None,
dump_addrs: Vec::new(),
dump_section: None,
phase_b_snapshot_dir: None,
phase_b_snapshot_and_exit: false,
phase_b_dump_section_content: false,
entry_pc: 0,
contention_manifest: None,
crowbar_workers_enabled: false,
crowbar_workers_trigger_instr: 20_000_000,
crowbar_workers_fired: false,
};
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
);
}
// Phase C+12 — eager mount-time entry-tree population mirrors
// canary's `HostPathDevice::PopulateEntry` recursion
// (`xenia-canary/src/xenia/vfs/devices/host_path_device.cc:63`).
// After the (optional) wipe, the on-disk tree is the source of
// truth; `nt_query_full_attributes_file` will consult the
// in-memory table built here before any host-FS round-trip.
if state.cache_root.is_some() {
let root_clone = state.cache_root.clone().unwrap();
state.populate_cache_entries_from_host(&root_clone);
}
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));
}
/// Register a kernel export whose canary signature is `void`.
/// See `KernelState::void_exports` doc. Identical semantics to
/// `register_export` except the Phase A `kernel.return` payload's
/// `return_value` field is emitted as 0 instead of `gpr[3]`,
/// matching canary's `EmitReturn(name, 0)` branch.
pub fn register_void_export(
&mut self,
module: ModuleId,
ordinal: u32,
name: &'static str,
func: KernelExportFn,
) {
self.exports.insert((module, ordinal), (name, func));
self.void_exports.insert((module, ordinal));
}
/// Phase C+6: register a kernel export that has a table-entry in
/// canary's `xboxkrnl_table.inc` but NO `DECLARE_XBOXKRNL_EXPORT`
/// shim. Identical execution semantics to `register_export`; only
/// difference is the Phase A emitter is silent for this export (to
/// mirror canary's syscall-thunk path which never reaches the
/// `Trampoline` that issues `import.call`/`kernel.call`/
/// `kernel.return`). See `KernelState::unimplemented_exports` doc.
/// Use for ords whose `func` is a `stub_*` and which would
/// otherwise inject spurious Phase A alignment drift.
pub fn register_unimplemented_export(
&mut self,
module: ModuleId,
ordinal: u32,
name: &'static str,
func: KernelExportFn,
) {
self.exports.insert((module, ordinal), (name, func));
self.unimplemented_exports.insert((module, ordinal));
}
/// 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);
// Phase C+12 — wipe path: tree is by definition empty after the
// clear-then-recreate. A subsequent `set_cache_root` could be
// called by tests that want a populated tree; we leave that path
// handle the eager scan.
self.cache_entries.clear();
// Insert the root directory entry so callers that probe
// `cache:/` directly (rare; Sylpheed does `NtOpenFile cache:\`
// at idx 102382) see canary's "yes, root is a directory" answer.
self.cache_entries.insert(
"cache:/".to_string(),
CacheEntryMeta {
is_directory: true,
size: 0,
allocation_size: 0,
create_time: 0,
access_time: 0,
write_time: 0,
},
);
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.
///
/// Phase C+12 — this also eagerly populates [`Self::cache_entries`]
/// from the existing host-FS tree under `root`, mirroring canary's
/// `HostPathDevice::Initialize` → `PopulateEntry`
/// (`xenia-canary/src/xenia/vfs/devices/host_path_device.cc:31-48,
/// 63-75`).
pub fn set_cache_root(&mut self, root: std::path::PathBuf) {
self.cache_root = Some(root.clone());
self.cache_entries.clear();
self.populate_cache_entries_from_host(&root);
}
/// Phase C+12 — eager mount-time scan. Walks `root` recursively
/// and inserts a [`CacheEntryMeta`] for every entry under the
/// `cache:/` namespace. Mirrors canary's `HostPathDevice::
/// PopulateEntry` recursion. Errors are non-fatal (logged at
/// trace level); missing/unreadable host paths just leave the
/// in-memory tree empty for that subtree, exactly like canary
/// (which uses `ListFiles` whose `WIN32_FIND_DATA` errors silently
/// produce an empty vector).
fn populate_cache_entries_from_host(&mut self, root: &std::path::Path) {
// Always seed the device root.
self.cache_entries.insert(
"cache:/".to_string(),
CacheEntryMeta {
is_directory: true,
size: 0,
allocation_size: 0,
create_time: 0,
access_time: 0,
write_time: 0,
},
);
if !root.is_dir() {
return;
}
let mut stack: Vec<(std::path::PathBuf, String)> =
vec![(root.to_path_buf(), "cache:".to_string())];
while let Some((host_dir, guest_prefix)) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&host_dir) else {
continue;
};
for entry in rd.flatten() {
let host_path = entry.path();
let Some(name) = host_path
.file_name()
.and_then(|n| n.to_str())
else {
continue;
};
let guest_path = format!("{}/{}", guest_prefix, name);
let Ok(md) = entry.metadata() else { continue };
let meta = cache_entry_from_metadata(&md);
let is_dir = meta.is_directory;
self.cache_entries.insert(guest_path.clone(), meta);
if is_dir {
stack.push((host_path, guest_path));
}
}
}
}
/// Phase C+12 — register / refresh a single cache-mount entry by
/// guest path (forward-slashed; matches `crate::path::normalize_path`
/// output and the keys produced by [`Self::populate_cache_entries_
/// from_host`]). Called from [`crate::exports::open_cache_file`]
/// after a successful create-or-open so subsequent
/// `NtQueryFullAttributesFile` probes see the freshly-materialised
/// entry without re-stating the host FS, mirroring canary's
/// `Entry::CreateEntry` insert path.
///
/// Idempotent — calling twice with the same path just refreshes
/// the cached metadata from `metadata` (useful after a write that
/// changed size / mtime).
pub fn register_cache_entry(&mut self, guest_path: &str, metadata: &std::fs::Metadata) {
let key = Self::normalize_cache_key(guest_path);
self.cache_entries
.insert(key, cache_entry_from_metadata(metadata));
}
/// Phase C+12 — drop a cache entry (used on NtSetInformationFile
/// rename and on delete). Idempotent.
pub fn forget_cache_entry(&mut self, guest_path: &str) {
let key = Self::normalize_cache_key(guest_path);
self.cache_entries.remove(&key);
}
/// Phase C+12 — look up a cache entry by guest path. The lookup
/// key is case-insensitive on the `cache:` prefix (canary matches
/// device-prefix case-insensitively via
/// `xe::utf8::starts_with` against `cache:`) and forward-slashed
/// for the rest. Path-traversal `..` / `.` components and leading
/// slashes are stripped to match the canonicalization
/// [`Self::resolve_cache_path`] performs against the host FS.
pub fn lookup_cache_entry(&self, raw: &str) -> Option<&CacheEntryMeta> {
let key = Self::normalize_cache_key(raw);
self.cache_entries.get(&key)
}
/// Canonical key form for [`Self::cache_entries`]:
/// `cache:/<lower-slashed-relative>`. Mirrors what
/// `crate::path::normalize_path` produces (forward slashes,
/// `cache:` prefix preserved). Accepts both `cache:\foo\bar` and
/// `cache:/foo/bar`, and treats `cache0:` / `cache1:` as aliases
/// of `cache:` (same backing dir; see [`Self::resolve_cache_path`]).
fn normalize_cache_key(raw: &str) -> String {
let lower = raw.to_ascii_lowercase();
let after_prefix = if let Some(rest) = lower
.strip_prefix("cache:\\")
.or_else(|| lower.strip_prefix("cache:/"))
{
rest
} 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:/"))
{
rest
} else if lower == "cache:" || lower == "cache:/" || lower == "cache:\\" {
return "cache:/".to_string();
} else {
return lower;
};
let clean: Vec<&str> = after_prefix
.split(|c: char| c == '/' || c == '\\')
.filter(|s| !s.is_empty() && *s != "." && *s != "..")
.collect();
if clean.is_empty() {
"cache:/".to_string()
} else {
format!("cache:/{}", clean.join("/"))
}
}
/// 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);
}
/// 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);
// Phase A event log — see crates/xenia-kernel/src/event_log.rs.
// Hot path: `is_enabled` is a relaxed atomic-bool load.
// Phase C+6: exports flagged `unimplemented_exports` mirror
// canary's table-entry-without-DECLARE_XBOXKRNL_EXPORT path
// (`xenia-canary/src/xenia/cpu/xex_module.cc:1316-1335`),
// which dispatches through the syscall thunk and never
// reaches the `Trampoline` that emits Phase A events. Suppress
// event emission so ours's stream matches canary's. The stub
// body still runs.
let phase_a_on = crate::event_log::is_enabled()
&& !self.unimplemented_exports.contains(&(module, ordinal));
let (phase_a_tid, phase_a_cycle) = if phase_a_on {
let tid = self.scheduler.thread(r).tid;
let cycle = ctx.cycle_count;
(tid, cycle)
} else {
(0u32, 0u64)
};
if phase_a_on {
let module_name = match module {
ModuleId::Xboxkrnl => "xboxkrnl.exe",
ModuleId::Xam => "xam.xex",
ModuleId::Xbdm => "xbdm.xex",
};
crate::event_log::emit_import_call(
phase_a_tid,
phase_a_cycle,
module_name,
ordinal as u16,
name,
);
// Phase C+10 schema-v1 extension: resolve path args for
// OBJECT_ATTRIBUTES*-taking exports so divergences on file
// existence probes carry the actual path string in the diff.
// Additive — degrades to empty args_resolved when name is
// not in the path-bearing set or resolution fails.
let resolved_path = match name {
// Path-bearing exports — argument positions per canary's
// `xboxkrnl/xboxkrnl_io.cc` signatures (verified):
// NtCreateFile (r3 = file_handle_ptr, r4 = ..., r5 = obj_attrs)
// NtOpenFile (r3 = file_handle_ptr, r4 = ..., r5 = obj_attrs)
// NtQueryFullAttributesFile (r3 = obj_attrs, r4 = file_info)
// NtOpenSymbolicLinkObject (r3 = handle_out, r4 = obj_attrs)
// Use the raw (untransformed) form to avoid masking
// upstream divergences via normalization.
"NtQueryFullAttributesFile" => {
crate::path::object_attributes_raw_name(mem, ctx.gpr[3] as u32)
}
"NtOpenSymbolicLinkObject" => {
crate::path::object_attributes_raw_name(mem, ctx.gpr[4] as u32)
}
"NtCreateFile" | "NtOpenFile" => {
crate::path::object_attributes_raw_name(mem, ctx.gpr[5] as u32)
}
// Phase C+11 — surface the rename target path for
// `NtSetInformationFile` calls with info_class==10
// (`XFileRenameInformation`). The target is in the
// info buffer, not OBJECT_ATTRIBUTES.
//
// Calling convention (canary `xboxkrnl_io_info.cc:180`):
// r3 = handle, r4 = iosb, r5 = info_ptr,
// r6 = info_length, r7 = info_class.
"NtSetInformationFile" if ctx.gpr[7] as u32 == 10 => {
crate::path::file_rename_information_raw_target(
mem,
ctx.gpr[5] as u32,
ctx.gpr[6] as u32,
)
}
_ => None,
};
crate::event_log::emit_kernel_call_with_path(
phase_a_tid,
phase_a_cycle,
name,
resolved_path.as_deref(),
);
}
let is_void = self.void_exports.contains(&(module, ordinal));
func(&mut ctx, mem, self);
if phase_a_on {
// Mirror canary's `if constexpr (std::is_void<R>::value)`
// trampoline branch: void exports emit literal 0; non-void
// emit post-call gpr[3]. Without this, void exports that
// take a pointer arg (e.g. `KeQuerySystemTime`) would
// report ours=r3=arg_ptr vs canary=0 — a Phase A diff
// that is purely an emitter-framing asymmetry, not an
// engine semantic divergence.
//
// Phase C+11 — sign-extend the lower 32 bits to match
// canary's `ResultBase::Store` (shim_utils.h:359-361):
// `ppc_context->r[3] = uint64_t(int32_t(value_));`
// For positive-as-i32 returns (status SUCCESS, pointers
// < 0x80000000) this is a no-op. For "negative" NTSTATUS
// codes (e.g. STATUS_NO_SUCH_FILE = 0xC000000F) it
// produces 0xFFFFFFFFC000000F — matching the diff's
// expected u64 representation.
let return_value = if is_void {
0
} else {
(ctx.gpr[3] as u32 as i32 as i64) as u64
};
crate::event_log::emit_kernel_return(
phase_a_tid,
ctx.cycle_count,
name,
return_value,
);
}
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
}
/// Phase D Stage 3: install a contention-replay manifest. Once set,
/// `rtl_enter_critical_section` consults the manifest at each call
/// and forces a park when the manifest says canary saw real
/// contention at the same `(tid, tid_event_idx)`. Idempotent —
/// calling twice replaces the previous manifest. Passing `None`
/// clears it.
pub fn install_contention_manifest(
&mut self,
manifest: Option<std::sync::Arc<crate::contention_manifest::ContentionManifest>>,
) {
self.contention_manifest = manifest;
}
/// 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 {
// 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)
}
pub fn alloc_handle_for(&mut self, obj: KernelObject) -> u32 {
let h = self.alloc_handle();
// Phase C+15-α: schema-v1 `handle.create` event. Cvar-gated
// default-off via `event_log::is_enabled()`. Centralized here so
// every alloc_handle_for site (39+ call sites across exports.rs,
// xam.rs) emits a symmetric handle.create. Semantic ID is
// FNV-1a(0, tid, tid_event_idx_at_creation, object_type) — see
// schema-v1.md. Canary emits the symmetric event at
// `ObjectTable::AddHandle`.
if crate::event_log::is_enabled() {
let object_type = obj.schema_object_type();
let (tid, cycle) = if let Some(r) = self.scheduler.current {
let t = self.scheduler.thread(r);
(t.tid, t.ctx.timebase)
} else {
(0u32, 0u64)
};
crate::event_log::emit_handle_create_auto(
tid,
cycle,
/* create_site_pc */ 0,
object_type,
h,
/* object_name */ None,
);
}
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);
// Phase C+19: the canonical kernel object starts with exactly one
// slot — its own. `NtDuplicateObject` bumps this every time it
// allocates a fresh dup slot; `nt_close` of a slot whose
// `handle_refcount` reaches zero decrements this and only drops
// `state.objects[h]` when all slots are gone. Mirrors canary's
// `XObject::handle_ref_count_` semantics (xobject.cc:73-87).
self.canonical_slot_count.insert(h, 1);
h
}
/// Phase C+19: resolve a handle id through the alias map to its
/// canonical id (the key under which `state.objects` holds the
/// underlying `KernelObject`). Idempotent for non-aliased handles —
/// `resolve_handle(h) == h` whenever `h` is a canonical id or an
/// unknown id.
///
/// Used by every Nt*/Ke* lookup site to ensure that signal-on-dup
/// wakes wait-on-source (AUDIT-062 invariant). Cheap: single
/// `HashMap::get`.
pub fn resolve_handle(&self, h: u32) -> u32 {
self.handle_aliases.get(&h).copied().unwrap_or(h)
}
/// Bump the per-handle refcount by one. Mirrors canary's
/// `XObject::RetainHandle()` → `ObjectTable::RetainHandle`
/// (xobject.cc:73-75, object_table.cc:218-228). Returns the new
/// refcount. Phase C+16: used by thread-spawn paths to install the
/// "thread owns itself until exited" reference (canary's
/// `XThread::Create` line 414). Without this, `XamTaskCloseHandle`'s
/// NtClose drops the only ref and destroys the thread handle while
/// the spawned thread is still live — surfaces as an extra
/// `handle.destroy` event at Phase A idx=102168 on the main chain.
/// No `handle.create` event is emitted (the handle already exists);
/// canary's symmetric path also emits no event on `RetainHandle`.
pub fn retain_handle(&mut self, handle: u32) -> u32 {
let c = self.handle_refcount.entry(handle).or_insert(0);
*c = c.saturating_add(1);
*c
}
/// Decrement the per-handle refcount by one; if it reaches zero, drop
/// the underlying object and emit a `handle.destroy` event. Mirrors
/// canary's `XObject::ReleaseHandle()` →
/// `ObjectTable::ReleaseHandle`/`RemoveHandle` (xobject.cc:77-81,
/// object_table.cc:230-295). Returns `true` if the final reference
/// was released (object destroyed), `false` if other references
/// remain. Phase C+16: used by thread-exit paths to release the
/// self-reference installed by `retain_handle` at spawn time.
pub fn release_handle(&mut self, handle: u32) -> bool {
let prior_rc = self.handle_refcount.get(&handle).copied().unwrap_or(0);
let remaining = self
.handle_refcount
.get_mut(&handle)
.map(|c| {
*c = c.saturating_sub(1);
*c
})
.unwrap_or(0);
if remaining == 0 {
self.objects.remove(&handle);
self.handle_refcount.remove(&handle);
self.async_file_handles.remove(&handle);
self.disarm_timer(handle);
if crate::event_log::is_enabled() {
let (tid, cycle) = if let Some(r) = self.scheduler.current {
let t = self.scheduler.thread(r);
(t.tid, t.ctx.timebase)
} else {
(0u32, 0u64)
};
crate::event_log::emit_handle_destroy_auto(tid, cycle, handle, prior_rc);
}
true
} else {
false
}
}
// ===== 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) {
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);
}
/// 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 { '.' },
);
}
/// 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
}
/// review-a Step 1 — one-shot per-round helper to fire the
/// `--force-spawn-workers` crowbar. Returns the number of workers
/// successfully resumed (1..=4 = at least partial fire; 0 = nothing
/// happened, either because the cvar is off, the trigger hasn't been
/// reached yet, or the helper has already fired). No-op when the
/// crowbar is disabled or the latch is already set.
///
/// Called from `xenia-app::coord_pre_round`. Honest failure modes:
/// the workers themselves may fault on bad guest memory, or block
/// further down the chain — that's the diagnostic outcome being
/// tested.
pub fn try_fire_crowbar_workers(
&mut self,
mem: &GuestMemory,
instruction_count: u64,
) -> u32 {
if !self.crowbar_workers_enabled || self.crowbar_workers_fired {
return 0;
}
if instruction_count < self.crowbar_workers_trigger_instr {
return 0;
}
self.crowbar_workers_fired = true;
tracing::warn!(
"CROWBAR: trigger reached @ instr={} (threshold={}), firing 4 workers",
instruction_count,
self.crowbar_workers_trigger_instr,
);
crate::exports::crowbar_force_spawn_workers(self, mem)
}
/// 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)
}
/// Iterate 2.H — top-down 64KB-paged allocator for the canary
/// `vA0000000` physical heap (`0xA0000000-0xBFFFFFFF`).
/// `MmAllocatePhysicalMemoryEx` routes large-page (`X_MEM_LARGE_PAGES`,
/// 0x20000000) requests here. Returns `None` if the cursor would
/// underflow past the bucket floor (`0xA000_0000`).
pub fn physical_heap_alloc(&self, size: u32, mem: &GuestMemory) -> Option<u32> {
use std::sync::atomic::Ordering;
if size == 0 {
return None;
}
// 64KB page rounding — canary's vA0000000 heap uses 64*1024 pages.
let aligned_size = (size + 0xFFFF) & !0xFFFF;
// Top-down: subtract first, the returned base IS the new cursor.
// CAS loop preserves the lock-free invariant heap_alloc enjoys.
let base = loop {
let cur = self.physical_heap_cursor.load(Ordering::Relaxed);
let new_cur = cur.checked_sub(aligned_size)?;
if new_cur < 0xA000_0000 {
return None;
}
match self.physical_heap_cursor.compare_exchange(
cur,
new_cur,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break new_cur,
Err(_) => continue,
}
};
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)
}
/// 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.scheduler.ctx(0).timebase;
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()
}
}
/// Pick the cache root path + wipe-on-init mode for a fresh
/// `KernelState`.
///
/// Phase C+11 (2026-05-14) — default flipped to PERSISTENT. Prior
/// AUDIT-038 behaviour (per-process tmpdir + wipe) is still
/// reachable via `XENIA_CACHE_WIPE=1`. Rationale for the flip:
///
/// * AUDIT-052 refuted AUDIT-038's "missing-or-stale ≡ fresh"
/// premise: Sylpheed's work-submitter wakeup is GATED on cache
/// existence, so wipe-on-boot blocks the cache-build cascade.
/// * AUDIT-054 introduced opt-in `XENIA_CACHE_PERSIST=1`; the
/// Phase C+11 fixes (NtSetInformationFile class 10 rename +
/// `is_dir_open` existing-file-wins + STATUS_NO_SUCH_FILE on
/// query miss + sign-extended status returns) make
/// Sylpheed's own cache-build path converge to canary-parity
/// leaf layout. The diff harness no longer needs the wipe.
/// * The C+10 args_resolved.path emitter surfaces any cache
/// divergence in the Phase A diff regardless of cache state,
/// so the original "lockstep determinism" rationale for the
/// wipe is no longer the only mechanism preventing silent
/// cache divergences.
///
/// Env-var contract (unchanged):
/// * `XENIA_CACHE_ROOT=<path>` — explicit persistent path.
/// Highest precedence. No wipe.
/// * `XENIA_CACHE_PERSIST=1` — alias for the new default. Kept
/// for backwards compatibility (no-op now).
/// * `XENIA_CACHE_WIPE=1` — opt back into the AUDIT-038
/// per-process tmpdir + wipe. Use for emergency lockstep
/// state-reset scenarios.
///
/// 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);
}
}
// Opt-out: explicit AUDIT-038-style wipe + tmpdir. Kept for
// emergency state-reset, e.g. Phase A determinism baseline
// captures that must start from a known-empty cache.
let wipe_explicit = std::env::var("XENIA_CACHE_WIPE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if wipe_explicit {
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);
return (
std::env::temp_dir().join(format!(
"xenia-rs-cache-{}-{}",
std::process::id(),
id
)),
true,
);
}
// Default: persistent cache at the standard XDG location.
// `XENIA_CACHE_PERSIST=1` is a no-op alias for the default
// — keep accepting it for callers that set it explicitly.
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,
);
}
}
// Final fallback: tmpdir without wipe (no $HOME, very rare).
static NEXT_CACHE_ID_FALLBACK: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let id = NEXT_CACHE_ID_FALLBACK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
(
std::env::temp_dir().join(format!(
"xenia-rs-cache-fallback-{}-{}",
std::process::id(),
id
)),
false,
)
}
/// 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 0x400000000x50000000;
/// the image and read-only-data are 0x820000000x83000000. 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);
}
}
/// Iterate 2.H — `physical_heap_alloc` must hand back addresses in the
/// `0xA0000000-0xBFFFFFFF` range, 64KB-page-aligned, in descending
/// (top-down) order. Mirrors canary's `vA0000000` `PhysicalHeap` policy
/// (xenia-canary memory.cc:269-271 + xboxkrnl_memory.cc top_down=true).
#[test]
fn physical_heap_alloc_descends_in_va_range() {
let mem = GuestMemory::new().expect("memory init");
let state = KernelState::new();
let mut prev = 0xC000_0000u32;
for _ in 0..10 {
let addr = state
.physical_heap_alloc(0x1234, &mem)
.expect("physical heap must service small allocs");
assert!(
(0xA000_0000..0xC000_0000).contains(&addr),
"phys alloc {addr:#x} outside vA0000000 range"
);
assert_eq!(addr & 0xFFFF, 0, "phys alloc {addr:#x} not 64KB-aligned");
assert!(
addr < prev,
"phys alloc {addr:#x} did not descend below previous {prev:#x}"
);
prev = 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 register_unimplemented_export_marks_set_membership() {
// Phase C+6: `register_unimplemented_export` must (a) install the
// export func like `register_export` does, AND (b) flag the
// (module, ord) pair in `unimplemented_exports` so the Phase A
// emitter inside `call_export` can suppress events for it. Without
// (a), guest calls would fault as "unimplemented ordinal". Without
// (b), ours would inject `import.call`/`kernel.call`/
// `kernel.return` triples that canary's syscall-thunk path never
// emits, drifting Phase A alignment.
fn noop(_: &mut PpcContext, _: &GuestMemory, _: &mut KernelState) {}
let mut state = KernelState::new();
state.register_unimplemented_export(
ModuleId::Xboxkrnl,
0xFFEE,
"FakeUnimplementedXboxkrnl",
noop,
);
assert!(state.exports.contains_key(&(ModuleId::Xboxkrnl, 0xFFEE)));
assert!(state
.unimplemented_exports
.contains(&(ModuleId::Xboxkrnl, 0xFFEE)));
// A normal `register_export` must NOT mark it unimplemented.
state.register_export(
ModuleId::Xboxkrnl,
0xFFEF,
"FakeRegularXboxkrnl",
noop,
);
assert!(state.exports.contains_key(&(ModuleId::Xboxkrnl, 0xFFEF)));
assert!(!state
.unimplemented_exports
.contains(&(ModuleId::Xboxkrnl, 0xFFEF)));
}
#[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), "");
}
}