Merge xam-handle-stack-trace/p0-class-probe (KRNBUG-AUDIT-003)
vtable/RTTI class probe at handle creation + wait. Read-only
diagnostic; lockstep determinism preserved.
Tests 581 → 586 green. --stable-digest -n 100M instructions=100000002.
Identifies handle 0x100c dispatcher at 0x828F3D08 and handle 0x15e0
dispatcher at 0x828F4070 — both POD job queues, not C++ classes
(`[this+0]=-1` sentinel, no vtable). Decisive xref audit shows every
reference to either base is in a ctor or the CRT — NO producer code
exists in static analysis. Producer hunt deliverable: confirms
unreachable-producer, not broken-producer.
Master HEAD prior: 6440261.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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.
|
||||
|
||||
@@ -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