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

@@ -0,0 +1,342 @@
//! Phase D Stage 3 — contention-replay manifest loader.
//!
//! Loads a `contention_manifest.json` produced by Stage 2's python
//! builder (`xenia-rs/tools/diff-events/build_contention_manifest.py`)
//! and exposes a `(tid, tid_event_idx) → Entry` lookup for
//! `rtl_enter_critical_section` to consult.
//!
//! The manifest tells ours: "canary observed real contention on this
//! `cs_ptr` at this `(tid, tid_event_idx)`." Ours's
//! `rtl_enter_critical_section` reads the next per-tid ordinal that
//! its `contention.observed` emit would consume and asks the manifest
//! whether to force a park. The Stage 3 forced-park is gated on the CS
//! actually having a live different-tid owner in guest memory at the
//! moment — without that, forced-park would deadlock (the plan's
//! "skip when free" branch).
//!
//! Lookup is O(1) via a `HashMap<(tid, idx), Entry>` behind a `Mutex`.
//! Single-host-thread scheduler means contention on the mutex is
//! minimal. `consume()` removes the entry on hit, so a single
//! (tid, idx) cannot re-fire — guards against any future re-entry of
//! `rtl_enter_critical_section` for the same ordinal.
use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufReader};
use std::path::Path;
use std::sync::Mutex;
/// One row of the manifest, post-deserialize.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub tid: u32,
pub tid_event_idx: u64,
/// 16-hex string (FNV-1a 64-bit). Stage-3 verifies this matches
/// `semantic_id_shared_global(cs_ptr, object_type::CRITICAL_SECTION)`.
pub site_sid: String,
/// Guest VA of the `X_RTL_CRITICAL_SECTION`. Both engines see the
/// same value (the guest manages the struct).
pub cs_ptr: u32,
}
pub struct ContentionManifest {
entries: Mutex<HashMap<(u32, u64), Entry>>,
/// Per-tid count of `contention.observed` emits ours has fired so
/// far in this run. Each emit shifts the per-tid event-log idx by
/// +1 relative to canary's stream, so subsequent manifest lookups
/// must translate ours's `peek_tid_idx` value back to canary's idx
/// space (`ours_peek - emits_so_far`). Updated by
/// `consume_at_peek`, which is the supported lookup entry point.
emit_counts: Mutex<HashMap<u32, u64>>,
/// Sum of all entries ever loaded (cap on growth: post-load lookup
/// only). For audit logging / sanity checks.
initial_count: usize,
}
impl ContentionManifest {
/// Load a manifest from a JSON file. The file must be a
/// well-formed `contention_manifest.json` (see Stage 2's
/// builder). Unknown top-level fields are ignored — only `entries`
/// is consumed.
///
/// Returns a friendly error string on malformed input so the caller
/// can surface it without a `serde_json::Error` dependency creep.
pub fn load_from_file(path: &Path) -> io::Result<Self> {
let f = File::open(path)?;
let reader = BufReader::new(f);
let json: serde_json::Value = serde_json::from_reader(reader)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
Self::load_from_json_value(&json)
}
pub fn load_from_str(s: &str) -> io::Result<Self> {
let json: serde_json::Value = serde_json::from_str(s)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
Self::load_from_json_value(&json)
}
fn load_from_json_value(json: &serde_json::Value) -> io::Result<Self> {
let version = json.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
if version != 1 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported manifest version: {version} (expected 1)"),
));
}
let arr = json.get("entries").and_then(|v| v.as_array()).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "manifest missing `entries` array")
})?;
let mut map = HashMap::with_capacity(arr.len());
for (i, entry) in arr.iter().enumerate() {
let tid = entry
.get("tid")
.and_then(|v| v.as_u64())
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("entry {i}: missing or non-u64 `tid`"),
))? as u32;
let idx = entry
.get("tid_event_idx")
.and_then(|v| v.as_u64())
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("entry {i}: missing or non-u64 `tid_event_idx`"),
))?;
let site_sid = entry
.get("site_sid")
.and_then(|v| v.as_str())
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("entry {i}: missing or non-str `site_sid`"),
))?
.to_owned();
// cs_ptr is emitted as "0xHHHHHHHH" — strip the prefix and parse.
let cs_ptr_str = entry
.get("cs_ptr")
.and_then(|v| v.as_str())
.ok_or_else(|| io::Error::new(
io::ErrorKind::InvalidData,
format!("entry {i}: missing or non-str `cs_ptr`"),
))?;
let cs_ptr = parse_hex_u32(cs_ptr_str).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("entry {i}: cs_ptr={cs_ptr_str:?}: {e}"),
)
})?;
let e = Entry { tid, tid_event_idx: idx, site_sid, cs_ptr };
map.insert((tid, idx), e);
}
let initial_count = map.len();
Ok(Self {
entries: Mutex::new(map),
emit_counts: Mutex::new(HashMap::new()),
initial_count,
})
}
/// Look up + REMOVE the entry for `(tid, idx)`. `None` if no entry.
/// Removal prevents a single ordinal from re-firing the forced-park
/// branch if `rtl_enter_critical_section` is re-entered at the same
/// per-tid ordinal (shouldn't happen because emits are monotone,
/// but defensive).
pub fn consume(&self, tid: u32, idx: u64) -> Option<Entry> {
self.entries.lock().unwrap().remove(&(tid, idx))
}
/// Stage 3 lookup entry point: translate ours's `peek_tid_idx`
/// value back to canary's idx space (subtracting the count of
/// `contention.observed` events ours has already emitted on this
/// tid), then `consume()`. On hit, the per-tid emit counter is
/// bumped so the next call's translation accounts for THIS emit.
///
/// Both halves of the bookkeeping (consume + emit-count bump) MUST
/// happen here, before the caller actually emits, to keep the
/// translation arithmetic consistent.
pub fn consume_at_peek(&self, tid: u32, peek_idx: u64) -> Option<Entry> {
let mut emits = self.emit_counts.lock().unwrap();
let already = *emits.get(&tid).unwrap_or(&0);
// Per-tid event log idx is monotone, so `peek_idx >= already`
// always — but guard against underflow defensively.
if peek_idx < already {
return None;
}
let canary_idx = peek_idx - already;
let hit = self.entries.lock().unwrap().remove(&(tid, canary_idx));
if hit.is_some() {
*emits.entry(tid).or_insert(0) += 1;
}
hit
}
/// Test helper: how many `contention.observed` emits we've tracked.
#[cfg(test)]
pub fn emit_count(&self, tid: u32) -> u64 {
*self.emit_counts.lock().unwrap().get(&tid).unwrap_or(&0)
}
/// Non-destructive peek (testing only).
pub fn peek(&self, tid: u32, idx: u64) -> Option<Entry> {
self.entries.lock().unwrap().get(&(tid, idx)).cloned()
}
/// Number of entries originally loaded (constant after load).
pub fn initial_count(&self) -> usize {
self.initial_count
}
/// Number of entries still un-consumed.
pub fn remaining_count(&self) -> usize {
self.entries.lock().unwrap().len()
}
}
fn parse_hex_u32(s: &str) -> Result<u32, String> {
let trimmed = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
u32::from_str_radix(trimmed, 16).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL: &str = r#"{
"version": 1,
"source_canary_jsonl": "/tmp/x.jsonl",
"source_canary_sha256": "00",
"built_at_host_unix": 0,
"summary": {},
"entries": [
{"tid": 6, "tid_event_idx": 104664, "site_sid": "c26a128bf45411f7",
"cs_ptr": "0xbc65c890", "contended": true},
{"tid": 9, "tid_event_idx": 386, "site_sid": "c26a128bf45411f7",
"cs_ptr": "0xbc65c890", "contended": true}
]
}"#;
#[test]
fn loads_two_entries() {
let m = ContentionManifest::load_from_str(MINIMAL).unwrap();
assert_eq!(m.initial_count(), 2);
assert_eq!(m.remaining_count(), 2);
}
#[test]
fn consume_returns_entry_and_decrements() {
let m = ContentionManifest::load_from_str(MINIMAL).unwrap();
let e = m.consume(6, 104664).unwrap();
assert_eq!(e.cs_ptr, 0xbc65c890);
assert_eq!(e.site_sid, "c26a128bf45411f7");
assert_eq!(m.remaining_count(), 1);
// Second consume of the same key yields None.
assert!(m.consume(6, 104664).is_none());
}
#[test]
fn miss_returns_none() {
let m = ContentionManifest::load_from_str(MINIMAL).unwrap();
assert!(m.consume(99, 99).is_none());
assert!(m.consume(6, 999999).is_none());
}
#[test]
fn peek_is_non_destructive() {
let m = ContentionManifest::load_from_str(MINIMAL).unwrap();
assert!(m.peek(6, 104664).is_some());
assert!(m.peek(6, 104664).is_some());
assert_eq!(m.remaining_count(), 2);
}
#[test]
fn rejects_unknown_version() {
let bad = r#"{"version":99,"entries":[]}"#;
assert!(ContentionManifest::load_from_str(bad).is_err());
}
#[test]
fn rejects_missing_entries() {
let bad = r#"{"version":1}"#;
assert!(ContentionManifest::load_from_str(bad).is_err());
}
#[test]
fn rejects_bad_cs_ptr() {
let bad = r#"{"version":1,"entries":[
{"tid":1,"tid_event_idx":0,"site_sid":"x","cs_ptr":"not-a-hex","contended":true}
]}"#;
assert!(ContentionManifest::load_from_str(bad).is_err());
}
#[test]
fn parses_cs_ptr_without_0x_prefix() {
let ok = r#"{"version":1,"entries":[
{"tid":1,"tid_event_idx":0,"site_sid":"x","cs_ptr":"DEADBEEF","contended":true}
]}"#;
let m = ContentionManifest::load_from_str(ok).unwrap();
assert_eq!(m.consume(1, 0).unwrap().cs_ptr, 0xDEADBEEF);
}
#[test]
fn empty_entries_loads_zero_count() {
let ok = r#"{"version":1,"entries":[]}"#;
let m = ContentionManifest::load_from_str(ok).unwrap();
assert_eq!(m.initial_count(), 0);
assert!(m.consume(0, 0).is_none());
}
#[test]
fn consume_at_peek_translates_idx() {
// Manifest stores canary idx values. Consumer's peek matches
// canary's idx on the very first lookup (no prior emits), then
// shifts by the number of emits this side has done.
let json = r#"{"version":1,"entries":[
{"tid":1,"tid_event_idx":100,"site_sid":"aa","cs_ptr":"0xaa","contended":true},
{"tid":1,"tid_event_idx":200,"site_sid":"bb","cs_ptr":"0xbb","contended":true},
{"tid":1,"tid_event_idx":300,"site_sid":"cc","cs_ptr":"0xcc","contended":true}
]}"#;
let m = ContentionManifest::load_from_str(json).unwrap();
// First lookup: peek_idx == canary_idx (no prior emit).
let hit = m.consume_at_peek(1, 100).unwrap();
assert_eq!(hit.tid_event_idx, 100);
assert_eq!(m.emit_count(1), 1);
// Second hit: ours's peek is 201 (canary's 200 + 1 prior emit).
let hit = m.consume_at_peek(1, 201).unwrap();
assert_eq!(hit.tid_event_idx, 200);
assert_eq!(m.emit_count(1), 2);
// Third hit: ours's peek is 302.
let hit = m.consume_at_peek(1, 302).unwrap();
assert_eq!(hit.tid_event_idx, 300);
assert_eq!(m.emit_count(1), 3);
}
#[test]
fn consume_at_peek_miss_does_not_bump_emit_count() {
let json = r#"{"version":1,"entries":[
{"tid":1,"tid_event_idx":100,"site_sid":"aa","cs_ptr":"0xaa","contended":true}
]}"#;
let m = ContentionManifest::load_from_str(json).unwrap();
// Miss at idx 50 — emit count stays 0.
assert!(m.consume_at_peek(1, 50).is_none());
assert_eq!(m.emit_count(1), 0);
// Miss at idx 999 — still 0.
assert!(m.consume_at_peek(1, 999).is_none());
assert_eq!(m.emit_count(1), 0);
}
#[test]
fn consume_at_peek_per_tid_independent() {
let json = r#"{"version":1,"entries":[
{"tid":1,"tid_event_idx":100,"site_sid":"a","cs_ptr":"0xa","contended":true},
{"tid":2,"tid_event_idx":200,"site_sid":"b","cs_ptr":"0xb","contended":true},
{"tid":2,"tid_event_idx":300,"site_sid":"c","cs_ptr":"0xc","contended":true}
]}"#;
let m = ContentionManifest::load_from_str(json).unwrap();
assert!(m.consume_at_peek(1, 100).is_some());
// tid=2's count should be unaffected by tid=1's emit.
assert_eq!(m.emit_count(2), 0);
assert!(m.consume_at_peek(2, 200).is_some());
// Now tid=2 has 1 emit; its second entry is at canary 300, so peek 301.
assert!(m.consume_at_peek(2, 301).is_some());
assert_eq!(m.emit_count(2), 2);
}
}

