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

@@ -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<String>,
/// Bounded ring of signal events.
pub signals: VecDeque<HandleAuditEntry>,
/// 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<String>,
) {
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);
}

View File

@@ -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<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
/// 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), "");
}
}