ITERATE-2.V: scheduler priority aging closes 18-day AUDIT-049 wedge

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-29 07:27:26 +02:00
parent e6d43a23ac
commit ad45873a1b
50 changed files with 14389 additions and 506 deletions

View File

@@ -242,6 +242,41 @@ enum Commands {
/// line). Stdout when omitted.
#[arg(long)]
lr_trace_out: Option<String>,
/// Phase A diff-harness — write schema-v1 JSONL events (kernel
/// calls, thread create/exit, handle create/destroy, waits) to
/// this path. Empty (default) = disabled, zero overhead.
/// Schema: `xenia-rs/audit-runs/phase-a-diff-harness/schema-v1.md`.
/// Settable via `XENIA_PHASE_A_EVENT_LOG`.
#[arg(long, value_name = "PATH")]
phase_a_event_log: Option<PathBuf>,
/// Phase B initial-state snapshot — write structured state
/// snapshot to `<dir>/ours/` at the moment immediately before
/// the first guest PPC instruction of the XEX entry_point.
/// Empty (default) = disabled, zero overhead. Settable via
/// `XENIA_PHASE_B_SNAPSHOT_DIR`. See
/// `xenia-rs/audit-runs/phase-b-state-equivalence/`.
#[arg(long, value_name = "DIR")]
phase_b_snapshot_dir: Option<PathBuf>,
/// Phase B: after writing the snapshot, exit the process
/// immediately (`_exit(0)`) so re-runs are byte-deterministic.
/// Settable via `XENIA_PHASE_B_SNAPSHOT_AND_EXIT`.
#[arg(long)]
phase_b_snapshot_and_exit: bool,
/// Phase B: in memory.json, populate `section_contents` with raw
/// bytes for every committed region. Default false. Settable via
/// `XENIA_PHASE_B_DUMP_SECTION_CONTENT`.
#[arg(long)]
phase_b_dump_section_content: bool,
/// review-a Step 1 diagnostic crowbar — when set, synthesises
/// the 4 `sub_825070F0` worker spawns once instruction_count
/// crosses the configured threshold (default 20M instr,
/// override via `XENIA_CROWBAR_TRIGGER_INSTR=N`). **NOT a
/// fix**: bypasses the natural-activation gate to test
/// whether the workers function correctly IF activated. Off
/// by default; settable via `XENIA_CROWBAR_WORKERS=1`. See
/// `xenia-rs/audit-runs/review-a-step1-crowbar/`.
#[arg(long)]
force_spawn_workers: bool,
},
/// Browse XISO disc image contents
Browse {
@@ -405,7 +440,45 @@ fn main() -> Result<()> {
probe_db,
lr_trace,
lr_trace_out,
} => cmd_exec(
phase_a_event_log,
phase_b_snapshot_dir,
phase_b_snapshot_and_exit,
phase_b_dump_section_content,
force_spawn_workers,
} => {
// review-a Step 1: CLI flag → env var so cmd_exec_inner's
// existing env-var-driven cvar wire-up picks it up. Avoids
// threading two more params through the (already long)
// cmd_exec / cmd_exec_inner signatures.
if force_spawn_workers {
// SAFETY: pre-thread-spawn process startup; no races.
unsafe { std::env::set_var("XENIA_CROWBAR_WORKERS", "1"); }
}
// Resolve the Phase A event-log path: CLI flag wins over env var.
// Empty/unset → emitter stays disabled (zero overhead).
let phase_a_path: Option<PathBuf> = phase_a_event_log
.or_else(|| std::env::var("XENIA_PHASE_A_EVENT_LOG").ok().map(PathBuf::from));
xenia_kernel::event_log::init(phase_a_path.as_deref());
// Resolve Phase B flags: CLI > env var. Empty/unset = disabled.
fn truthy(s: &str) -> bool {
let s = s.trim().to_ascii_lowercase();
s == "1" || s == "true" || s == "yes"
}
let phase_b_dir: Option<PathBuf> = phase_b_snapshot_dir
.or_else(|| std::env::var("XENIA_PHASE_B_SNAPSHOT_DIR").ok().map(PathBuf::from));
let phase_b_exit = phase_b_snapshot_and_exit
|| std::env::var("XENIA_PHASE_B_SNAPSHOT_AND_EXIT")
.ok()
.as_deref()
.map(truthy)
.unwrap_or(false);
let phase_b_dump = phase_b_dump_section_content
|| std::env::var("XENIA_PHASE_B_DUMP_SECTION_CONTENT")
.ok()
.as_deref()
.map(truthy)
.unwrap_or(false);
cmd_exec(
&path,
max_instructions,
ips_limit,
@@ -431,7 +504,11 @@ fn main() -> Result<()> {
probe_db.as_deref(),
lr_trace.as_deref(),
lr_trace_out.as_deref(),
),
phase_b_dir,
phase_b_exit,
phase_b_dump,
)
}
Commands::Browse { path } => cmd_browse(&path),
Commands::Info { path } => cmd_info(&path),
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()),
@@ -662,6 +739,9 @@ fn cmd_exec(
probe_db: Option<&str>,
lr_trace: Option<&str>,
lr_trace_out: Option<&str>,
phase_b_snapshot_dir: Option<PathBuf>,
phase_b_snapshot_and_exit: bool,
phase_b_dump_section_content: bool,
) -> Result<()> {
cmd_exec_inner(
path,
@@ -692,6 +772,9 @@ fn cmd_exec(
None,
None,
false,
phase_b_snapshot_dir,
phase_b_snapshot_and_exit,
phase_b_dump_section_content,
)
}
@@ -738,6 +821,9 @@ fn cmd_check(
out,
expect,
stable_digest,
None, // phase_b_snapshot_dir — never wanted on check goldens
false, // phase_b_snapshot_and_exit
false, // phase_b_dump_section_content
)
}
@@ -770,6 +856,9 @@ fn cmd_exec_inner(
digest_out: Option<&str>,
digest_expect: Option<&str>,
stable_digest: bool,
phase_b_snapshot_dir: Option<PathBuf>,
phase_b_snapshot_and_exit: bool,
phase_b_dump_section_content: bool,
) -> Result<()> {
let started = Instant::now();
let data = load_xex_data(path)?;
@@ -840,22 +929,121 @@ fn cmd_exec_inner(
info!(thunks = thunk_map.len(), "import thunks mapped");
// ── Phase 2: CPU initialization per xenia-canary ─────────────────────
// Allocate stack (1MB at 0x70000000)
//
// Stack VA = `0x70000000`, size honors `XEX_HEADER_DEFAULT_STACK_SIZE`
// (key `0x00020200`) when present, falling back to 1 MiB. The XEX
// header's stack-size value is rounded up to a 4 KiB page boundary
// before allocation. NOTE: guard pages are NOT yet allocated — that
// would require extending `xenia-memory` with a `NoAccess` protection
// flag and platform-layer page-decommit support, deferred to a future
// pass. Overflow into adjacent unmapped pages currently silently
// drops the write (per `GuestMemory::write_u32`'s `is_mapped` guard).
let stack_base = 0x7000_0000u32;
let stack_size = 0x10_0000u32;
let stack_size = {
let from_header = xenia_xex::loader::get_stack_size(&header);
let rounded = (from_header + 0xFFF) & !0xFFFu32;
rounded.max(0x1_0000) // never less than 64 KiB
};
mem.alloc(stack_base, stack_size, rw)
.map_err(|e| anyhow::anyhow!("Failed to allocate stack: {}", e))?;
// Allocate PCR (Processor Control Region) and TLS
// ── TLS region ────────────────────────────────────────────────────────
//
// Canary's `XEX_HEADER_TLS_INFO` (key `0x00020104`) describes the title's
// TLS template image (`raw_data_address` / `raw_data_size`) and the
// number of dynamic slots (`slot_count`, default 1024 per canary's
// `kDefaultTlsSlotCount` in `xthread.cc:335`). Layout in guest memory:
//
// [tls_extended_image (raw_data_size B) | tls_dynamic_slots (slot_count*4 B)]
//
// The PCR's `tls_ptr` (PCR+0) points at the START of the dynamic-slot
// area — i.e. the dynamic slots live IMMEDIATELY AFTER the image bytes.
// For ours we keep the historical fixed VA `0x7FFE_0000` but size the
// region from the parsed TLS info (clamped to at least 4 KiB). When
// the XEX has no TLS info, the block is a 4 KiB zero region — matching
// the pre-Phase-2 behavior.
let tls_info = xenia_xex::loader::get_tls_info(&header, &data);
let tls_raw_data_size = tls_info.map(|t| t.raw_data_size).unwrap_or(0);
let tls_slot_count = tls_info.map(|t| t.slot_count).unwrap_or(0).max(1024);
let tls_dynamic_bytes = tls_slot_count.saturating_mul(4);
let tls_total_bytes = {
let needed = tls_raw_data_size.saturating_add(tls_dynamic_bytes);
let rounded = (needed + 0xFFF) & !0xFFFu32;
rounded.max(0x1000) // never less than 4 KiB
};
let pcr_addr = 0x7FFF_0000u32;
let tls_addr = 0x7FFE_0000u32;
mem.alloc(pcr_addr, 0x1000, rw)?;
mem.alloc(tls_addr, 0x1000, rw)?;
mem.alloc(tls_addr, tls_total_bytes, rw)?;
// Initialize PCR structure
mem.write_u32(pcr_addr, tls_addr); // PCR->tls_ptr
mem.write_u32(pcr_addr + 0x100, 0x1000); // PCR->current_thread (fake)
mem.write_u32(pcr_addr + 0x150, 0); // PCR->dpc_active
// Copy the title's TLS template (initial-value image for `__declspec(thread)`
// variables) into the head of the TLS region. Canary mirrors this with
// `Memory::Copy(tls_static_address_, tls_header->raw_data_address,
// tls_header->raw_data_size)` (xthread.cc:357-360). When
// `raw_data_size` is zero (no TLS image), the region is left zeroed.
if let Some(info) = tls_info {
if info.raw_data_size > 0 && info.raw_data_address != 0 {
let mut buf = vec![0u8; info.raw_data_size as usize];
mem.read_bytes(info.raw_data_address, &mut buf);
mem.write_bulk(tls_addr, &buf);
}
}
// ── Guest X_KTHREAD struct ────────────────────────────────────────────
//
// Canary stores a real `X_KTHREAD` in guest memory (`xthread.h:260-`),
// and PCR `[+0x100]` (= `prcb_data.current_thread`) holds its VA. Ours
// previously wrote the bare host-side handle `0x1000` there, so any
// guest pointer-walk through `r13[+0x100]` read garbage. Allocate a
// 0x100-byte zero block at a fixed VA `0x7FFD_0000` (just below the
// TLS region, in unused address space) and populate the minimum
// credible fields:
//
// +0x00 X_DISPATCH_HEADER:
// [+0x00] u8 type = 6 (ThreadObject)
// [+0x04] u32 signal_state = 0
// [+0x08] X_LIST_ENTRY wait_list { flink, blink } — both self-pointers
// +0x5C u32 stack_base (high addr)
// +0x60 u32 stack_limit (low addr)
// +0x68 u32 tls_address
//
// The dispatcher-header `type` byte for ThreadObject is `0x06` in the
// Vista/Xbox 360 kernel (matches DISPATCHER_HEADER reference at
// `xenia-canary/src/xenia/kernel/xobject.h:37-62`); setting it non-zero
// is what prevents the worst null-deref class on KTHREAD pointer walks.
let kthread_addr = 0x7FFD_0000u32;
let kthread_size = 0x1000u32;
mem.alloc(kthread_addr, kthread_size, rw)
.map_err(|e| anyhow::anyhow!("Failed to allocate X_KTHREAD region: {}", e))?;
// X_DISPATCH_HEADER
mem.write_u8(kthread_addr, 0x06); // type = ThreadObject
mem.write_u32(kthread_addr + 0x04, 0); // signal_state
mem.write_u32(kthread_addr + 0x08, kthread_addr + 0x08); // wait_list.flink (self)
mem.write_u32(kthread_addr + 0x0C, kthread_addr + 0x08); // wait_list.blink (self)
// Stack/TLS pointers (canary X_KTHREAD layout, xthread.h:267-270).
mem.write_u32(kthread_addr + 0x5C, stack_base + stack_size); // stack_base = high addr
mem.write_u32(kthread_addr + 0x60, stack_base); // stack_limit = low addr
mem.write_u32(kthread_addr + 0x68, tls_addr); // tls_address
// ── PCR initialization ────────────────────────────────────────────────
//
// Canary `X_KPCR` layout (xthread.h:171-223). Fields ours now populates:
// +0x000 tls_ptr — base of dynamic TLS slots
// +0x030 pcr_ptr u64 BE — self-reference (PCR base)
// +0x070 stack_base_ptr — top of stack (high addr)
// +0x074 stack_end_ptr — bottom of stack (low addr)
// +0x100 prcb_data.current_thread — VA of the guest X_KTHREAD
// +0x150 prcb_data.dpc_active — 0
// +0x2A8 prcb — pointer back to prcb_data (= pcr+0x100)
// (Skipped: +0x038 `host_stash` — host pointer slot, not applicable to ours.)
mem.write_u32(pcr_addr, tls_addr); // tls_ptr
mem.write_u64(pcr_addr + 0x030, pcr_addr as u64); // pcr_ptr (self-ref, BE u64)
mem.write_u32(pcr_addr + 0x070, stack_base + stack_size); // stack_base_ptr (high)
mem.write_u32(pcr_addr + 0x074, stack_base); // stack_end_ptr (low)
mem.write_u32(pcr_addr + 0x100, kthread_addr); // prcb_data.current_thread
mem.write_u32(pcr_addr + 0x150, 0); // prcb_data.dpc_active
mem.write_u32(pcr_addr + 0x2A8, pcr_addr + 0x100); // prcb -> prcb_data
// Set up CPU context per xenia-canary/cpu/thread_state.cc.
//
@@ -925,6 +1113,13 @@ fn cmd_exec_inner(
let mut kernel = xenia_kernel::KernelState::with_gpu(gpu_backend);
kernel.image_base = base;
kernel.xex_system_flags = xenia_xex::loader::get_system_flags(&header);
// Phase B — install the entry-PC for the snapshot hook's identity
// check, plus the cvar-equivalent flags resolved by the caller. When
// `phase_b_snapshot_dir` is `None`, the hook short-circuits.
kernel.entry_pc = entry;
kernel.phase_b_snapshot_dir = phase_b_snapshot_dir.clone();
kernel.phase_b_snapshot_and_exit = phase_b_snapshot_and_exit;
kernel.phase_b_dump_section_content = phase_b_dump_section_content;
// Drain the reverse thunk map into the kernel so `XexGetProcedureAddress`
// can resolve ordinals back to callable thunk addresses.
for (module, ordinal, addr) in thunk_addr_map.drain(..) {
@@ -948,6 +1143,38 @@ fn cmd_exec_inner(
});
let parallel_active = parallel || parallel_via_env;
kernel.parallel_active = parallel_active;
// Phase D Stage 3 — install a contention-replay manifest if pointed
// to via `XENIA_CONTENTION_MANIFEST_PATH`. The manifest is built by
// Stage 2's python tool from a Stage-1 cvar-ON canary trace. Unset
// = default mode (no replay, identical to pre-Stage-3 behavior).
// Errors are non-fatal (log + continue without replay) so a stale
// path doesn't brick the run.
if let Ok(path) = std::env::var("XENIA_CONTENTION_MANIFEST_PATH") {
let trimmed = path.trim();
if !trimmed.is_empty() {
let p = std::path::PathBuf::from(trimmed);
match xenia_kernel::contention_manifest::ContentionManifest::load_from_file(&p) {
Ok(m) => {
let count = m.initial_count();
let arc = std::sync::Arc::new(m);
kernel.install_contention_manifest(Some(arc));
tracing::info!(
"Phase D Stage 3: loaded contention manifest from {:?} ({} entries)",
p,
count
);
}
Err(e) => {
tracing::warn!(
"Phase D Stage 3: failed to load contention manifest from {:?}: {} — replay disabled",
p,
e
);
}
}
}
}
// AUDIT-032: default is `KernelState::xaudio_tick_enabled = true` now
// that the dedicated worker eliminates HW-thread hijack regressions.
// Treat `--xaudio-tick` / `XENIA_XAUDIO_TICK=...` as an explicit
@@ -979,6 +1206,38 @@ fn cmd_exec_inner(
"XAudio callback ticker enabled (AUDIT-032 default; toggle via --xaudio-tick / XENIA_XAUDIO_TICK)"
);
}
// review-a Step 1 — `--force-spawn-workers` / `XENIA_CROWBAR_WORKERS=1`.
// Diagnostic-only, default-OFF. See
// `xenia-rs/audit-runs/review-a-step1-crowbar/`.
let crowbar_env_on = std::env::var("XENIA_CROWBAR_WORKERS")
.ok()
.as_deref()
.map(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
})
.unwrap_or(false);
if crowbar_env_on {
kernel.crowbar_workers_enabled = true;
}
if let Ok(v) = std::env::var("XENIA_CROWBAR_TRIGGER_INSTR") {
if let Ok(n) = v.trim().parse::<u64>() {
kernel.crowbar_workers_trigger_instr = n;
} else {
tracing::warn!(
"XENIA_CROWBAR_TRIGGER_INSTR={:?} — failed to parse as u64; keeping default {}",
v,
kernel.crowbar_workers_trigger_instr,
);
}
}
if kernel.crowbar_workers_enabled && !quiet {
tracing::warn!(
"review-a CROWBAR enabled: will force-spawn 4 sub_825070F0 workers \
at instr={} (NOT a fix — diagnostic only)",
kernel.crowbar_workers_trigger_instr,
);
}
if reservations_table || reservations_via_env || parallel_active {
kernel.reservations.enable();
if !quiet {
@@ -1305,14 +1564,47 @@ fn cmd_exec_inner(
main_handle,
&mut mem,
);
// Phase C+16: main thread self-reference. Mirrors canary's
// `KernelState::LaunchModule` → `thread->Create()` → `RetainHandle()`
// at xthread.cc:414 (the "main XThread" also goes through Create()).
// Released at LR-sentinel implicit-exit in the prologue/epilogue
// path. Without this, ours's main refcount=1 (creator only) vs
// canary's 2 (creator + self) — masked at present because the guest
// never calls `NtClose` on the main-thread handle, but kept symmetric
// to avoid asymmetric `handle.destroy` on shutdown.
kernel.retain_handle(main_handle);
// If the input was a disc image, mount it so the kernel's file I/O
// handlers can serve the game's own assets via VFS.
if path.to_lowercase().ends_with(".iso") || path.to_lowercase().ends_with(".xiso") {
match xenia_vfs::disc_image::DiscImageDevice::open("d", std::path::Path::new(path)) {
// Mount the title's content into the VFS so the kernel's file I/O
// handlers (`NtCreateFile`, `NtOpenFile`, etc.) can serve game-data
// reads. Canary always mounts `game:` + `d:` regardless of input
// format (xenia_main.cc:611-651); ours's path normalisation already
// strips both prefixes to a single bucket (see
// `crate::path::DEVICE_PREFIXES` in xenia-kernel and `is_disc_prefix`
// in exports.rs:1725), so a single backing `VfsDevice` covers both.
//
// Mount logic:
// - `.iso` / `.xiso` → `DiscImageDevice`
// - directory → `HostPathDevice` rooted at the directory
// - bare .xex file → `HostPathDevice` rooted at the file's parent
// - STFS / CON / PIRS containers — NOT YET (no reader in ours;
// would be 500+ LOC. Deferred to a future pass.)
let input_path = std::path::Path::new(path);
let lower = path.to_lowercase();
if lower.ends_with(".iso") || lower.ends_with(".xiso") {
match xenia_vfs::disc_image::DiscImageDevice::open("d", input_path) {
Ok(disc) => kernel.vfs = Some(Box::new(disc)),
Err(e) => tracing::warn!("Could not mount disc image for VFS: {}", e),
}
} else if input_path.is_dir() {
tracing::info!("VFS: mounting host directory {:?} as game:/d:", input_path);
kernel.vfs = Some(Box::new(xenia_vfs::device::HostPathDevice::new("game", input_path)));
} else if let Some(parent) = input_path.parent() {
// Bare XEX file — mount its containing directory so the title can
// reach sibling assets via `game:\<name>`.
if !parent.as_os_str().is_empty() {
tracing::info!("VFS: mounting XEX parent directory {:?} as game:/d:", parent);
kernel.vfs = Some(Box::new(xenia_vfs::device::HostPathDevice::new("game", parent)));
}
}
// ── Phase 3: Data export patching (variable imports) ─────────────────
@@ -1324,14 +1616,80 @@ fn cmd_exec_inner(
kernel.heap_alloc(size, mem).unwrap_or(0)
};
// Helper: allocate a 0x1C-byte X_OBJECT_TYPE descriptor with the
// four-char-code `pool_tag` at +0x18 (BE-readable) and write its
// guest VA into the import slot. Mirrors canary's
// `InitializeKernelGuestGlobals` populating per-type descriptors at
// `kernel_state.cc:1538-1615` — the type-tag bytes are non-zero
// there, so any guest code that reads the tag-byte field gets the
// real FourCC instead of zero.
let write_object_type =
|addr: u32, pool_tag: u32, mem: &xenia_memory::GuestMemory, kernel: &mut xenia_kernel::KernelState| {
let block = kernel.heap_alloc(0x1C, mem).unwrap_or(0);
if block != 0 {
mem.write_u32(block + 0x18, pool_tag);
}
mem.write_u32(addr, block);
};
for lib in &header.import_libraries {
for imp in &lib.imports {
if imp.record_type != 0 { continue; } // Only variable entries
let addr = imp.address;
match (lib.name.as_str(), imp.ordinal) {
// ──── KernelGuestGlobals object-type descriptors ────
// 0x1C-byte `X_OBJECT_TYPE` blocks with pool-tag FourCC at
// +0x18. Canary populates these via
// `InitializeKernelGuestGlobals` (`kernel_state.cc:1511+`);
// ours previously left every descriptor as a zero block, so
// any guest comparison against the tag-byte signature
// diverged. Tags are stored as host-order u32s whose BE
// byte-form spells the four-char code; `write_u32` BE-encodes
// automatically (see `GuestMemory::write_u32`).
("xboxkrnl.exe", 0x000E) => {
// ExEventObjectType — pool_tag "EvEv"
write_object_type(addr, 0x76657645, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x0012) => {
// ExMutantObjectType — pool_tag "Mutu"
write_object_type(addr, 0x6174754D, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x0017) => {
// ExSemaphoreObjectType — pool_tag "Sema"
write_object_type(addr, 0x616D6553, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x001B) => {
// ExThreadObjectType — ptr to OBJECT_TYPE descriptor (0x40 bytes)
let block = alloc_zero(0x40, &mut mem, &mut kernel);
// ExThreadObjectType — pool_tag "Thre"
write_object_type(addr, 0x65726854, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x001C) => {
// ExTimerObjectType — pool_tag "Time"
write_object_type(addr, 0x656D6954, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x0036) => {
// IoCompletionObjectType — pool_tag "Comp"
write_object_type(addr, 0x706D6F43, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x003A) => {
// IoDeviceObjectType — pool_tag "Devi"
write_object_type(addr, 0x69766544, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x003E) => {
// IoFileObjectType — pool_tag "File"
write_object_type(addr, 0x656C6946, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x0106) => {
// ObDirectoryObjectType — pool_tag "Dire"
write_object_type(addr, 0x65726944, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x0112) => {
// ObSymbolicLinkObjectType — pool_tag "Symb"
write_object_type(addr, 0x626D7953, &mut mem, &mut kernel);
}
("xboxkrnl.exe", 0x02DB) => {
// UsbdBootEnumerationDoneEvent — 0x10-byte X_KEVENT block,
// zero-initialised (signalled=false, type=NotificationEvent).
let block = alloc_zero(0x10, &mut mem, &mut kernel);
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x0059) => {
@@ -1340,16 +1698,51 @@ fn cmd_exec_inner(
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x00AD) => {
// KeTimeStampBundle — 0x18 block with FILETIME at +0 and
// interrupt-time u64 at +0x10. Mirrors the clock used by
// KeQuerySystemTime so fast-path readers see consistent values.
// KeTimeStampBundle — 0x18-byte `X_TIME_STAMP_BUNDLE`
// matching canary's `kernel_state.h:98-104`:
// +0x00 u64 interrupt_time (BE, 100-ns ticks since boot)
// +0x08 u64 system_time (BE, 100-ns Windows FILETIME)
// +0x10 u32 tick_count (BE, monotonic ms since boot)
// +0x14 u32 padding
// Stash the VA in `KernelState` so the 1 ms host-side
// repeating updater spawned later in this file can refresh
// the fields — without that updater, polling loops that
// wait on `tick_count` to advance hang forever (the
// previous "FILETIME at +0 and +0x10" layout never wrote
// +0x08 at all and never advanced).
let block = alloc_zero(0x18, &mut mem, &mut kernel);
if block != 0 {
let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME
mem.write_u32(block, (fake_time >> 32) as u32);
mem.write_u32(block + 4, fake_time as u32);
mem.write_u32(block + 0x10, (fake_time >> 32) as u32);
mem.write_u32(block + 0x14, fake_time as u32);
// Match ours's existing fixed `KeQueryInterruptTime`
// / `KeQuerySystemTime` constants for the initial
// sample — the timer thread will overwrite within
// ~1 ms, so these values are seen only briefly.
mem.write_u64(block, 0x0000_0001_0000_0000); // interrupt_time
mem.write_u64(block + 0x08, 132_500_000_000_000_000); // system_time
mem.write_u32(block + 0x10, 0); // tick_count
mem.write_u32(block + 0x14, 0); // padding
kernel.ke_timestamp_bundle_ptr = block;
}
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x000C) => {
// ExConsoleGameRegion — 4-byte u32 = 0xFFFFFFFF (region-free).
// Canary writes this at `xboxkrnl_module.cc:144-150`.
let block = alloc_zero(4, &mut mem, &mut kernel);
if block != 0 {
mem.write_u32(block, 0xFFFF_FFFF);
}
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x0156) => {
// XboxHardwareInfo — 16-byte block. Canary
// (`xboxkrnl_module.cc:125-142`) writes `[0]=0x20`
// (HDD-present flag, bit 5) and `[4]=0x06` (CPU count),
// rest zero. Games branch on these for storage- and
// SMP-aware code paths.
let block = alloc_zero(16, &mut mem, &mut kernel);
if block != 0 {
mem.write_u8(block, 0x20);
mem.write_u8(block + 4, 0x06);
}
mem.write_u32(addr, block);
}
@@ -1361,13 +1754,59 @@ fn cmd_exec_inner(
mem.write_u16(addr + 6, 0);
}
("xboxkrnl.exe", 0x0193) => {
// XexExecutableModuleHandle -> image base
// XexExecutableModuleHandle: keep the pre-existing
// `*XexExecutableModuleHandle = base` write (the
// game's CRT branches off this slot's value; an
// attempt to wire up a proper LDR_DATA_TABLE_ENTRY +
// xex_header_base chain at idx=0 short-circuits the
// CRT's early RtlImageXexHeaderField probe, causing
// Phase A to diverge at idx=0 instead of growing past
// 102014 — see Phase C+3 investigation.md). Instead,
// allocate a guest-memory copy of the raw XEX header
// bytes (mirrors canary `user_module.cc:223-227`'s
// `guest_xex_header_`), record its VA in KernelState
// for `rtl_image_xex_header_field` to use as a
// fallback when the game passes a NULL `xex_header`
// arg (which it does here because the LDR walk
// through `base` yields PE OptionalHeader bytes, not
// a real header pointer).
let header_size = header.header_size as usize;
if header_size > 0 && header_size <= data.len() {
let xex_va = alloc_zero(header.header_size, &mut mem, &mut kernel);
if xex_va != 0 {
mem.write_bulk(xex_va, &data[0..header_size]);
kernel.xex_header_guest_ptr = xex_va;
}
}
mem.write_u32(addr, base);
}
("xboxkrnl.exe", 0x01AE) => {
// ExLoadedCommandLine — ANSI empty string
let block = alloc_zero(0x10, &mut mem, &mut kernel);
// Block is already zero-initialized by heap_alloc -> empty string.
// ExLoadedCommandLine — 1024-byte ANSI buffer.
// Canary's default-init path (`xboxkrnl_module.cc:176-194`)
// writes the quoted form `"default.xex"` (with literal
// ASCII double-quotes) as a placeholder until post-launch
// replacement. An empty zero-block silently violates the
// CRT contract (any title that scans for the quote
// characters sees end-of-string immediately).
let block = alloc_zero(1024, &mut mem, &mut kernel);
if block != 0 {
let cmdline: &[u8] = b"\"default.xex\"\0";
mem.write_bulk(block, cmdline);
}
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x01AF) => {
// ExLoadedImageName — 256-byte ANSI buffer. Canary
// (`xboxkrnl_module.cc:166-174`,
// `kernel_state.cc:486-495`) post-launch fills this with
// the executable module path; for ours we write
// "default.xex\0" to match canary's pre-launch state.
// Size matches canary's `kExLoadedImageNameSize = 256`.
let block = alloc_zero(256, &mut mem, &mut kernel);
if block != 0 {
let imgname: &[u8] = b"default.xex\0";
mem.write_bulk(block, imgname);
}
mem.write_u32(addr, block);
}
("xboxkrnl.exe", 0x01BE) => {
@@ -1501,6 +1940,44 @@ fn cmd_exec_inner(
// responsibility per the trait contract.)
let mem_arc = std::sync::Arc::new(mem);
// ── KeTimeStampBundle 1 ms repeating updater ──
//
// Canary maintains the bundle's `interrupt_time` / `system_time` /
// `tick_count` fields via `HighResolutionTimer::CreateRepeating(1 ms,
// UpdateKeTimestampBundle)` registered at `kernel_state.cc:1272-1295`.
// Without an equivalent host-side ticker, the bundle stays frozen at its
// boot-time values and guest polling loops that wait on `tick_count` to
// advance hang forever. Spawn a detached thread that wakes every 1 ms,
// recomputes the three fields from a monotonic `Instant`, and writes
// them BE through the shared `Arc<GuestMemory>`. Cooperative shutdown
// via the existing `shutdown_arc` flag — flipped when the dispatch
// returns — so test runs don't leak threads. No-op if the patcher
// didn't allocate a bundle (the XEX never imported ord 0x00AD).
{
let ke_bundle_ptr = kernel.ke_timestamp_bundle_ptr;
if ke_bundle_ptr != 0 {
let mem_for_timer = mem_arc.clone();
let shutdown_for_timer = shutdown_arc.clone();
std::thread::Builder::new()
.name("ke-timestamp-bundle".to_string())
.spawn(move || {
use xenia_memory::MemoryAccess;
let start = std::time::Instant::now();
const SYSTEM_TIME_EPOCH: u64 = 132_500_000_000_000_000;
while !shutdown_for_timer.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(1));
let elapsed = start.elapsed();
let ms = elapsed.as_millis() as u32;
let ticks_100ns = (elapsed.as_micros() as u64) * 10;
mem_for_timer.write_u64(ke_bundle_ptr, ticks_100ns);
mem_for_timer.write_u64(ke_bundle_ptr + 0x08, SYSTEM_TIME_EPOCH + ticks_100ns);
mem_for_timer.write_u32(ke_bundle_ptr + 0x10, ms);
}
})
.expect("spawn ke-timestamp-bundle thread");
}
}
// Spawn the real GPU worker if the threaded backend was chosen at
// kernel-construction time. The handle the kernel already holds
// (`GpuBackend::Threaded`) is the CPU-side proxy; the worker owns
@@ -1679,6 +2156,11 @@ fn cmd_exec_inner(
}
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, &*mem_arc, quiet);
// Iterate 2.M — always-on (reading-error #42). Emits a
// sibling JSON next to the Phase-A trace; runs regardless
// of --quiet so future "is the wedge moved?" questions
// never depend on a manual non-quiet re-run.
write_thread_state_dump(&kernel);
info!(
wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count,
@@ -1896,6 +2378,7 @@ enum RoundCtl {
/// asks for shutdown.
fn coord_pre_round(
kernel: &mut xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
stats: &ExecStats,
max_instructions: Option<u64>,
ips_limit: Option<u64>,
@@ -1995,6 +2478,12 @@ fn coord_pre_round(
try_inject_audio_callback(kernel);
}
// review-a Step 1 — one-shot diagnostic crowbar. No-op when disabled
// or already fired. Uses the caller's `&GuestMemory` directly.
if kernel.crowbar_workers_enabled && !kernel.crowbar_workers_fired {
kernel.try_fire_crowbar_workers(mem, stats.instruction_count);
}
RoundCtl::Continue
}
@@ -2211,6 +2700,21 @@ fn worker_prologue(
let pc = kernel.scheduler.ctx(hw_id).pc;
// Phase B snapshot — no-op when `phase_b_snapshot_dir == None`
// (zero-cost Option-tag test on the hot path). Fires once on the
// entry thread at the moment immediately before its first guest
// instruction at entry_pc executes. See
// crates/xenia-kernel/src/phase_b_snapshot.rs.
if kernel.phase_b_snapshot_dir.is_some() {
let current_tid = kernel.scheduler.tid(hw_id).unwrap_or(0);
xenia_kernel::phase_b_snapshot::fire_if_entry_thread(
kernel,
mem,
pc,
current_tid,
);
}
// 0) Diagnostic ctor-probe: if `pc` is in
// `kernel.ctor_probe_pcs`, capture live r3/lr/sp + back-chain
// and println one record. Read-only; lockstep digest unaffected.
@@ -2267,19 +2771,41 @@ fn worker_prologue(
cycle = stats.instruction_count,
"HW thread returned to LR sentinel — marking exited"
);
// Phase C+15-α: schema-v1 `thread.exit` event on the implicit
// (LR-sentinel) thread-exit path. Symmetric with
// `ex_terminate_thread`; canary's `XThread::Execute` ends in
// `Exit()` which emits the same event regardless of whether the
// guest called `ExTerminateThread` or simply returned.
if let (Some(t), true) = (tid, xenia_kernel::event_log::is_enabled()) {
let cycle = kernel.scheduler.ctx(hw_id).timebase;
xenia_kernel::event_log::emit_thread_exit(t, cycle, 0);
}
let (_, _exited_tid, handle_opt) = kernel.scheduler.exit_current(0);
if let Some(h) = handle_opt
&& let Some(xenia_kernel::objects::KernelObject::Thread {
if let Some(h) = handle_opt {
if let Some(xenia_kernel::objects::KernelObject::Thread {
exit_code,
waiters,
..
}) = kernel.objects.get_mut(&h)
{
*exit_code = Some(0);
let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters);
for w in to_wake {
kernel.scheduler.wake_ref(w);
{
*exit_code = Some(0);
let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters);
for w in to_wake {
kernel.scheduler.wake_ref(w);
}
}
// Phase C+16: release the thread self-reference (paired with
// the retain installed at spawn time by `ex_create_thread` /
// `xam_task_schedule`). On the main thread (INITIAL_GUEST_TID)
// no retain was installed by `install_initial_thread`, so the
// refcount stays at 1 (creator-only). Pre-condition: a
// self-retained thread has refcount ≥ 2 at this point; an
// un-retained thread (main) has refcount = 1. We unconditionally
// call `release_handle` — for main, this destroys it (which is
// fine; main is exiting). For workers, this drops the
// self-ref; if guest still holds a ref (no NtClose yet) the
// object survives; if guest already closed, this destroys.
kernel.release_handle(h);
}
return PrologueOutcome::Continue;
}
@@ -2329,10 +2855,32 @@ fn worker_prologue(
// 3) Unmapped PC.
if !mem.is_mapped(pc) {
// Crowbar v2 — enrich fault with tid/lr/r3 so we can attribute the
// fault back to a specific guest thread. Read-only, no behaviour
// change. The kernel lock is held by the caller per
// run_execution's invariant; tid/ctx lookups are safe.
let tid = kernel.scheduler.tid(hw_id);
let r = kernel.scheduler.current_ref();
let t = kernel.scheduler.thread(r);
let lr = t.ctx.lr;
let r3 = t.ctx.gpr[3];
let r4 = t.ctx.gpr[4];
let r29 = t.ctx.gpr[29];
let r30 = t.ctx.gpr[30];
let r31 = t.ctx.gpr[31];
let ctr = t.ctx.ctr;
tracing::error!(
cycle = stats.instruction_count,
pc = format_args!("{:#010x}", pc),
hw_id,
tid = ?tid,
lr = format_args!("{:#010x}", lr),
ctr = format_args!("{:#010x}", ctr),
r3 = format_args!("{:#010x}", r3),
r4 = format_args!("{:#010x}", r4),
r29 = format_args!("{:#010x}", r29),
r30 = format_args!("{:#010x}", r30),
r31 = format_args!("{:#010x}", r31),
"FAULT: PC in unmapped memory"
);
return PrologueOutcome::BreakOuter;
@@ -2603,6 +3151,7 @@ fn run_execution(
// without duplicating it from the lockstep path.
match coord_pre_round(
kernel,
mem,
&stats,
max_instructions,
ips_limit,
@@ -3005,6 +3554,7 @@ fn run_execution_parallel(
let s = stats_mtx.lock().expect("stats mutex poisoned");
coord_pre_round(
&mut *guard,
mem,
&*s,
max_instructions,
ips_limit,
@@ -3907,6 +4457,131 @@ fn dump_thread_diagnostic(
}
}
/// Iterate 2.M — always-on structured exit-state dump (reading-error #42).
///
/// Phase-A's JSONL trace is blind to blocked-forever waits: a wait that
/// never returns emits no `kernel.return` event, so a wedge looks identical
/// to a clean termination. Iterate 2.J misclassified the wedge that way and
/// cost iterate 2.K a re-dispatch to recover. This dumps a machine-readable
/// snapshot of every alive thread + the handle/wedge map at exit time,
/// regardless of `--quiet`, so every future iterate has the wedge in hand
/// alongside the JSONL trace without needing a manual diagnostic re-run.
///
/// Output: `<phase-A-trace-dir>/exit-thread-state.json` when Phase-A is
/// enabled; `./exit-thread-state.json` (CWD) otherwise. Filename is
/// predictable — the harness can `glob('**/exit-thread-state.json')`.
fn write_thread_state_dump(kernel: &xenia_kernel::KernelState) {
use serde_json::{json, Value};
use xenia_kernel::objects::KernelObject;
let dump_path: std::path::PathBuf = xenia_kernel::event_log::output_path()
.and_then(|p| p.parent().map(|d| d.join("exit-thread-state.json")))
.unwrap_or_else(|| std::path::PathBuf::from("exit-thread-state.json"));
let tid_of = |r: &xenia_cpu::ThreadRef| -> u32 {
kernel.scheduler.slots.get(r.hw_id as usize)
.and_then(|s| s.runqueue.get(r.idx as usize)).map(|t| t.tid).unwrap_or(0)
};
// Returns (type_name, signaler_tid_if_known, full json payload).
let handle_meta = |h: u32| -> (&'static str, Option<u32>, Value) {
let waiters_v = |w: &Vec<xenia_cpu::ThreadRef>| -> Value {
json!(w.iter().map(&tid_of).collect::<Vec<_>>())
};
match kernel.objects.get(&h) {
Some(KernelObject::Event { signaled, manual_reset, waiters }) => ("Event", None,
json!({"type":"Event","signaled":signaled,"manual_reset":manual_reset,"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::Semaphore { count, max, waiters }) => ("Semaphore", None,
json!({"type":"Semaphore","count":count,"max":max,"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::Thread { id, exit_code, waiters, .. }) => ("Thread", Some(*id),
json!({"type":"Thread","thread_id":id,"exited":exit_code.is_some(),"exit_code":exit_code,"signaler_tid_if_known":id,"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::Timer { signaled, deadline, waiters, .. }) => ("Timer", None,
json!({"type":"Timer","signaled":signaled,"deadline":deadline,"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::Mutex { owner, recursion, waiters }) => ("Mutex", None,
json!({"type":"Mutex","owner_hw":owner,"recursion":recursion,"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::NotifyListener { mask, queue, waiters, .. }) => ("NotifyListener", None,
json!({"type":"NotifyListener","mask":format!("{:#x}",mask),"pending":queue.len(),"waiters_tid":waiters_v(waiters)})),
Some(KernelObject::File { path, .. }) => ("File", None, json!({"type":"File","path":path})),
None => ("unknown", None, json!({"type":"unknown_or_dropped"})),
}
};
let mut alive: Vec<Value> = Vec::new();
let mut wedge_map: Vec<Value> = Vec::new();
for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() {
for (idx, t) in slot.runqueue.iter().enumerate() {
let (state_name, block_payload): (&'static str, Value) = match &t.state {
xenia_cpu::HwState::Idle => ("Idle", Value::Null),
xenia_cpu::HwState::Ready => ("Ready", Value::Null),
xenia_cpu::HwState::Exited(code) => ("Exited", json!({"exit_code":code})),
xenia_cpu::HwState::ServicingIrq(_) => ("ServicingIrq", Value::Null),
xenia_cpu::HwState::Blocked(reason) => {
let body = match reason {
xenia_cpu::BlockReason::Suspended => json!({"kind":"Suspended"}),
xenia_cpu::BlockReason::DelayUntil(d) => json!({"kind":"DelayUntil","deadline_ns":d}),
xenia_cpu::BlockReason::CriticalSection(cs) =>
json!({"kind":"CriticalSection","cs_ptr":format!("{:#010x}",cs)}),
xenia_cpu::BlockReason::WaitAny { handles, deadline }
| xenia_cpu::BlockReason::WaitAll { handles, deadline } => {
let kind = if matches!(reason, xenia_cpu::BlockReason::WaitAny{..}) {"WaitAny"} else {"WaitAll"};
let hs: Vec<Value> = handles.iter().map(|h| {
let (ty, sig_tid, meta) = handle_meta(*h);
// Wedge-map: surface every blocked-forever
// wait (deadline==None) with a one-line
// human summary + structured cross-ref so
// future iterates can diff vs canary.
if deadline.is_none() {
let summary = match ty {
"Thread" => format!("tid={} → Thread(id={})", t.tid, sig_tid.unwrap_or(0)),
"Event" => format!("tid={} → Event(sig={})", t.tid,
meta.get("signaled").and_then(|v|v.as_bool()).unwrap_or(false)),
"Semaphore" => format!("tid={} → Semaphore({}/{})", t.tid,
meta.get("count").and_then(|v|v.as_i64()).unwrap_or(0),
meta.get("max").and_then(|v|v.as_i64()).unwrap_or(0)),
_ => format!("tid={} → handle {:#010x} ({})", t.tid, h, ty),
};
wedge_map.push(json!({
"waiter_tid": t.tid,
"waiter_pc": format!("{:#010x}", t.ctx.pc),
"handle": format!("{:#010x}", h),
"handle_type": ty,
"signaler_tid_if_known": sig_tid,
"summary": summary,
}));
}
json!({"handle":format!("{:#010x}",h),"object":meta})
}).collect();
json!({"kind":kind,"handles":hs,"deadline_ns_or_inf":deadline})
}
};
("Blocked", body)
}
};
alive.push(json!({
"tid": t.tid, "hw_id": hw_id, "idx": idx,
"pc": format!("{:#010x}", t.ctx.pc),
"lr": format!("{:#010x}", t.ctx.lr as u32),
"sp": format!("{:#010x}", t.ctx.gpr[1] as u32),
"priority": t.priority,
"affinity_mask": format!("{:#04x}", t.affinity_mask),
"suspend_count": t.suspend_count,
"state": state_name,
"block_reason": block_payload,
}));
}
}
let dump = json!({
"schema_version": 1, "produced_by": "ours", "reason": "exit_dump",
"alive_threads": alive, "wedge_map": wedge_map,
});
match serde_json::to_string_pretty(&dump) {
Ok(s) => match std::fs::write(&dump_path, s) {
Ok(()) => eprintln!(
"exit-thread-state: wrote {} thread(s), {} wedge entr(ies) to {}",
alive.len(), wedge_map.len(), dump_path.display(),
),
Err(e) => eprintln!("exit-thread-state: failed to write {}: {e}", dump_path.display()),
},
Err(e) => eprintln!("exit-thread-state: failed to serialize: {e}"),
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(skip_all, fields(title))]
fn run_with_ui(
@@ -3974,6 +4649,8 @@ fn run_with_ui(
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, &mem, quiet);
// Iterate 2.M — see cmd_exec_inner path for rationale.
write_thread_state_dump(&kernel);
info!(
wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count,