View File

@@ -0,0 +1,774 @@
//! Phase A event-log emitter. Schema v1 — see
//! `xenia-rs/audit-runs/phase-a-diff-harness/schema-v1.md`.
//!
//! Cvar-gated (disabled by default). Zero cost when disabled:
//! `is_enabled()` is a relaxed atomic-bool load.
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
static ENABLED: AtomicBool = AtomicBool::new(false);
static SINK: OnceLock<Mutex<BufWriter<File>>> = OnceLock::new();
static T0: OnceLock<Instant> = OnceLock::new();
static TID_COUNTERS: OnceLock<Mutex<HashMap<u32, u64>>> = OnceLock::new();
/// Iterate 2.M (reading-error #42): record the Phase-A trace path so the
/// always-on exit-time thread-state dump can derive a sibling JSON path
/// without re-threading CLI flags through `cmd_exec_inner`. `None` when
/// Phase-A is disabled — exit-state dump falls back to a CWD-relative
/// default in that case.
static OUTPUT_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Object-type codes — must match canary's enum exactly (schema-v1.md).
pub mod object_type {
pub const UNKNOWN: u32 = 0x00;
pub const EVENT: u32 = 0x01;
pub const MUTANT: u32 = 0x02;
pub const SEMAPHORE: u32 = 0x03;
pub const TIMER: u32 = 0x04;
pub const THREAD: u32 = 0x05;
pub const FILE: u32 = 0x06;
pub const IO_COMPLETION: u32 = 0x07;
pub const MODULE: u32 = 0x08;
pub const ENUM_STATE: u32 = 0x09;
pub const SECTION: u32 = 0x0A;
pub const NOTIFICATION: u32 = 0x0B;
/// Phase D Stage 1 (canary side) / Stage 3 (ours side): pseudo-type
/// used as the `object_type` input to `semantic_id_shared_global`
/// for RTL_CRITICAL_SECTION pointers. CS is NOT a real XObject
/// (it lives as a guest-memory struct, not a handle-tabled kernel
/// object), but the `site_sid` field of `contention.observed`
/// reuses the shared-global SID recipe so the Stage-3 manifest can
/// compute the same SID in both engines for the same CS pointer.
/// Must match canary's `kObjCriticalSection` exactly.
pub const CRITICAL_SECTION: u32 = 0x0C;
}
/// Initialize the emitter. Call from main once at startup with the
/// resolved path (CLI flag or env var). `None` keeps the emitter
/// disabled; cost is one relaxed atomic-bool check per emit call.
pub fn init(path: Option<&Path>) {
let _ = T0.set(Instant::now());
let Some(path) = path else {
return;
};
let _ = OUTPUT_PATH.set(path.to_path_buf());
let f = match File::create(path) {
Ok(f) => f,
Err(e) => {
eprintln!(
"phase-a event log: failed to open {:?}: {e} — disabled",
path
);
return;
}
};
let mut bw = BufWriter::new(f);
// Schema header (synthetic tid=0).
let host_ns = host_ns_since_start();
let _ = writeln!(
bw,
r#"{{"schema_version":1,"engine":"ours","kind":"schema_version","tid":0,"tid_event_idx":0,"guest_cycle":0,"host_ns":{host_ns},"deterministic":true,"payload":{{"version":1,"emitter_build":"ours-phaseA"}}}}"#
);
let _ = bw.flush();
if SINK.set(Mutex::new(bw)).is_err() {
// Already initialized — leave alone.
return;
}
let _ = TID_COUNTERS.set(Mutex::new(HashMap::new()));
ENABLED.store(true, Ordering::Release);
}
#[inline]
pub fn is_enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Path passed to `init()`, if any. Used by the iterate-2.M exit-state
/// dump so the sibling JSON lands next to the Phase-A JSONL trace.
pub fn output_path() -> Option<&'static Path> {
OUTPUT_PATH.get().map(|p| p.as_path())
}
fn host_ns_since_start() -> u128 {
let t0 = T0.get_or_init(Instant::now);
t0.elapsed().as_nanos()
}
fn next_tid_idx(tid: u32) -> u64 {
let map = TID_COUNTERS.get().expect("event_log not initialized");
let mut g = map.lock().unwrap();
let entry = g.entry(tid).or_insert(0);
let idx = *entry;
*entry = idx + 1;
idx
}
/// Peek next tid_event_idx without consuming it. Useful for handle
/// semantic-id computation that needs to match what the next emit will use.
pub fn peek_tid_idx(tid: u32) -> u64 {
let Some(map) = TID_COUNTERS.get() else {
return 0;
};
let g = map.lock().unwrap();
*g.get(&tid).unwrap_or(&0)
}
/// FNV-1a 64-bit. Identical implementation in canary (see event_log.cc).
pub fn semantic_id(
create_site_pc: u32,
creating_tid: u32,
tid_event_idx_at_creation: u64,
object_type: u32,
) -> u64 {
let mut bytes = [0u8; 4 + 4 + 8 + 4];
bytes[0..4].copy_from_slice(&create_site_pc.to_le_bytes());
bytes[4..8].copy_from_slice(&creating_tid.to_le_bytes());
bytes[8..16].copy_from_slice(&tid_event_idx_at_creation.to_le_bytes());
bytes[16..20].copy_from_slice(&object_type.to_le_bytes());
let mut h: u64 = 0xCBF29CE484222325;
for b in bytes.iter() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001B3);
}
h
}
/// Phase C+18: marker sentinel used as `create_site_pc` in
/// `semantic_id_shared_global` so the resulting SID is distinguishable
/// from regular per-thread handle SIDs (which use real guest PCs that
/// never collide with this value). Picked outside any plausible guest
/// code-address range. Both engines MUST use this exact constant.
pub const SHARED_GLOBAL_SID_MARKER: u32 = 0xC01AB005;
/// Phase C+18: scheduling-invariant SID for **process-global** kernel
/// dispatcher objects that are lazy-wrapped on first guest-thread touch
/// (see ours's `ensure_dispatcher_object` and canary's
/// `XObject::GetNativeObject`).
///
/// Whichever guest thread happens to be the first to touch a given
/// dispatcher pointer synthesizes the wrapper, but **which** thread wins
/// is timing-dependent and differs between canary and ours (and between
/// runs of the same engine). The regular per-thread `semantic_id`
/// recipe — keyed on `(create_site_pc, creating_tid, tid_event_idx)` —
/// therefore produces different SIDs in each engine for the same logical
/// object.
///
/// This helper keys on `(SHARED_GLOBAL_SID_MARKER, 0, pointer, object_type)`
/// so the SID depends only on the object's identity, not on the
/// scheduling order. Subsequent `wait.begin` events that reference the
/// dispatcher resolve a stable cross-engine SID, and the diff tool can
/// use SID equality to cross-tid match the floating `handle.create`
/// event.
///
/// Per the schema-v1 SID API, the inputs are still fed to the existing
/// `semantic_id()` FNV-1a function unchanged — we just choose inputs
/// that are scheduling-invariant. No new wire format.
pub fn semantic_id_shared_global(pointer: u32, object_type: u32) -> u64 {
semantic_id(
SHARED_GLOBAL_SID_MARKER,
0,
pointer as u64,
object_type,
)
}
fn write_line(line: &str) {
let Some(sink) = SINK.get() else { return };
let mut g = sink.lock().unwrap();
let _ = g.write_all(line.as_bytes());
let _ = g.write_all(b"\n");
let _ = g.flush();
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
#[inline]
fn common_prefix(
kind: &str,
tid: u32,
idx: u64,
guest_cycle: u64,
deterministic: bool,
) -> String {
let host_ns = host_ns_since_start();
let det = if deterministic { "true" } else { "false" };
format!(
r#"{{"schema_version":1,"engine":"ours","kind":"{kind}","tid":{tid},"tid_event_idx":{idx},"guest_cycle":{guest_cycle},"host_ns":{host_ns},"deterministic":{det}"#
)
}
pub fn emit_import_call(tid: u32, guest_cycle: u64, module: &str, ord: u16, name: &str) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("import.call", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"module":"{}","ord":{},"name":"{}"}}}}"#,
json_escape(module),
ord,
json_escape(name)
));
write_line(&line);
}
pub fn emit_kernel_call(tid: u32, guest_cycle: u64, name: &str) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("kernel.call", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"name":"{}","args":{{}},"args_resolved":{{}}}}}}"#,
json_escape(name)
));
write_line(&line);
}
/// Phase C+10 schema-v1 extension: emit a `kernel.call` event whose
/// `args_resolved` field carries a best-effort dereferenced path string.
///
/// Schema-v1 already allows `args_resolved` to be a free-form object
/// (see schema-v1.md kernel.call payload), so this remains v1-compatible.
/// Cvar-gated default-off via `is_enabled()`. When the path is empty or
/// resolution failed, the caller should pass `None` and we degrade to the
/// existing empty-object form so emitter output is byte-identical to the
/// pre-extension behavior.
///
/// Determinism: the resolved path is read directly out of guest memory
/// (OBJECT_ATTRIBUTES → ANSI_STRING → bytes). It is fully deterministic
/// across runs of the same input. The event-level `deterministic:true`
/// flag is preserved.
pub fn emit_kernel_call_with_path(
tid: u32,
guest_cycle: u64,
name: &str,
path: Option<&str>,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("kernel.call", tid, idx, guest_cycle, true);
match path {
Some(p) if !p.is_empty() => {
line.push_str(&format!(
r#","payload":{{"name":"{}","args":{{}},"args_resolved":{{"path":"{}"}}}}}}"#,
json_escape(name),
json_escape(p)
));
}
_ => {
line.push_str(&format!(
r#","payload":{{"name":"{}","args":{{}},"args_resolved":{{}}}}}}"#,
json_escape(name)
));
}
}
write_line(&line);
}
pub fn emit_kernel_return(tid: u32, guest_cycle: u64, name: &str, return_value: u64) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("kernel.return", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"name":"{}","return_value":{},"status":"0x{:08x}","side_effects":[]}}}}"#,
json_escape(name),
return_value,
return_value as u32
));
write_line(&line);
}
pub fn emit_handle_create(
tid: u32,
guest_cycle: u64,
semantic_id: u64,
object_type: u32,
raw_handle_id: u32,
object_name: Option<&str>,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("handle.create", tid, idx, guest_cycle, true);
let name_field = match object_name {
Some(n) => format!(r#""{}""#, json_escape(n)),
None => "null".to_string(),
};
line.push_str(&format!(
r#","payload":{{"handle_semantic_id":"{:016x}","object_type":{},"object_name":{},"raw_handle_id":"0x{:08x}"}}}}"#,
semantic_id, object_type, name_field, raw_handle_id
));
write_line(&line);
}
pub fn emit_handle_destroy(
tid: u32,
guest_cycle: u64,
semantic_id: u64,
raw_handle_id: u32,
prior_refcount: u32,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("handle.destroy", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"handle_semantic_id":"{:016x}","raw_handle_id":"0x{:08x}","prior_refcount":{}}}}}"#,
semantic_id, raw_handle_id, prior_refcount
));
write_line(&line);
}
pub fn emit_thread_create(
parent_tid: u32,
guest_cycle: u64,
semantic_id: u64,
entry_pc: u32,
ctx_ptr: u32,
priority: u32,
affinity: u32,
stack_size: u32,
suspended: bool,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(parent_tid);
let mut line = common_prefix("thread.create", parent_tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"handle_semantic_id":"{:016x}","parent_tid":{},"entry_pc":"0x{:08x}","ctx_ptr":"0x{:08x}","priority":{},"affinity":{},"stack_size":{},"suspended":{}}}}}"#,
semantic_id,
parent_tid,
entry_pc,
ctx_ptr,
priority,
affinity,
stack_size,
suspended
));
write_line(&line);
}
pub fn emit_thread_exit(tid: u32, guest_cycle: u64, exit_code: u32) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("thread.exit", tid, idx, guest_cycle, true);
line.push_str(&format!(r#","payload":{{"exit_code":{}}}}}"#, exit_code));
write_line(&line);
}
pub fn emit_wait_begin(
tid: u32,
guest_cycle: u64,
handles: &[u64],
timeout_ns: i64,
alertable: bool,
wait_all: bool,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("wait.begin", tid, idx, guest_cycle, true);
let mut ids = String::from("[");
for (i, h) in handles.iter().enumerate() {
if i > 0 {
ids.push(',');
}
ids.push_str(&format!(r#""{:016x}""#, h));
}
ids.push(']');
let wait_type = if wait_all { "all" } else { "any" };
line.push_str(&format!(
r#","payload":{{"handles_semantic_ids":{},"timeout_ns":{},"alertable":{},"wait_type":"{}"}}}}"#,
ids, timeout_ns, alertable, wait_type
));
write_line(&line);
}
pub fn emit_wait_end(
tid: u32,
guest_cycle: u64,
status: u32,
woken_by: Option<u64>,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let mut line = common_prefix("wait.end", tid, idx, guest_cycle, false);
let woken = match woken_by {
Some(h) => format!(r#""{:016x}""#, h),
None => "null".to_string(),
};
line.push_str(&format!(
r#","payload":{{"status":"0x{:08x}","woken_by_semantic_id":{},"wait_duration_cycles":0}}}}"#,
status, woken
));
write_line(&line);
}
// ===== Phase C+15-\u03b1 — Handle-semantic-ID registry =====
//
// Maps raw handle id -> FNV-1a 64-bit semantic_id assigned at handle
// creation. Used by `handle.destroy`, `wait.begin`, and any future event
// that references a handle to emit a stable cross-engine identity.
//
// Lifetime: entries are inserted on `register_handle_semantic_id` and
// removed on `forget_handle_semantic_id` (handle destroy). The map is
// completely separate from the live KernelState object table —
// looking up a destroyed handle returns None and the caller emits 0.
static HANDLE_SEMANTIC_IDS: OnceLock<Mutex<HashMap<u32, u64>>> = OnceLock::new();
fn handle_sid_map() -> &'static Mutex<HashMap<u32, u64>> {
HANDLE_SEMANTIC_IDS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Record `(raw_handle_id -> semantic_id)` so subsequent destroy/wait
/// events can resolve the SID. No-op when event_log is disabled.
pub fn register_handle_semantic_id(raw_handle_id: u32, sid: u64) {
if !is_enabled() {
return;
}
let m = handle_sid_map();
m.lock().unwrap().insert(raw_handle_id, sid);
}
/// Look up the semantic_id previously registered for a raw handle.
/// Returns 0 if the handle was never registered (e.g. pre-init handles,
/// pseudo-handles, or already destroyed).
pub fn lookup_handle_semantic_id(raw_handle_id: u32) -> u64 {
let Some(map) = HANDLE_SEMANTIC_IDS.get() else {
return 0;
};
*map.lock().unwrap().get(&raw_handle_id).unwrap_or(&0)
}
/// Forget the semantic_id mapping for a destroyed handle. Returns the
/// previous mapping (0 if absent) so callers can emit `handle.destroy`
/// with the correct SID before the entry is dropped.
pub fn forget_handle_semantic_id(raw_handle_id: u32) -> u64 {
let Some(map) = HANDLE_SEMANTIC_IDS.get() else {
return 0;
};
map.lock().unwrap().remove(&raw_handle_id).unwrap_or(0)
}
/// Convenience wrapper used by both engines: at handle creation time,
/// peek the current tid_event_idx, compute the FNV-1a 64-bit semantic_id,
/// register it for the raw handle, and emit a `handle.create` event.
/// Returns the semantic_id so callers can stash it on object metadata
/// when needed (currently only used for the registry side-effect).
///
/// `create_site_pc` is the guest LR at the kernel call that produced
/// the handle (or 0 if not available — both engines must use the same
/// value for the cross-engine SID to match). For v1.1 we pass 0
/// universally, which preserves cross-engine identity since the SID
/// becomes `fnv1a(0, tid, idx, type)` and both engines emit the same
/// tuple in the same order.
pub fn emit_handle_create_auto(
tid: u32,
guest_cycle: u64,
create_site_pc: u32,
object_type: u32,
raw_handle_id: u32,
object_name: Option<&str>,
) -> u64 {
if !is_enabled() {
return 0;
}
let idx_at_creation = peek_tid_idx(tid);
let sid = semantic_id(create_site_pc, tid, idx_at_creation, object_type);
register_handle_semantic_id(raw_handle_id, sid);
emit_handle_create(tid, guest_cycle, sid, object_type, raw_handle_id, object_name);
sid
}
/// Phase C+18: emit `handle.create` for a **process-global** kernel
/// dispatcher (canary `XObject::GetNativeObject` / ours
/// `ensure_dispatcher_object` first-touch synthesis). The SID is
/// computed via `semantic_id_shared_global(pointer, object_type)` so
/// the same object yields the same SID in both engines regardless of
/// which guest thread happens to be the first toucher (see C+18
/// memory entry / schema-v1.md §"Shared-global SIDs"). The diff tool
/// cross-tid matches `handle.create` events on shared-global SIDs.
///
/// The `raw_handle_id` is the guest dispatcher pointer itself in
/// ours; canary's `XObject::StashHandle` round-trips through the same
/// dispatcher slot. Cross-engine SID identity is independent of raw
/// handle namespace.
pub fn emit_handle_create_shared_global(
tid: u32,
guest_cycle: u64,
object_type: u32,
raw_handle_id: u32,
object_name: Option<&str>,
) -> u64 {
if !is_enabled() {
return 0;
}
let sid = semantic_id_shared_global(raw_handle_id, object_type);
register_handle_semantic_id(raw_handle_id, sid);
emit_handle_create(tid, guest_cycle, sid, object_type, raw_handle_id, object_name);
sid
}
/// Phase D Stage 3: emit a `contention.observed` event. Mirror of canary's
/// `phase_a::EmitContentionObserved` (Stage 1). Emitted from
/// `rtl_enter_critical_section` only when the contention-manifest forces a
/// park, so per-tid ordinals stay aligned with canary's emitter. The
/// `site_sid` is computed via `semantic_id_shared_global(cs_ptr,
/// object_type::CRITICAL_SECTION)` so both engines produce the same SID
/// for the same CS pointer (cross-engine identity).
///
/// `is_enabled()` gates this just like every other emitter — when the
/// Phase A event log is disabled, this is a zero-cost no-op.
///
/// Note: `contention.observed` is marked `ENGINE_LOCAL_KINDS` in
/// `diff_events.py` (Stage 4), so the diff tool advances the per-tid
/// pointer past these events on either side without comparison. That
/// keeps the matched-prefix definition unchanged across cvar
/// configurations.
pub fn emit_contention_observed(
tid: u32,
guest_cycle: u64,
cs_ptr: u32,
contended: bool,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let site_sid = semantic_id_shared_global(cs_ptr, object_type::CRITICAL_SECTION);
let mut line = common_prefix("contention.observed", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"cs_ptr":"0x{:08x}","site_sid":"{:016x}","contended":{}}}}}"#,
cs_ptr,
site_sid,
if contended { "true" } else { "false" }
));
write_line(&line);
}
/// Iterate 2.Q: emit a `signal.match` event recording which handle a
/// signal-class call (`NtSetEvent`/`KeSetEvent`/`NtReleaseSemaphore`/
/// `KeReleaseSemaphore`) targeted at the moment the signal fired, along
/// with the set of guest threads currently parked on that handle. The
/// caller is expected to gather `waiter_tids` BEFORE the wake fans out,
/// so the emitted set reflects the pre-wake waiter list.
///
/// `signal_call` is the kernel symbol (static `&str`). `target_handle`
/// is the resolved (post-pseudo-handle / post-dup-id) handle id; the
/// SID is resolved from the global registry (0 when absent — e.g.
/// pre-init handles or AUDIT-062 wrong-slot targets that were never
/// registered). `waiter_count` is the length of `waiter_tids` (passed
/// explicitly so callers may skip the emit when 0). This kind is
/// ENGINE_LOCAL in the diff tool — it consumes one per-tid idx slot on
/// the emitter side without alignment cost.
///
/// Pure observability. No behavior change. Cvar-gated default-off via
/// `is_enabled()`; when the Phase A event log is disabled the call is
/// a single relaxed atomic-bool check.
pub fn emit_signal_match(
tid: u32,
guest_cycle: u64,
signal_call: &str,
target_handle: u32,
waiter_count: usize,
waiter_tids: &[u32],
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(tid);
let target_sid = lookup_handle_semantic_id(target_handle);
let sid_field = if target_sid != 0 {
format!(r#""{:016x}""#, target_sid)
} else {
"null".to_string()
};
let mut tids_field = String::from("[");
for (i, t) in waiter_tids.iter().enumerate() {
if i > 0 {
tids_field.push(',');
}
tids_field.push_str(&format!("{}", t));
}
tids_field.push(']');
let mut line = common_prefix("signal.match", tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"signal_call":"{}","target_handle":"0x{:08x}","target_sid":{},"waiter_count":{},"waiter_tids":{}}}}}"#,
json_escape(signal_call),
target_handle,
sid_field,
waiter_count,
tids_field,
));
write_line(&line);
}
/// Iterate 2.T: emit a `wake.requested` event recording one waiter the
/// wake-loop in `wake_eligible_waiters` actually touched. Distinct from
/// `signal.match` (which records pre-wake intent at the call boundary):
/// `wake.requested` records the per-waiter transition outcome the kernel
/// wake primitive produced. Together they decisively distinguish:
/// C-2a (`signal.match` fires for waiter, but no `wake.requested` for
/// the same target tid) — kernel waiter list inconsistency, OR
/// C-2b (`wake.requested` fires with `transitioned=true` /
/// `new_state="Ready"`, but target tid never executes) —
/// scheduler-pick skip on Ready threads.
///
/// `signaling_tid` is the tid of the thread currently executing inside the
/// signal call (e.g., NtReleaseSemaphore caller). `target_tid` is the
/// woken thread's guest tid. `target_handle` is the handle we're waking
/// on. `wait_kind` is one of `"WaitAny"`, `"WaitAll"`, `"WaitSingle"`,
/// `"Other"`. `transitioned` is true iff prior_state was Blocked and
/// post-state is Ready; `new_state` carries the post-call state string
/// (`"Ready"`, `"StillBlocked"`, `"AlreadyReady"`, `"Exited"`, `"Other"`).
/// `target_cpu` is the woken thread's hw_id, or `null` if unknown.
///
/// ENGINE_LOCAL in the diff tool (see `ENGINE_LOCAL_KINDS` in
/// `tools/diff-events/diff_events.py`). Pure observability — no behavior
/// change.
#[allow(clippy::too_many_arguments)]
pub fn emit_wake_requested(
signaling_tid: u32,
guest_cycle: u64,
target_tid: u32,
target_handle: u32,
wait_kind: &str,
transitioned: bool,
new_state: &str,
target_cpu: Option<u8>,
) {
if !is_enabled() {
return;
}
let idx = next_tid_idx(signaling_tid);
let cpu_field = match target_cpu {
Some(c) => format!("{}", c),
None => "null".to_string(),
};
let mut line = common_prefix("wake.requested", signaling_tid, idx, guest_cycle, true);
line.push_str(&format!(
r#","payload":{{"target_tid":{},"target_handle":"0x{:08x}","wait_kind":"{}","transitioned":{},"new_state":"{}","target_cpu":{}}}}}"#,
target_tid,
target_handle,
json_escape(wait_kind),
if transitioned { "true" } else { "false" },
json_escape(new_state),
cpu_field,
));
write_line(&line);
}
/// Convenience wrapper used by both engines: emit a `handle.destroy`
/// event resolving the SID from the registry, and forget the mapping.
/// Pass `prior_refcount` as observed pre-decrement.
pub fn emit_handle_destroy_auto(
tid: u32,
guest_cycle: u64,
raw_handle_id: u32,
prior_refcount: u32,
) {
if !is_enabled() {
return;
}
let sid = forget_handle_semantic_id(raw_handle_id);
emit_handle_destroy(tid, guest_cycle, sid, raw_handle_id, prior_refcount);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a_known_vector() {
// FNV-1a 64-bit of "foobar" = 0x85944171f73967e8 (standard test vector).
let bytes = b"foobar";
let mut h: u64 = 0xCBF29CE484222325;
for b in bytes.iter() {
h ^= *b as u64;
h = h.wrapping_mul(0x100000001B3);
}
assert_eq!(h, 0x85944171f73967e8);
}
#[test]
fn semantic_id_stable() {
// Identity inputs → known fixed FNV-1a output. Locks the algorithm
// so a regression here is caught at build-time.
let a = semantic_id(0x82001234, 1, 0, object_type::EVENT);
let b = semantic_id(0x82001234, 1, 0, object_type::EVENT);
assert_eq!(a, b);
// Distinct input → distinct output (with overwhelming probability).
let c = semantic_id(0x82001234, 1, 1, object_type::EVENT);
assert_ne!(a, c);
}
/// Phase C+18: the shared-global SID must depend ONLY on
/// `(pointer, object_type)`, independent of the calling tid / event idx.
/// Two calls with the same pointer+type return the same SID; otherwise
/// the diff tool's cross-tid floating-create match cannot work.
#[test]
fn semantic_id_shared_global_is_scheduling_invariant() {
let a = semantic_id_shared_global(0x828a3230, object_type::SEMAPHORE);
let b = semantic_id_shared_global(0x828a3230, object_type::SEMAPHORE);
assert_eq!(a, b);
// Distinct pointer → distinct SID.
let c = semantic_id_shared_global(0x828a3234, object_type::SEMAPHORE);
assert_ne!(a, c);
// Distinct type at the same pointer → distinct SID (defends against
// games that map the same address with different headers — unlikely
// but the property is cheap to assert).
let d = semantic_id_shared_global(0x828a3230, object_type::EVENT);
assert_ne!(a, d);
}
/// Phase C+18: the shared-global SID must NOT collide with regular
/// per-thread SIDs for plausible inputs. The marker constant
/// `0xC01AB005` sits well outside any guest PC range (PPC text lives
/// in 0x8200_0000-0x82FF_FFFF in Sylpheed; XEX header in
/// 0x3001_xxxx; heap in 0x4xxx_xxxx). Verify the marker is also not
/// a plausible tid/idx value.
#[test]
fn semantic_id_shared_global_marker_isolated() {
// A regular per-thread SID for a plausible call site / tid / idx.
let regular = semantic_id(0x82001234, 13, 42, object_type::SEMAPHORE);
// The shared-global SID for the same type but different inputs.
let global = semantic_id_shared_global(0x828a3230, object_type::SEMAPHORE);
assert_ne!(regular, global);
// Ensure marker constant is documented.
assert_eq!(SHARED_GLOBAL_SID_MARKER, 0xC01AB005);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
pub mod audit;
pub mod contention_manifest;
pub mod event_log;
pub mod exports;
pub mod interrupts;
pub mod objects;
pub mod path;
pub mod phase_b_snapshot;
pub mod state;
pub mod thread;
pub mod ui_bridge;

View File

@@ -109,4 +109,20 @@ impl KernelObject {
KernelObject::File { .. } => None,
}
}
/// Phase C+15-α: schema-v1 object-type code (see schema-v1.md
/// `Object type codes` table). Used by `event_log::semantic_id` to
/// compute cross-engine handle identity. Both engines must agree on
/// this mapping.
pub fn schema_object_type(&self) -> u32 {
match self {
KernelObject::Event { .. } => crate::event_log::object_type::EVENT,
KernelObject::Mutex { .. } => crate::event_log::object_type::MUTANT,
KernelObject::Semaphore { .. } => crate::event_log::object_type::SEMAPHORE,
KernelObject::Timer { .. } => crate::event_log::object_type::TIMER,
KernelObject::Thread { .. } => crate::event_log::object_type::THREAD,
KernelObject::File { .. } => crate::event_log::object_type::FILE,
KernelObject::NotifyListener { .. } => crate::event_log::object_type::NOTIFICATION,
}
}
}

