analysis: complete, correct and faster XEX static analysis

Reworks the `dis --db` static-analysis pipeline. Everything here is derived
from the XEX alone; no run traces.

Completeness
- `.pdata` is now authoritative for function boundaries rather than a hint:
  all 23,073 linker-declared RUNTIME_FUNCTIONs are emitted and `end_address`
  is the declared end, not a prologue-walk guess. Adds tail-call targets
  (with a preceding-terminator fallback for the ~450 KB of .text `.pdata`
  leaves unclaimed): 12,156 -> 25,676 functions.
- New `jumptables.rs`: 394 switch tables / 6,356 case edges recovered from
  941 `bctr` sites. Table words are flagged `instructions.is_data` and kept
  out of the xref pass, and render as `.long` with case labels in `-o`.
- New `rtti.rs`: RTTI is not stripped in this title. 611 type descriptors,
  653 locators, 1,491 base-class records, giving a real inheritance graph
  with mdisp/pdisp/vdisp. Verified against the STL:
  std::out_of_range : std::logic_error : std::exception.
- New `xdbf.rs` + `xenia-xex/resources.rs`: the XDBF/SPA package the XEX
  names via its resource table -- 24 achievements (1000G, matching the
  project's independently derived figure), 888 localized strings across 6
  locales, 25 PNGs, title name/id/version. Located through the entry table,
  not a magic scan; a scan finds a phantom 7th string table where the entry
  table declares 6, which shifts every scan-derived language index.
- Strings scan extended to `.data`; metadata grows from 5 keys to 41.

Correctness
- `instructions.function` was a rolling window -- set at each function start
  and never cleared -- so every word in a `.pdata` gap was attributed to the
  preceding function. 55,227 rows were wrong and the bogus 100% attribution
  hid the gap. Now cleared at `end_address`; 0 wrong, honest 97.0%.
- Shift-JIS scanning accepted half-width katakana, which turned IEEE-754
  float tables into "Japanese" (`3f 66 66 66` = 0.9f reads as "fff"), and
  emitted escaped bytes rather than text. 837 mostly-noise rows -> 115 real
  strings, decoded via encoding_rs, with resync so a run starting one byte
  early reports the true address instead of mangling the first character.
- `indirect_dispatch_candidates` was a cross product, not a resolution: at
  vptr_offset 0 the (offset, slot) match hits nearly every class, so 6,556
  of 6,983 sites produced 1.80M of 1.81M rows (one site claiming 764
  callees) and the derived ind_call edges were 84% of the xrefs table. Adds
  `--max-indirect-candidates` (default 16); over-ceiling sites keep their
  row with a truthful count and a new `truncated` flag but emit no candidate
  rows and no xrefs. 1.81M -> 4,199 candidates, xrefs 2.16M -> 357k.
- xenia-xex: `TLS_INFO` and `DEFAULT_STACK_SIZE` header keys were swapped.
  `get_stack_size()` would have returned the TLS descriptor's file offset;
  it has no callers today, so nothing regressed.

Performance
- DuckDB autocommits per statement, so row-at-a-time INSERT paid a
  transaction + WAL flush per row: 221k rows took 20 minutes and the 1.8M
  candidate rows ~59 more, for an 81-minute build that never finished. One
  big transaction fixes the time but not the cause (uncommitted state grew
  to 16.6 GB RSS). Every table above a few thousand rows now uses the
  Appender; each converted sink documents why its key cannot collide or
  dedupes first. 2m20s wall, 486 MB peak, DB 635 MB -> 318 MB.

Also: 6 new SQL views, `zq.py` gains switch/switches/classes/class/str/
xdbf/ach, and an `analysis_report` example that runs the passes without
building a database. 80 tests pass (29 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-10 19:57:14 +02:00
parent ed2615b9b0
commit ac20afb1a9
31 changed files with 3950 additions and 299 deletions

4
.gitignore vendored
View File

@@ -28,3 +28,7 @@ vkd3d-proton.cache*
# local analysis-DB backups (regenerable; too large to track)
*.db.bak*
sylpheed.db.bak-*
sylpheed.db.pre-*.bak
# full-disassembly listings from `dis -o` (133 MB on a retail title, regenerable)
*.asm

138
Cargo.lock generated
View File

@@ -1226,6 +1226,15 @@ dependencies = [
"strum",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endian-type"
version = "0.1.2"
@@ -1521,7 +1530,7 @@ dependencies = [
"vec_map",
"wasm-bindgen",
"web-sys",
"windows 0.62.2",
"windows 0.58.0",
]
[[package]]
@@ -2100,13 +2109,12 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.95"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
@@ -2244,7 +2252,7 @@ dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.4",
"redox_syscall 0.7.5",
]
[[package]]
@@ -3360,9 +3368,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
"bitflags 2.11.0",
]
@@ -4540,9 +4548,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
@@ -4554,9 +4562,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.68"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4564,9 +4572,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -4574,9 +4582,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -4587,9 +4595,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
@@ -4726,9 +4734,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.95"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -4908,23 +4916,12 @@ dependencies = [
[[package]]
name = "windows"
version = "0.62.2"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-collections",
"windows-core 0.62.2",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
"windows-core 0.58.0",
"windows-targets",
]
[[package]]
@@ -4936,28 +4933,41 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link",
"windows-result",
"windows-strings",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.3.2"
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"windows-core 0.62.2",
"windows-link",
"windows-threading",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
@@ -4971,6 +4981,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -4989,13 +5010,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-core 0.62.2",
"windows-link",
"windows-targets",
]
[[package]]
@@ -5007,6 +5027,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
@@ -5059,15 +5089,6 @@ dependencies = [
"windows_x86_64_msvc",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -5252,6 +5273,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"duckdb",
"encoding_rs",
"metrics",
"msvc-demangler",
"serde",

View File

@@ -13,8 +13,10 @@ for CPU context setup, kernel export behavior, and XEX loading semantics.
- **XEX loader** — XEX2 header parsing, LZX decompression, AES decryption, PE section parsing.
- **VFS / XISO** — XGD2 dual-layer disc images (with the 0x0FD90000 partition offset).
- **PPC interpreter** — 200+ opcodes, PowerPC 32/64-bit GPR/FPR, VMX128 decoding.
- **Static analyzer** — function discovery (prolog/epilog heuristics), cross-references, labels,
save/restore helper detection, assembly text + SQLite database output.
- **Static analyzer** — function discovery (`.pdata` ground truth + prolog/epilog heuristics),
cross-references, labels, save/restore helper detection, `switch`/jump-table recovery,
MSVC RTTI class + vtable + inheritance recovery, string scan, EH scope tables,
assembly text + DuckDB database output.
- **Kernel HLE** — minimal subset driving Project Sylpheed: ~170 xboxkrnl + xam exports
(critical sections, events, TLS, virtual memory, Vd stubs, XAM input/user/content).
- **Debugger** — in-memory step/break, SQLite execution + import-call + branch tracing.
@@ -70,10 +72,35 @@ Writes `<name>.pe` (decompressed/decrypted PE image) and `<name>.xex.json`
xenia-rs dis <xex-or-iso> [-o <asm-file>] [--db <sqlite-path>] [--quiet]
```
Runs function + cross-reference analysis and produces:
Runs the full static-analysis pipeline and produces:
- assembly text to stdout or `-o <file>` (unless `--quiet`)
- optional SQLite DB with the **base tables + disasm tables**:
`functions`, `labels`, `instructions`, `xrefs`
- optional DB with the **base tables + analysis tables**:
| group | tables |
|---|---|
| disasm | `instructions` (`is_data` flags jump-table words), `functions`, `labels`, `xrefs`, `pdata_entries` |
| switches | `jump_tables`, `jump_table_entries`, `data_in_code` |
| C++ / RTTI | `rtti_type_descriptors`, `rtti_locators`, `rtti_base_classes`, `vtables`, `methods`, `classes`, `demangled_names` |
| other | `strings`, `eh_funcinfo`/`eh_unwind_map`/`eh_try_blocks`, `function_pointer_arrays`, `indirect_dispatch_*`, `vptr_writes`, `tls_info`/`tls_callbacks` |
`--analyze sql` additionally creates query views: `v_switch_cases`,
`v_class_hierarchy`, `v_class_methods`, `v_function_strings`, `v_call_graph`,
`v_reachability_from_entry`, `v_indirect_reachability_from_entry`,
`v_branch_xrefs`, `v_function_first_instruction`, `v_imports_called`.
Function boundaries come from the linker's `.pdata` `RUNTIME_FUNCTION` table
where it covers the function (exact), and from prologue/epilogue walking
elsewhere (`functions.pdata_validated` says which). **Rows in `instructions`
with `is_data = true` are jump-table words, not code** — filter them out of any
instruction-level statistic.
To iterate on an analysis pass without paying for a full DB build:
```sh
cargo run --release -p xenia-analysis --example analysis_report -- <xex>
```
which runs the passes and prints their headline counts in seconds.
### `exec` — interpret with tracing

View File

@@ -15,3 +15,4 @@ tracing = { workspace = true }
metrics = { workspace = true }
duckdb = { workspace = true }
msvc-demangler = "0.11"
encoding_rs = "0.8"

View File

@@ -0,0 +1,90 @@
//! Fast static-analysis report — runs the passes that need only the XEX and
//! prints their headline counts, without building a database.
//!
//! `dis --db` builds and indexes ~2.4M rows, which is a couple of minutes even
//! after the Appender rework. When you are iterating on an analysis pass itself
//! that round-trip is still dead time, so this example runs just the passes —
//! all of which finish in well under a second — and prints what they found:
//!
//! ```text
//! cargo run --release -p xenia-analysis --example analysis_report -- <default.xex>
//! ```
use std::env;
fn main() -> anyhow::Result<()> {
let path = env::args().nth(1).ok_or_else(|| anyhow::anyhow!("usage: analysis_report <xex>"))?;
let data = std::fs::read(&path)?;
let mut header = xenia_xex::loader::parse_xex2_header(&data)?;
let entry = xenia_xex::loader::get_entry_point(&header)
.ok_or_else(|| anyhow::anyhow!("no entry point"))?;
let base = xenia_xex::loader::get_image_base(&header)
.ok_or_else(|| anyhow::anyhow!("no image base"))?;
let pe = xenia_xex::loader::load_image(&data, &header)?;
xenia_xex::loader::resolve_imports(&mut header, &pe);
let sections = xenia_xex::pe::parse_sections(&pe)?;
println!("image_base {base:#010x} entry {entry:#010x} sections {}", sections.len());
let code_sections: Vec<(u32, u32, u32)> = sections
.iter()
.filter(|s| s.is_code())
.map(|s| (s.virtual_address, s.virtual_size, s.flags))
.collect();
let pdata = xenia_xex::pdata::parse_pdata(&pe, base, &sections);
let fa = xenia_analysis::func::analyze_with_pdata(&pe, base, entry, &code_sections, &pdata);
let validated = fa.functions.values().filter(|f| f.pdata_validated).count();
let exact = fa
.functions
.values()
.filter(|f| f.pdata_length.is_some_and(|n| f.end - f.start == n))
.count();
println!(
"pdata entries {} functions {} pdata-validated {} boundary==pdata {} has_eh {}",
pdata.len(),
fa.functions.len(),
validated,
exact,
fa.functions.values().filter(|f| f.has_eh).count(),
);
if std::env::var("DUMP_FNS").is_ok() {
for (a, f) in &fa.functions {
println!("FN {a:08X} {:08X} {}", f.end, f.pdata_validated);
}
}
let (jts, rejects) = xenia_analysis::jumptables::analyze_with_stats(&pe, base, &sections, &fa);
let cases: usize = jts.iter().map(|t| t.targets.len()).sum();
let words = xenia_analysis::jumptables::data_word_addresses(&jts);
println!(
"jump tables {} ({} indexed) case entries {} data-in-code words {}",
jts.len(),
jts.iter().filter(|t| t.kind == "indexed").count(),
cases,
words.len(),
);
println!(" rejected: {rejects:?}");
if std::env::var("DUMP_JT").is_ok() {
for t in &jts {
println!("JT {:#010x} {:#010x} {} {}", t.bctr_pc, t.table_address, t.kind, t.entry_count);
}
}
let rtti = xenia_analysis::rtti::analyze(&pe, base, &sections);
println!(
"RTTI: type descriptors {} locators {} vtables {} base-class records {}",
rtti.type_descriptors.len(),
rtti.locators.len(),
rtti.vtable_to_locator.len(),
rtti.base_classes.len(),
);
for t in rtti.type_descriptors.iter().take(8) {
println!(" {:#010x} {}", t.address, t.demangled_name);
}
let strings = xenia_analysis::strings::analyze(&pe, base, &sections);
let in_data = strings.iter().filter(|s| s.section == ".data").count();
println!("strings {} ({} in .data)", strings.len(), in_data);
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -275,3 +275,102 @@ mod tests {
assert_eq!(parts, vec!["a", "b<c::d>", "e"]);
}
}
// ── RTTI type-descriptor names ─────────────────────────────────────────────
/// Demangle an RTTI `TypeDescriptor` decorated name into a readable class path.
///
/// These are not ordinary symbols: they are *type* encodings prefixed with a
/// literal `.`, e.g. `.?AVSilph@silph@@` → `silph::Silph`,
/// `.?AUGAME_PART_PARAM@silph@@` → `silph::GAME_PART_PARAM`.
///
/// A bare descriptor name is not a symbol the demangler accepts, and feeding it
/// one anyway silently mis-parses (`?AVSilph@silph@@` demangles to
/// `silph::AVSilph`, keeping the `AV` type tag as part of the class name). The
/// correct move is to rebuild the symbol MSVC would have emitted for this
/// descriptor — `??_R0<name>@8` — demangle *that*, and strip the
/// ``::`RTTI Type Descriptor' `` suffix and the leading type keyword. That path
/// is the only one that renders template arguments properly
/// (`.?AV?$vector@H@std@@` → `std::vector<int>`).
///
/// If the demangler still declines, the decorated name is decoded directly:
/// strip the `.?A[VU]` tag, split the remainder on `@`, and re-join the
/// components in reverse (MSVC emits innermost scope first). The
/// anonymous-namespace component `?A0x<hash>` becomes `(anonymous namespace)`.
///
/// Returns `None` only when the input is not a type descriptor at all.
pub fn demangle_type_descriptor(decorated: &str) -> Option<String> {
let body = decorated.strip_prefix('.')?;
if !(body.starts_with("?AV") || body.starts_with("?AU") || body.starts_with("?AW")) {
return None;
}
const RTTI_SUFFIX: &str = "::`RTTI Type Descriptor'";
if let Ok(full) = msvc_demangler::demangle(&format!("??_R0{body}@8"), DemangleFlags::llvm())
&& let Some(qualified) = full.trim().strip_suffix(RTTI_SUFFIX)
{
let name = qualified
.trim_start_matches("class ")
.trim_start_matches("struct ")
.trim_start_matches("enum ")
.trim_start_matches("union ")
.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
let inner = body[3..].trim_end_matches('@');
let mut parts: Vec<String> = inner
.split('@')
.filter(|p| !p.is_empty())
.map(|p| {
if p.starts_with("?A0x") {
"(anonymous namespace)".to_string()
} else {
p.to_string()
}
})
.collect();
if parts.is_empty() {
return None;
}
parts.reverse();
Some(parts.join("::"))
}
#[cfg(test)]
mod rtti_name_tests {
use super::demangle_type_descriptor;
#[test]
fn plain_class_in_namespace() {
assert_eq!(demangle_type_descriptor(".?AVSilph@silph@@").as_deref(), Some("silph::Silph"));
}
#[test]
fn struct_tag() {
assert_eq!(
demangle_type_descriptor(".?AUGAME_PART_PARAM@silph@@").as_deref(),
Some("silph::GAME_PART_PARAM"),
);
}
#[test]
fn global_scope_class() {
assert_eq!(demangle_type_descriptor(".?AVexception@std@@").as_deref(), Some("std::exception"));
}
#[test]
fn anonymous_namespace_is_named() {
let got = demangle_type_descriptor(".?AVAct_Stop@?A0x5cc05762@unnamed_namespaces@@").unwrap();
assert!(got.ends_with("Act_Stop"), "got {got}");
assert!(got.starts_with("unnamed_namespaces"), "got {got}");
}
#[test]
fn rejects_non_descriptors() {
assert_eq!(demangle_type_descriptor("?Foo@@QAEXXZ"), None);
assert_eq!(demangle_type_descriptor("plain_name"), None);
}
}

View File

@@ -5,7 +5,7 @@
//! label name. The three sinks in [`crate::sinks`] (text, JSON, DuckDB) all
//! consume `RichDisasmItem`.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use xenia_cpu::disasm::DisasmItem;
@@ -18,14 +18,30 @@ pub struct RichDisasmItem<'a> {
pub section: &'a str,
pub function: Option<u32>,
pub label: Option<&'a str>,
/// True when this word is data embedded in a code section (a recovered
/// jump table or its index map), so its decoded text is meaningless.
pub is_data: bool,
}
/// Walk one code section, yielding rich items annotated with section name,
/// rolling-window enclosing function, and label-at-address.
/// enclosing function, and label-at-address.
///
/// The `function` field tracks the most recent function-start the iterator
/// has crossed — matching the legacy `current_func` behaviour in
/// `db.rs::insert_instructions_streaming`.
/// `function` is the function that actually *contains* the address: it is set
/// on crossing a function start and cleared again at that function's
/// `end_address`. It is deliberately `None` in the gaps between functions.
///
/// It used to be a pure rolling window — set at each start and never cleared —
/// which silently attributed every gap word to whichever function happened to
/// precede it. On the reference title that mislabelled 55,227 instructions,
/// so `WHERE function = X` returned code that is not part of X, and the
/// resulting 100% attribution rate hid the fact that `.pdata` leaves ~450 KB
/// of `.text` unclaimed.
///
/// `data_words` is the set of 4-byte-aligned addresses inside code sections
/// that are known to hold data (see [`crate::jumptables::data_word_addresses`]).
/// Rows at those addresses are still emitted — their `raw` value is the table
/// entry a consumer wants — but flagged so nothing mistakes the decoded text
/// for a real instruction.
pub fn enrich_section<'a>(
image: &'a [u8],
image_base: u32,
@@ -34,18 +50,105 @@ pub fn enrich_section<'a>(
va_end: u32,
func_analysis: &'a FuncAnalysis,
labels: &'a HashMap<u32, String>,
data_words: &'a BTreeSet<u32>,
) -> impl Iterator<Item = RichDisasmItem<'a>> + 'a {
let mut current_func: Option<u32> = None;
// (start, end) of the function currently being walked.
let mut current: Option<(u32, u32)> = None;
xenia_cpu::disasm::iter_disasm(image, image_base, va_start, va_end).map(move |item| {
if func_analysis.is_function_start(item.addr) {
current_func = Some(item.addr);
// Leaving the current function must be handled before entering the
// next: a function often starts exactly at its predecessor's end.
if let Some((_, end)) = current
&& item.addr >= end
{
current = None;
}
if let Some(fi) = func_analysis.functions.get(&item.addr) {
current = Some((item.addr, fi.end));
}
let current_func = current.map(|(start, _)| start);
let label = labels.get(&item.addr).map(|s| s.as_str());
let is_data = data_words.contains(&item.addr);
RichDisasmItem {
item,
section: section_name,
function: current_func,
label,
is_data,
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::func::{FuncAnalysis, FuncInfo};
use std::collections::BTreeMap;
fn fi(start: u32, end: u32) -> FuncInfo {
FuncInfo {
start, end,
frame_size: 0, saved_gprs: 0, is_leaf: true, is_saverestore: false,
pdata_validated: true, pdata_length: Some(end - start),
pdata_prolog_length: None, has_eh: false,
}
}
/// A word in the gap between two functions belongs to neither. Before the
/// containment check this walker carried the *preceding* function forward
/// across the gap, so `WHERE function = X` returned code outside X.
#[test]
fn gap_between_functions_is_unattributed() {
let image_base = 0x82000000u32;
// 6 words: [f0 f0] [gap gap] [f1 f1]
let image = vec![0x60u8; 0x40]; // `ori` — decodes cleanly, value irrelevant
let mut functions = BTreeMap::new();
functions.insert(image_base, fi(image_base, image_base + 8));
functions.insert(image_base + 16, fi(image_base + 16, image_base + 24));
let fa = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let labels = HashMap::new();
let data_words = BTreeSet::new();
let got: Vec<(u32, Option<u32>)> = enrich_section(
&image, image_base, ".text", image_base, image_base + 24,
&fa, &labels, &data_words,
).map(|r| (r.item.addr, r.function)).collect();
assert_eq!(got, vec![
(image_base, Some(image_base)), // inside f0
(image_base + 4, Some(image_base)), // inside f0
(image_base + 8, None), // gap — was wrongly f0
(image_base + 12, None), // gap — was wrongly f0
(image_base + 16, Some(image_base + 16)), // f1 starts
(image_base + 20, Some(image_base + 16)),
]);
}
/// A function starting exactly at its predecessor's `end_address` must be
/// entered, not dropped: the leave check runs before the enter check.
#[test]
fn adjacent_functions_hand_over_cleanly() {
let image_base = 0x82000000u32;
let image = vec![0x60u8; 0x40];
let mut functions = BTreeMap::new();
functions.insert(image_base, fi(image_base, image_base + 8));
functions.insert(image_base + 8, fi(image_base + 8, image_base + 16));
let fa = FuncAnalysis {
functions, save_gpr_base: None, restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let labels = HashMap::new();
let data_words = BTreeSet::new();
let got: Vec<Option<u32>> = enrich_section(
&image, image_base, ".text", image_base, image_base + 16,
&fa, &labels, &data_words,
).map(|r| r.function).collect();
assert_eq!(got, vec![
Some(image_base), Some(image_base),
Some(image_base + 8), Some(image_base + 8),
]);
}
}

View File

@@ -1,6 +1,6 @@
//! Assembly text output formatter for Xbox 360 disassembly.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use xenia_xex::header::ImportLibrary;
@@ -20,6 +20,11 @@ pub struct DisasmInfo<'a> {
pub media_id: Option<u32>,
pub sections: &'a [PeSection],
pub import_libraries: &'a [ImportLibrary],
/// Full parsed XEX2 header, when the caller loaded from a XEX/ISO. Drives
/// the extended `metadata` rows (module/system/image flags, image size,
/// compression + encryption, per-library versions, …). `None` when the
/// caller only had a bare PE.
pub xex_header: Option<&'a xenia_xex::header::Xex2Header>,
}
/// Write full disassembly to the output stream.
@@ -32,6 +37,7 @@ pub fn write_asm(
import_map: &HashMap<u32, String>,
xrefs: &XrefMap,
data_annotations: &HashMap<u32, (u32, XrefKind)>,
data_words: &BTreeSet<u32>,
) -> anyhow::Result<()> {
// Header
writeln!(out, "; ============================================================================")?;
@@ -95,6 +101,7 @@ pub fn write_asm(
let items = enrich_section(
pe, info.image_base, &section.name, abs_start, abs_end, func_analysis, labels,
data_words,
);
for ri in items {
let abs_addr = ri.item.addr;

View File

@@ -39,6 +39,9 @@ pub struct FuncInfo {
/// Function size in bytes per `.pdata`'s `function_length` field, if known.
/// Absent (None) when this row is prologue-only.
pub pdata_length: Option<u32>,
/// Prolog size in bytes per `.pdata`'s `prolog_length` field, if known.
/// The linker's own count — more reliable than the prologue pattern match.
pub pdata_prolog_length: Option<u32>,
/// True when `.pdata`'s exception-flag bit is set on this entry — the
/// function has a registered C++ EH (or SEH) frame handler. Always false
/// for entries without `.pdata` coverage. (M9)
@@ -220,6 +223,19 @@ pub fn analyze(
/// - Does not edit the `prolog_length` we'd derive from prologue analysis;
/// `frame_size` and `saved_gprs` remain best-effort prologue inferences.
/// - Does not infer base/derived call edges — that's M3+M5.
/// - Does not discover functions that are neither in `.pdata` nor the target of
/// a `bl`. Some code does live in the `.pdata` gaps — small leaf helpers
/// reached only through a function-pointer table. Measured against a Ghidra
/// export of the reference title, 217 such entries exist that this pass does
/// not emit. Two obvious heuristics for them were evaluated and **rejected**:
/// "a data word that points into code outside any `.pdata` range" yields 1994
/// new candidates of which Ghidra confirms 37, and "8-byte-aligned word in a
/// gap, preceded by `blr` + padding" yields 4011 of which Ghidra confirms
/// 106. Either would flood `functions` with several thousand unvalidated
/// rows and destroy the property that every emitted boundary is exact, in
/// exchange for a couple of hundred real ones. If this gap needs closing, it
/// wants a real recursive-descent walk seeded from the function-pointer
/// tables, not a pattern match.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point), pdata_entries = pdata.len()))]
pub fn analyze_with_pdata(
pe: &[u8],
@@ -280,10 +296,79 @@ pub fn analyze_with_pdata(
call_targets.insert(e.begin_address);
}
}
// Tail-call targets.
//
// `bl ∪ pdata` misses a function that is only ever entered by a tail call:
// it has no `bl` site, and small frameless helpers are frequently absent
// from `.pdata`. `0x82169630` in the reference title is one — it follows a
// `b 0x825F0FDC` that ends the previous function and is itself reached only
// by `b`, so nothing in the union nominates it.
//
// `.pdata` makes the test exact: a non-linking `b` whose target leaves the
// source's own linker-declared range, and that does not land inside any
// other declared range, is entering a *different* function — not branching
// within this one. Intra-function jumps and switch arms both stay inside
// the range and are therefore never nominated.
let pdata_sorted: Vec<(u32, u32)> = {
let mut v: Vec<(u32, u32)> = pdata.iter().map(|e| (e.begin_address, e.end_address())).collect();
v.sort_unstable();
v
};
let containing = |addr: u32| -> Option<(u32, u32)> {
match pdata_sorted.binary_search_by_key(&addr, |&(b, _)| b) {
Ok(i) => Some(pdata_sorted[i]),
Err(0) => None,
Err(i) => {
let (b, e) = pdata_sorted[i - 1];
(addr < e).then_some((b, e))
}
}
};
//
// `.pdata` does not cover the whole of `.text` here — roughly 450 KB of
// code sits in gaps between declared ranges, and both ends of a tail call
// can land there. When the source has no declared range to compare
// against, fall back on the standard entry test: the target is a function
// start if the instruction *before* it ends a function (`blr`, `bctr`, or
// an unconditional `b`). Code placed immediately after a terminator is
// unreachable by fallthrough, so something must enter it there.
let ends_function = |addr: u32| -> bool {
match read_instr(pe, addr, image_base) {
Some(i) => is_blr(i) || is_bctr(i) || is_b(i).is_some(),
None => false,
}
};
let mut tail_call_targets = 0usize;
for &(start, end) in &code_ranges {
let mut addr = start;
while addr < end {
if let Some(instr) = read_instr(pe, addr, image_base)
&& let Some(target) = b_target(instr, addr)
&& !saverestore_addrs.contains(&target)
&& containing(target).is_none()
&& code_ranges.iter().any(|&(s, e)| target >= s && target < e)
&& match containing(addr) {
// Source is declared: a jump out of its own range is a
// tail call, one inside it is ordinary control flow.
Some((src_lo, src_hi)) => target < src_lo || target >= src_hi,
// Source is in an undeclared gap: fall back to the
// preceding-terminator test.
None => target >= 4 && ends_function(target - 4),
}
&& call_targets.insert(target)
{
tail_call_targets += 1;
}
addr += 4;
}
}
tracing::debug!(
candidates = call_targets.len(),
pdata_entries = pdata.len(),
"function candidates (bl ∪ pdata)"
tail_call_targets,
"function candidates (bl ∪ pdata ∪ tail-call)"
);
// 3. For each candidate, detect prologue and walk to epilogue. Pdata
@@ -300,29 +385,38 @@ pub fn analyze_with_pdata(
if let Some(p) = pdata_entry {
fi.pdata_validated = true;
fi.pdata_length = Some(p.function_length);
// bit 0 of the packed flags = exception-handler-present
fi.pdata_prolog_length = Some(p.prolog_length);
// `flags` bit 1 mirrors packed-word bit 31 = exception handler
// registered (see `xenia_xex::pdata`). Bit 0 is the 32-bit-code
// flag, which is set on essentially every PPC entry.
fi.has_eh = (p.flags & 0x2) != 0;
// If the prologue walk ended too early, trust pdata's length.
let pdata_end = p.begin_address.wrapping_add(p.function_length);
if pdata_end > fi.end {
fi.end = pdata_end;
// The linker's length is ground truth in BOTH directions: a
// prologue walk that ran past a `blr` into the next function is
// just as wrong as one that stopped early. Only a zero-length
// entry (never observed, but cheap to guard) falls back.
if p.function_length > 0 {
fi.end = p.begin_address.wrapping_add(p.function_length);
}
}
functions.insert(func_addr, fi);
} else if let Some(p) = pdata_entry {
// Orphan: pdata claims a function here but no prologue matched.
// Emit a synthetic entry so the row exists for downstream queries.
let end = p.begin_address.wrapping_add(p.function_length);
functions.insert(
func_addr,
FuncInfo {
start: func_addr,
end: p.begin_address.wrapping_add(p.function_length),
end,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
// A pdata orphan is usually a hand-written or fully inlined
// leaf; decide it from the body rather than guessing.
is_leaf: !range_has_call(pe, image_base, func_addr, end),
is_saverestore: false,
pdata_validated: true,
pdata_length: Some(p.function_length),
pdata_prolog_length: Some(p.prolog_length),
has_eh: (p.flags & 0x2) != 0,
},
);
@@ -343,6 +437,7 @@ pub fn analyze_with_pdata(
is_saverestore: true,
pdata_validated: pe_sb.is_some(),
pdata_length: pe_sb.map(|p| p.function_length),
pdata_prolog_length: pe_sb.map(|p| p.prolog_length),
has_eh: pe_sb.map(|p| (p.flags & 0x2) != 0).unwrap_or(false),
});
}
@@ -357,19 +452,57 @@ pub fn analyze_with_pdata(
is_saverestore: true,
pdata_validated: pe_rb.is_some(),
pdata_length: pe_rb.map(|p| p.function_length),
pdata_prolog_length: pe_rb.map(|p| p.prolog_length),
has_eh: pe_rb.map(|p| (p.flags & 0x2) != 0).unwrap_or(false),
});
}
// 5. Fix up `end_address` collisions: if function A's `end` overlaps
// function B's `start` (B > A), trim A. This catches mis-merged
// prologue walks where pdata revealed an interleaved second prologue.
// We do this in a single forward pass.
// 5. Reconcile candidate starts against the linker's ground truth.
//
// 5a. A `bl` whose target lands *strictly inside* a `.pdata`-validated
// function is not a second function — it is a branch into the middle
// of one (shared epilogue, computed-goto landing pad, or a
// mis-decoded word). Left in place such a candidate would truncate
// the real function at step 5b and orphan the rest of its body.
// The ranges come straight from `.pdata`, which the linker emits
// sorted and non-overlapping — the property the binary search needs.
// (Deriving them from `functions` instead would fold in the
// save/restore stub rows, whose `end` is a fixed block length rather
// than a pdata length and can therefore overlap a neighbour.)
let pdata_ranges: Vec<(u32, u32)> = pdata
.iter()
.filter(|e| e.function_length > 0)
.map(|e| (e.begin_address, e.end_address()))
.collect();
debug_assert!(pdata_ranges.windows(2).all(|w| w[0].1 <= w[1].0));
let interior: Vec<u32> = functions
.iter()
.filter(|(_, f)| !f.pdata_validated)
.map(|(&addr, _)| addr)
.filter(|&addr| {
pdata_ranges
.binary_search_by(|&(s, e)| {
if addr < s { std::cmp::Ordering::Greater }
else if addr >= e { std::cmp::Ordering::Less }
else { std::cmp::Ordering::Equal }
})
.is_ok()
})
.collect();
let interior_dropped = interior.len();
for addr in interior {
functions.remove(&addr);
}
// 5b. Trim overlaps that remain. Only prologue-only rows are trimmed —
// a `.pdata` length is authoritative and must survive intact even
// when a neighbouring heuristic row disagrees.
let starts: Vec<u32> = functions.keys().copied().collect();
for i in 0..starts.len().saturating_sub(1) {
let cur = starts[i];
let next = starts[i + 1];
if let Some(fi) = functions.get_mut(&cur)
&& !fi.pdata_validated
&& fi.end > next
{
fi.end = next;
@@ -383,6 +516,7 @@ pub fn analyze_with_pdata(
functions = functions.len(),
pdata_entries = pdata.len(),
pdata_validated = pdata_validated_count,
interior_candidates_dropped = interior_dropped,
elapsed_ms,
"function detection complete"
);
@@ -395,6 +529,25 @@ pub fn analyze_with_pdata(
}
}
/// True when `[start, end)` contains any linking branch — `bl`, `bcl`,
/// `bctrl` or `bclrl`. Used to classify pdata-only entries as leaf or not.
fn range_has_call(pe: &[u8], image_base: u32, start: u32, end: u32) -> bool {
let mut addr = start;
while addr < end {
let Some(instr) = read_instr(pe, addr, image_base) else { return false };
let opcode = op(instr);
// I-form / B-form with LK, and XL-form bclrl / bcctrl.
if (opcode == 18 || opcode == 16) && instr & 1 == 1 {
return true;
}
if opcode == 19 && instr & 1 == 1 && matches!(bits(instr, 30, 21), 16 | 528) {
return true;
}
addr = addr.wrapping_add(4);
}
false
}
/// Analyze a single function starting at `func_addr`.
fn analyze_function(
pe: &[u8],
@@ -509,6 +662,7 @@ fn analyze_function(
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
})
}

View File

@@ -72,6 +72,10 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use crate::func::FuncAnalysis;
use crate::vtables::Vtable;
/// Default ceiling on how many candidates a single dispatch site may
/// materialise. See [`analyze`].
pub const DEFAULT_MAX_CANDIDATES: usize = 16;
/// One detected dispatch site after typed resolution.
#[derive(Debug, Clone)]
pub struct TypedDispatch {
@@ -79,9 +83,16 @@ pub struct TypedDispatch {
pub vptr_offset: u32,
pub slot: u32,
/// Set of candidate vtable addresses whose `(vptr_offset, slot)` matched.
/// Empty when [`Self::truncated`] is set.
pub candidate_vtables: Vec<u32>,
/// Set of resolved method PCs (one per candidate vtable).
/// Empty when [`Self::truncated`] is set.
pub method_pcs: Vec<u32>,
/// How many candidates matched, whether or not they were materialised.
pub total_candidates: usize,
/// True when `total_candidates` exceeded the ceiling, so the per-candidate
/// vectors were dropped. The site itself is still reported.
pub truncated: bool,
}
/// Result of the M5.5 pass.
@@ -117,6 +128,7 @@ pub fn analyze(
func_analysis: &FuncAnalysis,
vtables: &[Vtable],
labels: &HashMap<u32, String>,
max_candidates: usize,
) -> TypedIndirectResult {
let started = std::time::Instant::now();
@@ -138,13 +150,37 @@ pub fn analyze(
}
// Phase 3 + 4: scan dispatches and emit edges.
let dispatches = scan_dispatches_and_resolve(
let mut dispatches = scan_dispatches_and_resolve(
pe, image_base, func_analysis, &block_boundaries,
&vtables_by_offset, &vtable_by_addr,
);
// Drop the per-candidate lists for sites the analysis could not narrow.
//
// A site is resolved by matching `(vptr_offset, slot)` against every class
// seen installing a vtable at that offset. When the offset is 0 — a
// single-inheritance `this->vptr` — that matches essentially every class in
// the binary, so the "resolution" degenerates into a cross product: on the
// reference title 6,556 of 6,983 sites produced 1.80M of the 1.81M
// candidate rows, one site claiming 764 different callees. Those rows are
// not evidence about the callee, and they swamped `xrefs` (84% of it) and
// dominated the database file.
//
// The site row is still emitted with a truthful `total_candidates`, so
// "this is an unresolved virtual call with N possibilities" remains
// queryable — only the meaningless enumeration is dropped.
let mut truncated = 0usize;
for d in &mut dispatches {
if d.total_candidates > max_candidates {
d.candidate_vtables.clear();
d.method_pcs.clear();
d.truncated = true;
truncated += 1;
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
let single_candidate = dispatches.iter().filter(|d| d.candidate_vtables.len() == 1).count();
let single_candidate = dispatches.iter().filter(|d| d.total_candidates == 1).count();
let multi_candidate = dispatches.len() - single_candidate;
let total_edges: usize = dispatches.iter().map(|d| d.method_pcs.len()).sum();
metrics::histogram!("analysis.phase_ms", "phase" => "ind_dispatch_typed").record(elapsed_ms);
@@ -154,6 +190,8 @@ pub fn analyze(
dispatches = dispatches.len(),
single = single_candidate,
multi = multi_candidate,
truncated,
max_candidates,
edges = total_edges,
elapsed_ms,
"M5.5 typed indirect-dispatch scan complete",
@@ -398,6 +436,7 @@ fn try_resolve_dispatch_site(
}
}
if method_pcs.is_empty() { return None; }
let total_candidates = candidate_vtables.len();
Some(TypedDispatch {
dispatch_pc: bcctrl_pc,
@@ -405,6 +444,8 @@ fn try_resolve_dispatch_site(
slot,
candidate_vtables,
method_pcs,
total_candidates,
truncated: false,
})
}
@@ -464,6 +505,7 @@ mod tests {
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
});
FuncAnalysis { functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new() }
@@ -521,12 +563,12 @@ mod tests {
fa.functions.insert(disp_pc, FuncInfo {
start: disp_pc, end: disp_pc + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let vt = mk_vtable(0x82010000, vec![0xAA, 0xBB, 0xCC, 0xDD]);
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &fa, &[vt], &labels);
let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX);
assert_eq!(r.vptr_writes.len(), 1);
assert_eq!(r.vptr_writes[0].vtable_addr, 0x82010000);
@@ -541,9 +583,11 @@ mod tests {
assert_eq!(d.candidate_vtables, vec![0x82010000]);
}
#[test]
fn multi_candidate_emits_one_edge_per_match() {
let image_base = 0x82000000u32;
/// Two classes installing different vtables at offset 0, and one dispatch
/// at slot 1 that therefore matches both.
fn multi_candidate_fixture(image_base: u32)
-> (Vec<u8>, FuncAnalysis, Vec<crate::vtables::Vtable>, HashMap<u32, String>)
{
let mut pe = vec![0u8; 0x4000];
// Two ctors, each writing a different vtable at offset 0.
@@ -560,12 +604,12 @@ mod tests {
fa.functions.insert(ctor_b, FuncInfo {
start: ctor_b, end: ctor_b + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
fa.functions.insert(disp, FuncInfo {
start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let vts = vec![
@@ -573,7 +617,14 @@ mod tests {
mk_vtable(0x82010040, vec![0x55, 0x66, 0x77, 0x88]),
];
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &fa, &vts, &labels);
(pe, fa, vts, labels)
}
#[test]
fn multi_candidate_emits_one_edge_per_match() {
let image_base = 0x82000000u32;
let (pe, fa, vts, labels) = multi_candidate_fixture(image_base);
let r = analyze(&pe, image_base, &fa, &vts, &labels, usize::MAX);
assert_eq!(r.vptr_writes.len(), 2);
assert_eq!(r.dispatches.len(), 1);
@@ -599,12 +650,12 @@ mod tests {
fa.functions.insert(disp, FuncInfo {
start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]);
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &fa, &[vt], &labels);
let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX);
assert_eq!(r.dispatches.len(), 0);
}
@@ -625,12 +676,36 @@ mod tests {
fa.functions.insert(disp, FuncInfo {
start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]);
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &fa, &[vt], &labels);
let r = analyze(&pe, image_base, &fa, &[vt], &labels, usize::MAX);
assert_eq!(r.dispatches.len(), 0);
}
/// A site the resolver cannot narrow keeps its row and its true count, but
/// stops enumerating: those rows were 84% of `xrefs` and carried no
/// evidence about the callee.
#[test]
fn ceiling_truncates_unresolved_sites_without_losing_them() {
let image_base = 0x82000000u32;
let (pe, fa, vts, labels) = multi_candidate_fixture(image_base);
let unbounded = analyze(&pe, image_base, &fa, &vts, &labels, usize::MAX);
let d = &unbounded.dispatches[0];
assert_eq!(d.total_candidates, 2);
assert_eq!(d.method_pcs.len(), 2);
assert!(!d.truncated);
// Same binary, ceiling of 1: the site survives, the enumeration does not.
let bounded = analyze(&pe, image_base, &fa, &vts, &labels, 1);
let d = &bounded.dispatches[0];
assert_eq!(bounded.dispatches.len(), unbounded.dispatches.len());
assert!(d.truncated);
assert_eq!(d.total_candidates, 2, "count stays truthful");
assert!(d.method_pcs.is_empty(), "no speculative edges");
assert!(d.candidate_vtables.is_empty());
}
}

View File

@@ -374,6 +374,7 @@ mod tests {
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
});
let func_analysis = FuncAnalysis {
@@ -415,6 +416,7 @@ mod tests {
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
});
let func_analysis = FuncAnalysis {
@@ -450,6 +452,7 @@ mod tests {
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
});
let func_analysis = FuncAnalysis {

View File

@@ -0,0 +1,758 @@
//! Switch-statement (jump-table) recovery for MSVC PowerPC `bctr` dispatch.
//!
//! The Xbox 360 MSVC compiler lowers a dense `switch` to a table of **absolute
//! target VAs** that is emitted *inline in `.text`*, immediately after the
//! dispatching `bctr` in the common case. The canonical shape is:
//!
//! ```text
//! cmplwi rIdx, N ; bound check — N is the largest case index
//! bgt default ; out-of-range → default label
//! lis r12, tab@h ; addis r12, r0, hi
//! addi r12, r12, tab@l ; r12 = &table
//! rlwinm r0, rIdx, 2, 22, 29 ; r0 = idx * 4
//! lwzx r0, r12, r0 ; r0 = table[idx]
//! mtctr r0
//! bctr ; → case body
//! .long case0, case1, ... ; the table itself, inline in .text
//! ```
//!
//! Sparse switches add a second, byte-wide *index map* read with `lbzx`:
//! `slot = map[idx]` then `target = table[slot]`, which lets several case
//! values share one body without a full-width table. Both tables are
//! recovered; for the two-level form the emitted `targets` vector is already
//! **expanded per case value** (`targets[i] = table[map[i]]`), so consumers
//! never have to redo the indirection.
//!
//! # Why this matters
//!
//! Without this pass the `bctr` is a dead end: the case bodies have no
//! incoming edge (they are unreachable in `v_reachability_from_entry`), and —
//! worse — the table words themselves are linearly disassembled as if they
//! were instructions, so `instructions` carries thousands of bogus rows in the
//! middle of otherwise correct functions. This module fixes both: it emits
//! `jump` xrefs for every case target and reports the table extents as
//! data-in-code regions so the disassembler can mark those words.
//!
//! # Validation
//!
//! A candidate table is accepted only while its entries land **inside the
//! enclosing function** (per the `.pdata`-validated boundary from
//! [`crate::func`]). That is not a heuristic softener — a `switch` always
//! branches within its own function — and on the reference title it holds for
//! 100% of recovered entries, which is what lets the scan terminate a table
//! without needing the `cmplwi` bound. When the enclosing function is unknown
//! the weaker "inside some code section" test is used instead.
//!
//! # Limits
//!
//! - Only `bctr` (tail dispatch) is considered. `bctrl` is a call through a
//! function pointer — that is [`crate::indirect`]'s job, not a switch.
//! - The constant tracker is a straight-line, single-block model over a fixed
//! backward window. Table bases materialised across a branch, or through a
//! register the model conservatively invalidates, are missed (no false
//! positives result — target validation still gates every emission).
use std::collections::BTreeSet;
use xenia_xex::pe::PeSection;
use crate::func::FuncAnalysis;
/// How far back from a `bctr` the constant tracker looks for the table setup.
/// MSVC emits the whole sequence within a handful of instructions; 48 is far
/// beyond what any observed switch needs and still bounds the scan cost.
const WINDOW_INSTRS: u32 = 48;
/// Hard ceiling on entries read from a table whose extent cannot be bounded by
/// the enclosing function (only reached when the function is unknown).
const MAX_ENTRIES: u32 = 4096;
/// Why a `bctr` did not yield a table. Counted per image and logged, so a
/// coverage regression shows up as a shift between buckets rather than as a
/// silently smaller table count.
#[derive(Debug, Default, Clone, Copy)]
pub struct RejectCounts {
/// CTR was not loaded by an `lwzx` in the window — an ordinary indirect
/// tail-call (through a vtable slot or a function-pointer field).
pub not_table_driven: u32,
/// The `lwzx` operands did not resolve to exactly one code-range constant.
pub base_unresolved: u32,
/// A table base resolved, but fewer than two entries validated.
pub too_few_entries: u32,
}
/// One recovered switch dispatch.
#[derive(Debug, Clone)]
pub struct JumpTable {
/// VA of the dispatching `bctr`.
pub bctr_pc: u32,
/// VA of the enclosing function, when known.
pub function: Option<u32>,
/// VA of the table of absolute case targets.
pub table_address: u32,
/// Number of `targets` entries (case values, after index-map expansion).
pub entry_count: u32,
/// Number of 4-byte slots occupied by the target table itself. Equal to
/// `entry_count` for a direct table; for a two-level table it is the
/// highest map slot actually used + 1, which is what bounds the raw table.
pub table_slots: u32,
/// VA of the byte-wide index map for a two-level (sparse) switch.
pub index_map_address: Option<u32>,
/// Number of bytes read from the index map (= `bound + 1`).
pub index_map_count: Option<u32>,
/// Largest valid case index per the `cmplwi rIdx, N` bound check, when the
/// compare was found in the window.
pub bound: Option<u32>,
/// `"direct"` — `target = table[idx]`; `"indexed"` — `target =
/// table[map[idx]]`.
pub kind: &'static str,
/// Case target VA per case value, in case order. May repeat.
pub targets: Vec<u32>,
}
impl JumpTable {
/// Byte extents of the raw tables, for marking data-in-code.
/// Returns `(address, length)` pairs.
pub fn data_regions(&self) -> Vec<(u32, u32)> {
let mut out = Vec::with_capacity(2);
out.push((self.table_address, self.table_slots.saturating_mul(4)));
if let (Some(addr), Some(n)) = (self.index_map_address, self.index_map_count) {
out.push((addr, n));
}
out
}
/// Distinct case bodies this dispatch can reach, sorted.
pub fn distinct_targets(&self) -> Vec<u32> {
let mut t = self.targets.clone();
t.sort_unstable();
t.dedup();
t
}
}
// ── Instruction field helpers ──────────────────────────────────────────────
const BCTR: u32 = 0x4E80_0420;
fn op(i: u32) -> u32 { i >> 26 }
fn rt(i: u32) -> usize { ((i >> 21) & 0x1F) as usize }
fn ra(i: u32) -> usize { ((i >> 16) & 0x1F) as usize }
fn rb(i: u32) -> usize { ((i >> 11) & 0x1F) as usize }
fn xo(i: u32) -> u32 { (i >> 1) & 0x3FF }
fn simm(i: u32) -> i32 { ((i & 0xFFFF) as i16) as i32 }
fn uimm(i: u32) -> u32 { i & 0xFFFF }
/// `mtctr rS` — `mtspr` (op 31, xo 467) with the split SPR field naming CTR (9).
fn is_mtctr(i: u32) -> bool {
if op(i) != 31 || xo(i) != 467 { return false; }
let spr_field = (i >> 11) & 0x3FF;
(((spr_field & 0x1F) << 5) | (spr_field >> 5)) == 9
}
/// Which GPR (if any) an `op 31` instruction writes.
///
/// The model errs toward *over*-invalidation: an unrecognised `op 31` form is
/// assumed to clobber its `rT` field. Losing a tracked constant only costs a
/// missed table; it cannot invent one, because every emitted target is
/// range-validated against the enclosing function.
fn op31_dest(i: u32) -> Option<usize> {
// Logical / shift / sign-extend X-forms: destination is `rA` (bits 16..20).
const WRITES_RA: &[u32] = &[
24, 26, 27, 28, 58, 60, 124, 284, 316, 412, 444, 476,
536, 539, 792, 794, 824, 826, 827, 922, 954, 986,
];
// Stores, compares, traps, cache/sync ops and `mtspr`/`mtcrf`: no GPR write.
const NO_GPR: &[u32] = &[
0, 4, 32, 68, // cmp, tw, cmpl, td
150, 151, 215, 407, 662, 918, 660, 727, 231, // stwcx./stwx/stbx/sthx/stfsx/stfdx/…
144, 467, 512, 598, 854, 982, 1014, 86, 470, 54, // mtcrf/mtspr/mcrxr/sync/dcb*/icbi
];
// Store-*update* forms write back into `rA`.
if matches!(xo(i), 183 | 247 | 439 | 181 | 693 | 759) {
return Some(ra(i));
}
let x = xo(i);
if NO_GPR.contains(&x) { return None; }
if WRITES_RA.contains(&x) { return Some(ra(i)); }
Some(rt(i))
}
// ── Main analysis ──────────────────────────────────────────────────────────
/// Recover every dense/sparse switch dispatch in the image's code sections.
pub fn analyze(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
func_analysis: &FuncAnalysis,
) -> Vec<JumpTable> {
analyze_with_stats(pe, image_base, sections, func_analysis).0
}
/// Like [`analyze`], but also returns the per-reason reject tally — the same
/// numbers the pass logs, for callers that want to assert on coverage.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze_with_stats(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
func_analysis: &FuncAnalysis,
) -> (Vec<JumpTable>, RejectCounts) {
let started = std::time::Instant::now();
let code_ranges: Vec<(u32, u32)> = sections
.iter()
.filter(|s| s.is_code())
.map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size))
.collect();
let read = |va: u32| -> Option<u32> {
let off = va.wrapping_sub(image_base) as usize;
if off.checked_add(4)? > pe.len() { return None; }
Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]))
};
let in_code = |va: u32| code_ranges.iter().any(|&(s, e)| va >= s && va < e);
let mut out: Vec<JumpTable> = Vec::new();
let mut rejects = RejectCounts::default();
let mut sites = 0u32;
for &(sec_start, sec_end) in &code_ranges {
let mut pc = sec_start;
while pc < sec_end {
let Some(instr) = read(pc) else { break };
if instr == BCTR {
sites += 1;
if let Some(jt) =
recover_at(pc, sec_start, &read, &in_code, func_analysis, &mut rejects)
{
out.push(jt);
}
}
pc = pc.wrapping_add(4);
}
}
let entries: usize = out.iter().map(|t| t.targets.len()).sum();
let indexed = out.iter().filter(|t| t.kind == "indexed").count();
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "jumptables").record(elapsed_ms);
tracing::info!(
bctr_sites = sites,
jump_tables = out.len(),
indexed,
case_targets = entries,
rejected_not_table_driven = rejects.not_table_driven,
rejected_base_unresolved = rejects.base_unresolved,
rejected_too_few_entries = rejects.too_few_entries,
elapsed_ms,
"jump-table scan complete",
);
(out, rejects)
}
/// Try to recover a jump table for the `bctr` at `bctr_pc`.
fn recover_at(
bctr_pc: u32,
sec_start: u32,
read: &impl Fn(u32) -> Option<u32>,
in_code: &impl Fn(u32) -> bool,
func_analysis: &FuncAnalysis,
rejects: &mut RejectCounts,
) -> Option<JumpTable> {
let containing = func_analysis
.functions
.range(..=bctr_pc)
.next_back()
.filter(|(_, fi)| bctr_pc < fi.end);
// Only a `.pdata`-validated range is usable as a table bound. A
// prologue-only row's `end` comes from an epilogue walk that stops at the
// first `blr`/`bctr` — i.e. at *this* dispatch — so every case body would
// fall "outside the function" and the table would be rejected wholesale.
let enclosing = containing
.filter(|(_, fi)| fi.pdata_validated)
.map(|(&a, fi)| (a, fi.end));
// The window clamp is safe with either kind of row: it only limits how far
// back the constant tracker looks.
let window_start = {
let by_window = bctr_pc.saturating_sub(WINDOW_INSTRS * 4);
let by_func = containing.map(|(&a, _)| a).unwrap_or(sec_start);
by_window.max(by_func).max(sec_start)
};
// Straight-line constant propagation over [window_start, bctr_pc).
let mut regs: [Option<u32>; 32] = [None; 32];
let mut lwzx_dest: Option<usize> = None; // rT of the last lwzx
let mut lwzx_regs: Option<(Option<u32>, Option<u32>)> = None;
let mut lwzx_index_tainted = false; // did the index come from the lbzx?
let mut lbzx_regs: Option<(Option<u32>, Option<u32>)> = None;
let mut ctr_src: Option<usize> = None; // rS of the last mtctr
let mut bound: Option<u32> = None;
// Taint: "this register holds a value derived from the byte the `lbzx`
// read". Only a register carrying that taint may serve as the jump table's
// index in the two-level form — otherwise any unrelated `lbzx` in the
// window (there are plenty; games read bytes constantly) would be mistaken
// for a case index map.
let mut from_lbzx = [false; 32];
let mut pc = window_start;
while pc < bctr_pc {
let Some(i) = read(pc) else { return None };
match op(i) {
// addis rT, rA, SIMM (lis when rA == 0)
15 => {
let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] };
regs[rt(i)] = base.map(|b| b.wrapping_add(uimm(i) << 16));
}
// addi rT, rA, SIMM (li when rA == 0)
14 => {
let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] };
regs[rt(i)] = base.map(|b| b.wrapping_add(simm(i) as u32));
}
// ori / oris rA, rS, UIMM
24 => regs[ra(i)] = regs[rt(i)].map(|b| b | uimm(i)),
25 => regs[ra(i)] = regs[rt(i)].map(|b| b | (uimm(i) << 16)),
// cmplwi / cmpwi rA, IMM — the switch's bound check.
10 | 11 => bound = Some(uimm(i)),
31 => {
match xo(i) {
23 => { // lwzx rT, rA, rB — the table read
lwzx_dest = Some(rt(i));
lwzx_regs = Some((regs[ra(i)], regs[rb(i)]));
lwzx_index_tainted = from_lbzx[ra(i)] || from_lbzx[rb(i)];
regs[rt(i)] = None;
from_lbzx[rt(i)] = false;
}
87 => { // lbzx rT, rA, rB — the sparse index-map read
lbzx_regs = Some((regs[ra(i)], regs[rb(i)]));
regs[rt(i)] = None;
from_lbzx = [false; 32];
from_lbzx[rt(i)] = true;
}
467 if is_mtctr(i) => ctr_src = Some(rt(i)),
// `mr rA, rS` is `or rA, rS, rS` — propagate constant + taint.
444 if rt(i) == rb(i) => {
regs[ra(i)] = regs[rt(i)];
from_lbzx[ra(i)] = from_lbzx[rt(i)];
}
// `add rT, rA, rB` / `slw rA, rS, rB` also carry the index.
266 => {
regs[rt(i)] = None;
from_lbzx[rt(i)] = from_lbzx[ra(i)] || from_lbzx[rb(i)];
}
24 => {
regs[ra(i)] = None;
from_lbzx[ra(i)] = from_lbzx[rt(i)];
}
_ => {
if let Some(d) = op31_dest(i) {
regs[d] = None;
from_lbzx[d] = false;
}
}
}
}
// rlwinm / rlwnm / rlwimi write rA — and are how a byte index gets
// scaled to a word offset, so they carry the taint through.
20 | 21 | 23 => {
regs[ra(i)] = None;
from_lbzx[ra(i)] = from_lbzx[rt(i)];
}
// D/DS-form GPR loads write rT; the update forms also write rA.
32 | 34 | 40 | 42 => regs[rt(i)] = None,
33 | 35 | 41 | 43 => { regs[rt(i)] = None; regs[ra(i)] = None; }
// DS-form: bits 30..31 pick ld(0) / ldu(1) / lwa(2).
58 => {
regs[rt(i)] = None;
if i & 3 == 1 { regs[ra(i)] = None; }
}
// lmw loads rT..r31.
46 => for r in rt(i)..32 { regs[r] = None; },
// FP loads touch no GPR — except the update forms, which write rA.
// Plain stores write no register at all (their `rT` field is the
// *source*), so a tracked base that merely gets spilled survives.
37 | 39 | 45 | 49 | 51 | 53 | 55 => regs[ra(i)] = None,
// DS-form: bits 30..31 pick std(0) / stdu(1); only stdu writes rA.
62 if i & 3 == 1 => regs[ra(i)] = None,
// Immediate ALU: 7/8/12/13 write rT, 28/29 (andi./andis.) write rA.
7 | 8 | 12 | 13 => regs[rt(i)] = None,
28 | 29 => regs[ra(i)] = None,
_ => {}
}
pc = pc.wrapping_add(4);
}
// CTR must actually be loaded from the table read — otherwise the `lwzx`
// in the window belongs to unrelated code and the `bctr` is a plain
// indirect tail-call.
if ctr_src.is_none() || ctr_src != lwzx_dest {
rejects.not_table_driven += 1;
return None;
}
let Some((a_val, b_val)) = lwzx_regs else {
rejects.not_table_driven += 1;
return None;
};
// Exactly one operand of the table read must be a constant that lands in
// code — the other is the scaled index. Two constants is ambiguous.
let table_address = match (a_val.filter(|&v| in_code(v)), b_val.filter(|&v| in_code(v))) {
(Some(v), None) | (None, Some(v)) => v,
_ => {
rejects.base_unresolved += 1;
return None;
}
};
// Same test for the sparse index-map base — but only when the byte that
// `lbzx` produced actually reached the table read as its index.
let index_map_address = lbzx_regs.filter(|_| lwzx_index_tainted).and_then(|(a, b)| {
match (a.filter(|&v| in_code(v)), b.filter(|&v| in_code(v))) {
(Some(v), None) | (None, Some(v)) => Some(v),
_ => None,
}
});
// A recovered target is valid only inside the enclosing function; that is
// exact for a `switch`, and on the reference title it holds for 100% of
// recovered entries. Where no `.pdata` range covers the dispatch there is
// no trustworthy bound, so the compiler's own `cmplwi` bound is required
// instead and targets are only checked for being code at all.
if enclosing.is_none() && bound.is_none() {
rejects.base_unresolved += 1;
return None;
}
let valid = |t: u32| match enclosing {
Some((s, e)) => t >= s && t < e,
None => in_code(t),
};
if let (Some(map_addr), Some(n)) = (index_map_address, bound) {
// Two-level: expand map[0..=bound] through the table in one shot.
let count = n.saturating_add(1).min(MAX_ENTRIES);
let mut targets = Vec::with_capacity(count as usize);
let mut max_slot = 0u32;
for i in 0..count {
let byte_off = map_addr.wrapping_add(i);
let word = read(byte_off & !3)?;
let slot = (word >> (8 * (3 - (byte_off & 3)))) & 0xFF;
let t = read(table_address.wrapping_add(slot * 4))?;
if !valid(t) { break; }
max_slot = max_slot.max(slot);
targets.push(t);
}
if targets.len() < 2 {
rejects.too_few_entries += 1;
return None;
}
let n_read = targets.len() as u32;
return Some(JumpTable {
bctr_pc,
function: enclosing.map(|(s, _)| s),
table_address,
entry_count: n_read,
table_slots: max_slot + 1,
index_map_address,
index_map_count: Some(n_read),
bound,
kind: "indexed",
targets,
});
}
// Dense: read consecutive absolute targets until one leaves the function.
let cap = bound.map(|n| n.saturating_add(1)).unwrap_or(MAX_ENTRIES).min(MAX_ENTRIES);
let mut targets = Vec::new();
for i in 0..cap {
let Some(t) = read(table_address.wrapping_add(i * 4)) else { break };
if !valid(t) { break; }
targets.push(t);
}
if targets.len() < 2 {
rejects.too_few_entries += 1;
return None;
}
Some(JumpTable {
bctr_pc,
function: enclosing.map(|(s, _)| s),
table_address,
entry_count: targets.len() as u32,
table_slots: targets.len() as u32,
index_map_address: None,
index_map_count: None,
bound,
kind: "direct",
targets,
})
}
/// Collapse every recovered table's raw extents into a sorted, merged interval
/// list of data-in-code byte ranges.
pub fn data_regions(tables: &[JumpTable]) -> Vec<(u32, u32)> {
let mut regions: Vec<(u32, u32)> = tables
.iter()
.flat_map(|t| t.data_regions())
.filter(|&(_, len)| len > 0)
.collect();
regions.sort_unstable();
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(regions.len());
for (addr, len) in regions {
match merged.last_mut() {
Some((p_addr, p_len)) if addr <= p_addr.wrapping_add(*p_len) => {
let end = addr.wrapping_add(len).max(p_addr.wrapping_add(*p_len));
*p_len = end.wrapping_sub(*p_addr);
}
_ => merged.push((addr, len)),
}
}
merged
}
/// Expand merged byte ranges into the set of 4-byte-aligned word addresses they
/// cover — the granularity at which `instructions` rows are marked.
pub fn data_word_addresses(tables: &[JumpTable]) -> BTreeSet<u32> {
let mut set = BTreeSet::new();
for (addr, len) in data_regions(tables) {
let start = addr & !3;
let end = addr.wrapping_add(len).div_ceil(4) * 4;
let mut a = start;
while a < end {
set.insert(a);
a = a.wrapping_add(4);
}
}
set
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use xenia_xex::pe::PeSection;
use crate::func::{FuncAnalysis, FuncInfo};
const BASE: u32 = 0x8200_0000;
const TEXT_RVA: u32 = 0x1000;
const TEXT_VA: u32 = BASE + TEXT_RVA;
fn text_section(size: u32) -> PeSection {
PeSection {
name: ".text".into(),
virtual_address: TEXT_RVA,
virtual_size: size,
raw_offset: TEXT_RVA,
raw_size: size,
flags: 0x6000_0020, // CODE | EXECUTE | READ
}
}
fn one_function(start: u32, end: u32) -> FuncAnalysis {
let mut functions = BTreeMap::new();
functions.insert(start, FuncInfo {
start,
end,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: true,
pdata_length: Some(end - start),
pdata_prolog_length: Some(0),
has_eh: false,
});
FuncAnalysis { functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new() }
}
/// Encode the words of the canonical MSVC dense-switch dispatch, ending at
/// the `bctr`, then the inline table. Mirrors the real sequence:
/// cmplwi r10,N / bgt / lis r12 / addi r12 / slwi r0,r10,2 / lwzx r0,r12,r0
/// / mtctr r0 / bctr / <table>
fn dense_switch(table_va: u32, n_cases: u32) -> Vec<u32> {
vec![
0x2800_0000 | (10 << 16) | (n_cases - 1), // cmplwi r10, N
0x4181_0000 | 0x20, // bc (bound check, target irrelevant)
0x3D80_0000 | (table_va >> 16), // lis r12, hi
0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, lo
0x5540_103A, // slwi r0, r10, 2
0x7C0C_002E, // lwzx r0, r12, r0
0x7C09_03A6, // mtctr r0
BCTR,
]
}
fn assemble(words: &[u32], size: u32) -> Vec<u8> {
let mut pe = vec![0u8; (TEXT_RVA + size) as usize];
for (i, w) in words.iter().enumerate() {
let off = TEXT_RVA as usize + i * 4;
pe[off..off + 4].copy_from_slice(&w.to_be_bytes());
}
pe
}
#[test]
fn recovers_dense_switch_with_inline_table() {
let table_va = TEXT_VA + 8 * 4;
let mut words = dense_switch(table_va, 4);
// Four case bodies, all inside the function.
let cases = [TEXT_VA + 0x40, TEXT_VA + 0x50, TEXT_VA + 0x60, TEXT_VA + 0x70];
words.extend_from_slice(&cases);
let pe = assemble(&words, 0x100);
let sections = [text_section(0x100)];
let fa = one_function(TEXT_VA, TEXT_VA + 0x100);
let tables = analyze(&pe, BASE, &sections, &fa);
assert_eq!(tables.len(), 1, "expected exactly one recovered table");
let jt = &tables[0];
assert_eq!(jt.bctr_pc, TEXT_VA + 7 * 4);
assert_eq!(jt.table_address, table_va);
assert_eq!(jt.kind, "direct");
assert_eq!(jt.bound, Some(3));
assert_eq!(jt.targets, cases.to_vec());
assert_eq!(jt.function, Some(TEXT_VA));
}
#[test]
fn table_stops_at_a_target_outside_the_function() {
// Bound says 8 cases but only the first three land inside the function;
// the fourth word is an address in a different function, which must
// terminate the table rather than be emitted as a case.
let table_va = TEXT_VA + 8 * 4;
let mut words = dense_switch(table_va, 8);
words.extend_from_slice(&[
TEXT_VA + 0x40, TEXT_VA + 0x50, TEXT_VA + 0x60,
0x8300_0000, // far outside
TEXT_VA + 0x70,
]);
let pe = assemble(&words, 0x100);
let sections = [text_section(0x100)];
let fa = one_function(TEXT_VA, TEXT_VA + 0x80);
let tables = analyze(&pe, BASE, &sections, &fa);
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].targets.len(), 3);
assert_eq!(tables[0].entry_count, 3);
}
#[test]
fn plain_indirect_tail_call_is_not_a_switch() {
// `lwz r12, 0(r3); mtctr r12; bctr` — a virtual tail-call, no table.
let words = [
0x8183_0000, // lwz r12, 0(r3)
0x7D89_03A6, // mtctr r12
BCTR,
];
let pe = assemble(&words, 0x100);
let sections = [text_section(0x100)];
let fa = one_function(TEXT_VA, TEXT_VA + 0x100);
assert!(analyze(&pe, BASE, &sections, &fa).is_empty());
}
#[test]
fn recovers_sparse_two_level_switch() {
// cmplwi r10,5 / bc / lis+addi r11 = &map / lbzx r0,r11,r10
// / lis+addi r12 = &table / slwi r0,r0,2 / lwzx r0,r12,r0 / mtctr / bctr
let map_va = TEXT_VA + 13 * 4; // 6 bytes, then padding
let table_va = TEXT_VA + 17 * 4; // 3 distinct bodies
let words = vec![
0x2800_0000 | (10 << 16) | 5, // cmplwi r10, 5
0x4181_0000 | 0x20, // bc
0x3D60_0000 | (map_va >> 16), // lis r11, map@h
0x396B_0000 | (map_va & 0xFFFF), // addi r11, r11, map@l
0x7C0B_50AE, // lbzx r0, r11, r10
0x3D80_0000 | (table_va >> 16), // lis r12, tab@h
0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, tab@l
0x5400_103A, // slwi r0, r0, 2
0x7C0C_002E, // lwzx r0, r12, r0
0x7C09_03A6, // mtctr r0
BCTR,
0, 0,
// map[0..6] = 0,1,2,2,1,0 packed big-endian, then padding
0x0001_0202, 0x0100_0000,
0, 0,
// table[0..3]
TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0,
];
let pe = assemble(&words, 0x100);
let sections = [text_section(0x100)];
let fa = one_function(TEXT_VA, TEXT_VA + 0x100);
let tables = analyze(&pe, BASE, &sections, &fa);
assert_eq!(tables.len(), 1);
let jt = &tables[0];
assert_eq!(jt.kind, "indexed");
assert_eq!(jt.index_map_address, Some(map_va));
assert_eq!(jt.index_map_count, Some(6));
assert_eq!(jt.table_slots, 3);
assert_eq!(jt.targets, vec![
TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0,
TEXT_VA + 0xA0, TEXT_VA + 0x90, TEXT_VA + 0x80,
]);
}
/// An unrelated `lbzx` in the window must not be mistaken for a case index
/// map — games read bytes constantly, and a fabricated two-level mapping
/// would silently point every case at the wrong body.
#[test]
fn unrelated_lbzx_does_not_become_an_index_map() {
let table_va = TEXT_VA + 11 * 4;
let mut words = vec![
0x2800_0000 | (10 << 16) | 3, // cmplwi r10, 3
0x4181_0000 | 0x20, // bc
0x3D60_0000 | (TEXT_VA >> 16), // lis r11, text@h (a code constant)
0x396B_0000 | (TEXT_VA & 0xFFFF), // addi r11, r11, text@l
0x7CEB_44AE, // lbzx r7, r11, r8 — unrelated byte load
0x3D80_0000 | (table_va >> 16), // lis r12, tab@h
0x398C_0000 | (table_va & 0xFFFF), // addi r12, r12, tab@l
0x5540_103A, // slwi r0, r10, 2 — index is r10, NOT r7
0x7C0C_002E, // lwzx r0, r12, r0
0x7C09_03A6, // mtctr r0
];
words.push(BCTR);
words.extend_from_slice(&[TEXT_VA + 0x80, TEXT_VA + 0x90, TEXT_VA + 0xA0, TEXT_VA + 0xB0]);
let pe = assemble(&words, 0x100);
let sections = [text_section(0x100)];
let fa = one_function(TEXT_VA, TEXT_VA + 0x100);
let tables = analyze(&pe, BASE, &sections, &fa);
assert_eq!(tables.len(), 1);
assert_eq!(tables[0].kind, "direct");
assert_eq!(tables[0].index_map_address, None);
}
#[test]
fn data_regions_merge_adjacent_tables() {
let a = JumpTable {
bctr_pc: 0x8200_1000, function: None, table_address: 0x8200_2000,
entry_count: 4, table_slots: 4, index_map_address: None,
index_map_count: None, bound: None, kind: "direct",
targets: vec![0; 4],
};
let b = JumpTable { table_address: 0x8200_2010, bctr_pc: 0x8200_1004, ..a.clone() };
let merged = data_regions(&[a, b]);
assert_eq!(merged, vec![(0x8200_2000, 32)]);
}
#[test]
fn data_word_addresses_covers_every_slot() {
let jt = JumpTable {
bctr_pc: 0x8200_1000, function: None, table_address: 0x8200_2000,
entry_count: 3, table_slots: 3, index_map_address: Some(0x8200_3000),
index_map_count: Some(5), bound: Some(4), kind: "indexed",
targets: vec![0; 5],
};
let words = data_word_addresses(&[jt]);
// 3 table slots + ceil(5/4) = 2 words of index map.
assert_eq!(words.len(), 5);
assert!(words.contains(&0x8200_2008));
assert!(words.contains(&0x8200_3004));
}
}

View File

@@ -15,6 +15,9 @@ pub mod strings;
pub mod funcptr_arrays;
pub mod eh_scope;
pub mod static_init;
pub mod xdbf;
pub mod jumptables;
pub mod rtti;
mod ordinals;
pub use ordinals::resolve_ordinal;

View File

@@ -0,0 +1,453 @@
//! MSVC RTTI recovery — the authoritative source of C++ class identity.
//!
//! [`crate::vtables`] finds vtables *bottom-up*, by looking for runs of words
//! that happen to be function entries, and only then tries the RTTI walk. That
//! misses every table whose head holds a null / pure-virtual / thunk slot, and
//! it cannot see a class that has no such run at all. This module works
//! *top-down* from the RTTI structures the linker emitted, which is exact:
//! a `CompleteObjectLocator` names its class, and the word that points at a
//! COL is by definition `vftable[-1]`.
//!
//! ## Structure layout (32-bit MSVC, big-endian on Xbox 360)
//!
//! ```text
//! TypeDescriptor (in .data — it is written at startup)
//! +0 void* pVFTable -> type_info's own vftable (identical for all TDs)
//! +4 void* spare
//! +8 char name[] -> ".?AVFoo@Bar@@", NUL-terminated
//!
//! RTTICompleteObjectLocator (in .rdata)
//! +0 u32 signature -> 0 for 32-bit images
//! +4 u32 offset -> this-offset of the subobject this vftable serves
//! +8 u32 cdOffset -> constructor-displacement offset
//! +12 TypeDescriptor*
//! +16 RTTIClassHierarchyDescriptor*
//!
//! RTTIClassHierarchyDescriptor (in .rdata)
//! +0 u32 signature
//! +4 u32 attributes -> bit 0 = multiple inheritance, bit 1 = virtual
//! +8 u32 numBaseClasses
//! +12 RTTIBaseClassDescriptor** pBaseClassArray
//!
//! RTTIBaseClassDescriptor (in .rdata)
//! +0 TypeDescriptor*
//! +4 u32 numContainedBases
//! +8 i32 PMD.mdisp -> member displacement
//! +12 i32 PMD.pdisp -> vbtable displacement (-1 = not virtual)
//! +16 i32 PMD.vdisp -> displacement inside the vbtable
//! +20 u32 attributes
//! ```
//!
//! A vtable is located at `col_ref + 4` for every word `col_ref` whose value is
//! a validated COL address. `offset` distinguishes the primary vftable
//! (`offset == 0`) from the extra vftables a multiply-inheriting class emits
//! for its secondary base subobjects — those are linked to the same class
//! rather than being reported as unrelated tables.
//!
//! ## Limits
//!
//! - Only statically-emitted RTTI is seen; a class whose RTTI the linker
//! stripped stays anonymous and is left to [`crate::vtables`].
//! - Vtable *length* is measured by walking forward from `vftable[0]` while the
//! words are plausible method pointers, stopping at the next COL reference or
//! at a known label — the linker does not record it.
use std::collections::{BTreeMap, BTreeSet};
use xenia_xex::pe::PeSection;
use crate::demangle;
/// One `TypeDescriptor`: the mangled class name the compiler emitted.
#[derive(Debug, Clone)]
pub struct TypeDescriptor {
/// VA of the descriptor (i.e. of its `pVFTable` word).
pub address: u32,
/// Raw decorated name, e.g. `.?AVSilph@silph@@`.
pub mangled_name: String,
/// Readable form, e.g. `silph::Silph`. Falls back to `mangled_name`.
pub demangled_name: String,
}
/// One `RTTICompleteObjectLocator` and the vtable it labels.
#[derive(Debug, Clone)]
pub struct CompleteObjectLocator {
pub address: u32,
/// `this`-offset of the subobject whose vftable this is. 0 = primary.
pub offset: u32,
pub cd_offset: u32,
pub type_descriptor: u32,
pub class_hierarchy: u32,
/// VA of `vftable[0]`, when a word pointing at this COL was found.
pub vtable_address: Option<u32>,
}
/// One entry of a class's `RTTIBaseClassArray`, in linearised order.
#[derive(Debug, Clone)]
pub struct BaseClass {
/// VA of the deriving class's `RTTIClassHierarchyDescriptor`.
pub class_hierarchy: u32,
/// Position in the base-class array (index 0 is the class itself).
pub index: u32,
pub type_descriptor: u32,
pub name: String,
pub num_contained_bases: u32,
pub mdisp: i32,
pub pdisp: i32,
pub vdisp: i32,
pub attributes: u32,
}
/// Everything the RTTI walk recovered.
#[derive(Debug, Default)]
pub struct RttiResult {
pub type_descriptors: Vec<TypeDescriptor>,
pub locators: Vec<CompleteObjectLocator>,
pub base_classes: Vec<BaseClass>,
/// `vftable[0]` VA → the COL that labels it.
pub vtable_to_locator: BTreeMap<u32, u32>,
}
impl RttiResult {
/// Vtable base VAs the walk proved exist — the anchor set
/// [`crate::vtables`] should treat as authoritative.
pub fn vtable_anchors(&self) -> BTreeSet<u32> {
self.vtable_to_locator.keys().copied().collect()
}
/// `vftable[0]` VA → `(demangled class name, subobject offset)`.
pub fn vtable_class_names(&self) -> BTreeMap<u32, (String, u32)> {
let td: BTreeMap<u32, &TypeDescriptor> =
self.type_descriptors.iter().map(|t| (t.address, t)).collect();
let mut out = BTreeMap::new();
for col in &self.locators {
if let (Some(vt), Some(t)) = (col.vtable_address, td.get(&col.type_descriptor)) {
out.insert(vt, (t.demangled_name.clone(), col.offset));
}
}
out
}
}
// ── Scan ───────────────────────────────────────────────────────────────────
/// Walk the image's RTTI. `sections` must be the full PE section list.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(pe: &[u8], image_base: u32, sections: &[PeSection]) -> RttiResult {
let started = std::time::Instant::now();
let read = |va: u32| -> Option<u32> {
let off = va.wrapping_sub(image_base) as usize;
if off.checked_add(4)? > pe.len() { return None; }
Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]))
};
// Byte ranges actually backed by file data (a section's tail beyond
// `raw_size` is BSS: reading it yields zeros, never a real structure).
let backed = |s: &PeSection| -> (u32, u32) {
let start = image_base + s.virtual_address;
let len = s.virtual_size.min(s.raw_size);
(start, start + len)
};
let ranges: Vec<(String, u32, u32)> = sections
.iter()
.map(|s| { let (a, b) = backed(s); (s.name.clone(), a, b) })
.collect();
let range_of = |name: &str| -> Option<(u32, u32)> {
ranges.iter().find(|(n, _, _)| n == name).map(|&(_, a, b)| (a, b))
};
// 1. TypeDescriptors. The decorated name lives at descriptor+8 and always
// starts with ".?A". MSVC places these in writable data.
let mut type_descriptors: Vec<TypeDescriptor> = Vec::new();
let mut td_addrs: BTreeSet<u32> = BTreeSet::new();
for (name, start, end) in &ranges {
if !matches!(name.as_str(), ".data" | ".rdata") { continue; }
let s = (*start).wrapping_sub(image_base) as usize;
let e = (*end).wrapping_sub(image_base) as usize;
if e > pe.len() || s >= e { continue; }
let bytes = &pe[s..e];
let mut i = 0usize;
while i + 3 < bytes.len() {
if &bytes[i..i + 3] != b".?A" { i += 1; continue; }
let name_va = start.wrapping_add(i as u32);
// The descriptor head sits 8 bytes before the name.
let Some(td_va) = name_va.checked_sub(8) else { i += 1; continue };
if td_va < *start { i += 1; continue; }
let Some(decorated) = read_cstr(bytes, i, 512) else { i += 1; continue };
i += decorated.len() + 1;
if td_addrs.insert(td_va) {
type_descriptors.push(TypeDescriptor {
address: td_va,
demangled_name: demangle::demangle_type_descriptor(&decorated)
.unwrap_or_else(|| decorated.clone()),
mangled_name: decorated,
});
}
}
}
// 2. CompleteObjectLocators. Scan read-only data on a 4-byte grid for the
// 5-word shape whose `pTypeDescriptor` hits a descriptor we just found
// and whose `pClassDescriptor` points back into read-only data.
let rdata = range_of(".rdata");
let mut locators: Vec<CompleteObjectLocator> = Vec::new();
let mut col_addrs: BTreeSet<u32> = BTreeSet::new();
if let Some((rd_start, rd_end)) = rdata {
let mut va = rd_start;
while va + 20 <= rd_end {
let (Some(sig), Some(off), Some(cd), Some(ptd), Some(pchd)) = (
read(va), read(va + 4), read(va + 8), read(va + 12), read(va + 16),
) else { break };
if sig == 0 && td_addrs.contains(&ptd) && pchd >= rd_start && pchd < rd_end {
col_addrs.insert(va);
locators.push(CompleteObjectLocator {
address: va,
offset: off,
cd_offset: cd,
type_descriptor: ptd,
class_hierarchy: pchd,
vtable_address: None,
});
}
va += 4;
}
}
// 3. `vftable[-1]` sites: any word in initialised data whose value is a COL.
let mut vtable_to_locator: BTreeMap<u32, u32> = BTreeMap::new();
for (name, start, end) in &ranges {
if !matches!(name.as_str(), ".rdata" | ".data") { continue; }
let mut va = *start;
while va + 4 <= *end {
if let Some(w) = read(va)
&& col_addrs.contains(&w)
{
vtable_to_locator.insert(va + 4, w);
}
va += 4;
}
}
let locator_to_vtable: BTreeMap<u32, u32> =
vtable_to_locator.iter().map(|(&vt, &col)| (col, vt)).collect();
for col in &mut locators {
col.vtable_address = locator_to_vtable.get(&col.address).copied();
}
// 4. Class hierarchies: for each distinct CHD, read its base-class array.
let td_by_addr: BTreeMap<u32, &TypeDescriptor> =
type_descriptors.iter().map(|t| (t.address, t)).collect();
let mut base_classes: Vec<BaseClass> = Vec::new();
let chds: BTreeSet<u32> = locators.iter().map(|c| c.class_hierarchy).collect();
if let Some((rd_start, rd_end)) = rdata {
for chd in chds {
let (Some(n_bases), Some(p_array)) = (read(chd + 8), read(chd + 12)) else { continue };
// A malformed or misidentified descriptor would blow the scan up;
// real hierarchies are small.
if n_bases == 0 || n_bases > 64 { continue; }
if p_array < rd_start || p_array >= rd_end { continue; }
for i in 0..n_bases {
let Some(bcd) = read(p_array + i * 4) else { break };
if bcd < rd_start || bcd >= rd_end { break; }
let (Some(ptd), Some(ncb), Some(md), Some(pd), Some(vd), Some(attr)) = (
read(bcd), read(bcd + 4), read(bcd + 8),
read(bcd + 12), read(bcd + 16), read(bcd + 20),
) else { break };
let Some(td) = td_by_addr.get(&ptd) else { break };
base_classes.push(BaseClass {
class_hierarchy: chd,
index: i,
type_descriptor: ptd,
name: td.demangled_name.clone(),
num_contained_bases: ncb,
mdisp: md as i32,
pdisp: pd as i32,
vdisp: vd as i32,
attributes: attr,
});
}
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "rtti").record(elapsed_ms);
tracing::info!(
type_descriptors = type_descriptors.len(),
locators = locators.len(),
vtables = vtable_to_locator.len(),
base_class_records = base_classes.len(),
elapsed_ms,
"RTTI walk complete",
);
RttiResult { type_descriptors, locators, base_classes, vtable_to_locator }
}
/// Read a NUL-terminated ASCII string starting at `off` in `bytes`.
fn read_cstr(bytes: &[u8], off: usize, max: usize) -> Option<String> {
let end = (off + max).min(bytes.len());
let slice = &bytes[off..end];
let nul = slice.iter().position(|&b| b == 0)?;
let s = &slice[..nul];
if s.is_empty() || !s.iter().all(|&b| (0x20..0x7F).contains(&b)) {
return None;
}
Some(String::from_utf8_lossy(s).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: u32 = 0x8200_0000;
const RDATA_RVA: u32 = 0x1000;
const DATA_RVA: u32 = 0x2000;
const SEC_SIZE: u32 = 0x1000;
fn sections() -> Vec<PeSection> {
vec![
PeSection {
name: ".rdata".into(),
virtual_address: RDATA_RVA, virtual_size: SEC_SIZE,
raw_offset: RDATA_RVA, raw_size: SEC_SIZE,
flags: 0x4000_0040,
},
PeSection {
name: ".data".into(),
virtual_address: DATA_RVA, virtual_size: SEC_SIZE,
raw_offset: DATA_RVA, raw_size: SEC_SIZE,
flags: 0xC000_0040,
},
]
}
struct Image(Vec<u8>);
impl Image {
fn new() -> Self { Image(vec![0u8; (DATA_RVA + SEC_SIZE) as usize]) }
fn put_u32(&mut self, va: u32, v: u32) {
let o = (va - BASE) as usize;
self.0[o..o + 4].copy_from_slice(&v.to_be_bytes());
}
fn put_str(&mut self, va: u32, s: &str) {
let o = (va - BASE) as usize;
self.0[o..o + s.len()].copy_from_slice(s.as_bytes());
self.0[o + s.len()] = 0;
}
}
/// Lay down one class: TypeDescriptor in .data, COL + CHD + BCD in .rdata,
/// and the `vftable[-1]` word that points at the COL.
#[allow(clippy::too_many_arguments)]
fn emit_class(
img: &mut Image, td: u32, name: &str,
col: u32, offset: u32, chd: u32, bcd_array: u32, bcd: u32, base_name_td: Option<u32>,
vtable_minus_one: u32,
) {
img.put_u32(td, 0xDEAD_BEEF); // type_info vftable — value is irrelevant
img.put_str(td + 8, name);
img.put_u32(col, 0); // signature
img.put_u32(col + 4, offset);
img.put_u32(col + 8, 0); // cdOffset
img.put_u32(col + 12, td);
img.put_u32(col + 16, chd);
let n_bases = if base_name_td.is_some() { 2 } else { 1 };
img.put_u32(chd, 0);
img.put_u32(chd + 4, 0);
img.put_u32(chd + 8, n_bases);
img.put_u32(chd + 12, bcd_array);
// Base-class array: entry 0 is the class itself.
img.put_u32(bcd_array, bcd);
img.put_u32(bcd, td);
img.put_u32(bcd + 4, n_bases - 1);
img.put_u32(bcd + 8, 0); // mdisp
img.put_u32(bcd + 12, u32::MAX); // pdisp = -1
img.put_u32(bcd + 16, 0); // vdisp
img.put_u32(bcd + 20, 0x40); // attributes
if let Some(base_td) = base_name_td {
let bcd2 = bcd + 24;
img.put_u32(bcd_array + 4, bcd2);
img.put_u32(bcd2, base_td);
img.put_u32(bcd2 + 4, 0);
img.put_u32(bcd2 + 8, 4); // mdisp = 4
img.put_u32(bcd2 + 12, u32::MAX);
img.put_u32(bcd2 + 16, 0);
img.put_u32(bcd2 + 20, 0);
}
img.put_u32(vtable_minus_one, col);
}
#[test]
fn recovers_class_name_vtable_and_bases() {
let mut img = Image::new();
let rd = BASE + RDATA_RVA;
let da = BASE + DATA_RVA;
// Base class Foo, then Derived : Foo.
emit_class(&mut img, da + 0x100, ".?AVFoo@ns@@",
rd + 0x100, 0, rd + 0x200, rd + 0x280, rd + 0x300, None,
rd + 0x000);
emit_class(&mut img, da + 0x200, ".?AVDerived@ns@@",
rd + 0x400, 0, rd + 0x500, rd + 0x580, rd + 0x600, Some(da + 0x100),
rd + 0x040);
let r = analyze(&img.0, BASE, &sections());
assert_eq!(r.type_descriptors.len(), 2);
let derived = r.type_descriptors.iter()
.find(|t| t.mangled_name.contains("Derived")).unwrap();
assert_eq!(derived.demangled_name, "ns::Derived");
assert_eq!(r.locators.len(), 2);
// vftable[0] is one word past the word holding the COL pointer.
assert_eq!(r.vtable_to_locator.get(&(rd + 0x044)), Some(&(rd + 0x400)));
assert!(r.vtable_anchors().contains(&(rd + 0x004)));
let names = r.vtable_class_names();
assert_eq!(names.get(&(rd + 0x044)), Some(&("ns::Derived".to_string(), 0)));
// Derived's hierarchy lists itself at index 0 and Foo at index 1.
let mut bases: Vec<_> = r.base_classes.iter()
.filter(|b| b.class_hierarchy == rd + 0x500)
.collect();
bases.sort_by_key(|b| b.index);
assert_eq!(bases.len(), 2);
assert_eq!(bases[1].name, "ns::Foo");
assert_eq!(bases[1].mdisp, 4);
assert_eq!(bases[1].pdisp, -1);
}
#[test]
fn secondary_base_vftable_keeps_its_subobject_offset() {
let mut img = Image::new();
let rd = BASE + RDATA_RVA;
let da = BASE + DATA_RVA;
emit_class(&mut img, da + 0x100, ".?AVMulti@@",
rd + 0x100, 0x8, rd + 0x200, rd + 0x280, rd + 0x300, None,
rd + 0x000);
let r = analyze(&img.0, BASE, &sections());
let names = r.vtable_class_names();
assert_eq!(names.get(&(rd + 0x004)), Some(&("Multi".to_string(), 0x8)));
}
#[test]
fn ignores_data_that_merely_looks_like_a_locator() {
// A 5-word run with signature 0 but a `pTypeDescriptor` that hits no
// descriptor must not be reported.
let mut img = Image::new();
let rd = BASE + RDATA_RVA;
img.put_u32(rd + 0x100, 0);
img.put_u32(rd + 0x104, 0);
img.put_u32(rd + 0x108, 0);
img.put_u32(rd + 0x10C, BASE + DATA_RVA + 0x900); // no TD there
img.put_u32(rd + 0x110, rd + 0x200);
let r = analyze(&img.0, BASE, &sections());
assert!(r.locators.is_empty());
assert!(r.type_descriptors.is_empty());
}
}

View File

@@ -1,7 +1,8 @@
//! DuckDB sink — appends rich disasm items to the `instructions` table.
//!
//! Column layout matches [`crate::db`]: address, raw, mnemonic, operands,
//! disasm, ext_mnemonic, ext_operands, ext_disasm, section, function, label.
//! disasm, ext_mnemonic, ext_operands, ext_disasm, target_hex, section,
//! function, label, is_data.
use duckdb::{Appender, params};
@@ -30,6 +31,7 @@ pub fn append_instructions<'a>(
ri.section,
ri.function.map(|f| f as i64),
ri.label,
ri.is_data,
])?;
count += 1;
}

