diff --git a/audit-findings.md b/audit-findings.md index fdfbfcc..2dd18a1 100644 --- a/audit-findings.md +++ b/audit-findings.md @@ -4308,3 +4308,152 @@ matching message-push code path**. Steps: The walker is reusable: any handle added to `--trace-handles-focus` will get a 6-frame stack at creation time. Add new candidates freely — cost on the unfocused hot path is one `HashSet::contains`. + +### KRNBUG-AUDIT-003 — vtable/RTTI class probe + dispatcher identification + +**Status:** landed (diagnostic only; no behaviour change). Verified +end-to-end against 5 unit tests + the producer-trace pass at -n 500M. + +**Site:** `crates/xenia-kernel/src/state.rs` — new `ClassReadout` +enum + `read_class_at_this(this, mem)` + `probe_create_stack_classes( +ctx, frames, mem)` + private helpers (`is_likely_guest_heap_ptr`, +`is_likely_image_ptr`, `read_ascii_cstring`). +`crates/xenia-kernel/src/audit.rs` — extended `HandleAuditTrail` with +`created_class_probes: Vec` + new +`record_create_with_stack_and_probes`. +`crates/xenia-app/src/main.rs` — `dump_thread_diagnostic` now takes +`&GuestMemory`; FOCUS report prints WAIT-THREAD blocks with per-frame +back-chain + saved register slots + class probes. + +**Why it exists:** AUDIT-002 gave us back-chain frames at handle +creation. AUDIT-003's promise was "recover the dispatcher's MSVC C++ +class name via vtable[-4] → COL → TypeDescriptor" so the producer +hunt could read "who should call `Class::Submit` but doesn't" +instead of "who should signal handle X." + +**Probe correctness:** MSVC RTTI traversal (`vtable[-4]` = COL, +`COL+0x0c` = TypeDescriptor, `TypeDescriptor+8` = NUL-terminated +mangled name starting `.?A`). False-positive guard: at least the +first two vtable slots must be image-range function pointers. This +rejects the CRT static-init iterator pattern where `r31` holds a +pointer into the init-fn array and `[r31]` is a function PC, not a +vtable. + +**Verification:** +- Workspace tests: 581 → **586** (+5: 4 new in `state.rs` exercising + RTTI-intact / RTTI-stripped / non-object / `read_ascii_cstring` + termination + 1 integration test for `probe_create_stack_classes`). +- `--stable-digest -n 100M` lockstep oracle: + `instructions=100000002` (unchanged). +- `sylpheed_n50m` golden: passes. +- End-to-end: 500M producer-trace run captured at + `audit-runs/audit-003/run-500m-v4.txt`. RC=0. + +### KRNBUG-AUDIT-003 finding — dispatcher addresses + decisive xref audit + +**Run:** `exec sylpheed.iso --halt-on-deadlock --trace-handles-focus= +0x1004,0x100c,0x15e0,0x42450b5c -n 500_000_000`. + +**Handle 0x100c — dispatcher at `0x828F3D08`:** + +Confirmed three ways: + +1. Per-frame saved-r31 capture at handle creation: + ``` + frame=1 lr=0x821817c0 saved-r31=0x828f3d08 ← per-instance ctor + frame=2 lr=0x82180114 saved-r31=0x828f3d08 ← bridge ctor (same value) + ``` +2. Disassembly of `sub_82181750` at +0x14: + `addis r11, r0, 0x828F; addi r31, r11, 15624` ⇒ + `r31 = 0x828F3D08` (the `this` for the per-instance ctor). +3. Field-level write tracking via `xrefs.kind=write`: + `pc=0x82181778 in sub_82181750 — stw r11, 0(r31)` writes -1 to + `[this+0]`. + +**`[this+0] = -1` is decisive: this is a hand-rolled POD job-queue +struct, not a C++ polymorphic class.** No vtable means no RTTI; +"class name" doesn't exist in MSVC mangled form. The probe correctly +rejected 0x828F3D08 as a class candidate. + +Field layout (from sub_82181750 disasm): +``` +[this+ 0] = -1 ; sentinel (not a vtable) +[this+ 4..12] = 0 +[this+20] = 0 (halfword) +[this+36] = 0 +[this+40] = 7 ; count or version +[this+44..(44+256)] ; sub-region init by `bl 0x8284DCEC` +[this+72] = thread_handle ; set after thread spawn +[this+76] = event_handle ; = 0x100c, set after silph::Event ctor +[this+88..104] = 0 +``` + +Worker is `sub_82181830`: receives r3=this, copies r28=this and +r29=&this[44], does `silph::Thread::SetProcessor(CURRENT, 5)`, +then `lwarx`/`stwcx.` on `&this[80]`. Wait-side telemetry confirms: +the parked thread's spilled r28-r31 area has 0x828F3D08 (=r28 base) +and 0x828F3D34 (= base+44 = r29). + +**Handle 0x15e0 — dispatcher at `0x828F4070`:** + +Confirmed via xref table. Same shape as 0x100c (POD job queue, not +a C++ class). Constructed by `sub_821701C8` + `sub_8216F618`. + +**Handle 0x1004 — 8-instance pool, member addresses still TBD.** + +The MSVC ctors for the per-instance and bridge functions did not +preserve `this` in r31 across the call into `silph::Event::Ctor`, +so the saved-r31 chain captured at create time shows +stack-relative pointers (frames 1, 2, 5) and the CRT init-fn +iterator pointer 0x82870180 (frames 3, 4) instead of the pool +member's `this`. Recovering the 8 pool addresses requires hooking +`sub_8217C850`'s entry to capture r3 at each of its 8 calls from +the static ctor at `0x8280F810`. + +**Handle 0x42450b5c — separate bug class.** Heap-allocated +(0x4xxxxxxx is user-heap range), parks via non-`do_wait_single` +path. AUDIT-003's image-rdata-focused probe doesn't apply. Track +under a separate audit ID. + +**Decisive xref audit — producer is unreached:** + +``` +0x828F3D08 (handle 0x100c) — 4 references in static analysis: + pc=0x82180100 in sub_821800D8 (kind=ref) — bridge ctor + pc=0x8218176c in sub_82181750 (kind=ref) — per-instance ctor + pc=0x82181778 in sub_82181750 (kind=write) — per-instance ctor init + pc=0x8284caa4 in sub_8280C2C0 (kind=ref) — CRT init driver + +0x828F4070 (handle 0x15e0) — 5 references: + pc=0x8216f650 in sub_8216F618 (kind=ref) — bridge ctor + pc=0x8216f674 in sub_8216F618 (kind=ref) — bridge ctor + pc=0x821701e4 in sub_821701C8 (kind=ref) — per-instance ctor + pc=0x82170330 in sub_821701C8 (kind=ref) — per-instance ctor + pc=0x8284c9a4 in sub_8280C2C0 (kind=ref) — CRT init driver +``` + +**Every xref is in a ctor or the CRT.** No producer code references +either dispatcher base. Confirms AUDIT-001/002's `signal_attempts=0`: +the producer is unreached, not broken. The static analysis would +miss producers that operate via a `this` register passed through a +function arg (no constant-load), but the simple +"`load_const dispatcher_addr; call submit(this, work)`" pattern +**is not present** in the binary for 0x828F3D08 / 0x828F4070. + +**Recommendation for next session (no implementation here):** + +1. Investigate the call-chain `main() → sub_82181C20 → sub_82181750`. + sub_82181C20 is a subsystem driver — it constructs the queue and + should ALSO wire it into a feeder. If the feeder is itself a + static-init that's never invoked, the trail leads back to the + CRT init array driver (`sub_824ACB38`, walks + 0x82870010..0x828708D4) and whatever scheduling subsystem is + supposed to drive those. + +2. Hook `sub_8217C850` entry under `--trace-handles-focus=0x1004` to + capture r3 at each of its 8 calls — those are the pool member + `this` addresses for handle 0x1004's 8-instance pool. + +3. Treat 0x42450b5c independently. AUDIT-002's hook missed it because + the parking site (PC=0x824cd4f4) isn't routed through `do_wait_single`. + Open KRNBUG-AUDIT-004 for that wait path. diff --git a/crates/xenia-app/src/main.rs b/crates/xenia-app/src/main.rs index 227ab3e..c901a40 100644 --- a/crates/xenia-app/src/main.rs +++ b/crates/xenia-app/src/main.rs @@ -1279,7 +1279,7 @@ fn cmd_exec_inner( db.finalize_traces()?; } print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet); - dump_thread_diagnostic(&kernel, quiet); + dump_thread_diagnostic(&kernel, &*mem_arc, quiet); info!( wall_ms = started.elapsed().as_millis() as u64, instructions = stats.instruction_count, @@ -2968,7 +2968,11 @@ fn print_summary( /// session see *which* thread is stuck *where* without having to re-derive /// it from ring-buffer / event-count math. Mirrors the halt-on-deadlock /// dump in `run_execution` but runs on the normal `-n` exit path too. -fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) { +fn dump_thread_diagnostic( + kernel: &xenia_kernel::KernelState, + mem: &xenia_memory::GuestMemory, + quiet: bool, +) { if quiet { return; } @@ -3216,6 +3220,15 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) { ); } } + if !t.created_class_probes.is_empty() { + println!( + " created-class probes ({}):", + t.created_class_probes.len(), + ); + for line in &t.created_class_probes { + println!(" {}", line); + } + } } // Producer-class classification. Source strings are stable // labels passed to `record_signal` at the export sites. @@ -3299,6 +3312,96 @@ fn dump_thread_diagnostic(kernel: &xenia_kernel::KernelState, quiet: bool) { "not stuck — signals consumed correctly" }; println!(" => {}", conclusion); + + // KRNBUG-AUDIT-003. For each thread parked on this focus + // handle, walk its back-chain and probe `this` candidates + // for an MSVC C++ class name via vtable[-4] → COL → TD. + // Frame 0 uses the live r31/r30/r3 of the parked thread; + // deeper frames recover saved r31/r30 from the standard + // PPC prologue spill area at [fp - 12] / [fp - 16]. This + // names the dispatcher / queue / pool that owns each + // parked thread, converting "who should signal handle X" + // into "who should call Class::Submit but doesn't." + if let Some(obj) = kernel.objects.get(&h) { + let waiters: Vec = match obj { + KernelObject::Event { waiters, .. } + | KernelObject::Semaphore { waiters, .. } + | KernelObject::Timer { waiters, .. } + | KernelObject::Thread { waiters, .. } + | KernelObject::Mutex { waiters, .. } => waiters.iter().copied().collect(), + KernelObject::File { .. } => Vec::new(), + }; + for w in waiters { + let Some(slot) = kernel.scheduler.slots.get(w.hw_id as usize) else { + continue; + }; + let Some(thread) = slot.runqueue.get(w.idx as usize) else { + continue; + }; + let ctx = &thread.ctx; + let frames = xenia_kernel::state::walk_guest_back_chain( + ctx.gpr[1] as u32, + ctx.lr as u32, + mem, + 6, + ); + println!( + " WAIT-THREAD tid={} pc={:#010x} lr={:#010x} sp={:#010x} r3={:#010x} r30={:#010x} r31={:#010x}", + thread.tid, + ctx.pc, + ctx.lr as u32, + ctx.gpr[1] as u32, + ctx.gpr[3] as u32, + ctx.gpr[30] as u32, + ctx.gpr[31] as u32, + ); + for (frame_idx, (fp, frame_lr)) in frames.iter().enumerate() { + let saved_r31 = mem.read_u32(fp.wrapping_sub(12)); + let saved_r30 = mem.read_u32(fp.wrapping_sub(16)); + let saved_r29 = mem.read_u32(fp.wrapping_sub(20)); + let saved_r28 = mem.read_u32(fp.wrapping_sub(24)); + println!( + " FRAME {} fp={:#010x} lr={:#010x} saved r31={:#010x} r30={:#010x} r29={:#010x} r28={:#010x}", + frame_idx, fp, frame_lr, saved_r31, saved_r30, saved_r29, saved_r28, + ); + let candidates: Vec<(u32, &'static str)> = if frame_idx == 0 { + vec![ + (ctx.gpr[31] as u32, "r31"), + (ctx.gpr[30] as u32, "r30"), + (ctx.gpr[29] as u32, "r29"), + (ctx.gpr[28] as u32, "r28"), + (ctx.gpr[3] as u32, "r3"), + ] + } else { + vec![ + (saved_r31, "saved-r31"), + (saved_r30, "saved-r30"), + (saved_r29, "saved-r29"), + (saved_r28, "saved-r28"), + ] + }; + for (this_ptr, label) in candidates { + use xenia_kernel::state::ClassReadout; + match xenia_kernel::state::read_class_at_this(this_ptr, mem) { + ClassReadout::Named { vtable, mangled } => { + println!( + " WAIT-CLASS frame={} {}={:#010x} vtable={:#010x} class={}", + frame_idx, label, this_ptr, vtable, mangled, + ); + } + ClassReadout::VtableOnly { vtable, virtuals } => { + println!( + " WAIT-CLASS frame={} {}={:#010x} vtable={:#010x} virtuals=[{:#010x},{:#010x},{:#010x},{:#010x}] (RTTI stripped)", + frame_idx, label, this_ptr, vtable, + virtuals[0], virtuals[1], virtuals[2], virtuals[3], + ); + } + ClassReadout::NotAnObject => {} + } + } + } + } + } } } } @@ -3337,7 +3440,7 @@ fn run_with_ui( let worker_span = tracing::info_span!("cpu_worker"); let worker = std::thread::Builder::new() .name("xenia-cpu".into()) - .spawn(move || -> Result<(ExecStats, xenia_kernel::KernelState, xenia_debugger::Debugger, Option)> { + .spawn(move || -> Result<(ExecStats, xenia_memory::GuestMemory, xenia_kernel::KernelState, xenia_debugger::Debugger, Option)> { let _guard = worker_span.enter(); let stats = run_execution( &mut mem, @@ -3354,7 +3457,7 @@ fn run_with_ui( if let Some(ref mut db) = db_writer { db.finalize_traces()?; } - Ok((stats, kernel, debugger, db_writer)) + Ok((stats, mem, kernel, debugger, db_writer)) }) .map_err(|e| anyhow::anyhow!("spawn CPU worker: {e}"))?; @@ -3362,7 +3465,7 @@ fn run_with_ui( // flips the shutdown flag itself (e.g. after max_instructions). xenia_ui::run(event_loop, ui_handles, &title_owned)?; - let (stats, kernel, debugger, db_writer) = match worker.join() { + let (stats, mem, kernel, debugger, db_writer) = match worker.join() { Ok(res) => res?, Err(_) => { return Err(anyhow::anyhow!("CPU worker thread panicked")); @@ -3370,7 +3473,7 @@ fn run_with_ui( }; print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet); - dump_thread_diagnostic(&kernel, quiet); + dump_thread_diagnostic(&kernel, &mem, quiet); info!( wall_ms = started.elapsed().as_millis() as u64, instructions = stats.instruction_count, diff --git a/crates/xenia-kernel/src/audit.rs b/crates/xenia-kernel/src/audit.rs index f287a71..5fb0b17 100644 --- a/crates/xenia-kernel/src/audit.rs +++ b/crates/xenia-kernel/src/audit.rs @@ -59,6 +59,14 @@ pub struct HandleAuditTrail { /// from walking the PPC back-chain. An empty vec means either the /// handle wasn't in focus or the create site didn't capture a stack. pub created_stack: Vec<(u32, u32)>, + /// KRNBUG-AUDIT-003 class probes. Each entry is one already-formatted + /// "frame=N r31=0x... vtable=0x... class=..." line, captured at + /// allocation time from the live PPC context (frame 0: ctx.gpr[31] / + /// r30 / r3) and the standard prologue spill area at `[fp - 12]` / + /// `[fp - 16]` for deeper frames. Pre-formatted because the source + /// memory is overwritten once tid=1 leaves the static-init phase, so + /// the probe must run at the create call site, not at end-of-run. + pub created_class_probes: Vec, /// Bounded ring of signal events. pub signals: VecDeque, /// Bounded ring of wait-entry events (one per `Wait*` call). @@ -73,6 +81,7 @@ impl HandleAuditTrail { kind, created, created_stack: Vec::new(), + created_class_probes: Vec::new(), signals: VecDeque::with_capacity(AUDIT_RING_CAPACITY), waits: VecDeque::with_capacity(AUDIT_RING_CAPACITY), wakes: VecDeque::with_capacity(AUDIT_RING_CAPACITY), @@ -141,12 +150,30 @@ impl HandleAudit { kind: &'static str, entry: HandleAuditEntry, stack: Vec<(u32, u32)>, + ) { + self.record_create_with_stack_and_probes(handle, kind, entry, stack, Vec::new()); + } + + /// Variant of `record_create_with_stack` that also accepts pre- + /// formatted class-probe strings (KRNBUG-AUDIT-003). Each string is + /// one frame's RTTI/vtable readout: `frame=N candidate=r31 this=0x... + /// vtable=0x... class=...` or the RTTI-stripped fallback. Caller + /// formats them so this module remains memory-layout-agnostic. + #[inline] + pub fn record_create_with_stack_and_probes( + &mut self, + handle: u32, + kind: &'static str, + entry: HandleAuditEntry, + stack: Vec<(u32, u32)>, + class_probes: Vec, ) { if !self.enabled { return; } let mut trail = HandleAuditTrail::new(kind, entry); trail.created_stack = stack; + trail.created_class_probes = class_probes; self.trails.insert(handle, trail); } diff --git a/crates/xenia-kernel/src/state.rs b/crates/xenia-kernel/src/state.rs index 7d9c840..18a7a36 100644 --- a/crates/xenia-kernel/src/state.rs +++ b/crates/xenia-kernel/src/state.rs @@ -486,8 +486,9 @@ impl KernelState { return; } let stack = walk_guest_back_chain(ctx.gpr[1] as u32, lr, mem, 6); + let probes = probe_create_stack_classes(ctx, &stack, mem); self.audit - .record_create_with_stack(handle, kind, entry, stack); + .record_create_with_stack_and_probes(handle, kind, entry, stack, probes); } /// Record a Set/Pulse/Release/etc. call against a handle. `aux` is the @@ -710,6 +711,195 @@ impl Default for KernelState { } } +/// KRNBUG-AUDIT-003. Outcome of probing a guest pointer as the `this` +/// of a C++ object: read `[this]` as the vtable, then attempt MSVC +/// RTTI to recover the decorated class name. Pure read; lockstep-safe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClassReadout { + /// MSVC RTTI was intact. `mangled` is the decorated name as stored + /// in the TypeDescriptor (`.?AVEvent@silph@@` form). + Named { vtable: u32, mangled: String }, + /// `[this]` looked like a vtable pointer but RTTI was stripped (or + /// the COL/TypeDescriptor chain didn't yield a printable name). + /// `virtuals` are the first 4 vtable slots — resolve via the + /// analysis DB's `functions` table for offline class identification. + VtableOnly { vtable: u32, virtuals: [u32; 4] }, + /// Either `this` itself isn't a plausible heap pointer or `[this]` + /// doesn't land in the image's read-only-data range. Caller skips. + NotAnObject, +} + +/// Probe a candidate `this` pointer as a C++ object on the guest heap. +/// Read-only; safe to call from the diagnostic dump path. Behaviour: +/// 1. Reject non-heap candidate pointers (anything outside the user/ +/// image range). +/// 2. Read `[this]` as vtable; reject if it's not in the image range +/// where MSVC stores read-only `vftable` symbols. +/// 3. MSVC RTTI traversal: +/// vtable[-4 bytes] = RTTICompleteObjectLocator* +/// COL+0x0c = TypeDescriptor* +/// TypeDescriptor+0x08 = mangled name (NUL-terminated ASCII) +/// If every link looks plausible AND the name starts with `.?A` +/// (the MSVC class-name prefix), return `Named`. +/// 4. Otherwise return `VtableOnly` with the first 4 virtual slots +/// so the caller can resolve method names via the analysis DB. +pub fn read_class_at_this(this: u32, mem: &GuestMemory) -> ClassReadout { + if !is_likely_guest_heap_ptr(this) { + return ClassReadout::NotAnObject; + } + let vtable = mem.read_u32(this); + if !is_likely_image_ptr(vtable) { + return ClassReadout::NotAnObject; + } + let col = mem.read_u32(vtable.wrapping_sub(4)); + if is_likely_image_ptr(col) { + let type_desc = mem.read_u32(col.wrapping_add(12)); + if is_likely_image_ptr(type_desc) { + let name = read_ascii_cstring(mem, type_desc.wrapping_add(8), 128); + if name.starts_with(".?A") { + return ClassReadout::Named { + vtable, + mangled: name, + }; + } + } + } + let virtuals = [ + mem.read_u32(vtable), + mem.read_u32(vtable.wrapping_add(4)), + mem.read_u32(vtable.wrapping_add(8)), + mem.read_u32(vtable.wrapping_add(12)), + ]; + // False-positive guard: when [this] points at the entry of a + // function (e.g. the CRT static-init iterator with r31 holding a + // pointer into the init-fn array), `vtable` is the function PC and + // the "first virtuals" are the function's prologue *instructions* + // — words like 0x7D8802A6 (`mflr r12`) which are NOT in the image + // pointer range. A real C++ vtable's first slot is always a member + // function pointer in the image range. Require the first slot AND + // the second slot to look like image-range function pointers, + // else return `NotAnObject`. + if !is_likely_image_ptr(virtuals[0]) || !is_likely_image_ptr(virtuals[1]) { + return ClassReadout::NotAnObject; + } + ClassReadout::VtableOnly { vtable, virtuals } +} + +/// KRNBUG-AUDIT-003. At handle creation time, walk the captured frames +/// and probe each frame's most-likely `this` candidates for an MSVC C++ +/// class name. Returns one pre-formatted line per hit (Named or +/// VtableOnly); silent on `NotAnObject` so the noise floor stays low. +/// +/// Candidates per frame: +/// * Frame 0 (live): ctx.gpr[31] (canonical C++ `this`), ctx.gpr[30] +/// (often a secondary captured `this` in nested method calls), and +/// ctx.gpr[3] (the live first arg — at the moment NtCreateEvent is +/// entered, this is `&Event` being constructed). +/// * Frame K ≥ 1: read `[fp - 12]` and `[fp - 16]` — the standard +/// PPC EABI `__savegprlr_NN` spill area where the callee's prologue +/// placed the caller's r31 / r30 just before its `stwu`. So those +/// slots hold the value of the function-at-frame-K's r31 / r30 +/// captured at the moment IT made the bl into the next frame down. +/// +/// Read-only; never mutates guest state. +pub fn probe_create_stack_classes( + ctx: &PpcContext, + frames: &[(u32, u32)], + mem: &GuestMemory, +) -> Vec { + let mut out = Vec::new(); + for (idx, (fp, lr)) in frames.iter().enumerate() { + let (raw_r31, raw_r30, raw_r3) = if idx == 0 { + (ctx.gpr[31] as u32, ctx.gpr[30] as u32, ctx.gpr[3] as u32) + } else { + ( + mem.read_u32(fp.wrapping_sub(12)), + mem.read_u32(fp.wrapping_sub(16)), + 0, + ) + }; + // Emit one always-on raw line per frame so the back-chain plus + // saved-register dump is captured even when the RTTI probe is + // silent. Investigators can resolve the raw values offline via + // the analysis DB (lookup of vtable / static-init iterator + // pointers / etc. is otherwise impossible from logs alone). + if idx == 0 { + out.push(format!( + "frame={} lr={:#010x} live r31={:#010x} r30={:#010x} r3={:#010x}", + idx, lr, raw_r31, raw_r30, raw_r3, + )); + } else { + out.push(format!( + "frame={} lr={:#010x} saved-r31={:#010x} saved-r30={:#010x}", + idx, lr, raw_r31, raw_r30, + )); + } + let candidates: [(u32, &'static str); 3] = if idx == 0 { + [(raw_r31, "r31"), (raw_r30, "r30"), (raw_r3, "r3")] + } else { + [ + (raw_r31, "saved-r31"), + (raw_r30, "saved-r30"), + (0, ""), + ] + }; + for (this_ptr, label) in candidates { + if label.is_empty() { + continue; + } + match read_class_at_this(this_ptr, mem) { + ClassReadout::Named { vtable, mangled } => { + out.push(format!( + " → frame={} {}={:#010x} vtable={:#010x} class={}", + idx, label, this_ptr, vtable, mangled, + )); + } + ClassReadout::VtableOnly { vtable, virtuals } => { + out.push(format!( + " → frame={} {}={:#010x} vtable={:#010x} virtuals=[{:#010x},{:#010x},{:#010x},{:#010x}] (RTTI stripped)", + idx, label, this_ptr, vtable, + virtuals[0], virtuals[1], virtuals[2], virtuals[3], + )); + } + ClassReadout::NotAnObject => {} + } + } + } + out +} + +/// Heap-pointer plausibility: Xbox 360 user heap is 0x40000000–0x50000000; +/// the image and read-only-data are 0x82000000–0x83000000. Allow both — +/// dispatcher objects in Sylpheed live in static-init pools (image rdata) +/// AND in heap-allocated singletons. +fn is_likely_guest_heap_ptr(p: u32) -> bool { + matches!(p, 0x4000_0000..=0x4FFF_FFFF | 0x8200_0000..=0x82FF_FFFF) +} + +/// Image-pointer plausibility: vtables and RTTI structures live in the +/// module's read-only image, which on Xbox 360 maps at 0x82000000. +fn is_likely_image_ptr(p: u32) -> bool { + matches!(p, 0x8200_0000..=0x82FF_FFFF) +} + +/// Read a NUL-terminated ASCII string from guest memory, capped at +/// `max` bytes. Returns the empty string on any non-printable byte +/// (a cheap signal that `addr` doesn't actually point at a name). +fn read_ascii_cstring(mem: &GuestMemory, addr: u32, max: usize) -> String { + let mut s = String::with_capacity(max); + for i in 0..max { + let b = mem.read_u8(addr.wrapping_add(i as u32)); + if b == 0 { + return s; + } + if !(0x20..=0x7E).contains(&b) { + return String::new(); + } + s.push(b as char); + } + s +} + /// Walk the PPC EABI back-chain starting from `sp` (the value in r1 at /// the moment of capture). Returns up to `max_frames` entries of /// `(frame_pointer, saved_lr)`. Index 0 is the live frame @@ -895,4 +1085,164 @@ mod tests { assert_eq!(frames.len(), 1); assert_eq!(frames[0], (sp, 0x8242_0000)); } + + /// KRNBUG-AUDIT-003: synthesize a C++ object with intact MSVC RTTI + /// in mapped guest memory. The probe must traverse vtable[-4] → + /// COL → TypeDescriptor and recover the decorated mangled name. + #[test] + fn read_class_at_this_resolves_intact_rtti() { + use xenia_memory::page_table::MemoryProtect; + let mem = GuestMemory::new().expect("memory init"); + let mut state = KernelState::new(); + let this = state.heap_alloc(0x40, &mem).expect("heap object"); + // Map an image-range page so vtable / COL / TypeDescriptor + // pointers pass `is_likely_image_ptr`. + let img = 0x8280_0000u32; + mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) + .expect("image-range page"); + let vtable = img + 0x40; + let col = img + 0x80; + let type_desc = img + 0xC0; + // [this] = vtable + mem.write_u32(this, vtable); + // vtable[-4] = COL (one word before the first virtual) + mem.write_u32(vtable.wrapping_sub(4), col); + // COL+0xC = TypeDescriptor + mem.write_u32(col + 12, type_desc); + // TypeDescriptor+8 = NUL-terminated mangled name + let name = b".?AVAsyncQueue@silph@@\0"; + for (i, b) in name.iter().enumerate() { + mem.write_u8(type_desc + 8 + i as u32, *b); + } + let r = read_class_at_this(this, &mem); + match r { + ClassReadout::Named { vtable: v, mangled } => { + assert_eq!(v, vtable); + assert_eq!(mangled, ".?AVAsyncQueue@silph@@"); + } + other => panic!("expected Named, got {:?}", other), + } + } + + /// RTTI-stripped fallback: vtable looks plausible but vtable[-4] is + /// zero. The probe must return `VtableOnly` with the first 4 virtual + /// PCs so the caller can resolve method names via the analysis DB. + #[test] + fn read_class_at_this_falls_back_when_rtti_stripped() { + use xenia_memory::page_table::MemoryProtect; + let mem = GuestMemory::new().expect("memory init"); + let mut state = KernelState::new(); + let this = state.heap_alloc(0x40, &mem).expect("heap object"); + let img = 0x8281_0000u32; + mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) + .expect("image-range page"); + let vtable = img + 0x100; + mem.write_u32(this, vtable); + // No COL — vtable[-4] left as zero, which fails `is_likely_image_ptr`. + // Populate first four virtuals with image-range PCs. + let virts = [0x8200_AAAA, 0x8201_BBBB, 0x8202_CCCC, 0x8203_DDDD]; + for (i, v) in virts.iter().enumerate() { + mem.write_u32(vtable + (i as u32) * 4, *v); + } + match read_class_at_this(this, &mem) { + ClassReadout::VtableOnly { + vtable: v, + virtuals, + } => { + assert_eq!(v, vtable); + assert_eq!(virtuals, virts); + } + other => panic!("expected VtableOnly, got {:?}", other), + } + } + + /// `this` outside the heap/image range, or `[this]` not in the image + /// range, must yield `NotAnObject` so the dump skips the candidate + /// without printing noise. + #[test] + fn read_class_at_this_rejects_non_objects() { + use xenia_memory::page_table::MemoryProtect; + let mem = GuestMemory::new().expect("memory init"); + let mut state = KernelState::new(); + // Out-of-range this. + assert_eq!( + read_class_at_this(0x0000_1234, &mem), + ClassReadout::NotAnObject + ); + assert_eq!( + read_class_at_this(0xFFFF_FFFF, &mem), + ClassReadout::NotAnObject + ); + // In-range `this`, but [this] is zero (unmapped → reads as 0, + // which is not a plausible image pointer). + let this = state.heap_alloc(0x40, &mem).expect("heap object"); + assert_eq!(read_class_at_this(this, &mem), ClassReadout::NotAnObject); + // In-range this, [this] points into the heap range — also rejected + // because vtables live in the image rdata. + mem.alloc(0x4500_0000, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) + .expect("aux heap page"); + mem.write_u32(this, 0x4500_0080); + assert_eq!(read_class_at_this(this, &mem), ClassReadout::NotAnObject); + } + + /// `probe_create_stack_classes` is the integration of the back-chain + /// walker output and the per-frame RTTI probe used at handle creation + /// time. Build a minimal 2-frame scenario where frame 1's + /// `[fp - 12]` saved-r31 slot points at a heap C++ object with intact + /// MSVC RTTI, and verify the helper produces a `class=...` line. + #[test] + fn probe_create_stack_classes_recovers_saved_r31_class() { + use xenia_memory::page_table::MemoryProtect; + let mem = GuestMemory::new().expect("memory init"); + let mut state = KernelState::new(); + // Heap-allocate a fake `this` and lay out vtable / COL / TD in + // an image-range page. + let this = state.heap_alloc(0x40, &mem).expect("heap object"); + let img = 0x8282_0000u32; + mem.alloc(img, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) + .expect("image-range page"); + let vtable = img + 0x40; + let col = img + 0x80; + let td = img + 0xC0; + mem.write_u32(this, vtable); + mem.write_u32(vtable.wrapping_sub(4), col); + mem.write_u32(col + 12, td); + for (i, b) in b".?AVDispatcher@silph@@\0".iter().enumerate() { + mem.write_u8(td + 8 + i as u32, *b); + } + // Synthesize a 2-frame back-chain. Place the saved-r31 slot at + // [frames[1].fp - 12] = `this`. + let stack_base = state.heap_alloc(0x4000, &mem).expect("stack page"); + let sp0 = stack_base + 0x100; + let sp1 = stack_base + 0x300; + mem.write_u32(sp1.wrapping_sub(12), this); + let frames = vec![(sp0, 0x824a_9f6c), (sp1, 0x8217_8500)]; + // Live ctx — r3 holds &Event (some random value, not a real + // class), r31/r30 zero so frame 0 produces no hits. + let mut ctx = PpcContext::new(); + ctx.gpr[3] = 0x4000_BEEF; + let probes = probe_create_stack_classes(&ctx, &frames, &mem); + assert!(probes.iter().any(|s| s.contains(".?AVDispatcher@silph@@")), + "expected probes to contain the dispatcher class, got {:?}", probes); + assert!(probes.iter().any(|s| s.contains("frame=1")), + "expected at least one frame=1 line, got {:?}", probes); + } + + /// A NUL-terminated ASCII string is read up to `max`; non-printable + /// bytes mark the candidate as bogus (return empty string). The + /// `.?A` prefix gating in `read_class_at_this` then rejects them. + #[test] + fn read_ascii_cstring_handles_termination_and_garbage() { + use xenia_memory::page_table::MemoryProtect; + let mem = GuestMemory::new().expect("memory init"); + mem.alloc(0x4000_0000, 0x1000, MemoryProtect::READ | MemoryProtect::WRITE) + .expect("page"); + let addr = 0x4000_0100u32; + // Plain NUL-terminated. + mem.write_bytes(addr, b"hello\0world"); + assert_eq!(read_ascii_cstring(&mem, addr, 32), "hello"); + // Non-printable byte should reject the read. + mem.write_u8(addr, 0x01); + assert_eq!(read_ascii_cstring(&mem, addr, 32), ""); + } }