feat(kernel): KRNBUG-AUDIT-003 — vtable/RTTI class probe at handle creation + wait

Adds a read-only MSVC RTTI traversal helper (`read_class_at_this`)
and a `probe_create_stack_classes` integration that walks each
captured back-chain frame for handle creates in `--trace-handles-focus`
and probes each frame's most-likely `this` candidate (live r31/r30/r3
for frame 0; saved-r31/r30 from the prologue spill area at [fp-12]/
[fp-16] for deeper frames). False-positive guard rejects the CRT
static-init iterator pattern (vtable's first two slots must be image-
range function pointers — PPC instruction words like `mflr r12` are
not in 0x82xxxxxx).

`dump_thread_diagnostic` now takes `&GuestMemory` so the FOCUS report
prints, for each parked waiter, a WAIT-THREAD block with full back-
chain frames and per-slot saved-register dump for offline lookup.

End-to-end finding (-n 500M producer-trace):
  * Handle 0x100c dispatcher = 0x828F3D08 (image rdata; verified by
    sub_82181750 disasm + xref table). [this+0] = -1 sentinel — POD
    job queue, NOT a C++ polymorphic class.
  * Handle 0x15e0 dispatcher = 0x828F4070 (same shape).
  * Handle 0x1004's 8-instance pool members still TBD (MSVC ctors
    didn't preserve `this` in r31).
  * 0x42450b5c is a separate audit class (heap-allocated, parks via
    non-`do_wait_single` path).

Decisive xref audit: every reference to 0x828F3D08 / 0x828F4070 in
the static analysis is in a ctor or the CRT init driver. NO producer
code references either dispatcher base. Confirms `signal_attempts=0`
is unreachable-producer, not broken-producer.

Tests: 581 → 586 green (+5: RTTI-intact / RTTI-stripped / non-object
/ cstring / probe_create_stack integration). `--stable-digest -n
100M` instructions=100000002 unchanged. Master HEAD prior: 6440261.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-03 21:14:56 +02:00
parent 6440261e2e
commit f84e947547
4 changed files with 636 additions and 7 deletions

View File

@@ -4308,3 +4308,152 @@ matching message-push code path**. Steps:
The walker is reusable: any handle added to `--trace-handles-focus` The walker is reusable: any handle added to `--trace-handles-focus`
will get a 6-frame stack at creation time. Add new candidates will get a 6-frame stack at creation time. Add new candidates
freely — cost on the unfocused hot path is one `HashSet::contains`. 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<String>` + 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.

View File

@@ -1279,7 +1279,7 @@ fn cmd_exec_inner(
db.finalize_traces()?; db.finalize_traces()?;
} }
print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet); print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, quiet); dump_thread_diagnostic(&kernel, &*mem_arc, quiet);
info!( info!(
wall_ms = started.elapsed().as_millis() as u64, wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count, instructions = stats.instruction_count,
@@ -2968,7 +2968,11 @@ fn print_summary(
/// session see *which* thread is stuck *where* without having to re-derive /// session see *which* thread is stuck *where* without having to re-derive
/// it from ring-buffer / event-count math. Mirrors the halt-on-deadlock /// 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. /// 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 { if quiet {
return; 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 // Producer-class classification. Source strings are stable
// labels passed to `record_signal` at the export sites. // 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" "not stuck — signals consumed correctly"
}; };
println!(" => {}", conclusion); 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<xenia_cpu::ThreadRef> = 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_span = tracing::info_span!("cpu_worker");
let worker = std::thread::Builder::new() let worker = std::thread::Builder::new()
.name("xenia-cpu".into()) .name("xenia-cpu".into())
.spawn(move || -> Result<(ExecStats, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> { .spawn(move || -> Result<(ExecStats, xenia_memory::GuestMemory, xenia_kernel::KernelState, xenia_debugger::Debugger, Option<xenia_analysis::DbWriter>)> {
let _guard = worker_span.enter(); let _guard = worker_span.enter();
let stats = run_execution( let stats = run_execution(
&mut mem, &mut mem,
@@ -3354,7 +3457,7 @@ fn run_with_ui(
if let Some(ref mut db) = db_writer { if let Some(ref mut db) = db_writer {
db.finalize_traces()?; 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}"))?; .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). // flips the shutdown flag itself (e.g. after max_instructions).
xenia_ui::run(event_loop, ui_handles, &title_owned)?; 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?, Ok(res) => res?,
Err(_) => { Err(_) => {
return Err(anyhow::anyhow!("CPU worker thread panicked")); 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); print_summary(kernel.scheduler.ctx(0), &debugger, &db_writer, quiet);
dump_thread_diagnostic(&kernel, quiet); dump_thread_diagnostic(&kernel, &mem, quiet);
info!( info!(
wall_ms = started.elapsed().as_millis() as u64, wall_ms = started.elapsed().as_millis() as u64,
instructions = stats.instruction_count, instructions = stats.instruction_count,

View File

@@ -59,6 +59,14 @@ pub struct HandleAuditTrail {
/// from walking the PPC back-chain. An empty vec means either the /// 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. /// handle wasn't in focus or the create site didn't capture a stack.
pub created_stack: Vec<(u32, u32)>, 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<String>,
/// Bounded ring of signal events. /// Bounded ring of signal events.
pub signals: VecDeque<HandleAuditEntry>, pub signals: VecDeque<HandleAuditEntry>,
/// Bounded ring of wait-entry events (one per `Wait*` call). /// Bounded ring of wait-entry events (one per `Wait*` call).
@@ -73,6 +81,7 @@ impl HandleAuditTrail {
kind, kind,
created, created,
created_stack: Vec::new(), created_stack: Vec::new(),
created_class_probes: Vec::new(),
signals: VecDeque::with_capacity(AUDIT_RING_CAPACITY), signals: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
waits: VecDeque::with_capacity(AUDIT_RING_CAPACITY), waits: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
wakes: VecDeque::with_capacity(AUDIT_RING_CAPACITY), wakes: VecDeque::with_capacity(AUDIT_RING_CAPACITY),
@@ -141,12 +150,30 @@ impl HandleAudit {
kind: &'static str, kind: &'static str,
entry: HandleAuditEntry, entry: HandleAuditEntry,
stack: Vec<(u32, u32)>, 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<String>,
) { ) {
if !self.enabled { if !self.enabled {
return; return;
} }
let mut trail = HandleAuditTrail::new(kind, entry); let mut trail = HandleAuditTrail::new(kind, entry);
trail.created_stack = stack; trail.created_stack = stack;
trail.created_class_probes = class_probes;
self.trails.insert(handle, trail); self.trails.insert(handle, trail);
} }

View File

@@ -486,8 +486,9 @@ impl KernelState {
return; return;
} }
let stack = walk_guest_back_chain(ctx.gpr[1] as u32, lr, mem, 6); 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 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 /// 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<String> {
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 0x400000000x50000000;
/// the image and read-only-data are 0x820000000x83000000. 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 /// 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 /// the moment of capture). Returns up to `max_frames` entries of
/// `(frame_pointer, saved_lr)`. Index 0 is the live frame /// `(frame_pointer, saved_lr)`. Index 0 is the live frame
@@ -895,4 +1085,164 @@ mod tests {
assert_eq!(frames.len(), 1); assert_eq!(frames.len(), 1);
assert_eq!(frames[0], (sp, 0x8242_0000)); 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), "");
}
} }