View File

@@ -30,6 +30,7 @@ struct JsonRow<'a> {
function: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<&'a str>,
is_data: bool,
}
/// Write each item as a single JSON object on its own line. Returns the
@@ -54,6 +55,7 @@ pub fn write_jsonl<'a, W: Write>(
section: ri.section,
function: ri.function,
label: ri.label,
is_data: ri.is_data,
};
serde_json::to_writer(&mut *out, &row)?;
out.write_all(b"\n")?;

View File

@@ -25,6 +25,19 @@ pub fn write_instr_line<W: Write + ?Sized>(
image_base: u32,
data_annotation: Option<(u32, XrefKind)>,
) -> io::Result<()> {
// A word the analysis proved is data (a recovered jump table or its index
// map) must not be printed as if it decoded to something meaningful.
if item.is_data {
let lbl = labels.get(&item.item.raw)
.map(|s| format!(" ; -> {s}"))
.unwrap_or_default();
return writeln!(
out,
" {:08X}: {:08X} .long 0x{:08X}{}",
item.item.addr, item.item.raw, item.item.raw, lbl,
);
}
let disasm_text = item.item.text.display();
// Branch-target → label annotation. Uses the structured `branch_target`

View File

@@ -23,6 +23,42 @@
//! kind-classification CASE drifted out of agreement with `xref.rs`, and
//! is worth a one-line warning at log time.
/// Every XDBF string side-by-side across the languages the title ships, so a
/// piece of UI text can be looked up once and read in all locales.
const V_XDBF_TEXT: &str = "
CREATE OR REPLACE VIEW v_xdbf_text AS
SELECT
s.string_id,
MAX(CASE WHEN s.language = 1 THEN s.value END) AS english,
MAX(CASE WHEN s.language = 2 THEN s.value END) AS japanese,
MAX(CASE WHEN s.language = 3 THEN s.value END) AS german,
MAX(CASE WHEN s.language = 4 THEN s.value END) AS french,
MAX(CASE WHEN s.language = 5 THEN s.value END) AS spanish,
MAX(CASE WHEN s.language = 6 THEN s.value END) AS italian
FROM xdbf_strings s
GROUP BY s.string_id;
";
/// Achievements joined to their three strings in every shipped language.
const V_XDBF_ACHIEVEMENTS: &str = "
CREATE OR REPLACE VIEW v_xdbf_achievements AS
SELECT
a.id,
a.gamerscore,
s.language,
s.language_name,
n.value AS name,
u.value AS unlocked_desc,
l.value AS locked_desc,
a.image_id
FROM xdbf_achievements a
JOIN (SELECT DISTINCT language, language_name FROM xdbf_strings) s ON TRUE
LEFT JOIN xdbf_strings n ON n.language = s.language AND n.string_id = a.label_id
LEFT JOIN xdbf_strings u ON u.language = s.language AND u.string_id = a.description_id
LEFT JOIN xdbf_strings l ON l.language = s.language AND l.string_id = a.unachieved_id;
";
/// `(view_name, CREATE VIEW … SQL)` pairs in the order they must run.
/// Later views may depend on earlier ones (e.g. `v_call_graph` reads
/// `xrefs`, which is the Rust-pass table; `v_branch_xrefs` is independent).
@@ -33,6 +69,12 @@ pub const ALL_VIEWS: &[(&str, &str)] = &[
("v_indirect_reachability_from_entry", V_INDIRECT_REACHABILITY_FROM_ENTRY),
("v_function_first_instruction", V_FUNCTION_FIRST_INSTRUCTION),
("v_imports_called", V_IMPORTS_CALLED),
("v_xdbf_text", V_XDBF_TEXT),
("v_xdbf_achievements", V_XDBF_ACHIEVEMENTS),
("v_switch_cases", V_SWITCH_CASES),
("v_class_hierarchy", V_CLASS_HIERARCHY),
("v_class_methods", V_CLASS_METHODS),
("v_function_strings", V_FUNCTION_STRINGS),
];
/// Branch cross-references derived purely from `instructions.target_hex`.
@@ -105,7 +147,7 @@ WITH RECURSIVE reach(fn) AS (
JOIN instructions src ON src.address = x.source
JOIN instructions tgt ON tgt.address = x.target
JOIN reach r ON src.function = r.fn
WHERE x.kind IN ('call', 'j', 'br')
WHERE x.kind IN ('call', 'j', 'br', 'jt')
AND tgt.function IS NOT NULL
)
SELECT fn AS addr FROM reach;
@@ -128,7 +170,7 @@ WITH RECURSIVE reach(fn) AS (
JOIN instructions src ON src.address = x.source
JOIN instructions tgt ON tgt.address = x.target
JOIN reach r ON src.function = r.fn
WHERE x.kind IN ('call', 'ind_call', 'j', 'br')
WHERE x.kind IN ('call', 'ind_call', 'j', 'br', 'jt')
AND tgt.function IS NOT NULL
)
SELECT fn AS addr FROM reach;
@@ -163,3 +205,81 @@ LEFT JOIN functions f ON f.address = x.source_func
WHERE x.kind = 'call'
AND l.kind = 'import';
";
/// Every recovered `switch` case, joined to the dispatching function and to
/// the label on the case body. One row per case *value* — several rows can
/// share a `target_address` when case values fall through to one body.
const V_SWITCH_CASES: &str = "
CREATE OR REPLACE VIEW v_switch_cases AS
SELECT
jt.bctr_pc AS dispatch_pc,
jt.function AS function_addr,
f.name AS function_name,
jt.kind AS table_kind,
jt.table_address AS table_address,
e.case_index AS case_index,
e.target_address AS target_address,
l.name AS target_label
FROM jump_tables jt
JOIN jump_table_entries e ON e.bctr_pc = jt.bctr_pc
LEFT JOIN functions f ON f.address = jt.function
LEFT JOIN labels l ON l.address = e.target_address;
";
/// The C++ inheritance graph as recovered from RTTI. Index 0 of a base-class
/// array is the class itself and is excluded, so every row is a genuine
/// `derived -> base` edge carrying the displacement triple needed to find the
/// base subobject inside an instance.
const V_CLASS_HIERARCHY: &str = "
CREATE OR REPLACE VIEW v_class_hierarchy AS
SELECT DISTINCT
dtd.demangled_name AS derived_class,
b.name AS base_class,
b.base_index AS base_index,
b.mdisp AS mdisp,
b.pdisp AS pdisp,
b.vdisp AS vdisp,
c.vtable_address AS derived_vtable
FROM rtti_base_classes b
JOIN rtti_locators c ON c.class_hierarchy = b.class_hierarchy
JOIN rtti_type_descriptors dtd ON dtd.address = c.type_descriptor
WHERE b.base_index > 0;
";
/// Virtual methods per class, resolved through the RTTI-named vtable. The
/// authoritative counterpart to querying `methods` by an `ANON_Class_*` name.
const V_CLASS_METHODS: &str = "
CREATE OR REPLACE VIEW v_class_methods AS
SELECT
td.demangled_name AS class_name,
c.subobject_offset AS subobject_offset,
v.address AS vtable_address,
m.slot AS slot,
m.function_address AS method_addr,
f.name AS method_name,
f.has_eh AS method_has_eh
FROM rtti_locators c
JOIN rtti_type_descriptors td ON td.address = c.type_descriptor
JOIN vtables v ON v.address = c.vtable_address
JOIN methods m ON m.vtable_address = v.address
LEFT JOIN functions f ON f.address = m.function_address;
";
/// Which function references which string literal. The single most useful
/// orientation query in a stripped binary: it is how you find the code behind
/// a message you can see on screen.
const V_FUNCTION_STRINGS: &str = "
CREATE OR REPLACE VIEW v_function_strings AS
SELECT
x.source_func AS function_addr,
f.name AS function_name,
x.source AS reference_pc,
x.kind AS reference_kind,
s.address AS string_addr,
s.encoding AS encoding,
s.content AS content
FROM xrefs x
JOIN strings s ON s.address = x.target
LEFT JOIN functions f ON f.address = x.source_func
WHERE x.kind IN ('ref', 'read');
";

View File

@@ -347,7 +347,7 @@ mod tests {
functions.insert(driver, FuncInfo {
start: driver, end: driver + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: false, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let fa = FuncAnalysis {
functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new(),
@@ -385,7 +385,7 @@ mod tests {
functions.insert(driver, FuncInfo {
start: driver, end: driver + 0x40, frame_size: 0, saved_gprs: 0,
is_leaf: true, is_saverestore: false,
pdata_validated: false, pdata_length: None, has_eh: false,
pdata_validated: false, pdata_length: None, pdata_prolog_length: None, has_eh: false,
});
let fa = FuncAnalysis {
functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new(),

View File

@@ -1,4 +1,4 @@
//! String / constant-pool detection in `.rdata`.
//! String / constant-pool detection in the initialised data sections.
//!
//! Scans the `.rdata` section for runs of printable ASCII or null-terminated
//! UTF-16LE characters of length ≥ 6, emitting one row per discovered string.
@@ -9,7 +9,8 @@
//!
//! - No UTF-8 multibyte detection — Xbox 360 game binaries reliably use
//! ASCII for debug strings and UTF-16LE for localised text.
//! - Strings in `.data` (mutable globals) are not scanned by default.
//! - Only the file-backed part of a section is scanned: the tail of `.data`
//! past `raw_size` is BSS and contains nothing but zeros at rest.
//! - Wide strings on Xbox 360 are little-endian (compiler convention even
//! on this big-endian platform); we do NOT try big-endian UTF-16.
//! - No language detection / classification beyond encoding.
@@ -34,27 +35,40 @@ pub struct DetectedString {
pub length: u32,
/// UTF-8 representation of the string content.
pub content: String,
/// Name of the PE section the string lives in (`.rdata` / `.data`).
pub section: String,
}
/// Scan all `.rdata` sections (and any other read-only data section the user
/// configures) for ASCII and UTF-16LE strings.
/// Scan the initialised data sections for ASCII / UTF-16LE / Shift_JIS / UTF-8
/// strings.
///
/// `.data` is scanned as well as `.rdata`: a lot of a game's string material —
/// mutable tables, and every RTTI type-descriptor name — lives there, and
/// leaving it out is why this table comes back nearly empty on real titles.
/// The `section` column lets a consumer separate the two again.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Vec<DetectedString> {
let started = std::time::Instant::now();
let mut out: Vec<DetectedString> = Vec::new();
for section in sections {
if section.name != ".rdata" { continue; }
if !matches!(section.name.as_str(), ".rdata" | ".data") { continue; }
let raw_start = section.virtual_address as usize;
let raw_end = (section.virtual_address + section.virtual_size) as usize;
if raw_end > pe.len() { continue; }
let bytes = &pe[raw_start..raw_end.min(pe.len())];
// Clamp to the file-backed extent — everything past `raw_size` is BSS.
let backed = section.virtual_size.min(section.raw_size) as usize;
let raw_end = (raw_start + backed).min(pe.len());
if raw_start >= raw_end { continue; }
let bytes = &pe[raw_start..raw_end];
let va_base = image_base + section.virtual_address;
let before = out.len();
scan_ascii(bytes, va_base, &mut out);
scan_utf16le(bytes, va_base, &mut out);
scan_shift_jis(bytes, va_base, &mut out);
scan_utf8(bytes, va_base, &mut out);
for s in &mut out[before..] {
s.section = section.name.clone();
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
@@ -100,6 +114,7 @@ fn scan_ascii(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
encoding: "ascii",
length: run_len as u32,
content: s.to_string(),
section: String::new(),
});
}
// Skip the NUL (if any) before continuing.
@@ -139,6 +154,7 @@ fn scan_utf16le(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
encoding: "utf16le",
length: ((i - start) as u32),
content: s,
section: String::new(),
});
}
// Skip past the terminator.
@@ -146,23 +162,82 @@ fn scan_utf16le(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
}
}
/// Per JIS X 0208: Shift_JIS first byte ∈ [0x81, 0x9F] ∪ [0xE0, 0xEF];
/// trail byte ∈ [0x40, 0x7E] ∪ [0x80, 0xFC]. Single-byte ASCII and JIS
/// half-width katakana (0xA1..=0xDF) are passed through.
/// Per JIS X 0208: Shift_JIS lead byte is [0x81, 0x9F] u [0xE0, 0xEF];
/// trail byte is [0x40, 0x7E] u [0x80, 0xFC].
///
/// Half-width katakana (0xA1..=0xDF) is deliberately *not* accepted as string
/// content. It is legal Shift_JIS, but this binary's Japanese text never uses
/// it, while 0xA1..=0xDF is extremely common in the float and pointer tables
/// that share `.rdata` — admitting it turned the scan into a noise generator
/// (837 detections, of which the overwhelming majority were IEEE-754 arrays:
/// `3f 66 66 66` = 0.9f reads as "fff").
fn is_sjis_lead(b: u8) -> bool {
(0x81..=0x9F).contains(&b) || (0xE0..=0xEF).contains(&b)
}
fn is_sjis_trail(b: u8) -> bool {
(0x40..=0x7E).contains(&b) || (0x80..=0xFC).contains(&b)
}
fn is_sjis_singlebyte(b: u8) -> bool {
is_printable_ascii(b) || (0xA1..=0xDF).contains(&b)
/// A character that can plausibly appear in a Japanese debug/UI string:
/// printable ASCII, CJK punctuation and kana, CJK ideographs, or full-width
/// ASCII.
fn is_text_like(ch: char) -> bool {
let o = ch as u32;
matches!(o, 0x20..=0x7E)
|| matches!(ch, '\t' | '\n' | '\r')
|| is_wide(ch)
}
/// Scan for Shift_JIS strings — runs of ≥ 6 bytes consisting of valid
/// SJIS code units (single-byte ASCII / half-width katakana, OR a
/// lead+trail pair). At least one multi-byte pair must be present so we
/// don't double-count strings that are purely ASCII.
/// A full-width character — kana, CJK punctuation, ideograph, or full-width
/// ASCII. Used to tell "real text" from a lucky byte pair.
fn is_wide(ch: char) -> bool {
let o = ch as u32;
(0x3000..=0x30FF).contains(&o) || (0x4E00..=0x9FFF).contains(&o) || (0xFF01..=0xFF5E).contains(&o)
}
/// True when `t` contains a lone ASCII character with a full-width character
/// on *both* sides.
///
/// This is the Shift_JIS resynchronisation signal. A scan that starts one byte
/// early pairs the wrong lead with the wrong trail and typically produces a
/// stray kanji plus an orphaned ASCII letter before the real text resumes:
/// the run at 0x820a4b9f decodes as `帥Vステムマネージャ開始` when the actual
/// string is `システムマネージャ開始` at 0x820a4ba0. Genuine text mixes ASCII in
/// *runs* (`render_stateスタックオーバーフロー`, `size=%d`), never as a single
/// character wedged between two wide ones.
fn has_isolated_ascii(t: &str) -> bool {
let chars: Vec<char> = t.chars().collect();
(1..chars.len().saturating_sub(1)).any(|k| {
!is_wide(chars[k]) && is_wide(chars[k - 1]) && is_wide(chars[k + 1])
})
}
/// Decode `raw` as Shift_JIS, rejecting anything that is not convincingly
/// Japanese text. Returns the UTF-8 form on success.
fn decode_sjis(raw: &[u8]) -> Option<String> {
let (text, _, had_errors) = encoding_rs::SHIFT_JIS.decode(raw);
if had_errors {
return None;
}
let t = text.into_owned();
// Require real kana somewhere. Arbitrary binary readily decodes to
// obscure kanji, but hiragana/katakana (U+3040..U+30FF) essentially never
// appear by accident and are ubiquitous in genuine Japanese.
let has_kana = t.chars().any(|c| ('\u{3040}'..='\u{30FF}').contains(&c));
if t.chars().count() >= 4 && has_kana && t.chars().all(is_text_like) && !has_isolated_ascii(&t) {
Some(t)
} else {
None
}
}
/// Scan for Shift_JIS strings — NUL-terminated runs of >= `MIN_LEN` bytes made
/// of printable ASCII and valid lead+trail pairs, with at least one pair.
///
/// Each accepted run is *resynchronised*: the emitted string starts at the
/// earliest offset within the run whose full decode passes [`decode_sjis`], so
/// a run that begins mid-character reports the true string address rather than
/// a mangled one.
fn scan_shift_jis(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
let mut i = 0;
while i < bytes.len() {
@@ -175,47 +250,29 @@ fn scan_shift_jis(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
has_multibyte = true;
nbytes += 2;
i += 2;
} else if is_sjis_singlebyte(b) {
} else if is_printable_ascii(b) {
nbytes += 1;
i += 1;
} else {
break;
}
}
// Require NUL terminator + min length + at least one multi-byte char.
if has_multibyte
&& nbytes >= MIN_LEN
&& i < bytes.len() && bytes[i] == 0
{
// Decode SJIS → UTF-8 best-effort. We don't ship a full
// SJIS decoder; keep the bytes as a `\u{XX}\u{YY}…` style
// rendering for diagnostic readability, and let downstream
// tooling re-decode if needed.
let raw = &bytes[start..i];
let mut s = String::with_capacity(raw.len() * 4);
let mut p = 0;
while p < raw.len() {
let b = raw[p];
if is_sjis_lead(b) && p + 1 < raw.len() && is_sjis_trail(raw[p + 1]) {
// Render as SJIS hex pair so the string is identifiable
// even without a decoder. Real Japanese decoding is a
// future enhancement.
s.push_str(&format!("\\x{:02X}\\x{:02X}", b, raw[p + 1]));
p += 2;
} else {
s.push(b as char);
p += 1;
let end = i;
if has_multibyte && nbytes >= MIN_LEN && end < bytes.len() && bytes[end] == 0 {
for s in start..end {
if let Some(text) = decode_sjis(&bytes[s..end]) {
out.push(DetectedString {
address: va_base + s as u32,
encoding: "shift_jis",
length: (end - s) as u32,
content: text,
section: String::new(),
});
break;
}
}
out.push(DetectedString {
address: va_base + start as u32,
encoding: "shift_jis",
length: nbytes as u32,
content: s,
});
i += 1; // skip NUL
i = end + 1; // skip NUL
} else {
// Advance past whatever didn't match.
i = start + 1;
if i < bytes.len() && bytes[i] == 0 { i += 1; }
}
@@ -265,6 +322,7 @@ fn scan_utf8(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
encoding: "utf8",
length: nbytes as u32,
content: s.to_string(),
section: String::new(),
});
i += 1; // skip NUL
} else {
@@ -339,15 +397,54 @@ mod tests {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
// "ABC" + (SJIS hiragana 'a' = 0x82 0xA0) + (SJIS 'i' = 0x82 0xA2) + NUL
// "ABC" + SJIS hiragana あ (0x82 0xA0) + い (0x82 0xA2) + NUL.
let s: &[u8] = b"ABC\x82\xA0\x82\xA2\0";
pe[off..off + s.len()].copy_from_slice(s);
let sections = vec![mk_section(".rdata", 0x1000, 0x100)];
let strings = analyze(&pe, image_base, &sections);
let sjis: Vec<_> = strings.iter().filter(|s| s.encoding == "shift_jis").collect();
assert_eq!(sjis.len(), 1);
assert!(sjis[0].content.contains("ABC"));
assert!(sjis[0].content.contains("\\x82\\xA0"));
// Decoded to real UTF-8, not rendered as escaped bytes.
assert_eq!(sjis[0].content, "ABCあい");
assert_eq!(sjis[0].address, image_base + 0x1000);
}
#[test]
fn shift_jis_rejects_float_table_noise() {
// Four IEEE-754 floats (0.85, 0.9, 0.8, 0.7). Every byte satisfies the
// Shift_JIS lead/trail ranges, so the byte-range test alone accepts it.
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
let s: &[u8] = b"\x3f\x59\x99\x9a\x3f\x66\x66\x66\x3f\x4c\xcc\xcd\x3f\x33\x33\x33\0";
pe[off..off + s.len()].copy_from_slice(s);
let sections = vec![mk_section(".rdata", 0x1000, 0x100)];
let strings = analyze(&pe, image_base, &sections);
assert!(strings.iter().all(|s| s.encoding != "shift_jis"),
"float table must not be reported as Japanese text");
}
#[test]
fn shift_jis_resynchronises_to_true_start() {
// Mirrors 0x820a4b9f in the reference title: binary data runs straight
// into a real string, and a naive forward scan mis-pairs the boundary
// byte, yielding `帥Vステム…` one byte early instead of `システム…`.
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
// Exact bytes from that site: a trailing 0x90 from the preceding
// float pairs with the string's first byte (0x83) to form 帥, which
// orphans the 0x56 as an ASCII 'V' before the text resumes.
// 0x90 シ ス テ ム
let s: &[u8] = b"\x90\x83\x56\x83\x58\x83\x65\x83\x80\0";
pe[off..off + s.len()].copy_from_slice(s);
let sections = vec![mk_section(".rdata", 0x1000, 0x100)];
let strings = analyze(&pe, image_base, &sections);
let sjis: Vec<_> = strings.iter().filter(|s| s.encoding == "shift_jis").collect();
assert_eq!(sjis.len(), 1);
assert_eq!(sjis[0].content, "システム");
// Reported at the true start, one byte past the run's beginning.
assert_eq!(sjis[0].address, image_base + 0x1000 + 1);
}
#[test]

View File

@@ -101,12 +101,22 @@ pub fn analyze_with_anchors(
.filter(|s| matches!(s.name.as_str(), ".rdata" | ".data"))
.collect();
// Range table for "is this VA in .rdata or .data?"
// Range table for "is this VA in .rdata?" — where COLs and class-hierarchy
// descriptors live.
let rdata_ranges: Vec<(u32, u32)> = sections
.iter()
.filter(|s| s.name == ".rdata")
.map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size))
.collect();
// TypeDescriptors are *written at startup* (their first word is
// `type_info`'s vftable), so MSVC emits them into writable `.data`, not
// `.rdata`. Range-checking a TypeDescriptor pointer against `.rdata` alone
// rejects every one of them and leaves the whole inline walk dead.
let typedesc_ranges: Vec<(u32, u32)> = sections
.iter()
.filter(|s| matches!(s.name.as_str(), ".rdata" | ".data"))
.map(|s| (image_base + s.virtual_address, image_base + s.virtual_address + s.virtual_size))
.collect();
let mut candidates: Vec<Vtable> = Vec::new();
@@ -280,7 +290,7 @@ pub fn analyze_with_anchors(
// Try to extract the TypeDescriptor mangled-name string.
if let Some((td_ptr, hierarchy_ptr)) = read_col(pe, image_base, col_ptr)
&& let Some(mangled) = read_typedescriptor_name(pe, image_base, td_ptr, &rdata_ranges)
&& let Some(mangled) = read_typedescriptor_name(pe, image_base, td_ptr, &typedesc_ranges)
&& let Some(class) = demangle_rtti_typename(&mangled)
{
v.col_address = Some(col_ptr);
@@ -771,3 +781,61 @@ mod tests {
assert_eq!(vtables.len(), 0, "runs of 2 must be rejected to keep false-positive rate down");
}
}
// ── RTTI relabelling ───────────────────────────────────────────────────────
/// Overwrite heuristic vtable identity with the authoritative RTTI walk.
///
/// [`analyze_with_anchors`] names a table either from its own inline COL walk
/// or, failing that, with a synthetic `ANON_Class_<hash>`. [`crate::rtti`]
/// resolves the same question top-down from the structures the linker emitted,
/// which is exact — so wherever the two disagree, RTTI wins. Rows RTTI knows
/// nothing about keep their heuristic name.
///
/// `base_classes_json` is rebuilt here as the class's full linearised base list
/// (excluding index 0, which is the class itself), which is strictly more than
/// the first-level list the inline walk produced.
///
/// Returns the number of vtables that gained a real class name.
pub fn apply_rtti_names(vtables: &mut [Vtable], rtti: &crate::rtti::RttiResult) -> usize {
use std::collections::BTreeMap;
let names = rtti.vtable_class_names();
let locator_by_vtable: BTreeMap<u32, &crate::rtti::CompleteObjectLocator> = rtti
.locators
.iter()
.filter_map(|c| c.vtable_address.map(|v| (v, c)))
.collect();
// class-hierarchy VA → base class names, in the linker's order.
let mut bases_by_chd: BTreeMap<u32, Vec<&str>> = BTreeMap::new();
for b in &rtti.base_classes {
if b.index == 0 { continue; } // index 0 is the class itself
bases_by_chd.entry(b.class_hierarchy).or_default().push(b.name.as_str());
}
let mut named = 0usize;
for vt in vtables.iter_mut() {
let Some((class_name, offset)) = names.get(&vt.address) else { continue };
// A secondary-base vftable belongs to the same class but is a distinct
// table; keep them apart by suffixing the subobject offset.
vt.class_name = if *offset == 0 {
class_name.clone()
} else {
format!("{class_name}#base+0x{offset:X}")
};
vt.rtti_present = true;
if let Some(col) = locator_by_vtable.get(&vt.address) {
vt.col_address = Some(col.address);
vt.base_classes_json = bases_by_chd.get(&col.class_hierarchy).map(|names| {
let items: Vec<String> = names
.iter()
.map(|n| format!("\"{}\"", n.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
format!("[{}]", items.join(","))
});
}
named += 1;
}
named
}

View File

@@ -0,0 +1,450 @@
//! XDBF / SPA — the title metadata package embedded in the XEX.
//!
//! A title's `XEX_HEADER_RESOURCE_INFO` names one resource whose body is an
//! **XDBF** ("Xbox DataBase File") container, in its SPA flavour: achievement
//! definitions, one string table per shipped language, PNG images, and the
//! matchmaking / leaderboard / presence schema.
//!
//! ```text
//! XdbfHeader 24 bytes magic 'XDBF', version, entry_count, entry_used,
//! free_count, free_used
//! XdbfEntry[] 18 each namespace u16, id u64, offset u32, size u32
//! XdbfFileLoc[] 8 each the free-space table
//! data entry offsets are relative to the end of the two tables
//! ```
//!
//! Each entry's body starts with a section header — `magic, version, size`,
//! plus a `u16 count` for the table-shaped ones.
//!
//! Entries are enumerated from the **entry table**, not by scanning for section
//! magics. Scanning is what the project's earlier `tools/xach_dump.py` does, and
//! on this title it finds a phantom seventh `XSTR` (the byte pattern occurs
//! outside any declared entry) where the entry table declares six — which shifts
//! every language index derived from the scan order.
//!
//! Layouts follow the reference implementation in xenia-canary
//! (`src/xenia/kernel/xam/xdbf/{xdbf_io,spa_info}.h`), which in turn cites
//! freestyledash `Tools/XEX/SPA.{h,cpp}`.
/// `XDBF` big-endian.
const XDBF_MAGIC: u32 = 0x5844_4246;
/// The well-known entry id carrying the title's own name (in the string-table
/// namespace) and its icon (in the image namespace) — canary's `kXdbfIdTitle`.
pub const ID_TITLE: u64 = 0x8000;
const NS_METADATA: u16 = 1;
const NS_IMAGE: u16 = 2;
const NS_STRING_TABLE: u16 = 3;
/// One row of the container's entry table.
#[derive(Debug, Clone)]
pub struct XdbfEntry {
/// 1 = metadata, 2 = image, 3 = string table.
pub namespace: u16,
/// Entry id. For metadata entries this is the section fourcc as an integer;
/// for string tables it is the [`XLanguage`] value; for images, the image id.
pub id: u64,
/// Absolute offset of the entry body within the image buffer.
pub offset: usize,
/// Entry body length in bytes.
pub size: usize,
/// The body's leading fourcc, when it has one (`XACH`, `XSTR`, …).
pub magic: Option<String>,
}
/// One achievement definition (`XACH`, 36-byte records).
#[derive(Debug, Clone)]
pub struct Achievement {
pub id: u16,
/// String id of the achievement's name.
pub label_id: u16,
/// String id of the description shown once unlocked.
pub description_id: u16,
/// String id of the description shown while locked.
pub unachieved_id: u16,
pub image_id: u32,
pub gamerscore: u16,
pub flags: u32,
}
/// One localized string table (`XSTR`).
#[derive(Debug, Clone)]
pub struct StringTable {
/// `XLanguage` value; the entry id.
pub language: u32,
/// `(string id, value)` in table order.
pub strings: Vec<(u16, String)>,
}
/// `XTHD` — the title header.
#[derive(Debug, Clone, Copy)]
pub struct TitleHeader {
pub title_id: u32,
pub title_type: u32,
pub major: u16,
pub minor: u16,
pub build: u16,
pub revision: u16,
pub flags: u32,
}
/// An embedded image (namespace 2). Bodies are raw files, in practice PNG.
#[derive(Debug, Clone)]
pub struct Image {
pub id: u64,
pub offset: usize,
pub size: usize,
/// `"png"` when the body carries the PNG signature, else `"unknown"`.
pub format: &'static str,
}
/// Everything recovered from one XDBF package.
#[derive(Debug, Default)]
pub struct Xdbf {
/// Offset of the container within the image buffer.
pub base: usize,
pub version: u32,
pub entries: Vec<XdbfEntry>,
pub achievements: Vec<Achievement>,
pub string_tables: Vec<StringTable>,
pub images: Vec<Image>,
pub title: Option<TitleHeader>,
/// `XSTC` default language (an `XLanguage` value).
pub default_language: Option<u32>,
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?,
]))
}
fn be64(b: &[u8], o: usize) -> Option<u64> {
let hi = be32(b, o)? as u64;
let lo = be32(b, o + 4)? as u64;
Some((hi << 32) | lo)
}
/// Render a fourcc as text when all four bytes are printable ASCII.
fn fourcc(v: u32) -> Option<String> {
let b = v.to_be_bytes();
b.iter()
.all(|c| (0x20..0x7F).contains(c))
.then(|| String::from_utf8_lossy(&b).into_owned())
}
/// Human-readable name for an `XLanguage` value.
pub fn language_name(v: u32) -> &'static str {
match v {
1 => "English",
2 => "Japanese",
3 => "German",
4 => "French",
5 => "Spanish",
6 => "Italian",
7 => "Korean",
8 => "Chinese (Traditional)",
9 => "Portuguese",
10 => "Chinese (Simplified)",
11 => "Polish",
12 => "Russian",
_ => "unknown",
}
}
/// Parse the XDBF package at `base` within `image`.
///
/// Returns `None` when there is no XDBF magic there — callers locate the
/// package via `xenia_xex::resources`, and a title without one is normal.
#[tracing::instrument(skip_all, fields(base = format_args!("{base:#x}")))]
pub fn analyze(image: &[u8], base: usize) -> Option<Xdbf> {
let started = std::time::Instant::now();
if be32(image, base)? != XDBF_MAGIC {
return None;
}
let version = be32(image, base + 4)?;
let entry_count = be32(image, base + 8)? as usize;
let entry_used = be32(image, base + 12)? as usize;
let free_count = be32(image, base + 16)? as usize;
// Guard against a corrupt header pointing the data region off the end.
if entry_used > entry_count || entry_count > 0x10000 || free_count > 0x10000 {
return None;
}
let entry_table = base + 24;
let data_start = entry_table + entry_count * 18 + free_count * 8;
if data_start > image.len() {
return None;
}
let mut out = Xdbf {
base,
version,
..Default::default()
};
for i in 0..entry_used {
let p = entry_table + i * 18;
let (Some(namespace), Some(id), Some(off), Some(size)) =
(be16(image, p), be64(image, p + 2), be32(image, p + 10), be32(image, p + 14))
else {
continue;
};
let body = data_start + off as usize;
let size = size as usize;
if body + size > image.len() {
continue;
}
let magic = be32(image, body).and_then(fourcc);
out.entries.push(XdbfEntry {
namespace,
id,
offset: body,
size,
magic: magic.clone(),
});
match namespace {
NS_IMAGE => out.images.push(Image {
id,
offset: body,
size,
format: if image[body..].starts_with(b"\x89PNG") { "png" } else { "unknown" },
}),
NS_STRING_TABLE => {
if let Some(t) = parse_string_table(image, body, size, id as u32) {
out.string_tables.push(t);
}
}
NS_METADATA => match magic.as_deref() {
Some("XACH") => out.achievements.extend(parse_achievements(image, body, size)),
Some("XTHD") => out.title = parse_title_header(image, body),
Some("XSTC") => out.default_language = be32(image, body + 12),
_ => {}
},
_ => {}
}
}
metrics::histogram!("analysis.phase_ms", "phase" => "xdbf")
.record(started.elapsed().as_millis() as f64);
tracing::info!(
entries = out.entries.len(),
achievements = out.achievements.len(),
string_tables = out.string_tables.len(),
images = out.images.len(),
default_language = out.default_language,
"XDBF package parsed",
);
Some(out)
}
/// `XACH`: `magic, version, size, count u16`, then 36-byte records.
fn parse_achievements(image: &[u8], body: usize, size: usize) -> Vec<Achievement> {
let Some(count) = be16(image, body + 12) else { return Vec::new() };
let mut out = Vec::with_capacity(count as usize);
for i in 0..count as usize {
let p = body + 14 + i * 36;
if p + 36 > body + size {
break;
}
let (Some(id), Some(label_id), Some(description_id), Some(unachieved_id)) =
(be16(image, p), be16(image, p + 2), be16(image, p + 4), be16(image, p + 6))
else {
break;
};
out.push(Achievement {
id,
label_id,
description_id,
unachieved_id,
image_id: be32(image, p + 8).unwrap_or(0),
gamerscore: be16(image, p + 12).unwrap_or(0),
flags: be32(image, p + 16).unwrap_or(0),
});
}
out
}
/// `XSTR`: `magic, version, size, count u16`, then `id u16, len u16, bytes`.
///
/// Bodies are UTF-8 (the ASCII subset for most locales; Japanese uses the full
/// range), decoded lossily so one bad table cannot drop a whole language.
fn parse_string_table(image: &[u8], body: usize, size: usize, language: u32) -> Option<StringTable> {
if fourcc(be32(image, body)?)? != "XSTR" {
return None;
}
let count = be16(image, body + 12)?;
let end = body + size;
let mut p = body + 14;
let mut strings = Vec::with_capacity(count as usize);
for _ in 0..count {
let (Some(id), Some(len)) = (be16(image, p), be16(image, p + 2)) else { break };
let s = p + 4;
let e = s + len as usize;
if e > end || e > image.len() {
break;
}
strings.push((id, String::from_utf8_lossy(&image[s..e]).into_owned()));
p = e;
}
Some(StringTable { language, strings })
}
/// `XTHD`: section header then the 32-byte `TitleHeaderData`.
fn parse_title_header(image: &[u8], body: usize) -> Option<TitleHeader> {
let p = body + 12;
Some(TitleHeader {
title_id: be32(image, p)?,
title_type: be32(image, p + 4)?,
major: be16(image, p + 8)?,
minor: be16(image, p + 10)?,
build: be16(image, p + 12)?,
revision: be16(image, p + 14)?,
flags: be32(image, p + 16)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal XDBF: one XACH with a single achievement, one XSTR, one
/// PNG, an XTHD and an XSTC.
fn mk_xdbf() -> (Vec<u8>, usize) {
let base = 0x100usize;
let entry_count = 5usize;
let free_count = 1usize;
let data_start = base + 24 + entry_count * 18 + free_count * 8;
let mut bodies: Vec<(u16, u64, Vec<u8>)> = Vec::new();
let mut xach = Vec::new();
xach.extend(b"XACH");
xach.extend(1u32.to_be_bytes());
xach.extend(0u32.to_be_bytes());
xach.extend(1u16.to_be_bytes()); // count
let mut rec = Vec::new();
rec.extend(7u16.to_be_bytes()); // id
rec.extend(100u16.to_be_bytes()); // label
rec.extend(101u16.to_be_bytes()); // description
rec.extend(102u16.to_be_bytes()); // unachieved
rec.extend(9u32.to_be_bytes()); // image id
rec.extend(20u16.to_be_bytes()); // gamerscore
rec.extend(0u16.to_be_bytes());
rec.extend(0x0Cu32.to_be_bytes()); // flags
rec.extend([0u8; 16]);
assert_eq!(rec.len(), 36);
xach.extend(rec);
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XACH") as u64, xach));
let mut xstr = Vec::new();
xstr.extend(b"XSTR");
xstr.extend(1u32.to_be_bytes());
xstr.extend(0u32.to_be_bytes());
xstr.extend(2u16.to_be_bytes());
for (id, s) in [(100u16, "Space Combat Award"), (101u16, "Well done")] {
xstr.extend(id.to_be_bytes());
xstr.extend((s.len() as u16).to_be_bytes());
xstr.extend(s.as_bytes());
}
bodies.push((NS_STRING_TABLE, 1, xstr)); // language 1 = English
let mut xthd = Vec::new();
xthd.extend(b"XTHD");
xthd.extend(1u32.to_be_bytes());
xthd.extend(0u32.to_be_bytes());
xthd.extend(0x5351_07D4u32.to_be_bytes()); // title id
xthd.extend(1u32.to_be_bytes()); // type = full
xthd.extend(1u16.to_be_bytes());
xthd.extend(2u16.to_be_bytes());
xthd.extend(3u16.to_be_bytes());
xthd.extend(4u16.to_be_bytes());
xthd.extend(0u32.to_be_bytes());
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XTHD") as u64, xthd));
let mut xstc = Vec::new();
xstc.extend(b"XSTC");
xstc.extend(1u32.to_be_bytes());
xstc.extend(16u32.to_be_bytes());
xstc.extend(1u32.to_be_bytes()); // default language = English
bodies.push((NS_METADATA, u32::from_be_bytes(*b"XSTC") as u64, xstc));
let png = b"\x89PNG\r\n\x1a\n----".to_vec();
bodies.push((NS_IMAGE, 9, png));
let total: usize = bodies.iter().map(|(_, _, b)| b.len()).sum();
let mut img = vec![0u8; data_start + total + 0x10];
img[base..base + 4].copy_from_slice(&XDBF_MAGIC.to_be_bytes());
img[base + 4..base + 8].copy_from_slice(&0x10000u32.to_be_bytes());
img[base + 8..base + 12].copy_from_slice(&(entry_count as u32).to_be_bytes());
img[base + 12..base + 16].copy_from_slice(&(bodies.len() as u32).to_be_bytes());
img[base + 16..base + 20].copy_from_slice(&(free_count as u32).to_be_bytes());
let mut off = 0usize;
for (i, (ns, id, b)) in bodies.iter().enumerate() {
let p = base + 24 + i * 18;
img[p..p + 2].copy_from_slice(&ns.to_be_bytes());
img[p + 2..p + 10].copy_from_slice(&id.to_be_bytes());
img[p + 10..p + 14].copy_from_slice(&(off as u32).to_be_bytes());
img[p + 14..p + 18].copy_from_slice(&(b.len() as u32).to_be_bytes());
img[data_start + off..data_start + off + b.len()].copy_from_slice(b);
off += b.len();
}
(img, base)
}
#[test]
fn parses_container_via_entry_table() {
let (img, base) = mk_xdbf();
let x = analyze(&img, base).expect("parses");
assert_eq!(x.entries.len(), 5);
assert_eq!(x.achievements.len(), 1);
assert_eq!(x.string_tables.len(), 1);
assert_eq!(x.images.len(), 1);
assert_eq!(x.default_language, Some(1));
}
#[test]
fn achievement_fields_and_string_ids_line_up() {
let (img, base) = mk_xdbf();
let x = analyze(&img, base).unwrap();
let a = &x.achievements[0];
assert_eq!((a.id, a.gamerscore, a.image_id, a.flags), (7, 20, 9, 0x0C));
let t = &x.string_tables[0];
assert_eq!(t.language, 1);
assert_eq!(t.strings[0], (100, "Space Combat Award".to_string()));
// The achievement's label resolves through the table.
let name = t.strings.iter().find(|(i, _)| *i == a.label_id).map(|(_, s)| s.as_str());
assert_eq!(name, Some("Space Combat Award"));
}
#[test]
fn title_header_and_image_format() {
let (img, base) = mk_xdbf();
let x = analyze(&img, base).unwrap();
let t = x.title.expect("XTHD");
assert_eq!(t.title_id, 0x5351_07D4);
assert_eq!((t.major, t.minor, t.build, t.revision), (1, 2, 3, 4));
assert_eq!(x.images[0].format, "png");
assert_eq!(x.images[0].id, 9);
}
#[test]
fn rejects_non_xdbf() {
let img = vec![0u8; 0x200];
assert!(analyze(&img, 0x100).is_none());
}
#[test]
fn rejects_header_pointing_past_the_buffer() {
let mut img = vec![0u8; 0x200];
img[0..4].copy_from_slice(&XDBF_MAGIC.to_be_bytes());
img[8..12].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_count
img[12..16].copy_from_slice(&0xFFFFu32.to_be_bytes()); // entry_used
assert!(analyze(&img, 0).is_none());
}
}

View File

@@ -10,6 +10,7 @@ use crate::func::FuncAnalysis;
pub enum XrefKind {
Call, // bl
IndirectCall, // bcctrl through a statically-resolvable vtable slot (M5)
JumpTable, // bctr through a recovered switch jump table (M12)
Jump, // b (unconditional)
Branch, // bc / bXX (conditional)
DataRead, // lwz, lbz, lhz, lha, lfs, lfd, etc. from resolved address
@@ -22,6 +23,7 @@ impl XrefKind {
match self {
XrefKind::Call => "call",
XrefKind::IndirectCall => "ind_call",
XrefKind::JumpTable => "jt",
XrefKind::Jump => "j",
XrefKind::Branch => "br",
XrefKind::DataRead => "read",
@@ -109,6 +111,31 @@ pub fn analyze_xrefs(
sections: &[PeSection],
func_analysis: &FuncAnalysis,
import_map: &HashMap<u32, String>,
) -> XrefResult {
analyze_xrefs_skipping(
pe, image_base, entry_point, sections, func_analysis, import_map,
&std::collections::BTreeSet::new(),
)
}
/// Like [`analyze_xrefs`], but skips the word addresses in `data_words`.
///
/// Those are data embedded in a code section — recovered jump tables and their
/// index maps (see [`crate::jumptables`]). Decoding them yields whatever
/// instruction their bit pattern happens to spell, and any reference that
/// "instruction" appears to make is fiction. On the reference title every case
/// target begins `0x82…`, which decodes as a `lwz`, so the damage is bogus data
/// reads rather than bogus control flow — but it is damage either way, and it
/// also invents `dat_…` labels in the middle of `.rdata`.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), data_words = data_words.len()))]
pub fn analyze_xrefs_skipping(
pe: &[u8],
image_base: u32,
entry_point: u32,
sections: &[PeSection],
func_analysis: &FuncAnalysis,
import_map: &HashMap<u32, String>,
data_words: &std::collections::BTreeSet<u32>,
) -> XrefResult {
let started = std::time::Instant::now();
let func_labels = func_analysis.generate_labels();
@@ -138,7 +165,9 @@ pub fn analyze_xrefs(
pe[off], pe[off+1], pe[off+2], pe[off+3]
]);
collect_branch_target(instr, abs_addr, &mut labels, &mut xrefs);
if !data_words.contains(&abs_addr) {
collect_branch_target(instr, abs_addr, &mut labels, &mut xrefs);
}
addr += 4;
}
}
@@ -170,6 +199,15 @@ pub fn analyze_xrefs(
pe[off], pe[off+1], pe[off+2], pe[off+3]
]);
// A jump-table word is not an instruction. Skip it, and drop the
// tracked constants with it: the words around it belong to
// different basic blocks, so nothing carries across.
if data_words.contains(&abs_addr) {
reg_hi = [None; 32];
addr += 4;
continue;
}
let opcode = (instr >> 26) & 0x3F;
let rd = ((instr >> 21) & 0x1F) as usize;
let ra = ((instr >> 16) & 0x1F) as usize;

View File

@@ -8,7 +8,7 @@
//! instructions, plus an empty import-library list and one detected
//! function.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Write;
use duckdb::Connection;
@@ -16,6 +16,7 @@ use duckdb::Connection;
use xenia_analysis::DbWriter;
use xenia_analysis::formatter::DisasmInfo;
use xenia_analysis::func::{FuncAnalysis, FuncInfo};
use xenia_analysis::rtti::RttiResult;
use xenia_analysis::xref::XrefMap;
use xenia_xex::pe::PeSection;
@@ -67,6 +68,7 @@ fn synthetic_func_analysis(image_base: u32) -> FuncAnalysis {
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
},
);
@@ -92,6 +94,7 @@ fn db_schema_matches_expected_columns() {
media_id: Some(0xCAFEF00D),
sections: &sections,
import_libraries: &libs,
xex_header: None,
};
let func_analysis = synthetic_func_analysis(image_base);
@@ -105,9 +108,12 @@ fn db_schema_matches_expected_columns() {
{
let mut w = DbWriter::open_fresh(&tmp).expect("open fresh DB");
w.write_base(&info).expect("write_base");
w.ingest_instructions(&pe, &info, &func_analysis, &labels)
w.ingest_instructions(&pe, &info, &func_analysis, &labels, &BTreeSet::new())
.expect("ingest_instructions");
w.write_analysis_results(&pe, &info, &func_analysis, &labels, &xrefs, &[], &[], &[], None, &[])
w.write_analysis_results(
&pe, &info, &func_analysis, &labels, &xrefs,
&[], &[], &[], None, &[], &[], &RttiResult::default(), None,
)
.expect("write_analysis_results");
w.create_sql_views().expect("create_sql_views");
}
@@ -149,6 +155,7 @@ fn db_schema_matches_expected_columns() {
("section", "VARCHAR"),
("function", "BIGINT"),
("label", "VARCHAR"),
("is_data", "BOOLEAN"),
]),
("functions", &[
("address", "BIGINT"),
@@ -160,8 +167,54 @@ fn db_schema_matches_expected_columns() {
("is_saverestore", "BOOLEAN"),
("pdata_validated", "BOOLEAN"),
("pdata_length", "BIGINT"),
("prolog_length", "BIGINT"),
("has_eh", "BOOLEAN"),
]),
("jump_tables", &[
("bctr_pc", "BIGINT"),
("function", "BIGINT"),
("table_address", "BIGINT"),
("entry_count", "BIGINT"),
("table_slots", "BIGINT"),
("index_map_address", "BIGINT"),
("index_map_count", "BIGINT"),
("case_bound", "BIGINT"),
("kind", "VARCHAR"),
]),
("jump_table_entries", &[
("bctr_pc", "BIGINT"),
("case_index", "BIGINT"),
("target_address", "BIGINT"),
]),
("data_in_code", &[
("address", "BIGINT"),
("length", "BIGINT"),
("kind", "VARCHAR"),
]),
("rtti_type_descriptors", &[
("address", "BIGINT"),
("mangled_name", "VARCHAR"),
("demangled_name", "VARCHAR"),
]),
("rtti_locators", &[
("address", "BIGINT"),
("subobject_offset", "BIGINT"),
("cd_offset", "BIGINT"),
("type_descriptor", "BIGINT"),
("class_hierarchy", "BIGINT"),
("vtable_address", "BIGINT"),
]),
("rtti_base_classes", &[
("class_hierarchy", "BIGINT"),
("base_index", "BIGINT"),
("type_descriptor", "BIGINT"),
("name", "VARCHAR"),
("num_contained_bases", "BIGINT"),
("mdisp", "BIGINT"),
("pdisp", "BIGINT"),
("vdisp", "BIGINT"),
("attributes", "BIGINT"),
]),
("pdata_entries", &[
("begin_address", "BIGINT"),
("end_address", "BIGINT"),
@@ -174,6 +227,39 @@ fn db_schema_matches_expected_columns() {
("name", "VARCHAR"),
("kind", "VARCHAR"),
]),
("xdbf_entries", &[
("namespace", "BIGINT"),
("namespace_name", "VARCHAR"),
("id", "BIGINT"),
("body_offset", "BIGINT"),
("size", "BIGINT"),
("magic", "VARCHAR"),
]),
("xdbf_achievements", &[
("id", "BIGINT"),
("name", "VARCHAR"),
("unlocked_desc", "VARCHAR"),
("locked_desc", "VARCHAR"),
("label_id", "BIGINT"),
("description_id", "BIGINT"),
("unachieved_id", "BIGINT"),
("image_id", "BIGINT"),
("gamerscore", "BIGINT"),
("flags", "BIGINT"),
]),
("xdbf_strings", &[
("language", "BIGINT"),
("language_name", "VARCHAR"),
("string_id", "BIGINT"),
("value", "VARCHAR"),
]),
("xdbf_images", &[
("id", "BIGINT"),
("is_title_icon", "BOOLEAN"),
("body_offset", "BIGINT"),
("size", "BIGINT"),
("format", "VARCHAR"),
]),
("demangled_names", &[
("address", "BIGINT"),
("mangled", "VARCHAR"),
@@ -209,6 +295,7 @@ fn db_schema_matches_expected_columns() {
("encoding", "VARCHAR"),
("length", "BIGINT"),
("content", "VARCHAR"),
("section", "VARCHAR"),
]),
("tls_info", &[
("raw_data_start", "BIGINT"),
@@ -237,6 +324,7 @@ fn db_schema_matches_expected_columns() {
("vptr_offset", "BIGINT"),
("slot", "BIGINT"),
("candidate_count", "BIGINT"),
("truncated", "BOOLEAN"),
]),
("indirect_dispatch_candidates", &[
("dispatch_pc", "BIGINT"),

View File

@@ -330,6 +330,18 @@ enum Commands {
/// branch xrefs. Disagreement is logged as a warning (non-fatal).
#[arg(long, value_enum, default_value_t = AnalyzeMode::Rust)]
analyze: AnalyzeMode,
/// Ceiling on candidates materialised per unresolved virtual-call site.
///
/// A `bcctrl` through `this->vptr` is resolved by matching
/// `(vptr_offset, slot)` against every class installing a vtable at
/// that offset. At offset 0 that matches almost every class, so the
/// result is a cross product rather than an answer — one site can
/// claim 700+ callees. Sites above this ceiling are still recorded in
/// `indirect_dispatch_sites` (with `truncated` set and a truthful
/// `candidate_count`), but emit no `indirect_dispatch_candidates` rows
/// and no `ind_call` xrefs. Raise it to get the full cross product back.
#[arg(long, default_value_t = xenia_analysis::ind_dispatch_typed::DEFAULT_MAX_CANDIDATES)]
max_indirect_candidates: usize,
/// Suppress assembly text output (DB-only mode)
#[arg(long)]
quiet: bool,
@@ -491,7 +503,7 @@ fn main() -> Result<()> {
Commands::Browse { path } => cmd_browse(&path),
Commands::Info { path } => cmd_info(&path),
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()),
Commands::Dis { path, output, db, json, analyze, quiet } => cmd_dis(&path, output.as_deref(), db.as_deref(), json.as_deref(), analyze, quiet),
Commands::Dis { path, output, db, json, analyze, max_indirect_candidates, quiet } => cmd_dis(&path, output.as_deref(), db.as_deref(), json.as_deref(), analyze, max_indirect_candidates, quiet),
Commands::Check {
path,
max_instructions,
@@ -1701,6 +1713,7 @@ fn cmd_exec_inner(
media_id: header.execution_info.as_ref().map(|e| e.media_id),
sections: &sections,
import_libraries: &header.import_libraries,
xex_header: Some(&header),
};
info!(db = %db, "writing database");
@@ -5061,7 +5074,13 @@ fn cmd_browse(path: &str) -> Result<()> {
/// Helper: load XEX, parse header, decompress PE, resolve imports, parse sections.
#[instrument(skip_all, fields(path = %path))]
fn load_and_prepare(path: &str) -> Result<(xenia_xex::Xex2Header, Vec<u8>, Vec<xenia_xex::pe::PeSection>)> {
/// Load a XEX and prepare it for analysis.
///
/// Returns the parsed header, the decompressed image, its sections, and the
/// **raw XEX bytes**. The raw bytes are needed because optional-header values
/// are file offsets into the container, not image VAs — the resource table
/// (and so the embedded XDBF package) is only reachable through them.
fn load_and_prepare(path: &str) -> Result<(xenia_xex::Xex2Header, Vec<u8>, Vec<xenia_xex::pe::PeSection>, Vec<u8>)> {
let data = load_xex_data(path)?;
let mut header = xenia_xex::loader::parse_xex2_header(&data)?;
@@ -5086,14 +5105,14 @@ fn load_and_prepare(path: &str) -> Result<(xenia_xex::Xex2Header, Vec<u8>, Vec<x
let sections = xenia_xex::pe::parse_sections(&pe_image)?;
info!(sections = sections.len(), "parsed PE sections");
Ok((header, pe_image, sections))
Ok((header, pe_image, sections, data))
}
#[instrument(skip_all, fields(path = %path))]
fn cmd_extract(path: &str, output_dir: Option<&str>, db_path: Option<&str>) -> Result<()> {
use serde::Serialize;
let (header, pe_image, sections) = load_and_prepare(path)?;
let (header, pe_image, sections, _xex_data) = load_and_prepare(path)?;
let entry = xenia_xex::loader::get_entry_point(&header).unwrap();
let base = xenia_xex::loader::get_image_base(&header).unwrap();
@@ -5168,6 +5187,7 @@ fn cmd_extract(path: &str, output_dir: Option<&str>, db_path: Option<&str>) -> R
media_id: header.execution_info.as_ref().map(|e| e.media_id),
sections: &sections,
import_libraries: &header.import_libraries,
xex_header: Some(&header),
};
info!(db = %db, "writing base tables");
let mut w = xenia_analysis::DbWriter::open_fresh(std::path::Path::new(db))?;
@@ -5185,12 +5205,13 @@ fn cmd_dis(
db_path: Option<&str>,
json_path: Option<&str>,
analyze: AnalyzeMode,
max_indirect_candidates: usize,
quiet: bool,
) -> Result<()> {
use std::collections::HashMap;
let started = Instant::now();
let (header, pe_image, sections) = load_and_prepare(path)?;
let (header, pe_image, sections, xex_data) = load_and_prepare(path)?;
let entry = xenia_xex::loader::get_entry_point(&header).unwrap();
let base = xenia_xex::loader::get_image_base(&header).unwrap();
@@ -5225,10 +5246,48 @@ fn cmd_dis(
"function detection complete",
);
// Cross-reference analysis
let mut xref_result = xenia_analysis::xref::analyze_xrefs(
&pe_image, base, entry, &sections, &func_analysis, &import_map,
// M12 — switch / jump-table recovery. Emits one `jt` xref per distinct
// case body so the case bodies stop looking unreachable, and reports the
// table extents so the linear disassembler can flag them as data.
let jump_tables = xenia_analysis::jumptables::analyze(
&pe_image, base, &sections, &func_analysis,
);
let jt_data_words = xenia_analysis::jumptables::data_word_addresses(&jump_tables);
info!(
jump_tables = jump_tables.len(),
cases = jump_tables.iter().map(|t| t.targets.len()).sum::<usize>(),
data_words = jt_data_words.len(),
"jump-table recovery complete",
);
// Cross-reference analysis
let mut xref_result = xenia_analysis::xref::analyze_xrefs_skipping(
&pe_image, base, entry, &sections, &func_analysis, &import_map, &jt_data_words,
);
// Feed the recovered `switch` edges into the xref graph, so case bodies
// stop looking unreachable and get a label of their own.
let mut jt_edges = 0usize;
for jt in &jump_tables {
for target in jt.distinct_targets() {
xref_result.xrefs
.entry(target)
.or_default()
.push(xenia_analysis::xref::Xref {
source: jt.bctr_pc,
kind: xenia_analysis::xref::XrefKind::JumpTable,
addr_mode: None,
});
xref_result.labels
.entry(target)
.or_insert_with(|| format!("case_{target:08X}"));
jt_edges += 1;
}
xref_result.labels
.entry(jt.table_address)
.or_insert_with(|| format!("jpt_{:08X}", jt.table_address));
}
info!(case_edges = jt_edges, "switch edges added to xref graph");
let total_xrefs: usize = xref_result.xrefs.values().map(|v| v.len()).sum();
info!(
labels = xref_result.labels.len(),
@@ -5251,17 +5310,34 @@ fn cmd_dis(
.collect();
let vptr_block_boundaries: std::collections::HashSet<u32> =
xref_result.labels.keys().copied().collect();
let vtable_anchors = xenia_analysis::vtables::scan_vptr_write_constants(
let mut vtable_anchors = xenia_analysis::vtables::scan_vptr_write_constants(
&pe_image, base, &vptr_anchor_funcs, &sections, &vptr_block_boundaries,
);
info!(vtable_anchors = vtable_anchors.len(), "vptr-write anchor scan complete");
let vtables = xenia_analysis::vtables::analyze_with_anchors(
// M13 — authoritative MSVC RTTI walk. Every `vftable[-1] -> COL` link the
// linker emitted is an anchor the heuristic scan must not miss, and the
// class names it recovers override anything the contiguity scan guessed.
let rtti = xenia_analysis::rtti::analyze(&pe_image, base, &sections);
let rtti_anchors = rtti.vtable_anchors();
let rtti_new_anchors = rtti_anchors.difference(&vtable_anchors).count();
vtable_anchors.extend(rtti_anchors.iter().copied());
info!(
rtti_vtables = rtti_anchors.len(),
new_anchors = rtti_new_anchors,
"RTTI anchors merged",
);
let mut vtables = xenia_analysis::vtables::analyze_with_anchors(
&pe_image, base, &sections, &function_starts, &vtable_anchors,
);
let named = xenia_analysis::vtables::apply_rtti_names(&mut vtables, &rtti);
let vtables = vtables;
let rtti_count = vtables.iter().filter(|v| v.rtti_present).count();
info!(
vtables = vtables.len(),
rtti = rtti_count,
rtti_named = named,
anon = vtables.len() - rtti_count,
"vtable scan complete",
);
@@ -5343,8 +5419,9 @@ fn cmd_dis(
// M5.5 — typed indirect-dispatch resolution (this->vptr → method).
let typed_ind = xenia_analysis::ind_dispatch_typed::analyze(
&pe_image, base, &func_analysis, &vtables, &xref_result.labels,
max_indirect_candidates,
);
let single = typed_ind.dispatches.iter().filter(|d| d.candidate_vtables.len() == 1).count();
let single = typed_ind.dispatches.iter().filter(|d| d.total_candidates == 1).count();
let multi = typed_ind.dispatches.len() - single;
let typed_edges: usize = typed_ind.dispatches.iter().map(|d| d.method_pcs.len()).sum();
info!(
@@ -5355,7 +5432,10 @@ fn cmd_dis(
edges = typed_edges,
"M5.5 typed indirect-dispatch scan complete",
);
// Add ind_call edges for every (dispatch_pc, method) candidate.
// Add ind_call edges for every (dispatch_pc, method) candidate. Sites the
// resolver could not narrow contribute nothing here — `method_pcs` is
// empty for them — which keeps `xrefs` a table of evidence rather than of
// possibilities.
for d in &typed_ind.dispatches {
for &method_pc in &d.method_pcs {
xref_result.xrefs
@@ -5369,6 +5449,30 @@ fn cmd_dis(
}
}
// XDBF/SPA — the title metadata package the XEX names via its resource
// table (achievements, localized strings, images). Located through the
// resource table rather than by scanning for the magic, so the entry
// table's own accounting is what decides what exists.
let resources = xenia_xex::resources::parse_resources(&xex_data, &header);
let xdbf = resources.iter().find_map(|r| {
let off = r.image_offset(base)?;
let x = xenia_analysis::xdbf::analyze(&pe_image, off)?;
info!(
resource = %r.name,
address = format_args!("{:#010x}", r.address),
size = r.size,
entries = x.entries.len(),
achievements = x.achievements.len(),
string_tables = x.string_tables.len(),
images = x.images.len(),
"XDBF package found",
);
Some(x)
});
if xdbf.is_none() && !resources.is_empty() {
info!(resources = resources.len(), "resource table present but no XDBF package");
}
// Build DisasmInfo
let disasm_info = xenia_analysis::formatter::DisasmInfo {
image_base: base,
@@ -5378,6 +5482,7 @@ fn cmd_dis(
media_id: header.execution_info.as_ref().map(|e| e.media_id),
sections: &sections,
import_libraries: &header.import_libraries,
xex_header: Some(&header),
};
// SQLite database output (base + ingest + analyze layers)
@@ -5385,7 +5490,9 @@ fn cmd_dis(
info!(db = %db, analyze = ?analyze, "writing database");
let mut w = xenia_analysis::DbWriter::open_fresh(std::path::Path::new(db))?;
w.write_base(&disasm_info)?;
w.ingest_instructions(&pe_image, &disasm_info, &func_analysis, &xref_result.labels)?;
w.ingest_instructions(
&pe_image, &disasm_info, &func_analysis, &xref_result.labels, &jt_data_words,
)?;
w.write_analysis_results(
&pe_image,
&disasm_info,
@@ -5397,6 +5504,9 @@ fn cmd_dis(
&fparrays,
Some(&typed_ind),
&eh_records,
&jump_tables,
&rtti,
xdbf.as_ref(),
)?;
w.write_tls(tls_info.as_ref())?;
if matches!(analyze, AnalyzeMode::Sql | AnalyzeMode::Both) {
@@ -5430,7 +5540,7 @@ fn cmd_dis(
let abs_end = abs_start + section.virtual_size;
let items = xenia_analysis::enrich_section(
&pe_image, base, &section.name, abs_start, abs_end,
&func_analysis, &xref_result.labels,
&func_analysis, &xref_result.labels, &jt_data_words,
);
total += xenia_analysis::sinks::json::write_jsonl(&mut out, items)?;
}
@@ -5453,6 +5563,7 @@ fn cmd_dis(
&import_map,
&xref_result.xrefs,
&xref_result.data_annotations,
&jt_data_words,
)?;
if let Some(path) = output {

View File

@@ -120,10 +120,20 @@ pub mod header_keys {
pub const ENTRY_POINT: u32 = 0x00010100;
pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201;
pub const IMPORT_LIBRARIES: u32 = 0x000103FF;
pub const TLS_INFO: u32 = 0x00020200;
// These two were swapped. `0x00020104` is TLS_INFO and `0x00020200` is
// DEFAULT_STACK_SIZE — confirmed against the reference implementation
// (xenia-canary `kernel/util/xex2_info.h`) and against this title, whose
// `0x00020104` header points at a TLS descriptor (slot_count 64) while
// `0x00020200` carries the inline value 0x80000 (512 KiB), a sane stack.
// Swapped, `get_stack_size` returned the TLS descriptor's file offset.
pub const TLS_INFO: u32 = 0x00020104;
pub const EXECUTION_INFO: u32 = 0x00040006;
pub const DEFAULT_STACK_SIZE: u32 = 0x00020104;
pub const DEFAULT_STACK_SIZE: u32 = 0x00020200;
pub const ORIGINAL_PE_NAME: u32 = 0x000183FF;
pub const FILE_FORMAT_INFO: u32 = 0x000003FF;
pub const SYSTEM_FLAGS: u32 = 0x00030000;
pub const RESOURCE_INFO: u32 = 0x000002FF;
pub const STATIC_LIBRARIES: u32 = 0x000200FF;
pub const CHECKSUM_TIMESTAMP: u32 = 0x00018002;
pub const GAME_RATINGS: u32 = 0x00040310;
}

View File

@@ -3,6 +3,7 @@ pub mod loader;
pub mod lzx;
pub mod pe;
pub mod pdata;
pub mod resources;
pub mod tls;
pub use header::Xex2Header;

View File

@@ -29,7 +29,10 @@ pub struct PdataEntry {
pub function_length: u32,
/// Prolog size in bytes (prolog_length_dwords * 4).
pub prolog_length: u32,
/// Raw 2-bit flags from the packed word (bit 1 = 32-bit-code, bit 0 = exception).
/// Raw 2-bit flags lifted from the packed word's top two bits, i.e.
/// `(meta >> 30) & 3`. So **bit 0 mirrors packed bit 30 (32-bit-code, set
/// on essentially every PPC entry) and bit 1 mirrors packed bit 31
/// (exception handler registered)** — test `flags & 2` for "has EH".
pub flags: u8,
}

View File

@@ -0,0 +1,127 @@
//! XEX `XEX_HEADER_RESOURCE_INFO` (key `0x000002FF`) — the embedded resource table.
//!
//! The header points at a length-prefixed table of fixed 16-byte records:
//!
//! ```text
//! u32 size total table size in bytes, including this field
//! record[] entries (size - 4) / 16 of:
//! char[8] name resource name, NUL-padded (the title's is its
//! title id in uppercase hex, e.g. "535107D4")
//! u32 address absolute VA of the resource inside the loaded image
//! u32 size resource length in bytes
//! ```
//!
//! For a title the named resource is its **XDBF/SPA package** — achievements,
//! localized strings, and images. See `xenia_analysis::xdbf`.
//!
//! Reference: xenia-canary `kernel/util/xex2_info.h` (`xex2_resource`).
use crate::header::{Xex2Header, header_keys};
/// One entry of the XEX resource table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XexResource {
/// Resource name from the table, trailing NULs stripped.
pub name: String,
/// Absolute VA of the resource within the loaded image.
pub address: u32,
/// Resource length in bytes.
pub size: u32,
}
impl XexResource {
/// Offset of this resource within an image-base-relative buffer.
pub fn image_offset(&self, image_base: u32) -> Option<usize> {
self.address.checked_sub(image_base).map(|o| o as usize)
}
}
/// Parse the resource table out of the raw XEX bytes.
///
/// `data` is the whole XEX file (the optional-header value is a file offset
/// into it, not a VA). Returns an empty vec when the header is absent or the
/// table is truncated — never an error.
pub fn parse_resources(data: &[u8], header: &Xex2Header) -> Vec<XexResource> {
let Some(off) = header
.optional_headers
.iter()
.find(|h| h.key == header_keys::RESOURCE_INFO)
.map(|h| h.value as usize)
else {
return Vec::new();
};
if off + 4 > data.len() {
return Vec::new();
}
let size = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize;
// The size field counts itself; anything smaller than one record is junk.
if size < 4 + 16 || off + size > data.len() {
return Vec::new();
}
let count = (size - 4) / 16;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let p = off + 4 + i * 16;
let name = String::from_utf8_lossy(&data[p..p + 8])
.trim_end_matches('\0')
.to_string();
let address = u32::from_be_bytes([data[p + 8], data[p + 9], data[p + 10], data[p + 11]]);
let rsize = u32::from_be_bytes([data[p + 12], data[p + 13], data[p + 14], data[p + 15]]);
out.push(XexResource { name, address, size: rsize });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::header::{Xex2Header, Xex2OptionalHeader};
fn mk_header(opt: Vec<Xex2OptionalHeader>) -> Xex2Header {
Xex2Header {
magic: crate::header::XEX2_MAGIC,
module_flags: 0,
header_size: 0,
security_offset: 0,
header_count: opt.len() as u32,
optional_headers: opt,
security_info: None,
file_format_info: None,
import_libraries: Vec::new(),
execution_info: None,
original_pe_name: None,
}
}
fn with_resource(value: u32) -> Xex2Header {
mk_header(vec![Xex2OptionalHeader { key: header_keys::RESOURCE_INFO, value }])
}
#[test]
fn parses_one_resource() {
let mut data = vec![0u8; 0x100];
let off = 0x40usize;
data[off..off + 4].copy_from_slice(&(4u32 + 16).to_be_bytes());
data[off + 4..off + 12].copy_from_slice(b"535107D4");
data[off + 12..off + 16].copy_from_slice(&0x828F_B900u32.to_be_bytes());
data[off + 16..off + 20].copy_from_slice(&0x0002_1FCFu32.to_be_bytes());
let r = parse_resources(&data, &with_resource(off as u32));
assert_eq!(r.len(), 1);
assert_eq!(r[0].name, "535107D4");
assert_eq!(r[0].address, 0x828F_B900);
assert_eq!(r[0].size, 0x0002_1FCF);
assert_eq!(r[0].image_offset(0x8200_0000), Some(0x8F_B900));
}
#[test]
fn absent_header_yields_nothing() {
assert!(parse_resources(&[0u8; 0x100], &mk_header(Vec::new())).is_empty());
}
#[test]
fn truncated_table_yields_nothing() {
let mut data = vec![0u8; 0x20];
data[0..4].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes());
assert!(parse_resources(&data, &with_resource(0)).is_empty());
}
}

180
zq.py
View File

@@ -6,13 +6,25 @@ and the fact that the engine vtable / rdata is NOT in the DB (read it from guest
memory with `xenia-rs exec ... --dump-addr=0x<va>` instead).
Usage:
zq.py dis <lo_hex> <hi_hex> # disassemble [lo,hi)
zq.py dis <lo_hex> <hi_hex> # disassemble [lo,hi) (jump-table words shown as .long)
zq.py fn <pc_hex> # function containing pc (address,name,end)
zq.py xref <target_hex> # xrefs whose target == addr (callers)
zq.py callers <vtable_off_dec> # call-sites of vtable slot at byte offset N
# (finds `lwz r11, N(r11)` + reports the fn)
zq.py grep <substr> # instructions whose operands LIKE %substr%
zq.py find <word_hex> # instructions whose raw word == value (e.g. a ptr)
zq.py grep <substr> # instructions whose operands LIKE %substr%
zq.py find <word_hex> # instructions whose raw word == value (e.g. a ptr)
zq.py switch <pc_hex> # recovered switch cases for the bctr at/near pc
zq.py switches [fn_hex] # every recovered switch (optionally in one function)
zq.py classes [substr] # RTTI class names (+ vtable, method count)
zq.py class <name> # one class: bases, vtable, virtual methods
zq.py str <substr> # string literals matching, with referencing functions
zq.py xdbf [substr] # XDBF title text (all locales); substr filters
zq.py ach # XDBF achievements (id, gamerscore, name, descriptions)
A command that needs a table the current DB predates prints what to regenerate
rather than a SQL error.
"""
import duckdb, sys
@@ -20,6 +32,23 @@ DB = '/home/fabi/RE - Project Sylpheed/xenia-rs/sylpheed.db'
c = duckdb.connect(DB, read_only=True)
H = lambda x: '0x%08x' % x
REGEN = ("xenia-rs dis <xex|iso> --db sylpheed.db --analyze sql")
def _need(*tables):
"""Exit with a regeneration hint if any table is missing from this DB."""
have = {r[0] for r in c.execute(
"SELECT table_name FROM information_schema.tables").fetchall()}
missing = [t for t in tables if t not in have]
if missing:
sys.exit(f"this db predates {', '.join(missing)} — regenerate with:\n {REGEN}")
def _has_col(table, col):
return any(r[0] == col for r in c.execute(
"SELECT column_name FROM information_schema.columns WHERE table_name=?",
[table]).fetchall())
def _fn(pc):
r = c.execute('SELECT address,name,end_address FROM functions WHERE address<=? AND end_address>? '
@@ -27,40 +56,161 @@ def _fn(pc):
return f'{r[0][1]}({H(r[0][0])})' if r else '?'
def cmd_dis(lo, hi):
data_col = 'is_data' if _has_col('instructions', 'is_data') else 'false'
rows = c.execute(f'SELECT address,mnemonic,operands,raw,{data_col} FROM instructions '
'WHERE address>=? AND address<? ORDER BY address', [lo, hi]).fetchall()
for a, m, o, raw, is_data in rows:
if is_data:
print(H(a), '.long', H(raw & 0xffffffff), ' ; jump-table data')
else:
print(H(a), m, o)
def cmd_switch(pc):
_need('jump_tables', 'jump_table_entries')
r = c.execute('SELECT bctr_pc,function,table_address,kind,entry_count FROM jump_tables '
'WHERE bctr_pc>=? ORDER BY bctr_pc LIMIT 1', [pc]).fetchall()
if not r:
sys.exit('no recovered switch at or after %s' % H(pc))
bctr, fn, tbl, kind, n = r[0]
print(f'bctr {H(bctr)} in {_fn(bctr)} table={H(tbl)} kind={kind} cases={n}')
for ci, tgt in c.execute('SELECT case_index,target_address FROM jump_table_entries '
'WHERE bctr_pc=? ORDER BY case_index', [bctr]).fetchall():
print(f' case {ci:>3} -> {H(tgt)}')
def cmd_switches(fn):
_need('jump_tables')
q = ('SELECT bctr_pc,function,kind,entry_count,table_address FROM jump_tables '
+ ('WHERE function=? ' if fn is not None else '') + 'ORDER BY bctr_pc')
for bctr, f, kind, n, tbl in c.execute(q, [fn] if fn is not None else []).fetchall():
print(H(bctr), f'{kind:<8}', f'cases={n:<4}', 'table=' + H(tbl), 'in', _fn(bctr))
def cmd_classes(sub):
_need('rtti_type_descriptors', 'rtti_locators')
q = """SELECT td.demangled_name, c.vtable_address, c.subobject_offset,
(SELECT count(*) FROM methods m WHERE m.vtable_address = c.vtable_address)
FROM rtti_locators c
JOIN rtti_type_descriptors td ON td.address = c.type_descriptor
{} ORDER BY td.demangled_name, c.subobject_offset"""
q = q.format('WHERE td.demangled_name ILIKE ?' if sub else '')
for name, vt, off, nm in c.execute(q, [f'%{sub}%'] if sub else []).fetchall():
loc = H(vt) if vt is not None else '-'
print(f'{name:<60} vtable={loc} +0x{off:x} methods={nm}')
def cmd_class(name):
_need('rtti_type_descriptors', 'rtti_locators', 'rtti_base_classes')
rows = c.execute("""SELECT c.address, c.vtable_address, c.class_hierarchy, c.subobject_offset
FROM rtti_locators c
JOIN rtti_type_descriptors td ON td.address = c.type_descriptor
WHERE td.demangled_name = ?""", [name]).fetchall()
if not rows:
sys.exit(f'no RTTI class named {name!r} (try: zq.py classes {name})')
for col, vt, chd, off in rows:
print(f'== {name} (COL {H(col)}, subobject +0x{off:x})')
bases = c.execute('SELECT base_index,name,mdisp,pdisp,vdisp FROM rtti_base_classes '
'WHERE class_hierarchy=? AND base_index>0 ORDER BY base_index',
[chd]).fetchall()
for _, bn, md, pd, vd in bases:
print(f' base {bn} mdisp={md} pdisp={pd} vdisp={vd}')
if vt is None:
print(' (no vtable located)')
continue
for slot, fa in c.execute('SELECT slot,function_address FROM methods '
'WHERE vtable_address=? ORDER BY slot', [vt]).fetchall():
print(f' vf{slot:<3} {H(fa)} {_fn(fa)}')
def cmd_str(sub):
sec = ', section' if _has_col('strings', 'section') else ", ''"
rows = c.execute(f'SELECT address, encoding, content{sec} FROM strings '
'WHERE content ILIKE ? ORDER BY address', [f'%{sub}%']).fetchall()
for a, enc, content, section in rows:
refs = c.execute("SELECT DISTINCT source_func FROM xrefs WHERE target=? AND source_func IS NOT NULL",
[a]).fetchall()
where = ', '.join(_fn(r[0]) for r in refs[:4]) or '(no xref)'
print(f'{H(a)} [{enc}{"/" + section if section else ""}] {content!r}\n <- {where}')
def cmd_xdbf(args):
"""XDBF title text across every shipped locale."""
sub = args[0] if args else ""
rows = c.execute(
"SELECT string_id, english, japanese FROM v_xdbf_text "
"WHERE (? = '' OR english ILIKE '%' || ? || '%' OR japanese ILIKE '%' || ? || '%') "
"ORDER BY string_id",
[sub, sub, sub],
).fetchall()
for sid, en, ja in rows:
print(f"{sid:6} {en or ''}")
if ja and ja != en:
print(f" ja: {ja}")
print(f"({len(rows)} strings)")
def cmd_ach(_args):
"""XDBF achievements in the title's default language."""
rows = c.execute(
"SELECT id, gamerscore, name, unlocked_desc, locked_desc "
"FROM xdbf_achievements ORDER BY id"
).fetchall()
total = 0
for aid, gs, name, unlocked, locked in rows:
total += gs or 0
print(f"{aid:3} | {gs:3}G | {name}")
print(f" unlocked: {unlocked}")
print(f" locked : {locked}")
print(f"\n{len(rows)} achievements, {total}G")
def main():
if len(sys.argv) < 2:
print(__doc__); return
cmd = sys.argv[1]
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'dis':
lo, hi = int(sys.argv[2], 16), int(sys.argv[3], 16)
for a, m, o in c.execute('SELECT address,mnemonic,operands FROM instructions '
'WHERE address>=? AND address<? ORDER BY address', [lo, hi]).fetchall():
print(H(a), m, o)
cmd_dis(int(args[0], 16), int(args[1], 16))
elif cmd == 'fn':
print(_fn(int(sys.argv[2], 16)))
print(_fn(int(args[0], 16)))
elif cmd == 'xref':
t = int(sys.argv[2], 16)
t = int(args[0], 16)
for s, k, i, sf in c.execute('SELECT source,kind,instruction,source_func FROM xrefs '
'WHERE target=? ORDER BY source', [t]).fetchall():
print(H(s), k, 'in', _fn(s), ':', i)
elif cmd == 'callers':
off = int(sys.argv[2]) # decimal byte offset, e.g. 196 for vtable[49]
off = int(args[0]) # decimal byte offset, e.g. 196 for vtable[49]
pat = f'r11, {off}(r11)'
for (a,) in c.execute("SELECT address FROM instructions WHERE mnemonic='lwz' AND operands=? "
'ORDER BY address', [pat]).fetchall():
print(H(a), 'in', _fn(a))
elif cmd == 'grep':
sub = sys.argv[2]
for a, m, o in c.execute("SELECT address,mnemonic,operands FROM instructions "
"WHERE operands LIKE ? ORDER BY address", [f'%{sub}%']).fetchall():
"WHERE operands LIKE ? ORDER BY address", [f'%{args[0]}%']).fetchall():
print(H(a), m, o, ' in', _fn(a))
elif cmd == 'find':
w = int(sys.argv[2], 16)
for (a,) in c.execute('SELECT address FROM instructions WHERE raw=? ORDER BY address', [w]).fetchall():
for (a,) in c.execute('SELECT address FROM instructions WHERE raw=? ORDER BY address',
[int(args[0], 16)]).fetchall():
print(H(a))
elif cmd == 'switch':
cmd_switch(int(args[0], 16))
elif cmd == 'switches':
cmd_switches(int(args[0], 16) if args else None)
elif cmd == 'classes':
cmd_classes(args[0] if args else None)
elif cmd == 'class':
cmd_class(args[0])
elif cmd == 'str':
cmd_str(args[0])
elif cmd == 'xdbf':
cmd_xdbf(args)
elif cmd == 'ach':
cmd_ach(args)
else:
print(__doc__)
if __name__ == '__main__':
main()