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:
@@ -897,17 +897,12 @@ fn insert_functions(
|
||||
func_analysis: &FuncAnalysis,
|
||||
labels: &HashMap<u32, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO functions
|
||||
(address, name, end_address, frame_size, saved_gprs, is_leaf, is_saverestore,
|
||||
pdata_validated, pdata_length, has_eh)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)?;
|
||||
let mut app = conn.appender("functions")?;
|
||||
for (&addr, fi) in &func_analysis.functions {
|
||||
let name = labels.get(&addr)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("sub_{addr:08X}"));
|
||||
stmt.execute(params![
|
||||
app.append_row(params![
|
||||
addr as i64,
|
||||
name,
|
||||
fi.end as i64,
|
||||
@@ -920,6 +915,7 @@ fn insert_functions(
|
||||
fi.has_eh,
|
||||
])?;
|
||||
}
|
||||
app.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -930,15 +926,13 @@ fn insert_vtables(
|
||||
_image_base: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
if vtables.is_empty() { return Ok(()); }
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO vtables
|
||||
(address, length, col_address, class_name, rtti_present, base_classes_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut count = 0u64;
|
||||
let mut dedup: HashMap<u32, &crate::vtables::Vtable> = HashMap::new();
|
||||
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.length as i64,
|
||||
v.col_address.map(|a| a as i64),
|
||||
@@ -946,8 +940,9 @@ fn insert_vtables(
|
||||
v.rtti_present,
|
||||
v.base_classes_json.as_deref(),
|
||||
])?;
|
||||
count += 1;
|
||||
}
|
||||
app.flush()?;
|
||||
let count = dedup.len() as u64;
|
||||
metrics::counter!("db.rows", "table" => "vtables").increment(count);
|
||||
tracing::info!(rows = count, table = "vtables", "bulk insert complete");
|
||||
Ok(())
|
||||
@@ -960,17 +955,17 @@ fn insert_methods_and_classes(
|
||||
) -> anyhow::Result<()> {
|
||||
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);
|
||||
if !methods.is_empty() {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO methods
|
||||
(vtable_address, slot, function_address, mangled_name, demangled_name)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
for (vt_addr, slot, fn_addr, mangled, demangled) in &methods {
|
||||
stmt.execute(params![
|
||||
let mut idx: HashMap<(u32, u32), usize> = HashMap::new();
|
||||
for (i, m) in methods.iter().enumerate() {
|
||||
idx.entry((m.0, m.1)).or_insert(i);
|
||||
}
|
||||
let mut app = conn.appender("methods")?;
|
||||
for &i in idx.values() {
|
||||
let (vt_addr, slot, fn_addr, mangled, demangled) = &methods[i];
|
||||
app.append_row(params![
|
||||
*vt_addr as i64,
|
||||
*slot as i64,
|
||||
*fn_addr as i64,
|
||||
@@ -978,29 +973,33 @@ fn insert_methods_and_classes(
|
||||
demangled.as_deref(),
|
||||
])?;
|
||||
}
|
||||
metrics::counter!("db.rows", "table" => "methods").increment(methods.len() as u64);
|
||||
tracing::info!(rows = methods.len(), table = "methods", "bulk insert complete");
|
||||
app.flush()?;
|
||||
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);
|
||||
if !classes.is_empty() {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO classes
|
||||
(name, vtable_address, rtti_present, base_classes_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
for (name, vt_addr, rtti, bases) in &classes {
|
||||
stmt.execute(params![
|
||||
let mut idx: HashMap<&str, usize> = HashMap::new();
|
||||
for (i, c) in classes.iter().enumerate() {
|
||||
idx.entry(c.0.as_str()).or_insert(i);
|
||||
}
|
||||
let mut app = conn.appender("classes")?;
|
||||
for &i in idx.values() {
|
||||
let (name, vt_addr, rtti, bases) = &classes[i];
|
||||
app.append_row(params![
|
||||
name.as_str(),
|
||||
*vt_addr as i64,
|
||||
*rtti,
|
||||
bases.as_deref(),
|
||||
])?;
|
||||
}
|
||||
metrics::counter!("db.rows", "table" => "classes").increment(classes.len() as u64);
|
||||
tracing::info!(rows = classes.len(), table = "classes", "bulk insert complete");
|
||||
app.flush()?;
|
||||
let n = idx.len() as u64;
|
||||
metrics::counter!("db.rows", "table" => "classes").increment(n);
|
||||
tracing::info!(rows = n, table = "classes", "bulk insert complete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1011,20 +1010,21 @@ fn insert_strings(
|
||||
strings: &[crate::strings::DetectedString],
|
||||
) -> anyhow::Result<()> {
|
||||
if strings.is_empty() { return Ok(()); }
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO strings (address, encoding, length, content) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut count = 0u64;
|
||||
let mut dedup: HashMap<u32, &crate::strings::DetectedString> = HashMap::new();
|
||||
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.encoding,
|
||||
s.length as i64,
|
||||
s.content.as_str(),
|
||||
])?;
|
||||
count += 1;
|
||||
}
|
||||
app.flush()?;
|
||||
let count = dedup.len() as u64;
|
||||
metrics::counter!("db.rows", "table" => "strings").increment(count);
|
||||
tracing::info!(rows = count, table = "strings", "bulk insert complete");
|
||||
Ok(())
|
||||
@@ -1035,31 +1035,17 @@ fn insert_eh_records(
|
||||
records: &[crate::eh_scope::EhFuncInfo],
|
||||
) -> anyhow::Result<()> {
|
||||
if records.is_empty() { return Ok(()); }
|
||||
let mut stmt_fi = conn.prepare(
|
||||
"INSERT INTO eh_funcinfo
|
||||
(address, magic, max_state, p_unwind_map, n_try_blocks,
|
||||
p_try_block_map, n_ip_map_entries, p_ip_to_state_map,
|
||||
p_es_type_list, eh_flags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut stmt_unwind = conn.prepare(
|
||||
"INSERT INTO eh_unwind_map
|
||||
(funcinfo_address, state_index, to_state, action_pc)
|
||||
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![
|
||||
// Dedup eh_funcinfo by PK (address), first-write-wins.
|
||||
// Within a deduped record, unwind/try entries are uniquely indexed by enumerate.
|
||||
let mut fi_idx: HashMap<u32, usize> = HashMap::new();
|
||||
for (i, r) in records.iter().enumerate() {
|
||||
fi_idx.entry(r.address).or_insert(i);
|
||||
}
|
||||
|
||||
let mut app_fi = conn.appender("eh_funcinfo")?;
|
||||
for &i in fi_idx.values() {
|
||||
let r = &records[i];
|
||||
app_fi.append_row(params![
|
||||
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_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.eh_flags.map(|f| f as i64),
|
||||
])?;
|
||||
n_fi += 1;
|
||||
for (i, e) in r.unwind_map.iter().enumerate() {
|
||||
stmt_unwind.execute(params![
|
||||
r.address as i64, i as i64, e.to_state as i64, e.action_pc as i64,
|
||||
}
|
||||
app_fi.flush()?;
|
||||
let n_fi = fi_idx.len() as u64;
|
||||
|
||||
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;
|
||||
}
|
||||
for (i, t) in r.try_blocks.iter().enumerate() {
|
||||
stmt_try.execute(params![
|
||||
r.address as i64, i as i64,
|
||||
}
|
||||
app_unwind.flush()?;
|
||||
|
||||
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.n_catches as i64, t.p_handler_array as i64,
|
||||
])?;
|
||||
n_try += 1;
|
||||
}
|
||||
}
|
||||
app_try.flush()?;
|
||||
|
||||
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_try_blocks").increment(n_try);
|
||||
@@ -1097,54 +1099,55 @@ fn insert_typed_ind_dispatch(
|
||||
conn: &Connection,
|
||||
t: &crate::ind_dispatch_typed::TypedIndirectResult,
|
||||
) -> 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() {
|
||||
let mut stmt_site = conn.prepare(
|
||||
"INSERT INTO indirect_dispatch_sites
|
||||
(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;
|
||||
let mut sites: HashMap<u32, (u32, u32, usize)> = HashMap::new();
|
||||
let mut cands: HashMap<(u32, u32), u32> = HashMap::new();
|
||||
for d in &t.dispatches {
|
||||
stmt_site.execute(params![
|
||||
d.dispatch_pc as i64,
|
||||
d.vptr_offset as i64,
|
||||
d.slot as i64,
|
||||
d.candidate_vtables.len() as i64,
|
||||
])?;
|
||||
n_sites += 1;
|
||||
sites.entry(d.dispatch_pc)
|
||||
.or_insert((d.vptr_offset, d.slot, d.candidate_vtables.len()));
|
||||
for (vt, m) in d.candidate_vtables.iter().zip(d.method_pcs.iter()) {
|
||||
stmt_cand.execute(params![
|
||||
d.dispatch_pc as i64, *vt as i64, *m as i64,
|
||||
])?;
|
||||
n_cand += 1;
|
||||
cands.entry((d.dispatch_pc, *vt)).or_insert(*m);
|
||||
}
|
||||
}
|
||||
|
||||
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_candidates").increment(n_cand);
|
||||
tracing::info!(sites = n_sites, candidates = n_cand, "typed indirect-dispatch insert complete");
|
||||
}
|
||||
if !t.vptr_writes.is_empty() {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO vptr_writes
|
||||
(writer_pc, vtable_address, vptr_offset, writer_function)
|
||||
VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut n = 0u64;
|
||||
let mut writes: HashMap<(u32, u32, u32), u32> = HashMap::new();
|
||||
for w in &t.vptr_writes {
|
||||
stmt.execute(params![
|
||||
w.writer_pc as i64,
|
||||
w.vtable_addr as i64,
|
||||
w.vptr_offset as i64,
|
||||
w.writer_function as i64,
|
||||
])?;
|
||||
n += 1;
|
||||
writes.entry((w.writer_pc, w.vtable_addr, w.vptr_offset))
|
||||
.or_insert(w.writer_function);
|
||||
}
|
||||
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);
|
||||
tracing::info!(rows = n, "vptr_writes insert complete");
|
||||
}
|
||||
@@ -1156,26 +1159,31 @@ fn insert_funcptr_arrays(
|
||||
arrays: &[crate::funcptr_arrays::FuncPtrArray],
|
||||
) -> anyhow::Result<()> {
|
||||
if arrays.is_empty() { return Ok(()); }
|
||||
let mut stmt_arr = conn.prepare(
|
||||
"INSERT INTO function_pointer_arrays (address, length, kind) VALUES (?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut stmt_ent = conn.prepare(
|
||||
"INSERT INTO function_pointer_array_entries (array_address, slot, function_address)
|
||||
VALUES (?, ?, ?) ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
let mut n_arr = 0u64;
|
||||
// Dedup arrays by PK (address), first-write-wins.
|
||||
let mut idx: HashMap<u32, usize> = HashMap::new();
|
||||
for (i, a) in arrays.iter().enumerate() {
|
||||
idx.entry(a.address).or_insert(i);
|
||||
}
|
||||
|
||||
let mut app_arr = conn.appender("function_pointer_arrays")?;
|
||||
for &i in idx.values() {
|
||||
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;
|
||||
for a in arrays {
|
||||
let inserted = stmt_arr.execute(params![
|
||||
a.address as i64, a.length as i64, a.kind,
|
||||
])?;
|
||||
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])?;
|
||||
for &i in idx.values() {
|
||||
let a = &arrays[i];
|
||||
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])?;
|
||||
n_ent += 1;
|
||||
}
|
||||
}
|
||||
app_ent.flush()?;
|
||||
|
||||
metrics::counter!("db.rows", "table" => "function_pointer_arrays").increment(n_arr);
|
||||
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");
|
||||
@@ -1187,13 +1195,8 @@ fn insert_demangled_from_labels(
|
||||
labels: &HashMap<u32, String>,
|
||||
import_libraries: &[xenia_xex::header::ImportLibrary],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO demangled_names
|
||||
(address, mangled, raw_demangled, namespace_path, class_name,
|
||||
method_name, params_signature)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)?;
|
||||
|
||||
// demangled_names has no PK — straight append, no dedup needed.
|
||||
let mut app = conn.appender("demangled_names")?;
|
||||
let mut count = 0u64;
|
||||
|
||||
for (&addr, name) in labels {
|
||||
@@ -1206,7 +1209,7 @@ fn insert_demangled_from_labels(
|
||||
continue;
|
||||
}
|
||||
if let Some(d) = crate::demangle::demangle(name) {
|
||||
stmt.execute(params![
|
||||
app.append_row(params![
|
||||
addr as i64,
|
||||
d.mangled,
|
||||
d.raw_demangled,
|
||||
@@ -1226,7 +1229,7 @@ fn insert_demangled_from_labels(
|
||||
if let Some(name) = resolved
|
||||
&& let Some(d) = crate::demangle::demangle(name)
|
||||
{
|
||||
stmt.execute(params![
|
||||
app.append_row(params![
|
||||
imp.address as i64,
|
||||
d.mangled,
|
||||
d.raw_demangled,
|
||||
@@ -1240,6 +1243,7 @@ fn insert_demangled_from_labels(
|
||||
}
|
||||
}
|
||||
|
||||
app.flush()?;
|
||||
metrics::counter!("db.rows", "table" => "demangled_names").increment(count);
|
||||
tracing::info!(rows = count, table = "demangled_names", "demangler complete");
|
||||
Ok(())
|
||||
@@ -1252,14 +1256,15 @@ fn insert_pdata_entries(
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO pdata_entries
|
||||
(begin_address, end_address, function_length, prolog_length, flags)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
// Dedup by PK (begin_address), first-write-wins.
|
||||
let mut idx: HashMap<u32, usize> = HashMap::new();
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
idx.entry(e.begin_address).or_insert(i);
|
||||
}
|
||||
let mut app = conn.appender("pdata_entries")?;
|
||||
for &i in idx.values() {
|
||||
let e = &entries[i];
|
||||
app.append_row(params![
|
||||
e.begin_address as i64,
|
||||
e.end_address() as i64,
|
||||
e.function_length as i64,
|
||||
@@ -1267,6 +1272,7 @@ fn insert_pdata_entries(
|
||||
e.flags as i64,
|
||||
])?;
|
||||
}
|
||||
app.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1274,9 +1280,8 @@ fn insert_labels(
|
||||
conn: &Connection,
|
||||
labels: &HashMap<u32, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO labels (address, name, kind) VALUES (?, ?, ?) ON CONFLICT DO NOTHING"
|
||||
)?;
|
||||
// Source is a HashMap so addresses are unique by construction — no dedup needed.
|
||||
let mut app = conn.appender("labels")?;
|
||||
for (&addr, name) in labels {
|
||||
let kind = if name.starts_with("sub_") || name == "entry_point" {
|
||||
"function"
|
||||
@@ -1291,8 +1296,9 @@ fn insert_labels(
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
stmt.execute(params![addr as i64, name, kind])?;
|
||||
app.append_row(params![addr as i64, name, kind])?;
|
||||
}
|
||||
app.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,41 @@ enum Commands {
|
||||
/// line). Stdout when omitted.
|
||||
#[arg(long)]
|
||||
lr_trace_out: Option<String>,
|
||||
/// Phase A diff-harness — write schema-v1 JSONL events (kernel
|
||||
/// calls, thread create/exit, handle create/destroy, waits) to
|
||||
/// this path. Empty (default) = disabled, zero overhead.
|
||||
/// Schema: `xenia-rs/audit-runs/phase-a-diff-harness/schema-v1.md`.
|
||||
/// Settable via `XENIA_PHASE_A_EVENT_LOG`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
phase_a_event_log: Option<PathBuf>,
|
||||
/// Phase B initial-state snapshot — write structured state
|
||||
/// snapshot to `<dir>/ours/` at the moment immediately before
|
||||
/// the first guest PPC instruction of the XEX entry_point.
|
||||
/// Empty (default) = disabled, zero overhead. Settable via
|
||||
/// `XENIA_PHASE_B_SNAPSHOT_DIR`. See
|
||||
/// `xenia-rs/audit-runs/phase-b-state-equivalence/`.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
phase_b_snapshot_dir: Option<PathBuf>,
|
||||
/// Phase B: after writing the snapshot, exit the process
|
||||
/// immediately (`_exit(0)`) so re-runs are byte-deterministic.
|
||||
/// Settable via `XENIA_PHASE_B_SNAPSHOT_AND_EXIT`.
|
||||
#[arg(long)]
|
||||
phase_b_snapshot_and_exit: bool,
|
||||
/// Phase B: in memory.json, populate `section_contents` with raw
|
||||
/// bytes for every committed region. Default false. Settable via
|
||||
/// `XENIA_PHASE_B_DUMP_SECTION_CONTENT`.
|
||||
#[arg(long)]
|
||||
phase_b_dump_section_content: bool,
|
||||
/// review-a Step 1 diagnostic crowbar — when set, synthesises
|
||||
/// the 4 `sub_825070F0` worker spawns once instruction_count
|
||||
/// crosses the configured threshold (default 20M instr,
|
||||
/// override via `XENIA_CROWBAR_TRIGGER_INSTR=N`). **NOT a
|
||||
/// fix**: bypasses the natural-activation gate to test
|
||||
/// whether the workers function correctly IF activated. Off
|
||||
/// by default; settable via `XENIA_CROWBAR_WORKERS=1`. See
|
||||
/// `xenia-rs/audit-runs/review-a-step1-crowbar/`.
|
||||
#[arg(long)]
|
||||
force_spawn_workers: bool,
|
||||
},
|
||||
/// Browse XISO disc image contents
|
||||
Browse {
|
||||
@@ -405,7 +440,45 @@ fn main() -> Result<()> {
|
||||
probe_db,
|
||||
lr_trace,
|
||||
lr_trace_out,
|
||||
} => cmd_exec(
|
||||
phase_a_event_log,
|
||||
phase_b_snapshot_dir,
|
||||
phase_b_snapshot_and_exit,
|
||||
phase_b_dump_section_content,
|
||||
force_spawn_workers,
|
||||
} => {
|
||||
// review-a Step 1: CLI flag → env var so cmd_exec_inner's
|
||||
// existing env-var-driven cvar wire-up picks it up. Avoids
|
||||
// threading two more params through the (already long)
|
||||
// cmd_exec / cmd_exec_inner signatures.
|
||||
if force_spawn_workers {
|
||||
// SAFETY: pre-thread-spawn process startup; no races.
|
||||
unsafe { std::env::set_var("XENIA_CROWBAR_WORKERS", "1"); }
|
||||
}
|
||||
// Resolve the Phase A event-log path: CLI flag wins over env var.
|
||||
// Empty/unset → emitter stays disabled (zero overhead).
|
||||
let phase_a_path: Option<PathBuf> = phase_a_event_log
|
||||
.or_else(|| std::env::var("XENIA_PHASE_A_EVENT_LOG").ok().map(PathBuf::from));
|
||||
xenia_kernel::event_log::init(phase_a_path.as_deref());
|
||||
// Resolve Phase B flags: CLI > env var. Empty/unset = disabled.
|
||||
fn truthy(s: &str) -> bool {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
s == "1" || s == "true" || s == "yes"
|
||||
}
|
||||
let phase_b_dir: Option<PathBuf> = phase_b_snapshot_dir
|
||||
.or_else(|| std::env::var("XENIA_PHASE_B_SNAPSHOT_DIR").ok().map(PathBuf::from));
|
||||
let phase_b_exit = phase_b_snapshot_and_exit
|
||||
|| std::env::var("XENIA_PHASE_B_SNAPSHOT_AND_EXIT")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(truthy)
|
||||
.unwrap_or(false);
|
||||
let phase_b_dump = phase_b_dump_section_content
|
||||
|| std::env::var("XENIA_PHASE_B_DUMP_SECTION_CONTENT")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(truthy)
|
||||
.unwrap_or(false);
|
||||
cmd_exec(
|
||||
&path,
|
||||
max_instructions,
|
||||
ips_limit,
|
||||
@@ -431,7 +504,11 @@ fn main() -> Result<()> {
|
||||
probe_db.as_deref(),
|
||||
lr_trace.as_deref(),
|
||||
lr_trace_out.as_deref(),
|
||||
),
|
||||
phase_b_dir,
|
||||
phase_b_exit,
|
||||
phase_b_dump,
|
||||
)
|
||||
}
|
||||
Commands::Browse { path } => cmd_browse(&path),
|
||||
Commands::Info { path } => cmd_info(&path),
|
||||
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()),
|
||||
@@ -662,6 +739,9 @@ fn cmd_exec(
|
||||
probe_db: Option<&str>,
|
||||
lr_trace: Option<&str>,
|
||||
lr_trace_out: Option<&str>,
|
||||
phase_b_snapshot_dir: Option<PathBuf>,
|
||||
phase_b_snapshot_and_exit: bool,
|
||||
phase_b_dump_section_content: bool,
|
||||
) -> Result<()> {
|
||||
cmd_exec_inner(
|
||||
path,
|
||||
@@ -692,6 +772,9 @@ fn cmd_exec(
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
phase_b_snapshot_dir,
|
||||
phase_b_snapshot_and_exit,
|
||||
phase_b_dump_section_content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -738,6 +821,9 @@ fn cmd_check(
|
||||
out,
|
||||
expect,
|
||||
stable_digest,
|
||||
None, // phase_b_snapshot_dir — never wanted on check goldens
|
||||
false, // phase_b_snapshot_and_exit
|
||||
false, // phase_b_dump_section_content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -770,6 +856,9 @@ fn cmd_exec_inner(
|
||||
digest_out: Option<&str>,
|
||||
digest_expect: Option<&str>,
|
||||
stable_digest: bool,
|
||||
phase_b_snapshot_dir: Option<PathBuf>,
|
||||
phase_b_snapshot_and_exit: bool,
|
||||
phase_b_dump_section_content: bool,
|
||||
) -> Result<()> {
|
||||
let started = Instant::now();
|
||||
let data = load_xex_data(path)?;
|
||||
@@ -840,22 +929,121 @@ fn cmd_exec_inner(
|
||||
info!(thunks = thunk_map.len(), "import thunks mapped");
|
||||
|
||||
// ── Phase 2: CPU initialization per xenia-canary ─────────────────────
|
||||
// Allocate stack (1MB at 0x70000000)
|
||||
//
|
||||
// Stack VA = `0x70000000`, size honors `XEX_HEADER_DEFAULT_STACK_SIZE`
|
||||
// (key `0x00020200`) when present, falling back to 1 MiB. The XEX
|
||||
// header's stack-size value is rounded up to a 4 KiB page boundary
|
||||
// before allocation. NOTE: guard pages are NOT yet allocated — that
|
||||
// would require extending `xenia-memory` with a `NoAccess` protection
|
||||
// flag and platform-layer page-decommit support, deferred to a future
|
||||
// pass. Overflow into adjacent unmapped pages currently silently
|
||||
// drops the write (per `GuestMemory::write_u32`'s `is_mapped` guard).
|
||||
let stack_base = 0x7000_0000u32;
|
||||
let stack_size = 0x10_0000u32;
|
||||
let stack_size = {
|
||||
let from_header = xenia_xex::loader::get_stack_size(&header);
|
||||
let rounded = (from_header + 0xFFF) & !0xFFFu32;
|
||||
rounded.max(0x1_0000) // never less than 64 KiB
|
||||
};
|
||||
mem.alloc(stack_base, stack_size, rw)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to allocate stack: {}", e))?;
|
||||
|
||||
// Allocate PCR (Processor Control Region) and TLS
|
||||
// ── TLS region ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Canary's `XEX_HEADER_TLS_INFO` (key `0x00020104`) describes the title's
|
||||
// TLS template image (`raw_data_address` / `raw_data_size`) and the
|
||||
// number of dynamic slots (`slot_count`, default 1024 per canary's
|
||||
// `kDefaultTlsSlotCount` in `xthread.cc:335`). Layout in guest memory:
|
||||
//
|
||||
// [tls_extended_image (raw_data_size B) | tls_dynamic_slots (slot_count*4 B)]
|
||||
//
|
||||
// The PCR's `tls_ptr` (PCR+0) points at the START of the dynamic-slot
|
||||
// area — i.e. the dynamic slots live IMMEDIATELY AFTER the image bytes.
|
||||
// For ours we keep the historical fixed VA `0x7FFE_0000` but size the
|
||||
// region from the parsed TLS info (clamped to at least 4 KiB). When
|
||||
// the XEX has no TLS info, the block is a 4 KiB zero region — matching
|
||||
// the pre-Phase-2 behavior.
|
||||
let tls_info = xenia_xex::loader::get_tls_info(&header, &data);
|
||||
let tls_raw_data_size = tls_info.map(|t| t.raw_data_size).unwrap_or(0);
|
||||
let tls_slot_count = tls_info.map(|t| t.slot_count).unwrap_or(0).max(1024);
|
||||
let tls_dynamic_bytes = tls_slot_count.saturating_mul(4);
|
||||
let tls_total_bytes = {
|
||||
let needed = tls_raw_data_size.saturating_add(tls_dynamic_bytes);
|
||||
let rounded = (needed + 0xFFF) & !0xFFFu32;
|
||||
rounded.max(0x1000) // never less than 4 KiB
|
||||
};
|
||||
|
||||
let pcr_addr = 0x7FFF_0000u32;
|
||||
let tls_addr = 0x7FFE_0000u32;
|
||||
mem.alloc(pcr_addr, 0x1000, rw)?;
|
||||
mem.alloc(tls_addr, 0x1000, rw)?;
|
||||
mem.alloc(tls_addr, tls_total_bytes, rw)?;
|
||||
|
||||
// Initialize PCR structure
|
||||
mem.write_u32(pcr_addr, tls_addr); // PCR->tls_ptr
|
||||
mem.write_u32(pcr_addr + 0x100, 0x1000); // PCR->current_thread (fake)
|
||||
mem.write_u32(pcr_addr + 0x150, 0); // PCR->dpc_active
|
||||
// Copy the title's TLS template (initial-value image for `__declspec(thread)`
|
||||
// variables) into the head of the TLS region. Canary mirrors this with
|
||||
// `Memory::Copy(tls_static_address_, tls_header->raw_data_address,
|
||||
// tls_header->raw_data_size)` (xthread.cc:357-360). When
|
||||
// `raw_data_size` is zero (no TLS image), the region is left zeroed.
|
||||
if let Some(info) = tls_info {
|
||||
if info.raw_data_size > 0 && info.raw_data_address != 0 {
|
||||
let mut buf = vec![0u8; info.raw_data_size as usize];
|
||||
mem.read_bytes(info.raw_data_address, &mut buf);
|
||||
mem.write_bulk(tls_addr, &buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Guest X_KTHREAD struct ────────────────────────────────────────────
|
||||
//
|
||||
// Canary stores a real `X_KTHREAD` in guest memory (`xthread.h:260-`),
|
||||
// and PCR `[+0x100]` (= `prcb_data.current_thread`) holds its VA. Ours
|
||||
// previously wrote the bare host-side handle `0x1000` there, so any
|
||||
// guest pointer-walk through `r13[+0x100]` read garbage. Allocate a
|
||||
// 0x100-byte zero block at a fixed VA `0x7FFD_0000` (just below the
|
||||
// TLS region, in unused address space) and populate the minimum
|
||||
// credible fields:
|
||||
//
|
||||
// +0x00 X_DISPATCH_HEADER:
|
||||
// [+0x00] u8 type = 6 (ThreadObject)
|
||||
// [+0x04] u32 signal_state = 0
|
||||
// [+0x08] X_LIST_ENTRY wait_list { flink, blink } — both self-pointers
|
||||
// +0x5C u32 stack_base (high addr)
|
||||
// +0x60 u32 stack_limit (low addr)
|
||||
// +0x68 u32 tls_address
|
||||
//
|
||||
// The dispatcher-header `type` byte for ThreadObject is `0x06` in the
|
||||
// Vista/Xbox 360 kernel (matches DISPATCHER_HEADER reference at
|
||||
// `xenia-canary/src/xenia/kernel/xobject.h:37-62`); setting it non-zero
|
||||
// is what prevents the worst null-deref class on KTHREAD pointer walks.
|
||||
let kthread_addr = 0x7FFD_0000u32;
|
||||
let kthread_size = 0x1000u32;
|
||||
mem.alloc(kthread_addr, kthread_size, rw)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to allocate X_KTHREAD region: {}", e))?;
|
||||
// X_DISPATCH_HEADER
|
||||
mem.write_u8(kthread_addr, 0x06); // type = ThreadObject
|
||||
mem.write_u32(kthread_addr + 0x04, 0); // signal_state
|
||||
mem.write_u32(kthread_addr + 0x08, kthread_addr + 0x08); // wait_list.flink (self)
|
||||
mem.write_u32(kthread_addr + 0x0C, kthread_addr + 0x08); // wait_list.blink (self)
|
||||
// Stack/TLS pointers (canary X_KTHREAD layout, xthread.h:267-270).
|
||||
mem.write_u32(kthread_addr + 0x5C, stack_base + stack_size); // stack_base = high addr
|
||||
mem.write_u32(kthread_addr + 0x60, stack_base); // stack_limit = low addr
|
||||
mem.write_u32(kthread_addr + 0x68, tls_addr); // tls_address
|
||||
|
||||
// ── PCR initialization ────────────────────────────────────────────────
|
||||
//
|
||||
// Canary `X_KPCR` layout (xthread.h:171-223). Fields ours now populates:
|
||||
// +0x000 tls_ptr — base of dynamic TLS slots
|
||||
// +0x030 pcr_ptr u64 BE — self-reference (PCR base)
|
||||
// +0x070 stack_base_ptr — top of stack (high addr)
|
||||
// +0x074 stack_end_ptr — bottom of stack (low addr)
|
||||
// +0x100 prcb_data.current_thread — VA of the guest X_KTHREAD
|
||||
// +0x150 prcb_data.dpc_active — 0
|
||||
// +0x2A8 prcb — pointer back to prcb_data (= pcr+0x100)
|
||||
// (Skipped: +0x038 `host_stash` — host pointer slot, not applicable to ours.)
|
||||
mem.write_u32(pcr_addr, tls_addr); // tls_ptr
|
||||
mem.write_u64(pcr_addr + 0x030, pcr_addr as u64); // pcr_ptr (self-ref, BE u64)
|
||||
mem.write_u32(pcr_addr + 0x070, stack_base + stack_size); // stack_base_ptr (high)
|
||||
mem.write_u32(pcr_addr + 0x074, stack_base); // stack_end_ptr (low)
|
||||
mem.write_u32(pcr_addr + 0x100, kthread_addr); // prcb_data.current_thread
|
||||
mem.write_u32(pcr_addr + 0x150, 0); // prcb_data.dpc_active
|
||||
mem.write_u32(pcr_addr + 0x2A8, pcr_addr + 0x100); // prcb -> prcb_data
|
||||
|
||||
// Set up CPU context per xenia-canary/cpu/thread_state.cc.
|
||||
//
|
||||
@@ -925,6 +1113,13 @@ fn cmd_exec_inner(
|
||||
let mut kernel = xenia_kernel::KernelState::with_gpu(gpu_backend);
|
||||
kernel.image_base = base;
|
||||
kernel.xex_system_flags = xenia_xex::loader::get_system_flags(&header);
|
||||
// Phase B — install the entry-PC for the snapshot hook's identity
|
||||
// check, plus the cvar-equivalent flags resolved by the caller. When
|
||||
// `phase_b_snapshot_dir` is `None`, the hook short-circuits.
|
||||
kernel.entry_pc = entry;
|
||||
kernel.phase_b_snapshot_dir = phase_b_snapshot_dir.clone();
|
||||
kernel.phase_b_snapshot_and_exit = phase_b_snapshot_and_exit;
|
||||
kernel.phase_b_dump_section_content = phase_b_dump_section_content;
|
||||
// Drain the reverse thunk map into the kernel so `XexGetProcedureAddress`
|
||||
// can resolve ordinals back to callable thunk addresses.
|
||||
for (module, ordinal, addr) in thunk_addr_map.drain(..) {
|
||||
@@ -948,6 +1143,38 @@ fn cmd_exec_inner(
|
||||
});
|
||||
let parallel_active = parallel || parallel_via_env;
|
||||
kernel.parallel_active = parallel_active;
|
||||
|
||||
// Phase D Stage 3 — install a contention-replay manifest if pointed
|
||||
// to via `XENIA_CONTENTION_MANIFEST_PATH`. The manifest is built by
|
||||
// Stage 2's python tool from a Stage-1 cvar-ON canary trace. Unset
|
||||
// = default mode (no replay, identical to pre-Stage-3 behavior).
|
||||
// Errors are non-fatal (log + continue without replay) so a stale
|
||||
// path doesn't brick the run.
|
||||
if let Ok(path) = std::env::var("XENIA_CONTENTION_MANIFEST_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let p = std::path::PathBuf::from(trimmed);
|
||||
match xenia_kernel::contention_manifest::ContentionManifest::load_from_file(&p) {
|
||||
Ok(m) => {
|
||||
let count = m.initial_count();
|
||||
let arc = std::sync::Arc::new(m);
|
||||
kernel.install_contention_manifest(Some(arc));
|
||||
tracing::info!(
|
||||
"Phase D Stage 3: loaded contention manifest from {:?} ({} entries)",
|
||||
p,
|
||||
count
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Phase D Stage 3: failed to load contention manifest from {:?}: {} — replay disabled",
|
||||
p,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// AUDIT-032: default is `KernelState::xaudio_tick_enabled = true` now
|
||||
// that the dedicated worker eliminates HW-thread hijack regressions.
|
||||
// Treat `--xaudio-tick` / `XENIA_XAUDIO_TICK=...` as an explicit
|
||||
@@ -979,6 +1206,38 @@ fn cmd_exec_inner(
|
||||
"XAudio callback ticker enabled (AUDIT-032 default; toggle via --xaudio-tick / XENIA_XAUDIO_TICK)"
|
||||
);
|
||||
}
|
||||
// review-a Step 1 — `--force-spawn-workers` / `XENIA_CROWBAR_WORKERS=1`.
|
||||
// Diagnostic-only, default-OFF. See
|
||||
// `xenia-rs/audit-runs/review-a-step1-crowbar/`.
|
||||
let crowbar_env_on = std::env::var("XENIA_CROWBAR_WORKERS")
|
||||
.ok()
|
||||
.as_deref()
|
||||
.map(|v| {
|
||||
let v = v.trim().to_ascii_lowercase();
|
||||
v == "1" || v == "true" || v == "yes"
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if crowbar_env_on {
|
||||
kernel.crowbar_workers_enabled = true;
|
||||
}
|
||||
if let Ok(v) = std::env::var("XENIA_CROWBAR_TRIGGER_INSTR") {
|
||||
if let Ok(n) = v.trim().parse::<u64>() {
|
||||
kernel.crowbar_workers_trigger_instr = n;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"XENIA_CROWBAR_TRIGGER_INSTR={:?} — failed to parse as u64; keeping default {}",
|
||||
v,
|
||||
kernel.crowbar_workers_trigger_instr,
|
||||
);
|
||||
}
|
||||
}
|
||||
if kernel.crowbar_workers_enabled && !quiet {
|
||||
tracing::warn!(
|
||||
"review-a CROWBAR enabled: will force-spawn 4 sub_825070F0 workers \
|
||||
at instr={} (NOT a fix — diagnostic only)",
|
||||
kernel.crowbar_workers_trigger_instr,
|
||||
);
|
||||
}
|
||||
if reservations_table || reservations_via_env || parallel_active {
|
||||
kernel.reservations.enable();
|
||||
if !quiet {
|
||||
@@ -1305,14 +1564,47 @@ fn cmd_exec_inner(
|
||||
main_handle,
|
||||
&mut mem,
|
||||
);
|
||||
// Phase C+16: main thread self-reference. Mirrors canary's
|
||||
// `KernelState::LaunchModule` → `thread->Create()` → `RetainHandle()`
|
||||
// at xthread.cc:414 (the "main XThread" also goes through Create()).
|
||||
// Released at LR-sentinel implicit-exit in the prologue/epilogue
|
||||
// path. Without this, ours's main refcount=1 (creator only) vs
|
||||
// canary's 2 (creator + self) — masked at present because the guest
|
||||
// never calls `NtClose` on the main-thread handle, but kept symmetric
|
||||
// to avoid asymmetric `handle.destroy` on shutdown.
|
||||
kernel.retain_handle(main_handle);
|
||||
|
||||
// If the input was a disc image, mount it so the kernel's file I/O
|
||||
// handlers can serve the game's own assets via VFS.
|
||||
if path.to_lowercase().ends_with(".iso") || path.to_lowercase().ends_with(".xiso") {
|
||||
match xenia_vfs::disc_image::DiscImageDevice::open("d", std::path::Path::new(path)) {
|
||||
// Mount the title's content into the VFS so the kernel's file I/O
|
||||
// handlers (`NtCreateFile`, `NtOpenFile`, etc.) can serve game-data
|
||||
// reads. Canary always mounts `game:` + `d:` regardless of input
|
||||
// format (xenia_main.cc:611-651); ours's path normalisation already
|
||||
// strips both prefixes to a single bucket (see
|
||||
// `crate::path::DEVICE_PREFIXES` in xenia-kernel and `is_disc_prefix`
|
||||
// in exports.rs:1725), so a single backing `VfsDevice` covers both.
|
||||
//
|
||||
// Mount logic:
|
||||
// - `.iso` / `.xiso` → `DiscImageDevice`
|
||||
// - directory → `HostPathDevice` rooted at the directory
|
||||
// - bare .xex file → `HostPathDevice` rooted at the file's parent
|
||||
// - STFS / CON / PIRS containers — NOT YET (no reader in ours;
|
||||
// would be 500+ LOC. Deferred to a future pass.)
|
||||
let input_path = std::path::Path::new(path);
|
||||
let lower = path.to_lowercase();
|
||||
if lower.ends_with(".iso") || lower.ends_with(".xiso") {
|
||||
match xenia_vfs::disc_image::DiscImageDevice::open("d", input_path) {
|
||||
Ok(disc) => kernel.vfs = Some(Box::new(disc)),
|
||||
Err(e) => tracing::warn!("Could not mount disc image for VFS: {}", e),
|
||||
}
|
||||
} else if input_path.is_dir() {
|
||||
tracing::info!("VFS: mounting host directory {:?} as game:/d:", input_path);
|
||||
kernel.vfs = Some(Box::new(xenia_vfs::device::HostPathDevice::new("game", input_path)));
|
||||
} else if let Some(parent) = input_path.parent() {
|
||||
// Bare XEX file — mount its containing directory so the title can
|
||||
// reach sibling assets via `game:\<name>`.
|
||||
if !parent.as_os_str().is_empty() {
|
||||
tracing::info!("VFS: mounting XEX parent directory {:?} as game:/d:", parent);
|
||||
kernel.vfs = Some(Box::new(xenia_vfs::device::HostPathDevice::new("game", parent)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: Data export patching (variable imports) ─────────────────
|
||||
@@ -1324,14 +1616,80 @@ fn cmd_exec_inner(
|
||||
kernel.heap_alloc(size, mem).unwrap_or(0)
|
||||
};
|
||||
|
||||
// Helper: allocate a 0x1C-byte X_OBJECT_TYPE descriptor with the
|
||||
// four-char-code `pool_tag` at +0x18 (BE-readable) and write its
|
||||
// guest VA into the import slot. Mirrors canary's
|
||||
// `InitializeKernelGuestGlobals` populating per-type descriptors at
|
||||
// `kernel_state.cc:1538-1615` — the type-tag bytes are non-zero
|
||||
// there, so any guest code that reads the tag-byte field gets the
|
||||
// real FourCC instead of zero.
|
||||
let write_object_type =
|
||||
|addr: u32, pool_tag: u32, mem: &xenia_memory::GuestMemory, kernel: &mut xenia_kernel::KernelState| {
|
||||
let block = kernel.heap_alloc(0x1C, mem).unwrap_or(0);
|
||||
if block != 0 {
|
||||
mem.write_u32(block + 0x18, pool_tag);
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
};
|
||||
|
||||
for lib in &header.import_libraries {
|
||||
for imp in &lib.imports {
|
||||
if imp.record_type != 0 { continue; } // Only variable entries
|
||||
let addr = imp.address;
|
||||
match (lib.name.as_str(), imp.ordinal) {
|
||||
// ──── KernelGuestGlobals object-type descriptors ────
|
||||
// 0x1C-byte `X_OBJECT_TYPE` blocks with pool-tag FourCC at
|
||||
// +0x18. Canary populates these via
|
||||
// `InitializeKernelGuestGlobals` (`kernel_state.cc:1511+`);
|
||||
// ours previously left every descriptor as a zero block, so
|
||||
// any guest comparison against the tag-byte signature
|
||||
// diverged. Tags are stored as host-order u32s whose BE
|
||||
// byte-form spells the four-char code; `write_u32` BE-encodes
|
||||
// automatically (see `GuestMemory::write_u32`).
|
||||
("xboxkrnl.exe", 0x000E) => {
|
||||
// ExEventObjectType — pool_tag "EvEv"
|
||||
write_object_type(addr, 0x76657645, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0012) => {
|
||||
// ExMutantObjectType — pool_tag "Mutu"
|
||||
write_object_type(addr, 0x6174754D, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0017) => {
|
||||
// ExSemaphoreObjectType — pool_tag "Sema"
|
||||
write_object_type(addr, 0x616D6553, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x001B) => {
|
||||
// ExThreadObjectType — ptr to OBJECT_TYPE descriptor (0x40 bytes)
|
||||
let block = alloc_zero(0x40, &mut mem, &mut kernel);
|
||||
// ExThreadObjectType — pool_tag "Thre"
|
||||
write_object_type(addr, 0x65726854, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x001C) => {
|
||||
// ExTimerObjectType — pool_tag "Time"
|
||||
write_object_type(addr, 0x656D6954, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0036) => {
|
||||
// IoCompletionObjectType — pool_tag "Comp"
|
||||
write_object_type(addr, 0x706D6F43, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x003A) => {
|
||||
// IoDeviceObjectType — pool_tag "Devi"
|
||||
write_object_type(addr, 0x69766544, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x003E) => {
|
||||
// IoFileObjectType — pool_tag "File"
|
||||
write_object_type(addr, 0x656C6946, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0106) => {
|
||||
// ObDirectoryObjectType — pool_tag "Dire"
|
||||
write_object_type(addr, 0x65726944, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0112) => {
|
||||
// ObSymbolicLinkObjectType — pool_tag "Symb"
|
||||
write_object_type(addr, 0x626D7953, &mut mem, &mut kernel);
|
||||
}
|
||||
("xboxkrnl.exe", 0x02DB) => {
|
||||
// UsbdBootEnumerationDoneEvent — 0x10-byte X_KEVENT block,
|
||||
// zero-initialised (signalled=false, type=NotificationEvent).
|
||||
let block = alloc_zero(0x10, &mut mem, &mut kernel);
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0059) => {
|
||||
@@ -1340,16 +1698,51 @@ fn cmd_exec_inner(
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x00AD) => {
|
||||
// KeTimeStampBundle — 0x18 block with FILETIME at +0 and
|
||||
// interrupt-time u64 at +0x10. Mirrors the clock used by
|
||||
// KeQuerySystemTime so fast-path readers see consistent values.
|
||||
// KeTimeStampBundle — 0x18-byte `X_TIME_STAMP_BUNDLE`
|
||||
// matching canary's `kernel_state.h:98-104`:
|
||||
// +0x00 u64 interrupt_time (BE, 100-ns ticks since boot)
|
||||
// +0x08 u64 system_time (BE, 100-ns Windows FILETIME)
|
||||
// +0x10 u32 tick_count (BE, monotonic ms since boot)
|
||||
// +0x14 u32 padding
|
||||
// Stash the VA in `KernelState` so the 1 ms host-side
|
||||
// repeating updater spawned later in this file can refresh
|
||||
// the fields — without that updater, polling loops that
|
||||
// wait on `tick_count` to advance hang forever (the
|
||||
// previous "FILETIME at +0 and +0x10" layout never wrote
|
||||
// +0x08 at all and never advanced).
|
||||
let block = alloc_zero(0x18, &mut mem, &mut kernel);
|
||||
if block != 0 {
|
||||
let fake_time: u64 = 132_500_000_000_000_000; // ~2021 FILETIME
|
||||
mem.write_u32(block, (fake_time >> 32) as u32);
|
||||
mem.write_u32(block + 4, fake_time as u32);
|
||||
mem.write_u32(block + 0x10, (fake_time >> 32) as u32);
|
||||
mem.write_u32(block + 0x14, fake_time as u32);
|
||||
// Match ours's existing fixed `KeQueryInterruptTime`
|
||||
// / `KeQuerySystemTime` constants for the initial
|
||||
// sample — the timer thread will overwrite within
|
||||
// ~1 ms, so these values are seen only briefly.
|
||||
mem.write_u64(block, 0x0000_0001_0000_0000); // interrupt_time
|
||||
mem.write_u64(block + 0x08, 132_500_000_000_000_000); // system_time
|
||||
mem.write_u32(block + 0x10, 0); // tick_count
|
||||
mem.write_u32(block + 0x14, 0); // padding
|
||||
kernel.ke_timestamp_bundle_ptr = block;
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x000C) => {
|
||||
// ExConsoleGameRegion — 4-byte u32 = 0xFFFFFFFF (region-free).
|
||||
// Canary writes this at `xboxkrnl_module.cc:144-150`.
|
||||
let block = alloc_zero(4, &mut mem, &mut kernel);
|
||||
if block != 0 {
|
||||
mem.write_u32(block, 0xFFFF_FFFF);
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0156) => {
|
||||
// XboxHardwareInfo — 16-byte block. Canary
|
||||
// (`xboxkrnl_module.cc:125-142`) writes `[0]=0x20`
|
||||
// (HDD-present flag, bit 5) and `[4]=0x06` (CPU count),
|
||||
// rest zero. Games branch on these for storage- and
|
||||
// SMP-aware code paths.
|
||||
let block = alloc_zero(16, &mut mem, &mut kernel);
|
||||
if block != 0 {
|
||||
mem.write_u8(block, 0x20);
|
||||
mem.write_u8(block + 4, 0x06);
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
@@ -1361,13 +1754,59 @@ fn cmd_exec_inner(
|
||||
mem.write_u16(addr + 6, 0);
|
||||
}
|
||||
("xboxkrnl.exe", 0x0193) => {
|
||||
// XexExecutableModuleHandle -> image base
|
||||
// XexExecutableModuleHandle: keep the pre-existing
|
||||
// `*XexExecutableModuleHandle = base` write (the
|
||||
// game's CRT branches off this slot's value; an
|
||||
// attempt to wire up a proper LDR_DATA_TABLE_ENTRY +
|
||||
// xex_header_base chain at idx=0 short-circuits the
|
||||
// CRT's early RtlImageXexHeaderField probe, causing
|
||||
// Phase A to diverge at idx=0 instead of growing past
|
||||
// 102014 — see Phase C+3 investigation.md). Instead,
|
||||
// allocate a guest-memory copy of the raw XEX header
|
||||
// bytes (mirrors canary `user_module.cc:223-227`'s
|
||||
// `guest_xex_header_`), record its VA in KernelState
|
||||
// for `rtl_image_xex_header_field` to use as a
|
||||
// fallback when the game passes a NULL `xex_header`
|
||||
// arg (which it does here because the LDR walk
|
||||
// through `base` yields PE OptionalHeader bytes, not
|
||||
// a real header pointer).
|
||||
let header_size = header.header_size as usize;
|
||||
if header_size > 0 && header_size <= data.len() {
|
||||
let xex_va = alloc_zero(header.header_size, &mut mem, &mut kernel);
|
||||
if xex_va != 0 {
|
||||
mem.write_bulk(xex_va, &data[0..header_size]);
|
||||
kernel.xex_header_guest_ptr = xex_va;
|
||||
}
|
||||
}
|
||||
mem.write_u32(addr, base);
|
||||
}
|
||||
("xboxkrnl.exe", 0x01AE) => {
|
||||
// ExLoadedCommandLine — ANSI empty string
|
||||
let block = alloc_zero(0x10, &mut mem, &mut kernel);
|
||||
// Block is already zero-initialized by heap_alloc -> empty string.
|
||||
// ExLoadedCommandLine — 1024-byte ANSI buffer.
|
||||
// Canary's default-init path (`xboxkrnl_module.cc:176-194`)
|
||||
// writes the quoted form `"default.xex"` (with literal
|
||||
// ASCII double-quotes) as a placeholder until post-launch
|
||||
// replacement. An empty zero-block silently violates the
|
||||
// CRT contract (any title that scans for the quote
|
||||
// characters sees end-of-string immediately).
|
||||
let block = alloc_zero(1024, &mut mem, &mut kernel);
|
||||
if block != 0 {
|
||||
let cmdline: &[u8] = b"\"default.xex\"\0";
|
||||
mem.write_bulk(block, cmdline);
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x01AF) => {
|
||||
// ExLoadedImageName — 256-byte ANSI buffer. Canary
|
||||
// (`xboxkrnl_module.cc:166-174`,
|
||||
// `kernel_state.cc:486-495`) post-launch fills this with
|
||||
// the executable module path; for ours we write
|
||||
// "default.xex\0" to match canary's pre-launch state.
|
||||
// Size matches canary's `kExLoadedImageNameSize = 256`.
|
||||
let block = alloc_zero(256, &mut mem, &mut kernel);
|
||||
if block != 0 {
|
||||
let imgname: &[u8] = b"default.xex\0";
|
||||
mem.write_bulk(block, imgname);
|
||||
}
|
||||
mem.write_u32(addr, block);
|
||||
}
|
||||
("xboxkrnl.exe", 0x01BE) => {
|
||||
@@ -1501,6 +1940,44 @@ fn cmd_exec_inner(
|
||||
// responsibility per the trait contract.)
|
||||
let mem_arc = std::sync::Arc::new(mem);
|
||||
|
||||
// ── KeTimeStampBundle 1 ms repeating updater ──
|
||||
//
|
||||
// Canary maintains the bundle's `interrupt_time` / `system_time` /
|
||||
// `tick_count` fields via `HighResolutionTimer::CreateRepeating(1 ms,
|
||||
// UpdateKeTimestampBundle)` registered at `kernel_state.cc:1272-1295`.
|
||||
// Without an equivalent host-side ticker, the bundle stays frozen at its
|
||||
// boot-time values and guest polling loops that wait on `tick_count` to
|
||||
// advance hang forever. Spawn a detached thread that wakes every 1 ms,
|
||||
// recomputes the three fields from a monotonic `Instant`, and writes
|
||||
// them BE through the shared `Arc<GuestMemory>`. Cooperative shutdown
|
||||
// via the existing `shutdown_arc` flag — flipped when the dispatch
|
||||
// returns — so test runs don't leak threads. No-op if the patcher
|
||||
// didn't allocate a bundle (the XEX never imported ord 0x00AD).
|
||||
{
|
||||
let ke_bundle_ptr = kernel.ke_timestamp_bundle_ptr;
|
||||
if ke_bundle_ptr != 0 {
|
||||
let mem_for_timer = mem_arc.clone();
|
||||
let shutdown_for_timer = shutdown_arc.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ke-timestamp-bundle".to_string())
|
||||
.spawn(move || {
|
||||
use xenia_memory::MemoryAccess;
|
||||
let start = std::time::Instant::now();
|
||||
const SYSTEM_TIME_EPOCH: u64 = 132_500_000_000_000_000;
|
||||
while !shutdown_for_timer.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
let elapsed = start.elapsed();
|
||||
let ms = elapsed.as_millis() as u32;
|
||||
let ticks_100ns = (elapsed.as_micros() as u64) * 10;
|
||||
mem_for_timer.write_u64(ke_bundle_ptr, ticks_100ns);
|
||||
mem_for_timer.write_u64(ke_bundle_ptr + 0x08, SYSTEM_TIME_EPOCH + ticks_100ns);
|
||||
mem_for_timer.write_u32(ke_bundle_ptr + 0x10, ms);
|
||||
}
|
||||
})
|
||||
.expect("spawn ke-timestamp-bundle thread");
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the real GPU worker if the threaded backend was chosen at
|
||||
// kernel-construction time. The handle the kernel already holds
|
||||
// (`GpuBackend::Threaded`) is the CPU-side proxy; the worker owns
|
||||
@@ -1679,6 +2156,11 @@ fn cmd_exec_inner(
|
||||
}
|
||||
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
|
||||
dump_thread_diagnostic(&kernel, &*mem_arc, quiet);
|
||||
// Iterate 2.M — always-on (reading-error #42). Emits a
|
||||
// sibling JSON next to the Phase-A trace; runs regardless
|
||||
// of --quiet so future "is the wedge moved?" questions
|
||||
// never depend on a manual non-quiet re-run.
|
||||
write_thread_state_dump(&kernel);
|
||||
info!(
|
||||
wall_ms = started.elapsed().as_millis() as u64,
|
||||
instructions = stats.instruction_count,
|
||||
@@ -1896,6 +2378,7 @@ enum RoundCtl {
|
||||
/// asks for shutdown.
|
||||
fn coord_pre_round(
|
||||
kernel: &mut xenia_kernel::KernelState,
|
||||
mem: &xenia_memory::GuestMemory,
|
||||
stats: &ExecStats,
|
||||
max_instructions: Option<u64>,
|
||||
ips_limit: Option<u64>,
|
||||
@@ -1995,6 +2478,12 @@ fn coord_pre_round(
|
||||
try_inject_audio_callback(kernel);
|
||||
}
|
||||
|
||||
// review-a Step 1 — one-shot diagnostic crowbar. No-op when disabled
|
||||
// or already fired. Uses the caller's `&GuestMemory` directly.
|
||||
if kernel.crowbar_workers_enabled && !kernel.crowbar_workers_fired {
|
||||
kernel.try_fire_crowbar_workers(mem, stats.instruction_count);
|
||||
}
|
||||
|
||||
RoundCtl::Continue
|
||||
}
|
||||
|
||||
@@ -2211,6 +2700,21 @@ fn worker_prologue(
|
||||
|
||||
let pc = kernel.scheduler.ctx(hw_id).pc;
|
||||
|
||||
// Phase B snapshot — no-op when `phase_b_snapshot_dir == None`
|
||||
// (zero-cost Option-tag test on the hot path). Fires once on the
|
||||
// entry thread at the moment immediately before its first guest
|
||||
// instruction at entry_pc executes. See
|
||||
// crates/xenia-kernel/src/phase_b_snapshot.rs.
|
||||
if kernel.phase_b_snapshot_dir.is_some() {
|
||||
let current_tid = kernel.scheduler.tid(hw_id).unwrap_or(0);
|
||||
xenia_kernel::phase_b_snapshot::fire_if_entry_thread(
|
||||
kernel,
|
||||
mem,
|
||||
pc,
|
||||
current_tid,
|
||||
);
|
||||
}
|
||||
|
||||
// 0) Diagnostic ctor-probe: if `pc` is in
|
||||
// `kernel.ctor_probe_pcs`, capture live r3/lr/sp + back-chain
|
||||
// and println one record. Read-only; lockstep digest unaffected.
|
||||
@@ -2267,19 +2771,41 @@ fn worker_prologue(
|
||||
cycle = stats.instruction_count,
|
||||
"HW thread returned to LR sentinel — marking exited"
|
||||
);
|
||||
// Phase C+15-α: schema-v1 `thread.exit` event on the implicit
|
||||
// (LR-sentinel) thread-exit path. Symmetric with
|
||||
// `ex_terminate_thread`; canary's `XThread::Execute` ends in
|
||||
// `Exit()` which emits the same event regardless of whether the
|
||||
// guest called `ExTerminateThread` or simply returned.
|
||||
if let (Some(t), true) = (tid, xenia_kernel::event_log::is_enabled()) {
|
||||
let cycle = kernel.scheduler.ctx(hw_id).timebase;
|
||||
xenia_kernel::event_log::emit_thread_exit(t, cycle, 0);
|
||||
}
|
||||
let (_, _exited_tid, handle_opt) = kernel.scheduler.exit_current(0);
|
||||
if let Some(h) = handle_opt
|
||||
&& let Some(xenia_kernel::objects::KernelObject::Thread {
|
||||
if let Some(h) = handle_opt {
|
||||
if let Some(xenia_kernel::objects::KernelObject::Thread {
|
||||
exit_code,
|
||||
waiters,
|
||||
..
|
||||
}) = kernel.objects.get_mut(&h)
|
||||
{
|
||||
*exit_code = Some(0);
|
||||
let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters);
|
||||
for w in to_wake {
|
||||
kernel.scheduler.wake_ref(w);
|
||||
{
|
||||
*exit_code = Some(0);
|
||||
let to_wake: Vec<xenia_cpu::ThreadRef> = std::mem::take(waiters);
|
||||
for w in to_wake {
|
||||
kernel.scheduler.wake_ref(w);
|
||||
}
|
||||
}
|
||||
// Phase C+16: release the thread self-reference (paired with
|
||||
// the retain installed at spawn time by `ex_create_thread` /
|
||||
// `xam_task_schedule`). On the main thread (INITIAL_GUEST_TID)
|
||||
// no retain was installed by `install_initial_thread`, so the
|
||||
// refcount stays at 1 (creator-only). Pre-condition: a
|
||||
// self-retained thread has refcount ≥ 2 at this point; an
|
||||
// un-retained thread (main) has refcount = 1. We unconditionally
|
||||
// call `release_handle` — for main, this destroys it (which is
|
||||
// fine; main is exiting). For workers, this drops the
|
||||
// self-ref; if guest still holds a ref (no NtClose yet) the
|
||||
// object survives; if guest already closed, this destroys.
|
||||
kernel.release_handle(h);
|
||||
}
|
||||
return PrologueOutcome::Continue;
|
||||
}
|
||||
@@ -2329,10 +2855,32 @@ fn worker_prologue(
|
||||
|
||||
// 3) Unmapped PC.
|
||||
if !mem.is_mapped(pc) {
|
||||
// Crowbar v2 — enrich fault with tid/lr/r3 so we can attribute the
|
||||
// fault back to a specific guest thread. Read-only, no behaviour
|
||||
// change. The kernel lock is held by the caller per
|
||||
// run_execution's invariant; tid/ctx lookups are safe.
|
||||
let tid = kernel.scheduler.tid(hw_id);
|
||||
let r = kernel.scheduler.current_ref();
|
||||
let t = kernel.scheduler.thread(r);
|
||||
let lr = t.ctx.lr;
|
||||
let r3 = t.ctx.gpr[3];
|
||||
let r4 = t.ctx.gpr[4];
|
||||
let r29 = t.ctx.gpr[29];
|
||||
let r30 = t.ctx.gpr[30];
|
||||
let r31 = t.ctx.gpr[31];
|
||||
let ctr = t.ctx.ctr;
|
||||
tracing::error!(
|
||||
cycle = stats.instruction_count,
|
||||
pc = format_args!("{:#010x}", pc),
|
||||
hw_id,
|
||||
tid = ?tid,
|
||||
lr = format_args!("{:#010x}", lr),
|
||||
ctr = format_args!("{:#010x}", ctr),
|
||||
r3 = format_args!("{:#010x}", r3),
|
||||
r4 = format_args!("{:#010x}", r4),
|
||||
r29 = format_args!("{:#010x}", r29),
|
||||
r30 = format_args!("{:#010x}", r30),
|
||||
r31 = format_args!("{:#010x}", r31),
|
||||
"FAULT: PC in unmapped memory"
|
||||
);
|
||||
return PrologueOutcome::BreakOuter;
|
||||
@@ -2603,6 +3151,7 @@ fn run_execution(
|
||||
// without duplicating it from the lockstep path.
|
||||
match coord_pre_round(
|
||||
kernel,
|
||||
mem,
|
||||
&stats,
|
||||
max_instructions,
|
||||
ips_limit,
|
||||
@@ -3005,6 +3554,7 @@ fn run_execution_parallel(
|
||||
let s = stats_mtx.lock().expect("stats mutex poisoned");
|
||||
coord_pre_round(
|
||||
&mut *guard,
|
||||
mem,
|
||||
&*s,
|
||||
max_instructions,
|
||||
ips_limit,
|
||||
@@ -3907,6 +4457,131 @@ fn dump_thread_diagnostic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate 2.M — always-on structured exit-state dump (reading-error #42).
|
||||
///
|
||||
/// Phase-A's JSONL trace is blind to blocked-forever waits: a wait that
|
||||
/// never returns emits no `kernel.return` event, so a wedge looks identical
|
||||
/// to a clean termination. Iterate 2.J misclassified the wedge that way and
|
||||
/// cost iterate 2.K a re-dispatch to recover. This dumps a machine-readable
|
||||
/// snapshot of every alive thread + the handle/wedge map at exit time,
|
||||
/// regardless of `--quiet`, so every future iterate has the wedge in hand
|
||||
/// alongside the JSONL trace without needing a manual diagnostic re-run.
|
||||
///
|
||||
/// Output: `<phase-A-trace-dir>/exit-thread-state.json` when Phase-A is
|
||||
/// enabled; `./exit-thread-state.json` (CWD) otherwise. Filename is
|
||||
/// predictable — the harness can `glob('**/exit-thread-state.json')`.
|
||||
fn write_thread_state_dump(kernel: &xenia_kernel::KernelState) {
|
||||
use serde_json::{json, Value};
|
||||
use xenia_kernel::objects::KernelObject;
|
||||
let dump_path: std::path::PathBuf = xenia_kernel::event_log::output_path()
|
||||
.and_then(|p| p.parent().map(|d| d.join("exit-thread-state.json")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("exit-thread-state.json"));
|
||||
let tid_of = |r: &xenia_cpu::ThreadRef| -> u32 {
|
||||
kernel.scheduler.slots.get(r.hw_id as usize)
|
||||
.and_then(|s| s.runqueue.get(r.idx as usize)).map(|t| t.tid).unwrap_or(0)
|
||||
};
|
||||
// Returns (type_name, signaler_tid_if_known, full json payload).
|
||||
let handle_meta = |h: u32| -> (&'static str, Option<u32>, Value) {
|
||||
let waiters_v = |w: &Vec<xenia_cpu::ThreadRef>| -> Value {
|
||||
json!(w.iter().map(&tid_of).collect::<Vec<_>>())
|
||||
};
|
||||
match kernel.objects.get(&h) {
|
||||
Some(KernelObject::Event { signaled, manual_reset, waiters }) => ("Event", None,
|
||||
json!({"type":"Event","signaled":signaled,"manual_reset":manual_reset,"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::Semaphore { count, max, waiters }) => ("Semaphore", None,
|
||||
json!({"type":"Semaphore","count":count,"max":max,"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::Thread { id, exit_code, waiters, .. }) => ("Thread", Some(*id),
|
||||
json!({"type":"Thread","thread_id":id,"exited":exit_code.is_some(),"exit_code":exit_code,"signaler_tid_if_known":id,"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::Timer { signaled, deadline, waiters, .. }) => ("Timer", None,
|
||||
json!({"type":"Timer","signaled":signaled,"deadline":deadline,"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::Mutex { owner, recursion, waiters }) => ("Mutex", None,
|
||||
json!({"type":"Mutex","owner_hw":owner,"recursion":recursion,"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::NotifyListener { mask, queue, waiters, .. }) => ("NotifyListener", None,
|
||||
json!({"type":"NotifyListener","mask":format!("{:#x}",mask),"pending":queue.len(),"waiters_tid":waiters_v(waiters)})),
|
||||
Some(KernelObject::File { path, .. }) => ("File", None, json!({"type":"File","path":path})),
|
||||
None => ("unknown", None, json!({"type":"unknown_or_dropped"})),
|
||||
}
|
||||
};
|
||||
let mut alive: Vec<Value> = Vec::new();
|
||||
let mut wedge_map: Vec<Value> = Vec::new();
|
||||
for (hw_id, slot) in kernel.scheduler.slots.iter().enumerate() {
|
||||
for (idx, t) in slot.runqueue.iter().enumerate() {
|
||||
let (state_name, block_payload): (&'static str, Value) = match &t.state {
|
||||
xenia_cpu::HwState::Idle => ("Idle", Value::Null),
|
||||
xenia_cpu::HwState::Ready => ("Ready", Value::Null),
|
||||
xenia_cpu::HwState::Exited(code) => ("Exited", json!({"exit_code":code})),
|
||||
xenia_cpu::HwState::ServicingIrq(_) => ("ServicingIrq", Value::Null),
|
||||
xenia_cpu::HwState::Blocked(reason) => {
|
||||
let body = match reason {
|
||||
xenia_cpu::BlockReason::Suspended => json!({"kind":"Suspended"}),
|
||||
xenia_cpu::BlockReason::DelayUntil(d) => json!({"kind":"DelayUntil","deadline_ns":d}),
|
||||
xenia_cpu::BlockReason::CriticalSection(cs) =>
|
||||
json!({"kind":"CriticalSection","cs_ptr":format!("{:#010x}",cs)}),
|
||||
xenia_cpu::BlockReason::WaitAny { handles, deadline }
|
||||
| xenia_cpu::BlockReason::WaitAll { handles, deadline } => {
|
||||
let kind = if matches!(reason, xenia_cpu::BlockReason::WaitAny{..}) {"WaitAny"} else {"WaitAll"};
|
||||
let hs: Vec<Value> = handles.iter().map(|h| {
|
||||
let (ty, sig_tid, meta) = handle_meta(*h);
|
||||
// Wedge-map: surface every blocked-forever
|
||||
// wait (deadline==None) with a one-line
|
||||
// human summary + structured cross-ref so
|
||||
// future iterates can diff vs canary.
|
||||
if deadline.is_none() {
|
||||
let summary = match ty {
|
||||
"Thread" => format!("tid={} → Thread(id={})", t.tid, sig_tid.unwrap_or(0)),
|
||||
"Event" => format!("tid={} → Event(sig={})", t.tid,
|
||||
meta.get("signaled").and_then(|v|v.as_bool()).unwrap_or(false)),
|
||||
"Semaphore" => format!("tid={} → Semaphore({}/{})", t.tid,
|
||||
meta.get("count").and_then(|v|v.as_i64()).unwrap_or(0),
|
||||
meta.get("max").and_then(|v|v.as_i64()).unwrap_or(0)),
|
||||
_ => format!("tid={} → handle {:#010x} ({})", t.tid, h, ty),
|
||||
};
|
||||
wedge_map.push(json!({
|
||||
"waiter_tid": t.tid,
|
||||
"waiter_pc": format!("{:#010x}", t.ctx.pc),
|
||||
"handle": format!("{:#010x}", h),
|
||||
"handle_type": ty,
|
||||
"signaler_tid_if_known": sig_tid,
|
||||
"summary": summary,
|
||||
}));
|
||||
}
|
||||
json!({"handle":format!("{:#010x}",h),"object":meta})
|
||||
}).collect();
|
||||
json!({"kind":kind,"handles":hs,"deadline_ns_or_inf":deadline})
|
||||
}
|
||||
};
|
||||
("Blocked", body)
|
||||
}
|
||||
};
|
||||
alive.push(json!({
|
||||
"tid": t.tid, "hw_id": hw_id, "idx": idx,
|
||||
"pc": format!("{:#010x}", t.ctx.pc),
|
||||
"lr": format!("{:#010x}", t.ctx.lr as u32),
|
||||
"sp": format!("{:#010x}", t.ctx.gpr[1] as u32),
|
||||
"priority": t.priority,
|
||||
"affinity_mask": format!("{:#04x}", t.affinity_mask),
|
||||
"suspend_count": t.suspend_count,
|
||||
"state": state_name,
|
||||
"block_reason": block_payload,
|
||||
}));
|
||||
}
|
||||
}
|
||||
let dump = json!({
|
||||
"schema_version": 1, "produced_by": "ours", "reason": "exit_dump",
|
||||
"alive_threads": alive, "wedge_map": wedge_map,
|
||||
});
|
||||
match serde_json::to_string_pretty(&dump) {
|
||||
Ok(s) => match std::fs::write(&dump_path, s) {
|
||||
Ok(()) => eprintln!(
|
||||
"exit-thread-state: wrote {} thread(s), {} wedge entr(ies) to {}",
|
||||
alive.len(), wedge_map.len(), dump_path.display(),
|
||||
),
|
||||
Err(e) => eprintln!("exit-thread-state: failed to write {}: {e}", dump_path.display()),
|
||||
},
|
||||
Err(e) => eprintln!("exit-thread-state: failed to serialize: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(skip_all, fields(title))]
|
||||
fn run_with_ui(
|
||||
@@ -3974,6 +4649,8 @@ fn run_with_ui(
|
||||
|
||||
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
|
||||
dump_thread_diagnostic(&kernel, &mem, quiet);
|
||||
// Iterate 2.M — see cmd_exec_inner path for rationale.
|
||||
write_thread_state_dump(&kernel);
|
||||
info!(
|
||||
wall_ms = started.elapsed().as_millis() as u64,
|
||||
instructions = stats.instruction_count,
|
||||
|
||||
@@ -117,17 +117,27 @@ fn execute(ctx: &mut PpcContext, mem: &dyn MemoryAccess, instr: &DecodedInstr) -
|
||||
ctx.pc += 4;
|
||||
}
|
||||
PpcOpcode::addis => {
|
||||
// Xbox 360 user mode is 32-bit ABI (MSR.SF=0), so addis must
|
||||
// produce a value whose upper 32 bits don't pollute downstream
|
||||
// 64-bit arithmetic. The PPC ISA in 64-bit mode sign-extends
|
||||
// simm16 before the shift, producing 0xFFFFFFFF_xxxx0000 for
|
||||
// negative simm16 (high bit set). When this value flows into
|
||||
// a 64-bit subfc against a zero-extended lwz value, the unsigned
|
||||
// 64-bit comparison yields wrong CA. Truncate to 32 bits to
|
||||
// simulate 32-bit ABI behavior.
|
||||
let ra_val = if instr.ra() == 0 { 0u64 } else { ctx.gpr[instr.ra()] };
|
||||
let result = ra_val.wrapping_add((instr.simm16() as i64 as u64) << 16);
|
||||
ctx.gpr[instr.rd()] = result as u32 as u64;
|
||||
// Phase C+23: `addis` (and the `lis` simplified mnemonic) must
|
||||
// sign-extend the shifted immediate to the full 64 bits before
|
||||
// storing into the GPR, matching canary's HIR emitter
|
||||
// (`InstrEmit_addis` in `ppc_emit_alu.cc`: `EXTS16(SI) << 16`
|
||||
// as a 64-bit constant). Game code commonly builds a negative
|
||||
// 32-bit value via `lis rN, 0xFFFB; ori rN, rN, 0x6C20`
|
||||
// (yielding the i32 -300,000 for a 30ms `KeWait` timeout) and
|
||||
// then stores it as a 64-bit doubleword via `std`. Without
|
||||
// sign extension the high half on the wire was 0x00000000,
|
||||
// turning the timeout into a positive ~4.3-billion-tick
|
||||
// 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;
|
||||
}
|
||||
PpcOpcode::addic => {
|
||||
@@ -4934,6 +4944,92 @@ mod tests {
|
||||
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]
|
||||
fn test_lwz_stw() {
|
||||
let mut ctx = PpcContext::new();
|
||||
|
||||
@@ -42,6 +42,19 @@ pub const QUANTUM_DEFAULT: u32 = 50_000;
|
||||
/// gets one when the slot fills up.
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// thread; on zero, slot rotates within same-priority tier.
|
||||
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 {
|
||||
@@ -136,6 +157,7 @@ impl GuestThread {
|
||||
affinity_mask: 0xFF,
|
||||
ideal_processor: None,
|
||||
quantum_remaining: QUANTUM_DEFAULT,
|
||||
last_run_round: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,14 +228,23 @@ impl Default for HwSlot {
|
||||
}
|
||||
|
||||
impl HwSlot {
|
||||
/// Index of the highest-priority Ready/ServicingIrq thread in this
|
||||
/// slot's runqueue. Tiebreak: prefer lower index (deterministic).
|
||||
pub fn pick_runnable(&self) -> Option<usize> {
|
||||
/// Index of the highest *effective* priority Ready/ServicingIrq
|
||||
/// thread in this slot's runqueue. Effective priority = base priority
|
||||
/// + 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
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
pub enum OrderMode {
|
||||
Fixed,
|
||||
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 {
|
||||
@@ -244,6 +296,14 @@ impl OrderMode {
|
||||
.unwrap_or(0xC0FFEE_C0FFEE);
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -369,7 +429,7 @@ impl Scheduler {
|
||||
pub fn new() -> Self {
|
||||
let order = OrderMode::from_env();
|
||||
let rng_state = match order {
|
||||
OrderMode::Fixed => 0,
|
||||
OrderMode::Fixed | OrderMode::ScanQuantum { .. } => 0,
|
||||
OrderMode::Seeded { seed } => seed.max(1),
|
||||
};
|
||||
Scheduler {
|
||||
@@ -379,7 +439,15 @@ impl Scheduler {
|
||||
order,
|
||||
rng_state,
|
||||
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,
|
||||
rotation_cursor: 0,
|
||||
reservation_table: None,
|
||||
@@ -614,6 +682,13 @@ impl Scheduler {
|
||||
t.priority = params.priority;
|
||||
t.affinity_mask = mask;
|
||||
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
|
||||
// so the interpreter can route lwarx/stwcx through the table.
|
||||
t.ctx.hw_id = slot_id;
|
||||
@@ -663,6 +738,11 @@ impl Scheduler {
|
||||
t.pcr_base = pcr_base;
|
||||
t.tls_base = tls_base;
|
||||
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);
|
||||
mem.write_pcr_id(pcr_base, 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
|
||||
/// highest-priority Ready thread on the slot, sets `running_idx`, and
|
||||
/// 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) {
|
||||
let now_round = self.round_count;
|
||||
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
|
||||
.running_idx
|
||||
.map(|idx| ThreadRef::new(hw_id, idx as u16));
|
||||
@@ -765,6 +853,18 @@ impl Scheduler {
|
||||
///
|
||||
/// Returns `true` if a rotation occurred (purely informational;
|
||||
/// 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 {
|
||||
let Some(r) = self.current else { return false; };
|
||||
let slot = &mut self.slots[r.hw_id as usize];
|
||||
@@ -778,7 +878,7 @@ impl Scheduler {
|
||||
return false;
|
||||
}
|
||||
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.
|
||||
// Priority-higher peers are already going to win the next
|
||||
// `pick_runnable` on this slot, so we only need to find an *equal*
|
||||
@@ -795,6 +895,9 @@ impl Scheduler {
|
||||
}
|
||||
let cand = &slot.runqueue[i];
|
||||
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);
|
||||
self.current = Some(ThreadRef::new(r.hw_id, i as u16));
|
||||
return true;
|
||||
@@ -846,7 +949,7 @@ impl Scheduler {
|
||||
_ => return,
|
||||
}
|
||||
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.recompute_slot_runnable(r.hw_id);
|
||||
}
|
||||
@@ -868,7 +971,7 @@ impl Scheduler {
|
||||
}
|
||||
if t.suspend_count == 0 && matches!(t.state, HwState::Blocked(BlockReason::Suspended)) {
|
||||
t.state = HwState::Ready;
|
||||
t.quantum_remaining = QUANTUM_DEFAULT;
|
||||
t.quantum_remaining = Self::quantum_for(self.order);
|
||||
}
|
||||
self.recompute_slot_runnable(r.hw_id);
|
||||
prev
|
||||
@@ -1121,7 +1224,7 @@ impl Scheduler {
|
||||
BlockReason::Suspended
|
||||
}
|
||||
};
|
||||
t.quantum_remaining = QUANTUM_DEFAULT;
|
||||
t.quantum_remaining = Self::quantum_for(self.order);
|
||||
self.recompute_slot_runnable(r.hw_id);
|
||||
tracing::info!(
|
||||
"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.
|
||||
pub fn unblock_on_deadlock(&mut self) -> Vec<ThreadRef> {
|
||||
let mut woken = Vec::new();
|
||||
let quantum = Self::quantum_for(self.order);
|
||||
for (hw_id, slot) in self.slots.iter_mut().enumerate() {
|
||||
for (idx, t) in slot.runqueue.iter_mut().enumerate() {
|
||||
if matches!(
|
||||
@@ -1191,7 +1295,7 @@ impl Scheduler {
|
||||
| HwState::Blocked(BlockReason::CriticalSection(_))
|
||||
) {
|
||||
t.state = HwState::Ready;
|
||||
t.quantum_remaining = QUANTUM_DEFAULT;
|
||||
t.quantum_remaining = quantum;
|
||||
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).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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +339,23 @@ pub struct GpuSystem {
|
||||
/// `GpuSystem::new` and lives for the whole GPU lifetime — no
|
||||
/// per-frame churn.
|
||||
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 {
|
||||
@@ -365,9 +382,47 @@ impl GpuSystem {
|
||||
last_resolve: None,
|
||||
texture_cache: crate::texture_cache::TextureCache::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
|
||||
/// 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
|
||||
|
||||
@@ -390,7 +390,17 @@ impl GpuBackend {
|
||||
// fires; the safety-net fallback warning fired twice for
|
||||
// each Sylpheed run.
|
||||
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) => {
|
||||
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
|
||||
// returns `Idle`/`Blocked`.
|
||||
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) {
|
||||
if Instant::now() >= deadline {
|
||||
break;
|
||||
|
||||
@@ -15,3 +15,7 @@ tracing = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
libc = "0.2"
|
||||
|
||||
342
crates/xenia-kernel/src/contention_manifest.rs
Normal file
342
crates/xenia-kernel/src/contention_manifest.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
774
crates/xenia-kernel/src/event_log.rs
Normal file
774
crates/xenia-kernel/src/event_log.rs
Normal 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
@@ -1,8 +1,11 @@
|
||||
pub mod audit;
|
||||
pub mod contention_manifest;
|
||||
pub mod event_log;
|
||||
pub mod exports;
|
||||
pub mod interrupts;
|
||||
pub mod objects;
|
||||
pub mod path;
|
||||
pub mod phase_b_snapshot;
|
||||
pub mod state;
|
||||
pub mod thread;
|
||||
pub mod ui_bridge;
|
||||
|
||||
@@ -109,4 +109,20 @@ impl KernelObject {
|
||||
KernelObject::File { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C+15-α: schema-v1 object-type code (see schema-v1.md
|
||||
/// `Object type codes` table). Used by `event_log::semantic_id` to
|
||||
/// compute cross-engine handle identity. Both engines must agree on
|
||||
/// this mapping.
|
||||
pub fn schema_object_type(&self) -> u32 {
|
||||
match self {
|
||||
KernelObject::Event { .. } => crate::event_log::object_type::EVENT,
|
||||
KernelObject::Mutex { .. } => crate::event_log::object_type::MUTANT,
|
||||
KernelObject::Semaphore { .. } => crate::event_log::object_type::SEMAPHORE,
|
||||
KernelObject::Timer { .. } => crate::event_log::object_type::TIMER,
|
||||
KernelObject::Thread { .. } => crate::event_log::object_type::THREAD,
|
||||
KernelObject::File { .. } => crate::event_log::object_type::FILE,
|
||||
KernelObject::NotifyListener { .. } => crate::event_log::object_type::NOTIFICATION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,48 @@ pub fn object_attributes_to_vfs_path(mem: &GuestMemory, obj_attrs_ptr: u32) -> O
|
||||
Some(normalize_path(&raw))
|
||||
}
|
||||
|
||||
/// Phase C+10 schema-v1 extension helper: read the OBJECT_ATTRIBUTES
|
||||
/// struct at `obj_attrs_ptr` and return the **raw** path string (trimmed
|
||||
/// of leading/trailing whitespace, NO prefix-strip / case-fold). The
|
||||
/// emitter wants the exact bytes the guest passed so the Phase A diff
|
||||
/// surfaces upstream divergences (e.g. canary calls with one prefix /
|
||||
/// ours with another) rather than masking them via normalization.
|
||||
pub fn object_attributes_raw_name(mem: &GuestMemory, obj_attrs_ptr: u32) -> Option<String> {
|
||||
let raw = read_object_attributes_name(mem, obj_attrs_ptr)?;
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(raw.trim().to_string())
|
||||
}
|
||||
|
||||
/// Phase C+11 schema-v1 extension helper: read the rename target
|
||||
/// path from a `NtSetInformationFile` class-10 (`XFileRenameInformation`)
|
||||
/// info buffer. Returns the raw (un-normalized) path string for emitter
|
||||
/// use; null when the buffer is too small or the inner ANSI_STRING is
|
||||
/// empty.
|
||||
///
|
||||
/// Layout per canary `info/file.h:79-83`:
|
||||
/// offset 0: be<u32> replace_existing
|
||||
/// offset 4: be<u32> root_dir_handle
|
||||
/// offset 8: X_ANSI_STRING (u16 Length, u16 MaximumLength, u32 Buffer)
|
||||
/// (16 bytes total — caller is expected to check `info_length >= 16`
|
||||
/// before invoking.)
|
||||
pub fn file_rename_information_raw_target(
|
||||
mem: &GuestMemory,
|
||||
info_ptr: u32,
|
||||
info_length: u32,
|
||||
) -> Option<String> {
|
||||
if info_ptr == 0 || info_length < 16 {
|
||||
return None;
|
||||
}
|
||||
// The ANSI_STRING lives at offset 8 inside the rename-info struct.
|
||||
let raw = read_ansi_string(mem, info_ptr + 8)?;
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(raw.trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
849
crates/xenia-kernel/src/phase_b_snapshot.rs
Normal file
849
crates/xenia-kernel/src/phase_b_snapshot.rs
Normal 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 ®ions {
|
||||
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
@@ -30,7 +30,7 @@ pub fn register_exports(state: &mut KernelState) {
|
||||
|
||||
// Task
|
||||
state.register_export(Xam, 0x01AF, "XamTaskSchedule", xam_task_schedule);
|
||||
state.register_export(Xam, 0x01B1, "XamTaskCloseHandle", stub_success);
|
||||
state.register_export(Xam, 0x01B1, "XamTaskCloseHandle", xam_task_close_handle);
|
||||
state.register_export(Xam, 0x01B3, "XamTaskShouldExit", stub_return_zero);
|
||||
|
||||
// Alloc
|
||||
@@ -56,7 +56,7 @@ pub fn register_exports(state: &mut KernelState) {
|
||||
state.register_export(Xam, 0x0258, "XamContentCreate", stub_success);
|
||||
state.register_export(Xam, 0x025A, "XamContentClose", stub_success);
|
||||
state.register_export(Xam, 0x025B, "XamContentDelete", stub_success);
|
||||
state.register_export(Xam, 0x025C, "XamContentCreateEnumerator", stub_success);
|
||||
state.register_export(Xam, 0x025C, "XamContentCreateEnumerator", xam_content_create_enumerator);
|
||||
state.register_export(Xam, 0x025E, "XamContentGetDeviceData", stub_success);
|
||||
state.register_export(Xam, 0x025F, "XamContentGetDeviceName", stub_success);
|
||||
state.register_export(Xam, 0x0260, "XamContentSetThumbnail", stub_success);
|
||||
@@ -80,7 +80,10 @@ pub fn register_exports(state: &mut KernelState) {
|
||||
state.register_export(Xam, 0x02BC, "XamShowSigninUI", stub_success);
|
||||
state.register_export(Xam, 0x02C1, "XamShowKeyboardUI", stub_success);
|
||||
state.register_export(Xam, 0x02CB, "XamShowDeviceSelectorUI", stub_success);
|
||||
state.register_export(Xam, 0x02D5, "XamShowGamerCardUIForXUID", stub_success);
|
||||
// Class-E in canary (table entry only, no DECLARE_XAM_EXPORT shim) — canary's
|
||||
// syscall-thunk path emits no Phase A events. Mirror via
|
||||
// `register_unimplemented_export` so ours stays silent too. C+6.5-pattern fix.
|
||||
state.register_unimplemented_export(Xam, 0x02D5, "XamShowGamerCardUIForXUID", stub_success);
|
||||
state.register_export(Xam, 0x02D9, "XamShowDirtyDiscErrorUI", stub_success);
|
||||
state.register_export(Xam, 0x02DC, "XamShowMessageBoxUIEx", stub_success);
|
||||
|
||||
@@ -262,6 +265,13 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
|
||||
if let Some(KernelObject::Thread { hw_id: slot, .. }) = state.objects.get_mut(&handle) {
|
||||
*slot = Some(hw_id);
|
||||
}
|
||||
// Phase C+16: thread self-reference. See `ex_create_thread`.
|
||||
// The canary path `XamTaskSchedule_entry` → `thread->Create()`
|
||||
// → `RetainHandle()` (xthread.cc:414) installs this; without
|
||||
// it, `XamTaskCloseHandle` → `NtClose` destroys the handle
|
||||
// prematurely. This is the exact Phase A idx=102168 fix on
|
||||
// the main chain.
|
||||
state.retain_handle(handle);
|
||||
if handle_ptr != 0 {
|
||||
mem.write_u32(handle_ptr, handle);
|
||||
}
|
||||
@@ -284,6 +294,41 @@ fn xam_task_schedule(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut Kernel
|
||||
}
|
||||
}
|
||||
|
||||
/// `XamTaskCloseHandle(handle)` — release the handle minted by
|
||||
/// `XamTaskSchedule`. Mirrors xenia-canary's `XamTaskCloseHandle_entry`
|
||||
/// (xam_task.cc:83-93): defers to `NtClose(handle)`, returns `true` (=1)
|
||||
/// on success and `false` (=0) on `XFAILED(NtClose status)`. Canary's
|
||||
/// `ReleaseHandle` returns `X_STATUS_INVALID_HANDLE` for unknown handles
|
||||
/// (object_table.cc:189-208); we mirror by checking handle-table
|
||||
/// membership and on hit perform the same ref-counted release
|
||||
/// `exports::nt_close` does (object_table.cc:194-208). Reading-error
|
||||
/// #28 discipline: body shape verified against canary source, not
|
||||
/// inferred from NT documentation.
|
||||
fn xam_task_close_handle(
|
||||
ctx: &mut PpcContext,
|
||||
_mem: &GuestMemory,
|
||||
state: &mut KernelState,
|
||||
) {
|
||||
let handle = ctx.gpr[3] as u32;
|
||||
// Phase C+19: validate against the canonical slot (alias-aware) so a
|
||||
// duplicated thread-task handle still passes the XFAILED check.
|
||||
let canonical = state.resolve_handle(handle);
|
||||
if !state.objects.contains_key(&canonical) && !state.handle_refcount.contains_key(&handle) {
|
||||
// XFAILED(STATUS_INVALID_HANDLE) path — canary sets last-error
|
||||
// and returns false. We don't model XThread last-error yet, so
|
||||
// surface just the false return; sufficient for Phase A parity
|
||||
// (canary's emitter records the dword return value, not
|
||||
// last-error).
|
||||
ctx.gpr[3] = 0;
|
||||
return;
|
||||
}
|
||||
// Phase C+19: route through the shared close path so the alias map,
|
||||
// slot count, async-file side-table, and handle.destroy event are all
|
||||
// handled symmetrically with `nt_close`.
|
||||
crate::exports::close_handle_internal(state, handle);
|
||||
ctx.gpr[3] = 1;
|
||||
}
|
||||
|
||||
// ===== Alloc =====
|
||||
|
||||
fn xam_alloc(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||
@@ -306,20 +351,52 @@ fn xam_alloc(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||
|
||||
// ===== User =====
|
||||
|
||||
/// Canary default profile XUID — `0xB13EBABEBABEBABE` per
|
||||
/// `xenia-canary/src/xenia/kernel/xam/user_profile.cc` defaults. The
|
||||
/// `0xC000000000000000` mask bits tag the XUID as a local profile, which
|
||||
/// is what title 58410A1F probes via `xuid & 0x00C0000000000000` (per the
|
||||
/// comment in `UserProfile::UserProfile`). Used by `XamUserGetXUID` /
|
||||
/// `XamUserGetSigninInfo` / friends so signed-in slot 0 reports a real
|
||||
/// id instead of zero.
|
||||
pub const DEFAULT_USER_XUID: u64 = 0xB13E_BABE_BABE_BABE;
|
||||
|
||||
/// Canary default gamertag.
|
||||
pub const DEFAULT_USER_GAMERTAG: &str = "User";
|
||||
|
||||
fn xam_user_get_xuid(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
|
||||
// r3 = user_index, r4 = xuid_ptr
|
||||
let user_index = ctx.gpr[3] as u32;
|
||||
let xuid_ptr = ctx.gpr[4] as u32;
|
||||
let xuid = if user_index == 0 { DEFAULT_USER_XUID } else { 0 };
|
||||
if xuid_ptr != 0 {
|
||||
mem.write_u64(xuid_ptr, 0); // No XUID
|
||||
mem.write_u64(xuid_ptr, xuid);
|
||||
}
|
||||
ctx.gpr[3] = 0;
|
||||
}
|
||||
|
||||
fn xam_user_get_name(ctx: &mut PpcContext, mem: &GuestMemory, _state: &mut KernelState) {
|
||||
// r3 = user_index, r4 = buffer, r5 = buffer_size
|
||||
let user_index = ctx.gpr[3] as u32;
|
||||
let buffer = ctx.gpr[4] as u32;
|
||||
if buffer != 0 {
|
||||
mem.write_u8(buffer, 0); // Empty string
|
||||
let buffer_size = ctx.gpr[5] as u32;
|
||||
if buffer == 0 || buffer_size == 0 {
|
||||
ctx.gpr[3] = 0;
|
||||
return;
|
||||
}
|
||||
if user_index == 0 {
|
||||
// Write the canary default gamertag, NUL-terminated, truncated to
|
||||
// fit `buffer_size`. Canary returns the gamertag from the active
|
||||
// profile; ours uses the fixed default.
|
||||
let bytes = DEFAULT_USER_GAMERTAG.as_bytes();
|
||||
let n = (bytes.len() as u32).min(buffer_size.saturating_sub(1));
|
||||
for i in 0..n {
|
||||
mem.write_u8(buffer + i, bytes[i as usize]);
|
||||
}
|
||||
mem.write_u8(buffer + n, 0);
|
||||
} else {
|
||||
// No profile in slots 1-3; canary's `XamUserGetName` returns
|
||||
// empty string in that case.
|
||||
mem.write_u8(buffer, 0);
|
||||
}
|
||||
ctx.gpr[3] = 0;
|
||||
}
|
||||
@@ -335,6 +412,104 @@ fn xam_user_get_signin_state(ctx: &mut PpcContext, _mem: &GuestMemory, _state: &
|
||||
ctx.gpr[3] = if user_index == 0 { 1 } else { 0 };
|
||||
}
|
||||
|
||||
// ===== Content =====
|
||||
|
||||
/// `XamContentCreateEnumerator(user_index, device_id, content_type,
|
||||
/// content_flags, items_per_enumerate, buffer_size_ptr, handle_out)`.
|
||||
/// Mirrors xenia-canary `XamContentCreateEnumerator_entry`
|
||||
/// (xam_content.cc:129-220). Reading-error #28 discipline applied: body
|
||||
/// shape verified against canary source.
|
||||
///
|
||||
/// Canary's normal-path success returns `X_ERROR_SUCCESS` (0) with a
|
||||
/// fresh enumerator handle in `*handle_out`. The Phase A oracle at
|
||||
/// `tid_event_idx=102197` shows canary returning `X_ERROR_NO_SUCH_USER`
|
||||
/// (`0x525`, 1317) with empty side_effects — the call hit the
|
||||
/// `if (!user) return X_ERROR_NO_SUCH_USER;` early-return at
|
||||
/// xam_content.cc:153-155 because no profile is installed in canary's
|
||||
/// default `--mute=true` config (no `--profile_slot_*` flags).
|
||||
///
|
||||
/// Ours has no profile-manager state, so all `user_index != 0xFE`
|
||||
/// queries miss. Mirror the early-return: write `*buffer_size_ptr` per
|
||||
/// canary line 145-147 (which executes *before* the user check) and
|
||||
/// return `X_ERROR_NO_SUCH_USER`. Implementing real content enumeration
|
||||
/// is an XAM-content-subsystem session (escalation-tier scope), not
|
||||
/// this fix.
|
||||
///
|
||||
/// Side note on internal consistency: ours's `xam_user_get_signin_state`
|
||||
/// returns 1 for `user_index == 0`, conflicting with the "no profile"
|
||||
/// model used here. That divergence surfaces later in the Phase A trace
|
||||
/// (idx 107996+) and is a separate fix — deferred per single-fix
|
||||
/// discipline.
|
||||
fn xam_content_create_enumerator(
|
||||
ctx: &mut PpcContext,
|
||||
mem: &GuestMemory,
|
||||
_state: &mut KernelState,
|
||||
) {
|
||||
const X_USER_INDEX_NONE: u32 = 0xFE;
|
||||
const X_USER_INDEX_LATEST: u32 = 0xFD;
|
||||
const X_USER_MAX_USER_COUNT: u32 = 4;
|
||||
const X_E_INVALIDARG: u32 = 0x8007_0057;
|
||||
const X_ERROR_NO_SUCH_USER: u32 = 0x0000_0525;
|
||||
const X_ERROR_SUCCESS: u32 = 0;
|
||||
const X_CONTENT_DATA_SIZE: u32 = 0x134;
|
||||
|
||||
let user_index = ctx.gpr[3] as u32;
|
||||
let device_id = ctx.gpr[4] as u32;
|
||||
let _content_type = ctx.gpr[5] as u32;
|
||||
let _content_flags = ctx.gpr[6] as u32;
|
||||
let items_per_enumerate = ctx.gpr[7] as u32;
|
||||
let buffer_size_ptr = ctx.gpr[8] as u32;
|
||||
let handle_out = ctx.gpr[9] as u32;
|
||||
|
||||
// Canary xam_content.cc:135-143 — `device_id != 0 && device_info ==
|
||||
// nullptr` OR `!handle_out` → X_E_INVALIDARG, with
|
||||
// `*buffer_size_ptr = 0` if non-null. Ours's `GetDummyDeviceInfo`
|
||||
// accepts HDD (1) and ODD (2) per dummy_device_info.cc. For
|
||||
// simplicity (and because Sylpheed exercises `device_id == 0` on
|
||||
// first call per canary trace), accept device_id ∈ {0, 1, 2}; reject
|
||||
// larger.
|
||||
let device_unknown = device_id != 0 && device_id > 2;
|
||||
if device_unknown || handle_out == 0 {
|
||||
if buffer_size_ptr != 0 {
|
||||
mem.write_u32(buffer_size_ptr, 0);
|
||||
}
|
||||
ctx.gpr[3] = X_E_INVALIDARG as u64;
|
||||
return;
|
||||
}
|
||||
|
||||
// Canary line 145-147 — written *before* the user-profile check.
|
||||
if buffer_size_ptr != 0 {
|
||||
mem.write_u32(buffer_size_ptr, X_CONTENT_DATA_SIZE.wrapping_mul(items_per_enumerate));
|
||||
}
|
||||
|
||||
// Canary line 150-158 — `if (user_index != XUserIndexNone) { user =
|
||||
// GetUserProfile(user_index); if (!user) return X_ERROR_NO_SUCH_USER;
|
||||
// xuid = user->xuid(); }`. Ours has no profile manager, so any
|
||||
// `user_index != 0xFE` misses. Also reject indices ≥ 4 (canary's
|
||||
// GetUserProfile out-of-range path returns nullptr, falling into
|
||||
// the same branch). `XUserIndexLatest` (0xFD) is special-cased in
|
||||
// canary's GetUserProfile but still produces nullptr without a
|
||||
// profile installed.
|
||||
if user_index != X_USER_INDEX_NONE {
|
||||
let out_of_range = user_index >= X_USER_MAX_USER_COUNT
|
||||
&& user_index != X_USER_INDEX_LATEST;
|
||||
let _ = out_of_range; // documentation only — both branches → no user
|
||||
ctx.gpr[3] = X_ERROR_NO_SUCH_USER as u64;
|
||||
return;
|
||||
}
|
||||
|
||||
// user_index == XUserIndexNone: canary skips profile lookup and
|
||||
// proceeds to enumerator creation. With no installed content the
|
||||
// enumerator init succeeds and `*handle_out` receives a fresh
|
||||
// handle. We don't have an XEnumerator object model yet; return
|
||||
// success with handle_out=0 as a stub. Defensive: never exercised
|
||||
// in the current Phase A oracle (canary fires user_index!=0xFE).
|
||||
if handle_out != 0 {
|
||||
mem.write_u32(handle_out, 0);
|
||||
}
|
||||
ctx.gpr[3] = X_ERROR_SUCCESS as u64;
|
||||
}
|
||||
|
||||
// ===== System =====
|
||||
|
||||
fn xam_get_execution_id(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||
@@ -424,7 +599,8 @@ fn xam_notify_create_listener(ctx: &mut PpcContext, mem: &GuestMemory, state: &m
|
||||
}
|
||||
|
||||
fn xnotify_get_next(ctx: &mut PpcContext, mem: &GuestMemory, state: &mut KernelState) {
|
||||
let handle = ctx.gpr[3] as u32;
|
||||
// Phase C+19: canonicalize dup ids → source.
|
||||
let handle = state.resolve_handle(ctx.gpr[3] as u32);
|
||||
let match_id = ctx.gpr[4] as u32;
|
||||
let id_ptr = ctx.gpr[5] as u32;
|
||||
let param_ptr = ctx.gpr[6] as u32;
|
||||
@@ -578,6 +754,206 @@ mod tests {
|
||||
assert_eq!(ctx.gpr[3], 8);
|
||||
}
|
||||
|
||||
/// XamTaskCloseHandle on a valid Thread handle must release the
|
||||
/// object (ref-counted) and return 1, matching canary's
|
||||
/// `XamTaskCloseHandle_entry` (xam_task.cc:83-93) which delegates
|
||||
/// to `NtClose` and returns `true` on `XSUCCESS`.
|
||||
#[test]
|
||||
fn xam_task_close_handle_valid_handle_returns_one_and_releases() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let handle = state.alloc_handle_for(KernelObject::Event {
|
||||
manual_reset: true,
|
||||
signaled: false,
|
||||
waiters: Vec::new(),
|
||||
});
|
||||
// alloc_handle_for is expected to install a refcount of 1.
|
||||
assert!(
|
||||
state.objects.contains_key(&handle),
|
||||
"fresh handle should be in object table"
|
||||
);
|
||||
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
|
||||
assert_eq!(
|
||||
ctx.gpr[3], 1,
|
||||
"valid handle close must return 1 (canary parity, xam_task.cc:92)"
|
||||
);
|
||||
assert!(
|
||||
!state.objects.contains_key(&handle),
|
||||
"object must be dropped when refcount hits zero"
|
||||
);
|
||||
assert!(
|
||||
!state.handle_refcount.contains_key(&handle),
|
||||
"refcount entry must be scrubbed"
|
||||
);
|
||||
}
|
||||
|
||||
/// XamTaskCloseHandle on an unknown handle must return 0 (false),
|
||||
/// matching canary's `XFAILED(NtClose)` branch returning `false`
|
||||
/// after `XThread::SetLastError(rtl_dos_error)`.
|
||||
#[test]
|
||||
fn xam_task_close_handle_invalid_handle_returns_zero() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
ctx.gpr[3] = 0xDEAD_BEEFu64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(
|
||||
ctx.gpr[3], 0,
|
||||
"invalid handle close must return 0 (canary parity, xam_task.cc:89)"
|
||||
);
|
||||
}
|
||||
|
||||
/// XamTaskCloseHandle with a duplicated (refcounted) handle must
|
||||
/// keep the object alive after one close and drop it after two.
|
||||
/// Mirrors canary's `ObjectTable::ReleaseHandle`
|
||||
/// (object_table.cc:200-208).
|
||||
#[test]
|
||||
fn xam_task_close_handle_respects_refcount() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let handle = state.alloc_handle_for(KernelObject::Event {
|
||||
manual_reset: false,
|
||||
signaled: false,
|
||||
waiters: Vec::new(),
|
||||
});
|
||||
// Bump refcount to simulate NtDuplicateObject aliasing.
|
||||
*state.handle_refcount.entry(handle).or_insert(1) += 1;
|
||||
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 1, "first close returns 1");
|
||||
assert!(
|
||||
state.objects.contains_key(&handle),
|
||||
"object must survive first close (refcount > 0)"
|
||||
);
|
||||
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 1, "second close also returns 1");
|
||||
assert!(
|
||||
!state.objects.contains_key(&handle),
|
||||
"object must be dropped after second close (refcount == 0)"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end parity: spawn an XAM task with `xam_task_schedule`,
|
||||
/// then close the resulting handle via `xam_task_close_handle`.
|
||||
/// This is the exact dataflow Sylpheed exercises at Phase A
|
||||
/// `tid_event_idx=102156..102158` on the main chain.
|
||||
///
|
||||
/// Phase C+16: After C+16, `xam_task_schedule` installs a thread
|
||||
/// self-reference (refcount=2 post-spawn), so the user's NtClose
|
||||
/// (via XamTaskCloseHandle) only drops to refcount=1; the handle
|
||||
/// survives until the spawned thread exits. This mirrors canary's
|
||||
/// `XThread::Create` → `RetainHandle()` → `XThread::Exit` →
|
||||
/// `ReleaseHandle()` lifecycle (xthread.cc:414/524).
|
||||
#[test]
|
||||
fn xam_task_schedule_then_close_round_trip_returns_one() {
|
||||
let (mut ctx, mut mem, mut state) = fresh();
|
||||
let callback_pc: u32 = 0x824a_93c8;
|
||||
let message_ptr: u32 = SCRATCH_BASE + 0x100;
|
||||
let handle_out: u32 = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = callback_pc as u64;
|
||||
ctx.gpr[4] = message_ptr as u64;
|
||||
ctx.gpr[5] = 0;
|
||||
ctx.gpr[6] = handle_out as u64;
|
||||
ctx.lr = 0x824a_9a14;
|
||||
xam_task_schedule(&mut ctx, &mut mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 0, "schedule succeeded");
|
||||
|
||||
let handle = mem.read_u32(handle_out);
|
||||
// Phase C+16: post-spawn refcount must be 2 (creator + self-ref).
|
||||
assert_eq!(
|
||||
state.handle_refcount.get(&handle).copied(),
|
||||
Some(2),
|
||||
"post-spawn refcount must include the thread self-reference"
|
||||
);
|
||||
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(
|
||||
ctx.gpr[3], 1,
|
||||
"schedule→close round-trip must return 1 (Phase A idx=102158 parity)"
|
||||
);
|
||||
// Phase C+16: after close, the self-ref still holds the object.
|
||||
assert!(
|
||||
state.objects.contains_key(&handle),
|
||||
"object must survive XamTaskCloseHandle because the spawned thread holds a self-ref"
|
||||
);
|
||||
assert_eq!(
|
||||
state.handle_refcount.get(&handle).copied(),
|
||||
Some(1),
|
||||
"post-close refcount = self-ref only (canary XThread::Create::RetainHandle parity)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase C+16: refcount lifecycle balance test.
|
||||
/// Schedule task → close handle (refcount 2→1) → simulate thread
|
||||
/// exit by calling `release_handle` (refcount 1→0). After both:
|
||||
/// object destroyed, refcount entry scrubbed. Mirrors canary's
|
||||
/// `XamTaskCloseHandle`→`NtClose`+`XThread::Exit::ReleaseHandle`.
|
||||
#[test]
|
||||
fn xam_task_schedule_close_then_thread_exit_destroys_handle() {
|
||||
let (mut ctx, mut mem, mut state) = fresh();
|
||||
let callback_pc: u32 = 0x824a_93c8;
|
||||
let message_ptr: u32 = SCRATCH_BASE + 0x100;
|
||||
let handle_out: u32 = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = callback_pc as u64;
|
||||
ctx.gpr[4] = message_ptr as u64;
|
||||
ctx.gpr[5] = 0;
|
||||
ctx.gpr[6] = handle_out as u64;
|
||||
ctx.lr = 0x824a_9a14;
|
||||
xam_task_schedule(&mut ctx, &mut mem, &mut state);
|
||||
let handle = mem.read_u32(handle_out);
|
||||
|
||||
// User closes the handle (refcount 2→1, object survives).
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert!(state.objects.contains_key(&handle));
|
||||
|
||||
// Simulate thread exit releasing the self-ref (refcount 1→0).
|
||||
let destroyed = state.release_handle(handle);
|
||||
assert!(destroyed, "release_handle must return true on final ref drop");
|
||||
assert!(
|
||||
!state.objects.contains_key(&handle),
|
||||
"object must be destroyed once both user-ref and self-ref are released"
|
||||
);
|
||||
assert!(
|
||||
!state.handle_refcount.contains_key(&handle),
|
||||
"refcount entry must be scrubbed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase C+16: thread-exit-before-close ordering. Tests the reverse
|
||||
/// of the prior case — thread exits first (self-ref released), user
|
||||
/// then closes (creator-ref released → destroy). Both orderings
|
||||
/// must converge on a clean destroy with no double-free.
|
||||
#[test]
|
||||
fn xam_task_thread_exit_then_close_destroys_handle() {
|
||||
let (mut ctx, mut mem, mut state) = fresh();
|
||||
let callback_pc: u32 = 0x824a_93c8;
|
||||
let message_ptr: u32 = SCRATCH_BASE + 0x100;
|
||||
let handle_out: u32 = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = callback_pc as u64;
|
||||
ctx.gpr[4] = message_ptr as u64;
|
||||
ctx.gpr[5] = 0;
|
||||
ctx.gpr[6] = handle_out as u64;
|
||||
ctx.lr = 0x824a_9a14;
|
||||
xam_task_schedule(&mut ctx, &mut mem, &mut state);
|
||||
let handle = mem.read_u32(handle_out);
|
||||
|
||||
// Thread exits first (releases self-ref, refcount 2→1).
|
||||
let destroyed_first = state.release_handle(handle);
|
||||
assert!(!destroyed_first, "self-ref drop must not destroy (creator still holds)");
|
||||
assert!(state.objects.contains_key(&handle));
|
||||
|
||||
// User closes (refcount 1→0 → destroy).
|
||||
ctx.gpr[3] = handle as u64;
|
||||
xam_task_close_handle(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 1, "close returns 1");
|
||||
assert!(!state.objects.contains_key(&handle));
|
||||
assert!(!state.handle_refcount.contains_key(&handle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xam_user_get_signin_state_user0_signed_in_locally() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
@@ -688,4 +1064,113 @@ mod tests {
|
||||
assert_eq!(mem.read_u32(id_ptr), 0);
|
||||
assert_eq!(mem.read_u32(param_ptr), 0);
|
||||
}
|
||||
|
||||
/// Phase A oracle case at `tid_event_idx=102197`: canary returns
|
||||
/// `X_ERROR_NO_SUCH_USER` (0x525) because no profile is installed.
|
||||
/// Sylpheed must be querying with a `user_index < 4`.
|
||||
#[test]
|
||||
fn xam_content_create_enumerator_returns_no_such_user_for_user0() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let buffer_size_ptr = SCRATCH_BASE + 0x100;
|
||||
let handle_out = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = 0; // user_index = 0 (signed-in slot, no profile in ours)
|
||||
ctx.gpr[4] = 1; // device_id = HDD
|
||||
ctx.gpr[5] = 0x0000_0001; // content_type
|
||||
ctx.gpr[6] = 0; // content_flags
|
||||
ctx.gpr[7] = 4; // items_per_enumerate
|
||||
ctx.gpr[8] = buffer_size_ptr as u64;
|
||||
ctx.gpr[9] = handle_out as u64;
|
||||
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(
|
||||
ctx.gpr[3], 0x0000_0525,
|
||||
"canary mirror: X_ERROR_NO_SUCH_USER for any user_index < 4"
|
||||
);
|
||||
// Canary writes buffer_size_ptr BEFORE the user-profile check;
|
||||
// the X_ERROR_NO_SUCH_USER path keeps the computed size value.
|
||||
assert_eq!(
|
||||
mem.read_u32(buffer_size_ptr),
|
||||
0x134 * 4,
|
||||
"buffer_size_ptr must equal sizeof(XCONTENT_DATA) * items_per_enumerate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xam_content_create_enumerator_invalid_handle_out_returns_invalidarg() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let buffer_size_ptr = SCRATCH_BASE + 0x100;
|
||||
// Seed scratch with a sentinel so we can detect the buffer_size
|
||||
// = 0 reset.
|
||||
mem.write_u32(buffer_size_ptr, 0xDEAD_BEEF);
|
||||
ctx.gpr[3] = 0;
|
||||
ctx.gpr[4] = 1;
|
||||
ctx.gpr[5] = 0x0000_0001;
|
||||
ctx.gpr[6] = 0;
|
||||
ctx.gpr[7] = 4;
|
||||
ctx.gpr[8] = buffer_size_ptr as u64;
|
||||
ctx.gpr[9] = 0; // handle_out = NULL → X_E_INVALIDARG
|
||||
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 0x8007_0057);
|
||||
assert_eq!(
|
||||
mem.read_u32(buffer_size_ptr),
|
||||
0,
|
||||
"X_E_INVALIDARG path resets *buffer_size_ptr to 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xam_content_create_enumerator_unknown_device_returns_invalidarg() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let buffer_size_ptr = SCRATCH_BASE + 0x100;
|
||||
let handle_out = SCRATCH_BASE + 0x200;
|
||||
mem.write_u32(buffer_size_ptr, 0xDEAD_BEEF);
|
||||
ctx.gpr[3] = 0;
|
||||
ctx.gpr[4] = 99; // device_id = unknown
|
||||
ctx.gpr[5] = 0x0000_0001;
|
||||
ctx.gpr[6] = 0;
|
||||
ctx.gpr[7] = 4;
|
||||
ctx.gpr[8] = buffer_size_ptr as u64;
|
||||
ctx.gpr[9] = handle_out as u64;
|
||||
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 0x8007_0057);
|
||||
assert_eq!(mem.read_u32(buffer_size_ptr), 0);
|
||||
}
|
||||
|
||||
/// `user_index == XUserIndexNone (0xFE)` skips the profile check;
|
||||
/// canary proceeds to enumerator creation and returns SUCCESS.
|
||||
/// Defensive coverage — not currently exercised by Phase A.
|
||||
#[test]
|
||||
fn xam_content_create_enumerator_user_none_returns_success() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let buffer_size_ptr = SCRATCH_BASE + 0x100;
|
||||
let handle_out = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = 0xFE; // XUserIndexNone
|
||||
ctx.gpr[4] = 1;
|
||||
ctx.gpr[5] = 0x0000_0001;
|
||||
ctx.gpr[6] = 0;
|
||||
ctx.gpr[7] = 2;
|
||||
ctx.gpr[8] = buffer_size_ptr as u64;
|
||||
ctx.gpr[9] = handle_out as u64;
|
||||
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 0);
|
||||
assert_eq!(mem.read_u32(buffer_size_ptr), 0x134 * 2);
|
||||
}
|
||||
|
||||
/// Out-of-range user_index (>=4 and !=0xFD) takes the same
|
||||
/// no-such-user path because canary's `GetUserProfile` returns
|
||||
/// nullptr for those indices.
|
||||
#[test]
|
||||
fn xam_content_create_enumerator_out_of_range_user_returns_no_such_user() {
|
||||
let (mut ctx, mem, mut state) = fresh();
|
||||
let buffer_size_ptr = SCRATCH_BASE + 0x100;
|
||||
let handle_out = SCRATCH_BASE + 0x200;
|
||||
ctx.gpr[3] = 7; // out of range, < XUserIndexLatest
|
||||
ctx.gpr[4] = 0;
|
||||
ctx.gpr[5] = 0x0000_0001;
|
||||
ctx.gpr[6] = 0;
|
||||
ctx.gpr[7] = 1;
|
||||
ctx.gpr[8] = buffer_size_ptr as u64;
|
||||
ctx.gpr[9] = handle_out as u64;
|
||||
xam_content_create_enumerator(&mut ctx, &mem, &mut state);
|
||||
assert_eq!(ctx.gpr[3], 0x0000_0525);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,24 @@ pub const XAUDIO_PERIOD: Duration = Duration::from_nanos(5_333_333);
|
||||
/// queueing unbounded callbacks while injection is starved.
|
||||
pub const XAUDIO_QUEUE_CAP: usize = 16;
|
||||
|
||||
/// Phase HostAudioEager (2026-05-19): initial seeded fire count at
|
||||
/// `XAudioRegisterRenderDriverClient` time. Mirrors xenia-canary
|
||||
/// [`audio_system.cc:210`](../../../../xenia-canary/src/xenia/apu/audio_system.cc#L210)
|
||||
/// `client_semaphore->Release(queued_frames_=8, nullptr)` — the moment
|
||||
/// canary's `RegisterClient` returns, its already-running host worker
|
||||
/// thread has 8 buffer-complete fires queued to drain.
|
||||
///
|
||||
/// In ours, the dedicated guest audio worker (spawned at the same
|
||||
/// register call) can't be HOST-threaded; instead we seed the pending
|
||||
/// FIFO so the round prologue's `try_inject_audio_callback` injects
|
||||
/// the first callback on the very next round — well before tid=1
|
||||
/// reaches `ExCreateThread` for the XAudio worker threads (tid=14/15
|
||||
/// in canary, tid=9/10 in ours). This fixes the ordering issue where
|
||||
/// the 48k-instruction ticker delay let tid=9/10 spawn and enter
|
||||
/// their spin loop on the uninitialized voice struct before the
|
||||
/// callback could modify it.
|
||||
pub const XAUDIO_REGISTER_SEED_FIRES: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct XAudioClient {
|
||||
pub callback_pc: u32,
|
||||
@@ -155,6 +173,28 @@ impl XAudioState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase HostAudioEager: enqueue `n` buffer-complete fires for a
|
||||
/// specific client slot. Used by `XAudioRegisterRenderDriverClient`
|
||||
/// to mirror canary's `client_semaphore->Release(queued_frames_)`
|
||||
/// at register time. Capped by [`XAUDIO_QUEUE_CAP`] to avoid
|
||||
/// unbounded growth if the caller seeds aggressively. Returns the
|
||||
/// actual number of fires enqueued.
|
||||
pub fn seed_fires_for(&mut self, index: usize, n: usize) -> usize {
|
||||
if index >= XAUDIO_MAX_CLIENTS || self.clients[index].is_none() {
|
||||
return 0;
|
||||
}
|
||||
let mut queued = 0;
|
||||
for _ in 0..n {
|
||||
if self.pending.len() >= XAUDIO_QUEUE_CAP {
|
||||
self.dropped += 1;
|
||||
break;
|
||||
}
|
||||
self.pending.push_back(index);
|
||||
queued += 1;
|
||||
}
|
||||
queued
|
||||
}
|
||||
|
||||
pub fn peek_next(&self) -> Option<usize> {
|
||||
self.pending.front().copied()
|
||||
}
|
||||
@@ -320,6 +360,51 @@ mod tests {
|
||||
assert!(s.last_instant.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fires_for_registered_slot_enqueues_n() {
|
||||
let mut s = XAudioState::default();
|
||||
let i = s.register(dummy_client(1)).unwrap();
|
||||
let queued = s.seed_fires_for(i, XAUDIO_REGISTER_SEED_FIRES);
|
||||
assert_eq!(queued, XAUDIO_REGISTER_SEED_FIRES);
|
||||
assert_eq!(s.pending.len(), XAUDIO_REGISTER_SEED_FIRES);
|
||||
// All enqueued fires reference our slot.
|
||||
for _ in 0..XAUDIO_REGISTER_SEED_FIRES {
|
||||
assert_eq!(s.take_next(), Some(i));
|
||||
}
|
||||
assert!(s.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fires_for_unregistered_slot_is_noop() {
|
||||
let mut s = XAudioState::default();
|
||||
// Slot 3 is empty.
|
||||
let queued = s.seed_fires_for(3, 8);
|
||||
assert_eq!(queued, 0);
|
||||
assert!(s.pending.is_empty());
|
||||
assert_eq!(s.dropped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fires_for_caps_at_queue_cap_and_counts_drops() {
|
||||
let mut s = XAudioState::default();
|
||||
let i = s.register(dummy_client(1)).unwrap();
|
||||
let queued = s.seed_fires_for(i, XAUDIO_QUEUE_CAP * 4);
|
||||
assert_eq!(queued, XAUDIO_QUEUE_CAP);
|
||||
assert_eq!(s.pending.len(), XAUDIO_QUEUE_CAP);
|
||||
// Excess fires are counted as dropped (per
|
||||
// existing `enqueue_all_active` discipline).
|
||||
assert!(s.dropped >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_fires_for_out_of_range_index_is_noop() {
|
||||
let mut s = XAudioState::default();
|
||||
s.register(dummy_client(1)).unwrap();
|
||||
let queued = s.seed_fires_for(XAUDIO_MAX_CLIENTS + 5, 4);
|
||||
assert_eq!(queued, 0);
|
||||
assert!(s.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_wallclock_fires_after_period() {
|
||||
let mut s = XAudioState::default();
|
||||
|
||||
@@ -120,9 +120,13 @@ pub mod header_keys {
|
||||
pub const ENTRY_POINT: u32 = 0x00010100;
|
||||
pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201;
|
||||
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 DEFAULT_STACK_SIZE: u32 = 0x00020104;
|
||||
pub const DEFAULT_STACK_SIZE: u32 = 0x00020200;
|
||||
pub const ORIGINAL_PE_NAME: u32 = 0x000183FF;
|
||||
pub const FILE_FORMAT_INFO: u32 = 0x000003FF;
|
||||
pub const SYSTEM_FLAGS: u32 = 0x00030000;
|
||||
|
||||
@@ -353,8 +353,49 @@ pub fn get_image_base(header: &Xex2Header) -> Option<u32> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user