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:
@@ -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<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 = std::thread::Builder::new()
|
||||
.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 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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), "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user