View File

@@ -100,6 +100,48 @@ pub fn object_attributes_to_vfs_path(mem: &GuestMemory, obj_attrs_ptr: u32) -> O
Some(normalize_path(&raw))
}
/// Phase C+10 schema-v1 extension helper: read the OBJECT_ATTRIBUTES
/// struct at `obj_attrs_ptr` and return the **raw** path string (trimmed
/// of leading/trailing whitespace, NO prefix-strip / case-fold). The
/// emitter wants the exact bytes the guest passed so the Phase A diff
/// surfaces upstream divergences (e.g. canary calls with one prefix /
/// ours with another) rather than masking them via normalization.
pub fn object_attributes_raw_name(mem: &GuestMemory, obj_attrs_ptr: u32) -> Option<String> {
let raw = read_object_attributes_name(mem, obj_attrs_ptr)?;
if raw.is_empty() {
return None;
}
Some(raw.trim().to_string())
}
/// Phase C+11 schema-v1 extension helper: read the rename target
/// path from a `NtSetInformationFile` class-10 (`XFileRenameInformation`)
/// info buffer. Returns the raw (un-normalized) path string for emitter
/// use; null when the buffer is too small or the inner ANSI_STRING is
/// empty.
///
/// Layout per canary `info/file.h:79-83`:
/// offset 0: be<u32> replace_existing
/// offset 4: be<u32> root_dir_handle
/// offset 8: X_ANSI_STRING (u16 Length, u16 MaximumLength, u32 Buffer)
/// (16 bytes total — caller is expected to check `info_length >= 16`
/// before invoking.)
pub fn file_rename_information_raw_target(
mem: &GuestMemory,
info_ptr: u32,
info_length: u32,
) -> Option<String> {
if info_ptr == 0 || info_length < 16 {
return None;
}
// The ANSI_STRING lives at offset 8 inside the rename-info struct.
let raw = read_ansi_string(mem, info_ptr + 8)?;
if raw.is_empty() {
return None;
}
Some(raw.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,849 @@
//! Phase B initial-state snapshot. Cvar-gated (default off).
//!
//! Fires once, immediately before the first guest PPC instruction of the
//! XEX entry_point executes. Writes a five-file structured state snapshot
//! under `<dir>/ours/` plus a `manifest.json` indexing them by SHA-256.
//!
//! Spec: `xenia-rs/audit-runs/phase-b-state-equivalence/`.
//!
//! Zero cost when `KernelState::phase_b_snapshot_dir == None`. The single
//! hot-path check in `worker_prologue` is one Option-tag test.
use std::collections::BTreeMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use xenia_cpu::PpcContext;
use xenia_memory::page_table::AllocationState;
use xenia_memory::GuestMemory;
use crate::objects::KernelObject;
use crate::state::{KernelState, ModuleId};
const SCHEMA_VERSION: u32 = 1;
const ENGINE: &str = "ours";
static CLAIMED: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
/// FNV-1a 64-bit identity for kernel objects. Mirrors canary's
/// `phase_b_snapshot.cc::StableObjectId`.
fn stable_object_id(type_code: u32, raw_handle: u32) -> u64 {
let mut bytes = [0u8; 8];
bytes[..4].copy_from_slice(&type_code.to_le_bytes());
bytes[4..].copy_from_slice(&raw_handle.to_le_bytes());
let mut h: u64 = 0xCBF29CE484222325;
for b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x100000001B3);
}
h
}
fn sha256_hex(data: &[u8]) -> String {
let mut h = Sha256::new();
h.update(data);
let out = h.finalize();
let mut s = String::with_capacity(64);
for b in out {
s.push_str(&format!("{:02x}", b));
}
s
}
fn hex32(v: u32) -> String {
format!("0x{:08x}", v)
}
fn hex64(v: u64) -> String {
format!("0x{:016x}", v)
}
fn type_code(o: &KernelObject) -> u32 {
match o {
KernelObject::Event { .. } => 0x01,
KernelObject::Mutex { .. } => 0x02,
KernelObject::Semaphore { .. } => 0x03,
KernelObject::Timer { .. } => 0x04,
KernelObject::Thread { .. } => 0x05,
KernelObject::File { .. } => 0x06,
KernelObject::NotifyListener { .. } => 0x0B,
}
}
fn type_name(o: &KernelObject) -> &'static str {
match o {
KernelObject::Event { .. } => "Event",
KernelObject::Mutex { .. } => "Mutex",
KernelObject::Semaphore { .. } => "Semaphore",
KernelObject::Timer { .. } => "Timer",
KernelObject::Thread { .. } => "Thread",
KernelObject::File { .. } => "File",
KernelObject::NotifyListener { .. } => "NotifyListener",
}
}
/// Serialize a `serde_json::Value` to a sort-keys, 2-space-indent UTF-8
/// string. Used for byte-deterministic output regardless of HashMap
/// iteration order on the construction side — `Map<String, Value>` is
/// backed by a `BTreeMap` here so sorting is implicit.
fn serialize_sorted(v: &Value) -> String {
fn walk(v: &Value, out: &mut String, indent: usize) {
let pad = |out: &mut String, n: usize| {
for _ in 0..n {
out.push_str(" ");
}
};
match v {
Value::Null => out.push_str("null"),
Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
Value::Number(n) => out.push_str(&n.to_string()),
Value::String(s) => {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32))
}
c => out.push(c),
}
}
out.push('"');
}
Value::Array(a) => {
if a.is_empty() {
out.push_str("[]");
return;
}
out.push_str("[\n");
let n = a.len();
for (i, item) in a.iter().enumerate() {
pad(out, indent + 1);
walk(item, out, indent + 1);
if i + 1 < n {
out.push(',');
}
out.push('\n');
}
pad(out, indent);
out.push(']');
}
Value::Object(m) => {
if m.is_empty() {
out.push_str("{}");
return;
}
let mut keys: Vec<&String> = m.keys().collect();
keys.sort();
out.push_str("{\n");
let n = keys.len();
for (i, k) in keys.iter().enumerate() {
pad(out, indent + 1);
out.push('"');
out.push_str(k);
out.push_str("\": ");
walk(&m[*k], out, indent + 1);
if i + 1 < n {
out.push(',');
}
out.push('\n');
}
pad(out, indent);
out.push('}');
}
}
}
let mut s = String::new();
walk(v, &mut s, 0);
s.push('\n');
s
}
fn write_file(path: &Path, body: &str) -> std::io::Result<String> {
let mut f = File::create(path)?;
f.write_all(body.as_bytes())?;
f.flush()?;
f.sync_all()?;
Ok(sha256_hex(body.as_bytes()))
}
// ---------- cpu_state.json ----------
fn build_cpu_state(ctx: &PpcContext, entry_pc: u32, current_tid: u32) -> Value {
let mut o = Map::new();
o.insert("schema_version".into(), json!(SCHEMA_VERSION));
o.insert("engine".into(), json!(ENGINE));
o.insert("pc".into(), json!(hex32(entry_pc)));
o.insert("lr".into(), json!(hex64(ctx.lr)));
o.insert("ctr".into(), json!(hex64(ctx.ctr)));
o.insert("msr".into(), json!(hex64(ctx.msr)));
o.insert("vrsave".into(), json!(hex32(ctx.vrsave)));
o.insert("fpscr".into(), json!(hex32(ctx.fpscr)));
let xer = json!({
"ca": ctx.xer_ca as u32,
"ov": ctx.xer_ov as u32,
"so": ctx.xer_so as u32,
"tbc": ctx.xer_tbc as u32,
});
o.insert("xer".into(), xer);
let cr_arr: Vec<Value> = (0..8)
.map(|i| {
let val = ((ctx.cr() >> (28 - i * 4)) & 0xF) as u8;
json!(format!("0x{:x}", val))
})
.collect();
o.insert("cr".into(), Value::Array(cr_arr));
let gpr: Vec<Value> = ctx.gpr.iter().map(|&v| json!(hex64(v))).collect();
o.insert("gpr".into(), Value::Array(gpr));
let fpr: Vec<Value> = ctx.fpr.iter().map(|&v| json!(hex64(v.to_bits()))).collect();
o.insert("fpr".into(), Value::Array(fpr));
let vr: Vec<Value> = ctx
.vr
.iter()
.map(|v| {
let mut s = String::with_capacity(32);
for b in &v.bytes {
s.push_str(&format!("{:02x}", b));
}
json!(s)
})
.collect();
o.insert("vr".into(), Value::Array(vr));
let mut vscr_s = String::with_capacity(32);
for b in &ctx.vscr.bytes {
vscr_s.push_str(&format!("{:02x}", b));
}
o.insert("vscr".into(), json!(vscr_s));
o.insert("thread_id".into(), json!(current_tid));
o.insert("hw_id".into(), json!(ctx.hw_id as u32));
o.insert("stack_base".into(), json!(hex32(0)));
o.insert("stack_limit".into(), json!(hex32(0)));
o.insert("tls_base".into(), json!(hex32(0)));
o.insert("pcr_base".into(), json!(hex32(ctx.gpr[13] as u32)));
o.insert("deterministic_skip".into(), json!(["hw_id"]));
Value::Object(o)
}
// ---------- memory.json ----------
struct Region {
start: u32,
end: u32,
protect_bits: u32,
sha256: String,
}
fn walk_committed_regions(mem: &GuestMemory) -> Vec<Region> {
// Coalesce contiguous committed pages by (allocation_protect,
// current_protect, region base/count). Page table is 1 entry per
// 4 KiB across the full 4 GiB guest space.
const PAGE: u32 = 4096;
let mut regions = Vec::new();
let mut cur_start: Option<(u32, u32)> = None;
let mut last_protect: u32 = 0;
let mut addr: u64 = 0;
while addr < 0x1_0000_0000 {
let a = addr as u32;
let entry = mem.page_entry(a);
let committed = entry
.map(|e| e.state().contains(AllocationState::COMMIT))
.unwrap_or(false);
let protect_bits = entry
.map(|e| e.current_protect().bits())
.unwrap_or(0);
if committed {
match cur_start {
None => {
cur_start = Some((a, a));
last_protect = protect_bits;
}
Some((start, _end)) => {
if protect_bits == last_protect {
cur_start = Some((start, a));
} else {
// Protection change → flush prior region.
let prev_start = start;
let prev_end = cur_start.unwrap().1 + PAGE;
let bytes = read_bytes(mem, prev_start, prev_end - prev_start);
regions.push(Region {
start: prev_start,
end: prev_end,
protect_bits: last_protect,
sha256: sha256_hex(&bytes),
});
cur_start = Some((a, a));
last_protect = protect_bits;
}
}
}
} else if let Some((start, end)) = cur_start.take() {
let end_addr = end + PAGE;
let bytes = read_bytes(mem, start, end_addr - start);
regions.push(Region {
start,
end: end_addr,
protect_bits: last_protect,
sha256: sha256_hex(&bytes),
});
}
addr += PAGE as u64;
}
if let Some((start, end)) = cur_start.take() {
let end_addr = end + PAGE;
let bytes = read_bytes(mem, start, end_addr - start);
regions.push(Region {
start,
end: end_addr,
protect_bits: last_protect,
sha256: sha256_hex(&bytes),
});
}
regions
}
fn read_bytes(mem: &GuestMemory, start: u32, len: u32) -> Vec<u8> {
let mut v = vec![0u8; len as usize];
let base = mem.membase();
if base.is_null() {
return v;
}
// SAFETY: pages in [start, start+len) are confirmed committed by the
// caller; reading them is well-defined. We snapshot bytes at a
// moment when no guest thread is executing.
unsafe {
let src = base.add(start as usize);
std::ptr::copy_nonoverlapping(src, v.as_mut_ptr(), len as usize);
}
v
}
fn build_memory(
state: &KernelState,
mem: &GuestMemory,
dump_section_content: bool,
) -> Value {
let mut o = Map::new();
o.insert("schema_version".into(), json!(SCHEMA_VERSION));
o.insert("engine".into(), json!(ENGINE));
o.insert("page_size".into(), json!(4096));
o.insert("guest_address_space_bytes".into(), json!(0x1_0000_0000u64));
// Named regions: XEX image, main thread stack, PCR, TLS. Mirrors
// canary's BuildMemory exactly so the diff tool compares positional
// entries one-to-one.
let mut regions = Vec::new();
let hash_region = |start: u32, len: u32| -> Region {
let bytes = read_bytes(mem, start, len);
Region {
start,
end: start + len,
protect_bits: 0,
sha256: sha256_hex(&bytes),
}
};
if state.image_base != 0 {
// Walk forward while mapped — bounded by 64 MiB to avoid runaway
// if is_mapped is loose with COMMIT semantics.
let mut size: u32 = 0;
let mut a = state.image_base;
let limit = state.image_base.saturating_add(64 * 1024 * 1024);
while a < limit && mem.is_mapped(a) {
size = size.wrapping_add(4096);
let next = a.wrapping_add(4096);
if next < a {
break;
}
a = next;
}
if size != 0 {
regions.push(hash_region(state.image_base, size));
}
}
// Stack/PCR/TLS — derived from the entry thread's GuestThread.
if let Some(r) = state.scheduler.current {
let th = state.scheduler.thread(r);
if th.stack_size > 0 && th.stack_base >= th.stack_size {
// stack_base in ours is the LOW address (stack grows down from
// stack_base + stack_size). Hash the full alloc'd range.
regions.push(hash_region(th.stack_base, th.stack_size));
}
if th.pcr_base != 0 {
regions.push(hash_region(th.pcr_base, 0x1000));
}
if th.tls_base != 0 {
regions.push(hash_region(th.tls_base, 0x1000));
}
}
regions.sort_by_key(|r| (r.start, r.end));
let mut committed_pages: u64 = 0;
let mut regions_json = Vec::new();
for r in &regions {
committed_pages += ((r.end - r.start) / 4096) as u64;
let mut rm = Map::new();
rm.insert("start".into(), json!(hex32(r.start)));
rm.insert("end".into(), json!(hex32(r.end)));
rm.insert("byte_count".into(), json!(r.end - r.start));
rm.insert("protect".into(), json!(r.protect_bits));
rm.insert("sha256".into(), json!(r.sha256));
rm.insert("section_kind".into(), Value::Null);
regions_json.push(Value::Object(rm));
}
o.insert("regions".into(), Value::Array(regions_json));
o.insert("committed_pages_total".into(), json!(committed_pages));
// Note: a full page-table walk inventory was removed — the
// `mem.page_entry(addr).state().contains(COMMIT)` check returns
// true for some addresses whose underlying host pages aren't
// backed (likely due to interactions with reserved-vs-committed
// bookkeeping during early bring-up). Reading via raw pointer at
// those addresses faults. Phase B's named-regions list above
// captures the equivalence-relevant memory anyway.
o.insert("regions_walked".into(), Value::Array(Vec::new()));
// Single synthetic heap descriptor — ours doesn't model canary's
// heap split; the diff tool sorts heaps by `base` so a single-heap
// engine vs N-heap engine is itself a σ-class observation captured
// by the diff. Mirror canary's heap descriptors: 4 entries.
let heap_bases = [0x0000_0000u32, 0x4000_0000, 0x8000_0000, 0x9000_0000];
let mut heaps = Vec::new();
for base in heap_bases {
let mut heap = Map::new();
heap.insert("name".into(), json!(format!("v{:08x}", base)));
heap.insert("base".into(), json!(hex32(base)));
heap.insert("size".into(), json!(hex32(0x4000_0000)));
heap.insert("page_size".into(), json!(4096));
let mut hist = Map::new();
// Crude: count committed pages within this heap by sampling
// `is_mapped` across the range. O(heap_size / PAGE) — bounded.
let mut committed: u64 = 0;
let mut addr = base;
let end = base.saturating_add(0x4000_0000);
while addr < end {
if mem.is_mapped(addr) {
committed += 1;
}
let next = addr.wrapping_add(4096);
if next < addr {
break;
}
addr = next;
}
hist.insert("committed".into(), json!(committed));
heap.insert("page_state_histogram".into(), Value::Object(hist));
heaps.push(Value::Object(heap));
}
o.insert("heaps".into(), Value::Array(heaps));
if dump_section_content {
let secs: Vec<Value> = regions
.iter()
.map(|r| {
json!({
"start": hex32(r.start),
"end": hex32(r.end),
"sha256": r.sha256.clone(),
"content_b64": "",
})
})
.collect();
o.insert("section_contents".into(), Value::Array(secs));
} else {
o.insert("section_contents".into(), Value::Null);
}
o.insert("deterministic_skip".into(), json!(["host_base_pointer"]));
Value::Object(o)
}
// ---------- kernel.json ----------
fn build_kernel(state: &KernelState, entry_pc: u32) -> Value {
let mut o = Map::new();
o.insert("schema_version".into(), json!(SCHEMA_VERSION));
o.insert("engine".into(), json!(ENGINE));
let mut entries: Vec<(u64, Value)> = Vec::new();
for (handle, obj) in &state.objects {
let tc = type_code(obj);
let sid = stable_object_id(tc, *handle);
let mut e = Map::new();
e.insert(
"handle_semantic_id".into(),
json!(format!("{:016x}", sid)),
);
e.insert("raw_handle_id".into(), json!(hex32(*handle)));
e.insert("type".into(), json!(type_name(obj)));
e.insert("type_code".into(), json!(tc));
e.insert("name".into(), Value::Null);
let mut details = Map::new();
if let KernelObject::Thread { id, hw_id, exit_code, .. } = obj {
details.insert("thread_id".into(), json!(*id));
details.insert(
"is_entry_thread".into(),
json!(*id == xenia_cpu::scheduler::INITIAL_GUEST_TID),
);
details.insert("hw_id".into(), json!(hw_id.map(|v| v as u32)));
details.insert("exit_code".into(), json!(*exit_code));
details.insert(
"entry_pc".into(),
json!(hex32(if *id == xenia_cpu::scheduler::INITIAL_GUEST_TID {
entry_pc
} else {
0
})),
);
}
e.insert("details".into(), Value::Object(details));
entries.push((sid, Value::Object(e)));
}
entries.sort_by_key(|(s, _)| *s);
let objs: Vec<Value> = entries.into_iter().map(|(_, v)| v).collect();
o.insert("objects".into(), Value::Array(objs));
o.insert("handle_name_table".into(), json!([]));
o.insert("notification_listeners".into(), json!([]));
// Exports — list module/ord/name for every registered handler, hash
// the canonical sorted "<module>!<name>" list. KernelState doesn't
// expose `exports` publicly; we walk the published export-name
// accessor for each (module, ordinal) we know about. As a pragmatic
// shortcut: emit the count via `KernelState::export_name` probes.
// For the diff this is informational; the sha256 over the sorted
// name list is the canonical comparison key.
let mut export_names: Vec<String> = Vec::new();
for module in &[ModuleId::Xboxkrnl, ModuleId::Xam, ModuleId::Xbdm] {
let module_str = match module {
ModuleId::Xboxkrnl => "xboxkrnl.exe",
ModuleId::Xam => "xam.xex",
ModuleId::Xbdm => "xbdm.xex",
};
for ord in 1..=0x1000u32 {
if let Some(name) = state.export_name(*module, ord) {
export_names.push(format!("{}!{}", module_str, name));
}
}
}
export_names.sort();
let joined = export_names.join("\n");
let sha = sha256_hex(joined.as_bytes());
let sample: Vec<Value> = export_names.iter().take(32).map(|n| json!(n)).collect();
o.insert(
"exports_registered_count".into(),
json!(export_names.len() as u64),
);
o.insert("exports_registered_sha256".into(), json!(sha));
o.insert("exports_registered_sample".into(), Value::Array(sample));
o.insert(
"deterministic_skip".into(),
json!(["raw_handle_id", "exports_registered_count"]),
);
Value::Object(o)
}
// ---------- vfs.json ----------
fn build_vfs(state: &KernelState) -> Value {
let mut o = Map::new();
o.insert("schema_version".into(), json!(SCHEMA_VERSION));
o.insert("engine".into(), json!(ENGINE));
// Canonical probe set — same order as canary's, sorted alphabetically
// so the diff tool can compare positionally.
let mut probe_paths: Vec<&str> = vec![
"\\Device\\Cdrom0",
"\\Device\\Cdrom0\\default.xex",
"\\Device\\Cdrom0\\dat",
"\\Device\\Cdrom0\\dat\\movie",
"\\Device\\Cdrom0\\dat\\movie\\opening.bik",
"game:\\default.xex",
"game:\\dat",
"cache:\\",
"cache:\\nonexistent_probe",
"\\Device\\HardDisk0\\Partition1",
];
probe_paths.sort();
let mut probes = Vec::new();
for p in &probe_paths {
let (resolved, is_dir, size) = probe_vfs(state, p);
probes.push(json!({
"path": p,
"resolved": resolved,
"is_directory": is_dir,
"size": size,
}));
}
o.insert("resolve_path_probes".into(), Value::Array(probes));
o.insert(
"mounted_devices_observed_count".into(),
json!(state.vfs.is_some() as u32),
);
// cache_root_listing — recursive walk of cache_root if set. Empty
// post-AUDIT-038-wipe.
let listing = if let Some(root) = &state.cache_root {
walk_cache_root(root)
} else {
Vec::new()
};
o.insert("cache_root_listing".into(), Value::Array(listing));
o.insert("deterministic_skip".into(), json!(["host_path_realpath"]));
Value::Object(o)
}
fn probe_vfs(state: &KernelState, path: &str) -> (bool, Option<bool>, Option<u64>) {
let Some(vfs) = state.vfs.as_ref() else {
return (false, None, None);
};
// Strip leading device prefix for ours's API (relative paths only).
let normalized = if let Some(stripped) = path.strip_prefix("\\Device\\Cdrom0\\") {
stripped
} else if let Some(stripped) = path.strip_prefix("game:\\") {
stripped
} else if path == "\\Device\\Cdrom0" || path == "game:\\" || path == "cache:\\" {
// Root listing.
match vfs.list_root() {
Ok(_) => return (true, Some(true), None),
Err(_) => return (false, None, None),
}
} else {
return (false, None, None);
};
match vfs.stat(normalized) {
Ok(entry) => (true, Some(entry.is_directory), Some(entry.size)),
Err(_) => (false, None, None),
}
}
fn walk_cache_root(root: &Path) -> Vec<Value> {
fn walk(root: &Path, dir: &Path, out: &mut Vec<Value>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
walk(root, &p, out);
} else if let Ok(bytes) = std::fs::read(&p) {
let rel = p.strip_prefix(root).unwrap_or(&p).to_string_lossy().to_string();
out.push(json!({
"relpath": rel,
"size": bytes.len() as u64,
"sha256": sha256_hex(&bytes),
}));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort_by(|a, b| a["relpath"].as_str().cmp(&b["relpath"].as_str()));
out
}
// ---------- config.json ----------
fn build_config(state: &KernelState, mem: &GuestMemory, entry_pc: u32) -> Value {
let mut o = Map::new();
o.insert("schema_version".into(), json!(SCHEMA_VERSION));
o.insert("engine".into(), json!(ENGINE));
o.insert("build_id".into(), json!("ours-phaseB"));
o.insert("iso_path".into(), json!(""));
o.insert("xex_entry_point".into(), json!(hex32(entry_pc)));
o.insert("xex_image_base".into(), json!(hex32(state.image_base)));
// image_size: walk forward from image_base until we hit an uncommitted
// page. This matches canary's XexModule::image_size() semantics
// closely enough for an entry-point snapshot.
let mut image_size: u32 = 0;
let mut a = state.image_base;
while mem.is_mapped(a) {
image_size = image_size.wrapping_add(4096);
let next = a.wrapping_add(4096);
if next < a {
break;
}
a = next;
}
o.insert("xex_image_size".into(), json!(image_size));
let image_bytes = read_bytes(mem, state.image_base, image_size);
o.insert(
"image_loaded_sha256".into(),
json!(sha256_hex(&image_bytes)),
);
o.insert(
"xex_header_sha256".into(),
json!(String::from("0").repeat(64)),
);
let mut cvars = Map::new();
cvars.insert(
"phase_b_snapshot_dir".into(),
json!(state
.phase_b_snapshot_dir
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()),
);
cvars.insert(
"phase_b_snapshot_and_exit".into(),
json!(state.phase_b_snapshot_and_exit),
);
cvars.insert(
"phase_b_dump_section_content".into(),
json!(state.phase_b_dump_section_content),
);
o.insert("cvars".into(), Value::Object(cvars));
o.insert("host_ns_at_snapshot".into(), json!(0u64));
o.insert("wall_clock_iso8601".into(), json!("epoch:0"));
o.insert(
"deterministic_skip".into(),
json!([
"host_ns_at_snapshot",
"wall_clock_iso8601",
"build_id",
"iso_path",
"cvars.phase_b_snapshot_dir"
]),
);
Value::Object(o)
}
// ---------- orchestrator ----------
/// Called from `worker_prologue` once per slot visit. Cheap no-op when
/// `phase_b_snapshot_dir == None` (the common case).
pub fn fire_if_entry_thread(
state: &mut KernelState,
mem: &GuestMemory,
pc: u32,
current_tid: u32,
) {
// Hot fast path — empty Option is the default.
let Some(dir) = state.phase_b_snapshot_dir.clone() else {
return;
};
if DONE.load(Ordering::Acquire) {
return;
}
if pc != state.entry_pc || current_tid != xenia_cpu::scheduler::INITIAL_GUEST_TID {
return;
}
if CLAIMED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
write_snapshot(state, mem, &dir, pc, current_tid);
DONE.store(true, Ordering::Release);
if state.phase_b_snapshot_and_exit {
// Use libc::_exit so we skip Rust dtors (and the cleanup ordering
// that comes with them). All snapshot files have been
// fsync()'d in write_file, so the on-disk state is durable.
unsafe {
libc::_exit(0);
}
}
}
fn write_snapshot(
state: &KernelState,
mem: &GuestMemory,
dir: &Path,
entry_pc: u32,
current_tid: u32,
) {
let engine_dir: PathBuf = dir.join("ours");
if let Err(e) = std::fs::create_dir_all(&engine_dir) {
tracing::warn!(
"phase_b_snapshot: failed to create {:?}: {} — snapshot aborted",
engine_dir,
e
);
return;
}
let ctx = state
.scheduler
.current_hw_id()
.map(|hw| state.scheduler.ctx(hw));
let cpu = match ctx {
Some(ctx) => build_cpu_state(ctx, entry_pc, current_tid),
None => {
tracing::warn!("phase_b_snapshot: no current ctx; aborting");
return;
}
};
let memv = build_memory(state, mem, state.phase_b_dump_section_content);
let kern = build_kernel(state, entry_pc);
let vfs = build_vfs(state);
let cfg = build_config(state, mem, entry_pc);
let mut hashes: BTreeMap<String, String> = BTreeMap::new();
for (name, value) in [
("cpu_state.json", &cpu),
("memory.json", &memv),
("kernel.json", &kern),
("vfs.json", &vfs),
("config.json", &cfg),
] {
let body = serialize_sorted(value);
match write_file(&engine_dir.join(name), &body) {
Ok(h) => {
hashes.insert(name.to_string(), h);
}
Err(e) => {
tracing::warn!("phase_b_snapshot: write {} failed: {}", name, e);
}
}
}
let mut manifest_files = Map::new();
for (k, v) in &hashes {
manifest_files.insert(k.clone(), json!(v));
}
let manifest = json!({
"schema_version": SCHEMA_VERSION,
"engine": ENGINE,
"files": Value::Object(manifest_files),
});
let body = serialize_sorted(&manifest);
let _ = write_file(&engine_dir.join("manifest.json"), &body);
// Phase C: when dump_section_content is on, write raw bytes of the
// XEX image region to <engine_dir>/image.bin. This is the only
// region positionally matched between canary and ours, so it's the
// only one suitable for byte-level diff.
if state.phase_b_dump_section_content && state.image_base != 0 {
let mut sz: u32 = 0;
let mut a = state.image_base;
while mem.is_mapped(a) {
sz = sz.wrapping_add(4096);
let next = a.wrapping_add(4096);
if next < a {
break;
}
a = next;
}
if sz > 0 {
let bytes = read_bytes(mem, state.image_base, sz);
if let Err(e) = std::fs::write(engine_dir.join("image.bin"), &bytes) {
tracing::warn!("phase_b_snapshot: image.bin write failed: {}", e);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ pub fn register_exports(state: &mut KernelState) {
// Task
state.register_export(Xam, 0x01AF, "XamTaskSchedule", xam_task_schedule);
state.register_export(Xam, 0x01B1, "XamTaskCloseHandle", stub_success);
state.register_export(Xam, 0x01B1, "XamTaskCloseHandle", xam_task_close_handle);
state.register_export(Xam, 0x01B3, "XamTaskShouldExit", stub_return_zero);
// Alloc
@@ -56,7 +56,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xam, 0x0258, "XamContentCreate", stub_success);
state.register_export(Xam, 0x025A, "XamContentClose", stub_success);
state.register_export(Xam, 0x025B, "XamContentDelete", stub_success);
state.register_export(Xam, 0x025C, "XamContentCreateEnumerator", stub_success);
state.register_export(Xam, 0x025C, "XamContentCreateEnumerator", xam_content_create_enumerator);
state.register_export(Xam, 0x025E, "XamContentGetDeviceData", stub_success);
state.register_export(Xam, 0x025F, "XamContentGetDeviceName", stub_success);
state.register_export(Xam, 0x0260, "XamContentSetThumbnail", stub_success);
@@ -80,7 +80,10 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xam, 0x02BC, "XamShowSigninUI", stub_success);
state.register_export(Xam, 0x02C1, "XamShowKeyboardUI", stub_success);
state.register_export(Xam, 0x02CB, "XamShowDeviceSelectorUI", stub_success);
state.register_export(Xam, 0x02D5, "XamShowGamerCardUIForXUID", stub_success);
// Class-E in canary (table entry only, no DECLARE_XAM_EXPORT shim) — canary's
// syscall-thunk path emits no Phase A events. Mirror via
// `register_unimplemented_export` so ours stays silent too. C+6.5-pattern fix.
state.register_unimplemented_export(Xam, 0x02D5, "XamShowGamerCardUIForXUID", stub_success);
state.register_export(Xam, 0x02D9, "XamShowDirtyDiscErrorUI", stub_success);
state.register_export(Xam, 0x02DC, "XamShowMessageBoxUIEx", stub_success);
@@ -262,6 +265,13 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
*slot = Some(hw_id);
}
// Phase C+16: thread self-reference. See `ex_create_thread`.
// The canary path `XamTaskSchedule_entry` → `thread->Create()`
// → `RetainHandle()` (xthread.cc:414) installs this; without
// it, `XamTaskCloseHandle` → `NtClose` destroys the handle
// prematurely. This is the exact Phase A idx=102168 fix on
// the main chain.
state.retain_handle(handle);
if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle);
}
@@ -284,6 +294,41 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
}
}
/// `XamTaskCloseHandle(handle)` — release the handle minted by
/// `XamTaskSchedule`. Mirrors xenia-canary's `XamTaskCloseHandle_entry`
/// (xam_task.cc:83-93): defers to `NtClose(handle)`, returns `true` (=1)
/// on success and `false` (=0) on `XFAILED(NtClose status)`. Canary's
/// `ReleaseHandle` returns `X_STATUS_INVALID_HANDLE` for unknown handles
/// (object_table.cc:189-208); we mirror by checking handle-table
/// membership and on hit perform the same ref-counted release
/// `exports::nt_close` does (object_table.cc:194-208). Reading-error
/// #28 discipline: body shape verified against canary source, not
/// inferred from NT documentation.
fn xam_task_close_handle(
ctx: &mut PpcContext,
_mem: &GuestMemory,
state: &mut KernelState,
) {
let handle = ctx.gpr[3] as u32;
// Phase C+19: validate against the canonical slot (alias-aware) so a
// duplicated thread-task handle still passes the XFAILED check.
let canonical = state.resolve_handle(handle);
if !state.objects.contains_key(&canonical) && !state.handle_refcount.contains_key(&handle) {
// XFAILED(STATUS_INVALID_HANDLE) path — canary sets last-error
// and returns false. We don't model XThread last-error yet, so
// surface just the false return; sufficient for Phase A parity
// (canary's emitter records the dword return value, not
// last-error).
ctx.gpr[3] = 0;
return;
}
// Phase C+19: route through the shared close path so the alias map,
// slot count, async-file side-table, and handle.destroy event are all
// handled symmetrically with `nt_close`.
crate::exports::close_handle_internal(state, handle);
ctx.gpr[3] = 1;
}
// ===== Alloc =====
fn xam_alloc(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
@@ -306,20 +351,52 @@ fn xam_alloc(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
// ===== User =====
/// Canary default profile XUID — `0xB13EBABEBABEBABE` per
/// `xenia-canary/src/xenia/kernel/xam/user_profile.cc` defaults. The
/// `0xC000000000000000` mask bits tag the XUID as a local profile, which
/// is what title 58410A1F probes via `xuid & 0x00C0000000000000` (per the
/// comment in `UserProfile::UserProfile`). Used by `XamUserGetXUID` /
/// `XamUserGetSigninInfo` / friends so signed-in slot 0 reports a real
/// id instead of zero.
pub const DEFAULT_USER_XUID: u64 = 0xB13E_BABE_BABE_BABE;
/// Canary default gamertag.
pub const DEFAULT_USER_GAMERTAG: &str = "User";
fn xam_user_get_xuid(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = user_index, r4 = xuid_ptr
let user_index = ctx.gpr[3] as u32;
let xuid_ptr = ctx.gpr[4] as u32;
let xuid = if user_index == 0 { DEFAULT_USER_XUID } else { 0 };
if xuid_ptr != 0 {
mem.write_u64(xuid_ptr, 0); // No XUID
mem.write_u64(xuid_ptr, xuid);
}
ctx.gpr[3] = 0;
}
fn xam_user_get_name(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = user_index, r4 = buffer, r5 = buffer_size
let user_index = ctx.gpr[3] as u32;
let buffer = ctx.gpr[4] as u32;
if buffer != 0 {
mem.write_u8(buffer, 0); // Empty string
let buffer_size = ctx.gpr[5] as u32;
if buffer == 0 || buffer_size == 0 {
ctx.gpr[3] = 0;
return;
}
if user_index == 0 {
// Write the canary default gamertag, NUL-terminated, truncated to
// fit `buffer_size`. Canary returns the gamertag from the active
// profile; ours uses the fixed default.
let bytes = DEFAULT_USER_GAMERTAG.as_bytes();
let n = (bytes.len() as u32).min(buffer_size.saturating_sub(1));
for i in 0..n {
mem.write_u8(buffer + i, bytes[i as usize]);
}
mem.write_u8(buffer + n, 0);
} else {
// No profile in slots 1-3; canary's `XamUserGetName` returns
// empty string in that case.
mem.write_u8(buffer, 0);
}
ctx.gpr[3] = 0;
}
@@ -335,6 +412,104 @@ fn xam_user_get_signin_state(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &
ctx.gpr[3] = if user_index == 0 { 1 } else { 0 };
}
// ===== Content =====
/// `XamContentCreateEnumerator(user_index, device_id, content_type,
/// content_flags, items_per_enumerate, buffer_size_ptr, handle_out)`.
/// Mirrors xenia-canary `XamContentCreateEnumerator_entry`
/// (xam_content.cc:129-220). Reading-error #28 discipline applied: body
/// shape verified against canary source.
///
/// Canary's normal-path success returns `X_ERROR_SUCCESS` (0) with a
/// fresh enumerator handle in `*handle_out`. The Phase A oracle at
/// `tid_event_idx=102197` shows canary returning `X_ERROR_NO_SUCH_USER`
/// (`0x525`, 1317) with empty side_effects — the call hit the
/// `if (!user) return X_ERROR_NO_SUCH_USER;` early-return at
/// xam_content.cc:153-155 because no profile is installed in canary's
/// default `--mute=true` config (no `--profile_slot_*` flags).
///
/// Ours has no profile-manager state, so all `user_index != 0xFE`
/// queries miss. Mirror the early-return: write `*buffer_size_ptr` per
/// canary line 145-147 (which executes *before* the user check) and
/// return `X_ERROR_NO_SUCH_USER`. Implementing real content enumeration
/// is an XAM-content-subsystem session (escalation-tier scope), not
/// this fix.
///
/// Side note on internal consistency: ours's `xam_user_get_signin_state`
/// returns 1 for `user_index == 0`, conflicting with the "no profile"
/// model used here. That divergence surfaces later in the Phase A trace
/// (idx 107996+) and is a separate fix — deferred per single-fix
/// discipline.
fn xam_content_create_enumerator(
ctx: &mut PpcContext,
mem: &GuestMemory,
_state: &mut KernelState,
) {
const X_USER_INDEX_NONE: u32 = 0xFE;
const X_USER_INDEX_LATEST: u32 = 0xFD;
const X_USER_MAX_USER_COUNT: u32 = 4;
const X_E_INVALIDARG: u32 = 0x8007_0057;
const X_ERROR_NO_SUCH_USER: u32 = 0x0000_0525;
const X_ERROR_SUCCESS: u32 = 0;
const X_CONTENT_DATA_SIZE: u32 = 0x134;
let user_index = ctx.gpr[3] as u32;
let device_id = ctx.gpr[4] as u32;
let _content_type = ctx.gpr[5] as u32;
let _content_flags = ctx.gpr[6] as u32;
let items_per_enumerate = ctx.gpr[7] as u32;
let buffer_size_ptr = ctx.gpr[8] as u32;
let handle_out = ctx.gpr[9] as u32;
// Canary xam_content.cc:135-143 — `device_id != 0 && device_info ==
// nullptr` OR `!handle_out` → X_E_INVALIDARG, with
// `*buffer_size_ptr = 0` if non-null. Ours's `GetDummyDeviceInfo`
// accepts HDD (1) and ODD (2) per dummy_device_info.cc. For
// simplicity (and because Sylpheed exercises `device_id == 0` on
// first call per canary trace), accept device_id ∈ {0, 1, 2}; reject
// larger.
let device_unknown = device_id != 0 && device_id > 2;
if device_unknown || handle_out == 0 {
if buffer_size_ptr != 0 {
mem.write_u32(buffer_size_ptr, 0);
}
ctx.gpr[3] = X_E_INVALIDARG as u64;
return;
}
// Canary line 145-147 — written *before* the user-profile check.
if buffer_size_ptr != 0 {
mem.write_u32(buffer_size_ptr, X_CONTENT_DATA_SIZE.wrapping_mul(items_per_enumerate));
}
// Canary line 150-158 — `if (user_index != XUserIndexNone) { user =
// GetUserProfile(user_index); if (!user) return X_ERROR_NO_SUCH_USER;
// xuid = user->xuid(); }`. Ours has no profile manager, so any
// `user_index != 0xFE` misses. Also reject indices ≥ 4 (canary's
// GetUserProfile out-of-range path returns nullptr, falling into
// the same branch). `XUserIndexLatest` (0xFD) is special-cased in
// canary's GetUserProfile but still produces nullptr without a
// profile installed.
if user_index != X_USER_INDEX_NONE {
let out_of_range = user_index >= X_USER_MAX_USER_COUNT
&& user_index != X_USER_INDEX_LATEST;
let _ = out_of_range; // documentation only — both branches → no user
ctx.gpr[3] = X_ERROR_NO_SUCH_USER as u64;
return;
}
// user_index == XUserIndexNone: canary skips profile lookup and
// proceeds to enumerator creation. With no installed content the
// enumerator init succeeds and `*handle_out` receives a fresh
// handle. We don't have an XEnumerator object model yet; return
// success with handle_out=0 as a stub. Defensive: never exercised
// in the current Phase A oracle (canary fires user_index!=0xFE).
if handle_out != 0 {
mem.write_u32(handle_out, 0);
}
ctx.gpr[3] = X_ERROR_SUCCESS as u64;
}
// ===== System =====
fn xam_get_execution_id(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
@@ -424,7 +599,8 @@ fn xam_notify_create_listener(ctx: &mut PpcContext, mem: &GuestMemory, state: &m
}
fn xnotify_get_next(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
let handle = ctx.gpr[3] as u32;
// Phase C+19: canonicalize dup ids → source.
let handle = state.resolve_handle(ctx.gpr[3] as u32);
let match_id = ctx.gpr[4] as u32;
let id_ptr = ctx.gpr[5] as u32;
let param_ptr = ctx.gpr[6] as u32;
@@ -578,6 +754,206 @@ mod tests {
assert_eq!(ctx.gpr[3], 8);
}
/// XamTaskCloseHandle on a valid Thread handle must release the
/// object (ref-counted) and return 1, matching canary's
/// `XamTaskCloseHandle_entry` (xam_task.cc:83-93) which delegates
/// to `NtClose` and returns `true` on `XSUCCESS`.
#[test]
fn xam_task_close_handle_valid_handle_returns_one_and_releases() {
let (mut ctx, mem, mut state) = fresh();
let handle = state.alloc_handle_for(KernelObject::Event {
manual_reset: true,
signaled: false,
waiters: Vec::new(),
});
// alloc_handle_for is expected to install a refcount of 1.
assert!(
state.objects.contains_key(&handle),
"fresh handle should be in object table"
);
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], 1,
"valid handle close must return 1 (canary parity, xam_task.cc:92)"
);
assert!(
!state.objects.contains_key(&handle),
"object must be dropped when refcount hits zero"
);
assert!(
!state.handle_refcount.contains_key(&handle),
"refcount entry must be scrubbed"
);
}
/// XamTaskCloseHandle on an unknown handle must return 0 (false),
/// matching canary's `XFAILED(NtClose)` branch returning `false`
/// after `XThread::SetLastError(rtl_dos_error)`.
#[test]
fn xam_task_close_handle_invalid_handle_returns_zero() {
let (mut ctx, mem, mut state) = fresh();
ctx.gpr[3] = 0xDEAD_BEEFu64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], 0,
"invalid handle close must return 0 (canary parity, xam_task.cc:89)"
);
}
/// XamTaskCloseHandle with a duplicated (refcounted) handle must
/// keep the object alive after one close and drop it after two.
/// Mirrors canary's `ObjectTable::ReleaseHandle`
/// (object_table.cc:200-208).
#[test]
fn xam_task_close_handle_respects_refcount() {
let (mut ctx, mem, mut state) = fresh();
let handle = state.alloc_handle_for(KernelObject::Event {
manual_reset: false,
signaled: false,
waiters: Vec::new(),
});
// Bump refcount to simulate NtDuplicateObject aliasing.
*state.handle_refcount.entry(handle).or_insert(1) += 1;
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 1, "first close returns 1");
assert!(
state.objects.contains_key(&handle),
"object must survive first close (refcount > 0)"
);
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 1, "second close also returns 1");
assert!(
!state.objects.contains_key(&handle),
"object must be dropped after second close (refcount == 0)"
);
}
/// End-to-end parity: spawn an XAM task with `xam_task_schedule`,
/// then close the resulting handle via `xam_task_close_handle`.
/// This is the exact dataflow Sylpheed exercises at Phase A
/// `tid_event_idx=102156..102158` on the main chain.
///
/// Phase C+16: After C+16, `xam_task_schedule` installs a thread
/// self-reference (refcount=2 post-spawn), so the user's NtClose
/// (via XamTaskCloseHandle) only drops to refcount=1; the handle
/// survives until the spawned thread exits. This mirrors canary's
/// `XThread::Create` → `RetainHandle()` → `XThread::Exit` →
/// `ReleaseHandle()` lifecycle (xthread.cc:414/524).
#[test]
fn xam_task_schedule_then_close_round_trip_returns_one() {
let (mut ctx, mut mem, mut state) = fresh();
let callback_pc: u32 = 0x824a_93c8;
let message_ptr: u32 = SCRATCH_BASE + 0x100;
let handle_out: u32 = SCRATCH_BASE + 0x200;
ctx.gpr[3] = callback_pc as u64;
ctx.gpr[4] = message_ptr as u64;
ctx.gpr[5] = 0;
ctx.gpr[6] = handle_out as u64;
ctx.lr = 0x824a_9a14;
xam_task_schedule(&mut ctx, &mut mem, &mut state);
assert_eq!(ctx.gpr[3], 0, "schedule succeeded");
let handle = mem.read_u32(handle_out);
// Phase C+16: post-spawn refcount must be 2 (creator + self-ref).
assert_eq!(
state.handle_refcount.get(&handle).copied(),
Some(2),
"post-spawn refcount must include the thread self-reference"
);
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], 1,
"schedule→close round-trip must return 1 (Phase A idx=102158 parity)"
);
// Phase C+16: after close, the self-ref still holds the object.
assert!(
state.objects.contains_key(&handle),
"object must survive XamTaskCloseHandle because the spawned thread holds a self-ref"
);
assert_eq!(
state.handle_refcount.get(&handle).copied(),
Some(1),
"post-close refcount = self-ref only (canary XThread::Create::RetainHandle parity)"
);
}
/// Phase C+16: refcount lifecycle balance test.
/// Schedule task → close handle (refcount 2→1) → simulate thread
/// exit by calling `release_handle` (refcount 1→0). After both:
/// object destroyed, refcount entry scrubbed. Mirrors canary's
/// `XamTaskCloseHandle`→`NtClose`+`XThread::Exit::ReleaseHandle`.
#[test]
fn xam_task_schedule_close_then_thread_exit_destroys_handle() {
let (mut ctx, mut mem, mut state) = fresh();
let callback_pc: u32 = 0x824a_93c8;
let message_ptr: u32 = SCRATCH_BASE + 0x100;
let handle_out: u32 = SCRATCH_BASE + 0x200;
ctx.gpr[3] = callback_pc as u64;
ctx.gpr[4] = message_ptr as u64;
ctx.gpr[5] = 0;
ctx.gpr[6] = handle_out as u64;
ctx.lr = 0x824a_9a14;
xam_task_schedule(&mut ctx, &mut mem, &mut state);
let handle = mem.read_u32(handle_out);
// User closes the handle (refcount 2→1, object survives).
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert!(state.objects.contains_key(&handle));
// Simulate thread exit releasing the self-ref (refcount 1→0).
let destroyed = state.release_handle(handle);
assert!(destroyed, "release_handle must return true on final ref drop");
assert!(
!state.objects.contains_key(&handle),
"object must be destroyed once both user-ref and self-ref are released"
);
assert!(
!state.handle_refcount.contains_key(&handle),
"refcount entry must be scrubbed"
);
}
/// Phase C+16: thread-exit-before-close ordering. Tests the reverse
/// of the prior case — thread exits first (self-ref released), user
/// then closes (creator-ref released → destroy). Both orderings
/// must converge on a clean destroy with no double-free.
#[test]
fn xam_task_thread_exit_then_close_destroys_handle() {
let (mut ctx, mut mem, mut state) = fresh();
let callback_pc: u32 = 0x824a_93c8;
let message_ptr: u32 = SCRATCH_BASE + 0x100;
let handle_out: u32 = SCRATCH_BASE + 0x200;
ctx.gpr[3] = callback_pc as u64;
ctx.gpr[4] = message_ptr as u64;
ctx.gpr[5] = 0;
ctx.gpr[6] = handle_out as u64;
ctx.lr = 0x824a_9a14;
xam_task_schedule(&mut ctx, &mut mem, &mut state);
let handle = mem.read_u32(handle_out);
// Thread exits first (releases self-ref, refcount 2→1).
let destroyed_first = state.release_handle(handle);
assert!(!destroyed_first, "self-ref drop must not destroy (creator still holds)");
assert!(state.objects.contains_key(&handle));
// User closes (refcount 1→0 → destroy).
ctx.gpr[3] = handle as u64;
xam_task_close_handle(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 1, "close returns 1");
assert!(!state.objects.contains_key(&handle));
assert!(!state.handle_refcount.contains_key(&handle));
}
#[test]
fn xam_user_get_signin_state_user0_signed_in_locally() {
let (mut ctx, mem, mut state) = fresh();
@@ -688,4 +1064,113 @@ mod tests {
assert_eq!(mem.read_u32(id_ptr), 0);
assert_eq!(mem.read_u32(param_ptr), 0);
}
/// Phase A oracle case at `tid_event_idx=102197`: canary returns
/// `X_ERROR_NO_SUCH_USER` (0x525) because no profile is installed.
/// Sylpheed must be querying with a `user_index < 4`.
#[test]
fn xam_content_create_enumerator_returns_no_such_user_for_user0() {
let (mut ctx, mem, mut state) = fresh();
let buffer_size_ptr = SCRATCH_BASE + 0x100;
let handle_out = SCRATCH_BASE + 0x200;
ctx.gpr[3] = 0; // user_index = 0 (signed-in slot, no profile in ours)
ctx.gpr[4] = 1; // device_id = HDD
ctx.gpr[5] = 0x0000_0001; // content_type
ctx.gpr[6] = 0; // content_flags
ctx.gpr[7] = 4; // items_per_enumerate
ctx.gpr[8] = buffer_size_ptr as u64;
ctx.gpr[9] = handle_out as u64;
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
assert_eq!(
ctx.gpr[3], 0x0000_0525,
"canary mirror: X_ERROR_NO_SUCH_USER for any user_index < 4"
);
// Canary writes buffer_size_ptr BEFORE the user-profile check;
// the X_ERROR_NO_SUCH_USER path keeps the computed size value.
assert_eq!(
mem.read_u32(buffer_size_ptr),
0x134 * 4,
"buffer_size_ptr must equal sizeof(XCONTENT_DATA) * items_per_enumerate"
);
}
#[test]
fn xam_content_create_enumerator_invalid_handle_out_returns_invalidarg() {
let (mut ctx, mem, mut state) = fresh();
let buffer_size_ptr = SCRATCH_BASE + 0x100;
// Seed scratch with a sentinel so we can detect the buffer_size
// = 0 reset.
mem.write_u32(buffer_size_ptr, 0xDEAD_BEEF);
ctx.gpr[3] = 0;
ctx.gpr[4] = 1;
ctx.gpr[5] = 0x0000_0001;
ctx.gpr[6] = 0;
ctx.gpr[7] = 4;
ctx.gpr[8] = buffer_size_ptr as u64;
ctx.gpr[9] = 0; // handle_out = NULL → X_E_INVALIDARG
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 0x8007_0057);
assert_eq!(
mem.read_u32(buffer_size_ptr),
0,
"X_E_INVALIDARG path resets *buffer_size_ptr to 0"
);
}
#[test]
fn xam_content_create_enumerator_unknown_device_returns_invalidarg() {
let (mut ctx, mem, mut state) = fresh();
let buffer_size_ptr = SCRATCH_BASE + 0x100;
let handle_out = SCRATCH_BASE + 0x200;
mem.write_u32(buffer_size_ptr, 0xDEAD_BEEF);
ctx.gpr[3] = 0;
ctx.gpr[4] = 99; // device_id = unknown
ctx.gpr[5] = 0x0000_0001;
ctx.gpr[6] = 0;
ctx.gpr[7] = 4;
ctx.gpr[8] = buffer_size_ptr as u64;
ctx.gpr[9] = handle_out as u64;
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 0x8007_0057);
assert_eq!(mem.read_u32(buffer_size_ptr), 0);
}
/// `user_index == XUserIndexNone (0xFE)` skips the profile check;
/// canary proceeds to enumerator creation and returns SUCCESS.
/// Defensive coverage — not currently exercised by Phase A.
#[test]
fn xam_content_create_enumerator_user_none_returns_success() {
let (mut ctx, mem, mut state) = fresh();
let buffer_size_ptr = SCRATCH_BASE + 0x100;
let handle_out = SCRATCH_BASE + 0x200;
ctx.gpr[3] = 0xFE; // XUserIndexNone
ctx.gpr[4] = 1;
ctx.gpr[5] = 0x0000_0001;
ctx.gpr[6] = 0;
ctx.gpr[7] = 2;
ctx.gpr[8] = buffer_size_ptr as u64;
ctx.gpr[9] = handle_out as u64;
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 0);
assert_eq!(mem.read_u32(buffer_size_ptr), 0x134 * 2);
}
/// Out-of-range user_index (>=4 and !=0xFD) takes the same
/// no-such-user path because canary's `GetUserProfile` returns
/// nullptr for those indices.
#[test]
fn xam_content_create_enumerator_out_of_range_user_returns_no_such_user() {
let (mut ctx, mem, mut state) = fresh();
let buffer_size_ptr = SCRATCH_BASE + 0x100;
let handle_out = SCRATCH_BASE + 0x200;
ctx.gpr[3] = 7; // out of range, < XUserIndexLatest
ctx.gpr[4] = 0;
ctx.gpr[5] = 0x0000_0001;
ctx.gpr[6] = 0;
ctx.gpr[7] = 1;
ctx.gpr[8] = buffer_size_ptr as u64;
ctx.gpr[9] = handle_out as u64;
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
assert_eq!(ctx.gpr[3], 0x0000_0525);
}
}

