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

45
Cargo.lock generated
View File

@@ -463,6 +463,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -951,6 +960,16 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@@ -3439,6 +3458,28 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -5022,7 +5063,11 @@ name = "xenia-kernel"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc",
"metrics", "metrics",
"serde_json",
"sha1",
"sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
"xenia-cpu", "xenia-cpu",

View File

@@ -51,6 +51,8 @@ thiserror = "2"
anyhow = "1" anyhow = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8" aes = "0.8"
duckdb = { version = "1", features = ["bundled"] } duckdb = { version = "1", features = ["bundled"] }

View File

@@ -897,17 +897,12 @@ fn insert_functions(
func_analysis: &FuncAnalysis, func_analysis: &FuncAnalysis,
labels: &HashMap<u32, String>, labels: &HashMap<u32, String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut stmt = conn.prepare( let mut app = conn.appender("functions")?;
"INSERT INTO functions
(address, name, end_address, frame_size, saved_gprs, is_leaf, is_saverestore,
pdata_validated, pdata_length, has_eh)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)?;
for (&addr, fi) in &func_analysis.functions { for (&addr, fi) in &func_analysis.functions {
let name = labels.get(&addr) let name = labels.get(&addr)
.cloned() .cloned()
.unwrap_or_else(|| format!("sub_{addr:08X}")); .unwrap_or_else(|| format!("sub_{addr:08X}"));
stmt.execute(params![ app.append_row(params![
addr as i64, addr as i64,
name, name,
fi.end as i64, fi.end as i64,
@@ -920,6 +915,7 @@ fn insert_functions(
fi.has_eh, fi.has_eh,
])?; ])?;
} }
app.flush()?;
Ok(()) Ok(())
} }
@@ -930,15 +926,13 @@ fn insert_vtables(
_image_base: u32, _image_base: u32,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if vtables.is_empty() { return Ok(()); } if vtables.is_empty() { return Ok(()); }
let mut stmt = conn.prepare( let mut dedup: HashMap<u32, &crate::vtables::Vtable> = HashMap::new();
"INSERT INTO vtables
(address, length, col_address, class_name, rtti_present, base_classes_json)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING"
)?;
let mut count = 0u64;
for v in vtables { for v in vtables {
stmt.execute(params![ dedup.entry(v.address).or_insert(v);
}
let mut app = conn.appender("vtables")?;
for v in dedup.values() {
app.append_row(params![
v.address as i64, v.address as i64,
v.length as i64, v.length as i64,
v.col_address.map(|a| a as i64), v.col_address.map(|a| a as i64),
@@ -946,8 +940,9 @@ fn insert_vtables(
v.rtti_present, v.rtti_present,
v.base_classes_json.as_deref(), v.base_classes_json.as_deref(),
])?; ])?;
count += 1;
} }
app.flush()?;
let count = dedup.len() as u64;
metrics::counter!("db.rows", "table" => "vtables").increment(count); metrics::counter!("db.rows", "table" => "vtables").increment(count);
tracing::info!(rows = count, table = "vtables", "bulk insert complete"); tracing::info!(rows = count, table = "vtables", "bulk insert complete");
Ok(()) Ok(())
@@ -960,17 +955,17 @@ fn insert_methods_and_classes(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if vtables.is_empty() { return Ok(()); } if vtables.is_empty() { return Ok(()); }
// methods rows // methods rows — dedup by (vtable_address, slot), first-write-wins.
let methods = crate::vtables::methods_table(vtables, labels); let methods = crate::vtables::methods_table(vtables, labels);
if !methods.is_empty() { if !methods.is_empty() {
let mut stmt = conn.prepare( let mut idx: HashMap<(u32, u32), usize> = HashMap::new();
"INSERT INTO methods for (i, m) in methods.iter().enumerate() {
(vtable_address, slot, function_address, mangled_name, demangled_name) idx.entry((m.0, m.1)).or_insert(i);
VALUES (?, ?, ?, ?, ?) }
ON CONFLICT DO NOTHING" let mut app = conn.appender("methods")?;
)?; for &i in idx.values() {
for (vt_addr, slot, fn_addr, mangled, demangled) in &methods { let (vt_addr, slot, fn_addr, mangled, demangled) = &methods[i];
stmt.execute(params![ app.append_row(params![
*vt_addr as i64, *vt_addr as i64,
*slot as i64, *slot as i64,
*fn_addr as i64, *fn_addr as i64,
@@ -978,29 +973,33 @@ fn insert_methods_and_classes(
demangled.as_deref(), demangled.as_deref(),
])?; ])?;
} }
metrics::counter!("db.rows", "table" => "methods").increment(methods.len() as u64); app.flush()?;
tracing::info!(rows = methods.len(), table = "methods", "bulk insert complete"); let n = idx.len() as u64;
metrics::counter!("db.rows", "table" => "methods").increment(n);
tracing::info!(rows = n, table = "methods", "bulk insert complete");
} }
// classes rows (deduped by class_name, first-detected wins) // classes rows dedup by class_name, first-detected wins.
let classes = crate::vtables::classes_table(vtables); let classes = crate::vtables::classes_table(vtables);
if !classes.is_empty() { if !classes.is_empty() {
let mut stmt = conn.prepare( let mut idx: HashMap<&str, usize> = HashMap::new();
"INSERT INTO classes for (i, c) in classes.iter().enumerate() {
(name, vtable_address, rtti_present, base_classes_json) idx.entry(c.0.as_str()).or_insert(i);
VALUES (?, ?, ?, ?) }
ON CONFLICT DO NOTHING" let mut app = conn.appender("classes")?;
)?; for &i in idx.values() {
for (name, vt_addr, rtti, bases) in &classes { let (name, vt_addr, rtti, bases) = &classes[i];
stmt.execute(params![ app.append_row(params![
name.as_str(), name.as_str(),
*vt_addr as i64, *vt_addr as i64,
*rtti, *rtti,
bases.as_deref(), bases.as_deref(),
])?; ])?;
} }
metrics::counter!("db.rows", "table" => "classes").increment(classes.len() as u64); app.flush()?;
tracing::info!(rows = classes.len(), table = "classes", "bulk insert complete"); let n = idx.len() as u64;
metrics::counter!("db.rows", "table" => "classes").increment(n);
tracing::info!(rows = n, table = "classes", "bulk insert complete");
} }
Ok(()) Ok(())
@@ -1011,20 +1010,21 @@ fn insert_strings(
strings: &[crate::strings::DetectedString], strings: &[crate::strings::DetectedString],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if strings.is_empty() { return Ok(()); } if strings.is_empty() { return Ok(()); }
let mut stmt = conn.prepare( let mut dedup: HashMap<u32, &crate::strings::DetectedString> = HashMap::new();
"INSERT INTO strings (address, encoding, length, content) VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING"
)?;
let mut count = 0u64;
for s in strings { for s in strings {
stmt.execute(params![ dedup.entry(s.address).or_insert(s);
}
let mut app = conn.appender("strings")?;
for s in dedup.values() {
app.append_row(params![
s.address as i64, s.address as i64,
s.encoding, s.encoding,
s.length as i64, s.length as i64,
s.content.as_str(), s.content.as_str(),
])?; ])?;
count += 1;
} }
app.flush()?;
let count = dedup.len() as u64;
metrics::counter!("db.rows", "table" => "strings").increment(count); metrics::counter!("db.rows", "table" => "strings").increment(count);
tracing::info!(rows = count, table = "strings", "bulk insert complete"); tracing::info!(rows = count, table = "strings", "bulk insert complete");
Ok(()) Ok(())
@@ -1035,31 +1035,17 @@ fn insert_eh_records(
records: &[crate::eh_scope::EhFuncInfo], records: &[crate::eh_scope::EhFuncInfo],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if records.is_empty() { return Ok(()); } if records.is_empty() { return Ok(()); }
let mut stmt_fi = conn.prepare( // Dedup eh_funcinfo by PK (address), first-write-wins.
"INSERT INTO eh_funcinfo // Within a deduped record, unwind/try entries are uniquely indexed by enumerate.
(address, magic, max_state, p_unwind_map, n_try_blocks, let mut fi_idx: HashMap<u32, usize> = HashMap::new();
p_try_block_map, n_ip_map_entries, p_ip_to_state_map, for (i, r) in records.iter().enumerate() {
p_es_type_list, eh_flags) fi_idx.entry(r.address).or_insert(i);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) }
ON CONFLICT DO NOTHING"
)?; let mut app_fi = conn.appender("eh_funcinfo")?;
let mut stmt_unwind = conn.prepare( for &i in fi_idx.values() {
"INSERT INTO eh_unwind_map let r = &records[i];
(funcinfo_address, state_index, to_state, action_pc) app_fi.append_row(params![
VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING"
)?;
let mut stmt_try = conn.prepare(
"INSERT INTO eh_try_blocks
(funcinfo_address, try_index, try_low, try_high, catch_high,
n_catches, p_handler_array)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING"
)?;
let mut n_fi = 0u64;
let mut n_unwind = 0u64;
let mut n_try = 0u64;
for r in records {
stmt_fi.execute(params![
r.address as i64, r.magic as i64, r.max_state as i64, r.address as i64, r.magic as i64, r.max_state as i64,
r.p_unwind_map as i64, r.n_try_blocks as i64, r.p_unwind_map as i64, r.n_try_blocks as i64,
r.p_try_block_map as i64, r.n_ip_map_entries as i64, r.p_try_block_map as i64, r.n_ip_map_entries as i64,
@@ -1067,22 +1053,38 @@ fn insert_eh_records(
r.p_es_type_list.map(|p| p as i64), r.p_es_type_list.map(|p| p as i64),
r.eh_flags.map(|f| f as i64), r.eh_flags.map(|f| f as i64),
])?; ])?;
n_fi += 1; }
for (i, e) in r.unwind_map.iter().enumerate() { app_fi.flush()?;
stmt_unwind.execute(params![ let n_fi = fi_idx.len() as u64;
r.address as i64, i as i64, e.to_state as i64, e.action_pc as i64,
let mut app_unwind = conn.appender("eh_unwind_map")?;
let mut n_unwind = 0u64;
for &i in fi_idx.values() {
let r = &records[i];
for (j, e) in r.unwind_map.iter().enumerate() {
app_unwind.append_row(params![
r.address as i64, j as i64, e.to_state as i64, e.action_pc as i64,
])?; ])?;
n_unwind += 1; n_unwind += 1;
} }
for (i, t) in r.try_blocks.iter().enumerate() { }
stmt_try.execute(params![ app_unwind.flush()?;
r.address as i64, i as i64,
let mut app_try = conn.appender("eh_try_blocks")?;
let mut n_try = 0u64;
for &i in fi_idx.values() {
let r = &records[i];
for (j, t) in r.try_blocks.iter().enumerate() {
app_try.append_row(params![
r.address as i64, j as i64,
t.try_low as i64, t.try_high as i64, t.catch_high as i64, t.try_low as i64, t.try_high as i64, t.catch_high as i64,
t.n_catches as i64, t.p_handler_array as i64, t.n_catches as i64, t.p_handler_array as i64,
])?; ])?;
n_try += 1; n_try += 1;
} }
} }
app_try.flush()?;
metrics::counter!("db.rows", "table" => "eh_funcinfo").increment(n_fi); metrics::counter!("db.rows", "table" => "eh_funcinfo").increment(n_fi);
metrics::counter!("db.rows", "table" => "eh_unwind_map").increment(n_unwind); metrics::counter!("db.rows", "table" => "eh_unwind_map").increment(n_unwind);
metrics::counter!("db.rows", "table" => "eh_try_blocks").increment(n_try); metrics::counter!("db.rows", "table" => "eh_try_blocks").increment(n_try);
@@ -1097,54 +1099,55 @@ fn insert_typed_ind_dispatch(
conn: &Connection, conn: &Connection,
t: &crate::ind_dispatch_typed::TypedIndirectResult, t: &crate::ind_dispatch_typed::TypedIndirectResult,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Dedupe by PK before appending: the Appender writes directly to columnar
// storage and does not enforce primary keys, so duplicates here would
// surface as PK violations at flush. First-write-wins matches the previous
// `ON CONFLICT DO NOTHING` behaviour.
if !t.dispatches.is_empty() { if !t.dispatches.is_empty() {
let mut stmt_site = conn.prepare( let mut sites: HashMap<u32, (u32, u32, usize)> = HashMap::new();
"INSERT INTO indirect_dispatch_sites let mut cands: HashMap<(u32, u32), u32> = HashMap::new();
(dispatch_pc, vptr_offset, slot, candidate_count)
VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING"
)?;
let mut stmt_cand = conn.prepare(
"INSERT INTO indirect_dispatch_candidates
(dispatch_pc, vtable_address, method_address)
VALUES (?, ?, ?) ON CONFLICT DO NOTHING"
)?;
let mut n_sites = 0u64;
let mut n_cand = 0u64;
for d in &t.dispatches { for d in &t.dispatches {
stmt_site.execute(params![ sites.entry(d.dispatch_pc)
d.dispatch_pc as i64, .or_insert((d.vptr_offset, d.slot, d.candidate_vtables.len()));
d.vptr_offset as i64,
d.slot as i64,
d.candidate_vtables.len() as i64,
])?;
n_sites += 1;
for (vt, m) in d.candidate_vtables.iter().zip(d.method_pcs.iter()) { for (vt, m) in d.candidate_vtables.iter().zip(d.method_pcs.iter()) {
stmt_cand.execute(params![ cands.entry((d.dispatch_pc, *vt)).or_insert(*m);
d.dispatch_pc as i64, *vt as i64, *m as i64,
])?;
n_cand += 1;
} }
} }
let mut app_sites = conn.appender("indirect_dispatch_sites")?;
for (pc, (off, slot, count)) in &sites {
app_sites.append_row(params![
*pc as i64, *off as i64, *slot as i64, *count as i64,
])?;
}
app_sites.flush()?;
let mut app_cand = conn.appender("indirect_dispatch_candidates")?;
for ((pc, vt), m) in &cands {
app_cand.append_row(params![*pc as i64, *vt as i64, *m as i64])?;
}
app_cand.flush()?;
let n_sites = sites.len() as u64;
let n_cand = cands.len() as u64;
metrics::counter!("db.rows", "table" => "indirect_dispatch_sites").increment(n_sites); metrics::counter!("db.rows", "table" => "indirect_dispatch_sites").increment(n_sites);
metrics::counter!("db.rows", "table" => "indirect_dispatch_candidates").increment(n_cand); metrics::counter!("db.rows", "table" => "indirect_dispatch_candidates").increment(n_cand);
tracing::info!(sites = n_sites, candidates = n_cand, "typed indirect-dispatch insert complete"); tracing::info!(sites = n_sites, candidates = n_cand, "typed indirect-dispatch insert complete");
} }
if !t.vptr_writes.is_empty() { if !t.vptr_writes.is_empty() {
let mut stmt = conn.prepare( let mut writes: HashMap<(u32, u32, u32), u32> = HashMap::new();
"INSERT INTO vptr_writes
(writer_pc, vtable_address, vptr_offset, writer_function)
VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING"
)?;
let mut n = 0u64;
for w in &t.vptr_writes { for w in &t.vptr_writes {
stmt.execute(params![ writes.entry((w.writer_pc, w.vtable_addr, w.vptr_offset))
w.writer_pc as i64, .or_insert(w.writer_function);
w.vtable_addr as i64,
w.vptr_offset as i64,
w.writer_function as i64,
])?;
n += 1;
} }
let mut app = conn.appender("vptr_writes")?;
for ((wpc, vt, off), wf) in &writes {
app.append_row(params![
*wpc as i64, *vt as i64, *off as i64, *wf as i64,
])?;
}
app.flush()?;
let n = writes.len() as u64;
metrics::counter!("db.rows", "table" => "vptr_writes").increment(n); metrics::counter!("db.rows", "table" => "vptr_writes").increment(n);
tracing::info!(rows = n, "vptr_writes insert complete"); tracing::info!(rows = n, "vptr_writes insert complete");
} }
@@ -1156,26 +1159,31 @@ fn insert_funcptr_arrays(
arrays: &[crate::funcptr_arrays::FuncPtrArray], arrays: &[crate::funcptr_arrays::FuncPtrArray],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if arrays.is_empty() { return Ok(()); } if arrays.is_empty() { return Ok(()); }
let mut stmt_arr = conn.prepare( // Dedup arrays by PK (address), first-write-wins.
"INSERT INTO function_pointer_arrays (address, length, kind) VALUES (?, ?, ?) let mut idx: HashMap<u32, usize> = HashMap::new();
ON CONFLICT DO NOTHING" for (i, a) in arrays.iter().enumerate() {
)?; idx.entry(a.address).or_insert(i);
let mut stmt_ent = conn.prepare( }
"INSERT INTO function_pointer_array_entries (array_address, slot, function_address)
VALUES (?, ?, ?) ON CONFLICT DO NOTHING" let mut app_arr = conn.appender("function_pointer_arrays")?;
)?; for &i in idx.values() {
let mut n_arr = 0u64; let a = &arrays[i];
app_arr.append_row(params![a.address as i64, a.length as i64, a.kind])?;
}
app_arr.flush()?;
let n_arr = idx.len() as u64;
let mut app_ent = conn.appender("function_pointer_array_entries")?;
let mut n_ent = 0u64; let mut n_ent = 0u64;
for a in arrays { for &i in idx.values() {
let inserted = stmt_arr.execute(params![ let a = &arrays[i];
a.address as i64, a.length as i64, a.kind, for (slot, &fn_va) in a.entries.iter().enumerate() {
])?; app_ent.append_row(params![a.address as i64, slot as i64, fn_va as i64])?;
if inserted > 0 { n_arr += 1; }
for (i, &fn_va) in a.entries.iter().enumerate() {
stmt_ent.execute(params![a.address as i64, i as i64, fn_va as i64])?;
n_ent += 1; n_ent += 1;
} }
} }
app_ent.flush()?;
metrics::counter!("db.rows", "table" => "function_pointer_arrays").increment(n_arr); metrics::counter!("db.rows", "table" => "function_pointer_arrays").increment(n_arr);
metrics::counter!("db.rows", "table" => "function_pointer_array_entries").increment(n_ent); metrics::counter!("db.rows", "table" => "function_pointer_array_entries").increment(n_ent);
tracing::info!(arrays = n_arr, entries = n_ent, "function-pointer arrays insert complete"); tracing::info!(arrays = n_arr, entries = n_ent, "function-pointer arrays insert complete");
@@ -1187,13 +1195,8 @@ fn insert_demangled_from_labels(
labels: &HashMap<u32, String>, labels: &HashMap<u32, String>,
import_libraries: &[xenia_xex::header::ImportLibrary], import_libraries: &[xenia_xex::header::ImportLibrary],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut stmt = conn.prepare( // demangled_names has no PK — straight append, no dedup needed.
"INSERT INTO demangled_names let mut app = conn.appender("demangled_names")?;
(address, mangled, raw_demangled, namespace_path, class_name,
method_name, params_signature)
VALUES (?, ?, ?, ?, ?, ?, ?)"
)?;
let mut count = 0u64; let mut count = 0u64;
for (&addr, name) in labels { for (&addr, name) in labels {
@@ -1206,7 +1209,7 @@ fn insert_demangled_from_labels(
continue; continue;
} }
if let Some(d) = crate::demangle::demangle(name) { if let Some(d) = crate::demangle::demangle(name) {
stmt.execute(params![ app.append_row(params![
addr as i64, addr as i64,
d.mangled, d.mangled,
d.raw_demangled, d.raw_demangled,
@@ -1226,7 +1229,7 @@ fn insert_demangled_from_labels(
if let Some(name) = resolved if let Some(name) = resolved
&& let Some(d) = crate::demangle::demangle(name) && let Some(d) = crate::demangle::demangle(name)
{ {
stmt.execute(params![ app.append_row(params![
imp.address as i64, imp.address as i64,
d.mangled, d.mangled,
d.raw_demangled, d.raw_demangled,
@@ -1240,6 +1243,7 @@ fn insert_demangled_from_labels(
} }
} }
app.flush()?;
metrics::counter!("db.rows", "table" => "demangled_names").increment(count); metrics::counter!("db.rows", "table" => "demangled_names").increment(count);
tracing::info!(rows = count, table = "demangled_names", "demangler complete"); tracing::info!(rows = count, table = "demangled_names", "demangler complete");
Ok(()) Ok(())
@@ -1252,14 +1256,15 @@ fn insert_pdata_entries(
if entries.is_empty() { if entries.is_empty() {
return Ok(()); return Ok(());
} }
let mut stmt = conn.prepare( // Dedup by PK (begin_address), first-write-wins.
"INSERT INTO pdata_entries let mut idx: HashMap<u32, usize> = HashMap::new();
(begin_address, end_address, function_length, prolog_length, flags) for (i, e) in entries.iter().enumerate() {
VALUES (?, ?, ?, ?, ?) idx.entry(e.begin_address).or_insert(i);
ON CONFLICT DO NOTHING" }
)?; let mut app = conn.appender("pdata_entries")?;
for e in entries { for &i in idx.values() {
stmt.execute(params![ let e = &entries[i];
app.append_row(params![
e.begin_address as i64, e.begin_address as i64,
e.end_address() as i64, e.end_address() as i64,
e.function_length as i64, e.function_length as i64,
@@ -1267,6 +1272,7 @@ fn insert_pdata_entries(
e.flags as i64, e.flags as i64,
])?; ])?;
} }
app.flush()?;
Ok(()) Ok(())
} }
@@ -1274,9 +1280,8 @@ fn insert_labels(
conn: &Connection, conn: &Connection,
labels: &HashMap<u32, String>, labels: &HashMap<u32, String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut stmt = conn.prepare( // Source is a HashMap so addresses are unique by construction — no dedup needed.
"INSERT INTO labels (address, name, kind) VALUES (?, ?, ?) ON CONFLICT DO NOTHING" let mut app = conn.appender("labels")?;
)?;
for (&addr, name) in labels { for (&addr, name) in labels {
let kind = if name.starts_with("sub_") || name == "entry_point" { let kind = if name.starts_with("sub_") || name == "entry_point" {
"function" "function"
@@ -1291,8 +1296,9 @@ fn insert_labels(
} else { } else {
"other" "other"
}; };
stmt.execute(params![addr as i64, name, kind])?; app.append_row(params![addr as i64, name, kind])?;
} }
app.flush()?;
Ok(()) Ok(())
} }

View File

@@ -242,6 +242,41 @@ enum Commands {
/// line). Stdout when omitted. /// line). Stdout when omitted.
#[arg(long)] #[arg(long)]
lr_trace_out: Option<String>, 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 XISO disc image contents
Browse { Browse {
@@ -405,7 +440,45 @@ fn main() -> Result<()> {
probe_db, probe_db,
lr_trace, lr_trace,
lr_trace_out, 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, &path,
max_instructions, max_instructions,
ips_limit, ips_limit,
@@ -431,7 +504,11 @@ fn main() -> Result<()> {
probe_db.as_deref(), probe_db.as_deref(),
lr_trace.as_deref(), lr_trace.as_deref(),
lr_trace_out.as_deref(), lr_trace_out.as_deref(),
), phase_b_dir,
phase_b_exit,
phase_b_dump,
)
}
Commands::Browse { path } => cmd_browse(&path), Commands::Browse { path } => cmd_browse(&path),
Commands::Info { path } => cmd_info(&path), Commands::Info { path } => cmd_info(&path),
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()), 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>, probe_db: Option<&str>,
lr_trace: Option<&str>, lr_trace: Option<&str>,
lr_trace_out: 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<()> { ) -> Result<()> {
cmd_exec_inner( cmd_exec_inner(
path, path,
@@ -692,6 +772,9 @@ fn cmd_exec(
None, None,
None, None,
false, false,
phase_b_snapshot_dir,
phase_b_snapshot_and_exit,
phase_b_dump_section_content,
) )
} }
@@ -738,6 +821,9 @@ fn cmd_check(
out, out,
expect, expect,
stable_digest, 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_out: Option<&str>,
digest_expect: Option<&str>, digest_expect: Option<&str>,
stable_digest: bool, stable_digest: bool,
phase_b_snapshot_dir: Option<PathBuf>,
phase_b_snapshot_and_exit: bool,
phase_b_dump_section_content: bool,
) -> Result<()> { ) -> Result<()> {
let started = Instant::now(); let started = Instant::now();
let data = load_xex_data(path)?; let data = load_xex_data(path)?;
@@ -840,22 +929,121 @@ fn cmd_exec_inner(
info!(thunks = thunk_map.len(), "import thunks mapped"); info!(thunks = thunk_map.len(), "import thunks mapped");
// ── Phase 2: CPU initialization per xenia-canary ───────────────────── // ── 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_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) mem.alloc(stack_base, stack_size, rw)
.map_err(|e| anyhow::anyhow!("Failed to allocate stack: {}", e))?; .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 pcr_addr = 0x7FFF_0000u32;
let tls_addr = 0x7FFE_0000u32; let tls_addr = 0x7FFE_0000u32;
mem.alloc(pcr_addr, 0x1000, rw)?; mem.alloc(pcr_addr, 0x1000, rw)?;
mem.alloc(tls_addr, 0x1000, rw)?; mem.alloc(tls_addr, tls_total_bytes, rw)?;
// Initialize PCR structure // Copy the title's TLS template (initial-value image for `__declspec(thread)`
mem.write_u32(pcr_addr, tls_addr); // PCR->tls_ptr // variables) into the head of the TLS region. Canary mirrors this with
mem.write_u32(pcr_addr + 0x100, 0x1000); // PCR->current_thread (fake) // `Memory::Copy(tls_static_address_, tls_header->raw_data_address,
mem.write_u32(pcr_addr + 0x150, 0); // PCR->dpc_active // 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. // 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); let mut kernel = xenia_kernel::KernelState::with_gpu(gpu_backend);
kernel.image_base = base; kernel.image_base = base;
kernel.xex_system_flags = xenia_xex::loader::get_system_flags(&header); 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` // Drain the reverse thunk map into the kernel so `XexGetProcedureAddress`
// can resolve ordinals back to callable thunk addresses. // can resolve ordinals back to callable thunk addresses.
for (module, ordinal, addr) in thunk_addr_map.drain(..) { for (module, ordinal, addr) in thunk_addr_map.drain(..) {
@@ -948,6 +1143,38 @@ fn cmd_exec_inner(
}); });
let parallel_active = parallel || parallel_via_env; let parallel_active = parallel || parallel_via_env;
kernel.parallel_active = parallel_active; 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 // AUDIT-032: default is `KernelState::xaudio_tick_enabled = true` now
// that the dedicated worker eliminates HW-thread hijack regressions. // that the dedicated worker eliminates HW-thread hijack regressions.
// Treat `--xaudio-tick` / `XENIA_XAUDIO_TICK=...` as an explicit // 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)" "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 { if reservations_table || reservations_via_env || parallel_active {
kernel.reservations.enable(); kernel.reservations.enable();
if !quiet { if !quiet {
@@ -1305,14 +1564,47 @@ fn cmd_exec_inner(
main_handle, main_handle,
&mut mem, &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 // Mount the title's content into the VFS so the kernel's file I/O
// handlers can serve the game's own assets via VFS. // handlers (`NtCreateFile`, `NtOpenFile`, etc.) can serve game-data
if path.to_lowercase().ends_with(".iso") || path.to_lowercase().ends_with(".xiso") { // reads. Canary always mounts `game:` + `d:` regardless of input
match xenia_vfs::disc_image::DiscImageDevice::open("d", std::path::Path::new(path)) { // 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)), Ok(disc) => kernel.vfs = Some(Box::new(disc)),
Err(e) => tracing::warn!("Could not mount disc image for VFS: {}", e), 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) ───────────────── // ── Phase 3: Data export patching (variable imports) ─────────────────
@@ -1324,14 +1616,80 @@ fn cmd_exec_inner(
kernel.heap_alloc(size, mem).unwrap_or(0) 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 lib in &header.import_libraries {
for imp in &lib.imports { for imp in &lib.imports {
if imp.record_type != 0 { continue; } // Only variable entries if imp.record_type != 0 { continue; } // Only variable entries
let addr = imp.address; let addr = imp.address;
match (lib.name.as_str(), imp.ordinal) { 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) => { ("xboxkrnl.exe", 0x001B) => {
// ExThreadObjectType — ptr to OBJECT_TYPE descriptor (0x40 bytes) // ExThreadObjectType — pool_tag "Thre"
let block = alloc_zero(0x40, &mut mem, &mut kernel); 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); mem.write_u32(addr, block);
} }
("xboxkrnl.exe", 0x0059) => { ("xboxkrnl.exe", 0x0059) => {
@@ -1340,16 +1698,51 @@ fn cmd_exec_inner(
mem.write_u32(addr, block); mem.write_u32(addr, block);
} }
("xboxkrnl.exe", 0x00AD) => { ("xboxkrnl.exe", 0x00AD) => {
// KeTimeStampBundle — 0x18 block with FILETIME at +0 and // KeTimeStampBundle — 0x18-byte `X_TIME_STAMP_BUNDLE`
// interrupt-time u64 at +0x10. Mirrors the clock used by // matching canary's `kernel_state.h:98-104`:
// KeQuerySystemTime so fast-path readers see consistent values. // +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); let block = alloc_zero(0x18, &mut mem, &mut kernel);
if block != 0 { if block != 0 {
let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME // Match ours's existing fixed `KeQueryInterruptTime`
mem.write_u32(block, (fake_time >> 32) as u32); // / `KeQuerySystemTime` constants for the initial
mem.write_u32(block + 4, fake_time as u32); // sample — the timer thread will overwrite within
mem.write_u32(block + 0x10, (fake_time >> 32) as u32); // ~1 ms, so these values are seen only briefly.
mem.write_u32(block + 0x14, fake_time as u32); 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); mem.write_u32(addr, block);
} }
@@ -1361,13 +1754,59 @@ fn cmd_exec_inner(
mem.write_u16(addr + 6, 0); mem.write_u16(addr + 6, 0);
} }
("xboxkrnl.exe", 0x0193) => { ("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); mem.write_u32(addr, base);
} }
("xboxkrnl.exe", 0x01AE) => { ("xboxkrnl.exe", 0x01AE) => {
// ExLoadedCommandLine — ANSI empty string // ExLoadedCommandLine — 1024-byte ANSI buffer.
let block = alloc_zero(0x10, &mut mem, &mut kernel); // Canary's default-init path (`xboxkrnl_module.cc:176-194`)
// Block is already zero-initialized by heap_alloc -> empty string. // 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); mem.write_u32(addr, block);
} }
("xboxkrnl.exe", 0x01BE) => { ("xboxkrnl.exe", 0x01BE) => {
@@ -1501,6 +1940,44 @@ fn cmd_exec_inner(
// responsibility per the trait contract.) // responsibility per the trait contract.)
let mem_arc = std::sync::Arc::new(mem); 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 // Spawn the real GPU worker if the threaded backend was chosen at
// kernel-construction time. The handle the kernel already holds // kernel-construction time. The handle the kernel already holds
// (`GpuBackend::Threaded`) is the CPU-side proxy; the worker owns // (`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); print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, &*mem_arc, 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!( info!(
wall_ms = started.elapsed().as_millis() as u64, wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count, instructions = stats.instruction_count,
@@ -1896,6 +2378,7 @@ enum RoundCtl {
/// asks for shutdown. /// asks for shutdown.
fn coord_pre_round( fn coord_pre_round(
kernel: &mut xenia_kernel::KernelState, kernel: &mut xenia_kernel::KernelState,
mem: &xenia_memory::GuestMemory,
stats: &ExecStats, stats: &ExecStats,
max_instructions: Option<u64>, max_instructions: Option<u64>,
ips_limit: Option<u64>, ips_limit: Option<u64>,
@@ -1995,6 +2478,12 @@ fn coord_pre_round(
try_inject_audio_callback(kernel); 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 RoundCtl::Continue
} }
@@ -2211,6 +2700,21 @@ fn worker_prologue(
let pc = kernel.scheduler.ctx(hw_id).pc; 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 // 0) Diagnostic ctor-probe: if `pc` is in
// `kernel.ctor_probe_pcs`, capture live r3/lr/sp + back-chain // `kernel.ctor_probe_pcs`, capture live r3/lr/sp + back-chain
// and println one record. Read-only; lockstep digest unaffected. // and println one record. Read-only; lockstep digest unaffected.
@@ -2267,19 +2771,41 @@ fn worker_prologue(
cycle = stats.instruction_count, cycle = stats.instruction_count,
"HW thread returned to LR sentinel — marking exited" "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); let (_, _exited_tid, handle_opt) = kernel.scheduler.exit_current(0);
if let Some(h) = handle_opt if let Some(h) = handle_opt {
&& let Some(xenia_kernel::objects::KernelObject::Thread { if let Some(xenia_kernel::objects::KernelObject::Thread {
exit_code, exit_code,
waiters, waiters,
.. ..
}) = kernel.objects.get_mut(&h) }) = kernel.objects.get_mut(&h)
{ {
*exit_code = Some(0); *exit_code = Some(0);
let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters); let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters);
for w in to_wake { for w in to_wake {
kernel.scheduler.wake_ref(w); 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; return PrologueOutcome::Continue;
} }
@@ -2329,10 +2855,32 @@ fn worker_prologue(
// 3) Unmapped PC. // 3) Unmapped PC.
if !mem.is_mapped(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!( tracing::error!(
cycle = stats.instruction_count, cycle = stats.instruction_count,
pc = format_args!("{:#010x}", pc), pc = format_args!("{:#010x}", pc),
hw_id, 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" "FAULT: PC in unmapped memory"
); );
return PrologueOutcome::BreakOuter; return PrologueOutcome::BreakOuter;
@@ -2603,6 +3151,7 @@ fn run_execution(
// without duplicating it from the lockstep path. // without duplicating it from the lockstep path.
match coord_pre_round( match coord_pre_round(
kernel, kernel,
mem,
&stats, &stats,
max_instructions, max_instructions,
ips_limit, ips_limit,
@@ -3005,6 +3554,7 @@ fn run_execution_parallel(
let s = stats_mtx.lock().expect("stats mutex poisoned"); let s = stats_mtx.lock().expect("stats mutex poisoned");
coord_pre_round( coord_pre_round(
&mut *guard, &mut *guard,
mem,
&*s, &*s,
max_instructions, max_instructions,
ips_limit, 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)] #[allow(clippy::too_many_arguments)]
#[instrument(skip_all, fields(title))] #[instrument(skip_all, fields(title))]
fn run_with_ui( fn run_with_ui(
@@ -3974,6 +4649,8 @@ fn run_with_ui(
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet); print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, &mem, quiet); dump_thread_diagnostic(&kernel, &mem, quiet);
// Iterate 2.M — see cmd_exec_inner path for rationale.
write_thread_state_dump(&kernel);
info!( info!(
wall_ms = started.elapsed().as_millis() as u64, wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count, instructions = stats.instruction_count,

View File

@@ -117,17 +117,27 @@ fn execute(ctx: &mut PpcContext, mem: &dyn MemoryAccess, instr: &DecodedInstr) -
ctx.pc += 4; ctx.pc += 4;
} }
PpcOpcode::addis => { PpcOpcode::addis => {
// Xbox 360 user mode is 32-bit ABI (MSR.SF=0), so addis must // Phase C+23: `addis` (and the `lis` simplified mnemonic) must
// produce a value whose upper 32 bits don't pollute downstream // sign-extend the shifted immediate to the full 64 bits before
// 64-bit arithmetic. The PPC ISA in 64-bit mode sign-extends // storing into the GPR, matching canary's HIR emitter
// simm16 before the shift, producing 0xFFFFFFFF_xxxx0000 for // (`InstrEmit_addis` in `ppc_emit_alu.cc`: `EXTS16(SI) << 16`
// negative simm16 (high bit set). When this value flows into // as a 64-bit constant). Game code commonly builds a negative
// a 64-bit subfc against a zero-extended lwz value, the unsigned // 32-bit value via `lis rN, 0xFFFB; ori rN, rN, 0x6C20`
// 64-bit comparison yields wrong CA. Truncate to 32 bits to // (yielding the i32 -300,000 for a 30ms `KeWait` timeout) and
// simulate 32-bit ABI behavior. // then stores it as a 64-bit doubleword via `std`. Without
let ra_val = if instr.ra() == 0 { 0u64 } else { ctx.gpr[instr.ra()] }; // sign extension the high half on the wire was 0x00000000,
let result = ra_val.wrapping_add((instr.simm16() as i64 as u64) << 16); // turning the timeout into a positive ~4.3-billion-tick
ctx.gpr[instr.rd()] = result as u32 as u64; // absolute deadline (~7 minutes) instead of a 30ms relative
// wait — surfacing as `wait.begin.timeout_ns=429466729600`
// on canary tid=12 → ours tid=7 idx=3 sister chain
// (cold-vs-cold C+22 baseline). Defensive 32-bit truncation
// for the arithmetic chain consumers (`subfcx`/`addex`/etc.)
// is already implemented at each consumer site (see PPCBUG-002/
// 007/etc.), so widening `addis` here does NOT regress them.
let ra_val = if instr.ra() == 0 { 0i64 } else { ctx.gpr[instr.ra()] as i64 };
let shifted = (instr.simm16() as i64) << 16;
let result = ra_val.wrapping_add(shifted);
ctx.gpr[instr.rd()] = result as u64;
ctx.pc += 4; ctx.pc += 4;
} }
PpcOpcode::addic => { PpcOpcode::addic => {
@@ -4934,6 +4944,92 @@ mod tests {
assert_eq!(ctx.gpr[3], 0x10000); assert_eq!(ctx.gpr[3], 0x10000);
} }
/// Phase C+23 regression: `addis rD, 0, neg_simm` (the `lis` form
/// with a negative immediate) must sign-extend the result to the
/// full 64 bits, matching canary's HIR emitter. Without this fix,
/// game code that builds a 32-bit negative value via
/// `lis r11, 0xFFFB; ori r11, r11, 0x6C20` and then stores the
/// result as a 64-bit doubleword via `std` would put 0x00000000
/// in the high half instead of the correct 0xFFFFFFFF, turning a
/// 30 ms relative `KeWaitForSingleObject` timeout into a positive
/// absolute deadline ~7 minutes away. Anchored by the cold-vs-cold
/// sister chain canary tid=12 → ours tid=7 idx=3 divergence.
#[test]
fn addis_with_negative_simm_sign_extends_to_64_bits() {
let mut ctx = PpcContext::new();
let mut mem = TestMem::new();
// addis r11, r0, 0xFFFB (lis r11, 0xFFFB)
// op=15, rd=11, ra=0, simm=0xFFFB.
let raw = (15u32 << 26) | (11u32 << 21) | (0u32 << 16) | 0xFFFBu32;
write_instr(&mut mem, 0, raw);
ctx.pc = 0;
step(&mut ctx, &mut mem);
assert_eq!(
ctx.gpr[11], 0xFFFFFFFF_FFFB0000u64,
"addis with negative simm must sign-extend to 64 bits"
);
}
/// Phase C+23 regression: the full `lis + ori + std` sequence that
/// builds the 300,000 timeout tick count used by Sylpheed for its
/// 30 ms `KeWait` calls must produce 0xFFFFFFFFFFFB6C20 on the wire,
/// not 0x00000000FFFB6C20. This is the proximate cause of the
/// `wait.begin.timeout_ns = 429466729600` divergence on canary tid=12
/// → ours tid=7 idx=3 in the cold-vs-cold C+22 baseline.
#[test]
fn lis_ori_std_negative_timeout_writes_sign_extended_doubleword() {
let mut ctx = PpcContext::new();
let mut mem = TestMem::new();
// r1 = 0x100 (stack pointer surrogate). Storage slot at r1+8.
ctx.gpr[1] = 0x100;
// lis r11, 0xFFFB ; r11 = 0xFFFFFFFFFFFB0000
let lis = (15u32 << 26) | (11u32 << 21) | (0u32 << 16) | 0xFFFBu32;
// ori r11, r11, 0x6C20 ; r11 = 0xFFFFFFFFFFFB6C20
// op=24 (ori): D-form encoding | rs(11) | ra(11) | uimm.
let ori = (24u32 << 26) | (11u32 << 21) | (11u32 << 16) | 0x6C20u32;
// std r11, 8(r1) ; mem[0x108..0x110] = 0xFFFFFFFFFFFB6C20
// op=62, DS-form, ds_field=8>>2=2, xo=0.
let std_op = (62u32 << 26) | (11u32 << 21) | (1u32 << 16) | (8u32 & 0xFFFCu32);
write_instr(&mut mem, 0, lis);
write_instr(&mut mem, 4, ori);
write_instr(&mut mem, 8, std_op);
ctx.pc = 0;
step(&mut ctx, &mut mem); // lis
assert_eq!(ctx.gpr[11], 0xFFFFFFFF_FFFB0000u64);
step(&mut ctx, &mut mem); // ori
assert_eq!(ctx.gpr[11], 0xFFFFFFFF_FFFB6C20u64);
step(&mut ctx, &mut mem); // std
let stored = mem.read_u64(0x108);
assert_eq!(
stored, 0xFFFFFFFF_FFFB6C20u64,
"std must persist all 64 bits of the sign-extended GPR"
);
// Interpreting the stored doubleword as a 100ns NT TIMEOUT tick
// count: it must round-trip to 300,000 (30 ms relative wait),
// NOT to +4,294,667,296 (the C+22 broken value).
assert_eq!(stored as i64, -300_000i64);
assert_eq!((stored as i64).wrapping_mul(100), -30_000_000i64);
}
/// Phase C+23 regression: ensure `addis` against a non-zero rA still
/// performs the canonical Add with 64-bit semantics. Used by
/// arithmetic chains that combine a sign-extended `lis` high half
/// with a subsequent `addi` low half. Equivalent to canary's HIR
/// `Add(LoadGPR(rA), const_i64(simm << 16))`.
#[test]
fn addis_with_nonzero_ra_adds_in_64_bit() {
let mut ctx = PpcContext::new();
let mut mem = TestMem::new();
// r4 = 0x1234 already. addis r5, r4, 0xFFFE => r5 = r4 + (-2<<16)
// = 0x1234 + 0xFFFFFFFFFFFE0000
ctx.gpr[4] = 0x1234;
let raw = (15u32 << 26) | (5u32 << 21) | (4u32 << 16) | 0xFFFEu32;
write_instr(&mut mem, 0, raw);
ctx.pc = 0;
step(&mut ctx, &mut mem);
assert_eq!(ctx.gpr[5], 0xFFFFFFFF_FFFE1234u64);
}
#[test] #[test]
fn test_lwz_stw() { fn test_lwz_stw() {
let mut ctx = PpcContext::new(); let mut ctx = PpcContext::new();

View File

@@ -42,6 +42,19 @@ pub const QUANTUM_DEFAULT: u32 = 50_000;
/// gets one when the slot fills up. /// gets one when the slot fills up.
const PRUNE_DEPTH_THRESHOLD: usize = 4; const PRUNE_DEPTH_THRESHOLD: usize = 4;
/// Scheduler rounds per +1 age-priority bonus. With one bonus point per
/// round a thread sits Ready without being picked, a priority-0 thread
/// reaches parity with a same-slot priority-N peer after N rounds and wins
/// the tiebreak on round N+1. Iterate 2.V: closes the strict-priority
/// starvation hole that left tid=6 (pri=0) on CPU5 indefinitely behind a
/// CPU-bound tid=10 (pri=15). Counts in scheduler round_count, which is
/// fully deterministic (no host_ns / wallclock dependency).
const AGING_ROUNDS_PER_BONUS: u64 = 1;
/// Cap on the age-priority bonus. 31 easily overwhelms any realistic NT
/// priority-class difference (max is ~31) without saturating i32 math.
const MAX_AGE_BONUS: i32 = 31;
/// Stable identity for a guest thread across all scheduler tables. /// Stable identity for a guest thread across all scheduler tables.
/// ///
/// The positional `idx` is only valid while the source slot's runqueue /// The positional `idx` is only valid while the source slot's runqueue
@@ -117,6 +130,14 @@ pub struct GuestThread {
/// Axis 3 instruction budget. Decremented per retired step on this /// Axis 3 instruction budget. Decremented per retired step on this
/// thread; on zero, slot rotates within same-priority tier. /// thread; on zero, slot rotates within same-priority tier.
pub quantum_remaining: u32, pub quantum_remaining: u32,
/// Iterate 2.V: scheduler `round_count` at the last time this thread
/// was picked to run on its slot (via `begin_slot_visit` or the
/// `decrement_quantum` rotation hand-off). Used by `pick_runnable`
/// to compute an age-priority bonus so a CPU-bound high-priority
/// peer can't strict-priority-starve a same-slot Ready peer forever.
/// Initialized to the scheduler's `round_count` at spawn so a fresh
/// thread doesn't inherit a giant age bonus on its first pick.
pub last_run_round: u64,
} }
impl GuestThread { impl GuestThread {
@@ -136,6 +157,7 @@ impl GuestThread {
affinity_mask: 0xFF, affinity_mask: 0xFF,
ideal_processor: None, ideal_processor: None,
quantum_remaining: QUANTUM_DEFAULT, quantum_remaining: QUANTUM_DEFAULT,
last_run_round: 0,
} }
} }
} }
@@ -206,14 +228,23 @@ impl Default for HwSlot {
} }
impl HwSlot { impl HwSlot {
/// Index of the highest-priority Ready/ServicingIrq thread in this /// Index of the highest *effective* priority Ready/ServicingIrq
/// slot's runqueue. Tiebreak: prefer lower index (deterministic). /// thread in this slot's runqueue. Effective priority = base priority
pub fn pick_runnable(&self) -> Option<usize> { /// + age bonus, where age = scheduler rounds since the thread was
/// last picked. The age bonus prevents strict-priority starvation:
/// without it, a CPU-bound priority=15 peer pinned to the same slot
/// would deterministically beat any Ready priority=0 peer forever
/// (closes iterate 2.V's root-cause wedge — tid=10 vs tid=6 on CPU5).
/// Tiebreak on equal effective priority: lower idx wins (deterministic).
///
/// `now_round` is the scheduler's current `round_count`; passing it in
/// keeps this method side-effect-free and decouples it from `Scheduler`.
pub fn pick_runnable(&self, now_round: u64) -> Option<usize> {
self.runqueue self.runqueue
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, t)| matches!(t.state, HwState::Ready | HwState::ServicingIrq(_))) .filter(|(_, t)| matches!(t.state, HwState::Ready | HwState::ServicingIrq(_)))
.max_by_key(|(i, t)| (t.priority, -(*i as i64))) .max_by_key(|(i, t)| (effective_priority(t, now_round), -(*i as i64)))
.map(|(i, _)| i) .map(|(i, _)| i)
} }
@@ -228,10 +259,31 @@ impl HwSlot {
} }
/// Compute the effective scheduling priority of `t` at scheduler round
/// `now_round`. Adds a deterministic age bonus equal to
/// `(now_round - t.last_run_round) / AGING_ROUNDS_PER_BONUS`, capped at
/// `MAX_AGE_BONUS`. `saturating_sub` guards against the case where
/// `last_run_round` was set in a future round (shouldn't happen, but
/// keeps the math defensive). See module-level docs at
/// `AGING_ROUNDS_PER_BONUS` for rationale.
#[inline]
fn effective_priority(t: &GuestThread, now_round: u64) -> i32 {
let age = now_round.saturating_sub(t.last_run_round);
let bonus_u64 = age / AGING_ROUNDS_PER_BONUS;
let bonus = bonus_u64.min(MAX_AGE_BONUS as u64) as i32;
t.priority.saturating_add(bonus)
}
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum OrderMode { pub enum OrderMode {
Fixed, Fixed,
Seeded { seed: u64 }, Seeded { seed: u64 },
/// Stage 0 quantum-preemption spike. Replaces `QUANTUM_DEFAULT` at every
/// quantum-reload site with `ticks`, so the scheduler rotates between
/// same-priority peers more (or less) frequently. Used to probe whether
/// scheduling shape alone unblocks the 104,607 RtlEnterCS cap before
/// committing to the contention-replay manifest stages.
ScanQuantum { ticks: u32 },
} }
impl OrderMode { impl OrderMode {
@@ -244,6 +296,14 @@ impl OrderMode {
.unwrap_or(0xC0FFEE_C0FFEE); .unwrap_or(0xC0FFEE_C0FFEE);
OrderMode::Seeded { seed } OrderMode::Seeded { seed }
} }
Some("quantum") | Some("Quantum") | Some("QUANTUM") => {
let ticks = std::env::var("XENIA_SCHED_QUANTUM")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|&t| t > 0)
.unwrap_or(1000);
OrderMode::ScanQuantum { ticks }
}
_ => OrderMode::Fixed, _ => OrderMode::Fixed,
} }
} }
@@ -369,7 +429,7 @@ impl Scheduler {
pub fn new() -> Self { pub fn new() -> Self {
let order = OrderMode::from_env(); let order = OrderMode::from_env();
let rng_state = match order { let rng_state = match order {
OrderMode::Fixed => 0, OrderMode::Fixed | OrderMode::ScanQuantum { .. } => 0,
OrderMode::Seeded { seed } => seed.max(1), OrderMode::Seeded { seed } => seed.max(1),
}; };
Scheduler { Scheduler {
@@ -379,7 +439,15 @@ impl Scheduler {
order, order,
rng_state, rng_state,
timed_waits: Vec::new(), timed_waits: Vec::new(),
tls_slot_count: 0, // Match canary's `kDefaultTlsSlotCount = 1024` (xthread.cc:335).
// Per-thread `tls_values` vec is sized to this count when spawned
// (see [`Self::install_main_thread`] / [`Self::spawn`]). Cost is
// 4 KiB per guest thread; 24 KiB across the 6 HW slots. Without
// this, `tls_values` starts empty and any `lwz rN, off(rTLS)`
// before the first `ExAllocateTls` reads guest memory zeros
// (matches canary observably) while host-side `tls_values[idx]`
// accesses panic on out-of-range until the lazy grow kicks in.
tls_slot_count: 1024,
non_empty_runnable: 0, non_empty_runnable: 0,
rotation_cursor: 0, rotation_cursor: 0,
reservation_table: None, reservation_table: None,
@@ -614,6 +682,13 @@ impl Scheduler {
t.priority = params.priority; t.priority = params.priority;
t.affinity_mask = mask; t.affinity_mask = mask;
t.ideal_processor = params.ideal_processor; t.ideal_processor = params.ideal_processor;
// Stage 0 — honor ScanQuantum reload on the freshly-spawned thread;
// `default_fields` set QUANTUM_DEFAULT before the scheduler was reachable.
t.quantum_remaining = Self::quantum_for(self.order);
// Iterate 2.V — pin the age-bonus baseline so a freshly-spawned
// thread doesn't inherit a large age bonus from the scheduler's
// accumulated round_count.
t.last_run_round = self.round_count;
// M3.7 — populate the inter-thread reservation handle + slot id // M3.7 — populate the inter-thread reservation handle + slot id
// so the interpreter can route lwarx/stwcx through the table. // so the interpreter can route lwarx/stwcx through the table.
t.ctx.hw_id = slot_id; t.ctx.hw_id = slot_id;
@@ -663,6 +738,11 @@ impl Scheduler {
t.pcr_base = pcr_base; t.pcr_base = pcr_base;
t.tls_base = tls_base; t.tls_base = tls_base;
t.tls_values = vec![0; self.tls_slot_count]; t.tls_values = vec![0; self.tls_slot_count];
// Stage 0 — same ScanQuantum override as `spawn`; default_fields
// doesn't know about the scheduler's order.
t.quantum_remaining = Self::quantum_for(self.order);
// Iterate 2.V — same baseline pin as `spawn`.
t.last_run_round = self.round_count;
self.slots[0].runqueue.push(t); self.slots[0].runqueue.push(t);
mem.write_pcr_id(pcr_base, 0); mem.write_pcr_id(pcr_base, 0);
self.recompute_slot_runnable(0); self.recompute_slot_runnable(0);
@@ -742,9 +822,17 @@ impl Scheduler {
/// Called by the step loop at the top of each per-slot visit. Picks the /// Called by the step loop at the top of each per-slot visit. Picks the
/// highest-priority Ready thread on the slot, sets `running_idx`, and /// highest-priority Ready thread on the slot, sets `running_idx`, and
/// stashes `self.current` so exports can reach it. /// stashes `self.current` so exports can reach it.
///
/// Iterate 2.V: passes the scheduler's `round_count` to `pick_runnable`
/// for age-priority computation, then stamps the winner's
/// `last_run_round` so the next round's age math starts from now.
pub fn begin_slot_visit(&mut self, hw_id: u8) { pub fn begin_slot_visit(&mut self, hw_id: u8) {
let now_round = self.round_count;
let slot = &mut self.slots[hw_id as usize]; let slot = &mut self.slots[hw_id as usize];
slot.running_idx = slot.pick_runnable(); slot.running_idx = slot.pick_runnable(now_round);
if let Some(idx) = slot.running_idx {
slot.runqueue[idx].last_run_round = now_round;
}
self.current = slot self.current = slot
.running_idx .running_idx
.map(|idx| ThreadRef::new(hw_id, idx as u16)); .map(|idx| ThreadRef::new(hw_id, idx as u16));
@@ -765,6 +853,18 @@ impl Scheduler {
/// ///
/// Returns `true` if a rotation occurred (purely informational; /// Returns `true` if a rotation occurred (purely informational;
/// callers don't need to act on it). /// callers don't need to act on it).
/// Quantum reload value to use given the current `OrderMode`. Returns
/// `QUANTUM_DEFAULT` for `Fixed`/`Seeded` so existing baselines stay
/// byte-identical; returns `ticks.max(1)` for `ScanQuantum` so the Stage
/// 0 spike can sweep faster rotations.
#[inline]
fn quantum_for(order: OrderMode) -> u32 {
match order {
OrderMode::ScanQuantum { ticks } => ticks.max(1),
_ => QUANTUM_DEFAULT,
}
}
pub fn decrement_quantum(&mut self) -> bool { pub fn decrement_quantum(&mut self) -> bool {
let Some(r) = self.current else { return false; }; let Some(r) = self.current else { return false; };
let slot = &mut self.slots[r.hw_id as usize]; let slot = &mut self.slots[r.hw_id as usize];
@@ -778,7 +878,7 @@ impl Scheduler {
return false; return false;
} }
let my_pri = t.priority; let my_pri = t.priority;
t.quantum_remaining = QUANTUM_DEFAULT; t.quantum_remaining = Self::quantum_for(self.order);
// Scan the rest of the runqueue for a same-priority Ready peer. // Scan the rest of the runqueue for a same-priority Ready peer.
// Priority-higher peers are already going to win the next // Priority-higher peers are already going to win the next
// `pick_runnable` on this slot, so we only need to find an *equal* // `pick_runnable` on this slot, so we only need to find an *equal*
@@ -795,6 +895,9 @@ impl Scheduler {
} }
let cand = &slot.runqueue[i]; let cand = &slot.runqueue[i];
if cand.priority == my_pri && matches!(cand.state, HwState::Ready) { if cand.priority == my_pri && matches!(cand.state, HwState::Ready) {
// Iterate 2.V — pin age-bonus baseline on the freshly
// promoted thread so the next round sees age 0 for it.
slot.runqueue[i].last_run_round = self.round_count;
slot.running_idx = Some(i); slot.running_idx = Some(i);
self.current = Some(ThreadRef::new(r.hw_id, i as u16)); self.current = Some(ThreadRef::new(r.hw_id, i as u16));
return true; return true;
@@ -846,7 +949,7 @@ impl Scheduler {
_ => return, _ => return,
} }
t.state = HwState::Ready; t.state = HwState::Ready;
t.quantum_remaining = QUANTUM_DEFAULT; t.quantum_remaining = Self::quantum_for(self.order);
self.timed_waits.retain(|&(_, tr)| tr != r); self.timed_waits.retain(|&(_, tr)| tr != r);
self.recompute_slot_runnable(r.hw_id); self.recompute_slot_runnable(r.hw_id);
} }
@@ -868,7 +971,7 @@ impl Scheduler {
} }
if t.suspend_count == 0 && matches!(t.state, HwState::Blocked(BlockReason::Suspended)) { if t.suspend_count == 0 && matches!(t.state, HwState::Blocked(BlockReason::Suspended)) {
t.state = HwState::Ready; t.state = HwState::Ready;
t.quantum_remaining = QUANTUM_DEFAULT; t.quantum_remaining = Self::quantum_for(self.order);
} }
self.recompute_slot_runnable(r.hw_id); self.recompute_slot_runnable(r.hw_id);
prev prev
@@ -1121,7 +1224,7 @@ impl Scheduler {
BlockReason::Suspended BlockReason::Suspended
} }
}; };
t.quantum_remaining = QUANTUM_DEFAULT; t.quantum_remaining = Self::quantum_for(self.order);
self.recompute_slot_runnable(r.hw_id); self.recompute_slot_runnable(r.hw_id);
tracing::info!( tracing::info!(
"scheduler: advanced to deadline {} waking hw={} idx={}", "scheduler: advanced to deadline {} waking hw={} idx={}",
@@ -1182,6 +1285,7 @@ impl Scheduler {
/// `ctx_mut_ref(r).gpr[3]`. Returns the refs that were woken. /// `ctx_mut_ref(r).gpr[3]`. Returns the refs that were woken.
pub fn unblock_on_deadlock(&mut self) -> Vec<ThreadRef> { pub fn unblock_on_deadlock(&mut self) -> Vec<ThreadRef> {
let mut woken = Vec::new(); let mut woken = Vec::new();
let quantum = Self::quantum_for(self.order);
for (hw_id, slot) in self.slots.iter_mut().enumerate() { for (hw_id, slot) in self.slots.iter_mut().enumerate() {
for (idx, t) in slot.runqueue.iter_mut().enumerate() { for (idx, t) in slot.runqueue.iter_mut().enumerate() {
if matches!( if matches!(
@@ -1191,7 +1295,7 @@ impl Scheduler {
| HwState::Blocked(BlockReason::CriticalSection(_)) | HwState::Blocked(BlockReason::CriticalSection(_))
) { ) {
t.state = HwState::Ready; t.state = HwState::Ready;
t.quantum_remaining = QUANTUM_DEFAULT; t.quantum_remaining = quantum;
woken.push(ThreadRef::new(hw_id as u8, idx as u16)); woken.push(ThreadRef::new(hw_id as u8, idx as u16));
} }
} }
@@ -1916,4 +2020,146 @@ mod tests {
assert_eq!(s.thread(r).state, HwState::Ready); assert_eq!(s.thread(r).state, HwState::Ready);
assert_eq!(s.thread(r).quantum_remaining, QUANTUM_DEFAULT); assert_eq!(s.thread(r).quantum_remaining, QUANTUM_DEFAULT);
} }
// ---- Stage 0: OrderMode::ScanQuantum --------------------------------
#[test]
fn quantum_for_fixed_returns_default() {
assert_eq!(Scheduler::quantum_for(OrderMode::Fixed), QUANTUM_DEFAULT);
}
#[test]
fn quantum_for_seeded_returns_default() {
assert_eq!(
Scheduler::quantum_for(OrderMode::Seeded { seed: 0xC0FFEE }),
QUANTUM_DEFAULT
);
}
#[test]
fn quantum_for_scan_quantum_returns_ticks() {
assert_eq!(
Scheduler::quantum_for(OrderMode::ScanQuantum { ticks: 1000 }),
1000
);
}
#[test]
fn quantum_for_scan_quantum_floor_is_one() {
// ticks=0 would deadlock the rotation; quantum_for clamps to >=1.
assert_eq!(
Scheduler::quantum_for(OrderMode::ScanQuantum { ticks: 0 }),
1
);
}
fn mk_scheduler_with_order(order: OrderMode) -> Scheduler {
let mut s = Scheduler::new();
s.order = order;
s
}
#[test]
fn spawn_under_scan_quantum_uses_ticks() {
let mut s = mk_scheduler_with_order(OrderMode::ScanQuantum { ticks: 7 });
s.spawn(worker_spawn_params(1, 0x1000), &mut NullPcr).unwrap();
let r = ThreadRef { hw_id: 0, idx: 0, generation: 0 };
assert_eq!(s.thread(r).quantum_remaining, 7);
}
#[test]
fn install_initial_under_scan_quantum_uses_ticks() {
let mut s = mk_scheduler_with_order(OrderMode::ScanQuantum { ticks: 42 });
let mut ctx = PpcContext::new();
ctx.pc = 0x8200_0000;
s.install_initial_thread(
ctx,
0x7000_0000,
0x10_0000,
0x7FFF_0000,
0x7FFE_0000,
0x1000,
&mut NullPcr,
);
let r = ThreadRef { hw_id: 0, idx: 0, generation: 0 };
assert_eq!(s.thread(r).quantum_remaining, 42);
}
#[test]
fn wake_ref_under_scan_quantum_reloads_ticks_not_default() {
let mut s = mk_scheduler_with_order(OrderMode::ScanQuantum { ticks: 13 });
let mut p = SpawnParams::default();
p.guest_tid = 2;
p.thread_handle = 0x2000;
p.affinity_mask = 0b0010;
p.pcr_base = 0x4000_1000;
s.spawn(p, &mut NullPcr).unwrap();
let r = ThreadRef { hw_id: 1, idx: 0, generation: 0 };
s.thread_mut(r).state = HwState::Blocked(BlockReason::WaitAny {
handles: vec![0xDEAD],
deadline: None,
});
s.thread_mut(r).quantum_remaining = 1;
s.wake_ref(r);
assert_eq!(s.thread(r).quantum_remaining, 13);
}
#[test]
fn decrement_quantum_under_scan_quantum_rotates_after_ticks() {
let mut s = mk_scheduler_with_order(OrderMode::ScanQuantum { ticks: 4 });
for tid in [1u32, 2] {
let mut p = SpawnParams::default();
p.guest_tid = tid;
p.thread_handle = 0x1000 + tid * 4;
p.affinity_mask = 0b0001;
p.pcr_base = 0x4000_0000 + tid * 0x1000;
s.spawn(p, &mut NullPcr).unwrap();
}
s.begin_slot_visit(0);
let first_tid = s.thread(s.current.unwrap()).tid;
// ticks=4: three decrements stay on first, the fourth rotates.
for _ in 0..3 {
assert!(!s.decrement_quantum());
}
assert!(s.decrement_quantum(), "fourth tick should rotate");
let second_tid = s.thread(s.current.unwrap()).tid;
assert_ne!(first_tid, second_tid);
// And the freshly-current thread also gets ticks=4, not DEFAULT.
assert_eq!(s.thread(s.current.unwrap()).quantum_remaining, 4);
}
#[test]
fn order_from_env_parses_quantum_arm() {
// SAFETY: tests in this module run serially within a single process;
// set_var/remove_var here matches the existing rng/seeded test idiom
// elsewhere in the crate. If we ever shard tests across threads, gate
// this group behind a Mutex.
let prev_order = std::env::var("XENIA_SCHED_ORDER").ok();
let prev_q = std::env::var("XENIA_SCHED_QUANTUM").ok();
unsafe {
std::env::set_var("XENIA_SCHED_ORDER", "quantum");
std::env::set_var("XENIA_SCHED_QUANTUM", "250");
}
match OrderMode::from_env() {
OrderMode::ScanQuantum { ticks } => assert_eq!(ticks, 250),
other => panic!("expected ScanQuantum, got {:?}", other),
}
// ticks=0 falls back to the 1000 default (filter(>0)).
unsafe { std::env::set_var("XENIA_SCHED_QUANTUM", "0") };
match OrderMode::from_env() {
OrderMode::ScanQuantum { ticks } => assert_eq!(ticks, 1000),
other => panic!("expected ScanQuantum, got {:?}", other),
}
// Restore env so siblings don't see leftover state.
unsafe {
match prev_order {
Some(v) => std::env::set_var("XENIA_SCHED_ORDER", v),
None => std::env::remove_var("XENIA_SCHED_ORDER"),
}
match prev_q {
Some(v) => std::env::set_var("XENIA_SCHED_QUANTUM", v),
None => std::env::remove_var("XENIA_SCHED_QUANTUM"),
}
}
}
} }

View File

@@ -339,6 +339,23 @@ pub struct GpuSystem {
/// `GpuSystem::new` and lives for the whole GPU lifetime — no /// `GpuSystem::new` and lives for the whole GPU lifetime — no
/// per-frame churn. /// per-frame churn.
pub edram: crate::edram::ShadowEdram, pub edram: crate::edram::ShadowEdram,
/// 256-entry `DC_LUT_30_COLOR` gamma ramp (10-bit BGR packed per entry).
/// Mirrors canary's `gamma_ramp_256_entry_table_` array on
/// `CommandProcessor` (`command_processor.cc:130-148`). Pre-loaded
/// with the linear sRGB ramp at construction so any code path that
/// queries gamma before the guest writes its own ramp sees the same
/// initial values as canary. MMIO read/write index handling for
/// `DC_LUT_RW_INDEX` is NOT yet wired in ours, so guests can't access
/// these bytes today; the field exists for state parity and to give
/// future MMIO handlers a populated buffer.
pub gamma_ramp_256: Vec<u32>,
/// 128-entry per-channel `DC_LUT_PWL_DATA` gamma ramp (base/delta pairs,
/// stored interleaved RGB → 384 u32 entries). Layout matches
/// `gamma_ramp_pwl_rgb_[i][j]` in canary (`command_processor.cc:141-148`):
/// index = `i * 3 + j` where `i ∈ [0,128)` and `j ∈ {0,1,2}` for R/G/B.
/// Same status as `gamma_ramp_256`: state-parity only until MMIO
/// handlers are added.
pub gamma_ramp_pwl: Vec<u32>,
} }
impl GpuSystem { impl GpuSystem {
@@ -365,9 +382,47 @@ impl GpuSystem {
last_resolve: None, last_resolve: None,
texture_cache: crate::texture_cache::TextureCache::new(), texture_cache: crate::texture_cache::TextureCache::new(),
edram: crate::edram::ShadowEdram::new(), edram: crate::edram::ShadowEdram::new(),
gamma_ramp_256: Self::default_gamma_ramp_256(),
gamma_ramp_pwl: Self::default_gamma_ramp_pwl(),
} }
} }
/// Build canary's default 256-entry sRGB linear ramp. Per
/// `command_processor.cc:134-140`: for each `i ∈ [0,256)`, the 10-bit
/// per-channel value is `i * 0x3FF / 0xFF`; the BGR triple is packed
/// into a single `DC_LUT_30_COLOR` u32. The packing here is BGR-low
/// to match canary's `color_10_blue` / `green` / `red` field order
/// (low bits = blue, high bits = red).
fn default_gamma_ramp_256() -> Vec<u32> {
let mut v = Vec::with_capacity(256);
for i in 0..256u32 {
let lane = (i * 0x3FF) / 0xFF;
// DC_LUT_30_COLOR bit layout: blue[0..10] | green[10..20] | red[20..30].
let entry = lane | (lane << 10) | (lane << 20);
v.push(entry);
}
v
}
/// Build canary's default 128-entry PWL ramp (interleaved RGB →
/// 384 u32s). Per `command_processor.cc:141-148`: for each
/// `i ∈ [0,128)`, `base = (i * 0xFFFF / 0x7F) & ~0x3F`, and
/// `delta = 0x200` when `i < 0x7F` else `0`. Same value mirrored
/// across R/G/B (j=0/1/2). Each `DC_LUT_PWL_DATA` is one u32
/// (`base` in low 16, `delta` in high 16).
fn default_gamma_ramp_pwl() -> Vec<u32> {
let mut v = Vec::with_capacity(128 * 3);
for i in 0..128u32 {
let base = ((i * 0xFFFF) / 0x7F) & !0x3Fu32;
let delta: u32 = if i < 0x7F { 0x200 } else { 0 };
let entry = (base & 0xFFFF) | ((delta & 0xFFFF) << 16);
for _ in 0..3 {
v.push(entry);
}
}
v
}
/// P8 — insert a shader blob + bump the FIFO so long-running games /// P8 — insert a shader blob + bump the FIFO so long-running games
/// don't grow `shader_blobs` without bound. Caps at [`SHADER_BLOB_CAP`]. /// don't grow `shader_blobs` without bound. Caps at [`SHADER_BLOB_CAP`].
/// Never evicts the currently-active VS/PS blobs (if they ended up at /// Never evicts the currently-active VS/PS blobs (if they ended up at

View File

@@ -390,7 +390,17 @@ impl GpuBackend {
// fires; the safety-net fallback warning fired twice for // fires; the safety-net fallback warning fired twice for
// each Sylpheed run. // each Sylpheed run.
let target = s.mmio.cp_rb_wptr.load(Ordering::Acquire); let target = s.mmio.cp_rb_wptr.load(Ordering::Acquire);
s.drain_until_wptr(mem, target, Duration::from_millis(900)) // GPUBUG-DRAIN-001 (iterate-2F, 2026-05-27): cap the inline
// drain at 1 ms so vd_swap does not block the main guest
// thread for ~900 ms per swap. Canary's `VdSwap_entry`
// returns in ~6.6 us — no synchronous drain. The 900 ms
// deadline parked tid=1 long enough to starve the post-swap
// worker fan-out at `sub_825070F0`, which in turn left
// tid=13's wait predicate unsatisfiable (wedge at
// PC=0x821CB1DC). Remaining packets stay queued in the
// ring; the next drain (next vd_swap or kernel-callback
// boundary) consumes them.
s.drain_until_wptr(mem, target, Duration::from_millis(1))
} }
GpuBackend::Threaded(h) => { GpuBackend::Threaded(h) => {
let target_wptr = h.mmio.cp_rb_wptr.load(Ordering::Acquire); let target_wptr = h.mmio.cp_rb_wptr.load(Ordering::Acquire);
@@ -560,7 +570,12 @@ impl GpuWorker {
// empty (rptr == wptr after modulo) or a packet // empty (rptr == wptr after modulo) or a packet
// returns `Idle`/`Blocked`. // returns `Idle`/`Blocked`.
self.system.sync_with_mmio(); self.system.sync_with_mmio();
let deadline = Instant::now() + Duration::from_millis(900); // GPUBUG-DRAIN-001 (iterate-2F, 2026-05-27): cap at
// 1 ms so the CPU's `recv_timeout(1s)` returns
// promptly. Canary doesn't synchronously drain in
// VdSwap; mirroring that frees tid=1 to spawn
// post-swap workers in time.
let deadline = Instant::now() + Duration::from_millis(1);
while self.system.is_ready(&*memory) { while self.system.is_ready(&*memory) {
if Instant::now() >= deadline { if Instant::now() >= deadline {
break; break;

View File

@@ -15,3 +15,7 @@ tracing = { workspace = true }
metrics = { workspace = true } metrics = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde_json = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
libc = "0.2"

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 audit;
pub mod contention_manifest;
pub mod event_log;
pub mod exports; pub mod exports;
pub mod interrupts; pub mod interrupts;
pub mod objects; pub mod objects;
pub mod path; pub mod path;
pub mod phase_b_snapshot;
pub mod state; pub mod state;
pub mod thread; pub mod thread;
pub mod ui_bridge; pub mod ui_bridge;

View File

@@ -109,4 +109,20 @@ impl KernelObject {
KernelObject::File { .. } => None, 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)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 // Task
state.register_export(Xam, 0x01AF, "XamTaskSchedule", xam_task_schedule); 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); state.register_export(Xam, 0x01B3, "XamTaskShouldExit", stub_return_zero);
// Alloc // Alloc
@@ -56,7 +56,7 @@ pub fn register_exports(state: &mut KernelState) {
state.register_export(Xam, 0x0258, "XamContentCreate", stub_success); state.register_export(Xam, 0x0258, "XamContentCreate", stub_success);
state.register_export(Xam, 0x025A, "XamContentClose", stub_success); state.register_export(Xam, 0x025A, "XamContentClose", stub_success);
state.register_export(Xam, 0x025B, "XamContentDelete", 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, 0x025E, "XamContentGetDeviceData", stub_success);
state.register_export(Xam, 0x025F, "XamContentGetDeviceName", stub_success); state.register_export(Xam, 0x025F, "XamContentGetDeviceName", stub_success);
state.register_export(Xam, 0x0260, "XamContentSetThumbnail", 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, 0x02BC, "XamShowSigninUI", stub_success);
state.register_export(Xam, 0x02C1, "XamShowKeyboardUI", stub_success); state.register_export(Xam, 0x02C1, "XamShowKeyboardUI", stub_success);
state.register_export(Xam, 0x02CB, "XamShowDeviceSelectorUI", 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, 0x02D9, "XamShowDirtyDiscErrorUI", stub_success);
state.register_export(Xam, 0x02DC, "XamShowMessageBoxUIEx", 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) { if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
*slot = Some(hw_id); *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 { if handle_ptr != 0 {
mem.write_u32(handle_ptr, handle); 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 ===== // ===== Alloc =====
fn xam_alloc(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { 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 ===== // ===== 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) { fn xam_user_get_xuid(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = user_index, r4 = xuid_ptr // r3 = user_index, r4 = xuid_ptr
let user_index = ctx.gpr[3] as u32;
let xuid_ptr = ctx.gpr[4] 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 { if xuid_ptr != 0 {
mem.write_u64(xuid_ptr, 0); // No XUID mem.write_u64(xuid_ptr, xuid);
} }
ctx.gpr[3] = 0; ctx.gpr[3] = 0;
} }
fn xam_user_get_name(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) { fn xam_user_get_name(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
// r3 = user_index, r4 = buffer, r5 = buffer_size // r3 = user_index, r4 = buffer, r5 = buffer_size
let user_index = ctx.gpr[3] as u32;
let buffer = ctx.gpr[4] as u32; let buffer = ctx.gpr[4] as u32;
if buffer != 0 { let buffer_size = ctx.gpr[5] as u32;
mem.write_u8(buffer, 0); // Empty string 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; 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 }; 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 ===== // ===== System =====
fn xam_get_execution_id(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) { 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) { 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 match_id = ctx.gpr[4] as u32;
let id_ptr = ctx.gpr[5] as u32; let id_ptr = ctx.gpr[5] as u32;
let param_ptr = ctx.gpr[6] as u32; let param_ptr = ctx.gpr[6] as u32;
@@ -578,6 +754,206 @@ mod tests {
assert_eq!(ctx.gpr[3], 8); 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] #[test]
fn xam_user_get_signin_state_user0_signed_in_locally() { fn xam_user_get_signin_state_user0_signed_in_locally() {
let (mut ctx, mem, mut state) = fresh(); 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(id_ptr), 0);
assert_eq!(mem.read_u32(param_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. /// queueing unbounded callbacks while injection is starved.
pub const XAUDIO_QUEUE_CAP: usize = 16; 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)] #[derive(Debug, Clone, Copy)]
pub struct XAudioClient { pub struct XAudioClient {
pub callback_pc: u32, 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> { pub fn peek_next(&self) -> Option<usize> {
self.pending.front().copied() self.pending.front().copied()
} }
@@ -320,6 +360,51 @@ mod tests {
assert!(s.last_instant.is_some()); 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] #[test]
fn tick_wallclock_fires_after_period() { fn tick_wallclock_fires_after_period() {
let mut s = XAudioState::default(); let mut s = XAudioState::default();

View File

@@ -120,9 +120,13 @@ pub mod header_keys {
pub const ENTRY_POINT: u32 = 0x00010100; pub const ENTRY_POINT: u32 = 0x00010100;
pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201; pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201;
pub const IMPORT_LIBRARIES: u32 = 0x000103FF; pub const IMPORT_LIBRARIES: u32 = 0x000103FF;
pub const TLS_INFO: u32 = 0x00020200; // Canary authoritative: `xenia-canary/src/xenia/kernel/util/xex2_info.h:217-218`.
// The two values below were transposed prior to Phase 2 of the boot-state
// remediation — the swap was latent because the sole caller of
// `get_stack_size()` (loader.rs:356) was never invoked.
pub const TLS_INFO: u32 = 0x00020104;
pub const EXECUTION_INFO: u32 = 0x00040006; pub const EXECUTION_INFO: u32 = 0x00040006;
pub const DEFAULT_STACK_SIZE: u32 = 0x00020104; pub const DEFAULT_STACK_SIZE: u32 = 0x00020200;
pub const ORIGINAL_PE_NAME: u32 = 0x000183FF; pub const ORIGINAL_PE_NAME: u32 = 0x000183FF;
pub const FILE_FORMAT_INFO: u32 = 0x000003FF; pub const FILE_FORMAT_INFO: u32 = 0x000003FF;
pub const SYSTEM_FLAGS: u32 = 0x00030000; pub const SYSTEM_FLAGS: u32 = 0x00030000;

View File

@@ -353,8 +353,49 @@ pub fn get_image_base(header: &Xex2Header) -> Option<u32> {
} }
/// Get the default stack size. /// Get the default stack size.
///
/// Canary: `XEX_HEADER_DEFAULT_STACK_SIZE = 0x00020200`, low key byte = 0,
/// which by XEX-key encoding means the `value` field IS the stack size
/// directly (not an offset into the header). Fallback to 1 MiB mirrors
/// the historical hardcoded default in `xenia-app`.
pub fn get_stack_size(header: &Xex2Header) -> u32 { pub fn get_stack_size(header: &Xex2Header) -> u32 {
get_opt_header(header, header_keys::DEFAULT_STACK_SIZE).unwrap_or(0x10_0000) // Default 1MB get_opt_header(header, header_keys::DEFAULT_STACK_SIZE).unwrap_or(0x10_0000)
}
/// Parsed `XEX_HEADER_TLS_INFO` (key `0x00020104`). Canary's
/// `xex2_opt_tls_info` struct (`xex2_info.h:595-601`):
/// +0x00 u32 slot_count — number of dynamic TLS slots
/// +0x04 u32 raw_data_address — guest VA of the initial-value template
/// +0x08 u32 data_size — total TLS region size (image + slots)
/// +0x0C u32 raw_data_size — bytes of the initial-value template
#[derive(Debug, Clone, Copy)]
pub struct TlsInfo {
pub slot_count: u32,
pub raw_data_address: u32,
pub data_size: u32,
pub raw_data_size: u32,
}
/// Parse the `XEX_HEADER_TLS_INFO` opt-header. The opt-header's low key
/// byte = 0x04, which by XEX-key encoding means the `value` field is an
/// OFFSET (in bytes) into the raw XEX header where the 16-byte
/// `xex2_opt_tls_info` struct lives — NOT an inline value. `data` must
/// be the raw XEX header bytes (length ≥ `value + 16`). Returns `None`
/// when the opt-header is absent or the offset is out of range.
pub fn get_tls_info(header: &Xex2Header, data: &[u8]) -> Option<TlsInfo> {
let off = get_opt_header(header, header_keys::TLS_INFO)? as usize;
if off.checked_add(16)? > data.len() {
return None;
}
let read_be_u32 = |o: usize| -> u32 {
u32::from_be_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]])
};
Some(TlsInfo {
slot_count: read_be_u32(off),
raw_data_address: read_be_u32(off + 4),
data_size: read_be_u32(off + 8),
raw_data_size: read_be_u32(off + 12),
})
} }
/// XEX `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) — the privilege bitmap /// XEX `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) — the privilege bitmap

23
docs/functions/INDEX.md Normal file
View File

@@ -0,0 +1,23 @@
# Function dossier index
Sorted by guest address. Update when adding/changing a dossier. See [README.md](README.md) for schema.
| Address | Dossier | Classification | Synopsis | Last audit |
|---------|---------|----------------|----------|------------|
| `0x82172BA0` | [sub_82172BA0](sub_82172BA0.md) | `normal_callee` | Array-walk dispatcher (vtable slot 6 bctrl at +0x1E8 / PC 0x82172D88). Walks `[r29+56]` array, invokes slot 6 on each — one observed target is sub_821B55D8. Gated by `[r30+4]` 3-bit-field==4 in sole caller sub_821741C8. Fires 1-2× canary / 0× ours @ 180s. | 064 |
| `0x82173990` | [sub_82173990](sub_82173990.md) | `normal_callee` | Synchronous task-spawn-and-join helper. PC `0x82173C60 bl 0x824AA330` (= +0x2D0) = wedge site for tid=1's join wait on tid=13's thread handle. Wait is on a sync object (event) populated by `sub_82172370` inside `sub_821746B0`. Canary completes wait in <1ms (worker exits via ExTerminateThread); ours never (tid=13 stuck in sub_821CB030). Fires 1× per boot in both engines. | 066 |
| `0x821B55D8` | [sub_821B55D8](sub_821B55D8.md) | `normal_callee` | Vtable slot 6 dispatched from sub_82172BA0+0x1E8 bctrl. Calls sub_824F8398 at +0x584. DB static caller is EH `b` from sub_821B6DF4, but real runtime caller is the bctrl. 1× canary / 0× ours. | 064 |
| `0x821B6DF4` | [sub_821B6DF4](sub_821B6DF4.md) | `msvc_eh_catch_handler` | MSVC C++ catch-handler thunk. FuncInfo @ .rdata:0x820C1994. 0 fires both engines at this horizon. | 060 |
| `0x821C4EB0` | [sub_821C4EB0](sub_821C4EB0.md) | `vtable_method` | `silph::GamePart_Title::UImpl` member fn. AUDIT-061: NOT a branch-divergence gate. All 4 cond-branches in [+0x44, +0xE0] decided bit-identically. First divergence is non-returning `bl 0x821CC3F8` at +0x64 (wedge inside sub_821CB030). | 061 |
| `0x821CB030` | [sub_821CB030](sub_821CB030.md) | `normal_callee` | Wedge primary site: creates per-call work-queue completion XEvent (+0x128), submits via sub_82452DC0 (+0x19C/+0x2EC), waits INFINITE (+0x1AC/+0x318). AUDIT-066 corrected framing: wait is on guest worker-cluster signal, NOT IO completion. AUDIT-065: ours's tid=13 wedges on FIRST sub_821CB030 call on 0x12AC; canary's tid=17 completes 16+ such calls and reaches ExTerminateThread. | 066 |
| `0x822F1AA8` | [sub_822F1AA8](sub_822F1AA8.md) | `normal_callee` | tid=1 post-init game-loop dispatcher. Bctrl vtable[0] of *(0x828E1F08) at +0xA4 dispatches into sub_82173990 (via thunk sub_82175330). Ours wedges in the vtable[0] callee (sub_82173990+0x2D0); refined in AUDIT-065. Outer loop in sub_822F1AA8 itself iterates 4040× in canary 60s (PCs 0x822F1BCC/D58/DFC). | 065 |
| `0x824ACB38` | [sub_824ACB38](sub_824ACB38.md) | `crt_init_driver` | Iterates runtime vtable-registration slots at 0x82870010..0x828708D4. Two loops (3 + 557 slots); 160-slot intentional zero gap at [0x828702F0, 0x82870590). | 060 |
| `0x82452DC0` | [sub_82452DC0](sub_82452DC0.md) | `normal_callee` | Work-submitter / cluster root. AUDIT-050060 convergence node. Ours fires ~3.21× less than canary. | 060 |
| `0x82457EF0` | [sub_82457EF0](sub_82457EF0.md) | `thread_proc` | tid=6 thread_proc. 0 static callers is CORRECT (registered via ExCreateThread). | 060 |
| `0x82458B90` | [sub_82458B90](sub_82458B90.md) | `normal_callee` | Canary γ-wedge signaler A. NtSetEvent caller; called via sub_82457EF0+0x24 (tid=6). Fires 1× in ours / 2× in canary. | 060 |
| `0x8245EC10` | [sub_8245EC10](sub_8245EC10.md) | `dispatch_table_method` | Canary γ-wedge signaler B. Slot 1 of dispatch_table @ 0x820B5830, installed by sub_8245FEB8. NtSetEvent caller. | 060 |
| `0x8245FEB8` | [sub_8245FEB8](sub_8245FEB8.md) | `normal_callee` | Vptr installer for dispatch_table @ 0x820B5830. Fires 5× in ours, 2× in canary, **same call site both engines**. | 060 |
| `0x824F7800` | [sub_824F7800](sub_824F7800.md) | `normal_callee` | Activation chain fn #2 → bctrl vtable[1] dispatches sub_825070F0 at PC 0x824F7B20. Standard prolog. 1× canary / 0× ours. | 064 |
| `0x824F7CD0` | [sub_824F7CD0](sub_824F7CD0.md) | `normal_callee` | Activation chain fn #3. Contains 4-way computed switch (`bctr` jump-table) at +0x40. Calls sub_824F7800. 1× canary / 0× ours. | 064 |
| `0x824F8398` | [sub_824F8398](sub_824F8398.md) | `normal_callee` | Activation chain fn #4. Tiny 20-insn adapter constructing a 36-byte stack-record before calling sub_824F7CD0. 1× canary / 0× ours. | 064 |
| `0x825070F0` | [sub_825070F0](sub_825070F0.md) | `vtable_method` | Slot 1 of class `ANON_Class_713383D7` vtable (0x8200A208/0x8200A928). 1× fire in canary @ ~25s wallclock; spawns 4 workers with ctx 0xBCE25340. AUDIT-064: full activation chain identified; wedge is upstream at tid=1's join-wait on tid=13 (AUDIT-049). AUDIT-067 (negative result): the vtable address `0x8200A208` is never stored via any guest store opcode in canary — install is host-side (kernel-import direct memory write / XEX-loader); search guest-code for the install is fundamentally blind. | 067 |

141
docs/functions/README.md Normal file
View File

@@ -0,0 +1,141 @@
# Function dossiers — persistent RE notes for Project Sylpheed (Sylpheed.xex)
## What this is
One markdown file per guest function we've investigated during a kernel-bug audit. The dossier is a **living, append-only record** of what we know (and what we got wrong) about each function. The goal is two-fold:
1. **Don't re-derive understanding.** When an audit touches `sub_821C4EB0`, the next agent shouldn't have to re-walk the disasm — read [sub_821C4EB0.md](sub_821C4EB0.md) first.
2. **Don't repeat misinterpretations.** AUDIT-060 falsified two audits of work because we'd read MSVC EH FuncInfo metadata as if it were static call edges. The dossier captures both the corrected reading AND the falsified one — so future agents see the trap was already sprung once.
This system is **agent-writable**. Audit agents are expected to consult dossiers before probing, and to *append* (not rewrite) when a new audit produces evidence about a known function. Agents should create new dossiers for any function they perform non-trivial work on.
## Layout
```
docs/functions/
README.md — this file
INDEX.md — one-line lookup table, sorted by address
sub_XXXXXXXX.md — per-function dossier (one per function, address in UPPERCASE hex)
```
Filename convention: `sub_` + 8-hex-uppercase + `.md`. Match the name used in `sylpheed.db.functions.name`. If the function has a symbol (e.g. `GamePart_Title::UImpl::ctor`), still use the address-based filename; record the symbol inside.
## Schema
Each dossier follows this shape:
```markdown
---
address: 0xXXXXXXXX
classification: <one-word category — see below>
confidence: <high | medium | low | refuted>
last_audit: NNN
aliases:
- "human-readable name or prior misnomer (status)"
---
# sub_XXXXXXXX
## Synopsis
One short paragraph: the current best understanding. ONLY the latest consensus —
old interpretations live in the audit log.
## Evidence
Hard facts only. Disasm patterns, .rdata/.pdata references, runtime fires from
instrumentation, byte-level dumps. No inference here; that goes in Activation
or Notes.
## Activation
When/how this function runs:
- direct bl from caller X at PC Y
- indirect via fnptr-array slot N at 0x...
- vtable dispatch from class C, slot K (vtable at 0x...)
- C++ EH catch-handler dispatch (FuncInfo @ 0x...)
- thread_proc entry point (registered via ExCreateThread call site PC Z)
## Static graph
- Callers (from sylpheed.db `xrefs` table, source_func column — never source per AUDIT-045):
- PC `0xCCCCCCCC` inside `sub_DDDDDDDD`
- Callees:
- bl `sub_EEEEEEEE` at PC `0x...`
- bctrl (computed) at PC `0x...` — candidates: ...
## Audit log
Append-only. Most recent FIRST. Each entry pairs (audit-NNN, date, observation,
status). Status options: confirmed | falsified | superseded-by-NNN.
- **AUDIT-NNN (YYYY-MM-DD)** — observation + relevant data point [STATUS]
- **AUDIT-MMM (YYYY-MM-DD)** — earlier observation [STATUS: falsified by NNN — reason]
## Open questions
Future-work bullets:
- Specific PC to probe
- Hypothesis to test
- Cross-reference to verify
## Cross-references
- Related dossiers: [sub_XXXXX](sub_XXXXX.md) (relationship)
- Audit memory entries: `project_xenia_rs_audit_NNN_*.md`
- Trace artifacts: `audit-runs/audit-NNN-*/...`
```
## Classification vocabulary
Pick the **most specific** that fits. Add new ones if needed but don't bloat the list.
| Class | Meaning |
|-------|---------|
| `normal_callee` | Plain function reached by direct `bl`. The default. |
| `vtable_method` | Virtual method dispatched via `bctrl` from a class vtable. |
| `thread_proc` | Entry point registered via `ExCreateThread` / `KeInitializeThread`. 0 static callers is correct; check for `lr=0xbcbcbcbc` thread-entry sentinel at first fire. |
| `msvc_eh_catch_handler` | MSVC C++ catch handler. Prolog `subi r31, r12, N; mflr r12; ...`. Referenced from `.rdata` FuncInfo (magic `0x19930520..22`). 0 static callers; dispatched by EH runtime only. **Do not treat its `.rdata` references as call edges.** |
| `msvc_eh_state_handler` | MSVC EH state/unwind handler. Similar to above but no `subi r31, r12` prolog. |
| `import_thunk` | Wraps an xboxkrnl import (e.g. NtCreateEvent at thunk 0x8284DF1C). Behavior is host-side. |
| `wrapper` | Thin wrapper around a kernel import or library call. |
| `crt_init_driver` | CRT-style iterator that walks an array of fn pointers / vtables (e.g. `sub_824ACB38`). |
| `fnptr_array_entry` | Function reached only via enumeration by a `crt_init_driver`. |
| `dispatch_table_method` | Function installed into a runtime dispatch table by a ctor; reached via indirect call only. |
| `synchronization_primitive` | Function that wraps Nt/Ke wait/set/release calls. |
| `unknown` | Not yet investigated. Synopsis describes what little we know. |
## Confidence levels
| Confidence | Meaning |
|------------|---------|
| `high` | Multiple converging evidence sources (disasm + runtime instrumentation + cross-engine probe). |
| `medium` | One strong source (e.g. disasm alone or one canary trace). Plausible but not cross-checked. |
| `low` | Inference from static call graph or one observation; should be probed if it becomes load-bearing. |
| `refuted` | An earlier claim was falsified. Keep the dossier; document what the function actually is in synopsis + put the refuted claim in audit log with status `falsified`. |
## Golden rules — for agents and humans
1. **Append, don't overwrite.** New audits add entries to "Audit log". Old entries stay with their original wording so future readers can see the evolution.
2. **Falsify, don't delete.** If a later audit disproves an earlier claim, mark the old audit-log entry `[STATUS: falsified by AUDIT-NNN — reason]`. The earlier interpretation taught us *something* (often that a class of disasm pattern is ambiguous) — preserve it.
3. **Cite the source.** Every claim ties to either (a) an audit number + trace artifact path, or (b) a static-DB query you can reproduce. "X is a thread_proc" without a basis is unacceptable.
4. **Distinguish fact from inference.** "Fires 5× at -n 500M with lr=0x8246020C all five times" is a fact. "Therefore it's a vptr installer for slot 1 of dispatch_table 0x820B5830" is an inference. Put facts in Evidence; inferences in Synopsis/Activation/Notes — and label inferences as such.
5. **Update INDEX.md.** When you create a new dossier or change a classification, add/update the corresponding row in `INDEX.md`.
6. **Update the `last_audit` frontmatter.** Reflects the most recent audit that touched the dossier.
7. **One function per file.** If you find a fn is structurally a wrapper for another, write two dossiers and link them.
## Anti-patterns to avoid
- **Reading EH metadata as call edges.** `.rdata` references to a fn inside an MSVC FuncInfo struct (magic `0x19930520..22` nearby) are unwind-handler bindings, NOT bl call sites. Pattern: catch-handler prolog `subi r31, r12, N; mflr r12; stwu r1, ...`. See [sub_821B6DF4.md](sub_821B6DF4.md) for the canonical falsified example.
- **"0 static callers" = "dead in ours".** Three legitimate reasons a fn has 0 static callers and still runs: thread_proc (ExCreateThread), fnptr_array_entry (enumerated by crt_init_driver), msvc_eh_*_handler (dispatched by EH runtime). Always check.
- **Comparing fire counts at fixed instruction horizons across engines.** Canary @ 60s wallclock and ours @ -n 500M are different time bases. State (i) and state (ii) data points must be normalized — either both at the same wallclock or both at the same boot milestone.
- **Trusting handle IDs across runs.** `KernelState::alloc_handle` is monotonic; handles drift run-to-run. Function-context names (e.g. "sub_821CB030+0x128 creator") are stable; handle IDs are not.
- **Quoting xrefs.source instead of xrefs.source_func.** See AUDIT-045 reading-error #12. Use `source_func` for caller-set queries.
## Backfill status
Initial set (created in AUDIT-060 retrospective backfill, 2026-05-12):
- The 10 most-cited fns from AUDIT-049060.
Future audits should extend coverage as they touch new fns. Backfilling earlier audit fns (AUDIT-030048) is a nice-to-have but not blocking.

View File

@@ -0,0 +1,56 @@
---
address: 0x82172BA0
classification: normal_callee
confidence: high
last_audit: 064
aliases:
- "Vtable-slot-6 array-walker / AUDIT-033 T6-gateway descendant"
---
# sub_82172BA0 — array-walk dispatcher (vtable slot 6)
## Synopsis
Normal-callee dispatcher. Walks an array of object pointers (header at `r29+56`: `r29+56[8]` = element count `>>2`; `r29+56[4]` = data ptr) and invokes vtable slot 6 (`lwz r11, 24(r11)`) on each. The `bctrl` at PC `0x82172D88` is the slot-6 dispatch site — observed in canary firing into [sub_821B55D8](sub_821B55D8.md). Has a critical-section prologue (`lwarx`/`stwcx.` at PC `0x82172C08..0x82172C14`) protecting the array snapshot. Only fires when caller `sub_821741C8` sees `[r30+4]` mask-3-bits-field == 4. AUDIT-064 verified canary fires 2× at 180s wallclock; ours fires 0× because tid=1's wait at `sub_82173990+0x2D0` (handle 0x12A4 = tid=13 thread handle) never completes.
## Evidence
- Disasm prolog at `0x82172BA0`: `mflr r12; bl 0x825F0F78 (frame helper); subi r31, r1, 176; stwu r1, -176(r1); mr r29, r3; ...` — normal-callee prolog, frame ptr `r31 = r1-176`. NOT MSVC EH handler.
- Function size: 604 bytes / 151 insns. `has_eh=True`, `frame_size=0` per DB (dynamic).
- Static caller xref (sole): PC `0x821744C8` inside `sub_821741C8` via `bl`. Gating disasm at `sub_821741C8+0x2C8..2C8` matches mask-bits of `[r30+4]` to value 4 to take this call.
- The bctrl at PC `0x82172D88` operates on slot 6 (`lwz r11, 24(r11)` = byte-offset 24 in 4-byte slots = slot index 6).
- AUDIT-064 canary 60s+180s probes: fires 1-2× with `lr=0x821744CC r3=BCCC4A80 r4=BC369160 r5=BC369160 r6=03A72328` on tid=6. PC `0x82172D88` (the bctrl) fires 2× at 60s in upstream probe.
- AUDIT-064 ours `--ctor-probe=0x82172BA0` -n 500M: **0 fires**.
- Critical-section pattern at `0x82172C08..0x82172C14`: `mfmsr r8; mtmsrd r13; lwarx r9, r0, r10; stwcx. r11, r0, r10; mtmsrd r8; bne 0x82172C00` — disable interrupts → atomic swap → restore.
## Activation
Direct `bl` from `sub_821741C8+0x300` (PC `0x821744C8`). Conditional: `sub_821741C8` masks `[r30+4]` via `rlwinm r11, r11, 0, 27, 29` and switches on the 3-bit field — value `4` selects this fn, value `8` selects `sub_82172E58`, else no-op.
## Static graph
- Static callers (DB):
- `sub_821741C8+0x300` via `bl`.
- Callees:
- `sub_822F2328` (PC `0x82172BC4`).
- `sub_8284DCFC` (PC `0x82172BD4`) — likely a kernel sync primitive.
- `sub_8228E138` (PC `0x82172BF4`).
- Indirect via `bctrl` at PC `0x82172D88` (slot 6) and other vtable slots inside the body.
- DB lists many `ind_call` targets recorded for PC `0x82172D88` (sub_82680370, sub_823A2258, sub_82455300, sub_827E8D60, sub_8237B020, sub_82398CC0, sub_82391BA8, sub_827ED308, sub_826B24E8, sub_822C7418, sub_821F8340, sub_823800A8, sub_824A6C00, sub_823762E8, sub_825ED990, sub_827EFED0, sub_822B06A0, sub_82455658, sub_82388FF8, sub_827FA850, sub_8232C4C0, sub_8238EC10, sub_82674028, sub_823929D0, ...). **Critical caveat**: this list is missing `sub_821B55D8` despite that being the runtime target observed in canary — the dynamic-target inference has gaps.
## Audit log
- **AUDIT-064 (2026-05-12)** — disasm confirms array-walk dispatcher pattern; canary fires 1-2× / ours 0×. The runtime activation chain for sub_825070F0 starts here. **Convergence finding**: ours never reaches sub_82172BA0 because tid=1 is stalled at `sub_82173990+0x2D0` (handle 0x12A4 = tid=13's thread handle — AUDIT-049 wedge). The whole 5-level ladder downstream is gated by this wait. [confirmed]
## Open questions
- What is the array at `[r29+56]`? Likely a list of subsystem objects (graphics, audio, input, etc.) the game-loop dispatcher iterates each frame. Canary `r3=0xBCCC4A80` is the dispatcher object.
- The `bctrl`'s xref-table is incomplete (missing `sub_821B55D8`). Investigate the dynamic-target inference's gap.
## Cross-references
- Callers: `sub_821741C8+0x300`.
- Callees (via bctrl): `sub_821B55D8` (observed in canary), plus 50+ others recorded in DB.
- Upstream: `sub_822F1AA8` → vtable[0]=`sub_82173990` → calls `sub_821741C8`.
- Audits: 033 (T6 gateway analysis), 058, 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/canary-{60,120,180}s.log`, `canary-upstream-60s.log`, `canary-inside-822F1AA8.log`.

View File

@@ -0,0 +1,167 @@
---
address: 0x82173990
classification: normal_callee
confidence: high
last_audit: 066
aliases:
- "tid=1 join wait site (the wedge PC 0x82173C60)"
- "synchronous task-spawn + join helper"
---
# sub_82173990 — synchronous task-spawn-and-join helper
## Synopsis
Tid=1 (main) one-shot helper that builds a stack-resident task descriptor, calls
`sub_821746B0` to allocate+initialize a 24-byte task record (which encapsulates
a sync object created by `sub_82172370`), and waits INFINITE on that sync
object. The wait at PC **`0x82173C60 bl 0x824AA330`** (= `sub_82173990+0x2D0`)
is the AUDIT-049/AUDIT-064 wedge site — both canary and ours enter the wait,
but only canary's wait completes. The wait is on the **thread handle** of the
worker spawned by `sub_821746B0` (XThread or KE_THREAD), released when that
worker calls `ExTerminateThread`. Function is called exactly **1× per boot** in
both engines (entry probe fires once, all body PCs fire once each).
## Evidence
Disasm-anatomy (size 768B / 192 insns @ `0x82173990..0x82173C8C`):
```
0x82173990 mflr/prologue (256-byte frame)
0x821739B0 bl 0x8216E7E8 ; first string-init helper (r4=r11+6244)
0x821739CC bl 0x82448AA0 ; cr0=.G. → arg!=0 path
0x821739F0 bl 0x82448BC8 ; returns string-table entry → r28
0x82173A38 bl 0x8216F218 ; internal copy
0x82173A68 bl 0x821835E0 ; → r25 (ID/result); cr6-tests below
0x82173A78 bne cr6, 0x82173A84 ; skip if r25==28
0x82173A88 beq cr6, 0x82173BC0 ; skip if r25==0
0x82173B98 bl 0x82453910 ; signaler candidate (AUDIT-049 column)
0x82173BC0 convergence label (string-table clean-up + dispatch)
0x82173BE4 bl 0x824B2188 ; tid=1's outer-channel pump
0x82173C34 bl 0x821746B0 ; allocates 24-byte task record, sub_82172370 fills [r29+4]
0x82173C38 mr r30, r3 ; r30 = task struct
0x82173C48 bl 0x824AA5C8 ; status query → r3 (canary r3=1 → [r31+80]=0x103 STATUS_PENDING)
0x82173C54 bne cr6, 0x82173C64 ; guard: only wait if r11==0x103 (STATUS_PENDING)
0x82173C5C lwz r3, 4(r30) ; r3 = task->sync_handle = [struct+4]
0x82173C60 bl 0x824AA330 ; KeWaitForSingleObject INFINITE ← THE WEDGE
0x82173C70 bl 0x82174AF8 ; post-wait task completion (sub_82174AF8 runs post-state transition)
0x82173C88 epilogue (b 0x825F0FC4)
```
### Canary run (AUDIT-065, 180s wallclock, --audit_61_branch_probe_pcs)
All 17 probed PCs fire **exactly 1× each on tid=F8000008 (= canary main / mapped from `tid=6`)**:
| PC | lr | r3 | r4 | tid | meaning |
|---|---|---|---|---|---|
| `0x82173990` | `0x822F1B50` | `BCCC4A80` | `701CF8C0` | 6 | entry; lr=post-bctrl of sub_822F1AA8 |
| `0x821739CC` | — | `0x00000001` | `0x820A17A8` | 6 | cr0=.G. — `cmplwi r28,0` post-strcmp != 0 path |
| `0x821739F0` | — | `BCCC4A64` | `BCCC49FC` | 6 | r28 populated; cr6=..E (==) |
| `0x82173A38` | — | `701CF860` | `701CF840` | 6 | inner copy call entry |
| `0x82173A68` | — | `BDE996FF` | `BDE98F14` | 6 | r25=0xBDE996FF (returned ID), cr6=..E |
| `0x82173A78` | — | `0x0000001C` | `BDE98F14` | 6 | `bne 0x82173A84``r25 != 28` taken |
| `0x82173BC0` | `0x82173A6C` | `0x1C` | … | 6 | convergence (post-bne over alloc); `beq 0x82173B14`-skipped |
| `0x82173BE4` | `0x82173BD4` | `BE568F00` | `0x00000005` | 6 | bl 0x824B2188 entry |
| `0x82173C34` | `0x82173C1C` | `BCCC4A80` | `0x00000000` | 6 | bl 0x821746B0 entry — calls task-alloc |
| `0x82173C38` | `0x82173C38` | `BC365700` | `701CF6E0` | 6 | r3=task_struct, cr6=.G. |
| `0x82173C48` | `0x82173C38` | `F8000094` | `701CF800` | 6 | post bl 0x824AA5C8; r3=F8000094 ?? actually this is r3 at BB entry post-bl |
| `0x82173C54` | `0x82173C4C` | `0x00000001` | `0x30000000` | 6 | cmplwi r11, 0x103 — value 0x103 sets cr6=..E (eq) per actual r11; BUT cr6=..E means !lt!gt eq — wait was entered |
| `0x82173C60` | `0x82173C4C` | **`F8000094`** | `FFFFFFFF` | 6 | **wait entry — r3 = thread handle** |
| `0x82173C64` | `0x82173C64` | `0` | `0x1` | 6 | post-wait — wait completed! |
| `0x82173C70` | `0x82173C64` | `BCCC4A80` | `BC365700` | 6 | bl 0x82174AF8 (cleanup) |
| `0x82173C88` | `0x82173C88` | `701CF840` | … | 6 | epilogue |
Wait duration: ~445 log lines between PC `0x82173C60` (entry) and `0x82173C64` (post-wait).
Inside the wait window: `K> F8000094 XThread::Execute thid 17 (handle=F8000094, 'XThread01F4 (F8000094)', native=000001F4)` — i.e. F8000094 IS a thread handle. The thread loads cache files (`cache:\aab216c3\5\ee70e0a`, `cache:\87719002\c\dba806e`, `cache:\87719002\c\ec0a96e`, `cache:\87719002\a\60fcb85`, etc) and spawns child workers (`ExCreateThread(..., 824AFF88, 821C4AD0, BCA44C00, ...)` and others). The wait completes immediately after `d> F8000094 ExTerminateThread(00000000)`.
### Ours run (AUDIT-065, -n 500M instructions)
Only 6 of 24 BB-entry probed PCs fire (ours's branch probe fires only at BB-entry):
| PC | lr | r3 | cycle | meaning |
|---|---|---|---|---|
| `0x82173990` | `0x822F1B50` | `0x40ba9a80` | 6,172,194 | entry; lr= bctrl in sub_822F1AA8 |
| `0x821739CC` | — | `0x00000001` | 6,172,686 | non-zero arg path |
| `0x821739F0` | — | `0x40ba9a64` | 6,173,074 | str-init complete |
| `0x82173A68` | — | `0x41d7e6ff` | 9,174,034 | r3=str-table-entry (the AUDIT-049 wait inside sub_82452DC0 already happened HERE — note the cycle gap from 6.17M to 9.17M means tid=1 has been blocked in sub_82172370/etc; actually this is the `bl 0x821835E0` post-return) |
| `0x82173BC0` | `0x82173A6C` | `0x1C` | 9,175,368 | convergence (r25=0x41d7e6ff != 28 not equal to 28; checks ≠0 also nonzero, fall thru to skip block @ 0x82173B14) |
| `0x82173C38` | `0x82173C38` | `0x4024a640` | 9,178,243 | post `bl 0x821746B0` — r3=`0x4024a640` (task struct, ALSO start_ctx of tid=13) |
**No probe fires beyond `0x82173C38`**. The next BB-entry probe was `0x82173C64` (post-wait). Mid-block PCs `0x82173C48/C54/C60/C70` don't fire in ours's branch-probe (per AUDIT-046 reading-error #13). The fact that `0x82173C64` does NOT fire confirms: **ours's tid=1 wedges between `0x82173C38` and `0x82173C64`** — at the wait at `0x82173C60`.
### End-of-run thread state (ours --trace-handles dump)
```
handle=0x000012a4 Thread(id=13, exit=None) waiters(tid)=[1]
handle=0x000012ac kind=Event/Auto waiters=1 signals=0 waits=1 wakes=0 <NO_SIGNALS_DESPITE_WAITS>
[ 0] cycle=0 tid=13 lr=0x824ac578 src=do_wait_single
handle=0x000012b8 kind=Event/Auto waiters=1 signals=0 waits=1 wakes=0 <NO_SIGNALS_DESPITE_WAITS>
[ 0] cycle=0 tid=5 lr=0x824ac578 src=do_wait_single
```
tid=13 (handle 0x12A4, exit=None) is alive but stuck on event 0x12AC inside sub_821CB030 (cache file IO completion event). tid=5 is one of the workers parked on its own idle event 0x12B8. tid=1 join-waits tid=13 → tid=13 waits 0x12AC → 0x12AC needs workers → workers parked.
## Activation
Direct `bl` from `sub_82175330+0x4` via tail-jump (post-bctrl of vtable[0] dispatched at `sub_822F1AA8+0xA4`). One static caller `sub_82175330` per `sylpheed.db`.
Called exactly **1× per boot** on tid=1 in both engines.
## Static graph
- Direct callers (sylpheed.db `xrefs.source_func`):
- `sub_82175330+0x4` via `b 0x82173990` (tail-jump from the vtable thunk).
- Direct callees of interest:
- `bl 0x8216E7E8` at `+0x20` — string-table helper (used twice).
- `bl 0x82448AA0` at `+0x38`, `+0x48`, `+0x88`, `+0xC4`, `+0x168`, `+0x260` — string-table lookup.
- `bl 0x824AA7A0` at `+0x4C` — string-helper.
- `bl 0x82448BC8` at `+0x5C` — internal lookup.
- `bl 0x82448C50` at `+0x78`, `+0x98`, `+0x178`, `+0x308` — string convert.
- `bl 0x8216F218` at `+0xA8`, `+0x188` — copy / string ops.
- `bl 0x8217C850` at `+0xAC` — query.
- `bl 0x82178E50` at `+0xB8` — query.
- `bl 0x821835E0` at `+0xD8` — returns ID into `r25` (key gate).
- `bl 0x824AA830` at `+0xFC`, `+0x148` — kernel helper.
- `bl 0x822C69C8` at `+0x104`, `+0x134` — task-helper.
- `bl 0x822DE650` at `+0x118` — helper.
- `bl 0x822F2328` at `+0x124`, `+0x240` — helper (calls inside outer sub_822F1AA8 too).
- `bl 0x822DE858` at `+0x13C` — helper.
- `bl 0x822F28C0` at `+0x144`, `+0x25C` — helper.
- `bl 0x82674028` at `+0x15C` — kernel-debug printf? format.
- `bl 0x82150EF8` at `+0x1A4` — heap alloc 28-byte struct.
- `bl 0x824523E8` at `+0x1FC` — task-helper.
- `bl 0x82453910` at `+0x208`**signaler/notify (AUDIT-049 column)**.
- `bl 0x821506B8` at `+0x224` — heap free.
- `bl 0x8216E790` at `+0x22C`, `+0x2EC`, `+0x2F4` — string-cleanup.
- `bl 0x824B2188` at `+0x254` — tid=1's outer-channel pump.
- `bl 0x824482D0` at `+0x288` — format.
- **`bl 0x821746B0` at `+0x2A4`** — **task allocator + worker spawn (the key call).**
- `bl 0x824AA5C8` at `+0x2B8` — status query (returns `r3` → checked vs `0x103 STATUS_PENDING`).
- **`bl 0x824AA330` at `+0x2D0`** — **`KeWaitForSingleObject` INFINITE — THE WEDGE PC.**
- `bl 0x82174AF8` at `+0x2E0` — post-wait task-cleanup/state-transition.
- `b 0x825F0FC4` at `+0x2FC` — epilogue tail-jump.
## Audit log
- **AUDIT-066 (2026-05-12)** — **source-review only (READ-ONLY)**. AUDIT-065's "host-side `F8000048` IO completion thread" inference falsified by canary source review. `F8000048` is a **guest XThread thid=10**, spawned by main at canary-run.stdout:1331 via `ExCreateThread(...,824AFF88, 82450A28, 828F3B68, 0)` — entry `0x82450A28` is a Sylpheed-internal worker thread, not host infrastructure. Canary's only host helper thread is "Kernel Dispatch" (`xenia-canary/src/xenia/kernel/kernel_state.cc:524-549`) which services `CompleteOverlappedDeferred` for XAM UI/content, not file IO. Canary's `NtReadFile`/`NtReadFileScatter`/`NtWriteFile` (`xboxkrnl_io.cc:125-389`) are synchronous and signal the supplied event handle inline via `ev->Set(0, false)` after the sync read. Ours's `signal_io_completion_event` (`exports.rs:1156-1169`) is the bit-equivalent. **No "host-side IO completion signal" gap exists** in ours's IO handlers. The wait at this fn's `+0x2D0` (PC `0x82173C60`) is on the thread handle for the worker spawned via `bl 0x821746B0` (= tid=13 in ours / thid=17 in canary), released by `ExTerminateThread` per AUDIT-065 — confirmed correct framing. AUDIT-066 conclusion: brief's proposed fix locus (`xenia-kernel/src/exports.rs` IO handlers) is wrong; the bug is upstream worker-cluster bootstrap (AUDIT-057/063/064 chain). No code change of any size in `exports.rs` would unwedge tid=13. [confirmed: brief premise falsified]
- **AUDIT-065 (2026-05-12)** — full disasm + 17-PC probe in BOTH engines. **Canary's tid=1 (= F8000008, internally tid=6) reaches PC `0x82173C60` exactly once, waits on r3=`0xF8000094` (= XThread thid 17's thread handle), and the wait completes when that worker reaches `ExTerminateThread(0)`**. Worker runs synchronous cache file IO (`cache:\aab216c3\5\ee70e0a`, `cache:\87719002\c\...` etc) and spawns child workers via `ExCreateThread(... 824AFF88, 821C4AD0, BCA44C00 ...)` before terminating. **Ours's tid=1 reaches PC `0x82173C38` (post `bl 0x821746B0`, r3=`0x4024a640` = ours's task struct = ours's tid=13's start_ctx) and stalls before `0x82173C64`** — i.e. inside the wait at `0x82173C60`. Ours's tid=13 (created by `bl 0x821746B0`'s subroutine, entry `0x821748F0`) DOES open the same cache files (`cache:/aab216c3/5`, `cache:/aab216c3`) but BLOCKS inside `sub_821CB030+0x1AC` on event `0x12AC` (NO_SIGNALS_DESPITE_WAITS). So `ExTerminateThread` is never reached on ours's tid=13 → tid=1's wait on `0x12A4` never completes. **Refines the wedge from "thread-join on tid=13" to a precise mechanism**: the wait at `+0x2D0` is structurally a synchronous task-join (canary worker exits in ~1ms; ours's worker is permanently stuck downstream). **`sub_82173990` body itself is clean** — every probed PC except the wait completion matches canary's behavior; the divergence is entirely in what happens inside `sub_821746B0`'s spawned worker (tid=13's body in `sub_821748F0``sub_821C4EB0``sub_821CC3F8``sub_821CBA08``sub_821CB030`). Same AUDIT-049 island, now framed as: **how does canary's worker get its `0x12AC`-equivalent event signaled fast enough that the worker can call `ExTerminateThread`?** [confirmed]
## Open questions
- **What signals canary's tid=17 cache-IO completion event (the `0x12AC`-equivalent inside its `sub_821CB030`)?** Probe canary's `NtSetEvent`/`KeSetEvent` thunks (`0x8284DF5C`/`0x82490018`) filtered on tid=17's `r3`-handle in the lr-window around the corresponding tid=17 wait. Compare against ours's empty `0x12AC.signals` count.
- The cache file open flow looks identical in both engines for `cache:\aab216c3\…` paths — confirming AUDIT-054's VFS layout fix landed correctly. The divergence is purely in the producer-side signaling.
- Both engines pass through PC `0x82173B98` (the `sub_82453910` "signaler" candidate) on the `r25!=0 && r25!=28` path — but only if the AUDIT-046 "5/5 iter" path lands inside the block `[0x82173B14, 0x82173BC0]`. Ours's BB-entry probe shows `0x82173BC0` fires but NOT `0x82173B14/B40/B84/B98/BA0/BA8` — meaning ours's beq at `0x82173B14` is **taken** (r5==0) and we skip the entire block. Canary's BR list also shows direct jump from `0x82173A78``0x82173BC0` (line 1998→1999) — **so canary also skips the block at 0x82173B14**. Block is dead in both engines at this horizon. `sub_82453910` is NOT the relevant signaler at this call site.
- `r3` at `0x82173C48` shows `F8000094` in canary at probe time, but that's the **post-`bl 0x824AA5C8` PC** (mid-block; the captured value is whatever r3 carried at that instant — likely the handle being queried, not the return). Worth a follow-up probe to confirm the status-query target.
## Cross-references
- Caller: [sub_822F1AA8](sub_822F1AA8.md) (via thunk sub_82175330, vtable[0] of `*(0x828E1F08)`).
- Callees of interest:
- `sub_821746B0` — task allocator + worker spawn (no dossier yet — recommend creating one).
- `sub_82172370` — sync object creator (no dossier yet).
- `sub_82174AF8` — post-wait cleanup (no dossier yet).
- Worker-side wedge: [sub_821CB030](sub_821CB030.md) — fires inside the worker spawned via sub_821746B0 from sub_82173990's `bl` at `+0x2A4`.
- Audits: 049 (original tid=1 stall localization), 064 (full activation chain to sub_825070F0), 065 (this).
- Artifacts: `audit-runs/audit-065-sub82173990-wait-site/{sub_82173990.disasm,canary.log,canary-run.stdout,ours.log,ours-stdout.log,summary.md}`.

View File

@@ -0,0 +1,53 @@
---
address: 0x821B55D8
classification: normal_callee
confidence: high
last_audit: 064
aliases:
- "AUDIT-058 caller-ladder fn #5 (vtable slot 6 of class containing 0x82172D88 dispatcher)"
---
# sub_821B55D8 — vtable slot 6 invoked from sub_82172BA0 dispatcher
## Synopsis
Normal callee dispatched via the `bctrl` at `sub_82172BA0+0x1E8` (PC `0x82172D88`) — slot 6 of some game-object vtable (offset 24 = `lwz r11, 24(r11)`). Calls [sub_824F8398](sub_824F8398.md) at PC `0x821B5B5C` (=+0x584). Note the **only static caller is via `b` (jump, NOT bl)** from `sub_821B6DF4+0x40` — that's the MSVC EH catch-handler trampoline at PC `0x821B6E34`. **AUDIT-064 falsifies the AUDIT-058 framing that this is reached primarily via the EH path**: at runtime it's reached via the `bctrl` slot-6 dispatch from `sub_82172BA0`, not via the EH thunk.
## Evidence
- Disasm prolog at `0x821B55D8`: `mflr r12; bl 0x825F0F74; stfd f31, -88(r1); subi r31, r1, 368; stwu r1, -368(r1); mr r30, r3; ...` — standard normal-callee prolog. Uses `subi r31, r1, 368` (frame-pointer is `r1-368`), NOT MSVC EH-handler's `subi r31, r12, N`.
- Function size: 2076 bytes / 519 insns. `has_eh=True`, `frame_size=0` per DB (but the actual stack alloc is 368 bytes — `frame_size=0` likely indicates dynamic).
- Static caller xref (sole): PC `0x821B6E34` inside `sub_821B6DF4` via `kind=j insn=b` (unconditional branch, NOT bl). This is an EH catch-handler trampoline that tail-jumps into this fn's body — it's how the MSVC EH machinery enters the fn AFTER a matching exception is caught. Pattern at `0x821B6E30..0x821B6E34`: `lwz r3, 8(r3); b 0x821B55D8`.
- AUDIT-064 canary 60s probe: fires 1× with `lr=0x82172D8C r3=BCCC52C0 r4=FFFFFFFF r5=01000000 r6=00000000` on tid=6. `lr=0x82172D8C` is the post-bctrl PC inside `sub_82172BA0+0x1E8`. Reproduced at 120s and 180s.
- AUDIT-064 ours `--ctor-probe=0x821B55D8` -n 500M: **0 fires**.
## Activation
**Primary (runtime)**: vtable slot 6 dispatch from `sub_82172BA0+0x1E8 bctrl` (PC `0x82172D88`). The dispatcher walks an array of objects (loaded from `[r29+56]`) and invokes vtable slot 6 on each. Slot 6 = `lwz r11, 24(r11)` where r11 is the vtable.
**Secondary (EH path)**: MSVC catch-handler at `sub_821B6DF4+0x40` tail-jumps here when a matching exception is caught. Not the runtime activation path observed in either engine at this horizon.
## Static graph
- Static callers (DB):
- `sub_821B6DF4+0x40` via `b 0x821B55D8` (EH thunk, NOT a `bl` — reached via exception dispatch only).
- No `bl` static callers recorded — but **AUDIT-064 captured `lr=0x82172D8C` at runtime fire**, meaning the actual `bl`-equivalent caller is the bctrl at `sub_82172BA0+0x1E8`. The static analyzer's ind_call list for PC `0x82172D88` includes many observed targets but NOT this fn (gap in the dynamic-target inference).
- Callees: `sub_824F8398` at PC `0x821B5B5C`, plus many others (`sub_821707C0`, `sub_822F13B0`, `sub_822F2A00`, `sub_823C2990`, ...).
## Audit log
- **AUDIT-064 (2026-05-12)** — disasm confirms normal-callee prolog (refutes "EH handler" hypothesis). Canary fires 1× / ours 0×. **Real runtime caller is `sub_82172BA0+0x1E8 bctrl`, NOT `sub_821B6DF4` EH thunk.** The DB xref via `b` from EH is a secondary entry path. **New reading-error class observed**: static xrefs for `bctrl` indirect targets are populated by some dynamic-target inference but it has gaps — must cross-check at runtime via `--audit_61_branch_probe_pcs` + LR resolution. [confirmed]
- **AUDIT-058 (2026-05-10)** — flagged as part of static caller ladder under `sub_821B6DF4`. [STATUS: partially falsified by AUDIT-064 — the runtime path is the bctrl from sub_82172BA0, not the EH thunk.]
## Open questions
- Which class's vtable has slot 6 = `sub_821B55D8`? The instance loaded by `sub_82172BA0` at `[r3+24]` from the array. Possibly `silph::GamePart_Title` or a sibling — would need to enumerate `sub_82172BA0`'s array-walk target instances at runtime.
- Why does the DB's `xrefs` (kind=`ind_call`) for source `0x82172D88` not list `sub_821B55D8` as a target? The dynamic-target inference appears to populate from a separate trace, missing this one.
## Cross-references
- Callees: `sub_824F8398` (PC `0x821B5B5C`).
- EH-secondary entry: `sub_821B6DF4+0x40` (`b 0x821B55D8`).
- Runtime caller (bctrl): `sub_82172BA0+0x1E8` (PC `0x82172D88`).
- Audits: 058, 060, 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/canary-{60,120,180}s.log`, `canary-upstream-60s.log`.

View File

@@ -0,0 +1,58 @@
---
address: 0x821B6DF4
classification: msvc_eh_catch_handler
confidence: high
last_audit: 060
aliases:
- "AUDIT-058 caller-ladder top (FALSIFIED)"
---
# sub_821B6DF4 — MSVC C++ catch-handler thunk
## Synopsis
A C++ catch-handler thunk emitted by the MSVC PowerPC C++ runtime. Dispatched by the EH machinery (`_CxxFrameHandler3` equivalent) when a matching exception type is thrown — NOT a normal `bl` callee. AUDIT-058 mistakenly treated it as the top of a "static caller ladder" for `sub_825070F0`'s activation; AUDIT-060 falsified that by reading the prolog and the `.rdata` reference context.
**This is the canonical "MSVC EH FuncInfo metadata mistaken for call edges" case. Always check the prolog before assuming a 0-caller fn is a missing activator.**
## Evidence
- Disasm at `0x821B6DF4` opens with the canonical MSVC catch-handler prolog: `subi r31, r12, 112; mflr r12; stwu r1, -96(r1); ...`. The use of `r12` (parent-frame pointer offset) and `mflr r12` is signature MSVC EH-handler shape.
- Address `0x821B6DF4` appears as a u32 value in only two places in the binary:
- `.rdata:0x820C1994` — embedded inside an MSVC FuncInfo struct. Bracketing bytes: `FFFFFFFF 821B6DF4 19930522 00000001 820C1990 ...`. `0x19930522` is the MSVC FuncInfo magic.
- `.pdata:0x8211C678` — exception-unwind metadata.
- AUDIT-060 Probe C-Win Windows Debug canary: `--log_lr_on_pc=0x821B6DF4`, runs at 120s and 240s wallclock → **0 fires both runs**. The matching exception is not thrown at this boot horizon.
- AUDIT-060 Probe O ours: `--ctor-probe=0x821B6DF4 -n 500M`**0 fires**.
- Body: single `bl 0x82183B78` (an EH support routine) then return.
## Activation
C++ exception runtime dispatch. Fires iff a try-block protected by the FuncInfo at `0x820C1990` catches a thrown object whose type matches the catch's CatchTypeInfo. Neither engine throws this exception at the probed horizon.
## Static graph
- Static callers: **0** — and this is correct (0 callers does not imply dead; it implies "not a bl target").
- Callees: `sub_82183B78` (EH support routine).
- xrefs in DB will show `kind=indirect` or absent entries; the `.rdata` reference at `0x820C1994` is the FuncInfo binding, not a call edge.
## Audit log
- **AUDIT-060 (2026-05-12)** — disassembled body; identified MSVC catch-handler prolog; cross-referenced `.rdata` bytes to find FuncInfo magic `0x19930522`; probed in both engines at 240s/-n500M → 0 fires both sides. AUDIT-058's "caller ladder" framing falsified. New reading-error class #16 logged. [confirmed]
- **AUDIT-058 (2026-05-10)** — claimed as "top of static caller ladder" for `sub_825070F0` activation, walked: `sub_825070F0 ← sub_824F7800 ← sub_824F7CD0 ← sub_824F8398 ← sub_821B55D8 ← sub_821B6DF4`. All 6 fire 0× in ours; framed as missing activation. [STATUS: falsified by AUDIT-060 — the entire 6-fn chain is C++ EH unwind metadata; none of them are normal call edges; they fire only on specific exception throws.]
## Open questions
- What exception type-id activates this catch? Parse the FuncInfo struct at `0x820C1990`:
- TryBlockMap entries → CatchTypeArray pointer → CatchType records (each has type_info* + handler ptr).
- The type_info string would identify the C++ class being caught.
- Is the matching throw site reachable in either engine at *any* boot horizon? If yes, when?
- Are the other 5 fns in the AUDIT-058 ladder ALL catch-handler thunks? Spot-check `sub_821B55D8`, `sub_824F8398`, `sub_824F7CD0`, `sub_824F7800`, `sub_825070F0`. (`sub_825070F0` DOES fire 1× per AUDIT-058 — so at least it's not pure-EH; could be the actual throw site or a normal-call leaf.)
## Cross-references
- FuncInfo location: `.rdata:0x820C1990` (start of struct), `0x820C1994` contains this fn's pointer.
- `.pdata` unwind: `0x8211C678`.
- Body callee: `sub_82183B78` (EH support).
- Companion ladder fns (need separate dossiers): `sub_821B55D8`, `sub_824F8398`, `sub_824F7CD0`, `sub_824F7800`, [sub_825070F0](sub_825070F0.md).
- Audits: 058, 060.
- Artifacts: `audit-runs/audit-060-fnptr-array-bootstrap/canary-sub821B6DF4-120s.log`, `canary-sub821B6DF4-240s.log`, `ours-summary.md`.

View File

@@ -0,0 +1,76 @@
---
address: 0x821C4EB0
classification: vtable_method
confidence: high
last_audit: 061
aliases:
- "silph::GamePart_Title::UImpl member fn"
- "AUDIT-056 early-exit (falsified by 061)"
---
# sub_821C4EB0 — silph::GamePart_Title::UImpl member fn (AUDIT-061: NOT a branch-divergence gate)
## Synopsis
Member function on class `silph::GamePart_Title::UImpl` (vtable `0x820a3e00`). **AUDIT-061 falsified the "conditional-branch divergence in `[+0x44, +0xE0]`" framing**: all 4 branches in that range are decided **bit-identically** in canary and ours. The actual divergence is the call `bl 0x821CC3F8` at PC `0x821C4F14`: in canary the call returns to `0x821C4F18` and the rest of sub_821C4EB0 executes through the 5 `bl 0x821CEDF8` sites at +0x198..+0x240; in ours the call enters the chain `sub_821CC3F8 → sub_821CBA08 → sub_821CB030` and never returns (tid=13 wedge inside sub_821CB030 = AUDIT-049 wedge handle, `NtCreateEvent` at +0x128 → INFINITE wait). AUDIT-056's "5× canary / 0× ours" callsite count is an indirect consequence of the upstream wedge, not a branch-decision asymmetry in this fn.
## Evidence
- AUDIT-049: appears in tid=13 thread-create chain — `sub_821748F0 → sub_821C4EB0 (UImpl@GamePart_Title@silph) → sub_821CC3F8 → sub_821CBA08 → sub_821CB030`.
- AUDIT-056: caller-LR `0x821C4F2C / 0x821C5014 / 0x821C5048` are post-`bl` PCs inside this fn. Reported `sub_821CEDF8` 5× canary / 0× ours.
- AUDIT-059: in the wedge's wait-thread frame-4 saved-r29 the vtable is `0x820a3e00 = .?AUImpl@GamePart_Title@silph@@`, confirming class membership.
- AUDIT-061 (READ-ONLY canary multi-PC probe @ ~2:00 wallclock; ours `--branch-probe` @ -n 500M):
- Both engines call sub_821C4EB0 exactly **1×** at this horizon. Same caller LR=0x82174A80 (canary tid=17, ours tid=13).
- Canary probe fires 17× covering entry + post-bl block entries + all 4 cond-branches: B1 `beq cr6 NOT taken` (cr6=.G., r3=0xBC220008≠0), B2 `bne cr6 NOT taken` (cr6=..E, lbz @ 0x828F3284 = 0), B3 `beq cr6 TAKEN` (cr6=..E, lwz r3,92(r30) == 0), B4 `bgt cr6 TAKEN` (cr6=.G., [r27+4] > 4). Reaches 0x821C5048 (1st `bl 0x821CEDF8`) and 0x821C504C (returned).
- Ours probe fires 4× covering entry + 3 post-bl: 0x821C4EB0 → 0x821C4EB8 → 0x821C4ED0 → 0x821C4EEC (r3=0x40105004 returned from `bl 0x82150EF8`; cr6=.G., **same direction as canary**). After 0x821C4EEC: **never reaches 0x821C4F18 or anywhere later in the function**.
- Chain probe (separate run) confirms ours's tid=13 enters sub_821CC3F8 (cycle 2069) → sub_821CC3F8+0x38 post-alloc (2249) → sub_821CBA08 (2258) → sub_821CB030 (3242), then stalls. Canary's tid=17 returns out of all four and reaches 0x821CC454 (post-bl-sub_821CBA08) and 0x821C4F18 (post-bl-sub_821CC3F8) cleanly.
- First divergent INSTRUCTION (not branch): `bl 0x821CC3F8` at PC `0x821C4F14`. First divergent state: ours's r3 at function entry to sub_821CC3F8 is `0x40105004` (40xxxxxx host-allocator region) vs canary's `0xBC220008` (BCxxxxxx region) — but this VA difference is the AUDIT-043 ε-class (allocator region drift) and is BENIGN here; sub_821CC3F8 dereferences r3 as a pool handle the same way in both engines and downstream allocation succeeds (sub_82150EF8 returns valid pointer in both).
## Activation
Vtable method. Reached via `bctrl` from class-owning code in the boot UI / GamePart_Title state machine. Indirect; the dispatch site PC and vtable slot index need DB cross-reference (see Open questions).
## Static graph
- Caller chain at the wedge site (AUDIT-049):
- `sub_821C4EB0 ← sub_821748F0` (top-level)
- flows down to `sub_821CC3F8 (GamePart_Title)``sub_821CBA08``sub_821CB030` (where wedge fires)
- Callees in source order:
- `0x821C4EB4 bl 0x825F0F7C` — save-GPRs prologue helper
- `0x821C4ECC bl 0x8284DA7C` — XAM import `XNotifyPositionUI` (xam.xex ord 652); r3=0xA → returns 0 in both engines.
- `0x821C4EE8 bl 0x82150EF8` — pool allocator (called with allocator table @ `[0x828E0000+11028]`, size=4); returns pointer in both engines (canary BC220008, ours 0x40105004).
- `0x821C4F14 bl 0x821CC3F8`**first divergent instruction (AUDIT-061)**: returns in canary, wedges in ours.
- `0x821C4F2C bl 0x82187C30` — only reached in canary at this horizon.
- `0x821C4F60 bl 0x82172370` — only reached in canary.
- `0x821C4F74 bl 0x824AA3E0` — conditional on prior beq; canary takes the SKIP-bl path (B3 = taken).
- `0x821C5048 / 0x821C5074 / 0x821C50A0 / 0x821C50C8 / 0x821C50F0 bl 0x821CEDF8` — 5 sites in the bgt-taken path; only reached in canary.
- Conditional branches in `[+0x44, +0xE0]` (enumerated AUDIT-061):
- B1 `0x821C4EF8 beq cr6, 0x821C4F20` — after `cmplwi cr6, r3, 0` (r3 = sub_82150EF8 return). Decided NOT taken in both.
- B2 `0x821C4F3C bne cr6, 0x821C4F7C` — after `lbz r10, 12932(0x828F0000)+cmplwi r10, 0`. Decided NOT taken in canary; UNREACHED in ours.
- B3 `0x821C4F70 beq cr6, 0x821C4F78` — after `lwz r3, 92(r30)`. Decided TAKEN in canary; UNREACHED in ours.
- B4 `0x821C4F90 bgt cr6, 0x821C5000` — after `cmplwi cr6, r11, 3`, r11 = `[r27+4]1`. Decided TAKEN in canary; UNREACHED in ours.
## Audit log
- **AUDIT-061 (2026-05-12)** — Multi-PC branch probe in both engines (new canary cvar `audit_61_branch_probe_pcs`, ours `--branch-probe`). All 4 conditional branches in `[+0x44, +0xE0]` decided **bit-identically** (B1 NOT-taken in both; B2/B3/B4 UNREACHED in ours because the function stalls earlier). First divergent BEHAVIOR is the call `bl 0x821CC3F8` at PC `0x821C4F14` — returns in canary, wedges in ours. The wedge is INSIDE `sub_821CB030` (chain `sub_821C4EB0 → sub_821CC3F8 → sub_821CBA08 → sub_821CB030`); tid=13 reaches sub_821CB030 at cycle 3242 and blocks indefinitely. Confirms AUDIT-049 wedge premise; matches AUDIT-059 γ-class missing-signaler. AUDIT-056's "5× sub_821CEDF8 canary / 0× ours" is an indirect consequence (those 5 sites are at +0x198..+0x240, downstream of the wedge). [confirmed — sub_821C4EB0 is NOT a branch-divergence gate]
- **AUDIT-060 (2026-05-12)** — convergence confirmed this fn as the AUDIT-061 target after AUDIT-058/059's "missing activator" framing was refuted. [superseded by 061 — actual divergence is non-returning call, not a branch]
- **AUDIT-056 (2026-05-10)** — identified as the primary divergence-introducer. Caller-LR is IDENTICAL canary/ours but body chooses a different path. [partially falsified by 061 — the "different path" framing was true at a high level, but it's because of a non-returning call, not a divergent conditional-branch decision in `[+0x44, +0xE0]`. The 5 sub_821CEDF8 callsites are downstream of the wedge.]
- **AUDIT-049 (2026-05-10)** — placed on the tid=13 chain that ultimately creates wedge handle. [confirmed — AUDIT-061 directly observed tid=13 entering sub_821CB030 in ours]
## Open questions
- ~~Enumerate every conditional branch PC in `[0x821C4EF4, 0x821C4F90]`~~. **DONE in AUDIT-061**: B1/B2/B3/B4 enumerated; none divergent in decision.
- ~~For each branch: capture cr0/cr6/cr-of-interest...~~. **DONE in AUDIT-061**.
- ~~What input register controls the first divergent branch?~~ **Moot — no branch diverges in this fn.**
- **NEW (AUDIT-062 target):** Where INSIDE sub_821CB030 does ours's tid=13 stall? AUDIT-049 hypothesized the wait at the event handle created at +0x128. Probe sub_821CB030's basic-block entries to find the highest-PC reached by tid=13 before stall; cross-reference with the NtCreateEvent / KeWaitForSingleObject sites.
- Which vtable slot is `sub_821C4EB0` at in vtable `0x820a3e00`? (still open; cross-ref `xrefs` table for `target = 0x821C4EB0` with `kind = 'read'` or `'ref'` in `.data`/`.rdata`).
## Cross-references
- Vtable: `0x820a3e00 = .?AUImpl@GamePart_Title@silph@@` (class)
- Sibling class vtable: `0x820a3dc8 = .?AVGamePart_Title@silph@@` (parent? aggregate?)
- Callees: `sub_821CC3F8` (first-divergent-call AUDIT-061), `sub_821CEDF8` (5× sites at +0x198..+0x240, only reached in canary)
- Callers: `sub_821748F0` (top of tid=13 chain; lr=0x82174A80 seen in both engines AUDIT-061)
- Wedge chain: [sub_821CB030](sub_821CB030.md) is where ours's tid=13 stalls per AUDIT-061's chain probe.
- Audits: 049, 056, 057, 058, 059, 060, **061**
- Artifacts: `audit-runs/audit-056-producer-trace/`, `audit-runs/audit-059-gamma-wedge/`, `audit-runs/audit-061-sub821C4EB0-branch-diff/`

View File

@@ -0,0 +1,62 @@
---
address: 0x821CB030
classification: normal_callee
confidence: high
last_audit: 066
aliases:
- "wedge primary site"
- "file-IO completion event creator+waiter"
---
# sub_821CB030 — wedge primary site (creates + submits + waits file-IO completion XEvent)
## Synopsis
The function whose body creates, submits work for, and waits on the canonical AUDIT-049/058/059 γ-wedge XEvent. Used by `silph::GamePart_Title::UImpl` to load `cache:\aab216c3\5\…` files synchronously: NtCreateEvent at `+0x128`, work submit at `+0x19C` (calls `sub_82452DC0`), wait INFINITE at `+0x1AC`. The wait is what blocks the entire post-intro phase in ours.
## Evidence
- AUDIT-049: tid=13 chain ends at this fn with wait at `0x824ac578` (KeWaitForSingleObject in the wait wrapper called from `+0x1AC`).
- AUDIT-058: canary captures `sub_821CB030+0x12c` (=PC after the NtCreateEvent bl) in stacks.
- AUDIT-059 Probe O ours: handle `0x12AC` (Event/Auto) created at `0x821cb158` (=`+0x128`), waited at `0x821cb1dc` (=`+0x1AC`). Wedge has `signal_attempts=0` — never signaled by the worker side.
- AUDIT-059 Probe C canary: same PCs fire; `0xF8000098` created, then `NtDuplicateObject`'d to `0xF80000A0`, original closed fast, dup signaled by worker via `sub_82458B90`/`sub_8245EC10`.
- File-IO context: precedes synchronous file load of `cache:\aab216c3\5\…` (post-VFS work in AUDIT-054).
## Activation
Direct `bl` from `sub_821CBA08+0xd8` (AUDIT-059 create-stack frame 1). One static caller. Higher in the chain: `sub_821CC3F8 (GamePart_Title) → sub_821CBA08 → sub_821CB030`.
## Static graph
- Callers:
- `sub_821CBA08+0xd8` (only static caller)
- Callees of interest:
- `sub_824A9F18` — NtCreateEvent wrapper, called at `+0x124 bl` (post-call PC = `+0x128 = 0x821CB158`).
- `sub_82452DC0` — work-submitter, called at `+0x198 bl` (post-call PC = `+0x19C`).
- `sub_824AC540` — wait wrapper, called at `+0x1A8 bl` (post-call PC = `+0x1AC = 0x821CB1DC`).
## Audit log
- **AUDIT-066 (2026-05-12)** — **source-review only (READ-ONLY)**. Re-read canary's `xenia/kernel/xboxkrnl/xboxkrnl_io.cc:39-389` + `xfile.cc:19-198` + `kernel_state.cc:519-551` and ours's `xenia-kernel/src/exports.rs:1103-1518, 3747-3764`. AUDIT-065's "host-side IO completion thread `F8000048` signals each per-load event" framing is **falsified**: (i) canary's `NtReadFile`/`NtReadFileScatter`/`NtWriteFile` are synchronous and signal the supplied event handle **inline** via `ev->Set(0, false)` (lines 210-212, 296-298, 383-385); no host async-IO thread exists; the only host thread "Kernel Dispatch" (`kernel_state.cc:524-549`) services `CompleteOverlappedDeferred` for XAM overlapped UI/content, not file IO; (ii) `F8000048` in AUDIT-065 stdout is a **guest XThread** thid=10 (entry `0x82450A28`, ctx `0x828F3B68`), spawned by main at `canary-run.stdout:1331` via `ExCreateThread(...,824AFF88, 82450A28, 828F3B68, 0)` — the `F8` prefix is a guest kernel-object handle region marker, NOT a host-thread marker; (iii) cache loads at `canary-run.stdout:2127-2154` (sequence `NtCreateEvent → NtCreateFile → NtDuplicateObject → NtQueryInformationFile → NtClose`) emit **zero** `NtReadFile`/`NtSetEvent` lines — `NtQueryInformationFile` has no event-handle parameter in either engine; (iv) thid=17 (`F8000094`) terminates via `ExTerminateThread(0)` WITHOUT ever calling Wait inside its cache loop — so the canary path doesn't even hit this fn's wait sites for the cache files visible in AUDIT-065's stdout. Ours's `signal_io_completion_event` (`exports.rs:1156-1169`) called from 16 sites in `nt_read_file`/`nt_write_file`/`nt_device_io_control_file` already implements canary's `ev->Set(0, false)` semantics — **there is no missing analog**. The wait at this fn's `+0x1AC` is a wait on the `sub_82452DC0` work-queue dup'd XEvent, signaled by guest worker-cluster code (γ-signalers A/B/C/D per AUDIT-059/060) — not IO completion. Bug class confirmed = AUDIT-063 structural / bootstrap-ordering. **AUDIT-066 fix locus (`xenia-kernel/src/exports.rs` IO handlers) is the WRONG target**; the bug is upstream in worker-cluster bootstrap (`sub_825070F0` activation gate). [confirmed: NO IO-completion gap]
- **AUDIT-065 (2026-05-12)** — wedge mechanism precisely framed via [sub_82173990](sub_82173990.md). Canary's tid=17 worker (= analog of ours's tid=13) reaches `ExTerminateThread(0)` after sequentially loading `cache:\aab216c3\5\ee70e0a`, `cache:\87719002\c\dba806e/ec0a96e`, `cache:\87719002\a\60fcb85`, `cache:\87719002\2\85d8849`, `cache:\87719002\0\1a2db9c` etc — 16+ cache file loads — AND spawning child workers via `ExCreateThread(..., 824AFF88, 821C4AD0/822C6870, ...)`. Worker's own `sub_821CB030` calls (file-IO completion event waits) complete in canary. **In ours, the very first sub_821CB030 call (on handle `0x12AC`) hangs (`NO_SIGNALS_DESPITE_WAITS`)** — tid=13 never reaches `ExTerminateThread`, tid=1's join wait on `0x12A4` never completes. Cache file opens succeed in ours (paths `cache:/aab216c3/5`, `cache:/aab216c3` etc seen in log just before the stall) — so the bug is post-VFS, in the producer→worker async-IO completion signaling, exactly as AUDIT-062 found. [confirmed]
- **AUDIT-063 (2026-05-12)** — AUDIT-062's candidate trio (`0x822F2304`/`0x822F1D84`/`0x821743D8`) confirmed as RED HERRINGS: containing fns `sub_822F2248`/`sub_822F1AA8`/`sub_821741C8` resolved, but **none are reachable from `sub_82452DC0` in 12 hops**. Track-A probe (180s canary / 500M-instr ours): canary fires 11.7k× / ours 0× on `0x822F1D84` and `0x821743D8` — but they're downstream of an unblocked main event loop (canary tid=6 = guest main). Ours's main (tid=1) is `Blocked` on `0x12A4` (tid=13 thread-join handle, AUDIT-049), which transitively blocks on this fn's wedge `0x12AC`. Real producer is the worker cluster `sub_82458B90`/`sub_8245EC10`/`sub_8245FEB8`/`sub_8245D9D8`/`sub_8245DA78` running on the 4 workers spawned by `sub_825070F0`**0 of those 8 workers spawn in ours** vs 8 in canary. The bug is the AUDIT-057 thread-gap closing in on itself: the cluster cannot bootstrap because the wedge isn't signaled, and the wedge isn't signaled because the cluster cannot bootstrap. NO new producer fn was missed by prior audits. [confirmed: trio is symptom not cause]
- **AUDIT-062 (2026-05-12)** — wedge KEVENT data-flow traced. Outcome **(b)**: NtDuplicateObject thunk = `0x8284DF7C`; sub_821CB030 has NO direct bl-NtDup (dup is performed by descendant via wrapper `sub_824AA398`). Phase 2 ours `--lr-trace=0x8284DF7C`: wedge handle `0x12AC` IS duped by tid=13 cycle 26711 (alongside `0x12B0` cycle 23833). Out_ptr `0x40541E80` populated with dup_handle = source_handle = `0x12AC` (ours aliases per `exports.rs:4263`). sub_82452DC0 fires 8× in ours; line 8 = wedge submit on tid=13 cycle 8127 lr=0x821CB1D0, with r6=0x40541E80 (job struct carries the dup pointer). So **work IS submitted with the right handle**. Phase 4 ours `--lr-trace=0x8284DF5C,0x824AA2F0`: 68 NtSet fires, **0 on `0x12AC`** (neighbors 0x129C / 0x12B0 ARE signaled — infrastructure capable). γ-signalers A/B/C/D all fire (3/2/3/6+2 fires resp.) — but on non-wedge handles. **The break is upstream of γ-signaler**: ours's worker tid=5 is parked on its OWN idle event `0x12B8` (created by tid=5 via NtCreateEvent), and **no NtSetEvent in ours signals `0x12B8`** (also NO_SIGNALS_DESPITE_WAITS). Producer-side worker-wake signal is missing. Cascade A=NtDup fires correctly on wedge YES (cycle 26711); B=wedge dup NOT signaled CONFIRMED; C=outcome (b) localized to producer→worker wake gap (`0x12B8`); D=draws>0 deferred to AUDIT-063 fix. New finding: **γ-signaler D = `sub_8245D9D8` / `sub_8245DA78`** (LR `0x8245DA44` / `0x8245DB08`) — NtSet wrapper hot from worker-side, missed by AUDIT-059/060 dossier list. Canary spreads NtDup across 6 tids (6/10/16/17/18/26 → 33 fires/180s); ours across 3 (1/5/13 → 14 fires) — confirms AUDIT-057 thread-gap as enabling condition. Trace `audit-runs/audit-062-wedge-kevent-flow/`. [confirmed outcome b]
- **AUDIT-060 (2026-05-12)** — confirmed wedge structural identification: `NtCreateEvent → NtDuplicateObject → enqueue → worker → NtSetEvent on dup` (canary path); ours stalls at the wait because workers don't signal. [confirmed]
- **AUDIT-059 (2026-05-11)** — established as keystone γ-wedge site. Handle 0x12AC create-site is here at `+0x128`. [confirmed]
- **AUDIT-058 (2026-05-10)** — sister mention in tid=13 chain (frames via sub_821CB1D0 ← sub_821CBAE0). [confirmed]
- **AUDIT-049 (2026-05-10)** — original discovery that tid=13 waits INFINITE on event created here; main thread (tid=1) is downstream via thread-join handle. [confirmed]
## Open questions
- Is the `+0x128` create the ONLY NtCreateEvent in this fn, or are there multiple? **AUDIT-062 db query: exactly 1 `bl 0x824A9F18` (NtCreateEvent wrapper) at `+0x128`.** Two `bl 0x82452DC0` (`+0x19C`, `+0x2EC`) and two `bl 0x824AA330` wait-wrappers (`+0x1AC`, `+0x318`) — same KEVENT submitted+waited twice (sequential file-IO loads), or alternative-branch fork. Canary's 2 fires at `0x821CB158` therefore mean sub_821CB030 is *invoked twice* by its caller, each creating a fresh KEVENT.
- What does `+0x19C..+0x1A8` do between work-submit and wait? (Likely sets up the wait params.) Disassemble to confirm.
- ~~Does ours's NtDuplicateObject correctly create a signal-aliased handle?~~ AUDIT-062 confirmed: YES — ours aliases (dup_id = source_id), out_ptr populated, refcount bumped. Bug is NOT here.
- **Open after AUDIT-062**: which producer-side call (descendant of `sub_82452DC0`) calls `NtSetEvent` on the worker idle event (`0x12B8`-class) in canary, and why does ours skip it? Probe canary's hot NtSet wrapper LRs `0x822F2304, 0x822F1D84, 0x821743D8` (9k+ fires each) — one of these is likely the worker-wake.
## Cross-references
- Wedge handle in ours: drifts per run (0x1288/0x12A4/0x12AC across audits — see [reference_function_dossiers](docs/functions/README.md) caveat).
- Callers: [sub_821CBA08](#) (not yet dossierd)
- Callees: [sub_82452DC0](sub_82452DC0.md), sub_824A9F18 (NtCreateEvent wrapper)
- Audits: 049, 058, 059, 060, 062
- Artifacts: `audit-runs/audit-049-tid1-stall-0x1280/`, `audit-runs/audit-059-gamma-wedge/`, `audit-runs/audit-062-wedge-kevent-flow/`

View File

@@ -0,0 +1,55 @@
---
address: 0x822F1AA8
classification: normal_callee
confidence: high
last_audit: 065
aliases:
- "tid=1 post-init dispatch loop (calls sub_82173990 via vtable[0])"
---
# sub_822F1AA8 — tid=1 post-init game-loop dispatcher
## Synopsis
Normal-callee invoked by tid=1's `entry_point → sub_8216EA68 → sub_822F1AA8`. Contains the per-frame game-loop pump for the post-init subsystem-dispatch tree (audit-064 chain to [sub_825070F0](sub_825070F0.md)). Runs an outer loop: (a) `KeWaitForSingleObject` infinite at PC `0x822F1DFC`, (b) dispatches vtable[0] of object at `*(0x828E1F08)` at PC `0x822F1B4C bctrl` — which is `sub_82175330` → tail-jump → `sub_82173990`. Canary executes the body 4040× in 60s (per-frame). Ours executes the function entry 1× then **blocks immediately inside sub_82173990 (the vtable[0] callee) at sub_82173990+0x2D0** — KeWaitForSingleObject INFINITE on handle `0x12A4` (= tid=13's thread handle = AUDIT-049 wedge).
## Evidence
- Disasm prolog: `mflr r12; bl 0x825F0F60; stfd f30, -136(r1); stfd f31, -128(r1); subi r31, r1, 256; stwu r1, -256(r1); mr r30, r3; ...` — normal-callee.
- Function size: 996 bytes / 249 insns. `has_eh=True`, `frame_size=0` per DB (dynamic 256-byte frame).
- Static caller: `sub_8216EA68+0x3AC` via `bl` (the post-init dispatcher).
- AUDIT-064 ours `--ctor-probe=0x822F1AA8` -n 500M fires 1× at tid=1, cycle=6,171,801, lr=0x8216ee14, r3=0x40d09a40. Back-chain: tid=1 thread_proc → entry_point → sub_8216EA68+0x3AC → sub_822F1AA8.
- AUDIT-064 ours fine probe at BB-entries `0x822F1ACC/0x822F1AEC/0x822F1B20/0x822F1B30/0x822F1B38` all fire 1× — execution does pass through the function body to PC `0x822F1B38`.
- AUDIT-064 ours `--ctor-probe=0x822F1B50` fires **0×**. The bctrl at PC `0x822F1B4C` DOES execute (sub_82175330 fires 1× per separate probe), but never returns — because sub_82175330 tail-jumps to sub_82173990 which blocks at +0x2D0.
- AUDIT-064 canary 60s probe (`--audit_61_branch_probe_pcs`): all probes in body fire — `0x822F1B5C/0x822F1B78/0x822F1BB8` fire 1×, `0x822F1BCC` (outer-loop body) fires 4040×, `0x822F1D58` (the inner bctrl → sub_821741C8) fires 4030×, `0x822F1DFC` (outer KeWait) fires 4040×.
- Global `0x828E1F08` is the slot holding the object pointer that the vtable[0] bctrl dispatches off. Its writers are `sub_822F14D8+0xF0` (PC `0x822F15A4`, observed in ours) and `sub_822F1638+0x84` (PC `0x822F16BC`). At cycle ~6,171,800 in ours, `[0x828E1F08]` is set to `0x40111890`; `[0x40111890+0]` evolves through multiple vtable values (`0x820AD894`, `0x820A183C`, ...) before the bctrl fires; final value at bctrl is `0x820A183C` (slot 0 = `sub_82175330`).
## Activation
Direct `bl` from `sub_8216EA68+0x3AC` on tid=1. One-shot at boot per game session — but the function itself contains an outer loop that iterates `KeWaitForSingleObject` waits until termination.
## Static graph
- Static callers: `sub_8216EA68+0x3AC` via `bl` (sole).
- Direct callees: `sub_822F13B0`, `sub_824AA2F0` (NtSetEvent wrapper), `sub_82172370`, `sub_824AA3E0`, `sub_824C1910` (leaf), `sub_824AA8B0`, `sub_82456B58`, `sub_824AA330` (KeWaitForSingleObject wrapper), `sub_824574C0`, `sub_82457038`, `sub_8284E45C` (kernel import thunk).
- Indirect: `bctrl` at PC `0x822F1B4C` (vtable[0] of `*(0x828E1F08)`) and `bctrl` at PC `0x822F1D58` (vtable[2] of same).
- Reads `0x828E1F08` at PCs `0x822F1B3C, 0x822F1BE8, 0x822F1D40, 0x822F1E44, ...` (11 reads).
## Audit log
- **AUDIT-065 (2026-05-12)** — refined the dispatch-target understanding. The vtable[0] callee at PC `0x822F1B4C bctrl` is `sub_82175330` (2-insn tail-jump to `sub_82173990`). `sub_82173990` is a **synchronous task-spawn-and-join helper** — not an outer game loop. Canary fires this function **exactly 1× per boot** (not 4040× as the synopsis previously suggested) — the 4040× metric in audit-064 referred to PCs *downstream of* sub_82173990's return into sub_822F1AA8's outer loop (PCs `0x822F1BCC`/`0x822F1D58`/`0x822F1DFC`). Per AUDIT-065 sub_82173990 dossier, the wait at sub_82173990+0x2D0 IS a thread-join, and the body of sub_82173990 itself is clean — only the worker spawned via `sub_821746B0` (which becomes ours's tid=13) is wedged inside `sub_821CB030`. [confirmed]
- **AUDIT-064 (2026-05-12)** — identified as the immediate dispatch chain origin for the 4-fn ladder leading to sub_825070F0. Disasm + ours fine-grained BB probes localize the wedge: tid=1 enters function (1×), passes through PCs 0x822F1ACC/0x822F1AEC/0x822F1B20/0x822F1B30/0x822F1B38, executes bctrl at PC `0x822F1B4C` → sub_82175330 → sub_82173990 → KeWaitForSingleObject(handle=0x12A4 = tid=13 thread handle) → STALL. Canary instead returns from that wait and enters the outer game-loop body (`0x822F1BCC` fires 4040× in 60s). **First divergence between canary and ours is at sub_82173990's wait inside vtable[0] of `*(0x828E1F08)` — same AUDIT-049 wedge.** [confirmed]
## Open questions
- The vptr at `[0x40111890+0]` mutates multiple times before the bctrl fires (writes from sub_82152XXX, sub_8244e850, sub_8244e8bc, sub_82155b4c, sub_82460c40, sub_822F2758, sub_8216F110, ...). Is the final value `0x820A183C` (which has slot 0 = sub_82175330) the same as canary's final value? Run the same `--mem-watch` against canary to verify.
- Why does canary's tid=13 finish (allowing tid=1's join wait on handle 0x12A4 to complete) while ours's tid=13 stalls? That's the AUDIT-049 root question — separately tracked.
## Cross-references
- Direct callers: `sub_8216EA68+0x3AC`.
- Callees of interest: `sub_82173990` (via vtable[0] thunk `sub_82175330`) — where tid=1's stall occurs.
- Downstream (when activated): `sub_82173990``sub_821741C8``sub_82172BA0``sub_821B55D8``sub_824F8398``sub_824F7CD0``sub_824F7800``sub_825070F0`.
- Object dispatch: `*(0x828E1F08) = 0x40111890`, vptr `[0x40111890+0] = 0x820A183C` (vtable), slot 0 = `sub_82175330`.
- Audits: 049 (the underlying wedge), 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/ours-fine-822F1AA8.stdout`, `ours-bb-822F1AA8.stdout`, `ours-vtable820a183c.stdout`, `ours-vptr-time.log`, `canary-inside-822F1AA8.log`.

View File

@@ -0,0 +1,62 @@
---
address: 0x82452DC0
classification: normal_callee
confidence: high
last_audit: 063
aliases:
- "work-submitter"
- "audit-050 root"
---
# sub_82452DC0 — work-submitter / cluster-root
## Synopsis
Central work-submission function. All AUDIT-049060 γ-wedge chains and AUDIT-058 vtable-activation chains funnel through this function. Receives a request (likely a file-IO descriptor + completion XEvent) and dispatches it via 9 direct callees + 1 indirect call. In canary it fires ~3.21× more often than in ours per AUDIT-056 — the upstream gate is in its caller, not in it.
## Evidence
- AUDIT-050 enumerated 9 direct targets at `bl` sites: `0x8245AE50, 0x82452068, 0x82452200, 0x8245B000, 0x8245B078, 0x82454A40, 0x82452AB8, 0x82454918, 0x82452EC4`, plus 1 `ind_call`.
- AUDIT-051 found a predicate gate at `+0x78`: `0x82452E2C beq cr6, 0x82452E88`, controlled by `sub_8245B000(r3)` returning 1 iff `[r3+0]≠0 AND [r3+4]≠0`. The 80-byte stack-local struct lives at `r31+96`.
- AUDIT-052 found `[r3+0]` / `[r3+4]` are halves of a hash key formatted into `cache:\<HASH1>\<X>\<HASH2>` paths — i.e. the struct holds a content hash for cache resolution. Predicate refuted as the bug.
- AUDIT-055 probed `sub_8245B078`'s body with a cache override: body executes correctly; divergence is upstream.
- AUDIT-056: fires **canary 45/60s, ours 14/26s = 3.21× ratio**. Sharpest specific divergence: `sub_8217FA08` from `LR=0x82455E60` (`=sub_82455DF0+0x70`) canary 20 / ours 0.
- AUDIT-059: tid=13 itself fires `sub_82452DC0` once at LR=`0x821cb1d0` (from `sub_821CB030+0x19C`) immediately before waiting on the file-IO completion XEvent.
- AUDIT-060: confirmed convergence — `sub_8245FEB8 ← sub_824601A0 ← sub_82460118 ← sub_82452AB8 ← sub_82452DC0`. The vptr-installer chain bottoms out here.
## Activation
Direct `bl` from 34 static caller sites per AUDIT-051. Notable callers:
- `sub_821CB030+0x19C` — drives the file-IO completion submission used by `silph::GamePart_Title::UImpl`.
- `sub_821CB030+0x2BC` — second site in same fn.
- `sub_821C4EB0` chain (AUDIT-056 gate).
- `sub_82173990+0x208` (program-top frame).
## Static graph
- Static callers: 34 sites across boot + tid=13 + UI cluster.
- Static callees (direct `bl`): 9 functions above + 1 computed call.
- The 9-target tree is the "worker activation surface". `sub_82452AB8` is the gate leading to vptr installers; `sub_8245B078`, `sub_8245B000` are the cache-key/hash gates; the others are queue management.
## Audit log
- **AUDIT-063 (2026-05-12)** — static reachability surveyed: among the 60 distinct callers of NtSet wrapper `0x824AA2F0`, **only 1 is reachable within 12 hops from `sub_82452DC0`**: `sub_8245FEB8` (γ-signaler C). The AUDIT-062 candidate trio (`sub_822F2248`, `sub_822F1AA8`, `sub_821741C8`) are NOT downstream. Confirms that the "producer-side worker-wake signal" canary path is the worker cluster (`sub_82458B90`/`sub_8245EC10`/`sub_8245FEB8`/`sub_8245D9D8`/`sub_8245DA78`) reached via the 4 worker threads spawned by `sub_825070F0` — and those threads are unspawned in ours (0 vs 8 in canary). [confirmed]
- **AUDIT-060 (2026-05-12)** — confirmed as single funnel for AUDIT-058+059 chains; the work-submitter is alive and queues but the throughput is gated by `sub_821C4EB0` early-exit per AUDIT-056. [confirmed]
- **AUDIT-059 (2026-05-11)** — fires 8× in ours; one of those is tid=13 from `sub_821CB030+0x19C` right before waiting on the wedge XEvent. Work submitted but no signal returns. [confirmed]
- **AUDIT-056 (2026-05-10)** — fires canary 45 / ours 14 in matched windows = 3.21× gap. Bug class refined to "δ-throughput". [confirmed]
- **AUDIT-055 (2026-05-10)** — proved `sub_8245B078` body executes correctly; ruled out a downstream bug here. [confirmed]
- **AUDIT-052 (2026-05-10)** — refuted AUDIT-051's "missing population" hypothesis. The struct is bit-identical to canary; `[r3+0]/[r3+4]` are a content hash. [supersedes-AUDIT-051-claim]
- **AUDIT-051 (2026-05-10)** — initially identified `+0x78` predicate gate as bug. [STATUS: hypothesis falsified by AUDIT-052; the gate itself is real and named correctly, but it's not the bug]
- **AUDIT-050 (2026-05-10)** — enumerated 9 direct targets + 1 indirect; framed as activation-surface root. [confirmed]
## Open questions
- Why does `sub_82452DC0` fire 3.21× less in ours? AUDIT-061 pivots to its caller `sub_821C4EB0`'s internal branches `[+0x44, +0xE0]`.
- The 1 indirect call (computed) — what does it dispatch to, and does our static-analyzer miss any of its candidates?
## Cross-references
- Callers: [sub_821CB030](sub_821CB030.md), [sub_821C4EB0](sub_821C4EB0.md)
- Callees-of-interest: [sub_82452AB8](#) (not yet dossierd), `sub_8245B078`, `sub_8245B000` (cache-hash gate)
- Audits: 049, 050, 051, 052, 053, 054, 055, 056, 057, 058, 059, 060
- Artifacts: `audit-runs/audit-050-*/`, `audit-runs/audit-056-producer-trace/`, `audit-runs/audit-059-gamma-wedge/`, `audit-runs/audit-060-fnptr-array-bootstrap/`

View File

@@ -0,0 +1,47 @@
---
address: 0x82457EF0
classification: thread_proc
confidence: high
last_audit: 060
aliases:
- "tid=6 thread_proc"
---
# sub_82457EF0 — tid=6 thread_proc (worker entry)
## Synopsis
Thread procedure for tid=6 in ours. 0 static callers — and that is *correct* for a `thread_proc`: it is installed as an entry-point via `ExCreateThread` somewhere in boot, not invoked via `bl`. AUDIT-059's "only-caller of [sub_82458B90](sub_82458B90.md) has 0 callers — fnptr-array only" inference was wrong; the actual activation is thread creation.
## Evidence
- AUDIT-060 Probe O ours: fires **1× on tid=6** (HW=2, cycle=0, lr=`0xbcbcbcbc` — thread-entry sentinel).
- `lr=0xbcbcbcbc` is the Xbox 360 / xenia convention for "this is the very first instruction of a thread proc; no return address". This is a diagnostic that distinguishes thread entry from a normal `bl` fire.
- Calls [sub_82458B90](sub_82458B90.md) at `+0x24` (1 callee at this offset).
## Activation
Registered as a thread entry-point via `ExCreateThread` (or similar). The caller of `ExCreateThread` that installs this entry has not yet been traced — that's the *real* activation site, and tracing it would close the loop on tid=6's purpose. Once tid=6 starts, the OS scheduler runs `sub_82457EF0` from PC `0x82457EF0` with LR=`0xbcbcbcbc`.
## Static graph
- Static callers (`bl`): **0** (correct — see classification).
- Callees: `bl sub_82458B90` at `+0x24` (PC `0x82457F18`).
- The "indirect call site" that activates this fn is the `ExCreateThread` invocation, captured at runtime, not in static `xrefs`.
## Audit log
- **AUDIT-060 (2026-05-12)** — identified as tid=6 thread_proc via `lr=0xbcbcbcbc` thread-entry sentinel + HW=2 + cycle=0 first-fire context. AUDIT-059's static-reachability inference invalidated. [confirmed]
- **AUDIT-059 (2026-05-11)** — flagged as "only-caller of canary signaler A; 0 callers — fnptr-array only". [STATUS: partially correct (0 callers true; fnptr-array WRONG), corrected by AUDIT-060 — it's a thread_proc.]
## Open questions
- Where is `ExCreateThread(entry=sub_82457EF0, ...)` called from? Probe the `ExCreateThread` import thunk in both engines with filtered LR/r3 to find the install site.
- What does the thread body do beyond calling [sub_82458B90](sub_82458B90.md) once? Likely it's a loop that waits on a queue, dequeues work, and signals completion via the bl at `+0x24`. Disassemble the body.
## Cross-references
- Thread-body callee: [sub_82458B90](sub_82458B90.md).
- Install site (`ExCreateThread` caller): not yet identified.
- Audits: 059, 060.
- Artifacts: `audit-runs/audit-060-fnptr-array-bootstrap/ours-phase1.stdout` (the `lr=0xbcbcbcbc` sentinel evidence).

View File

@@ -0,0 +1,50 @@
---
address: 0x82458B90
classification: normal_callee
confidence: high
last_audit: 060
aliases:
- "canary γ-wedge signaler A"
---
# sub_82458B90 — canary γ-wedge signaler A (NtSetEvent caller from tid=6 thread_proc body)
## Synopsis
A function that wraps `bl 0x824AA2F0` (NtSetEvent wrapper) at an internal PC near `+0x180` (canary LR `0x82458D14`). In canary, this is one of two NtSetEvent caller-LRs that signal the AUDIT-059 file-IO completion wedge dup handle (per `(tid, r31)` cross-run invariant). Reached only from [sub_82457EF0](sub_82457EF0.md)+0x24, which is itself the **tid=6 thread_proc entry**. The "1 static caller, 0 callers above" chain in `xrefs` is structurally correct for a fn invoked from a thread loop's body.
## Evidence
- AUDIT-059 Probe C canary: at LR `0x82458D14` (=`sub_82458B90+0x184` or similar post-`bl 0x824AA2F0` internal PC), signals the wedge dup handle (matched cross-run via `r31` stack invariant — thread `F8000054` / frame `0x7036FDC0`).
- AUDIT-060 Probe O ours: fires **1× in ours** (`--ctor-probe`), called from `sub_82457EF0+0x24` (PC `0x82457f18`).
- Static caller chain in DB: `sub_82458B90 ← sub_82457EF0` (1 caller); `sub_82457EF0` itself has 0 static callers — it is the tid=6 thread_proc entry.
## Activation
Direct `bl` from `sub_82457EF0+0x24` (single static caller). [sub_82457EF0](sub_82457EF0.md) is a `thread_proc`, so the activation chain is:
1. Some boot-site calls `ExCreateThread(entry=sub_82457EF0)` — installing tid=6's thread_proc.
2. Thread tid=6 starts; PPC entry-LR sentinel `0xbcbcbcbc` indicates "first instruction of thread_proc".
3. `sub_82457EF0` body calls this fn via `bl` at `+0x24`.
## Static graph
- Static callers (`bl`): 1 site = `sub_82457EF0+0x24` (PC `0x82457f18`).
- Callees: `bl 0x824AA2F0` (NtSetEvent wrapper) internal.
## Audit log
- **AUDIT-060 (2026-05-12)** — confirmed alive in ours (1 fire on tid=6). AUDIT-059's "fires 1× off-wedge" wording was technically correct but misleading; the function IS active, just signaling a different KEVENT instance per call. [confirmed alive]
- **AUDIT-059 (2026-05-11)** — identified as canary NtSetEvent signaler A for the wedge dup handle via cross-run `r31` invariant. Static reachability claim ("only-caller has 0 callers — fnptr-array only") flagged as suspect; AUDIT-060 confirms the chain is correct but the conclusion ("unreachable") was wrong. [confirmed for canary signaler role]
## Open questions
- What r3 (handle) does `sub_82458B90` pass to `bl 0x824AA2F0` in ours's 1 fire vs canary's signaling fires? Probe entry of `sub_824AA2F0` filtered by caller=`sub_82458B90`.
- Is `sub_82457EF0`'s thread body a "wait on queue, dequeue work, signal completion" loop? If yes, what queue? And is the queue empty in ours but populated in canary?
## Cross-references
- Caller (thread_proc): [sub_82457EF0](sub_82457EF0.md).
- NtSetEvent wrapper: `sub_824AA2F0` (not yet dossierd).
- Sibling canary signaler: [sub_8245EC10](sub_8245EC10.md).
- Audits: 059, 060.
- Artifacts: `audit-runs/audit-059-gamma-wedge/canary-setwrapper.log`, `audit-runs/audit-060-fnptr-array-bootstrap/`.

View File

@@ -0,0 +1,55 @@
---
address: 0x8245EC10
classification: dispatch_table_method
confidence: high
last_audit: 060
aliases:
- "canary γ-wedge signaler B"
- "dispatch_table 0x820B5830 slot 1"
---
# sub_8245EC10 — dispatch_table slot 1 method, canary γ-wedge signaler B
## Synopsis
Method living at slot 1 of `dispatch_table @ 0x820B5830`. The dispatch table is installed at struct offset 0 (vptr) by [sub_8245FEB8](sub_8245FEB8.md). In canary, this method is one of two NtSetEvent caller-LRs that signal the AUDIT-059 file-IO completion wedge dup handle (LR `0x8245ED80` post-`bl 0x824AA2F0`). In ours it fires 2× total but not on the wedge handle.
## Evidence
- Located at slot 1 of dispatch table `0x820B5830`. Slot 0 is `sub_8245F1D0`.
- The dispatch table is referenced from:
- `sub_8245F1D0+0x1C` (self-recursive)
- `sub_8245FEB8+0x100` (= `0x8245FFC0`, the `stw r11, 0(r31)` vtable install)
- AUDIT-060 Probe O ours: fires **2× in ours** (`--ctor-probe`); both fires come from `sub_8245FEB8` callers (transitively, via the installed dispatch-table dispatch).
- AUDIT-059 Probe C canary: at LR `0x8245ED80` (`= sub_8245EC10+0x170` or similar internal PC after `bl 0x824AA2F0`), this fn is one of two distinct canary NtSetEvent caller-fns that signal the wedge dup handle (per cross-run `r31` invariant; the other is [sub_82458B90](sub_82458B90.md)).
- Both canary signalers wrap `bl 0x824AA2F0` (NtSetEvent wrapper). Each fires once per file-IO completion in canary.
## Activation
Indirect dispatch. Reachable only via `bctrl` against an object whose vptr was set to `dispatch_table @ 0x820B5830`. The install happens via [sub_8245FEB8](sub_8245FEB8.md). No direct `bl` callers — and that is correct for a `dispatch_table_method`.
## Static graph
- Static callers (direct `bl`): **0** (correct — indirect dispatch only).
- Callees: includes `bl 0x824AA2F0` (NtSetEvent wrapper) at internal PC near `+0x170` (canary LR `0x8245ED80`).
## Audit log
- **AUDIT-060 (2026-05-12)** — fires 2× in ours; not dead. AUDIT-059's "dead via 0 static callers" framing was too narrow — dispatch_table reachability needs runtime-installed-vptr awareness, not just static `bl` xref BFS. [confirmed alive]
- **AUDIT-059 (2026-05-11)** — identified as canary NtSetEvent signaler B for the file-IO completion wedge dup handle. Cross-run `(tid, r31)` invariant matched. [confirmed for canary signaler role]
- **AUDIT-059 (2026-05-11)** — claimed dead in ours due to 0 static callers + dispatch-table installer ([sub_8245FEB8](sub_8245FEB8.md)) ALSO claimed dead. [STATUS: falsified by AUDIT-060]
## Open questions
- What handle does `sub_8245EC10` signal in ours? (Two fires — capture r3 at each fire to identify the target handles.)
- Why doesn't it signal the wedge handle in ours? Either (a) it's running on the wrong object (different installed instance), or (b) the work item it's processing has a different completion-event field.
- Cross-engine method match: is canary fire #1 and ours fire #1 the same logical event? Compare object base (would need new instrumentation).
## Cross-references
- Installed at: `dispatch_table @ 0x820B5830` slot 1.
- Vptr installer: [sub_8245FEB8](sub_8245FEB8.md).
- Sibling method (slot 0): `sub_8245F1D0` (not yet dossierd).
- Sibling canary signaler: [sub_82458B90](sub_82458B90.md).
- Audits: 059, 060.
- Artifacts: `audit-runs/audit-059-gamma-wedge/canary-setwrapper.log`, `audit-runs/audit-060-fnptr-array-bootstrap/`.

View File

@@ -0,0 +1,59 @@
---
address: 0x8245FEB8
classification: normal_callee
confidence: high
last_audit: 060
aliases:
- "vptr installer for dispatch_table 0x820B5830"
- "AUDIT-059 'dead' (FALSIFIED)"
---
# sub_8245FEB8 — vptr installer for dispatch_table 0x820B5830
## Synopsis
Installs a vtable pointer / dispatch-table entry into a runtime-allocated object. Body contains `stw r11, 0(r31)` at `0x8245FFC0` writing the vtable address to the object's slot 0. After install, the object's `bctrl` dispatch reaches the methods in `dispatch_table @ 0x820B5830` (slot 0 = `sub_8245F1D0`, slot 1 = `sub_8245EC10`). Fires 5× in ours / 2× in canary; lives in the AUDIT-050 worker cluster but is NOT dead in either engine — both engines reach the same call site `sub_824601A0+0x68 (PC=0x82460208)`.
## Evidence
- Body opcode at `0x8245FFC0`: `stw r11, 0(r31)` — vtable install pattern.
- Two static callers per `xrefs` table (`source_func`):
- `sub_824601A0` at `+0x68` (PC `0x82460208`) — 1 site
- `sub_8245FB68` at `+0x198` and `+0x1C0` (PCs `0x8245FD00` and `0x8245FD28`) — 2 sites
- AUDIT-060 Probe C-Win Windows Debug canary: `--log_lr_on_pc=0x8245FEB8`, 120s → **2 fires, both lr=0x8246020C** (= `sub_824601A0+0x6C`, post-bl PC). `r3=BC365C40` (same object), `r4=4` then `r4=1` (different slot indices), `r31=701CF2E0` then `r31=705AFAA0` (different threads).
- AUDIT-060 Probe O ours: `--ctor-probe=0x8245FEB8 -n 500M`**5 fires total**: 1 from tid=1 boot path at cycle 5.5M via `sub_824601A0+0x68`, 3 more from tid=1 during UI inflation, 1 on tid=13 at cycle 23788 via the wedge chain.
- The `r4` parameter (slot index) and the `r31` saved value diverge per call; install target object differs.
## Activation
Direct `bl` from one of:
- `sub_824601A0+0x68` (most frequent — boot path)
- `sub_8245FB68+0x198` and `+0x1C0` (internal lib path, sub_8245FB68 itself has callers `sub_8245F880`, `sub_8245FAB0`)
Caller chain upward: `sub_824601A0 ← sub_82460118 ← sub_82452AB8 ← sub_82452DC0`. The vptr-install path piggybacks on the work-submitter cluster.
## Static graph
- Callers: `sub_824601A0`, `sub_8245FB68`.
- Body: vtable write at `+0x108` (`0x8245FFC0`). Other body content not yet detailed.
- The vtable being installed is implicit in caller-supplied `r11` (and possibly elsewhere).
## Audit log
- **AUDIT-060 (2026-05-12)** — measured 5× ours, 2× canary, identical call site both engines. AUDIT-059's framing falsified. Convergence to AUDIT-050 work-submitter cluster confirmed. [confirmed]
- **AUDIT-059 (2026-05-11)** — claimed as "vptr installer dead in ours" because static graph showed `sub_8245EC10` (slot 1 of the installed dispatch table) had 0 static callers reachable from any caller-chain that DB classified as live. [STATUS: falsified by AUDIT-060 — was alive in ours all along; the DB caller-chain reachability call was too narrow.]
## Open questions
- What is the *class* being installed? (Read `r11` at the `stw r11, 0(r31)` site — canary trace shows it's a specific dispatch table.)
- The 5 ours fires vs 2 canary fires — is this a parity match (canary just had only 2 because of the same AUDIT-056 3.21× upstream gate) or does ours over-fire? Aligning instruction-horizon vs wallclock would clarify.
- Slot-1 method `sub_8245EC10` is named the canary signaler B. It fires 2× in ours per AUDIT-060 — but not on the wedge handle (per AUDIT-059 ours signal_attempts=0 on 0x12AC). What handle is it signaling in ours?
## Cross-references
- Installs dispatch_table at: `0x820B5830`
- Slot 0: `sub_8245F1D0` (referenced; not yet dossierd)
- Slot 1: [sub_8245EC10](sub_8245EC10.md)
- Direct callers: `sub_824601A0`, `sub_8245FB68`
- Audits: 059, 060
- Artifacts: `audit-runs/audit-059-gamma-wedge/`, `audit-runs/audit-060-fnptr-array-bootstrap/`

View File

@@ -0,0 +1,56 @@
---
address: 0x824ACB38
classification: crt_init_driver
confidence: high
last_audit: 060
aliases:
- "CRT driver"
- "vtable-slot enumerator (NOT a static-ctor list iterator)"
---
# sub_824ACB38 — CRT init driver / vtable-slot enumerator
## Synopsis
CRT-style driver called from `entry_point` (or near it). Body is 224 bytes (`0x824ACB38..0x824ACC18`). Contains two enumeration loops over fnptr-array regions at `0x82870xxx`. AUDIT-050 framed it as "iterates 0x82870xxx fnptr arrays (557 slots, 82 non-NULL)" and concluded a half-bootstrapped state; AUDIT-060 found this framing semantically misleading — the slots are runtime vtable-registration entries, not C++ static initializers, and the "82 non-NULL" count obscures a structural 160-slot intentional zero gap.
## Evidence
- Body anatomy at `0x824ACB38..0x824ACC18` (AUDIT-060 disasm):
- `+0x00..+0x2C` — preamble + one optional dispatch through fn-ptr at `[0x82023F08]` (= `0x825F1630`, an LZ-runtime thunk).
- `+0x30..+0x6C`**loop A**: enumerate u32 slots in `[0x828708C8, 0x828708D4)` — 3 slots. Filter: non-NULL. `bctrl` at `0x824ACBA0`.
- `+0x80..+0xB8`**loop B**: enumerate u32 slots in `[0x82870010, 0x828708C4)` — 557 slots. Filter: non-NULL AND `!= 0xFFFFFFFF`. `bctrl` at `0x824ACBEC`.
- `+0xC4` — epilogue, `blr`.
- Array layout (AUDIT-060 dumped at -n 1M and -n 500M; both identical):
- `0x82870010..0x828702E8` — populated with `0x82xxxxxx` pointers (vtable methods).
- `0x828702F0..0x82870580`**PERMANENTLY ZERO** across both dumps (160 of 557 slots = 28.7%).
- `0x82870590..0x828708C4` — populated with `0x82xxxxxx` pointers.
- `0x828708C8..0x828708D4` — loop-A array, populated (small CRT helpers).
- Static-DB cross-check: the 557-slot region contains 14+ separate small `vtable`-classified arrays at `0x82870014/0x24/0x94/0xC8/0x16C/0x214/0x238/0x250/0x2A8/0x2C0/0x2E4/0x5A0/0x62C/0x870`, NOT a single CRT static-ctor list. NO statically-detected arrays in `[0x82870300, 0x828705A0)` — the gap is intentional padding between two vtable clusters.
## Activation
Called once from `entry_point`-near code (per AUDIT-050 — exact caller PC not in AUDIT-060 trace). The driver enumerates all slot entries; each non-NULL entry is `bctrl`'d once.
## Static graph
- Static callers: 1 (from boot entry path; exact PC to confirm).
- Callees: indirect (`bctrl`) — targets are the contents of the enumerated slots.
## Audit log
- **AUDIT-060 (2026-05-12)** — disassembled body, identified structure (2 loops, gap), confirmed slot contents are runtime vtable entries rather than C++ static-ctor function pointers. The "82 non-NULL" AUDIT-050 count was correct per-slot but missed the structural 160-slot intentional gap. Driver fires 1× at -n 500M as expected (single boot enumeration). [confirmed]
- **AUDIT-050 (2026-05-10)** — framed as "CRT driver iterates 0x82870xxx fnptr arrays (557 slots, 82 non-NULL)". Structurally correct but semantically misleading ("static-ctor list" implication was wrong). [STATUS: partially superseded by AUDIT-060 — the "iterate fnptr array" claim stands; the "static-ctor list" implication does not.]
## Open questions
- What invokes this driver in `entry_point`? Find exact caller PC.
- Are the 14+ small vtable clusters in `[0x82870010, 0x828708C4)` enumerated by THIS driver, or by separate driver functions? If multiple drivers exist for the same region, the gap might be region-partitioning, not padding.
- For ours: are all 397 non-NULL slots dispatched at runtime? If some slot dispatch falls through (e.g. predicate skips it), that would be a real bug — needs runtime confirmation via `--branch-probe=0x824ACBA0,0x824ACBEC` (loop bodies).
## Cross-references
- LZ-runtime thunk at `+0x00..+0x2C`: `0x825F1630`.
- Fnptr-array region: `0x82870010..0x828708D4`.
- Audits: 045 (DB schema caveat: `v_call_graph` uses `xrefs.source`; prefer `xrefs.source_func`), 050, 060.
- Artifacts: `audit-runs/audit-060-fnptr-array-bootstrap/ours-dump-500M.stdout`.

View File

@@ -0,0 +1,50 @@
---
address: 0x824F7800
classification: normal_callee
confidence: high
last_audit: 064
aliases:
- "AUDIT-058 caller-ladder fn #2 (bctrl-dispatch site for sub_825070F0)"
---
# sub_824F7800 — dispatch caller for ANON_Class_713383D7 vtable slot 1
## Synopsis
Normal callee that performs the `bctrl` invoking [sub_825070F0](sub_825070F0.md) (slot 1 of the `ANON_Class_713383D7` vtable at `0x8200A208`). Bottom of a 4-fn linear call chain (`sub_824F8398 → sub_824F7CD0 → sub_824F7800 → [bctrl] → sub_825070F0`) that runs once per game-loop activation pass. AUDIT-064 verified canary fires this fn 1× at ~60s wallclock; ours fires it 0× because the entire chain sits downstream of tid=13's audit-049 wedge.
## Evidence
- Disasm prolog at `0x824F7800`: `mflr r12; bl 0x825F0F60 (frame helper); stwu r1, -336(r1); mr r22, r3; ...` — standard normal-callee prolog. NOT MSVC EH-handler shape (no `subi r31, r12, N`).
- Function size: 1232 bytes / 308 insns. `has_eh=False`, `frame_size=336`.
- Static caller xref: 1 — `bl` from PC `0x824F8314` inside [sub_824F7CD0](sub_824F7CD0.md). No other refs (only `.pdata` entry at file offset `0x1347B0` — standard unwind metadata).
- AUDIT-064 canary 60s probe (`--audit_61_branch_probe_pcs=0x824F7800,...`): fires 1× with `lr=0x824F8318 r3=BE568F00 r4=701CF5B0 r5=BCA44D40 r6=BCA44DE0` on tid=6. Reproduced bit-identical at 120s and 180s wallclock.
- AUDIT-064 ours `--ctor-probe=0x824F7800` -n 500M: **0 fires**.
- The `bctrl` at PC `0x824F7B20` (= `sub_824F7800+0x320`, slot 1 of `0x8200A208` vtable) is where [sub_825070F0](sub_825070F0.md) is dispatched from.
## Activation
Direct `bl` from `sub_824F7CD0+0x644` (PC `0x824F8314`). Both engines see the same single static caller.
## Static graph
- Static callers (from `xrefs.source_func`):
- PC `0x824F8314` inside `sub_824F7CD0` (the only caller).
- Callees include the `bctrl` at PC `0x824F7B20` that dispatches to `sub_825070F0` via vtable slot 1 of `ANON_Class_713383D7` (vtable `0x8200A208`).
## Audit log
- **AUDIT-064 (2026-05-12)** — disasm confirms normal-callee prolog (refutes "another EH handler" hypothesis). Canary probe fires 1× / ours 0×. Static-DB caller is the runtime caller (no surprise bctrl divergence here). The chain runs downstream of [sub_822F1AA8](sub_822F1AA8.md)'s vtable[0] dispatch through sub_82173990 — which waits on tid=13 — so ours never reaches it because tid=13 is blocked on the AUDIT-049 wedge. [confirmed]
- **AUDIT-058 (2026-05-10)** — flagged as part of the static caller ladder for sub_825070F0. [confirmed at this level; ladder framing partially preserved — see sub_821B6DF4 for the EH-thunk caveat one step further up]
## Open questions
- Why does the bctrl at `0x824F7B20` always dispatch to `sub_825070F0` (slot 1 of vtable `0x8200A208`) at this point? Investigate where the `r3` instance pointer comes from — likely a class member loaded via the slot-1 ctor path of `ANON_Class_713383D7`.
- The 4-fn linear chain (`sub_824F8398 → sub_824F7CD0 → sub_824F7800 → bctrl`) is rigid and runs end-to-end without branching in canary. Confirm no early-exit branches inside the chain in ours (irrelevant if we resolve the audit-049 wedge first).
## Cross-references
- Callees: `sub_825070F0` via slot 1 of vtable `0x8200A208` at `bctrl` PC `0x824F7B20`.
- Callers: `sub_824F7CD0+0x644`.
- Audits: 058, 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/canary-{60,120,180}s.log`, `audit-runs/audit-064-activation-ladder/ours-500M.stdout`.

View File

@@ -0,0 +1,49 @@
---
address: 0x824F7CD0
classification: normal_callee
confidence: high
last_audit: 064
aliases:
- "AUDIT-058 caller-ladder fn #3"
---
# sub_824F7CD0 — middle of sub_825070F0 activation chain
## Synopsis
Normal callee in the linear 4-fn activation chain ending at [sub_825070F0](sub_825070F0.md). Calls `sub_824F7800` at PC `0x824F8314`. Has a 4-way computed `bctr` switch table near its entry (PCs `0x824F7D00..0x824F7D34` — a jump-table dispatch on `[r31+0]-1` for values 1..4). AUDIT-064 verified canary fires 1× at ~60s wallclock; ours fires 0×.
## Evidence
- Disasm prolog at `0x824F7CD0`: `mflr r12; bl 0x825F0F68; stwu r1, -256(r1); ...` — standard normal-callee prolog. NOT MSVC EH-handler shape.
- Function size: 1736 bytes / 434 insns. `has_eh=False`, `frame_size=256`.
- Static caller xref: 1 — `bl` from PC `0x824F83D4` inside [sub_824F8398](sub_824F8398.md).
- Computed jump-table at `0x824F7D10..0x824F7D24`: `lis r12, 0x824F; addi r12, r12, 32040; slwi r0, r11, 2; lwzx r0, r12, r0; mtctr r0; bctr` — 4-way switch on argument. Targets at `0x824F7D28/2C/30/34/...` are jump-table data, NOT call edges.
- AUDIT-064 canary 60s probe: fires 1× with `lr=0x824F83D8 r3=BE568F00 r4=701CF5B0 r5=701CF658 r6=03A72328` on tid=6. Reproduced bit-identical at 120s and 180s.
- AUDIT-064 ours `--ctor-probe=0x824F7CD0` -n 500M: **0 fires**.
## Activation
Direct `bl` from `sub_824F8398+0x3C` (PC `0x824F83D4`).
## Static graph
- Static callers (from `xrefs.source_func`):
- PC `0x824F83D4` inside `sub_824F8398`.
- Callees include `sub_824F7800` (PC `0x824F8314`), `sub_824FD230`, `sub_824FD240`, `sub_824FC498`, `sub_824FCC18`, and others.
## Audit log
- **AUDIT-064 (2026-05-12)** — disasm confirms normal-callee + 4-way computed jump-table near entry. Canary fires 1× / ours 0×. Single static caller is the actual runtime caller. Chain blocks upstream at the audit-049 wedge (tid=13 thread-join wait on handle 0x12A4). [confirmed]
- **AUDIT-058 (2026-05-10)** — flagged as part of the ladder. [confirmed]
## Open questions
- The 4-way switch at `0x824F7D10..0x824F7D34`: which jump-table entry corresponds to the path that calls `sub_824F7800`? Disasm shows `lwz r11, 0(r31); subi r11, r11, 1; cmplwi cr6, r11, 0x3; bgt cr6, 0x824F80E4` — so input `r4` (saved to r31) must be 1..4 to enter switch. Canary's r4 was `0x701CF5B0` (a stack ptr), so the value at `[stack]` indexes the switch.
## Cross-references
- Callees: `sub_824F7800`, `sub_824FD230/40`, `sub_824FC498`, `sub_824FCC18`.
- Callers: `sub_824F8398+0x3C`.
- Audits: 058, 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/canary-{60,120,180}s.log`, `audit-runs/audit-064-activation-ladder/ours-500M.stdout`.

View File

@@ -0,0 +1,48 @@
---
address: 0x824F8398
classification: normal_callee
confidence: high
last_audit: 064
aliases:
- "AUDIT-058 caller-ladder fn #4 (tiny adapter, 20 insns)"
---
# sub_824F8398 — 20-insn adapter to sub_824F7CD0
## Synopsis
Tiny 20-insn normal-callee adapter. Zeros a stack buffer (`std r9, 0(r11)` × 10 unrolled via `bdnz`), sets `[r1+80]=1` and `[r1+112]=r8` (its r4 argument), then calls `sub_824F7CD0` with `r3` passed through and `r4=&stack_buf+80`. Essentially a 2-arg→1-arg adapter that constructs a 36-byte stack-record before dispatching. AUDIT-064 verified canary fires 1× at ~60s wallclock; ours fires 0×.
## Evidence
- Disasm: `mflr r12; stw r12, -8(r1); stwu r1, -160(r1); mr r8, r4; addi r11, r1, 80; li r9, 0; li r10, 9; mtctr r10; std r9, 0(r11); addi r11, r11, 8; bdnz 0x824F83B8; li r11, 1; stw r8, 112(r1); addi r4, r1, 80; stw r11, 80(r1); bl 0x824F7CD0; addi r1, r1, 160; lwz r12, -8(r1); mtlr r12; blr` — clear normal-callee, no EH.
- Function size: 80 bytes / 20 insns. `has_eh=False`.
- Static caller xref: 1 — `bl` from PC `0x821B5B5C` inside [sub_821B55D8](sub_821B55D8.md).
- Stack buffer at `[r1+80]..[r1+112]` is 36 bytes (9 × 8-byte zero + first u32=1 + last u32=r8).
- AUDIT-064 canary 60s probe: fires 1× with `lr=0x821B5B60 r3=BE568F00 r4=BC369380 r5=701CF658 r6=03A72328` on tid=6. Reproduced bit-identical at 120s and 180s.
- AUDIT-064 ours `--ctor-probe=0x824F8398` -n 500M: **0 fires**.
## Activation
Direct `bl` from `sub_821B55D8+0x584` (PC `0x821B5B5C`).
## Static graph
- Static callers: PC `0x821B5B5C` inside `sub_821B55D8`.
- Callees: `sub_824F7CD0` (PC `0x824F83D4`).
## Audit log
- **AUDIT-064 (2026-05-12)** — disasm confirms tiny adapter (20 insns). Canary fires 1× / ours 0×. The size is small enough to inline; possibly an MSVC compiler artifact. [confirmed]
- **AUDIT-058 (2026-05-10)** — flagged as part of the ladder. [confirmed]
## Open questions
- What does the constructed stack-record (`[1, 0, 0, 0, 0, 0, 0, 0, 0, r8]`) represent semantically? Likely a state-machine init record passed by reference to `sub_824F7CD0`'s 4-way switch.
## Cross-references
- Callees: `sub_824F7CD0`.
- Callers: `sub_821B55D8+0x584`.
- Audits: 058, 064.
- Artifacts: `audit-runs/audit-064-activation-ladder/canary-{60,120,180}s.log`.

View File

@@ -0,0 +1,63 @@
---
address: 0x825070F0
classification: vtable_method
confidence: high
last_audit: 067
aliases:
- "ANON_Class_713383D7 vtable slot 1"
- "AUDIT-057 top missing-thread spawner"
---
# sub_825070F0 — ANON_Class_713383D7 vtable slot 1 (worker spawner)
## Synopsis
Slot 1 of class `ANON_Class_713383D7` vtable (located at `0x8200A208` and clone at `0x8200A928`). When invoked, initializes 4 worker threads with shared context `r3=0xBCE25340` (canary). The thread entry points are `0x82506528 / 0x82506558 / 0x82506588 / 0x825065B8`. In canary, this fn fires 1× at ~60s wallclock immediately after `DiscImageDevice::ResolvePath(\\dat\\movie)` (post-intro file open). In ours, it fires 0× at any horizon probed so far.
## Evidence
- AUDIT-058 Linux Debug canary: fires 1× at ~60s wallclock with `pc=0x825070F0 lr=0x824F7B24 r3=BCE25340 r4=701CF3C0 r5=BCE25AC0`.
- AUDIT-060 Probe C-Win Windows Debug canary: same probe (`--log_lr_on_pc=0x825070F0`, 90s) → 1 fire, `lr=0x824F7B24`**bit-identical to Linux Debug**, validating the new Wine canary oracle.
- LR `0x824F7B24` resolves to inside `sub_824F7800+0x24` — the vtable `bctrl` dispatch site.
- Class `ANON_Class_713383D7` lives at vtables `0x8200A208` (and clone `0x8200A928`); both are 7-method tables. Slot 1 is this fn. **Zero recorded vptr_writes in DB** — the ctor that writes this vtable is in an unreachability island OR is a computed-store-only ctor.
- **AUDIT-067 (2026-05-12)** strengthens this: **zero `vptr_writes`, zero `xrefs`, zero u32-byte occurrences of `0x8200A208`/`0x8200A928` in the `.pe` file, zero `addis+addi/ori` pairs materializing the value**. Runtime mem-watch of all 16 guest store opcodes (`stw`/`std`/`stwx`/`stwbrx`/`stwcx.`/`stmw`/`stvx`/`stvewx`/etc.) for 211 s wallclock in canary produces **0 hits** for these values — though `sub_825070F0` itself fires 1× at ~25 s wallclock with `*r3 = 0x8200A208` implicit at the bctrl. The install is **host-side**, not guest-side.
- AUDIT-057 named this as the top missing-thread spawner: 4 missing thread spawns in ours.
## Activation
Vtable dispatch from `sub_824F7800+0x24 bctrl` (slot 1 of vtable `0x8200A208`). **AUDIT-064 fully classified the ladder**: `sub_824F7800`, `sub_824F7CD0`, `sub_824F8398`, `sub_821B55D8` are ALL `normal_callee` (NOT EH thunks). Only `sub_821B6DF4` is the EH catch-handler; it's a secondary entry path, not the runtime activation route.
**Full runtime activation chain (in canary; identified by AUDIT-064 via lr-resolution at each fire)**: tid=1 `entry_point → sub_8216EA68 → sub_822F1AA8` (post-init dispatcher) → `bctrl vtable[0] of *(0x828E1F08)``sub_82175330` (2-insn thunk) → tail-jump → `sub_82173990` → … → `sub_821741C8``sub_82172BA0` (array-walk dispatcher) → `bctrl vtable[6]``sub_821B55D8``sub_824F8398``sub_824F7CD0``sub_824F7800``bctrl vtable[1]``sub_825070F0`.
**Wedge in ours (AUDIT-064)**: tid=1 successfully enters `sub_822F1AA8`, reaches the bctrl at `0x822F1B4C`, dispatches to `sub_82175330``sub_82173990` → blocks at `sub_82173990+0x2D0` on `KeWaitForSingleObject` INFINITE on handle `0x12A4` = tid=13's thread handle. Tid=13 itself is blocked on the AUDIT-049 wedge (event 0x12AC inside the audit-009 cluster). The 5-fn ladder downstream of `sub_82172BA0` is NEVER reached because tid=1 hasn't returned from the thread-join wait.
## Static graph
- Static callers (direct `bl`): 0 — it's reached only via `bctrl`. The `bctrl` site is `0x824F7B20`.
- Callees: spawns 4 worker threads via `ExCreateThread` (or equivalent) with entries `0x82506528/58/88/B8`.
## Audit log
- **Phase Non-match Investigation (2026-05-19)** — Phase A `thread.create` events directly corroborate the AUDIT-058 framing using **runtime** evidence (previously only static + ctor-probe). Canary cold trace `canary-jitter-1.jsonl` (4.4 GB, 18.7M events) contains EXACTLY 4 `thread.create` events at `host_ns = 10.382912900 / 10.383282200 / 10.383647200 / 10.384161700` (spaced ~370500 ns apart on tid=6 = guest main) with entries `0x82506528 / 0x82506558 / 0x82506588 / 0x825065B8`, shared `ctx_ptr=0xBCE251C0`, stack=65,536, `suspended=true`, affinity=0. These match the dossier's listed worker entries 1:1 and are bit-identical-in-structure to the AUDIT-058 fire (modulo `ctx_ptr` arena drift: AUDIT-058 cited `0xBCE25340`, this jitter sample has `0xBCE251C0` — both inside the `0xBCE25xxx` arena allocated by the same fn). FIFO-matched child tids: `0x82506528 → tid=28` (3.26M events, file IO + heavy RtlEnterCS), `0x82506558 → tid=27` (36k events), `0x82506588 → tid=29` (91k events), `0x825065B8 → never started` in the 90s window. Same canary-vs-ours digest comparison shows ours-postfix.jsonl has **0 occurrences** of `0xBCE251C0` and **0 thread.create events** after spawn #10 (1.727 s). The full set of static-analysis-invisible properties (0 vptr_writes, 0 xrefs, 0 indirect_dispatch_candidates targeting vtables `0x8200A208` / `0x8200A928`) was re-verified against current sylpheed.db — AUDIT-067's conclusion stands. New artifacts at `audit-runs/phase-nonmatch-investigation/`. **Recommended next probe**: AUDIT-068 host-side mem-watch was deferred — re-attempt now with Phase A event correlation (the 10.382 s spawn burst is the precise wall-clock window to hook). [confirmed runtime; framing intact]
- **AUDIT-067 (2026-05-12)** — runtime mem-watch via new canary cvar `audit_67_value_watch` (~422 LOC additive instrumentation; default-empty / zero-cost; kept in canary tree per policy). Hooked **all 16 store opcodes**: `stw`, `stwu`, `stwx`, `stwux`, `stwbrx`, `stwcx.`, `stmw`, `std`, `stdu`, `stdux`, `stdx`, `stdbrx`, `stdcx.`, `stvx`/`stvxl`/`128`, `stvewx`/`128`. Each hook emits `CompareEQ(val32, watch) → TrapTrue(_,250+idx)`; trap handler logs `pc/lr/val/dst/regs/tid`. Sanity test with `watch=0x00000000`**103,321 hits in 30s** (instrumentation verified). Main run `watch=0x8200A208,0x8200A928` for **211 s wallclock**: **0 hits** despite `AUDIT-061-BR pc=0x825070F0` firing 1× with `r3=0xBCE25340 r4=0x701CF3C0 r5=0xBCE25AC0` (bit-identical to AUDIT-058/060). **CONCLUSION**: the vtable address `0x8200A208` is never stored to guest memory via any guest PowerPC store opcode in canary — the install is **host-side** (most likely a kernel-import direct memory write via `xe::store_and_swap<uint32_t>(memory + addr, val)`, OR an XEX loader operation, OR a `RtlCopyMemory`-style host helper). Path A (static binary search) also yielded 0 matches: no `vptr_writes`, no `xrefs`, no `addis`+`addi/ori` pair (with or without mr-chain register propagation) materializing the value, no u32 occurrence anywhere in the `.pe` file. **Reading-error #19**: assumption that meaningful guest-memory writes go through guest PPC code is false — kernel imports and the image loader perform direct host writes that bypass the JIT. AUDIT-068 must hook at the `Memory::write*` / `store_and_swap<*>` level instead. [confirmed — negative result; structural finding]
- **AUDIT-064 (2026-05-12)** — classified all 4 unclassified ladder fns: `sub_824F7800`, `sub_824F7CD0`, `sub_824F8398`, `sub_821B55D8`**all 4 are normal_callee**, NOT EH thunks (refutes the worst-case hypothesis from AUDIT-060 that the whole chain might be EH metadata). Probed canary at 60s/120s/180s — all 4 fire 1× each, bit-identical context. Walked upward: real runtime caller of `sub_821B55D8` is `sub_82172BA0+0x1E8 bctrl` (PC `0x82172D88`), NOT the static-DB-listed `sub_821B6DF4` EH branch. **Identified the full upstream activation chain**: tid=1 entry_point → `sub_8216EA68` → [`sub_822F1AA8`](sub_822F1AA8.md) → vtable[0] of `*(0x828E1F08)` = `sub_82175330` (2-insn thunk) → tail-jump to `sub_82173990` → … (canary continues through `sub_821741C8` → [`sub_82172BA0`](sub_82172BA0.md) → vtable[6]=[`sub_821B55D8`](sub_821B55D8.md) → [`sub_824F8398`](sub_824F8398.md) → [`sub_824F7CD0`](sub_824F7CD0.md) → [`sub_824F7800`](sub_824F7800.md) → bctrl → `sub_825070F0`). **First divergence in ours**: tid=1 enters `sub_82173990` via the vtable[0] dispatch but blocks at `sub_82173990+0x2D0 bl 0x824AA330` (KeWaitForSingleObject INFINITE) on handle `0x12A4` = tid=13's thread handle. This is the **same AUDIT-049 wedge**: tid=13 itself is blocked on handle `0x12AC` waiting for the audit-009-cluster signal. Activation of sub_825070F0 is gated on resolving the tid=13 wait, NOT on any divergence in the ladder fns themselves. [confirmed]
- **AUDIT-060 (2026-05-12)** — verified canary fire reproduces under Windows Debug oracle. Caller chain caveat added: `sub_821B6DF4` ladder-top is EH, not normal call edge. Other ladder fns need individual classification. [confirmed for canary fire; caveat on the upstream chain]
- **AUDIT-058 (2026-05-10)** — captured canary fire context, walked static caller ladder, found all 6 ladder fns fire 0× in ours. Concluded "activation phase doesn't activate in ours". [STATUS: ladder framing partially falsified by AUDIT-060 — at least `sub_821B6DF4` is EH; the *real* gate is the AUDIT-056 sub_821C4EB0 throughput gap, upstream.]
- **AUDIT-057 (2026-05-10)** — flagged as top missing-thread spawner (4 of 13 missing thread spawns). [confirmed quantitatively]
## Open questions
- What spawns the 4 worker threads exactly? Disassemble body. The threads have entries `0x82506528/58/88/B8` — are these consecutive 0x30-byte stubs that all forward to a common worker fn?
- What class instance triggers the slot-1 dispatch? Is it a `silph::GamePart_Title` instance? The wallclock context (post-`\\dat\\movie` ResolvePath) suggests so.
- **(AUDIT-067 result)** What host-side mechanism installs `0x8200A208` at `0xBCE25340+0`? Candidates: `xboxkrnl_rtl*` direct-write helpers (`RtlCopyMemory`/`RtlFillMemory`/`RtlInitializeCriticalSection` etc.), XEX loader image-rewrites, or kernel-import factory helpers. Next probe: AUDIT-068 host-side mem-watch — hook `Memory::write*` and/or `xe::store_and_swap<*>` in canary.
## Cross-references
- Vtable: `0x8200A208` (primary), `0x8200A928` (clone), class `ANON_Class_713383D7`, slot 1.
- Dispatch site: `sub_824F7800+0x20 bctrl` (PC `0x824F7B20`); post-bctrl PC `0x824F7B24`.
- Worker thread entries spawned: `0x82506528, 0x82506558, 0x82506588, 0x825065B8`.
- **Real runtime activation chain (AUDIT-064)**: `tid=1 entry_point → sub_8216EA68 → [sub_822F1AA8](sub_822F1AA8.md) → bctrl vtable[0]={sub_82175330 tail-jump→sub_82173990} → … → sub_821741C8 → [sub_82172BA0](sub_82172BA0.md) → bctrl vtable[6] → [sub_821B55D8](sub_821B55D8.md) → [sub_824F8398](sub_824F8398.md) → [sub_824F7CD0](sub_824F7CD0.md) → [sub_824F7800](sub_824F7800.md) → bctrl vtable[1] → sub_825070F0`.
- **Wedge in ours**: tid=1 blocks at `sub_82173990+0x2D0` on KeWaitForSingleObject(handle=0x12A4 = tid=13's thread handle); tid=13 itself blocks at `sub_821CB030+0x128`-created event 0x12AC — AUDIT-049 wedge.
- Old static-DB ladder (AUDIT-058, partly EH): `sub_824F7800 ← sub_824F7CD0 ← sub_824F8398 ← sub_821B55D8 ← [sub_821B6DF4](sub_821B6DF4.md) (EH catch-handler — secondary EH-only entry path)`.
- Audits: 057, 058, 060, 064, 067.
- Artifacts: `audit-runs/audit-058-sub825070F0-activation/`, `audit-runs/audit-060-fnptr-array-bootstrap/canary-sanity-825070F0.log`, `audit-runs/audit-064-activation-ladder/`, `audit-runs/audit-067-vptr-install-mem-watch/`.

View File

@@ -0,0 +1,73 @@
# diff_events.py — Phase A event-log diff tool
A stdlib-only Python tool that diffs two schema-v1 JSONL event logs (one per engine) and reports the **first behavioral divergence per guest thread**. Built for the Phase A diff harness — see `audit-runs/phase-a-diff-harness/README.md` and `schema-v1.md`.
## What it does
1. Reads two JSONL files. Validates each begins with a `schema_version=1` header event.
2. Builds per-thread streams keyed by `tid_event_idx` (the schema's per-tid monotonic counter).
3. Maps canary-tid ↔ ours-tid (auto-pairs by first `kernel.call` name in each stream, or manual via `--tid-map`).
4. Walks each mapped pair in parallel, comparing events with rules from the schema (raw_handle_id skipped, host_ns skipped, wait_duration_cycles skipped, etc.).
5. On first divergence: prints 5-event pre-context + the divergent event + the next event from each. Stops that thread's walk.
6. Writes a markdown report.
## Usage
```bash
# Default — auto-map tids, write markdown to stdout
python3 diff_events.py --canary canary.jsonl --ours ours.jsonl
# Write report to a file
python3 diff_events.py --canary c.jsonl --ours o.jsonl --out report.md
# Manual tid map
python3 diff_events.py --canary c.jsonl --ours o.jsonl --tid-map 6=1,7=2
# Negative-test mode — exit non-zero on ANY divergence (gate-4)
python3 diff_events.py --canary c.jsonl --ours o.jsonl --validate-identical
```
## How it compares
These fields are **skipped** when comparing payloads:
- Top-level: `engine`, `host_ns`, `guest_cycle`, `deterministic`.
- `handle.create`/`handle.destroy`: `raw_handle_id`, `handle_semantic_id` (engine-local).
- `wait.begin`: `handles_semantic_ids` (engine-local SIDs).
- `wait.end`: `wait_duration_cycles` (depends on host scheduling), `woken_by_semantic_id`.
The `tid_event_idx` field is the **alignment key**. Two events at the same `tid_event_idx` on a mapped pair of tids are expected to be the same logical event. The `kind` must match; the `payload` must match field-by-field (except skipped fields).
## Phase C+18 — Cross-tid floating `handle.create` (shared-global dispatchers)
Process-global kernel dispatcher objects (`KEVENT`/`KSEMAPHORE` etc. that game code creates with `KeInitializeEvent` or static-allocs and shares across multiple guest threads) are lazy-wrapped on **first guest-thread touch** by canary's `XObject::GetNativeObject` and ours's `ensure_dispatcher_object`. Whichever thread happens to touch the dispatcher first synthesizes the wrapper and emits the `handle.create` event. Which thread wins is timing-dependent — canary and ours may disagree.
The SID for these synthesized handles is computed via a **scheduling-invariant recipe** keyed on `(pointer, object_type)` only (see schema-v1.md §"Shared-global SIDs"). The same dispatcher therefore yields the same SID in both engines regardless of the first-toucher thread.
The diff tool detects shared-global `handle.create` events by recomputing the deterministic SID from the event's `(raw_handle_id, object_type)` payload and matching against the emitted `handle_semantic_id`. When per-tid alignment finds one side has an "extra" `handle.create` event whose SID is in the global set, the tool **advances only that side's stream pointer past the floating event** and re-compares — preserving strict alignment for everything else.
The summary table shows per-pair `floating_skipped (c/o)` counts so you can see how many events were absorbed by this mechanism.
## Known limitations (v1)
- **Auto tid-map is naive**: pairs canary-tid with ours-tid by the first `kernel.call` name on each thread. Works for boot when the same initial call happens on each engine's primary thread; can mis-pair if two threads start with the same first-call name or if a thread spawns earlier on one engine. Use `--tid-map` to override.
- **No streaming**: loads both files fully into memory. Acceptable for boot-window runs; the canary log is ~370 MB for a 12 s run.
- **First-divergence only**: per-thread walk stops at first divergence. Subsequent divergences on the same thread are not reported (a sliding-window mode could be added later if needed).
- **Schema v1 only**: refuses to parse v2 inputs (forward-incompat is intentional).
## Files
- `diff_events.py` — single-file CLI, stdlib only (json, argparse, pathlib).
- `README.md` — this file.
## Test it
```bash
# Self-diff (compare a file against itself) should report 0 divergences.
python3 diff_events.py --canary x.jsonl --ours x.jsonl --validate-identical
echo "exit=$?" # expect 0
# Negative test: corrupt one event and confirm the tool reports it.
sed '50s/"kernel.call"/"kernel.CORRUPT"/' x.jsonl > /tmp/x-corrupt.jsonl
python3 diff_events.py --canary x.jsonl --ours /tmp/x-corrupt.jsonl --validate-identical
echo "exit=$?" # expect 1
```

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Phase D Stage 2 — contention-manifest builder.
Reads a Phase A JSONL event log produced by canary with cvar
`kernel_emit_contention=true` (Stage 1) and distills it to a
replay-ready manifest for Stage 3 to consume.
Output schema (`contention_manifest.json`):
{
"version": 1,
"source_canary_jsonl": "<absolute path>",
"source_canary_sha256": "<hex>",
"built_at_host_unix": <int>,
"summary": {
"total_input_events": <int>,
"total_contention_events_kept": <int>,
"per_tid_counts": { "<tid>": <int>, ... }
},
"entries": [
{ "tid": 6, "tid_event_idx": 104664, "site_sid": "c26a128bf45411f7",
"cs_ptr": "0xbc65c890", "contended": true },
...
]
}
Entries are sorted by (tid asc, tid_event_idx asc). Stage 3's ours-side
replay loader keys on `(tid, tid_event_idx)`; the canary tid is the
*native* tid emitted by canary (no display-mapping is applied here —
see investigation.md §"Tid mapping is per-engine native").
Only events with `kind == "contention.observed"` and `contended == true`
are kept. Stage 1's emitter never emits `contended=false`, so this
filter is paranoid-defensive. Schema events / handle events / wait
events are dropped.
Usage:
python3 build_contention_manifest.py \\
--canary-jsonl path/to/canary-cvaron-trunc.jsonl \\
--out path/to/contention_manifest.json
Exit 0 on success. Exit 1 on parse error or empty manifest (no
contention events found — likely cvar wasn't enabled when the trace
was captured).
"""
import argparse
import hashlib
import json
import sys
import time
from pathlib import Path
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument(
"--canary-jsonl",
required=True,
help="Path to canary Phase A JSONL log (with cvar=true).",
)
p.add_argument(
"--out",
required=True,
help="Output path for contention_manifest.json.",
)
p.add_argument(
"--tid-map",
default="",
help=(
"Optional canary→ours tid translation. Format "
"'CANARY=OURS,CANARY=OURS,...' (e.g. '6=1,7=2,4=11'). When "
"supplied, manifest entries are emitted with the ours-side tid "
"so the Stage-3 consumer can key on its own native current_tid. "
"Entries on a canary tid NOT in the map are dropped with a "
"warning. Same format as diff_events.py."
),
)
p.add_argument(
"--quiet",
action="store_true",
help="Suppress the human-readable summary on stderr.",
)
return p.parse_args()
def parse_tid_map(s: str) -> dict[int, int] | None:
"""Parse 'a=b,c=d' into {a: b, c: d}. Empty/None → None."""
s = s.strip()
if not s:
return None
out: dict[int, int] = {}
for piece in s.split(","):
piece = piece.strip()
if not piece:
continue
if "=" not in piece:
raise ValueError(f"bad tid-map fragment: {piece!r}")
l, r = piece.split("=", 1)
out[int(l.strip())] = int(r.strip())
return out
def sha256_of(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def build_manifest(
jsonl_path: Path,
tid_map: dict[int, int] | None = None,
) -> dict:
"""Read `jsonl_path` and return a manifest dict.
If `tid_map` (canary_tid → ours_tid) is provided, entries are written
with the translated ours-side tid. Entries on a canary tid not in
the map are dropped (counted in `summary.skipped_unmapped_tids`).
When `tid_map` is None, manifest tids are canary's native values
(back-compat with Stage 2's first iteration).
Raises FileNotFoundError / json.JSONDecodeError on bad input.
"""
entries: list[dict] = []
total_input = 0
bad_lines = 0
unmapped = 0
with jsonl_path.open("r", encoding="utf-8") as f:
for lineno, line in enumerate(f, start=1):
line = line.rstrip("\n")
if not line:
continue
total_input += 1
try:
ev = json.loads(line)
except json.JSONDecodeError:
bad_lines += 1
continue
if ev.get("kind") != "contention.observed":
continue
payload = ev.get("payload") or {}
if payload.get("contended") is not True:
continue
canary_tid = int(ev["tid"])
if tid_map is not None:
if canary_tid not in tid_map:
unmapped += 1
continue
tid = tid_map[canary_tid]
else:
tid = canary_tid
entry = {
"tid": tid,
"tid_event_idx": int(ev["tid_event_idx"]),
"site_sid": str(payload.get("site_sid", "")),
"cs_ptr": str(payload.get("cs_ptr", "")),
"contended": True,
}
# Defensive: every Stage 1 event carries cs_ptr + site_sid.
# If either is missing, skip rather than emit a broken entry.
if not entry["site_sid"] or not entry["cs_ptr"]:
bad_lines += 1
continue
entries.append(entry)
# Stable sort by (tid, tid_event_idx). Same (tid, idx) pair is not
# expected — the per-tid counter is monotone — but if duplicates
# appear (e.g. mis-merged jsonls), keep the first; later phases would
# otherwise see ambiguous manifest keys.
entries.sort(key=lambda e: (e["tid"], e["tid_event_idx"]))
deduped: list[dict] = []
seen: set[tuple[int, int]] = set()
dup_count = 0
for e in entries:
key = (e["tid"], e["tid_event_idx"])
if key in seen:
dup_count += 1
continue
seen.add(key)
deduped.append(e)
per_tid: dict[str, int] = {}
for e in deduped:
per_tid[str(e["tid"])] = per_tid.get(str(e["tid"]), 0) + 1
return {
"version": 1,
"source_canary_jsonl": str(jsonl_path.resolve()),
"source_canary_sha256": sha256_of(jsonl_path),
"built_at_host_unix": int(time.time()),
"tid_map": tid_map,
"summary": {
"total_input_events": total_input,
"total_contention_events_kept": len(deduped),
"skipped_bad_lines": bad_lines,
"skipped_unmapped_tids": unmapped,
"skipped_duplicate_keys": dup_count,
"per_tid_counts": per_tid,
},
"entries": deduped,
}
def render_summary(manifest: dict) -> str:
s = manifest["summary"]
lines = [
f"contention manifest built from {manifest['source_canary_jsonl']}",
f" source sha256: {manifest['source_canary_sha256']}",
f" total input events scanned: {s['total_input_events']}",
f" contention events kept: {s['total_contention_events_kept']}",
f" bad/skipped lines: {s['skipped_bad_lines']}",
f" duplicate (tid,idx) skipped: {s['skipped_duplicate_keys']}",
" per-tid counts:",
]
for tid, count in sorted(s["per_tid_counts"].items(),
key=lambda kv: int(kv[0])):
lines.append(f" tid={int(tid):4d} {count}")
return "\n".join(lines)
def main() -> int:
args = parse_args()
src = Path(args.canary_jsonl)
if not src.is_file():
print(f"error: not a file: {src}", file=sys.stderr)
return 1
try:
tid_map = parse_tid_map(args.tid_map)
except ValueError as e:
print(f"error: --tid-map: {e}", file=sys.stderr)
return 1
manifest = build_manifest(src, tid_map=tid_map)
if manifest["summary"]["total_contention_events_kept"] == 0:
print(
"error: 0 contention.observed events found — was the trace "
"captured with --kernel_emit_contention=true?",
file=sys.stderr,
)
return 1
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2)
f.write("\n")
if not args.quiet:
print(render_summary(manifest), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Unit tests for `build_contention_manifest.py`.
Run as `python3 test_build_manifest.py` — prints `PASS` per test.
"""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from build_contention_manifest import build_manifest, render_summary # noqa: E402
def write_jsonl(lines: list[str]) -> Path:
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".jsonl", delete=False, encoding="utf-8"
)
for line in lines:
tmp.write(line + "\n")
tmp.close()
return Path(tmp.name)
def mk_event(
kind: str,
tid: int,
idx: int,
payload: dict,
engine: str = "canary",
) -> str:
return json.dumps(
{
"schema_version": 1,
"engine": engine,
"kind": kind,
"tid": tid,
"tid_event_idx": idx,
"guest_cycle": 0,
"host_ns": 0,
"deterministic": True,
"payload": payload,
}
)
def test_basic_extract() -> None:
src = write_jsonl([
mk_event("import.call", 6, 0, {"name": "Foo"}),
mk_event(
"contention.observed",
6,
104664,
{"cs_ptr": "0xbc65c890", "site_sid": "c26a128b", "contended": True},
),
mk_event("import.call", 6, 1, {"name": "Bar"}),
])
m = build_manifest(src)
assert m["version"] == 1
assert m["summary"]["total_input_events"] == 3
assert m["summary"]["total_contention_events_kept"] == 1
assert m["summary"]["per_tid_counts"] == {"6": 1}
e = m["entries"][0]
assert e["tid"] == 6 and e["tid_event_idx"] == 104664
assert e["site_sid"] == "c26a128b" and e["cs_ptr"] == "0xbc65c890"
assert e["contended"] is True
print("PASS test_basic_extract")
def test_filters_non_contention_kinds() -> None:
src = write_jsonl([
mk_event("handle.create", 6, 0, {"handle_semantic_id": "x"}),
mk_event("wait.begin", 6, 1, {"handles_semantic_ids": ["x"]}),
mk_event("kernel.call", 6, 2, {"name": "X"}),
mk_event(
"contention.observed",
7,
42,
{"cs_ptr": "0x1000", "site_sid": "deadbeef", "contended": True},
),
])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 1
assert m["entries"][0]["tid"] == 7
print("PASS test_filters_non_contention_kinds")
def test_filters_contended_false() -> None:
# Stage 1's emitter never emits contended=false today, but defensive
# filter must skip those if a future variant adds them.
src = write_jsonl([
mk_event(
"contention.observed",
6,
10,
{"cs_ptr": "0xa", "site_sid": "11", "contended": False},
),
mk_event(
"contention.observed",
6,
11,
{"cs_ptr": "0xa", "site_sid": "11", "contended": True},
),
])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 1
assert m["entries"][0]["tid_event_idx"] == 11
print("PASS test_filters_contended_false")
def test_sorts_by_tid_then_idx() -> None:
src = write_jsonl([
mk_event(
"contention.observed",
9,
5,
{"cs_ptr": "0x9", "site_sid": "99", "contended": True},
),
mk_event(
"contention.observed",
6,
200,
{"cs_ptr": "0xb", "site_sid": "bb", "contended": True},
),
mk_event(
"contention.observed",
6,
100,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
])
m = build_manifest(src)
keys = [(e["tid"], e["tid_event_idx"]) for e in m["entries"]]
assert keys == [(6, 100), (6, 200), (9, 5)], keys
print("PASS test_sorts_by_tid_then_idx")
def test_deduplicates_same_tid_idx() -> None:
src = write_jsonl([
mk_event(
"contention.observed",
6,
42,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
mk_event(
"contention.observed",
6,
42,
{"cs_ptr": "0xb", "site_sid": "bb", "contended": True},
),
])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 1
assert m["summary"]["skipped_duplicate_keys"] == 1
# Keeps the first occurrence.
assert m["entries"][0]["cs_ptr"] == "0xa"
print("PASS test_deduplicates_same_tid_idx")
def test_skips_missing_fields() -> None:
src = write_jsonl([
# Missing site_sid.
mk_event(
"contention.observed",
6,
1,
{"cs_ptr": "0xa", "contended": True},
),
# Missing cs_ptr.
mk_event(
"contention.observed",
6,
2,
{"site_sid": "aa", "contended": True},
),
# Both present — kept.
mk_event(
"contention.observed",
6,
3,
{"cs_ptr": "0xb", "site_sid": "bb", "contended": True},
),
])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 1
assert m["summary"]["skipped_bad_lines"] == 2
print("PASS test_skips_missing_fields")
def test_handles_bad_json_lines() -> None:
src = write_jsonl([
"not-json",
mk_event(
"contention.observed",
6,
1,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
"{\"truncated\":",
])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 1
assert m["summary"]["skipped_bad_lines"] == 2
print("PASS test_handles_bad_json_lines")
def test_render_summary_human_readable() -> None:
src = write_jsonl([
mk_event(
"contention.observed",
6,
1,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
mk_event(
"contention.observed",
14,
100,
{"cs_ptr": "0xb", "site_sid": "bb", "contended": True},
),
])
m = build_manifest(src)
out = render_summary(m)
assert "contention events kept: 2" in out
assert "tid= 6 1" in out
assert "tid= 14 1" in out
print("PASS test_render_summary_human_readable")
def test_empty_input_yields_zero_kept() -> None:
src = write_jsonl([mk_event("import.call", 0, 0, {"name": "X"})])
m = build_manifest(src)
assert m["summary"]["total_contention_events_kept"] == 0
assert m["entries"] == []
print("PASS test_empty_input_yields_zero_kept")
def test_tid_map_translates_canary_to_ours() -> None:
src = write_jsonl([
mk_event(
"contention.observed",
6,
104664,
{"cs_ptr": "0xbc65c890", "site_sid": "c26a128bf45411f7", "contended": True},
),
mk_event(
"contention.observed",
7,
10,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
])
m = build_manifest(src, tid_map={6: 1, 7: 2})
assert m["entries"][0]["tid"] == 1, m["entries"][0]
assert m["entries"][1]["tid"] == 2
print("PASS test_tid_map_translates_canary_to_ours")
def test_tid_map_drops_unmapped_canary_tids() -> None:
src = write_jsonl([
mk_event(
"contention.observed",
6,
100,
{"cs_ptr": "0xa", "site_sid": "aa", "contended": True},
),
mk_event(
"contention.observed",
99,
200,
{"cs_ptr": "0xb", "site_sid": "bb", "contended": True},
),
])
m = build_manifest(src, tid_map={6: 1})
assert m["summary"]["total_contention_events_kept"] == 1
assert m["summary"]["skipped_unmapped_tids"] == 1
assert m["entries"][0]["tid"] == 1
print("PASS test_tid_map_drops_unmapped_canary_tids")
if __name__ == "__main__":
tests = [
test_basic_extract,
test_filters_non_contention_kinds,
test_filters_contended_false,
test_sorts_by_tid_then_idx,
test_deduplicates_same_tid_idx,
test_skips_missing_fields,
test_handles_bad_json_lines,
test_render_summary_human_readable,
test_empty_input_yields_zero_kept,
test_tid_map_translates_canary_to_ours,
test_tid_map_drops_unmapped_canary_tids,
]
for t in tests:
t()
print(f"\nALL {len(tests)} TESTS PASS")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
# diff-state
Phase B initial-state snapshot diff tool. Stdlib-only Python. Mirrors the
shape of `tools/diff-events/` but operates on the *static structural*
snapshots emitted by `phase_b_snapshot` at the moment immediately before
the first guest PPC instruction of the XEX entry_point executes.
## Usage
```bash
python3 tools/diff-state/diff_state.py \
--canary <snapshot_dir>/canary \
--ours <snapshot_dir>/ours \
--out <snapshot_dir>/report.md
```
Writes:
- `<snapshot_dir>/report.md` — human-readable divergence catalog
- `<snapshot_dir>/report.json` — machine-readable sibling (same content)
## Exit codes
| code | meaning |
|---|---|
| 0 | no divergence (or `--validate-identical` succeeded) |
| 1 | divergences found |
| 2 | STOP triggered (`image_loaded_sha256` / `xex_entry_point` / `iso_sha256` mismatch) |
## Field-comparison rules
Lives at the top of `diff_state.py` as Python constants — read those for
the authoritative spec. Summary:
- `engine`, `schema_version`, `deterministic_skip` are always skipped.
- `cpu_state.json`: skip `hw_id`.
- `kernel.json`: skip `raw_handle_id`, `exports_registered_count`.
- `config.json`: skip `build_id`, `iso_path`, `host_ns_at_snapshot`,
`wall_clock_iso8601`, `cli_argv`, `cvars.phase_b_snapshot_dir`.
- Each snapshot's `deterministic_skip` array is honored too.
## Set vs sequence semantics
- **Set** (sort by key, then positional compare):
- `kernel.json::objects` (key=`handle_semantic_id`)
- `kernel.json::handle_name_table` (key=`name`)
- `vfs.json::cache_root_listing` (key=`relpath`)
- `memory.json::heaps` (key=`base`)
- **Sequence** (positional compare): everything else, including
`memory.json::regions` (which both engines emit pre-sorted by
`(start, end)`).
## Classification
| class | trigger | priority |
|---|---|---|
| σ-structural | field missing/extra; sequence-length mismatch; set element only in one engine | 1 (always report) |
| δ-content-STOP | `image_loaded_sha256` / `xex_entry_point` / `iso_sha256` mismatch | STOP (exit 2) |
| δ-content | other `*_sha256` field differs | 2 |
| γ-kernel-content | `objects[].details` field differs | 2 — primary Phase C target |
| κ-cache | non-empty `cache_root_listing` either side | re-run after `rm -rf` of caches |
| ε-host-allocator | heap base/region start differs but sha256 agrees | catalog only |
| τ-host-timing | `deterministic_skip`-listed timing field | silent unless verbose |
## Negative-test recipe
To verify the tool catches a hand-mutation:
```bash
cp -r snap-001/ours snap-001/ours-mut
sed -i 's/"thread_id": 1/"thread_id": 999/' snap-001/ours-mut/kernel.json
python3 tools/diff-state/diff_state.py \
--canary snap-001/ours --ours snap-001/ours-mut --out /tmp/r.md
# exit code 1; report names objects[handle_semantic_id=...] details.thread_id
```

View File

@@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""Phase B state-snapshot diff tool.
Reads two snapshot directories (one per engine, `<dir>/canary/` and
`<dir>/ours/`) emitted by `phase_b_snapshot` at the moment immediately
before the first guest PPC instruction of the XEX entry_point. Produces
a markdown report (`report.md`) plus a machine-readable JSON sibling
(`report.json`) classifying every observable divergence.
Field-comparison rules + classification table:
audit-runs/phase-b-state-equivalence/README.md
Both engines' emitter source + this tool read the same rules.
Usage:
diff_state.py --canary <dir>/canary --ours <dir>/ours [--out report.md]
diff_state.py --canary <a> --ours <b> --validate-identical
Exit codes:
0 — no divergence (or `--validate-identical` succeeded)
1 — divergences found
2 — STOP triggered (image_loaded_sha256 / xex_entry_point / iso_sha256
mismatch — interpretation of downstream files is not valid)
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
# ---------- field-comparison rules (declared up front) ----------
# Per-snapshot-file fields the diff tool always skips at the top level.
SKIP_TOP_FIELDS = {"schema_version", "engine", "deterministic_skip"}
# Per-file: extra fields skipped. JSON-pointer-style ("a.b.c") matched
# either at top-level keys or within array-of-objects members keyed by
# `handle_semantic_id` etc.
SKIP_BY_FILE: dict[str, set[str]] = {
"cpu_state.json": {"hw_id"},
"memory.json": set(),
"kernel.json": {"raw_handle_id", "exports_registered_count"},
"vfs.json": set(),
"config.json": {
"build_id",
"iso_path",
"host_ns_at_snapshot",
"wall_clock_iso8601",
"cli_argv",
"cvars.phase_b_snapshot_dir",
},
}
# `objects` etc. are sets (sort then compare); `regions`/`probes`/`gpr`/
# etc. are sequences (positional compare). Mismatches handled separately.
SET_FIELDS: dict[str, dict[str, str]] = {
# file -> field_name -> sort-key (used as dict key)
"kernel.json": {
"objects": "handle_semantic_id",
"handle_name_table": "name",
},
"vfs.json": {"cache_root_listing": "relpath"},
"memory.json": {"heaps": "base"},
}
# STOP-trigger fields (δ-content critical equivalence).
# Note: image_loaded_sha256 is reported but NOT a STOP trigger here. The
# raw hash mismatches when engines patch imports differently — see
# check_invariants() which evaluates `image_canonical_sha256` (computed
# from image.bin + xex.json) as the real semantic STOP key.
STOP_FIELDS = {
("config.json", "xex_entry_point"),
("config.json", "iso_sha256"),
}
# ---------- divergence record ----------
class Divergence:
__slots__ = ("file", "path", "kind", "canary", "ours", "klass")
def __init__(self, file: str, path: str, kind: str, canary: Any, ours: Any, klass: str):
self.file = file
self.path = path
self.kind = kind
self.canary = canary
self.ours = ours
self.klass = klass
def to_dict(self) -> dict:
return {
"file": self.file,
"path": self.path,
"kind": self.kind,
"canary": self.canary,
"ours": self.ours,
"class": self.klass,
}
# ---------- classification ----------
def classify(file: str, path: str, kind: str, canary: Any, ours: Any) -> str:
if (file, path) in STOP_FIELDS:
return "delta-content-STOP"
if kind in ("set-size-mismatch", "missing-field", "extra-field", "seq-length"):
return "sigma-structural"
if path.endswith(".sha256") or path.endswith("_sha256"):
return "delta-content"
if path.startswith("objects[") and ".details." in path:
return "gamma-kernel-content"
if file == "vfs.json" and path.startswith("cache_root_listing"):
return "kappa-cache"
if path in ("heaps[].base", "heaps[].name"):
return "epsilon-host-allocator"
if path in ("host_ns_at_snapshot", "wall_clock_iso8601"):
return "tau-host-timing"
return "gamma-kernel-content"
# ---------- generic walker ----------
def collect_skip_set(file: str, doc: dict) -> set[str]:
s = set(SKIP_TOP_FIELDS) | set(SKIP_BY_FILE.get(file, set()))
extra = doc.get("deterministic_skip")
if isinstance(extra, list):
for x in extra:
if isinstance(x, str):
s.add(x)
return s
def is_skipped(file: str, path: str, skip: set[str]) -> bool:
if path in skip:
return True
# Strip array indices for membership check, so "objects[].raw_handle_id"
# in the skip set matches "objects[3].raw_handle_id".
bracketed = []
parts = path.split(".")
for p in parts:
idx = p.find("[")
if idx >= 0:
bracketed.append(p[:idx] + "[]")
else:
bracketed.append(p)
norm = ".".join(bracketed)
if norm in skip:
return True
# Last-token (leaf field) match — e.g. "raw_handle_id" anywhere.
leaf = bracketed[-1]
if leaf in skip:
return True
return False
def diff_value(
file: str,
path: str,
a: Any,
b: Any,
out: list[Divergence],
skip: set[str],
set_keys: dict[str, str] | None = None,
) -> None:
if is_skipped(file, path, skip):
return
if type(a) != type(b):
out.append(Divergence(file, path, "type-mismatch", a, b,
classify(file, path, "type-mismatch", a, b)))
return
if isinstance(a, dict):
a_keys = set(a.keys())
b_keys = set(b.keys())
for k in sorted(a_keys - b_keys):
sub = f"{path}.{k}" if path else k
if is_skipped(file, sub, skip):
continue
out.append(Divergence(file, sub, "missing-field", a[k], None,
classify(file, sub, "missing-field", a[k], None)))
for k in sorted(b_keys - a_keys):
sub = f"{path}.{k}" if path else k
if is_skipped(file, sub, skip):
continue
out.append(Divergence(file, sub, "extra-field", None, b[k],
classify(file, sub, "extra-field", None, b[k])))
for k in sorted(a_keys & b_keys):
sub = f"{path}.{k}" if path else k
diff_value(file, sub, a[k], b[k], out, skip, set_keys)
return
if isinstance(a, list):
# Set-field handling: sort by configured key.
last_seg = path.rsplit(".", 1)[-1] if path else ""
bare = last_seg.split("[", 1)[0]
key = (set_keys or {}).get(bare)
if key is not None:
a_sorted = sorted(a, key=lambda x: x.get(key, "") if isinstance(x, dict) else "")
b_sorted = sorted(b, key=lambda x: x.get(key, "") if isinstance(x, dict) else "")
a_keys = {x.get(key) for x in a_sorted if isinstance(x, dict)}
b_keys = {x.get(key) for x in b_sorted if isinstance(x, dict)}
missing = sorted(a_keys - b_keys, key=str)
extra = sorted(b_keys - a_keys, key=str)
for m in missing:
out.append(Divergence(file, f"{path}[{key}={m}]",
"missing-from-ours", m, None,
classify(file, f"{path}[{key}={m}]",
"missing-from-ours", m, None)))
for e in extra:
out.append(Divergence(file, f"{path}[{key}={e}]",
"extra-in-ours", None, e,
classify(file, f"{path}[{key}={e}]",
"extra-in-ours", None, e)))
common = sorted(a_keys & b_keys, key=str)
a_by = {x.get(key): x for x in a_sorted if isinstance(x, dict)}
b_by = {x.get(key): x for x in b_sorted if isinstance(x, dict)}
for ck in common:
diff_value(file, f"{path}[{key}={ck}]", a_by[ck], b_by[ck],
out, skip, set_keys)
return
# Sequence-field: positional.
if len(a) != len(b):
out.append(Divergence(file, path, "seq-length", len(a), len(b),
classify(file, path, "seq-length", len(a), len(b))))
n = min(len(a), len(b))
else:
n = len(a)
for i in range(n):
diff_value(file, f"{path}[{i}]", a[i], b[i], out, skip, set_keys)
return
if a != b:
out.append(Divergence(file, path, "value", a, b,
classify(file, path, "value", a, b)))
# ---------- file-level orchestration ----------
def load_json(p: Path) -> dict:
with p.open("r", encoding="utf-8") as f:
return json.load(f)
def diff_directory(canary_dir: Path, ours_dir: Path) -> tuple[list[Divergence], dict]:
files = ["cpu_state.json", "memory.json", "kernel.json", "vfs.json", "config.json"]
divergences: list[Divergence] = []
manifest_canary = load_json(canary_dir / "manifest.json") if (canary_dir / "manifest.json").exists() else {}
manifest_ours = load_json(ours_dir / "manifest.json") if (ours_dir / "manifest.json").exists() else {}
file_status = {}
for name in files:
cp = canary_dir / name
op = ours_dir / name
if not cp.exists():
divergences.append(Divergence(name, "<file>", "missing-file",
"absent", "present", "sigma-structural"))
file_status[name] = "missing-in-canary"
continue
if not op.exists():
divergences.append(Divergence(name, "<file>", "missing-file",
"present", "absent", "sigma-structural"))
file_status[name] = "missing-in-ours"
continue
ch = manifest_canary.get("files", {}).get(name)
oh = manifest_ours.get("files", {}).get(name)
if ch is not None and ch == oh:
# Verify the manifest hashes against the actual file contents
# before trusting them — a tampered file with an intact manifest
# would otherwise be silently masked.
ch_actual = hashlib.sha256(cp.read_bytes()).hexdigest()
oh_actual = hashlib.sha256(op.read_bytes()).hexdigest()
if ch_actual == ch and oh_actual == oh:
file_status[name] = "identical"
continue
# Manifest claim does not match disk — fall through to full diff
# and surface the manifest mismatch as a structural divergence.
if ch_actual != ch:
divergences.append(Divergence(
name, "<manifest>", "manifest-hash-mismatch", ch, ch_actual,
"sigma-structural"))
if oh_actual != oh:
divergences.append(Divergence(
name, "<manifest>", "manifest-hash-mismatch", oh, oh_actual,
"sigma-structural"))
a = load_json(cp)
b = load_json(op)
skip = collect_skip_set(name, a) | collect_skip_set(name, b)
diff_value(name, "", a, b, divergences, skip,
set_keys=SET_FIELDS.get(name))
file_status[name] = "diverged"
return divergences, file_status
# ---------- invariants ----------
def _canonicalize_image(image: bytes, xex_meta: dict, image_base: int) -> bytes:
"""Mask XEX import slots to 0xCD. Import patches are legitimate
engine-specific runtime overlays (record_type=0 var slots = 4 bytes,
record_type=1 thunks = 16 bytes); they break a naive byte-equality
invariant even when both engines decoded the XEX identically."""
ranges = []
for lib in xex_meta.get("import_libraries", []):
for imp in lib.get("imports", []):
addr = imp["address"]
rt = imp["record_type"]
if rt == 0:
ranges.append((addr, addr + 4))
elif rt == 1:
ranges.append((addr, addr + 16))
buf = bytearray(image)
for sva, eva in ranges:
s = sva - image_base
e = eva - image_base
if s < 0 or e > len(buf):
continue
for i in range(s, e):
buf[i] = 0xCD
return bytes(buf)
def check_invariants(
canary_dir: Path, ours_dir: Path, xex_json: Path | None = None
) -> tuple[list[tuple[str, str, str, bool]], bool]:
"""Returns (rows, stop) where each row is (name, canary_val, ours_val, ok).
`stop` is True iff any STOP-class invariant failed.
When --xex-json is provided AND both snapshots contain `image.bin`,
the image-load invariant is computed over a canonicalized buffer
(XEX import slots masked). This relaxes the original raw-bytes STOP
to the only meaningful semantic check — both engines decoded the
XEX identically — and avoids tripping on legitimate runtime import
patches (canary's 0xDEADC0DE vs ours's 0x00000000 sentinels)."""
rows = []
stop = False
try:
c_cfg = load_json(canary_dir / "config.json")
o_cfg = load_json(ours_dir / "config.json")
c_cpu = load_json(canary_dir / "cpu_state.json")
o_cpu = load_json(ours_dir / "cpu_state.json")
except FileNotFoundError as e:
return [(f"file_present:{e.filename}", "", "", False)], True
c_entry = c_cfg.get("xex_entry_point")
o_entry = o_cfg.get("xex_entry_point")
rows.append(("xex_entry_point", str(c_entry), str(o_entry), c_entry == o_entry))
if c_entry != o_entry:
stop = True
c_pc = c_cpu.get("pc")
o_pc = o_cpu.get("pc")
pc_match = c_pc == c_entry and o_pc == o_entry
rows.append((
"cpu_state.pc == xex_entry_point",
f"{c_pc} == {c_entry}",
f"{o_pc} == {o_entry}",
pc_match,
))
if not pc_match:
stop = True
c_img = c_cfg.get("image_loaded_sha256")
o_img = o_cfg.get("image_loaded_sha256")
# Original raw hash — informational. Mismatch is expected when the
# engines patch imports differently. Reported but does NOT STOP.
rows.append((
"image_loaded_sha256 (raw)",
c_img or "",
o_img or "",
c_img == o_img,
))
# Canonical hash — the real equivalence check. Requires both engines
# to have dumped image.bin (--phase-b-dump-section-content) AND a
# caller-supplied --xex-json with the import table. When unavailable
# we fall back to the raw hash as the STOP key for backward compat.
c_img_bin = canary_dir / "image.bin"
o_img_bin = ours_dir / "image.bin"
canonical_available = (
xex_json is not None
and c_img_bin.exists()
and o_img_bin.exists()
)
if canonical_available:
xex_meta = json.loads(Path(xex_json).read_text())
image_base = xex_meta.get("image_base", 0x82000000)
cbytes = c_img_bin.read_bytes()
obytes = o_img_bin.read_bytes()
c_canon = _canonicalize_image(cbytes, xex_meta, image_base)
o_canon = _canonicalize_image(obytes, xex_meta, image_base)
import hashlib as _hl
c_canon_h = _hl.sha256(c_canon).hexdigest()
o_canon_h = _hl.sha256(o_canon).hexdigest()
canon_ok = c_canon_h == o_canon_h
rows.append((
"image_canonical_sha256",
c_canon_h,
o_canon_h,
canon_ok,
))
if not canon_ok:
stop = True
else:
# No canonicalization possible — fall back to raw bytes as the
# STOP key. This preserves the original Phase B semantics.
if c_img != o_img:
stop = True
return rows, stop
# ---------- report writing ----------
def write_report(out_path: Path, canary_dir: Path, ours_dir: Path,
divergences: list[Divergence], file_status: dict,
invariants: list, stop: bool):
lines = []
lines.append("# Phase B snapshot diff")
lines.append("")
lines.append(f"- canary snapshot: `{canary_dir}`")
lines.append(f"- ours snapshot: `{ours_dir}`")
lines.append("")
lines.append("## Invariants (HARD GATE)")
lines.append("")
lines.append("| invariant | canary | ours | ok? |")
lines.append("|---|---|---|---|")
for name, cval, oval, ok in invariants:
lines.append(f"| {name} | `{cval}` | `{oval}` | {'PASS' if ok else 'FAIL'} |")
lines.append("")
if stop:
lines.append("> **STOP**: a primary equivalence invariant failed. "
"Downstream divergences are not interpretable until this is "
"resolved. Re-run with `--phase-b-dump-section-content` on both "
"engines and binary-diff the regions to localize.")
lines.append("")
lines.append("## File-level summary")
lines.append("")
lines.append("| file | status | divergence count by class |")
lines.append("|---|---|---|")
by_file_class: dict[tuple[str, str], int] = {}
for d in divergences:
by_file_class[(d.file, d.klass)] = by_file_class.get((d.file, d.klass), 0) + 1
for fname, st in file_status.items():
counts = []
for klass in ["sigma-structural", "delta-content-STOP", "delta-content",
"gamma-kernel-content", "kappa-cache",
"epsilon-host-allocator", "tau-host-timing"]:
c = by_file_class.get((fname, klass), 0)
if c:
counts.append(f"{klass}={c}")
lines.append(f"| {fname} | {st} | {' '.join(counts) if counts else ''} |")
lines.append("")
# Per-class sections.
by_class: dict[str, list[Divergence]] = {}
for d in divergences:
by_class.setdefault(d.klass, []).append(d)
priority_order = [
("sigma-structural", "σ-structural divergences (priority 1)"),
("delta-content-STOP", "δ-content STOP divergences"),
("delta-content", "δ-content divergences (priority 2)"),
("gamma-kernel-content", "γ-kernel-content divergences (priority 2)"),
("kappa-cache", "κ-cache divergences (re-run after pre-clean)"),
("epsilon-host-allocator", "ε-host-allocator (informational)"),
("tau-host-timing", "τ-host-timing (informational)"),
]
for klass, title in priority_order:
items = by_class.get(klass, [])
if not items:
continue
lines.append(f"## {title}")
lines.append("")
for d in items[:200]: # cap each section
lines.append(f"- **{d.file}** `{d.path}`: kind=`{d.kind}` "
f"canary=`{d.canary!r}` ours=`{d.ours!r}`")
if len(items) > 200:
lines.append(f"- _… {len(items) - 200} more in this class (see report.json)_")
lines.append("")
lines.append("## Phase C handoff")
lines.append("")
lines.append("Suggested attack order: σ first (structural), then γ ranked by "
"object type (Thread > Event > Semaphore > Mutex > Timer > File > "
"Other), then δ. ε and τ are catalog-only.")
out_path.write_text("\n".join(lines), encoding="utf-8")
def write_report_json(out_path: Path, divergences: list[Divergence],
file_status: dict, invariants: list, stop: bool):
obj = {
"schema_version": SCHEMA_VERSION,
"invariants": [
{"name": n, "canary": c, "ours": o, "ok": ok}
for n, c, o, ok in invariants
],
"stop": stop,
"file_status": file_status,
"divergences": [d.to_dict() for d in divergences],
}
out_path.write_text(json.dumps(obj, indent=2, sort_keys=True), encoding="utf-8")
# ---------- CLI ----------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--canary", required=True)
ap.add_argument("--ours", required=True)
ap.add_argument("--out", default=None)
ap.add_argument("--xex-json", default=None,
help="optional xex.json metadata for canonical image-load "
"invariant (requires image.bin in both snapshot dirs)")
ap.add_argument("--validate-identical", action="store_true")
ns = ap.parse_args()
canary_dir = Path(ns.canary)
ours_dir = Path(ns.ours)
if not canary_dir.is_dir() or not ours_dir.is_dir():
print(f"both snapshot dirs must exist: {canary_dir} {ours_dir}", file=sys.stderr)
sys.exit(2)
xex_json = Path(ns.xex_json) if ns.xex_json else None
invariants, stop = check_invariants(canary_dir, ours_dir, xex_json)
divergences, file_status = diff_directory(canary_dir, ours_dir)
if ns.validate_identical:
if divergences or not all(ok for _, _, _, ok in invariants):
print("validate-identical: differences found", file=sys.stderr)
sys.exit(1)
print("validate-identical: OK")
sys.exit(0)
out_md = Path(ns.out) if ns.out else (canary_dir.parent / "report.md")
out_json = out_md.with_suffix(".json")
write_report(out_md, canary_dir, ours_dir, divergences, file_status,
invariants, stop)
write_report_json(out_json, divergences, file_status, invariants, stop)
print(f"wrote {out_md} ({len(divergences)} divergences)")
print(f"wrote {out_json}")
if stop:
sys.exit(2)
if divergences:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()