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>
2523 lines
116 KiB
Rust
2523 lines
116 KiB
Rust
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 0x40000000–0x50000000;
|
||
/// the image and read-only-data are 0x82000000–0x83000000. Allow both —
|
||
/// dispatcher objects in Sylpheed live in static-init pools (image rdata)
|
||
/// AND in heap-allocated singletons.
|
||
fn is_likely_guest_heap_ptr(p: u32) -> bool {
|
||
matches!(p, 0x4000_0000..=0x4FFF_FFFF | 0x8200_0000..=0x82FF_FFFF)
|
||
}
|
||
|
||
/// Image-pointer plausibility: vtables and RTTI structures live in the
|
||
/// module's read-only image, which on Xbox 360 maps at 0x82000000.
|
||
fn is_likely_image_ptr(p: u32) -> bool {
|
||
matches!(p, 0x8200_0000..=0x82FF_FFFF)
|
||
}
|
||
|
||
/// Read a NUL-terminated ASCII string from guest memory, capped at
|
||
/// `max` bytes. Returns the empty string on any non-printable byte
|
||
/// (a cheap signal that `addr` doesn't actually point at a name).
|
||
fn read_ascii_cstring(mem: &GuestMemory, addr: u32, max: usize) -> String {
|
||
let mut s = String::with_capacity(max);
|
||
for i in 0..max {
|
||
let b = mem.read_u8(addr.wrapping_add(i as u32));
|
||
if b == 0 {
|
||
return s;
|
||
}
|
||
if !(0x20..=0x7E).contains(&b) {
|
||
return String::new();
|
||
}
|
||
s.push(b as char);
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Walk the PPC EABI back-chain starting from `sp` (the value in r1 at
|
||
/// the moment of capture). Returns up to `max_frames` entries of
|
||
/// `(frame_pointer, saved_lr)`. Index 0 is the live frame
|
||
/// `(sp, live_lr)` — `live_lr` is the caller-supplied current LR, since
|
||
/// it has not yet been spilled to memory by this frame's prologue.
|
||
///
|
||
/// PPC convention reminder: a function's prologue stores the caller's
|
||
/// LR at `[old_sp - 8]` *before* bumping `r1` down to the new frame. So
|
||
/// from the live `sp`, `prev_sp = mem[sp]` and the LR saved in the
|
||
/// frame above is at `mem[prev_sp - 8]`. The walk stops on a
|
||
/// 0/0xFFFFFFFF/self-loop sentinel — those guard against
|
||
/// uninitialized stacks and the topmost frame.
|
||
///
|
||
/// This is read-only; it never mutates guest memory or CPU state.
|
||
pub fn walk_guest_back_chain(
|
||
sp: u32,
|
||
live_lr: u32,
|
||
mem: &GuestMemory,
|
||
max_frames: usize,
|
||
) -> Vec<(u32, u32)> {
|
||
let mut frames = Vec::with_capacity(max_frames);
|
||
if max_frames == 0 {
|
||
return frames;
|
||
}
|
||
frames.push((sp, live_lr));
|
||
let mut cur = sp;
|
||
while frames.len() < max_frames {
|
||
if cur == 0 || cur == 0xFFFF_FFFF {
|
||
break;
|
||
}
|
||
let prev = mem.read_u32(cur);
|
||
if prev == 0 || prev == 0xFFFF_FFFF || prev == cur {
|
||
break;
|
||
}
|
||
let saved_lr = mem.read_u32(prev.wrapping_sub(8));
|
||
frames.push((prev, saved_lr));
|
||
cur = prev;
|
||
}
|
||
frames
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use xenia_memory::GuestMemory;
|
||
|
||
/// Ten consecutive `heap_alloc(0x14)` calls must return distinct
|
||
/// page-aligned addresses. A previous bug had kernel exports passing 0 as
|
||
/// `size`, causing the bump allocator to return the same address every
|
||
/// time — 10 "allocations" that all aliased 0x40105000 and silently
|
||
/// corrupted the guest's static-constructor state.
|
||
#[test]
|
||
fn heap_alloc_advances_for_nonzero_size() {
|
||
let mut mem = GuestMemory::new().expect("memory init");
|
||
let mut state = KernelState::new();
|
||
let mut seen = Vec::new();
|
||
for _ in 0..10 {
|
||
let addr = state
|
||
.heap_alloc(0x14, &mut mem)
|
||
.expect("heap must have room for 0x14 bytes");
|
||
assert_eq!(addr & 0xFFF, 0, "heap returns page-aligned addresses");
|
||
assert!(!seen.contains(&addr), "heap returned duplicate address {addr:#x}");
|
||
seen.push(addr);
|
||
}
|
||
}
|
||
|
||
/// 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), "");
|
||
}
|
||
}
|