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::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>, /// Kernel object table: handle → object pub objects: HashMap, /// 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, /// 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, /// 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>, /// 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, /// 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, /// Bridge to the host UI. `None` when running headless. Installed by /// `cmd_exec` when the user passes `--ui`. pub ui: Option, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// M12 — optional file writer for `--lr-trace` output. `None` means /// stdout. pub lr_trace_writer: Option>, /// 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, /// `--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, /// 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>, /// 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 // `.tmp` writes produce). Opt-in to persistence via // `XENIA_CACHE_ROOT=` (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:/`. 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 { 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 { 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 { 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::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>, ) { 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 { 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 { 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 { 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 { 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=` — 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 { 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>> = 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), ""); } }