diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index 0f1aaec..cc03d82 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -242,6 +242,23 @@ enum Commands { /// line). Stdout when omitted. #[arg(long)] lr_trace_out: Option, + /// AUDIT-2BF — comma-separated list of guest PCs (hex, no `0x` + /// prefix required) to capture as one-line `AUDIT-PC-PROBE` + /// records on every fire. Designed for the silph init chain + /// virtual-dispatch site at `sub_82172BA0+0x1E8` (PC + /// `0x82172D88`, a `bctrl` after a 3-deep vtable-slot-6 load). + /// Each record carries (pc, tid, hw, cycle, lr, r3, r11) plus + /// four guest-memory dereferences off r3: `[r3+0]` (vtable), + /// `[[r3+0]+24]` (slot 6 method = bctrl target), `[r3+0x0C]` + /// (auxiliary handle), `[r3+0x30]` (embedded sub-object vtable). + /// Compares directly against canary's round-9 capture: + /// r3=0xBCCC52C0, [r3+0]=0x820A3644, slot6=sub_821B55D8, + /// [r3+0xC]=0xF80000D8, [r3+0x30]=0x820A1870. Read-only; + /// lockstep digest unaffected. Settable via + /// `XENIA_AUDIT_PC_PROBE`. Example: + /// `--audit-pc-probe-hex=82172D88,82172D80`. + #[arg(long)] + audit_pc_probe_hex: Option, }, /// Browse XISO disc image contents Browse { @@ -405,6 +422,7 @@ fn main() -> Result<()> { probe_db, lr_trace, lr_trace_out, + audit_pc_probe_hex, } => cmd_exec( &path, max_instructions, @@ -431,6 +449,7 @@ fn main() -> Result<()> { probe_db.as_deref(), lr_trace.as_deref(), lr_trace_out.as_deref(), + audit_pc_probe_hex.as_deref(), ), Commands::Browse { path } => cmd_browse(&path), Commands::Info { path } => cmd_info(&path), @@ -662,6 +681,7 @@ fn cmd_exec( probe_db: Option<&str>, lr_trace: Option<&str>, lr_trace_out: Option<&str>, + audit_pc_probe_hex: Option<&str>, ) -> Result<()> { cmd_exec_inner( path, @@ -689,6 +709,7 @@ fn cmd_exec( probe_db, lr_trace, lr_trace_out, + audit_pc_probe_hex, None, None, false, @@ -735,6 +756,7 @@ fn cmd_check( None, // probe_db — same None, // lr_trace — same None, // lr_trace_out — same + None, // audit_pc_probe_hex — diagnostic, never wanted on goldens out, expect, stable_digest, @@ -767,6 +789,7 @@ fn cmd_exec_inner( probe_db: Option<&str>, lr_trace: Option<&str>, lr_trace_out: Option<&str>, + audit_pc_probe_hex: Option<&str>, digest_out: Option<&str>, digest_expect: Option<&str>, stable_digest: bool, @@ -1167,6 +1190,36 @@ fn cmd_exec_inner( } } + // AUDIT-2BF — `--audit-pc-probe-hex=82172D88,...`. Bare-hex tokens + // (with or without `0x` prefix). Parses every comma-separated entry + // as a u32 PC and inserts into `kernel.audit_pc_probe_pcs`. Empty + // set is the hot-path no-op (single is_empty() check). + let audit_pc_probe_combined: Option = match ( + audit_pc_probe_hex, std::env::var("XENIA_AUDIT_PC_PROBE").ok(), + ) { + (Some(s), _) => Some(s.to_string()), + (None, Some(s)) if !s.is_empty() => Some(s), + _ => None, + }; + if let Some(list) = audit_pc_probe_combined { + for token in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let hex = token.strip_prefix("0x").or_else(|| token.strip_prefix("0X")).unwrap_or(token); + let pc = u32::from_str_radix(hex, 16) + .map_err(|e| anyhow::anyhow!("--audit-pc-probe-hex {token:?}: {e}"))?; + kernel.audit_pc_probe_pcs.insert(pc); + } + if !quiet && !kernel.audit_pc_probe_pcs.is_empty() { + let mut pcs: Vec = kernel.audit_pc_probe_pcs.iter().copied().collect(); + pcs.sort_unstable(); + let strs: Vec = pcs.iter().map(|p| format!("{p:#010x}")).collect(); + tracing::info!( + "audit-pc-probe armed: {} ({})", + kernel.audit_pc_probe_pcs.len(), + strs.join(", "), + ); + } + } + // Diagnostic. Parse `--dump-addr=0x828F3D08,...` (or // `XENIA_DUMP_ADDR=...`) into `kernel.dump_addrs`. The contents // are dumped at end-of-run by `dump_thread_diagnostic`. Pure @@ -2242,6 +2295,7 @@ fn worker_prologue( // the helper, no overhead on the hot path. kernel.fire_ctor_probe_if_match(hw_id, mem); kernel.fire_branch_probe_if_match(hw_id); + kernel.fire_audit_pc_probe_if_match(hw_id, mem); kernel.fire_lr_trace_if_match(hw_id); if mem.has_mem_watch() { diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs index b256fe7..9879add 100644 --- a/crates/xenia-kernel/src/state.rs +++ b/crates/xenia-kernel/src/state.rs @@ -244,6 +244,22 @@ pub struct KernelState { /// Distinct from `ctor_probe_pcs` because that helper emits 8 /// frames of back-chain per hit — too noisy for branch tracing. pub branch_probe_pcs: std::collections::HashSet, + /// AUDIT-2BF — diagnostic. PCs at which to emit a structured one-line + /// `AUDIT-PC-PROBE` record on every fire, designed for the silph init + /// chain virtual-dispatch site at `sub_82172BA0+0x1E8` (PC + /// `0x82172D88`, a `bctrl` after a 3-deep load of vtable slot 6). The + /// emitted line carries (pc, tid, hw, cycle, lr, r3, r11) plus four + /// guest-memory dereferences off `r3`: `[r3+0]` (vtable), `[[r3+0]+24]` + /// (slot 6 method pointer = the bctrl target), `[r3+0x0C]` (audit-059 + /// round-9 canary-known auxiliary handle `0xF80000D8`), and `[r3+0x30]` + /// (canary-known embedded sub-object vtable `0x820A1870`). Distinct + /// from `branch_probe_pcs` because that helper only logs registers (no + /// memory) and from `lr_trace_pcs` because that emits JSON intended + /// for canary diffing, not the four hard-coded indirect dereferences + /// needed here. Read-only — no guest state mutation. Lockstep + /// digest unaffected. Settable via `--audit-pc-probe-hex` / + /// `XENIA_AUDIT_PC_PROBE`. + pub audit_pc_probe_pcs: std::collections::HashSet, /// M12 — diagnostic. PCs at which to emit a structured JSONL record /// per fire, designed for diffing against xenia-canary's /// `--log_lr_on_pc` patch output. Each line carries @@ -327,6 +343,7 @@ impl KernelState { ctor_probe_pcs: std::collections::HashSet::new(), pc_probe_consumers: HashMap::new(), branch_probe_pcs: std::collections::HashSet::new(), + audit_pc_probe_pcs: std::collections::HashSet::new(), lr_trace_pcs: std::collections::HashSet::new(), lr_trace_writer: None, dump_addrs: Vec::new(), @@ -797,6 +814,52 @@ impl KernelState { ); } + /// AUDIT-2BF — diagnostic. If the live PC for HW slot `hw_id` is in + /// `self.audit_pc_probe_pcs`, emit a single one-line + /// `AUDIT-PC-PROBE` record with (pc, tid, hw, cycle, lr, r3, r11) + /// plus four guest-memory dereferences off r3: `[r3+0]` (vtable), + /// `[[r3+0]+24]` (slot 6 method = bctrl target), `[r3+0x0C]` + /// (auxiliary handle field), `[r3+0x30]` (embedded sub-object + /// vtable field). Tuned for the silph init chain virtual-dispatch + /// site at `sub_82172BA0+0x1E8` (PC `0x82172D88`). + /// + /// Read-only. No guest-state mutation; lockstep digest unaffected. + /// Empty set is the common case → single `is_empty()` test on the + /// hot path. + pub fn fire_audit_pc_probe_if_match(&self, hw_id: u8, mem: &GuestMemory) { + if self.audit_pc_probe_pcs.is_empty() { + return; + } + let ctx = self.scheduler.ctx(hw_id); + let pc = ctx.pc; + if !self.audit_pc_probe_pcs.contains(&pc) { + return; + } + let tid = self.scheduler.tid(hw_id).unwrap_or(0); + let r3 = ctx.gpr[3] as u32; + let r11 = ctx.gpr[11] as u32; + let lr = ctx.lr as u32; + let cycle = ctx.cycle_count; + // Memory dereferences. Guest pointers may be unmapped/garbage; + // `read_u32` returns 0 for unmapped pages (heap.rs:510 returns + // a default), so an all-zero block in the output reliably + // indicates an invalid `r3`. + let vtable = mem.read_u32(r3); + let slot6_method = if vtable != 0 { + mem.read_u32(vtable.wrapping_add(24)) + } else { + 0 + }; + let aux_handle = mem.read_u32(r3.wrapping_add(0x0C)); + let sub_vt = mem.read_u32(r3.wrapping_add(0x30)); + println!( + "AUDIT-PC-PROBE pc={:#010x} tid={} hw={} cycle={} lr={:#010x} r3={:#010x} r11={:#010x} \ + [r3+0]={:#010x} [[r3+0]+24]={:#010x} [r3+0x0C]={:#010x} [r3+0x30]={:#010x}", + pc, tid, hw_id, cycle, lr, r3, r11, + vtable, slot6_method, aux_handle, sub_vt, + ); + } + /// M12 — diagnostic. If the live PC for HW slot `hw_id` is in /// `self.lr_trace_pcs`, emit one JSONL record. Format mirrors what /// xenia-canary's `--log_lr_on_pc` patch emits, plus the cycle