View File

@@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333);
/// queueing unbounded callbacks while injection is starved.
pub const XAUDIO_QUEUE_CAP: usize = 16;
/// Phase HostAudioEager (2026-05-19): initial seeded fire count at
/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary
/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210)
/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment
/// canary's `RegisterClient` returns, its already-running host worker
/// thread has 8 buffer-complete fires queued to drain.
///
/// In ours, the dedicated guest audio worker (spawned at the same
/// register call) can't be HOST-threaded; instead we seed the pending
/// FIFO so the round prologue's `try_inject_audio_callback` injects
/// the first callback on the very next round — well before tid=1
/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15
/// in canary, tid=9/10 in ours). This fixes the ordering issue where
/// the 48k-instruction ticker delay let tid=9/10 spawn and enter
/// their spin loop on the uninitialized voice struct before the
/// callback could modify it.
pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8;
#[derive(Debug, Clone, Copy)]
pub struct XAudioClient {
pub callback_pc: u32,
@@ -155,6 +173,28 @@ impl XAudioState {
}
}
/// Phase HostAudioEager: enqueue `n` buffer-complete fires for a
/// specific client slot. Used by `XAudioRegisterRenderDriverClient`
/// to mirror canary's `client_semaphore->Release(queued_frames_)`
/// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid
/// unbounded growth if the caller seeds aggressively. Returns the
/// actual number of fires enqueued.
pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize {
if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() {
return 0;
}
let mut queued = 0;
for _ in 0..n {
if self.pending.len() >= XAUDIO_QUEUE_CAP {
self.dropped += 1;
break;
}
self.pending.push_back(index);
queued += 1;
}
queued
}
pub fn peek_next(&self) -> Option<usize> {
self.pending.front().copied()
}
@@ -320,6 +360,51 @@ mod tests {
assert!(s.last_instant.is_some());
}
#[test]
fn seed_fires_for_registered_slot_enqueues_n() {
let mut s = XAudioState::default();
let i = s.register(dummy_client(1)).unwrap();
let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES);
assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES);
assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES);
// All enqueued fires reference our slot.
for _ in 0..XAUDIO_REGISTER_SEED_FIRES {
assert_eq!(s.take_next(), Some(i));
}
assert!(s.pending.is_empty());
}
#[test]
fn seed_fires_for_unregistered_slot_is_noop() {
let mut s = XAudioState::default();
// Slot 3 is empty.
let queued = s.seed_fires_for(3, 8);
assert_eq!(queued, 0);
assert!(s.pending.is_empty());
assert_eq!(s.dropped, 0);
}
#[test]
fn seed_fires_for_caps_at_queue_cap_and_counts_drops() {
let mut s = XAudioState::default();
let i = s.register(dummy_client(1)).unwrap();
let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4);
assert_eq!(queued, XAUDIO_QUEUE_CAP);
assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP);
// Excess fires are counted as dropped (per
// existing `enqueue_all_active` discipline).
assert!(s.dropped >= 1);
}
#[test]
fn seed_fires_for_out_of_range_index_is_noop() {
let mut s = XAudioState::default();
s.register(dummy_client(1)).unwrap();
let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4);
assert_eq!(queued, 0);
assert!(s.pending.is_empty());
}
#[test]
fn tick_wallclock_fires_after_period() {
let mut s = XAudioState::default();