Merge pull request 'feat(xexdb): lift the DuckDB generator and disassembler out of the retired emulator' (#32) from feat/xexdb-tool into main

Reviewed-on: #32
This commit is contained in:
2026-09-15 19:15:22 +00:00
57 changed files with 24568 additions and 1 deletions

765
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,9 @@ members = [
"crates/sylpheed-viewer",
"crates/sylpheed-cli",
"crates/sylpheed-export",
"crates/sylpheed-xex",
"crates/sylpheed-ppc",
"crates/sylpheed-xexdb",
]
resolver = "2"

View File

@@ -0,0 +1,14 @@
[package]
name = "sylpheed-ppc"
version = "0.1.0"
edition = "2024"
description = "PowerPC decode and disassembly, for static analysis"
[dependencies]
tracing = "0.1"
bitflags = "2"
thiserror = "1"
[dev-dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,41 @@
//! Cross-check our decoder against xenia-canary's authoritative encoding table.
//!
//! Canary's `ppc_opcode_table_gen.cc` lists, for every opcode it knows, a
//! representative instruction word with the operand fields zeroed. Feeding each
//! word to our decoder must yield the matching opcode — anything else is a hole
//! or a mis-decode in our tables.
//!
//! ```text
//! cargo run --release -p xenia-cpu --example decode_table_check -- <table.txt>
//! ```
//! where each line is `0xWORD name`.
use std::io::BufRead;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let path = std::env::args()
.nth(1)
.ok_or("usage: decode_table_check <table>")?;
let f = std::io::BufReader::new(std::fs::File::open(path)?);
let (mut ok, mut bad, mut invalid) = (0u32, 0u32, 0u32);
for line in f.lines() {
let line = line?;
let mut it = line.split_whitespace();
let (Some(w), Some(name)) = (it.next(), it.next()) else {
continue;
};
let word = u32::from_str_radix(w.trim_start_matches("0x"), 16)?;
let d = sylpheed_ppc::decoder::decode(word, 0x8200_0000);
let got = format!("{:?}", d.opcode);
if got == name {
ok += 1;
} else if got == "Invalid" {
invalid += 1;
println!("MISSING {w} {name:<14} -> Invalid");
} else {
bad += 1;
println!("MISMATCH {w} {name:<14} -> {got}");
}
}
println!("\nmatched {ok}, mismatched {bad}, missing {invalid}");
Ok(())
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
//! PowerPC decode and disassembly, for static analysis.
//!
//! Lifted from `xenia-rs`'s `xenia-cpu` when that emulator was retired. Only
//! three modules came: the interpreter, JIT, scheduler and VMX are the parts
//! this project no longer runs, and `sylpheed-xexdb` never referenced them —
//! it used exactly `decoder::decode`, `disasm::DisasmItem` and `disasm::format`.
//!
//! See `docs/agents/CONSOLIDATION.md` Phase 3.
pub mod decoder;
pub mod disasm;
pub mod opcode;
pub use decoder::decode;
pub use disasm::{DisasmItem, DisasmText, disassemble, format as disasm_format, iter_disasm};
pub use opcode::PpcOpcode;

View File

@@ -0,0 +1,740 @@
/// All PPC opcodes supported by the Xbox 360, including VMX128 extensions.
/// Directly mirrors the C++ PPCOpcode enum from ppc_opcode.h.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u32)]
#[allow(non_camel_case_types)]
pub enum PpcOpcode {
// ALU
addcx,
addex,
addi,
addic,
addicx,
addis,
addmex,
addx,
addzex,
andcx,
andisx,
andix,
andx,
// Branch
bcctrx,
bclrx,
bcx,
bx,
// Compare
cmp,
cmpi,
cmpl,
cmpli,
// Count leading zeros
cntlzdx,
cntlzwx,
// Condition register
crand,
crandc,
creqv,
crnand,
crnor,
cror,
crorc,
crxor,
// Data cache
dcbf,
dcbi,
dcbst,
dcbt,
dcbtst,
dcbz,
dcbz128,
// Division
divdux,
divdx,
divwux,
divwx,
// Sync/barrier
eieio,
// Logical
eqvx,
extsbx,
extshx,
extswx,
// FPU
fabsx,
faddsx,
faddx,
fcfidx,
fcmpo,
fcmpu,
fctidx,
fctidzx,
fctiwx,
fctiwzx,
fdivsx,
fdivx,
fmaddsx,
fmaddx,
fmrx,
fmsubsx,
fmsubx,
fmulsx,
fmulx,
fnabsx,
fnegx,
fnmaddsx,
fnmaddx,
fnmsubsx,
fnmsubx,
fresx,
frspx,
frsqrtex,
fselx,
fsqrtsx,
fsqrtx,
fsubsx,
fsubx,
// Instruction cache
icbi,
isync,
// Load byte
lbz,
lbzu,
lbzux,
lbzx,
// Load doubleword
ld,
ldarx,
ldbrx,
ldu,
ldux,
ldx,
// Load float
lfd,
lfdu,
lfdux,
lfdx,
lfs,
lfsu,
lfsux,
lfsx,
// Load halfword
lha,
lhau,
lhaux,
lhax,
lhbrx,
lhz,
lhzu,
lhzux,
lhzx,
// Load multiple/string
lmw,
lswi,
lswx,
// Load vector
lvebx,
lvehx,
lvewx,
lvewx128,
lvlx,
lvlx128,
lvlxl,
lvlxl128,
lvrx,
lvrx128,
lvrxl,
lvrxl128,
lvsl,
lvsl128,
lvsr,
lvsr128,
lvx,
lvx128,
lvxl,
lvxl128,
// Load word
lwa,
lwarx,
lwaux,
lwax,
lwbrx,
lwz,
lwzu,
lwzux,
lwzx,
// Move CR
mcrf,
mcrfs,
mcrxr,
// Move from special
mfcr,
mffsx,
mfmsr,
mfspr,
mftb,
mfvscr,
// Move to special
mtcrf,
mtfsb0x,
mtfsb1x,
mtfsfix,
mtfsfx,
mtmsr,
mtmsrd,
mtspr,
mtvscr,
// Multiply
mulhdux,
mulhdx,
mulhwux,
mulhwx,
mulldx,
mulli,
mullwx,
// Logical
nandx,
negx,
norx,
orcx,
ori,
oris,
orx,
// Rotate
rldclx,
rldcrx,
rldiclx,
rldicrx,
rldicx,
rldimix,
rlwimix,
rlwinmx,
rlwnmx,
// System call
sc,
// Shift
sldx,
slwx,
sradix,
sradx,
srawix,
srawx,
srdx,
srwx,
// Store byte
stb,
stbu,
stbux,
stbx,
// Store doubleword
std,
stdbrx,
stdcx,
stdu,
stdux,
stdx,
// Store float
stfd,
stfdu,
stfdux,
stfdx,
stfiwx,
stfs,
stfsu,
stfsux,
stfsx,
// Store halfword
sth,
sthbrx,
sthu,
sthux,
sthx,
// Store multiple/string
stmw,
stswi,
stswx,
// Store vector
stvebx,
stvehx,
stvewx,
stvewx128,
stvlx,
stvlx128,
stvlxl,
stvlxl128,
stvrx,
stvrx128,
stvrxl,
stvrxl128,
stvx,
stvx128,
stvxl,
stvxl128,
// Store word
stw,
stwbrx,
stwcx,
stwu,
stwux,
stwx,
// Subtract
subfcx,
subfex,
subficx,
subfmex,
subfx,
subfzex,
// Sync
sync,
// Trap
td,
tdi,
tw,
twi,
// VMX integer
vaddcuw,
vaddfp,
vaddfp128,
vaddsbs,
vaddshs,
vaddsws,
vaddubm,
vaddubs,
vadduhm,
vadduhs,
vadduwm,
vadduws,
vand,
vand128,
vandc,
vandc128,
vavgsb,
vavgsh,
vavgsw,
vavgub,
vavguh,
vavguw,
vcfpsxws128,
vcfpuxws128,
vcfsx,
vcfux,
vcmpbfp,
vcmpbfp128,
vcmpeqfp,
vcmpeqfp128,
vcmpequb,
vcmpequh,
vcmpequw,
vcmpequw128,
vcmpgefp,
vcmpgefp128,
vcmpgtfp,
vcmpgtfp128,
vcmpgtsb,
vcmpgtsh,
vcmpgtsw,
vcmpgtub,
vcmpgtuh,
vcmpgtuw,
vcsxwfp128,
vctsxs,
vctuxs,
vcuxwfp128,
vexptefp,
vexptefp128,
vlogefp,
vlogefp128,
vmaddcfp128,
vmaddfp,
vmaddfp128,
vmaxfp,
vmaxfp128,
vmaxsb,
vmaxsh,
vmaxsw,
vmaxub,
vmaxuh,
vmaxuw,
vmhaddshs,
vmhraddshs,
vminfp,
vminfp128,
vminsb,
vminsh,
vminsw,
vminub,
vminuh,
vminuw,
vmladduhm,
vmrghb,
vmrghh,
vmrghw,
vmrghw128,
vmrglb,
vmrglh,
vmrglw,
vmrglw128,
vmsum3fp128,
vmsum4fp128,
vmsummbm,
vmsumshm,
vmsumshs,
vmsumubm,
vmsumuhm,
vmsumuhs,
vmulesb,
vmulesh,
vmuleub,
vmuleuh,
vmulfp128,
vmulosb,
vmulosh,
vmuloub,
vmulouh,
vnmsubfp,
vnmsubfp128,
vnor,
vnor128,
vor,
vor128,
vperm,
vperm128,
vpermwi128,
vpkd3d128,
vpkpx,
vpkshss,
vpkshss128,
vpkshus,
vpkshus128,
vpkswss,
vpkswss128,
vpkswus,
vpkswus128,
vpkuhum,
vpkuhum128,
vpkuhus,
vpkuhus128,
vpkuwum,
vpkuwum128,
vpkuwus,
vpkuwus128,
vrefp,
vrefp128,
vrfim,
vrfim128,
vrfin,
vrfin128,
vrfip,
vrfip128,
vrfiz,
vrfiz128,
vrlb,
vrlh,
vrlimi128,
vrlw,
vrlw128,
vrsqrtefp,
vrsqrtefp128,
vsel,
vsel128,
vsl,
vslb,
vsldoi,
vsldoi128,
vslh,
vslo,
vslo128,
vslw,
vslw128,
vspltb,
vsplth,
vspltisb,
vspltish,
vspltisw,
vspltisw128,
vspltw,
vspltw128,
vsr,
vsrab,
vsrah,
vsraw,
vsraw128,
vsrb,
vsrh,
vsro,
vsro128,
vsrw,
vsrw128,
vsubcuw,
vsubfp,
vsubfp128,
vsubsbs,
vsubshs,
vsubsws,
vsububm,
vsububs,
vsubuhm,
vsubuhs,
vsubuwm,
vsubuws,
vsum2sws,
vsum4sbs,
vsum4shs,
vsum4ubs,
vsumsws,
vupkd3d128,
vupkhpx,
vupkhsb,
vupkhsb128,
vupkhsh,
vupklpx,
vupklsb,
vupklsb128,
vupklsh,
vxor,
vxor128,
// XOR immediate
xori,
xoris,
xorx,
// Invalid
Invalid,
}
impl PpcOpcode {
/// Returns true if this opcode is a branch instruction.
pub fn is_branch(&self) -> bool {
matches!(self, Self::bx | Self::bcx | Self::bclrx | Self::bcctrx)
}
/// Returns true if this opcode is a system call.
pub fn is_syscall(&self) -> bool {
matches!(self, Self::sc)
}
/// Returns true if this opcode unconditionally ends a basic block:
/// any branch, system call, trap, or `Invalid` (decoder couldn't
/// recognize the instruction — execution will hit the
/// `Unimplemented` arm and we don't want to swallow the boundary
/// inside a cached block).
///
/// Notably *not* terminating: `mtmsr`/`mtmsrd`/`isync`/`mfmsr`.
/// On real hardware these have synchronization semantics (a context
/// synchronizing event for `isync`, MSR rewrite for the `mt*`s) but
/// our interpreter has no asynchronous-exception model and no
/// out-of-order execution — they execute as plain ALU/move ops and
/// don't change control flow synchronously. Block-cache replay is
/// still bit-for-bit identical to per-instruction dispatch for
/// those.
///
/// Used by the basic-block cache (`block_cache.rs`) to know when to
/// stop accumulating instructions during a forward decode walk.
pub fn terminates_block(&self) -> bool {
matches!(
self,
Self::bx
| Self::bcx
| Self::bclrx
| Self::bcctrx
| Self::sc
| Self::td
| Self::tdi
| Self::tw
| Self::twi
| Self::Invalid
)
}
/// Returns true if this is a load instruction.
pub fn is_load(&self) -> bool {
matches!(
self,
Self::lbz
| Self::lbzu
| Self::lbzux
| Self::lbzx
| Self::lhz
| Self::lhzu
| Self::lhzux
| Self::lhzx
| Self::lha
| Self::lhau
| Self::lhaux
| Self::lhax
| Self::lwz
| Self::lwzu
| Self::lwzux
| Self::lwzx
| Self::lwa
| Self::lwax
| Self::lwaux
| Self::ld
| Self::ldu
| Self::ldux
| Self::ldx
| Self::lfs
| Self::lfsu
| Self::lfsux
| Self::lfsx
| Self::lfd
| Self::lfdu
| Self::lfdux
| Self::lfdx
| Self::lhbrx
| Self::lwbrx
| Self::ldbrx
| Self::lmw
| Self::lswi
| Self::lswx
| Self::lwarx
| Self::ldarx
)
}
/// Returns true if this is a store instruction.
pub fn is_store(&self) -> bool {
matches!(
self,
Self::stb
| Self::stbu
| Self::stbux
| Self::stbx
| Self::sth
| Self::sthu
| Self::sthux
| Self::sthx
| Self::stw
| Self::stwu
| Self::stwux
| Self::stwx
| Self::std
| Self::stdu
| Self::stdux
| Self::stdx
| Self::stfs
| Self::stfsu
| Self::stfsux
| Self::stfsx
| Self::stfd
| Self::stfdu
| Self::stfdux
| Self::stfdx
| Self::sthbrx
| Self::stwbrx
| Self::stdbrx
| Self::stmw
| Self::stswi
| Self::stswx
| Self::stwcx
| Self::stdcx
| Self::stfiwx
)
}
/// Returns true if this opcode is a cross-thread synchronization
/// point at which the superblock runner MUST yield back to the
/// round-robin scheduler so the lockstep interleaving stays
/// fine-grained enough to preserve correct cross-thread ordering:
///
/// - reserved load/store (`lwarx`/`ldarx`/`stwcx.`/`stdcx.`): the
/// atomic primitive other threads race on. Running past one
/// without returning to the scheduler would let a single slot
/// win/lose a reservation across many blocks before any peer
/// observes it.
/// - memory barriers (`sync`/`eieio`/`isync`): the guest explicitly
/// demands a global ordering point here; honour it by ending the
/// superblock so the scheduler re-interleaves.
///
/// Purely a function of the opcode (no guest data), so the yield
/// decision is deterministic and the schedule reproduces byte-identically.
/// Note: `sc` (syscall) and traps already `terminates_block`, and
/// import-thunk / halt-sentinel PCs are handled by the per-block
/// prologue re-check in the superblock loop — they are not listed here.
#[inline]
pub fn is_sync_sensitive(&self) -> bool {
matches!(
self,
Self::lwarx
| Self::ldarx
| Self::stwcx
| Self::stdcx
| Self::sync
| Self::eieio
| Self::isync
)
}
pub fn name(&self) -> &'static str {
match self {
Self::Invalid => "invalid",
_ => {
// Use debug formatting to get the variant name
// This is a placeholder - in practice we'd have a lookup table
"?"
}
}
}
}
impl std::fmt::Display for PpcOpcode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(self, f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminates_block_includes_all_branches() {
assert!(PpcOpcode::bx.terminates_block());
assert!(PpcOpcode::bcx.terminates_block());
assert!(PpcOpcode::bclrx.terminates_block());
assert!(PpcOpcode::bcctrx.terminates_block());
}
#[test]
fn terminates_block_includes_sc_and_traps() {
assert!(PpcOpcode::sc.terminates_block());
assert!(PpcOpcode::td.terminates_block());
assert!(PpcOpcode::tdi.terminates_block());
assert!(PpcOpcode::tw.terminates_block());
assert!(PpcOpcode::twi.terminates_block());
}
#[test]
fn terminates_block_includes_invalid() {
// Decoder failure must end the block — otherwise an unknown
// opcode would be replayed inside a cached block without going
// through the per-instruction Unimplemented path.
assert!(PpcOpcode::Invalid.terminates_block());
}
#[test]
fn terminates_block_excludes_straight_line_ops() {
// Common ALU and load/store ops must NOT terminate a block.
assert!(!PpcOpcode::addi.terminates_block());
assert!(!PpcOpcode::addis.terminates_block());
assert!(!PpcOpcode::addx.terminates_block());
assert!(!PpcOpcode::cmpi.terminates_block());
assert!(!PpcOpcode::cmp.terminates_block());
assert!(!PpcOpcode::lwz.terminates_block());
assert!(!PpcOpcode::stw.terminates_block());
assert!(!PpcOpcode::lbzx.terminates_block());
assert!(!PpcOpcode::ori.terminates_block());
assert!(!PpcOpcode::oris.terminates_block());
assert!(!PpcOpcode::rlwinmx.terminates_block());
}
#[test]
fn terminates_block_excludes_msr_and_sync_ops() {
// Documented decision: synchronizing ops execute as ALU within
// a block since the interpreter has no async-exception model.
assert!(!PpcOpcode::mtmsr.terminates_block());
assert!(!PpcOpcode::mtmsrd.terminates_block());
assert!(!PpcOpcode::isync.terminates_block());
assert!(!PpcOpcode::sync.terminates_block());
assert!(!PpcOpcode::mfmsr.terminates_block());
}
}

View File

@@ -0,0 +1,851 @@
// 🔴 `identity_op` IS OFF FOR THIS FILE, and the shifts it objects to are the
// point. These are instruction ENCODERS: every field is written at its ISA
// position, so `(((vb >> 5) & 0x3) << 0)` sits in a column with `<< 11`,
// `<< 16`, `<< 21` and reads straight against the manual's field table. The
// trailing `| 0` on a fixed-form word says "the low field is zero here",
// which is information. Reduced to what clippy wants, the encodings stop
// being checkable by eye against the ISA, and eye-checkability is the only
// thing a golden generator has -- there is nothing else to verify it against.
#![allow(clippy::identity_op)]
//! Assert-based goldens for the PPC disassembler.
//!
//! Each test owns an inline list of `(raw, addr, label)` cases. On a
//! normal run, the test reads the corresponding fixture JSON and asserts
//! that `format(decode(raw, addr))` reproduces every field exactly. On
//! first creation (fixture file missing) or with `REGEN_GOLDENS=1` set,
//! the test (re)writes the fixture from `format()` output.
//!
//! Workflow:
//! ```sh
//! cargo test -p sylpheed-ppc --test disasm_goldens # assert
//! REGEN_GOLDENS=1 cargo test -p sylpheed-ppc --test disasm_goldens # regen
//! ```
//!
//! The hand-encoded test cases below cover the silent-bug regression
//! cases that lived in the old println-based `disasm_audit.rs` harness
//! (now deleted).
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use sylpheed_ppc::decoder::{DecodedInstr, decode};
use sylpheed_ppc::disasm::format;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct GoldenRow {
label: String,
raw: String,
addr: String,
mnemonic: String,
operands: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ext_mnemonic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ext_operands: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
branch_target: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct GoldenFile {
rows: Vec<GoldenRow>,
}
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
.join(name)
}
/// Encode a VMX128 VX128-form (or VX128_R/_2) instruction with canary's
/// 7-bit register layout: VD low at PPC 6-10, high 2 bits at PPC 28-29;
/// VA low at PPC 11-15, mid bit at PPC 26, high bit at PPC 21; VB low at
/// PPC 16-20, high 2 bits at PPC 30-31. `secondary_bits` carries any
/// secondary opcode + VC + Rc + key bits the caller needs.
fn encode_vx128(op6: u32, vd: u32, va: u32, vb: u32, secondary_bits: u32) -> u32 {
((op6 & 0x3F) << 26)
| ((vd & 0x1F) << 21)
| (((vd >> 5) & 0x3) << 2)
| ((va & 0x1F) << 16)
| (((va >> 5) & 0x1) << 5)
| (((va >> 6) & 0x1) << 10)
| ((vb & 0x1F) << 11)
| (((vb >> 5) & 0x3) << 0)
| secondary_bits
}
fn build_rows(cases: &[(u32, u32, &str)]) -> Vec<GoldenRow> {
cases
.iter()
.map(|&(raw, addr, label)| {
let d = decode(raw, addr);
let t = format(&d);
GoldenRow {
label: label.to_string(),
raw: format!("0x{raw:08X}"),
addr: format!("0x{addr:08X}"),
mnemonic: t.mnemonic,
operands: t.operands,
ext_mnemonic: t.ext_mnemonic,
ext_operands: t.ext_operands,
branch_target: t.branch_target.map(|t| format!("0x{t:08X}")),
}
})
.collect()
}
/// Compare what `format()` produces against the committed JSON snapshot.
/// Set `REGEN_GOLDENS=1` to overwrite the snapshot from current output.
/// Missing snapshot is treated as "first creation": writes and panics so
/// CI can't accidentally accept blank goldens.
fn assert_or_regen(fixture_name: &str, cases: &[(u32, u32, &str)]) {
let rows = build_rows(cases);
let path = fixture_path(fixture_name);
let regen = std::env::var("REGEN_GOLDENS").is_ok();
if regen || !path.exists() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
let serialized = serde_json::to_string_pretty(&GoldenFile { rows }).unwrap();
std::fs::write(&path, serialized + "\n").unwrap();
if !regen {
panic!(
"Generated fixture {} (was missing). Inspect, commit, then re-run.",
path.display()
);
}
return;
}
let src = std::fs::read_to_string(&path).unwrap();
let golden: GoldenFile = serde_json::from_str(&src).unwrap();
assert_eq!(
rows.len(),
golden.rows.len(),
"row count differs from {} (live={}, fixture={}). Run with REGEN_GOLDENS=1 if the test cases changed intentionally.",
path.display(),
rows.len(),
golden.rows.len()
);
for (i, (got, expected)) in rows.iter().zip(golden.rows.iter()).enumerate() {
assert_eq!(
got,
expected,
"row {} ({}) differs in {}\n live: {got:#?}\n fixture: {expected:#?}",
i,
expected.label,
path.display()
);
}
}
// ── Encoding helpers ────────────────────────────────────────────────────────
// PPC bit numbering: bit 0 is MSB, bit 31 is LSB. Most helpers below emit
// instructions in canonical hand-readable form: opcode << 26 | <fields>.
#[allow(clippy::too_many_arguments)]
fn xform_xo3(rd: u32, ra: u32, rb: u32, oe: u32, xo: u32, rc: u32) -> u32 {
(31 << 26) | (rd << 21) | (ra << 16) | (rb << 11) | (oe << 10) | (xo << 1) | rc
}
fn xform_logic(rs: u32, ra: u32, rb: u32, xo: u32, rc: u32) -> u32 {
(31 << 26) | (rs << 21) | (ra << 16) | (rb << 11) | (xo << 1) | rc
}
fn dform(op: u32, rt: u32, ra: u32, imm: i16) -> u32 {
(op << 26) | (rt << 21) | (ra << 16) | ((imm as u16) as u32)
}
fn iform_b(target_disp: i32, aa: u32, lk: u32) -> u32 {
// I-form: opcode 18 | LI<<2 | AA<<1 | LK
let li = (target_disp as u32) & 0x03FF_FFFC;
(18 << 26) | li | (aa << 1) | lk
}
fn bform_bc(bo: u32, bi: u32, target_disp: i32, aa: u32, lk: u32) -> u32 {
// B-form: opcode 16 | BO<<21 | BI<<16 | BD<<2 | AA<<1 | LK
let bd = (target_disp as u32) & 0x0000_FFFC;
(16 << 26) | (bo << 21) | (bi << 16) | bd | (aa << 1) | lk
}
fn xlform_bclr(bo: u32, bi: u32, lk: u32) -> u32 {
// XL-form: opcode 19 | BO<<21 | BI<<16 | XO=16<<1 | LK
(19 << 26) | (bo << 21) | (bi << 16) | (16 << 1) | lk
}
fn xlform_bcctr(bo: u32, bi: u32, lk: u32) -> u32 {
(19 << 26) | (bo << 21) | (bi << 16) | (528 << 1) | lk
}
fn rlwinm(rs: u32, ra: u32, sh: u32, mb: u32, me: u32, rc: u32) -> u32 {
(21 << 26) | (rs << 21) | (ra << 16) | (sh << 11) | (mb << 6) | (me << 1) | rc
}
fn rldicl(rs: u32, ra: u32, sh: u32, mb: u32, rc: u32) -> u32 {
// MD-form: sh[4:0] at PPC bits 16-20 (host bits 11-15); sh[5] at PPC bit 30 (host bit 1).
// mb[4:0] at PPC bits 21-25 (host bits 6-10); mb[5] at PPC bit 26 (host bit 5).
let sh_lo = sh & 0x1F;
let sh_hi = (sh >> 5) & 1;
let mb_lo = mb & 0x1F;
let mb_hi = (mb >> 5) & 1;
(30 << 26)
| (rs << 21)
| (ra << 16)
| (sh_lo << 11)
| (mb_lo << 6)
| (mb_hi << 5)
| (0 << 2)
| (sh_hi << 1)
| rc
}
fn mfspr(rd: u32, spr: u32) -> u32 {
let spr_swapped = ((spr & 0x1F) << 5) | ((spr >> 5) & 0x1F);
(31 << 26) | (rd << 21) | (spr_swapped << 11) | (339 << 1)
}
fn mtspr(rs: u32, spr: u32) -> u32 {
let spr_swapped = ((spr & 0x1F) << 5) | ((spr >> 5) & 0x1F);
(31 << 26) | (rs << 21) | (spr_swapped << 11) | (467 << 1)
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[test]
fn base_mnemonics() {
let cases: &[(u32, u32, &str)] = &[
// X-form ALU (Rc and OE bits)
(xform_xo3(3, 4, 5, 0, 266, 0), 0x82000000, "add r3,r4,r5"),
(xform_xo3(3, 4, 5, 0, 266, 1), 0x82000000, "add. r3,r4,r5"),
(xform_xo3(3, 4, 5, 1, 266, 0), 0x82000000, "addo r3,r4,r5"),
(xform_xo3(3, 4, 5, 1, 266, 1), 0x82000000, "addo. r3,r4,r5"),
(xform_xo3(3, 4, 0, 0, 104, 0), 0x82000000, "neg r3,r4"),
(xform_xo3(3, 4, 5, 0, 235, 0), 0x82000000, "mullw r3,r4,r5"),
(xform_xo3(3, 4, 5, 0, 491, 0), 0x82000000, "divw r3,r4,r5"),
(xform_xo3(3, 4, 5, 0, 75, 1), 0x82000000, "mulhw. r3,r4,r5"),
(xform_xo3(3, 4, 5, 0, 11, 1), 0x82000000, "mulhwu. r3,r4,r5"),
(xform_xo3(3, 4, 5, 0, 233, 0), 0x82000000, "mulld r3,r4,r5"),
// X-form logical
(xform_logic(4, 3, 5, 28, 0), 0x82000000, "and r3,r4,r5"),
(
xform_logic(4, 3, 5, 444, 0),
0x82000000,
"or r3,r4,r5 (non-mr: rs!=rb)",
),
(xform_logic(4, 3, 5, 316, 0), 0x82000000, "xor r3,r4,r5"),
(xform_logic(4, 3, 5, 124, 0), 0x82000000, "nor r3,r4,r5"),
(xform_logic(4, 3, 5, 476, 0), 0x82000000, "nand r3,r4,r5"),
(xform_logic(4, 3, 5, 284, 0), 0x82000000, "eqv r3,r4,r5"),
(xform_logic(4, 3, 5, 60, 0), 0x82000000, "andc r3,r4,r5"),
(xform_logic(4, 3, 5, 412, 0), 0x82000000, "orc r3,r4,r5"),
// X-form shift
(xform_logic(4, 3, 5, 24, 0), 0x82000000, "slw r3,r4,r5"),
(xform_logic(4, 3, 5, 536, 0), 0x82000000, "srw r3,r4,r5"),
(xform_logic(4, 3, 5, 792, 0), 0x82000000, "sraw r3,r4,r5"),
(xform_logic(4, 3, 5, 27, 0), 0x82000000, "sld r3,r4,r5"),
(xform_logic(4, 3, 5, 539, 0), 0x82000000, "srd r3,r4,r5"),
// srawi / sradi (immediate shifts)
(
(31 << 26) | (4 << 21) | (3 << 16) | (16 << 11) | (824 << 1),
0x82000000,
"srawi r3,r4,16",
),
// Atomics
(
(31 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (150 << 1) | 1,
0x82000000,
"stwcx. r3,r4,r5",
),
(
(31 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (214 << 1) | 1,
0x82000000,
"stdcx. r3,r4,r5",
),
(
(31 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (20 << 1),
0x82000000,
"lwarx r3,r4,r5",
),
(
(31 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (84 << 1),
0x82000000,
"ldarx r3,r4,r5",
),
// Compares
(dform(11, 0, 3, 16), 0x82000000, "cmpwi cr0, r3, 16"),
(dform(11, 2 << 2, 3, 16), 0x82000000, "cmpwi cr2, r3, 16"),
(dform(10, 0, 3, 16), 0x82000000, "cmplwi cr0, r3, 16"),
(
(31 << 26) | (3 << 16) | (4 << 11),
0x82000000,
"cmpw r3,r4 in cr0",
),
(
(31 << 26) | (1 << 21) | (3 << 16) | (4 << 11),
0x82000000,
"cmpd r3,r4",
),
(
(31 << 26) | (3 << 16) | (4 << 11) | (32 << 1),
0x82000000,
"cmplw r3,r4",
),
// D-form ALU/load/store
(dform(14, 3, 1, 16), 0x82000000, "addi r3, r1, 16"),
(
dform(15, 3, 1, 0x100),
0x82000000,
"addis r3, r1, 0x100 (ra!=0)",
),
(dform(7, 3, 4, 5), 0x82000000, "mulli r3, r4, 5"),
(dform(8, 3, 4, 5), 0x82000000, "subfic r3, r4, 5"),
(dform(12, 3, 4, 16), 0x82000000, "addic r3, r4, 16"),
(dform(13, 3, 4, 16), 0x82000000, "addic. r3, r4, 16"),
(
dform(24, 3, 4, 0x10),
0x82000000,
"ori r4, r3, 0x10 (non-nop)",
),
(dform(25, 3, 4, 0x10), 0x82000000, "oris r4, r3, 0x10"),
(dform(26, 3, 4, 0x10), 0x82000000, "xori r4, r3, 0x10"),
(dform(28, 3, 4, 0x10), 0x82000000, "andi. r4, r3, 0x10"),
// Loads/stores D-form
(dform(32, 5, 1, 0x20), 0x82000000, "lwz r5, 0x20(r1)"),
(dform(36, 5, 1, 0x20), 0x82000000, "stw r5, 0x20(r1)"),
(dform(34, 5, 1, 0x20), 0x82000000, "lbz r5, 0x20(r1)"),
(dform(40, 5, 1, 0x20), 0x82000000, "lhz r5, 0x20(r1)"),
(dform(48, 5, 1, 0x20), 0x82000000, "lfs f5, 0x20(r1)"),
(dform(50, 5, 1, 0x20), 0x82000000, "lfd f5, 0x20(r1)"),
(dform(54, 5, 1, 0x20), 0x82000000, "stfd f5, 0x20(r1)"),
// DS-form 64-bit loads
(
(58u32 << 26) | (5 << 21) | (1 << 16) | 0x20,
0x82000000,
"ld r5, 0x20(r1)",
),
(
(62u32 << 26) | (5 << 21) | (1 << 16) | 0x20,
0x82000000,
"std r5, 0x20(r1)",
),
// Sync / barrier (parameterless)
(
(31 << 26) | (598 << 1),
0x82000000,
"sync 0 (extends to sync)",
),
((19 << 26) | (150 << 1), 0x82000000, "isync"),
((31 << 26) | (854 << 1), 0x82000000, "eieio"),
// Cache hints
(
(31 << 26) | (1 << 16) | (2 << 11) | (54 << 1),
0x82000000,
"dcbst r1, r2",
),
(
(31 << 26) | (1 << 16) | (2 << 11) | (86 << 1),
0x82000000,
"dcbf r1, r2",
),
(
(31 << 26) | (1 << 16) | (2 << 11) | (278 << 1),
0x82000000,
"dcbt r1, r2",
),
(
(31 << 26) | (1 << 16) | (2 << 11) | (1014 << 1),
0x82000000,
"dcbz r1, r2",
),
(
(31 << 26) | (1 << 21) | (1 << 16) | (2 << 11) | (1014 << 1),
0x82000000,
"dcbz128 r1, r2",
),
// CR logical (without simplification triggers)
(
(19 << 26) | (4 << 21) | (5 << 16) | (6 << 11) | (33 << 1),
0x82000000,
"crnor 4,5,6 (no simplify)",
),
(
(19 << 26) | (4 << 21) | (5 << 16) | (6 << 11) | (257 << 1),
0x82000000,
"crand 4,5,6",
),
(
(19 << 26) | (4 << 21) | (5 << 16) | (6 << 11) | (449 << 1),
0x82000000,
"cror 4,5,6 (no simplify)",
),
// Trap (no simplification: TO=11 doesn't match the table)
(
(31 << 26) | (11 << 21) | (3 << 16) | (4 << 11) | (4 << 1),
0x82000000,
"tw 11, r3, r4 (uncommon TO)",
),
(
(2u32 << 26) | (11 << 21) | (3 << 16) | (123u32 & 0xFFFF),
0x82000000,
"tdi 11, r3, 123",
),
// mtcr (extended): mtcrf 0xFF, r5
(
(31 << 26) | (5 << 21) | (0xFF << 12) | (144 << 1),
0x82000000,
"mtcrf 0xFF, r5 → mtcr",
),
// mfcr / mfmsr / mtmsr / mtmsrd
((31 << 26) | (5 << 21) | (19 << 1), 0x82000000, "mfcr r5"),
((31 << 26) | (5 << 21) | (83 << 1), 0x82000000, "mfmsr r5"),
((31 << 26) | (5 << 21) | (146 << 1), 0x82000000, "mtmsr r5"),
((31 << 26) | (5 << 21) | (178 << 1), 0x82000000, "mtmsrd r5"),
// FPU base
(
(63u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (21 << 1),
0x82000000,
"fadd f3, f4, f5",
),
(
(63u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (20 << 1),
0x82000000,
"fsub f3, f4, f5",
),
(
(63u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (18 << 1),
0x82000000,
"fdiv f3, f4, f5",
),
(
(63u32 << 26) | (3 << 21) | (5 << 21) | (5 << 11) | (25 << 1),
0x82000000,
"fmul f3, f0, f5 (encoded)",
),
(
(63u32 << 26) | (3 << 21) | (4 << 16) | (40 << 1),
0x82000000,
"fneg f3, f4",
),
(
(63u32 << 26) | (3 << 21) | (4 << 16) | (72 << 1),
0x82000000,
"fmr f3, f4",
),
// mtfsf — XFL form (Fix 1). FM at LSB bits 17-24 (PPC bits 7-14).
// Encoding: opcode 63 | FM<<17 | frB<<11 | XO=711<<1 | Rc.
(
(63u32 << 26) | (0xFF << 17) | (5 << 11) | (711 << 1),
0x82000000,
"mtfsf 0xFF, f5 (Rc=0)",
),
(
(63u32 << 26) | (0xFF << 17) | (5 << 11) | (711 << 1) | 1,
0x82000000,
"mtfsf. 0xFF, f5 (Rc=1)",
),
];
assert_or_regen("base_mnemonics.json", cases);
}
#[test]
fn extended_mnemonics() {
let cases: &[(u32, u32, &str)] = &[
// ori r0, r0, 0 → nop
(dform(24, 0, 0, 0), 0x82000000, "nop"),
// addi r3, r0, imm → li
(dform(14, 3, 0, 16), 0x82000000, "li r3, 16"),
(dform(14, 3, 0, -1), 0x82000000, "li r3, -1"),
// addi r3, r4, neg → subi
(dform(14, 3, 4, -16), 0x82000000, "subi r3, r4, 16"),
// addis r3, r0, imm → lis
(dform(15, 3, 0, 0x1234), 0x82000000, "lis r3, 0x1234"),
// addis r3, r4, neg → subis
(dform(15, 3, 4, -1), 0x82000000, "subis r3, r4, 0xFFFF"),
// or rA, rS, rS → mr
(xform_logic(4, 3, 4, 444, 0), 0x82000000, "mr r3, r4"),
(xform_logic(4, 3, 4, 444, 1), 0x82000000, "mr. r3, r4"),
// and rA, rS, rS → mr (also)
(xform_logic(4, 3, 4, 28, 0), 0x82000000, "mr (via and)"),
// nor rA, rS, rS → not
(xform_logic(4, 3, 4, 124, 0), 0x82000000, "not r3, r4"),
// subf → sub (operand swap)
(
xform_xo3(3, 4, 5, 0, 40, 0),
0x82000000,
"subf → sub r3, r5, r4",
),
// rlwinm simplifications
(rlwinm(4, 3, 4, 0, 31 - 4, 0), 0x82000000, "slwi r3, r4, 4"),
(rlwinm(4, 3, 32 - 4, 4, 31, 0), 0x82000000, "srwi r3, r4, 4"),
(rlwinm(4, 3, 8, 0, 31, 0), 0x82000000, "rotlwi r3, r4, 8"),
(rlwinm(4, 3, 0, 4, 31, 0), 0x82000000, "clrlwi r3, r4, 4"),
(rlwinm(4, 3, 0, 0, 27, 0), 0x82000000, "clrrwi r3, r4, 4"),
(rlwinm(4, 3, 8, 0, 7, 0), 0x82000000, "extlwi r3, r4, 8, 8"),
// rlwinm with Rc
(rlwinm(4, 3, 4, 0, 31 - 4, 1), 0x82000000, "slwi. r3, r4, 4"),
// rlwinm Sylpheed regression
(
rlwinm(11, 11, 0, 31, 31, 1),
0x82000000,
"rlwinm. r11,r11,0,31,31 (no simplify)",
),
// rldicl simplifications
(rldicl(4, 3, 0, 32, 0), 0x82000000, "clrldi r3, r4, 32"),
(rldicl(4, 3, 64u32 - 8, 8, 0), 0x82000000, "srdi r3, r4, 8"),
(rldicl(4, 3, 8, 0, 0), 0x82000000, "rotldi r3, r4, 8"),
// cmpi / cmpli → cmpwi/cmpdi/cmplwi/cmpldi
(dform(11, 0, 3, 16), 0x82000000, "cmpwi cr0, r3, 16"),
(
dform(11, (1 << 21) | (2 << 23), 3, 16) | (1 << 21),
0x82000000,
"cmpdi (L=1) variant",
),
// bclr 20, 0 → blr
(xlform_bclr(20, 0, 0), 0x82000000, "blr"),
(xlform_bclr(20, 0, 1), 0x82000000, "blrl"),
// bcctr 20, 0 → bctr
(xlform_bcctr(20, 0, 0), 0x82000000, "bctr"),
(xlform_bcctr(20, 0, 1), 0x82000000, "bctrl"),
// bclr conditional
(
xlform_bclr(12, 2, 0),
0x82000000,
"beqlr (BO=12, BI=2 → cr0.eq true)",
),
(xlform_bclr(4, 2, 0), 0x82000000, "bnelr"),
// bc with full BO/BI: branch always (BO=20)
(bform_bc(20, 0, 0x40, 0, 0), 0x82000000, "bc → b 0x82000040"),
(
bform_bc(20, 0, 0x40, 0, 1),
0x82000000,
"bc l → bl 0x82000040",
),
// Conditional bc → beq/bne/etc
(
bform_bc(12, 2, 0x40, 0, 0),
0x82000000,
"bc 12,cr0.eq → beq 0x82000040",
),
(
bform_bc(4, 2, 0x40, 0, 0),
0x82000000,
"bc 4,cr0.eq → bne 0x82000040",
),
(
bform_bc(12, 0, 0x40, 0, 0),
0x82000000,
"bc 12,cr0.lt → blt 0x82000040",
),
(
bform_bc(4, 0, 0x40, 0, 0),
0x82000000,
"bc 4,cr0.lt → bge 0x82000040",
),
(
bform_bc(12, 1, 0x40, 0, 0),
0x82000000,
"bc 12,cr0.gt → bgt 0x82000040",
),
(
bform_bc(4, 1, 0x40, 0, 0),
0x82000000,
"bc 4,cr0.gt → ble 0x82000040",
),
// Conditional with non-zero CR field
(
bform_bc(12, 2 + 8, 0x40, 0, 0),
0x82000000,
"bc 12, cr2.eq → beq cr2, 0x...040",
),
// bdnz / bdz (decrement-CTR branches)
(bform_bc(16, 0, 0x40, 0, 0), 0x82000000, "bdnz 0x82000040"),
(bform_bc(18, 0, 0x40, 0, 0), 0x82000000, "bdz 0x82000040"),
// I-form branches
(iform_b(0x40, 0, 0), 0x82000000, "b +0x40 → 0x82000040"),
(iform_b(0x40, 0, 1), 0x82000000, "bl +0x40 → 0x82000040"),
(iform_b(0x40, 1, 0), 0x82000000, "ba 0x40 absolute"),
(iform_b(0x40, 1, 1), 0x82000000, "bla 0x40 absolute"),
// Trap immediate simplifications
(
(2u32 << 26) | (4 << 21) | (3 << 16) | (123u32 & 0xFFFF),
0x82000000,
"tdeqi r3, 123",
),
(
(3u32 << 26) | (16 << 21) | (3 << 16) | (123u32 & 0xFFFF),
0x82000000,
"twlti r3, 123",
),
// mfspr → mflr / mfctr / mfxer
(mfspr(3, 8), 0x82000000, "mflr r3"),
(mfspr(3, 9), 0x82000000, "mfctr r3"),
(mfspr(3, 1), 0x82000000, "mfxer r3"),
// mtspr → mtlr / mtctr / mtxer
(mtspr(3, 8), 0x82000000, "mtlr r3"),
(mtspr(3, 9), 0x82000000, "mtctr r3"),
(mtspr(3, 1), 0x82000000, "mtxer r3"),
// crnor with same source bits → crnot
(
(19 << 26) | (4 << 21) | (5 << 16) | (5 << 11) | (33 << 1),
0x82000000,
"crnot 4, 5",
),
// crxor with all same → crclr
(
(19 << 26) | (4 << 21) | (4 << 16) | (4 << 11) | (193 << 1),
0x82000000,
"crclr 4",
),
// creqv with all same → crset
(
(19 << 26) | (4 << 21) | (4 << 16) | (4 << 11) | (289 << 1),
0x82000000,
"crset 4",
),
// cror with same source bits → crmove
(
(19 << 26) | (4 << 21) | (5 << 16) | (5 << 11) | (449 << 1),
0x82000000,
"crmove 4, 5",
),
// sync L=1 → lwsync
((31 << 26) | (1 << 21) | (598 << 1), 0x82000000, "lwsync"),
// tw 31, 0, 0 → trap
((31 << 26) | (31 << 21) | (4 << 1), 0x82000000, "trap"),
// Fix 2: bclr/bcctr with BO=20 and BI≠0 still emits blr/bctr ext.
// BO=20 ignores both CTR test and CR test, so BI is don't-care.
(
xlform_bclr(20, 4, 0),
0x82000000,
"blr (BO=20, BI=4 — BI is don't-care)",
),
(xlform_bclr(20, 7, 1), 0x82000000, "blrl (BO=20, BI=7)"),
(xlform_bcctr(20, 4, 0), 0x82000000, "bctr (BO=20, BI=4)"),
// Fix 3: trap unsigned simplified mnemonics (TO=1, 2, 5, 6 — logical
// compare conditions). Register form (tw/td) and immediate (twi/tdi).
(
(31u32 << 26) | (2 << 21) | (3 << 16) | (4 << 11) | (4 << 1),
0x82000000,
"twllt r3, r4 (TO=2)",
),
(
(31u32 << 26) | (1 << 21) | (3 << 16) | (4 << 11) | (4 << 1),
0x82000000,
"twlgt r3, r4 (TO=1)",
),
(
(31u32 << 26) | (5 << 21) | (3 << 16) | (4 << 11) | (68 << 1),
0x82000000,
"tdlge r3, r4 (TO=5)",
),
(
(31u32 << 26) | (6 << 21) | (3 << 16) | (4 << 11) | (4 << 1),
0x82000000,
"twlle r3, r4 (TO=6)",
),
(
(3u32 << 26) | (2 << 21) | (3 << 16) | (16u32 & 0xFFFF),
0x82000000,
"twllti r3, 16",
),
(
(2u32 << 26) | (5 << 21) | (3 << 16) | (16u32 & 0xFFFF),
0x82000000,
"tdlgei r3, 16",
),
];
assert_or_regen("extended_mnemonics.json", cases);
}
#[test]
fn vmx128_registers() {
// Standard VMX (op=4) — 5-bit registers v0..v31. Verifies that the
// low-register path renders correctly through the new formatter.
let std_vmx = [
// vaddubm v3, v4, v5 : op=4, 3-op key=0
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | 0,
0x82000000,
"vaddubm v3, v4, v5",
),
// vaddfp v3, v4, v5 : op=4, vx=10
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | 10,
0x82000000,
"vaddfp v3, v4, v5",
),
// vand v3, v4, v5 : vx=1028
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | 1028,
0x82000000,
"vand v3, v4, v5",
),
// vor v3, v4, v5 : vx=1156
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | 1156,
0x82000000,
"vor v3, v4, v5",
),
// vxor v3, v4, v5 : vx=1220
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | 1220,
0x82000000,
"vxor v3, v4, v5",
),
// vsel v3, v4, v5, v6 : op=4, va_key=42 (4-op)
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (6 << 6) | 42,
0x82000000,
"vsel v3,v4,v5,v6",
),
// vperm v3, v4, v5, v6 : va_key=43
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (6 << 6) | 43,
0x82000000,
"vperm v3,v4,v5,v6",
),
// vmaddfp v3, v4, v5, v6 : va_key=46 (operand swap: vd, va, vc, vb)
(
(4u32 << 26) | (3 << 21) | (4 << 16) | (5 << 11) | (6 << 6) | 46,
0x82000000,
"vmaddfp v3, v4, v6, v5 (swap)",
),
// mfvscr v3 : vx=1540
((4u32 << 26) | (3 << 21) | 1540, 0x82000000, "mfvscr v3"),
// mtvscr v5 : vx=1604, vb=v5
((4u32 << 26) | (5 << 11) | 1604, 0x82000000, "mtvscr v5"),
];
// VMX128 op=5: vperm128 v3, v4, v5, vc=0. Canary FormatVX128: VD low
// at PPC 6-10, VA low at PPC 11-15, VB low at PPC 16-20, VC at PPC 23-25.
// key1 = (bit22<<5)|bit27 = 0 selects vperm128.
let vmx128_op5 = [(
encode_vx128(5, 3, 4, 5, 0),
0x82000000,
"vperm128 v3, v4, v5, 0 (canary)",
)];
// VMX128 op=6 — exercise full 0-127 vd128 range under canary's layout.
// VD128h is at PPC 28-29 (host 2-3): no overlap with secondary opcode key,
// so vd can be freely 0-127 for any op6 instruction.
let vsrw128 = |vd: u32, vb: u32| -> u32 {
// vsrw128 secondary: 0x000001D0 (decode_op6 key5 = 0b011101).
encode_vx128(6, vd, 0, vb, 0x000001D0)
};
let vpermwi128 = |vd: u32, vb: u32, perm: u32| -> u32 {
// vpermwi128: PERMl at PPC 11-15, PERMh at PPC 23-25, key1 sets bit 22 + bit 27.
let perml = perm & 0x1F;
let permh = (perm >> 5) & 0x7;
let mut raw = (6u32 << 26)
| ((vd & 0x1F) << 21)
| (((vd >> 5) & 0x3) << 2) // VD128h
| (perml << 16)
| ((vb & 0x1F) << 11)
| (((vb >> 5) & 0x3) << 0) // VB128h
| (permh << 6) // PERMh at PPC 23-25
| (1 << 9) // bit 22 (key1 high)
| (1 << 4); // bit 27 (key1 low)
raw &= !(1 << 10); // PPC 21 = 0 for vpermwi128
raw
};
let vrlimi128 = |vd: u32, vb: u32, imm: u32, z: u32| -> u32 {
// vrlimi128: IMM at PPC 11-15, z at PPC 24-25, key2 = 0b1110001 over
// bits 21-23 + 26-27 → bits 21,22,23 = 1, bit 26 = 0, bit 27 = 1.
(6u32 << 26)
| ((vd & 0x1F) << 21)
| (((vd >> 5) & 0x3) << 2) // VD128h
| ((imm & 0x1F) << 16)
| ((vb & 0x1F) << 11)
| (((vb >> 5) & 0x3) << 0) // VB128h
| ((z & 0x3) << 6) // z at PPC 24-25 = host 6-7
| (1 << 8) // bit 23 (key2)
| (1 << 9) // bit 22 (key2)
| (1 << 10) // bit 21 (key2)
| (1 << 4) // bit 27 (key2)
};
let vmx128_high = [
(
vsrw128(0, 12),
0x82000000,
"vsrw128 v0, v0, v12 (canary, vd_hi=00)",
),
(
vsrw128(32, 12),
0x82000000,
"vsrw128 v32, v0, v12 (canary, VD128h=01)",
),
(
vpermwi128(64, 12, 0xE4),
0x82000000,
"vpermwi128 v64, v12, 0xE4 (canary, VD128h=10)",
),
(
vrlimi128(96, 12, 4, 3),
0x82000000,
"vrlimi128 v96, v12, 4, 3 (canary, VD128h=11)",
),
(
vrlimi128(127, 95, 4, 3),
0x82000000,
"vrlimi128 v127, v95, 4, 3 (canary)",
),
];
// Fix 4: VMX128 multiply-add 4-operand layouts. Per canary, the addend
// is the VD register re-used; operand order differs between the three
// mnemonics. Encodings hand-built to satisfy decode_op5's key2 secondary
// opcode (vmaddfp128=0b001101, vmaddcfp128=0b010001, vnmsubfp128=0b010101)
// with bit 22=0 (forced by key2's high nibble) so vd128 high bit 1 = 0.
// vd128 low = 3 (bits 6-10); va128 = 3 | (bit29<<5) = 35; vb128 = 5.
// Distinct VD vs VA verifies the layout isn't trivially aliasing VD.
//
// layout (canary):
// vmaddfp128 VD, VA, VB, VD → "v3, v35, v5, v3"
// vmaddcfp128 VD, VA, VD, VB → "v3, v35, v3, v5"
// vnmsubfp128 VD, VA, VD, VB → "v3, v35, v3, v5"
let vmx128_4op = [
// Canary FormatVX128 layout: vd=3 (PPC 6-10), va=35 (low 3 at PPC 11-15 + VA128h=1 at PPC 26),
// vb=5 (PPC 16-20), key2 at PPC 22-25 + bit 27.
(0x146328F0u32, 0x82000000, "vmaddfp128 v3, v35, v5, v3"),
(0x14632930u32, 0x82000000, "vmaddcfp128 v3, v35, v3, v5"),
(0x14632970u32, 0x82000000, "vnmsubfp128 v3, v35, v3, v5"),
];
let mut all = Vec::new();
all.extend_from_slice(&std_vmx);
all.extend_from_slice(&vmx128_op5);
all.extend_from_slice(&vmx128_high);
all.extend_from_slice(&vmx128_4op);
assert_or_regen("vmx128_registers.json", &all);
}
#[test]
fn sradi_shift_32_decodes_to_32() {
// sradi rA, rS, 32: sh=32 → sh[4:0]=0, sh[5]=1
// After PPCBUG-040 fix, sh64() must return 32, not 1.
let instr: DecodedInstr = decode(rldicl(3, 4, 32, 63, 0), 0);
// rldicl with mb=63 is not sradi, but tests sh64() extraction.
assert_eq!(
instr.sh64(),
32,
"sh64 must return 32 for sh=32 (sh5=1, sh_lo=0)"
);
}
#[test]
fn sh64_shift_1_decodes_correctly() {
// sh=1: sh[4:0]=1, sh[5]=0 → sh64() must return 1
let instr: DecodedInstr = decode(rldicl(3, 4, 1, 0, 0), 0);
assert_eq!(instr.sh64(), 1, "sh64 must return 1 for sh=1");
}
#[test]
fn sh64_shift_63_decodes_correctly() {
// sh=63: sh[4:0]=31=0x1F, sh[5]=1 → sh64() must return 63
let instr: DecodedInstr = decode(rldicl(3, 4, 63, 0, 0), 0);
assert_eq!(instr.sh64(), 63, "sh64 must return 63 for sh=63");
}

View File

@@ -0,0 +1,571 @@
{
"rows": [
{
"label": "add r3,r4,r5",
"raw": "0x7C642A14",
"addr": "0x82000000",
"mnemonic": "add",
"operands": "r3, r4, r5"
},
{
"label": "add. r3,r4,r5",
"raw": "0x7C642A15",
"addr": "0x82000000",
"mnemonic": "add.",
"operands": "r3, r4, r5"
},
{
"label": "addo r3,r4,r5",
"raw": "0x7C642E14",
"addr": "0x82000000",
"mnemonic": "addo",
"operands": "r3, r4, r5"
},
{
"label": "addo. r3,r4,r5",
"raw": "0x7C642E15",
"addr": "0x82000000",
"mnemonic": "addo.",
"operands": "r3, r4, r5"
},
{
"label": "neg r3,r4",
"raw": "0x7C6400D0",
"addr": "0x82000000",
"mnemonic": "neg",
"operands": "r3, r4"
},
{
"label": "mullw r3,r4,r5",
"raw": "0x7C6429D6",
"addr": "0x82000000",
"mnemonic": "mullw",
"operands": "r3, r4, r5"
},
{
"label": "divw r3,r4,r5",
"raw": "0x7C642BD6",
"addr": "0x82000000",
"mnemonic": "divw",
"operands": "r3, r4, r5"
},
{
"label": "mulhw. r3,r4,r5",
"raw": "0x7C642897",
"addr": "0x82000000",
"mnemonic": "mulhw.",
"operands": "r3, r4, r5"
},
{
"label": "mulhwu. r3,r4,r5",
"raw": "0x7C642817",
"addr": "0x82000000",
"mnemonic": "mulhwu.",
"operands": "r3, r4, r5"
},
{
"label": "mulld r3,r4,r5",
"raw": "0x7C6429D2",
"addr": "0x82000000",
"mnemonic": "mulld",
"operands": "r3, r4, r5"
},
{
"label": "and r3,r4,r5",
"raw": "0x7C832838",
"addr": "0x82000000",
"mnemonic": "and",
"operands": "r3, r4, r5"
},
{
"label": "or r3,r4,r5 (non-mr: rs!=rb)",
"raw": "0x7C832B78",
"addr": "0x82000000",
"mnemonic": "or",
"operands": "r3, r4, r5"
},
{
"label": "xor r3,r4,r5",
"raw": "0x7C832A78",
"addr": "0x82000000",
"mnemonic": "xor",
"operands": "r3, r4, r5"
},
{
"label": "nor r3,r4,r5",
"raw": "0x7C8328F8",
"addr": "0x82000000",
"mnemonic": "nor",
"operands": "r3, r4, r5"
},
{
"label": "nand r3,r4,r5",
"raw": "0x7C832BB8",
"addr": "0x82000000",
"mnemonic": "nand",
"operands": "r3, r4, r5"
},
{
"label": "eqv r3,r4,r5",
"raw": "0x7C832A38",
"addr": "0x82000000",
"mnemonic": "eqv",
"operands": "r3, r4, r5"
},
{
"label": "andc r3,r4,r5",
"raw": "0x7C832878",
"addr": "0x82000000",
"mnemonic": "andc",
"operands": "r3, r4, r5"
},
{
"label": "orc r3,r4,r5",
"raw": "0x7C832B38",
"addr": "0x82000000",
"mnemonic": "orc",
"operands": "r3, r4, r5"
},
{
"label": "slw r3,r4,r5",
"raw": "0x7C832830",
"addr": "0x82000000",
"mnemonic": "slw",
"operands": "r3, r4, r5"
},
{
"label": "srw r3,r4,r5",
"raw": "0x7C832C30",
"addr": "0x82000000",
"mnemonic": "srw",
"operands": "r3, r4, r5"
},
{
"label": "sraw r3,r4,r5",
"raw": "0x7C832E30",
"addr": "0x82000000",
"mnemonic": "sraw",
"operands": "r3, r4, r5"
},
{
"label": "sld r3,r4,r5",
"raw": "0x7C832836",
"addr": "0x82000000",
"mnemonic": "sld",
"operands": "r3, r4, r5"
},
{
"label": "srd r3,r4,r5",
"raw": "0x7C832C36",
"addr": "0x82000000",
"mnemonic": "srd",
"operands": "r3, r4, r5"
},
{
"label": "srawi r3,r4,16",
"raw": "0x7C838670",
"addr": "0x82000000",
"mnemonic": "srawi",
"operands": "r3, r4, 16"
},
{
"label": "stwcx. r3,r4,r5",
"raw": "0x7C64292D",
"addr": "0x82000000",
"mnemonic": "stwcx.",
"operands": "r3, r4, r5"
},
{
"label": "stdcx. r3,r4,r5",
"raw": "0x7C6429AD",
"addr": "0x82000000",
"mnemonic": "stdcx.",
"operands": "r3, r4, r5"
},
{
"label": "lwarx r3,r4,r5",
"raw": "0x7C642828",
"addr": "0x82000000",
"mnemonic": "lwarx",
"operands": "r3, r4, r5"
},
{
"label": "ldarx r3,r4,r5",
"raw": "0x7C6428A8",
"addr": "0x82000000",
"mnemonic": "ldarx",
"operands": "r3, r4, r5"
},
{
"label": "cmpwi cr0, r3, 16",
"raw": "0x2C030010",
"addr": "0x82000000",
"mnemonic": "cmpi",
"operands": "0, r3, 16",
"ext_mnemonic": "cmpwi",
"ext_operands": "r3, 16"
},
{
"label": "cmpwi cr2, r3, 16",
"raw": "0x2D030010",
"addr": "0x82000000",
"mnemonic": "cmpi",
"operands": "cr2, 0, r3, 16",
"ext_mnemonic": "cmpwi",
"ext_operands": "cr2, r3, 16"
},
{
"label": "cmplwi cr0, r3, 16",
"raw": "0x28030010",
"addr": "0x82000000",
"mnemonic": "cmpli",
"operands": "0, r3, 0x10",
"ext_mnemonic": "cmplwi",
"ext_operands": "r3, 0x10"
},
{
"label": "cmpw r3,r4 in cr0",
"raw": "0x7C032000",
"addr": "0x82000000",
"mnemonic": "cmp",
"operands": "0, r3, r4",
"ext_mnemonic": "cmpw",
"ext_operands": "r3, r4"
},
{
"label": "cmpd r3,r4",
"raw": "0x7C232000",
"addr": "0x82000000",
"mnemonic": "cmp",
"operands": "1, r3, r4",
"ext_mnemonic": "cmpd",
"ext_operands": "r3, r4"
},
{
"label": "cmplw r3,r4",
"raw": "0x7C032040",
"addr": "0x82000000",
"mnemonic": "cmpl",
"operands": "0, r3, r4",
"ext_mnemonic": "cmplw",
"ext_operands": "r3, r4"
},
{
"label": "addi r3, r1, 16",
"raw": "0x38610010",
"addr": "0x82000000",
"mnemonic": "addi",
"operands": "r3, r1, 16"
},
{
"label": "addis r3, r1, 0x100 (ra!=0)",
"raw": "0x3C610100",
"addr": "0x82000000",
"mnemonic": "addis",
"operands": "r3, r1, 0x100"
},
{
"label": "mulli r3, r4, 5",
"raw": "0x1C640005",
"addr": "0x82000000",
"mnemonic": "mulli",
"operands": "r3, r4, 5"
},
{
"label": "subfic r3, r4, 5",
"raw": "0x20640005",
"addr": "0x82000000",
"mnemonic": "subfic",
"operands": "r3, r4, 5"
},
{
"label": "addic r3, r4, 16",
"raw": "0x30640010",
"addr": "0x82000000",
"mnemonic": "addic",
"operands": "r3, r4, 16"
},
{
"label": "addic. r3, r4, 16",
"raw": "0x34640010",
"addr": "0x82000000",
"mnemonic": "addic.",
"operands": "r3, r4, 16"
},
{
"label": "ori r4, r3, 0x10 (non-nop)",
"raw": "0x60640010",
"addr": "0x82000000",
"mnemonic": "ori",
"operands": "r4, r3, 0x10"
},
{
"label": "oris r4, r3, 0x10",
"raw": "0x64640010",
"addr": "0x82000000",
"mnemonic": "oris",
"operands": "r4, r3, 0x10"
},
{
"label": "xori r4, r3, 0x10",
"raw": "0x68640010",
"addr": "0x82000000",
"mnemonic": "xori",
"operands": "r4, r3, 0x10"
},
{
"label": "andi. r4, r3, 0x10",
"raw": "0x70640010",
"addr": "0x82000000",
"mnemonic": "andi.",
"operands": "r4, r3, 0x10"
},
{
"label": "lwz r5, 0x20(r1)",
"raw": "0x80A10020",
"addr": "0x82000000",
"mnemonic": "lwz",
"operands": "r5, 32(r1)"
},
{
"label": "stw r5, 0x20(r1)",
"raw": "0x90A10020",
"addr": "0x82000000",
"mnemonic": "stw",
"operands": "r5, 32(r1)"
},
{
"label": "lbz r5, 0x20(r1)",
"raw": "0x88A10020",
"addr": "0x82000000",
"mnemonic": "lbz",
"operands": "r5, 32(r1)"
},
{
"label": "lhz r5, 0x20(r1)",
"raw": "0xA0A10020",
"addr": "0x82000000",
"mnemonic": "lhz",
"operands": "r5, 32(r1)"
},
{
"label": "lfs f5, 0x20(r1)",
"raw": "0xC0A10020",
"addr": "0x82000000",
"mnemonic": "lfs",
"operands": "f5, 32(r1)"
},
{
"label": "lfd f5, 0x20(r1)",
"raw": "0xC8A10020",
"addr": "0x82000000",
"mnemonic": "lfd",
"operands": "f5, 32(r1)"
},
{
"label": "stfd f5, 0x20(r1)",
"raw": "0xD8A10020",
"addr": "0x82000000",
"mnemonic": "stfd",
"operands": "f5, 32(r1)"
},
{
"label": "ld r5, 0x20(r1)",
"raw": "0xE8A10020",
"addr": "0x82000000",
"mnemonic": "ld",
"operands": "r5, 32(r1)"
},
{
"label": "std r5, 0x20(r1)",
"raw": "0xF8A10020",
"addr": "0x82000000",
"mnemonic": "std",
"operands": "r5, 32(r1)"
},
{
"label": "sync 0 (extends to sync)",
"raw": "0x7C0004AC",
"addr": "0x82000000",
"mnemonic": "sync",
"operands": ""
},
{
"label": "isync",
"raw": "0x4C00012C",
"addr": "0x82000000",
"mnemonic": "isync",
"operands": ""
},
{
"label": "eieio",
"raw": "0x7C0006AC",
"addr": "0x82000000",
"mnemonic": "eieio",
"operands": ""
},
{
"label": "dcbst r1, r2",
"raw": "0x7C01106C",
"addr": "0x82000000",
"mnemonic": "dcbst",
"operands": "r1, r2"
},
{
"label": "dcbf r1, r2",
"raw": "0x7C0110AC",
"addr": "0x82000000",
"mnemonic": "dcbf",
"operands": "r1, r2"
},
{
"label": "dcbt r1, r2",
"raw": "0x7C01122C",
"addr": "0x82000000",
"mnemonic": "dcbt",
"operands": "r1, r2"
},
{
"label": "dcbz r1, r2",
"raw": "0x7C0117EC",
"addr": "0x82000000",
"mnemonic": "dcbz",
"operands": "r1, r2"
},
{
"label": "dcbz128 r1, r2",
"raw": "0x7C2117EC",
"addr": "0x82000000",
"mnemonic": "dcbz128",
"operands": "r1, r2"
},
{
"label": "crnor 4,5,6 (no simplify)",
"raw": "0x4C853042",
"addr": "0x82000000",
"mnemonic": "crnor",
"operands": "4*cr1+lt, 4*cr1+gt, 4*cr1+eq"
},
{
"label": "crand 4,5,6",
"raw": "0x4C853202",
"addr": "0x82000000",
"mnemonic": "crand",
"operands": "4*cr1+lt, 4*cr1+gt, 4*cr1+eq"
},
{
"label": "cror 4,5,6 (no simplify)",
"raw": "0x4C853382",
"addr": "0x82000000",
"mnemonic": "cror",
"operands": "4*cr1+lt, 4*cr1+gt, 4*cr1+eq"
},
{
"label": "tw 11, r3, r4 (uncommon TO)",
"raw": "0x7D632008",
"addr": "0x82000000",
"mnemonic": "tw",
"operands": "11, r3, r4"
},
{
"label": "tdi 11, r3, 123",
"raw": "0x0963007B",
"addr": "0x82000000",
"mnemonic": "tdi",
"operands": "11, r3, 123"
},
{
"label": "mtcrf 0xFF, r5 → mtcr",
"raw": "0x7CAFF120",
"addr": "0x82000000",
"mnemonic": "mtcrf",
"operands": "0xFF, r5",
"ext_mnemonic": "mtcr",
"ext_operands": "r5"
},
{
"label": "mfcr r5",
"raw": "0x7CA00026",
"addr": "0x82000000",
"mnemonic": "mfcr",
"operands": "r5"
},
{
"label": "mfmsr r5",
"raw": "0x7CA000A6",
"addr": "0x82000000",
"mnemonic": "mfmsr",
"operands": "r5"
},
{
"label": "mtmsr r5",
"raw": "0x7CA00124",
"addr": "0x82000000",
"mnemonic": "mtmsr",
"operands": "r5"
},
{
"label": "mtmsrd r5",
"raw": "0x7CA00164",
"addr": "0x82000000",
"mnemonic": "mtmsrd",
"operands": "r5"
},
{
"label": "fadd f3, f4, f5",
"raw": "0xFC64282A",
"addr": "0x82000000",
"mnemonic": "fadd",
"operands": "f3, f4, f5"
},
{
"label": "fsub f3, f4, f5",
"raw": "0xFC642828",
"addr": "0x82000000",
"mnemonic": "fsub",
"operands": "f3, f4, f5"
},
{
"label": "fdiv f3, f4, f5",
"raw": "0xFC642824",
"addr": "0x82000000",
"mnemonic": "fdiv",
"operands": "f3, f4, f5"
},
{
"label": "fmul f3, f0, f5 (encoded)",
"raw": "0xFCE02832",
"addr": "0x82000000",
"mnemonic": "fmul",
"operands": "f7, f0, f0"
},
{
"label": "fneg f3, f4",
"raw": "0xFC640050",
"addr": "0x82000000",
"mnemonic": "fneg",
"operands": "f3, f0"
},
{
"label": "fmr f3, f4",
"raw": "0xFC640090",
"addr": "0x82000000",
"mnemonic": "fmr",
"operands": "f3, f0"
},
{
"label": "mtfsf 0xFF, f5 (Rc=0)",
"raw": "0xFDFE2D8E",
"addr": "0x82000000",
"mnemonic": "mtfsf",
"operands": "0xFF, f5"
},
{
"label": "mtfsf. 0xFF, f5 (Rc=1)",
"raw": "0xFDFE2D8F",
"addr": "0x82000000",
"mnemonic": "mtfsf.",
"operands": "0xFF, f5"
}
]
}

View File

@@ -0,0 +1,623 @@
{
"rows": [
{
"label": "nop",
"raw": "0x60000000",
"addr": "0x82000000",
"mnemonic": "ori",
"operands": "r0, r0, 0x0",
"ext_mnemonic": "nop",
"ext_operands": ""
},
{
"label": "li r3, 16",
"raw": "0x38600010",
"addr": "0x82000000",
"mnemonic": "addi",
"operands": "r3, r0, 16",
"ext_mnemonic": "li",
"ext_operands": "r3, 16"
},
{
"label": "li r3, -1",
"raw": "0x3860FFFF",
"addr": "0x82000000",
"mnemonic": "addi",
"operands": "r3, r0, -1",
"ext_mnemonic": "li",
"ext_operands": "r3, -1"
},
{
"label": "subi r3, r4, 16",
"raw": "0x3864FFF0",
"addr": "0x82000000",
"mnemonic": "addi",
"operands": "r3, r4, -16",
"ext_mnemonic": "subi",
"ext_operands": "r3, r4, 16"
},
{
"label": "lis r3, 0x1234",
"raw": "0x3C601234",
"addr": "0x82000000",
"mnemonic": "addis",
"operands": "r3, r0, 0x1234",
"ext_mnemonic": "lis",
"ext_operands": "r3, 0x1234"
},
{
"label": "subis r3, r4, 0xFFFF",
"raw": "0x3C64FFFF",
"addr": "0x82000000",
"mnemonic": "addis",
"operands": "r3, r4, 0xFFFF",
"ext_mnemonic": "subis",
"ext_operands": "r3, r4, 0x1"
},
{
"label": "mr r3, r4",
"raw": "0x7C832378",
"addr": "0x82000000",
"mnemonic": "or",
"operands": "r3, r4, r4",
"ext_mnemonic": "mr",
"ext_operands": "r3, r4"
},
{
"label": "mr. r3, r4",
"raw": "0x7C832379",
"addr": "0x82000000",
"mnemonic": "or.",
"operands": "r3, r4, r4",
"ext_mnemonic": "mr.",
"ext_operands": "r3, r4"
},
{
"label": "mr (via and)",
"raw": "0x7C832038",
"addr": "0x82000000",
"mnemonic": "and",
"operands": "r3, r4, r4",
"ext_mnemonic": "mr",
"ext_operands": "r3, r4"
},
{
"label": "not r3, r4",
"raw": "0x7C8320F8",
"addr": "0x82000000",
"mnemonic": "nor",
"operands": "r3, r4, r4",
"ext_mnemonic": "not",
"ext_operands": "r3, r4"
},
{
"label": "subf → sub r3, r5, r4",
"raw": "0x7C642850",
"addr": "0x82000000",
"mnemonic": "subf",
"operands": "r3, r4, r5",
"ext_mnemonic": "sub",
"ext_operands": "r3, r5, r4"
},
{
"label": "slwi r3, r4, 4",
"raw": "0x54832036",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 4, 0, 27",
"ext_mnemonic": "slwi",
"ext_operands": "r3, r4, 4"
},
{
"label": "srwi r3, r4, 4",
"raw": "0x5483E13E",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 28, 4, 31",
"ext_mnemonic": "srwi",
"ext_operands": "r3, r4, 4"
},
{
"label": "rotlwi r3, r4, 8",
"raw": "0x5483403E",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 8, 0, 31",
"ext_mnemonic": "rotlwi",
"ext_operands": "r3, r4, 8"
},
{
"label": "clrlwi r3, r4, 4",
"raw": "0x5483013E",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 0, 4, 31",
"ext_mnemonic": "clrlwi",
"ext_operands": "r3, r4, 4"
},
{
"label": "clrrwi r3, r4, 4",
"raw": "0x54830036",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 0, 0, 27",
"ext_mnemonic": "clrrwi",
"ext_operands": "r3, r4, 4"
},
{
"label": "extlwi r3, r4, 8, 8",
"raw": "0x5483400E",
"addr": "0x82000000",
"mnemonic": "rlwinm",
"operands": "r3, r4, 8, 0, 7",
"ext_mnemonic": "extlwi",
"ext_operands": "r3, r4, 8, 8"
},
{
"label": "slwi. r3, r4, 4",
"raw": "0x54832037",
"addr": "0x82000000",
"mnemonic": "rlwinm.",
"operands": "r3, r4, 4, 0, 27",
"ext_mnemonic": "slwi.",
"ext_operands": "r3, r4, 4"
},
{
"label": "rlwinm. r11,r11,0,31,31 (no simplify)",
"raw": "0x556B07FF",
"addr": "0x82000000",
"mnemonic": "rlwinm.",
"operands": "r11, r11, 0, 31, 31",
"ext_mnemonic": "clrlwi.",
"ext_operands": "r11, r11, 31"
},
{
"label": "clrldi r3, r4, 32",
"raw": "0x78830020",
"addr": "0x82000000",
"mnemonic": "rldicl",
"operands": "r3, r4, 0, 32",
"ext_mnemonic": "clrldi",
"ext_operands": "r3, r4, 32"
},
{
"label": "srdi r3, r4, 8",
"raw": "0x7883C202",
"addr": "0x82000000",
"mnemonic": "rldicl",
"operands": "r3, r4, 56, 8",
"ext_mnemonic": "srdi",
"ext_operands": "r3, r4, 8"
},
{
"label": "rotldi r3, r4, 8",
"raw": "0x78834000",
"addr": "0x82000000",
"mnemonic": "rldicl",
"operands": "r3, r4, 8, 0",
"ext_mnemonic": "rotldi",
"ext_operands": "r3, r4, 8"
},
{
"label": "cmpwi cr0, r3, 16",
"raw": "0x2C030010",
"addr": "0x82000000",
"mnemonic": "cmpi",
"operands": "0, r3, 16",
"ext_mnemonic": "cmpwi",
"ext_operands": "r3, 16"
},
{
"label": "cmpdi (L=1) variant",
"raw": "0x2C230010",
"addr": "0x82000000",
"mnemonic": "cmpi",
"operands": "1, r3, 16",
"ext_mnemonic": "cmpdi",
"ext_operands": "r3, 16"
},
{
"label": "blr",
"raw": "0x4E800020",
"addr": "0x82000000",
"mnemonic": "bclr",
"operands": "20, lt",
"ext_mnemonic": "blr",
"ext_operands": ""
},
{
"label": "blrl",
"raw": "0x4E800021",
"addr": "0x82000000",
"mnemonic": "bclrl",
"operands": "20, lt",
"ext_mnemonic": "blrl",
"ext_operands": ""
},
{
"label": "bctr",
"raw": "0x4E800420",
"addr": "0x82000000",
"mnemonic": "bcctr",
"operands": "20, lt",
"ext_mnemonic": "bctr",
"ext_operands": ""
},
{
"label": "bctrl",
"raw": "0x4E800421",
"addr": "0x82000000",
"mnemonic": "bcctrl",
"operands": "20, lt",
"ext_mnemonic": "bctrl",
"ext_operands": ""
},
{
"label": "beqlr (BO=12, BI=2 → cr0.eq true)",
"raw": "0x4D820020",
"addr": "0x82000000",
"mnemonic": "bclr",
"operands": "12, eq",
"ext_mnemonic": "beqlr",
"ext_operands": ""
},
{
"label": "bnelr",
"raw": "0x4C820020",
"addr": "0x82000000",
"mnemonic": "bclr",
"operands": "4, eq",
"ext_mnemonic": "bnelr",
"ext_operands": ""
},
{
"label": "bc → b 0x82000040",
"raw": "0x42800040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "20, lt, 0x82000040",
"ext_mnemonic": "b",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc l → bl 0x82000040",
"raw": "0x42800041",
"addr": "0x82000000",
"mnemonic": "bcl",
"operands": "20, lt, 0x82000040",
"ext_mnemonic": "bl",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 12,cr0.eq → beq 0x82000040",
"raw": "0x41820040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "12, eq, 0x82000040",
"ext_mnemonic": "beq",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 4,cr0.eq → bne 0x82000040",
"raw": "0x40820040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "4, eq, 0x82000040",
"ext_mnemonic": "bne",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 12,cr0.lt → blt 0x82000040",
"raw": "0x41800040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "12, lt, 0x82000040",
"ext_mnemonic": "blt",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 4,cr0.lt → bge 0x82000040",
"raw": "0x40800040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "4, lt, 0x82000040",
"ext_mnemonic": "bge",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 12,cr0.gt → bgt 0x82000040",
"raw": "0x41810040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "12, gt, 0x82000040",
"ext_mnemonic": "bgt",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 4,cr0.gt → ble 0x82000040",
"raw": "0x40810040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "4, gt, 0x82000040",
"ext_mnemonic": "ble",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bc 12, cr2.eq → beq cr2, 0x...040",
"raw": "0x418A0040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "12, 4*cr2+eq, 0x82000040",
"ext_mnemonic": "beq",
"ext_operands": "cr2, 0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bdnz 0x82000040",
"raw": "0x42000040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "16, lt, 0x82000040",
"ext_mnemonic": "bdnz",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bdz 0x82000040",
"raw": "0x42400040",
"addr": "0x82000000",
"mnemonic": "bc",
"operands": "18, lt, 0x82000040",
"ext_mnemonic": "bdz",
"ext_operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "b +0x40 → 0x82000040",
"raw": "0x48000040",
"addr": "0x82000000",
"mnemonic": "b",
"operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "bl +0x40 → 0x82000040",
"raw": "0x48000041",
"addr": "0x82000000",
"mnemonic": "bl",
"operands": "0x82000040",
"branch_target": "0x82000040"
},
{
"label": "ba 0x40 absolute",
"raw": "0x48000042",
"addr": "0x82000000",
"mnemonic": "ba",
"operands": "0x00000040",
"branch_target": "0x00000040"
},
{
"label": "bla 0x40 absolute",
"raw": "0x48000043",
"addr": "0x82000000",
"mnemonic": "bla",
"operands": "0x00000040",
"branch_target": "0x00000040"
},
{
"label": "tdeqi r3, 123",
"raw": "0x0883007B",
"addr": "0x82000000",
"mnemonic": "tdi",
"operands": "4, r3, 123",
"ext_mnemonic": "tdeqi",
"ext_operands": "r3, 123"
},
{
"label": "twlti r3, 123",
"raw": "0x0E03007B",
"addr": "0x82000000",
"mnemonic": "twi",
"operands": "16, r3, 123",
"ext_mnemonic": "twlti",
"ext_operands": "r3, 123"
},
{
"label": "mflr r3",
"raw": "0x7C6802A6",
"addr": "0x82000000",
"mnemonic": "mfspr",
"operands": "r3, LR",
"ext_mnemonic": "mflr",
"ext_operands": "r3"
},
{
"label": "mfctr r3",
"raw": "0x7C6902A6",
"addr": "0x82000000",
"mnemonic": "mfspr",
"operands": "r3, CTR",
"ext_mnemonic": "mfctr",
"ext_operands": "r3"
},
{
"label": "mfxer r3",
"raw": "0x7C6102A6",
"addr": "0x82000000",
"mnemonic": "mfspr",
"operands": "r3, XER",
"ext_mnemonic": "mfxer",
"ext_operands": "r3"
},
{
"label": "mtlr r3",
"raw": "0x7C6803A6",
"addr": "0x82000000",
"mnemonic": "mtspr",
"operands": "LR, r3",
"ext_mnemonic": "mtlr",
"ext_operands": "r3"
},
{
"label": "mtctr r3",
"raw": "0x7C6903A6",
"addr": "0x82000000",
"mnemonic": "mtspr",
"operands": "CTR, r3",
"ext_mnemonic": "mtctr",
"ext_operands": "r3"
},
{
"label": "mtxer r3",
"raw": "0x7C6103A6",
"addr": "0x82000000",
"mnemonic": "mtspr",
"operands": "XER, r3",
"ext_mnemonic": "mtxer",
"ext_operands": "r3"
},
{
"label": "crnot 4, 5",
"raw": "0x4C852842",
"addr": "0x82000000",
"mnemonic": "crnor",
"operands": "4*cr1+lt, 4*cr1+gt, 4*cr1+gt",
"ext_mnemonic": "crnot",
"ext_operands": "4*cr1+lt, 4*cr1+gt"
},
{
"label": "crclr 4",
"raw": "0x4C842182",
"addr": "0x82000000",
"mnemonic": "crxor",
"operands": "4*cr1+lt, 4*cr1+lt, 4*cr1+lt",
"ext_mnemonic": "crclr",
"ext_operands": "4*cr1+lt"
},
{
"label": "crset 4",
"raw": "0x4C842242",
"addr": "0x82000000",
"mnemonic": "creqv",
"operands": "4*cr1+lt, 4*cr1+lt, 4*cr1+lt",
"ext_mnemonic": "crset",
"ext_operands": "4*cr1+lt"
},
{
"label": "crmove 4, 5",
"raw": "0x4C852B82",
"addr": "0x82000000",
"mnemonic": "cror",
"operands": "4*cr1+lt, 4*cr1+gt, 4*cr1+gt",
"ext_mnemonic": "crmove",
"ext_operands": "4*cr1+lt, 4*cr1+gt"
},
{
"label": "lwsync",
"raw": "0x7C2004AC",
"addr": "0x82000000",
"mnemonic": "sync",
"operands": "",
"ext_mnemonic": "lwsync",
"ext_operands": ""
},
{
"label": "trap",
"raw": "0x7FE00008",
"addr": "0x82000000",
"mnemonic": "tw",
"operands": "31, r0, r0",
"ext_mnemonic": "trap",
"ext_operands": ""
},
{
"label": "blr (BO=20, BI=4 — BI is don't-care)",
"raw": "0x4E840020",
"addr": "0x82000000",
"mnemonic": "bclr",
"operands": "20, 4*cr1+lt",
"ext_mnemonic": "blr",
"ext_operands": ""
},
{
"label": "blrl (BO=20, BI=7)",
"raw": "0x4E870021",
"addr": "0x82000000",
"mnemonic": "bclrl",
"operands": "20, 4*cr1+so",
"ext_mnemonic": "blrl",
"ext_operands": ""
},
{
"label": "bctr (BO=20, BI=4)",
"raw": "0x4E840420",
"addr": "0x82000000",
"mnemonic": "bcctr",
"operands": "20, 4*cr1+lt",
"ext_mnemonic": "bctr",
"ext_operands": ""
},
{
"label": "twllt r3, r4 (TO=2)",
"raw": "0x7C432008",
"addr": "0x82000000",
"mnemonic": "tw",
"operands": "2, r3, r4",
"ext_mnemonic": "twllt",
"ext_operands": "r3, r4"
},
{
"label": "twlgt r3, r4 (TO=1)",
"raw": "0x7C232008",
"addr": "0x82000000",
"mnemonic": "tw",
"operands": "1, r3, r4",
"ext_mnemonic": "twlgt",
"ext_operands": "r3, r4"
},
{
"label": "tdlge r3, r4 (TO=5)",
"raw": "0x7CA32088",
"addr": "0x82000000",
"mnemonic": "td",
"operands": "5, r3, r4",
"ext_mnemonic": "tdlge",
"ext_operands": "r3, r4"
},
{
"label": "twlle r3, r4 (TO=6)",
"raw": "0x7CC32008",
"addr": "0x82000000",
"mnemonic": "tw",
"operands": "6, r3, r4",
"ext_mnemonic": "twlle",
"ext_operands": "r3, r4"
},
{
"label": "twllti r3, 16",
"raw": "0x0C430010",
"addr": "0x82000000",
"mnemonic": "twi",
"operands": "2, r3, 16",
"ext_mnemonic": "twllti",
"ext_operands": "r3, 16"
},
{
"label": "tdlgei r3, 16",
"raw": "0x08A30010",
"addr": "0x82000000",
"mnemonic": "tdi",
"operands": "5, r3, 16",
"ext_mnemonic": "tdlgei",
"ext_operands": "r3, 16"
}
]
}

View File

@@ -0,0 +1,137 @@
{
"rows": [
{
"label": "vaddubm v3, v4, v5",
"raw": "0x10642800",
"addr": "0x82000000",
"mnemonic": "vaddubm",
"operands": "v3, v4, v5"
},
{
"label": "vaddfp v3, v4, v5",
"raw": "0x1064280A",
"addr": "0x82000000",
"mnemonic": "vaddfp",
"operands": "v3, v4, v5"
},
{
"label": "vand v3, v4, v5",
"raw": "0x10642C04",
"addr": "0x82000000",
"mnemonic": "vand",
"operands": "v3, v4, v5"
},
{
"label": "vor v3, v4, v5",
"raw": "0x10642C84",
"addr": "0x82000000",
"mnemonic": "vor",
"operands": "v3, v4, v5"
},
{
"label": "vxor v3, v4, v5",
"raw": "0x10642CC4",
"addr": "0x82000000",
"mnemonic": "vxor",
"operands": "v3, v4, v5"
},
{
"label": "vsel v3,v4,v5,v6",
"raw": "0x106429AA",
"addr": "0x82000000",
"mnemonic": "vsel",
"operands": "v3, v4, v5, v6"
},
{
"label": "vperm v3,v4,v5,v6",
"raw": "0x106429AB",
"addr": "0x82000000",
"mnemonic": "vperm",
"operands": "v3, v4, v5, v6"
},
{
"label": "vmaddfp v3, v4, v6, v5 (swap)",
"raw": "0x106429AE",
"addr": "0x82000000",
"mnemonic": "vmaddfp",
"operands": "v3, v4, v6, v5"
},
{
"label": "mfvscr v3",
"raw": "0x10600604",
"addr": "0x82000000",
"mnemonic": "mfvscr",
"operands": "v3"
},
{
"label": "mtvscr v5",
"raw": "0x10002E44",
"addr": "0x82000000",
"mnemonic": "mtvscr",
"operands": "v5"
},
{
"label": "vperm128 v3, v4, v5, 0 (canary)",
"raw": "0x14642800",
"addr": "0x82000000",
"mnemonic": "vperm128",
"operands": "v3, v4, v5, 0"
},
{
"label": "vsrw128 v0, v0, v12 (canary, vd_hi=00)",
"raw": "0x180061D0",
"addr": "0x82000000",
"mnemonic": "vsrw128",
"operands": "v0, v0, v12"
},
{
"label": "vsrw128 v32, v0, v12 (canary, VD128h=01)",
"raw": "0x180061D4",
"addr": "0x82000000",
"mnemonic": "vsrw128",
"operands": "v32, v0, v12"
},
{
"label": "vpermwi128 v64, v12, 0xE4 (canary, VD128h=10)",
"raw": "0x180463D8",
"addr": "0x82000000",
"mnemonic": "vpermwi128",
"operands": "v64, v12, 0xE4"
},
{
"label": "vrlimi128 v96, v12, 4, 3 (canary, VD128h=11)",
"raw": "0x180467DC",
"addr": "0x82000000",
"mnemonic": "vrlimi128",
"operands": "v96, v12, 4, 3"
},
{
"label": "vrlimi128 v127, v95, 4, 3 (canary)",
"raw": "0x1BE4FFDE",
"addr": "0x82000000",
"mnemonic": "vrlimi128",
"operands": "v127, v95, 4, 3"
},
{
"label": "vmaddfp128 v3, v35, v5, v3",
"raw": "0x146328F0",
"addr": "0x82000000",
"mnemonic": "vmaddfp128",
"operands": "v3, v35, v5, v3"
},
{
"label": "vmaddcfp128 v3, v35, v3, v5",
"raw": "0x14632930",
"addr": "0x82000000",
"mnemonic": "vmaddcfp128",
"operands": "v3, v35, v3, v5"
},
{
"label": "vnmsubfp128 v3, v35, v3, v5",
"raw": "0x14632970",
"addr": "0x82000000",
"mnemonic": "vnmsubfp128",
"operands": "v3, v35, v3, v5"
}
]
}

View File

@@ -0,0 +1,15 @@
[package]
name = "sylpheed-xex"
version = "0.1.0"
edition = "2024"
description = "XEX2 container: decrypt, LZX, PE image, resources — and the disc image it lives in"
[dependencies]
tracing = "0.1"
byteorder = "1"
thiserror = "1"
anyhow = "1"
aes = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
metrics = "0.23"

View File

@@ -0,0 +1,139 @@
use serde::Serialize;
/// XEX2 file header. Parsed from the beginning of an Xbox 360 executable.
#[derive(Debug, Serialize)]
pub struct Xex2Header {
pub magic: u32,
pub module_flags: u32,
pub header_size: u32,
pub security_offset: u32,
pub header_count: u32,
pub optional_headers: Vec<Xex2OptionalHeader>,
pub security_info: Option<Xex2SecurityInfo>,
/// Parsed file format info (if present).
pub file_format_info: Option<FileFormatInfo>,
/// Parsed import libraries (addresses only until resolve_imports is called).
pub import_libraries: Vec<ImportLibrary>,
/// Execution info (title ID, media ID, etc.).
pub execution_info: Option<ExecutionInfo>,
/// Original PE name from the XEX header.
pub original_pe_name: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Xex2OptionalHeader {
pub key: u32,
pub value: u32,
}
#[derive(Debug, Serialize)]
pub struct Xex2SecurityInfo {
pub image_size: u32,
pub load_address: u32,
pub export_table_address: u32,
pub image_flags: u32,
/// Encrypted session key (decrypted with retail/devkit key to get actual session key).
pub aes_key: [u8; 16],
pub page_descriptors: Vec<Xex2PageDescriptor>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct Xex2PageDescriptor {
pub size_and_info: u32,
}
impl Xex2PageDescriptor {
pub fn page_count(&self) -> u32 {
self.size_and_info >> 4
}
pub fn info(&self) -> u32 {
self.size_and_info & 0xF
}
}
/// File format info (compression and encryption types).
#[derive(Debug, Clone, Serialize)]
pub struct FileFormatInfo {
pub info_size: u32,
pub encryption_type: u16,
pub compression_type: u16,
/// For basic compression: list of (data_size, zero_size) block pairs.
pub basic_blocks: Vec<BasicCompressionBlock>,
/// For normal (LZX) compression: window size.
pub normal_window_size: u32,
/// For normal (LZX) compression: first block size (from header).
pub normal_first_block_size: u32,
/// For normal (LZX) compression: first block hash (from header).
pub normal_first_block_hash: [u8; 20],
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct BasicCompressionBlock {
pub data_size: u32,
pub zero_size: u32,
}
/// An imported library with its resolved imports.
#[derive(Debug, Clone, Serialize)]
pub struct ImportLibrary {
pub name: String,
pub id: u32,
pub version_min: u32,
pub version_cur: u32,
/// Import entries. Before `resolve_imports`, these contain addresses but no ordinals.
/// After `resolve_imports`, ordinals and record types are filled in from the PE image.
pub imports: Vec<ImportEntry>,
}
/// A single import entry within an import library.
#[derive(Debug, Clone, Serialize)]
pub struct ImportEntry {
pub ordinal: u16,
pub record_type: u8, // 0 = variable, 1 = thunk
pub address: u32,
}
/// Execution info parsed from the XEX header.
#[derive(Debug, Clone, Serialize)]
pub struct ExecutionInfo {
pub media_id: u32,
pub title_id: u32,
pub disc_number: u8,
pub disc_count: u8,
}
/// XEX2 magic: "XEX2"
pub const XEX2_MAGIC: u32 = 0x58455832;
/// Compression types
pub const COMPRESSION_NONE: u16 = 0;
pub const COMPRESSION_BASIC: u16 = 1;
pub const COMPRESSION_NORMAL: u16 = 2;
/// Encryption types
pub const ENCRYPTION_NONE: u16 = 0;
pub const ENCRYPTION_NORMAL: u16 = 1;
/// Optional header keys
pub mod header_keys {
pub const ENTRY_POINT: u32 = 0x00010100;
pub const IMAGE_BASE_ADDRESS: u32 = 0x00010201;
pub const IMPORT_LIBRARIES: u32 = 0x000103FF;
// 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 = 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

@@ -0,0 +1,16 @@
//! XEX2 container: header, decrypt, LZX, PE image, resources — and the disc
//! image it may live inside (`vfs`).
//!
//! From `xenia-rs`'s `xenia-xex` + `xenia-vfs` when that emulator was retired.
//! `docs/agents/CONSOLIDATION.md` Phase 3.
pub mod header;
pub mod loader;
pub mod lzx;
pub mod pdata;
pub mod pe;
pub mod resources;
pub mod tls;
pub mod vfs;
pub use header::Xex2Header;

View File

@@ -0,0 +1,671 @@
use crate::header::*;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit};
use byteorder::{BigEndian, ReadBytesExt};
use std::io::{self, Cursor, Read, Seek, SeekFrom};
/// Parse a XEX2 header from raw file data.
pub fn parse_xex2_header(data: &[u8]) -> io::Result<Xex2Header> {
let mut cursor = Cursor::new(data);
let magic = cursor.read_u32::<BigEndian>()?;
if magic != XEX2_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Invalid XEX2 magic: {:#010x} (expected {:#010x})",
magic, XEX2_MAGIC
),
));
}
let module_flags = cursor.read_u32::<BigEndian>()?;
let header_size = cursor.read_u32::<BigEndian>()?;
let _reserved = cursor.read_u32::<BigEndian>()?;
let security_offset = cursor.read_u32::<BigEndian>()?;
let header_count = cursor.read_u32::<BigEndian>()?;
let mut optional_headers = Vec::new();
for _ in 0..header_count {
let key = cursor.read_u32::<BigEndian>()?;
let value = cursor.read_u32::<BigEndian>()?;
optional_headers.push(Xex2OptionalHeader { key, value });
}
// Parse security info
let security_info = if (security_offset as usize) < data.len() {
cursor.seek(SeekFrom::Start(security_offset as u64))?;
Some(parse_security_info(&mut cursor)?)
} else {
None
};
// Parse file format info
let file_format_info = parse_file_format_info(data, &optional_headers);
// Parse import libraries (addresses only; call resolve_imports after decompression)
let import_libraries = parse_import_libraries(data, &optional_headers);
// Parse execution info
let execution_info = parse_execution_info(data, &optional_headers);
// Parse original PE name
let original_pe_name = parse_original_pe_name(data, &optional_headers);
Ok(Xex2Header {
magic,
module_flags,
header_size,
security_offset,
header_count,
optional_headers,
security_info,
file_format_info,
import_libraries,
execution_info,
original_pe_name,
})
}
fn parse_security_info(cursor: &mut Cursor<&[u8]>) -> io::Result<Xex2SecurityInfo> {
// xex2_security_info layout (from xex2_info.h):
// 0x000: header_size (u32)
// 0x004: image_size (u32)
// 0x008: rsa_signature (0x100 bytes)
// 0x108: unk_108 (u32)
// 0x10C: image_flags (u32)
// 0x110: load_address (u32)
// 0x114: section_digest (0x14 bytes)
// 0x128: import_table_count (u32)
// 0x12C: import_table_digest (0x14 bytes)
// 0x140: xgd2_media_id (0x10 bytes)
// 0x150: aes_key (0x10 bytes)
// 0x160: export_table (u32)
// 0x164: header_digest (0x14 bytes)
// 0x178: region (u32)
// 0x17C: allowed_media_types (u32)
// 0x180: page_descriptor_count (u32)
// 0x184: page_descriptors[] (each is 0x18 bytes: u32 value + 0x14 digest)
let _header_size = cursor.read_u32::<BigEndian>()?; // 0x000
let image_size = cursor.read_u32::<BigEndian>()?; // 0x004
// Skip RSA signature (0x100 bytes)
let mut rsa_sig = [0u8; 0x100];
cursor.read_exact(&mut rsa_sig)?; // 0x008
let _unk_108 = cursor.read_u32::<BigEndian>()?; // 0x108
let image_flags = cursor.read_u32::<BigEndian>()?; // 0x10C
let load_address = cursor.read_u32::<BigEndian>()?; // 0x110
// Skip section_digest (0x14 bytes)
let mut digest = [0u8; 0x14];
cursor.read_exact(&mut digest)?; // 0x114
let _import_table_count = cursor.read_u32::<BigEndian>()?; // 0x128
// Skip import_table_digest (0x14 bytes)
cursor.read_exact(&mut digest)?; // 0x12C
// Skip xgd2_media_id (0x10 bytes)
let mut media_id = [0u8; 0x10];
cursor.read_exact(&mut media_id)?; // 0x140
// Read aes_key (0x10 bytes)
let mut aes_key = [0u8; 0x10];
cursor.read_exact(&mut aes_key)?; // 0x150
let export_table_address = cursor.read_u32::<BigEndian>()?; // 0x160
// Skip header_digest (0x14 bytes)
cursor.read_exact(&mut digest)?; // 0x164
let _region = cursor.read_u32::<BigEndian>()?; // 0x178
let _allowed_media = cursor.read_u32::<BigEndian>()?; // 0x17C
let page_descriptor_count = cursor.read_u32::<BigEndian>()?; // 0x180
let mut page_descriptors = Vec::new();
for _ in 0..page_descriptor_count {
let size_and_info = cursor.read_u32::<BigEndian>()?;
// Skip data_digest (0x14 bytes per descriptor)
cursor.read_exact(&mut digest)?;
page_descriptors.push(Xex2PageDescriptor { size_and_info });
}
Ok(Xex2SecurityInfo {
image_size,
load_address,
export_table_address,
image_flags,
aes_key,
page_descriptors,
})
}
/// Parse file format info from the optional header data.
fn parse_file_format_info(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option<FileFormatInfo> {
// The key format: low 8 bits indicate the data size category
// 0xFF = data offset is a pointer to variable-size data in the header area
let header = headers
.iter()
.find(|h| h.key == header_keys::FILE_FORMAT_INFO)?;
let offset = header.value as usize;
if offset + 8 > data.len() {
return None;
}
let mut cursor = Cursor::new(data);
cursor.seek(SeekFrom::Start(offset as u64)).ok()?;
let info_size = cursor.read_u32::<BigEndian>().ok()?;
let encryption_type = cursor.read_u16::<BigEndian>().ok()?;
let compression_type = cursor.read_u16::<BigEndian>().ok()?;
let mut basic_blocks = Vec::new();
let mut normal_window_size = 0u32;
let mut normal_first_block_size = 0u32;
let mut normal_first_block_hash = [0u8; 20];
match compression_type {
COMPRESSION_BASIC => {
// Basic compression blocks: (data_size, zero_size) pairs
// Number of blocks = (info_size - 8) / 8
let block_count = if info_size > 8 {
(info_size - 8) / 8
} else {
0
};
for _ in 0..block_count {
let data_size = cursor.read_u32::<BigEndian>().ok()?;
let zero_size = cursor.read_u32::<BigEndian>().ok()?;
basic_blocks.push(BasicCompressionBlock {
data_size,
zero_size,
});
}
}
COMPRESSION_NORMAL => {
normal_window_size = cursor.read_u32::<BigEndian>().ok()?;
// Read first_block: block_size (4) + block_hash (20)
normal_first_block_size = cursor.read_u32::<BigEndian>().ok()?;
cursor.read_exact(&mut normal_first_block_hash).ok()?;
}
_ => {}
}
Some(FileFormatInfo {
info_size,
encryption_type,
compression_type,
basic_blocks,
normal_window_size,
normal_first_block_size,
normal_first_block_hash,
})
}
/// Parse import libraries from the optional header data.
/// At this stage, only record addresses are read; ordinals and record types
/// are resolved later by `resolve_imports` once the PE image is decompressed.
fn parse_import_libraries(data: &[u8], headers: &[Xex2OptionalHeader]) -> Vec<ImportLibrary> {
let header = match headers
.iter()
.find(|h| h.key == header_keys::IMPORT_LIBRARIES)
{
Some(h) => h,
None => return Vec::new(),
};
let offset = header.value as usize;
if offset + 12 > data.len() {
return Vec::new();
}
fn be_u32(data: &[u8], off: usize) -> u32 {
u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
fn be_u16(data: &[u8], off: usize) -> u16 {
u16::from_be_bytes([data[off], data[off + 1]])
}
let total_size = be_u32(data, offset) as usize;
let string_table_size = be_u32(data, offset + 4) as usize;
let string_count = be_u32(data, offset + 8) as usize;
// Parse string table (null-terminated, 4-byte aligned)
let string_data_start = offset + 12;
let mut strings = Vec::new();
let mut spos = 0usize;
for _ in 0..string_count {
let start = string_data_start + spos;
let mut end = start;
while end < data.len() && data[end] != 0 {
end += 1;
}
let name = std::str::from_utf8(&data[start..end])
.unwrap_or("???")
.to_string();
spos += name.len() + 1;
// 4-byte alignment
if !spos.is_multiple_of(4) {
spos += 4 - (spos % 4);
}
strings.push(name);
}
// Parse libraries
let mut libs = Vec::new();
let mut lib_off = offset + 12 + string_table_size;
while lib_off + 0x28 <= data.len() && lib_off < offset + total_size {
let lib_size = be_u32(data, lib_off) as usize;
if lib_size == 0 {
break;
}
let id = be_u32(data, lib_off + 0x18);
let version_cur = be_u32(data, lib_off + 0x1C);
let version_min = be_u32(data, lib_off + 0x20);
let name_index = (be_u16(data, lib_off + 0x24) & 0xFF) as usize;
let count = be_u16(data, lib_off + 0x26) as usize;
let lib_name = strings
.get(name_index)
.cloned()
.unwrap_or_else(|| format!("lib_{name_index}"));
let mut imports = Vec::new();
for i in 0..count {
let record_addr = be_u32(data, lib_off + 0x28 + i * 4);
imports.push(ImportEntry {
ordinal: 0,
record_type: 0xFF,
address: record_addr,
});
}
libs.push(ImportLibrary {
name: lib_name,
id,
version_min,
version_cur,
imports,
});
lib_off += lib_size;
}
libs
}
/// Resolve import ordinals and record types from the decompressed PE image.
/// Must be called after `load_image` provides the PE data.
pub fn resolve_imports(header: &mut Xex2Header, pe_image: &[u8]) {
let image_base = get_image_base(header).unwrap_or(0);
for lib in &mut header.import_libraries {
for imp in &mut lib.imports {
let pe_off = imp.address.wrapping_sub(image_base) as usize;
if pe_off + 4 <= pe_image.len() {
// PE image values are big-endian (Xbox 360 native)
let val = u32::from_be_bytes([
pe_image[pe_off],
pe_image[pe_off + 1],
pe_image[pe_off + 2],
pe_image[pe_off + 3],
]);
imp.record_type = ((val >> 24) & 0xFF) as u8;
imp.ordinal = (val & 0xFFFF) as u16;
}
}
}
}
/// Parse execution info from optional header data.
fn parse_execution_info(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option<ExecutionInfo> {
// EXECUTION_INFO key is 0x00040006 — the low byte 0x06 means the value
// is an inline struct of 6 u32 words (24 bytes total).
// Layout: media_id(4), version(4), base_version(4), title_id(4),
// platform(1), exec_type(1), disc_number(1), disc_count(1)
let header = headers
.iter()
.find(|h| h.key == header_keys::EXECUTION_INFO)?;
let off = header.value as usize;
if off + 20 > data.len() {
return None;
}
let media_id = u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
let title_id = u32::from_be_bytes([
data[off + 12],
data[off + 13],
data[off + 14],
data[off + 15],
]);
let disc_number = data[off + 18];
let disc_count = data[off + 19];
Some(ExecutionInfo {
media_id,
title_id,
disc_number,
disc_count,
})
}
/// Parse original PE name from optional header data.
fn parse_original_pe_name(data: &[u8], headers: &[Xex2OptionalHeader]) -> Option<String> {
let header = headers
.iter()
.find(|h| h.key == header_keys::ORIGINAL_PE_NAME)?;
let off = header.value as usize;
if off + 4 > data.len() {
return None;
}
let size =
u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) as usize;
if off + size > data.len() || size <= 4 {
return None;
}
let name_bytes = &data[off + 4..off + size];
Some(
String::from_utf8_lossy(name_bytes)
.trim_end_matches('\0')
.to_string(),
)
}
/// Get an optional header value by key.
pub fn get_opt_header(header: &Xex2Header, key: u32) -> Option<u32> {
header
.optional_headers
.iter()
.find(|h| h.key == key)
.map(|h| h.value)
}
/// Get the entry point address from the XEX2 header.
pub fn get_entry_point(header: &Xex2Header) -> Option<u32> {
get_opt_header(header, header_keys::ENTRY_POINT)
}
/// Get the image base address.
pub fn get_image_base(header: &Xex2Header) -> Option<u32> {
get_opt_header(header, header_keys::IMAGE_BASE_ADDRESS)
}
/// Get the default stack size.
pub fn get_stack_size(header: &Xex2Header) -> u32 {
get_opt_header(header, header_keys::DEFAULT_STACK_SIZE).unwrap_or(0x10_0000) // Default 1MB
}
/// XEX `XEX_HEADER_SYSTEM_FLAGS` (key `0x00030000`) — the privilege bitmap
/// queried by `XexCheckExecutablePrivilege`. Low byte 0x00 means the inline
/// `value` field is the u32 itself (canary `xex_module.cc:103-108`). Returns
/// 0 when the header is absent (matches canary's `GetOptHeader` zero-init).
pub fn get_system_flags(header: &Xex2Header) -> u32 {
get_opt_header(header, header_keys::SYSTEM_FLAGS).unwrap_or(0)
}
/// Load the XEX image data into a flat buffer (decompressing if needed).
/// Returns the decompressed image bytes ready to map into guest memory.
#[tracing::instrument(skip_all, fields(bytes = data.len()))]
pub fn load_image(data: &[u8], header: &Xex2Header) -> io::Result<Vec<u8>> {
let started = std::time::Instant::now();
let source = &data[header.header_size as usize..];
let bytes_in = source.len();
let output = match &header.file_format_info {
Some(info) if info.compression_type == COMPRESSION_BASIC => {
tracing::debug!(compression = "basic", "decompressing");
load_basic_compressed(source, info)?
}
Some(info) if info.compression_type == COMPRESSION_NORMAL => {
tracing::debug!(compression = "normal/LZX", "decompressing");
load_normal_compressed(source, info, header)?
}
_ => source.to_vec(),
};
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("xex.load_image_ms").record(elapsed_ms);
metrics::counter!("xex.bytes_in").increment(bytes_in as u64);
metrics::counter!("xex.bytes_out").increment(output.len() as u64);
let ratio = if bytes_in == 0 {
0.0
} else {
output.len() as f64 / bytes_in as f64
};
tracing::info!(
bytes_in,
bytes_out = output.len(),
ratio,
elapsed_ms,
"image loaded"
);
Ok(output)
}
/// Load basic compressed image data.
fn load_basic_compressed(source: &[u8], info: &FileFormatInfo) -> io::Result<Vec<u8>> {
// Calculate total uncompressed size
let total_size: u64 = info
.basic_blocks
.iter()
.map(|b| b.data_size as u64 + b.zero_size as u64)
.sum();
let mut output = vec![0u8; total_size as usize];
let mut src_offset = 0usize;
let mut dst_offset = 0usize;
for block in &info.basic_blocks {
let data_size = block.data_size as usize;
let zero_size = block.zero_size as usize;
if src_offset + data_size > source.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"Basic compression block data extends past end of file (src_offset={:#x}, data_size={:#x}, source_len={:#x})",
src_offset,
data_size,
source.len()
),
));
}
// Copy data block
if dst_offset + data_size <= output.len() {
output[dst_offset..dst_offset + data_size]
.copy_from_slice(&source[src_offset..src_offset + data_size]);
}
src_offset += data_size;
dst_offset += data_size;
// Zero-filled gap (already zeroed from vec initialization)
dst_offset += zero_size;
}
Ok(output)
}
/// Xbox 360 retail AES key for XEX2 session key decryption.
const XEX2_RETAIL_KEY: [u8; 16] = [
0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF, 0x91,
];
/// Xbox 360 devkit AES key (all zeros).
#[allow(dead_code)]
const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16];
/// AES-128-CBC decryption with zero IV (matching Xbox 360 XEX decryption).
#[tracing::instrument(skip_all, fields(bytes = input.len()))]
fn aes_decrypt_cbc(key: &[u8; 16], input: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(key.into());
let mut output = vec![0u8; input.len()];
let mut iv = [0u8; 16];
for (i, chunk) in input.chunks(16).enumerate() {
if chunk.len() < 16 {
// Partial block at end - copy as-is
output[i * 16..i * 16 + chunk.len()].copy_from_slice(chunk);
break;
}
let mut block = aes::Block::clone_from_slice(chunk);
cipher.decrypt_block(&mut block);
// XOR with IV (previous ciphertext block)
for j in 0..16 {
block[j] ^= iv[j];
}
iv.copy_from_slice(chunk);
output[i * 16..(i + 1) * 16].copy_from_slice(&block);
}
output
}
/// Derive the session key by decrypting the XEX's aes_key field with the retail key.
/// Falls back to devkit key if retail produces invalid results.
fn derive_session_key(header: &Xex2Header) -> [u8; 16] {
let sec = match &header.security_info {
Some(s) => s,
None => return [0u8; 16],
};
let decrypted = aes_decrypt_cbc(&XEX2_RETAIL_KEY, &sec.aes_key);
let mut session_key = [0u8; 16];
session_key.copy_from_slice(&decrypted[..16]);
session_key
}
/// De-block compressed data: strip block headers and extract chunk payloads.
///
/// The first block's size comes from the file format header (first_block_size).
/// Each block in the data starts with a block_info struct for the NEXT block:
///
/// - block_size: u32 BE (size of the next block)
/// - block_hash: [u8; 20] (SHA1 of the next block)
///
/// Followed by chunks: { chunk_size: u16 BE, data: [u8; chunk_size] }, terminated by chunk_size=0
fn deblock(input: &[u8], first_block_size: u32) -> io::Result<Vec<u8>> {
let mut output = Vec::new();
let mut pos = 0usize;
let mut cur_block_size = first_block_size as usize;
while cur_block_size > 0 && pos < input.len() {
let next_block_pos = pos + cur_block_size;
// Read next block's info from start of current block data
let next_block_size = if pos + 4 <= input.len() {
u32::from_be_bytes([input[pos], input[pos + 1], input[pos + 2], input[pos + 3]])
as usize
} else {
0
};
// Skip block_info header (4 bytes size + 20 bytes hash)
let mut p = pos + 4 + 20;
// Read chunks within this block
loop {
if p + 2 > input.len() {
break;
}
let chunk_size = ((input[p] as usize) << 8) | (input[p + 1] as usize);
p += 2;
if chunk_size == 0 {
break;
}
if p + chunk_size > input.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"De-block chunk extends past input (pos={:#x}, chunk_size={:#x}, input_len={:#x})",
p,
chunk_size,
input.len()
),
));
}
output.extend_from_slice(&input[p..p + chunk_size]);
p += chunk_size;
}
if next_block_pos <= pos {
break; // Prevent infinite loop
}
pos = next_block_pos;
cur_block_size = next_block_size;
}
Ok(output)
}
/// Load normal (LZX) compressed image data.
/// Pipeline: decrypt → de-block → LZX decompress (pure Rust)
#[tracing::instrument(skip_all, fields(bytes_in = source.len()))]
fn load_normal_compressed(
source: &[u8],
info: &FileFormatInfo,
header: &Xex2Header,
) -> io::Result<Vec<u8>> {
let uncompressed_size = header
.security_info
.as_ref()
.map(|s| s.image_size as usize)
.unwrap_or(0);
if uncompressed_size == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Cannot decompress: image_size is 0",
));
}
// Step 1: Decrypt if needed
let decrypted;
let input = if info.encryption_type == ENCRYPTION_NORMAL {
let session_key = derive_session_key(header);
decrypted = aes_decrypt_cbc(&session_key, source);
&decrypted
} else {
source
};
// Step 2: De-block (strip block headers, extract chunk payloads)
let deblocked = deblock(input, info.normal_first_block_size)?;
if deblocked.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"De-blocking produced no data",
));
}
// Step 3: LZX decompress using pure Rust decoder
let window_bits = match info.normal_window_size {
0 => 15, // default
s => (s as f64).log2() as u32,
};
let mut decoder = crate::lzx::LzxDecoder::new(window_bits);
let output = decoder
.decompress(&deblocked, uncompressed_size)
.map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("LZX decompression failed: {e}"),
)
})?;
tracing::info!(
"LZX decompressed: {} -> {} bytes",
deblocked.len(),
uncompressed_size
);
Ok(output)
}

View File

@@ -0,0 +1,948 @@
// 🔴 INDEX ARITHMETIC IS THE ALGORITHM HERE, so the range-loop lints are off for
// this file and this file only. LZX decoding is defined over symbol indices,
// Huffman table slots and window positions; rewriting those loops as iterators
// would hide the correspondence with the format, and a decompressor that is
// merely idiomatic is worth nothing if it is not bit-exact.
#![allow(clippy::needless_range_loop, clippy::explicit_counter_loop)]
//! LZX decompressor for Xbox 360 XEX2 "normal compression".
//! Ported from libmspack lzxd.c (C) 2003-2013 Stuart Caie, LGPL 2.1.
use std::fmt;
// ── LZX constants ───────────────────────────────────────────────────────────
const LZX_MIN_MATCH: usize = 2;
const LZX_NUM_CHARS: usize = 256;
const LZX_BLOCKTYPE_VERBATIM: u8 = 1;
const LZX_BLOCKTYPE_ALIGNED: u8 = 2;
const LZX_BLOCKTYPE_UNCOMPRESSED: u8 = 3;
const LZX_NUM_PRIMARY_LENGTHS: usize = 7;
const LZX_NUM_SECONDARY_LENGTHS: usize = 249;
const LZX_FRAME_SIZE: usize = 32768;
const HUFF_MAXBITS: usize = 16;
const PRETREE_MAXSYMS: usize = 20;
const PRETREE_TABLEBITS: usize = 6;
const MAINTREE_MAXSYMS: usize = LZX_NUM_CHARS + 290 * 8; // 2576
const MAINTREE_TABLEBITS: usize = 12;
const LENGTH_MAXSYMS: usize = LZX_NUM_SECONDARY_LENGTHS + 1; // 250
const LENGTH_TABLEBITS: usize = 12;
const ALIGNED_MAXSYMS: usize = 8;
const ALIGNED_TABLEBITS: usize = 7;
const LENTABLE_SAFETY: usize = 64;
const BITBUF_WIDTH: u32 = 32;
// ── Static tables ───────────────────────────────────────────────────────────
static POSITION_SLOTS: [u32; 11] = [30, 32, 34, 36, 38, 42, 50, 66, 98, 162, 290];
static EXTRA_BITS: [u8; 36] = [
0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13,
13, 14, 14, 15, 15, 16, 16,
];
#[rustfmt::skip]
static POSITION_BASE: [u32; 290] = [
0, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512,
768, 1024, 1536, 2048, 3072, 4096, 6144, 8192, 12288, 16384, 24576, 32768,
49152, 65536, 98304, 131072, 196608, 262144, 393216, 524288, 655360,
786432, 917504, 1048576, 1179648, 1310720, 1441792, 1572864, 1703936,
1835008, 1966080, 2097152, 2228224, 2359296, 2490368, 2621440, 2752512,
2883584, 3014656, 3145728, 3276800, 3407872, 3538944, 3670016, 3801088,
3932160, 4063232, 4194304, 4325376, 4456448, 4587520, 4718592, 4849664,
4980736, 5111808, 5242880, 5373952, 5505024, 5636096, 5767168, 5898240,
6029312, 6160384, 6291456, 6422528, 6553600, 6684672, 6815744, 6946816,
7077888, 7208960, 7340032, 7471104, 7602176, 7733248, 7864320, 7995392,
8126464, 8257536, 8388608, 8519680, 8650752, 8781824, 8912896, 9043968,
9175040, 9306112, 9437184, 9568256, 9699328, 9830400, 9961472, 10092544,
10223616, 10354688, 10485760, 10616832, 10747904, 10878976, 11010048,
11141120, 11272192, 11403264, 11534336, 11665408, 11796480, 11927552,
12058624, 12189696, 12320768, 12451840, 12582912, 12713984, 12845056,
12976128, 13107200, 13238272, 13369344, 13500416, 13631488, 13762560,
13893632, 14024704, 14155776, 14286848, 14417920, 14548992, 14680064,
14811136, 14942208, 15073280, 15204352, 15335424, 15466496, 15597568,
15728640, 15859712, 15990784, 16121856, 16252928, 16384000, 16515072,
16646144, 16777216, 16908288, 17039360, 17170432, 17301504, 17432576,
17563648, 17694720, 17825792, 17956864, 18087936, 18219008, 18350080,
18481152, 18612224, 18743296, 18874368, 19005440, 19136512, 19267584,
19398656, 19529728, 19660800, 19791872, 19922944, 20054016, 20185088,
20316160, 20447232, 20578304, 20709376, 20840448, 20971520, 21102592,
21233664, 21364736, 21495808, 21626880, 21757952, 21889024, 22020096,
22151168, 22282240, 22413312, 22544384, 22675456, 22806528, 22937600,
23068672, 23199744, 23330816, 23461888, 23592960, 23724032, 23855104,
23986176, 24117248, 24248320, 24379392, 24510464, 24641536, 24772608,
24903680, 25034752, 25165824, 25296896, 25427968, 25559040, 25690112,
25821184, 25952256, 26083328, 26214400, 26345472, 26476544, 26607616,
26738688, 26869760, 27000832, 27131904, 27262976, 27394048, 27525120,
27656192, 27787264, 27918336, 28049408, 28180480, 28311552, 28442624,
28573696, 28704768, 28835840, 28966912, 29097984, 29229056, 29360128,
29491200, 29622272, 29753344, 29884416, 30015488, 30146560, 30277632,
30408704, 30539776, 30670848, 30801920, 30932992, 31064064, 31195136,
31326208, 31457280, 31588352, 31719424, 31850496, 31981568, 32112640,
32243712, 32374784, 32505856, 32636928, 32768000, 32899072, 33030144,
33161216, 33292288, 33423360,
];
// ── Error type ──────────────────────────────────────────────────────────────
#[derive(Debug)]
pub enum LzxError {
BadHuffmanTable,
Decrunch(String),
}
impl fmt::Display for LzxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadHuffmanTable => write!(f, "failed to build Huffman table"),
Self::Decrunch(msg) => write!(f, "LZX decrunch error: {msg}"),
}
}
}
impl std::error::Error for LzxError {}
// ── Bit reader (MSB order, 16-bit LE pairs) ────────────────────────────────
struct BitReader<'a> {
data: &'a [u8],
pos: usize,
buf: u32,
left: i32,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
Self {
data,
pos: 0,
buf: 0,
left: 0,
}
}
/// Inject one 16-bit little-endian pair into MSB bit buffer.
fn fill(&mut self) {
let b0 = if self.pos < self.data.len() {
let b = self.data[self.pos];
self.pos += 1;
b as u32
} else {
0
};
let b1 = if self.pos < self.data.len() {
let b = self.data[self.pos];
self.pos += 1;
b as u32
} else {
0
};
let word = (b1 << 8) | b0;
self.buf |= word << (16 - self.left as u32);
self.left += 16;
}
#[inline]
fn ensure(&mut self, n: i32) {
while self.left < n {
self.fill();
}
}
#[inline]
fn peek(&self, n: u32) -> u32 {
self.buf >> (BITBUF_WIDTH - n)
}
#[inline]
fn remove(&mut self, n: u32) {
self.buf <<= n;
self.left -= n as i32;
}
#[inline]
fn read(&mut self, n: u32) -> u32 {
self.ensure(n as i32);
let v = self.peek(n);
self.remove(n);
v
}
/// Read a raw byte directly (for UNCOMPRESSED blocks).
fn raw_byte(&mut self) -> u8 {
if self.pos < self.data.len() {
let b = self.data[self.pos];
self.pos += 1;
b
} else {
0
}
}
/// Re-align the bitstream at a frame boundary.
fn align_frame(&mut self) {
if self.left > 0 {
self.ensure(16);
}
let r = self.left & 15;
if r != 0 {
self.remove(r as u32);
}
}
}
// ── Huffman table builder (MSB order) ───────────────────────────────────────
fn make_decode_table(nsyms: usize, nbits: usize, length: &[u8], table: &mut [u16]) -> bool {
let mut pos: usize = 0;
let table_mask = 1usize << nbits;
let mut bit_mask = table_mask >> 1;
// Short codes: direct mapping
for bit_num in 1..=nbits {
for sym in 0..nsyms {
if length[sym] as usize != bit_num {
continue;
}
let leaf = pos;
pos += bit_mask;
if pos > table_mask {
return true;
}
for i in leaf..leaf + bit_mask {
table[i] = sym as u16;
}
}
bit_mask >>= 1;
}
if pos == table_mask {
return false;
}
// Mark remaining entries as unused
for i in pos..table_mask {
table[i] = 0xFFFF;
}
let mut next_symbol = if (table_mask >> 1) < nsyms {
nsyms
} else {
table_mask >> 1
};
let mut pos32 = (pos as u32) << 16;
let table_mask32 = (table_mask as u32) << 16;
let mut bit_mask32: u32 = 1 << 15;
// Long codes: tree traversal
for bit_num in (nbits + 1)..=HUFF_MAXBITS {
for sym in 0..nsyms {
if length[sym] as usize != bit_num {
continue;
}
if pos32 >= table_mask32 {
return true;
}
let mut leaf = (pos32 >> 16) as usize;
for fill in 0..(bit_num - nbits) {
if table[leaf] == 0xFFFF {
table[next_symbol << 1] = 0xFFFF;
table[(next_symbol << 1) + 1] = 0xFFFF;
table[leaf] = next_symbol as u16;
next_symbol += 1;
}
leaf = (table[leaf] as usize) << 1;
if (pos32 >> (15 - fill as u32)) & 1 != 0 {
leaf += 1;
}
}
table[leaf] = sym as u16;
pos32 += bit_mask32;
}
bit_mask32 >>= 1;
}
pos32 != table_mask32
}
// ── Huffman symbol decoder ──────────────────────────────────────────────────
fn read_huffsym(
br: &mut BitReader,
table: &[u16],
lens: &[u8],
tablebits: usize,
maxsyms: usize,
) -> Result<usize, LzxError> {
br.ensure(HUFF_MAXBITS as i32);
let mut sym = table[br.peek(tablebits as u32) as usize] as usize;
if sym >= maxsyms {
let mut i: u32 = 1 << (BITBUF_WIDTH - tablebits as u32);
loop {
i >>= 1;
if i == 0 {
return Err(LzxError::BadHuffmanTable);
}
sym = table[(sym << 1) | if br.buf & i != 0 { 1 } else { 0 }] as usize;
if sym < maxsyms {
break;
}
}
}
br.remove(lens[sym] as u32);
Ok(sym)
}
// ── LZX decoder state ───────────────────────────────────────────────────────
pub struct LzxDecoder {
window: Vec<u8>,
window_size: usize,
window_posn: usize,
frame_posn: usize,
frame: usize,
num_offsets: usize,
r0: u32,
r1: u32,
r2: u32,
block_type: u8,
block_length: usize,
block_remaining: usize,
header_read: bool,
intel_filesize: i32,
intel_curpos: i32,
intel_started: bool,
// Huffman code lengths
pretree_len: Vec<u8>,
maintree_len: Vec<u8>,
length_len: Vec<u8>,
aligned_len: Vec<u8>,
// Huffman decode tables
pretree_table: Vec<u16>,
maintree_table: Vec<u16>,
length_table: Vec<u16>,
aligned_table: Vec<u16>,
length_empty: bool,
}
impl LzxDecoder {
pub fn new(window_bits: u32) -> Self {
assert!((15..=21).contains(&window_bits));
let window_size = 1usize << window_bits;
let num_offsets = (POSITION_SLOTS[(window_bits - 15) as usize] as usize) << 3;
Self {
window: vec![0u8; window_size],
window_size,
window_posn: 0,
frame_posn: 0,
frame: 0,
num_offsets,
r0: 1,
r1: 1,
r2: 1,
block_type: 0,
block_length: 0,
block_remaining: 0,
header_read: false,
intel_filesize: 0,
intel_curpos: 0,
intel_started: false,
pretree_len: vec![0u8; PRETREE_MAXSYMS + LENTABLE_SAFETY],
maintree_len: vec![0u8; MAINTREE_MAXSYMS + LENTABLE_SAFETY],
length_len: vec![0u8; LENGTH_MAXSYMS + LENTABLE_SAFETY],
aligned_len: vec![0u8; ALIGNED_MAXSYMS + LENTABLE_SAFETY],
pretree_table: vec![0u16; (1 << PRETREE_TABLEBITS) + PRETREE_MAXSYMS * 2],
maintree_table: vec![0u16; (1 << MAINTREE_TABLEBITS) + MAINTREE_MAXSYMS * 2],
length_table: vec![0u16; (1 << LENGTH_TABLEBITS) + LENGTH_MAXSYMS * 2],
aligned_table: vec![0u16; (1 << ALIGNED_TABLEBITS) + ALIGNED_MAXSYMS * 2],
length_empty: false,
}
}
fn build_table(
lens: &[u8],
table: &mut [u16],
maxsyms: usize,
tablebits: usize,
) -> Result<(), LzxError> {
if make_decode_table(maxsyms, tablebits, lens, table) {
Err(LzxError::BadHuffmanTable)
} else {
Ok(())
}
}
fn build_table_maybe_empty(
lens: &[u8],
table: &mut [u16],
maxsyms: usize,
tablebits: usize,
) -> Result<bool, LzxError> {
if make_decode_table(maxsyms, tablebits, lens, table) {
// Check if table is simply empty (all lengths zero)
for i in 0..maxsyms {
if lens[i] > 0 {
return Err(LzxError::BadHuffmanTable);
}
}
Ok(true) // empty
} else {
Ok(false) // not empty
}
}
/// Read Huffman code lengths using the pretree (lzxd_read_lens).
fn read_lens(
br: &mut BitReader,
lens: &mut [u8],
pretree_len: &mut [u8],
pretree_table: &mut [u16],
first: usize,
last: usize,
) -> Result<(), LzxError> {
// Build pretree: 20 symbols, 4 bits each
for i in 0..20 {
pretree_len[i] = br.read(4) as u8;
}
Self::build_table(
pretree_len,
pretree_table,
PRETREE_MAXSYMS,
PRETREE_TABLEBITS,
)?;
let mut x = first;
while x < last {
let z = read_huffsym(
br,
pretree_table,
pretree_len,
PRETREE_TABLEBITS,
PRETREE_MAXSYMS,
)?;
if z == 17 {
// Run of zeros: [read 4 bits] + 4
let mut y = br.read(4) as usize + 4;
while y > 0 && x < last {
lens[x] = 0;
x += 1;
y -= 1;
}
} else if z == 18 {
// Run of zeros: [read 5 bits] + 20
let mut y = br.read(5) as usize + 20;
while y > 0 && x < last {
lens[x] = 0;
x += 1;
y -= 1;
}
} else if z == 19 {
// Run of same: [read 1 bit] + 4, then read symbol
let mut y = br.read(1) as usize + 4;
let z2 = read_huffsym(
br,
pretree_table,
pretree_len,
PRETREE_TABLEBITS,
PRETREE_MAXSYMS,
)?;
let mut val = lens[x] as i32 - z2 as i32;
if val < 0 {
val += 17;
}
while y > 0 && x < last {
lens[x] = val as u8;
x += 1;
y -= 1;
}
} else {
// Delta: code 0..16
let mut val = lens[x] as i32 - z as i32;
if val < 0 {
val += 17;
}
lens[x] = val as u8;
x += 1;
}
}
Ok(())
}
/// Decompress the full LZX stream into the output buffer.
pub fn decompress(&mut self, input: &[u8], output_len: usize) -> Result<Vec<u8>, LzxError> {
let mut br = BitReader::new(input);
let mut output = Vec::with_capacity(output_len);
let mut offset: usize = 0;
let end_frame = (output_len / LZX_FRAME_SIZE) + 1;
while self.frame < end_frame {
// Read header once
if !self.header_read {
let i_bit = br.read(1);
let (hi, lo) = if i_bit != 0 {
(br.read(16), br.read(16))
} else {
(0, 0)
};
self.intel_filesize = ((hi << 16) | lo) as i32;
self.header_read = true;
}
// Frame size
let frame_size = if output_len > 0 && (output_len - offset) < LZX_FRAME_SIZE {
output_len - offset
} else {
LZX_FRAME_SIZE
};
let mut bytes_todo =
(self.frame_posn + frame_size).wrapping_sub(self.window_posn) as i32;
while bytes_todo > 0 {
// New block?
if self.block_remaining == 0 {
// Realign after odd UNCOMPRESSED block
if self.block_type == LZX_BLOCKTYPE_UNCOMPRESSED && (self.block_length & 1) != 0
{
br.raw_byte();
}
// Read block type (3 bits) and length (24 bits)
self.block_type = br.read(3) as u8;
let hi = br.read(16) as usize;
let lo = br.read(8) as usize;
self.block_length = (hi << 8) | lo;
self.block_remaining = self.block_length;
match self.block_type {
LZX_BLOCKTYPE_ALIGNED => {
for i in 0..8 {
self.aligned_len[i] = br.read(3) as u8;
}
Self::build_table(
&self.aligned_len,
&mut self.aligned_table,
ALIGNED_MAXSYMS,
ALIGNED_TABLEBITS,
)?;
// Fall through to verbatim tree reading
Self::read_lens(
&mut br,
&mut self.maintree_len,
&mut self.pretree_len,
&mut self.pretree_table,
0,
256,
)?;
Self::read_lens(
&mut br,
&mut self.maintree_len,
&mut self.pretree_len,
&mut self.pretree_table,
256,
LZX_NUM_CHARS + self.num_offsets,
)?;
Self::build_table(
&self.maintree_len,
&mut self.maintree_table,
MAINTREE_MAXSYMS,
MAINTREE_TABLEBITS,
)?;
if self.maintree_len[0xE8] != 0 {
self.intel_started = true;
}
Self::read_lens(
&mut br,
&mut self.length_len,
&mut self.pretree_len,
&mut self.pretree_table,
0,
LZX_NUM_SECONDARY_LENGTHS,
)?;
self.length_empty = Self::build_table_maybe_empty(
&self.length_len,
&mut self.length_table,
LENGTH_MAXSYMS,
LENGTH_TABLEBITS,
)?;
}
LZX_BLOCKTYPE_VERBATIM => {
Self::read_lens(
&mut br,
&mut self.maintree_len,
&mut self.pretree_len,
&mut self.pretree_table,
0,
256,
)?;
Self::read_lens(
&mut br,
&mut self.maintree_len,
&mut self.pretree_len,
&mut self.pretree_table,
256,
LZX_NUM_CHARS + self.num_offsets,
)?;
Self::build_table(
&self.maintree_len,
&mut self.maintree_table,
MAINTREE_MAXSYMS,
MAINTREE_TABLEBITS,
)?;
if self.maintree_len[0xE8] != 0 {
self.intel_started = true;
}
Self::read_lens(
&mut br,
&mut self.length_len,
&mut self.pretree_len,
&mut self.pretree_table,
0,
LZX_NUM_SECONDARY_LENGTHS,
)?;
self.length_empty = Self::build_table_maybe_empty(
&self.length_len,
&mut self.length_table,
LENGTH_MAXSYMS,
LENGTH_TABLEBITS,
)?;
}
LZX_BLOCKTYPE_UNCOMPRESSED => {
self.intel_started = true;
// Align to byte boundary
if br.left == 0 {
br.ensure(16);
}
br.left = 0;
br.buf = 0;
// Read R0, R1, R2 (12 bytes, little-endian u32s)
let mut buf = [0u8; 12];
for b in &mut buf {
*b = br.raw_byte();
}
self.r0 = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
self.r1 = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]);
self.r2 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
}
_ => return Err(LzxError::Decrunch("bad block type".into())),
}
}
let mut this_run = self.block_remaining as i32;
if this_run > bytes_todo {
this_run = bytes_todo;
}
bytes_todo -= this_run;
self.block_remaining -= this_run as usize;
let window_size = self.window_size;
match self.block_type {
LZX_BLOCKTYPE_VERBATIM => {
while this_run > 0 {
let main_element = read_huffsym(
&mut br,
&self.maintree_table,
&self.maintree_len,
MAINTREE_TABLEBITS,
MAINTREE_MAXSYMS,
)?;
if main_element < LZX_NUM_CHARS {
self.window[self.window_posn] = main_element as u8;
self.window_posn += 1;
this_run -= 1;
} else {
let me = main_element - LZX_NUM_CHARS;
let mut match_length = me & LZX_NUM_PRIMARY_LENGTHS;
if match_length == LZX_NUM_PRIMARY_LENGTHS {
if self.length_empty {
return Err(LzxError::Decrunch("LENGTH tree empty".into()));
}
let footer = read_huffsym(
&mut br,
&self.length_table,
&self.length_len,
LENGTH_TABLEBITS,
LENGTH_MAXSYMS,
)?;
match_length += footer;
}
match_length += LZX_MIN_MATCH;
let mut match_offset = (me >> 3) as u32;
match match_offset {
0 => match_offset = self.r0,
1 => {
match_offset = self.r1;
self.r1 = self.r0;
self.r0 = match_offset;
}
2 => {
match_offset = self.r2;
self.r2 = self.r0;
self.r0 = match_offset;
}
3 => {
match_offset = 1;
self.r2 = self.r1;
self.r1 = self.r0;
self.r0 = match_offset;
}
_ => {
let extra = if match_offset >= 36 {
17
} else {
EXTRA_BITS[match_offset as usize] as u32
};
let verbatim_bits = br.read(extra);
match_offset = POSITION_BASE[match_offset as usize] - 2
+ verbatim_bits;
self.r2 = self.r1;
self.r1 = self.r0;
self.r0 = match_offset;
}
}
if self.window_posn + match_length > window_size {
return Err(LzxError::Decrunch("match overrun".into()));
}
self.copy_match(match_offset as usize, match_length);
this_run -= match_length as i32;
}
}
}
LZX_BLOCKTYPE_ALIGNED => {
while this_run > 0 {
let main_element = read_huffsym(
&mut br,
&self.maintree_table,
&self.maintree_len,
MAINTREE_TABLEBITS,
MAINTREE_MAXSYMS,
)?;
if main_element < LZX_NUM_CHARS {
self.window[self.window_posn] = main_element as u8;
self.window_posn += 1;
this_run -= 1;
} else {
let me = main_element - LZX_NUM_CHARS;
let mut match_length = me & LZX_NUM_PRIMARY_LENGTHS;
if match_length == LZX_NUM_PRIMARY_LENGTHS {
if self.length_empty {
return Err(LzxError::Decrunch("LENGTH tree empty".into()));
}
let footer = read_huffsym(
&mut br,
&self.length_table,
&self.length_len,
LENGTH_TABLEBITS,
LENGTH_MAXSYMS,
)?;
match_length += footer;
}
match_length += LZX_MIN_MATCH;
let mut match_offset = (me >> 3) as u32;
match match_offset {
0 => match_offset = self.r0,
1 => {
match_offset = self.r1;
self.r1 = self.r0;
self.r0 = match_offset;
}
2 => {
match_offset = self.r2;
self.r2 = self.r0;
self.r0 = match_offset;
}
_ => {
let extra = if match_offset >= 36 {
17
} else {
EXTRA_BITS[match_offset as usize] as u32
};
match_offset = POSITION_BASE[match_offset as usize] - 2;
if extra > 3 {
let verbatim_bits = br.read(extra - 3);
match_offset += verbatim_bits << 3;
let aligned = read_huffsym(
&mut br,
&self.aligned_table,
&self.aligned_len,
ALIGNED_TABLEBITS,
ALIGNED_MAXSYMS,
)?;
match_offset += aligned as u32;
} else if extra == 3 {
let aligned = read_huffsym(
&mut br,
&self.aligned_table,
&self.aligned_len,
ALIGNED_TABLEBITS,
ALIGNED_MAXSYMS,
)?;
match_offset += aligned as u32;
} else if extra > 0 {
let verbatim_bits = br.read(extra);
match_offset += verbatim_bits;
} else {
match_offset = 1;
}
self.r2 = self.r1;
self.r1 = self.r0;
self.r0 = match_offset;
}
}
if self.window_posn + match_length > window_size {
return Err(LzxError::Decrunch("match overrun".into()));
}
self.copy_match(match_offset as usize, match_length);
this_run -= match_length as i32;
}
}
}
LZX_BLOCKTYPE_UNCOMPRESSED => {
let run = this_run as usize;
for _ in 0..run {
self.window[self.window_posn] = br.raw_byte();
self.window_posn += 1;
}
}
_ => return Err(LzxError::Decrunch("bad block type in decode".into())),
}
// Overrun accounting
if this_run < 0 {
let overrun = (-this_run) as usize;
if overrun > self.block_remaining {
return Err(LzxError::Decrunch("overrun past block end".into()));
}
self.block_remaining -= overrun;
}
}
// Frame boundary check
if (self.window_posn.wrapping_sub(self.frame_posn)) != frame_size {
return Err(LzxError::Decrunch(format!(
"decode beyond frame: {} != {}",
self.window_posn - self.frame_posn,
frame_size
)));
}
// Re-align bitstream
br.align_frame();
// Intel E8 postprocessing
if self.intel_started
&& self.intel_filesize != 0
&& self.frame <= 32768
&& frame_size > 10
{
let mut e8_buf = vec![0u8; frame_size];
e8_buf.copy_from_slice(&self.window[self.frame_posn..self.frame_posn + frame_size]);
let mut i = 0usize;
let limit = frame_size - 10;
let mut curpos = self.intel_curpos;
let filesize = self.intel_filesize;
while i < limit {
if e8_buf[i] != 0xE8 {
i += 1;
curpos += 1;
continue;
}
let abs_off = e8_buf[i + 1] as i32
| (e8_buf[i + 2] as i32) << 8
| (e8_buf[i + 3] as i32) << 16
| (e8_buf[i + 4] as i32) << 24;
if abs_off >= -curpos && abs_off < filesize {
let rel_off = if abs_off >= 0 {
abs_off - curpos
} else {
abs_off + filesize
};
e8_buf[i + 1] = rel_off as u8;
e8_buf[i + 2] = (rel_off >> 8) as u8;
e8_buf[i + 3] = (rel_off >> 16) as u8;
e8_buf[i + 4] = (rel_off >> 24) as u8;
}
i += 5;
curpos += 5;
}
self.intel_curpos += frame_size as i32;
let to_write = frame_size.min(output_len - offset);
output.extend_from_slice(&e8_buf[..to_write]);
offset += to_write;
} else {
if self.intel_filesize != 0 {
self.intel_curpos += frame_size as i32;
}
let to_write = frame_size.min(output_len - offset);
output.extend_from_slice(&self.window[self.frame_posn..self.frame_posn + to_write]);
offset += to_write;
}
// Advance frame
self.frame_posn += frame_size;
self.frame += 1;
if self.window_posn == self.window_size {
self.window_posn = 0;
}
if self.frame_posn == self.window_size {
self.frame_posn = 0;
}
}
Ok(output)
}
/// Copy a match from the window (handles wrap-around).
fn copy_match(&mut self, match_offset: usize, match_length: usize) {
let window_size = self.window_size;
let mut remaining = match_length;
if match_offset > self.window_posn {
// Source wraps around window end
let j = match_offset - self.window_posn;
let mut src = window_size - j;
if j < remaining {
remaining -= j;
for _ in 0..j {
self.window[self.window_posn] = self.window[src];
self.window_posn += 1;
src += 1;
}
src = 0; // wrap to start
}
for _ in 0..remaining {
self.window[self.window_posn] = self.window[src];
self.window_posn += 1;
src += 1;
}
} else {
let mut src = self.window_posn - match_offset;
for _ in 0..remaining {
self.window[self.window_posn] = self.window[src];
self.window_posn += 1;
src += 1;
}
}
}
}

View File

@@ -0,0 +1,228 @@
//! PE32 `.pdata` exception data parser for PowerPC Xbox 360 binaries.
//!
//! Each `RUNTIME_FUNCTION` entry is 8 bytes, big-endian on disk:
//! ```text
//! word 0: BeginAddress (absolute VA, not RVA — Xbox 360 convention)
//! word 1: packed metadata (read as a single big-endian u32; MSVC
//! bit-field layout packs LSB-first):
//! bits 0.. 7 (low 8) : prolog_length (instruction count, dwords)
//! bits 8..29 (mid 22): function_length (instruction count, dwords)
//! bit 30 : 32-bit code flag (always 1 on PPC)
//! bit 31 : exception-handler-present flag
//! ```
//!
//! Reference: Microsoft PE32+ exception data spec (PowerPC RUNTIME_FUNCTION);
//! xenia-canary `src/xenia/cpu/xex_module.cc:1570-1587` (canary only reads
//! `BeginAddress`; the metadata layout above is the authoritative spec).
//!
//! `BeginAddress = 0` terminates the table early in some images (canary breaks
//! on this; we mirror).
use crate::pe::PeSection;
/// One parsed `RUNTIME_FUNCTION` entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdataEntry {
/// Absolute VA of the function's first instruction.
pub begin_address: u32,
/// Function size in bytes (function_length_dwords * 4).
pub function_length: u32,
/// Prolog size in bytes (prolog_length_dwords * 4).
pub prolog_length: u32,
/// 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,
}
impl PdataEntry {
/// One-past-the-last instruction (exclusive).
pub fn end_address(&self) -> u32 {
self.begin_address.wrapping_add(self.function_length)
}
}
/// Parse the `.pdata` section out of a decompressed PE image.
///
/// `pe` is the full image buffer (image_base-relative); `image_base` and the
/// `.pdata` section descriptor come from `sylpheed_xex::pe::parse_sections`.
/// Returns an empty vec if no `.pdata` section is present or it falls outside
/// the buffer — never an error (the caller already validated the section list).
pub fn parse_pdata(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Vec<PdataEntry> {
let pdata = match sections.iter().find(|s| s.name == ".pdata") {
Some(s) => s,
None => return Vec::new(),
};
let off = pdata.virtual_address as usize;
let len = pdata.virtual_size as usize;
if off.saturating_add(len) > pe.len() {
return Vec::new();
}
// Each entry is 8 bytes; truncate any partial trailing entry.
let n_entries = len / 8;
let mut out = Vec::with_capacity(n_entries);
for i in 0..n_entries {
let p = off + i * 8;
let begin = u32::from_be_bytes([pe[p], pe[p + 1], pe[p + 2], pe[p + 3]]);
let meta = u32::from_be_bytes([pe[p + 4], pe[p + 5], pe[p + 6], pe[p + 7]]);
// Sentinel: BeginAddress=0 marks early termination (canary `xex_module.cc:1583`).
if begin == 0 {
break;
}
let prolog_dwords = meta & 0xFF;
let function_dwords = (meta >> 8) & 0x003F_FFFF;
let flags = ((meta >> 30) & 0x3) as u8;
out.push(PdataEntry {
begin_address: begin,
function_length: function_dwords * 4,
prolog_length: prolog_dwords * 4,
flags,
});
}
// Sanity: drop any entry whose begin_address falls outside the image bounds.
// Image high water = image_base + the largest virtual_address+virtual_size.
let high = sections
.iter()
.map(|s| {
image_base
.wrapping_add(s.virtual_address)
.wrapping_add(s.virtual_size)
})
.max()
.unwrap_or(u32::MAX);
out.retain(|e| e.begin_address >= image_base && e.begin_address < high);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pe::PeSection;
fn mk_pe(
image_base: u32,
text_va: u32,
text_size: u32,
pdata: &[(u32, u32)],
) -> (Vec<u8>, Vec<PeSection>) {
// Build a synthetic PE image with .text and .pdata.
// Layout: pdata at RVA 0x1000, .text at RVA 0x2000.
let pdata_rva = 0x1000u32;
let pdata_size = (pdata.len() * 8) as u32;
let total = (text_va + text_size).max(pdata_rva + pdata_size) as usize;
let mut buf = vec![0u8; total];
for (i, &(begin, packed)) in pdata.iter().enumerate() {
let p = pdata_rva as usize + i * 8;
buf[p..p + 4].copy_from_slice(&begin.to_be_bytes());
buf[p + 4..p + 8].copy_from_slice(&packed.to_be_bytes());
}
let sections = vec![
PeSection {
name: ".pdata".into(),
virtual_address: pdata_rva,
virtual_size: pdata_size,
raw_offset: pdata_rva,
raw_size: pdata_size,
flags: 0x4000_0040, // INITIALIZED_DATA | READ
},
PeSection {
name: ".text".into(),
virtual_address: text_va,
virtual_size: text_size,
raw_offset: text_va,
raw_size: text_size,
flags: 0x6000_0020, // CODE | EXECUTE | READ
},
];
let _ = image_base; // image_base only matters for high-water bound
(buf, sections)
}
/// Pack metadata in the on-disk layout: prolog in low 8 bits, function
/// in next 22, flags in top 2.
fn pack(prolog_dwords: u32, function_dwords: u32, flags: u32) -> u32 {
((flags & 0x3) << 30) | ((function_dwords & 0x3F_FFFF) << 8) | (prolog_dwords & 0xFF)
}
#[test]
fn parses_simple_pdata() {
// function at 0x82001000, 32 bytes long (8 dwords), 8-dword prolog (32 bytes).
let packed = pack(8, 8, 0b01); // 32-bit-code flag set
let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x100, &[(0x8200_1000, packed)]);
let entries = parse_pdata(&pe, 0x8200_0000, &sections);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].begin_address, 0x8200_1000);
assert_eq!(entries[0].prolog_length, 32);
assert_eq!(entries[0].function_length, 32);
assert_eq!(entries[0].flags, 0b01);
assert_eq!(entries[0].end_address(), 0x8200_1020);
}
#[test]
fn stops_on_zero_sentinel() {
let packed = pack(4, 4, 0b01);
let entries = vec![
(0x8200_1000, packed),
(0u32, 0u32), // sentinel
(0x8200_2000, packed),
];
let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x4000, &entries);
let parsed = parse_pdata(&pe, 0x8200_0000, &sections);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].begin_address, 0x8200_1000);
}
#[test]
fn drops_out_of_range_entries() {
let packed = pack(4, 4, 0b01);
let entries = vec![
(0x8200_1000, packed),
(0x4000_0000, packed), // outside image — drop
];
let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x100, &entries);
let parsed = parse_pdata(&pe, 0x8200_0000, &sections);
assert_eq!(parsed.len(), 1);
}
#[test]
fn decodes_real_world_layout() {
// Mimics a real-world entry: function_length 306 dwords (1224 bytes),
// 0 prolog dwords, 32-bit-code flag set. Verify the bit-packed value
// round-trips correctly through parse_pdata.
let packed = pack(0, 306, 0b01);
let begin = 0x8200_2000u32; // inside the synthetic .text region
let (pe, sections) = mk_pe(0x8200_0000, 0x2000, 0x1000, &[(begin, packed)]);
let entries = parse_pdata(&pe, 0x8200_0000, &sections);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].function_length, 306 * 4);
assert_eq!(entries[0].prolog_length, 0);
assert_eq!(entries[0].flags, 0b01);
assert_eq!(entries[0].end_address(), begin + 1224);
}
#[test]
fn returns_empty_when_no_pdata_section() {
let sections = vec![PeSection {
name: ".text".into(),
virtual_address: 0x1000,
virtual_size: 0x100,
raw_offset: 0x1000,
raw_size: 0x100,
flags: 0x6000_0020,
}];
let pe = vec![0u8; 0x2000];
assert!(parse_pdata(&pe, 0x8200_0000, &sections).is_empty());
}
}

View File

@@ -0,0 +1,70 @@
//! Minimal PE parser for Xbox 360 executables.
//! PE headers are little-endian even on the big-endian Xbox 360.
use serde::Serialize;
#[derive(Serialize, Debug, Clone)]
pub struct PeSection {
pub name: String,
pub virtual_address: u32,
pub virtual_size: u32,
pub raw_offset: u32,
pub raw_size: u32,
pub flags: u32,
}
impl PeSection {
pub fn is_code(&self) -> bool {
self.flags & 0x20000000 != 0 // IMAGE_SCN_MEM_EXECUTE
}
}
fn le_u16(data: &[u8], off: usize) -> u16 {
u16::from_le_bytes([data[off], data[off + 1]])
}
fn le_u32(data: &[u8], off: usize) -> u32 {
u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
}
pub fn parse_sections(pe: &[u8]) -> anyhow::Result<Vec<PeSection>> {
anyhow::ensure!(pe.len() >= 64, "PE too small");
anyhow::ensure!(pe[0] == b'M' && pe[1] == b'Z', "not a PE (bad MZ)");
let e_lfanew = le_u32(pe, 0x3C) as usize;
anyhow::ensure!(e_lfanew + 4 <= pe.len(), "e_lfanew out of bounds");
let nt_sig = le_u32(pe, e_lfanew);
anyhow::ensure!(nt_sig == 0x00004550, "bad PE signature: 0x{nt_sig:08X}");
let file_header_off = e_lfanew + 4;
let num_sections = le_u16(pe, file_header_off + 2) as usize;
let opt_header_size = le_u16(pe, file_header_off + 16) as usize;
let section_table_off = file_header_off + 20 + opt_header_size;
let mut sections = Vec::new();
for i in 0..num_sections {
let s = section_table_off + i * 40;
if s + 40 > pe.len() {
break;
}
let name_bytes = &pe[s..s + 8];
let name = std::str::from_utf8(name_bytes)
.unwrap_or("???")
.trim_end_matches('\0')
.to_string();
sections.push(PeSection {
name,
virtual_size: le_u32(pe, s + 8),
virtual_address: le_u32(pe, s + 12),
raw_size: le_u32(pe, s + 16),
raw_offset: le_u32(pe, s + 20),
flags: le_u32(pe, s + 36),
});
}
Ok(sections)
}

View File

@@ -0,0 +1,135 @@
//! 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 `sylpheed_xexdb::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());
}
}

View File

@@ -0,0 +1,185 @@
//! `.tls` section parser for PE32 PowerPC.
//!
//! When MSVC links a binary that uses `__declspec(thread)` storage, it emits
//! a `.tls` section plus an IMAGE_TLS_DIRECTORY32 inside `.rdata`. The
//! directory points at:
//! - the raw initialised TLS data range (start, end VAs)
//! - the address of the index field (a u32 written at runtime by the
//! loader to identify which TLS slot was assigned)
//! - an array of TLS callback function pointers (NUL-terminated)
//! - the size of the zero-fill area appended after raw data
//!
//! Xbox 360 binaries follow the standard PE layout. Sylpheed has no `.tls`
//! section and no TLS directory — the parser simply returns `None` and
//! callers emit zero rows.
//!
//! Reference: Microsoft PE/COFF spec, IMAGE_TLS_DIRECTORY32 layout.
use crate::pe::PeSection;
/// One TLS callback function pointer extracted from the directory's
/// callback array.
#[derive(Debug, Clone, Copy)]
pub struct TlsCallback {
pub address: u32,
}
/// Parsed `.tls` directory information. All fields are absolute VAs.
#[derive(Debug, Clone)]
pub struct TlsInfo {
/// VA of the start of the initialised raw TLS data (template).
pub raw_data_start: u32,
/// VA of one-past-end of the raw TLS data.
pub raw_data_end: u32,
/// VA of the u32 the loader writes the assigned slot index into.
pub index_address: u32,
/// VA of the zero-terminated callback array; 0 when no callbacks.
pub callback_array: u32,
/// Bytes of zero-fill appended after the raw template at thread init.
pub zero_fill_size: u32,
/// Characteristics flags (alignment / etc).
pub characteristics: u32,
/// Resolved TLS callbacks (parsed from `callback_array`).
pub callbacks: Vec<TlsCallback>,
}
/// Parse the `.tls` section. Returns `None` if the binary has no `.tls`
/// section or the directory is malformed.
pub fn parse_tls(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Option<TlsInfo> {
// Find the `.tls` section. The IMAGE_TLS_DIRECTORY32 lives somewhere
// in `.rdata`; rather than hunt the IMAGE_DATA_DIRECTORY entry through
// the optional header, we accept any 24-byte struct at the start of
// `.tls` if the section's raw data looks like a valid directory.
//
// Per MS docs, IMAGE_TLS_DIRECTORY32 layout (24 bytes):
// +0x00 StartAddressOfRawData (VA, 4)
// +0x04 EndAddressOfRawData (VA, 4)
// +0x08 AddressOfIndex (VA, 4)
// +0x0C AddressOfCallBacks (VA, 4 — array of FN ptrs, NUL-terminated)
// +0x10 SizeOfZeroFill (4)
// +0x14 Characteristics (4)
let tls_section = sections.iter().find(|s| s.name == ".tls")?;
let off = tls_section.virtual_address as usize;
if off + 24 > pe.len() {
return None;
}
// Xbox 360 PE bodies are big-endian; this is consistent with how we
// parse the PE elsewhere (e.g. xref scanning reads BE u32 from PE).
let read_u32 = |start: usize| -> u32 {
u32::from_be_bytes([pe[start], pe[start + 1], pe[start + 2], pe[start + 3]])
};
let raw_data_start = read_u32(off);
let raw_data_end = read_u32(off + 4);
let index_address = read_u32(off + 8);
let callback_array = read_u32(off + 12);
let zero_fill_size = read_u32(off + 16);
let characteristics = read_u32(off + 20);
// Sanity: raw_data_start should land somewhere inside the image.
if raw_data_start == 0 && raw_data_end == 0 && index_address == 0 {
return None;
}
// Walk the callback array (zero-terminated array of u32 VAs).
let mut callbacks = Vec::new();
if callback_array != 0 {
let mut p = callback_array.wrapping_sub(image_base) as usize;
while p + 4 <= pe.len() {
let v = read_u32(p);
if v == 0 {
break;
}
callbacks.push(TlsCallback { address: v });
p += 4;
if callbacks.len() >= 64 {
break;
} // sanity cap
}
}
Some(TlsInfo {
raw_data_start,
raw_data_end,
index_address,
callback_array,
zero_fill_size,
characteristics,
callbacks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pe::PeSection;
fn mk_section(name: &str, va: u32, size: u32) -> PeSection {
PeSection {
name: name.into(),
virtual_address: va,
virtual_size: size,
raw_offset: va,
raw_size: size,
flags: 0x4000_0040,
}
}
#[test]
fn returns_none_when_no_tls_section() {
let pe = vec![0u8; 0x100];
let sections = vec![mk_section(".text", 0x10, 0x40)];
assert!(parse_tls(&pe, 0x82000000, &sections).is_none());
}
#[test]
fn parses_directory_and_callback_array() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
// Place the .tls section at RVA 0x100 with the directory.
let tls_va: u32 = 0x100;
let cb_va: u32 = 0x200;
// Directory fields:
let raw_start = 0x800u32;
let raw_end = 0x900u32;
let idx = 0x1000u32;
let zero_fill = 0x40u32;
let chars = 0x0u32;
let cb_array = image_base + cb_va;
for (i, v) in [
image_base + raw_start,
image_base + raw_end,
image_base + idx,
cb_array,
zero_fill,
chars,
]
.iter()
.enumerate()
{
pe[tls_va as usize + i * 4..tls_va as usize + i * 4 + 4]
.copy_from_slice(&v.to_be_bytes());
}
// Two callbacks + NUL terminator at cb_va.
let cb1 = image_base + 0x500;
let cb2 = image_base + 0x600;
pe[cb_va as usize..cb_va as usize + 4].copy_from_slice(&cb1.to_be_bytes());
pe[cb_va as usize + 4..cb_va as usize + 8].copy_from_slice(&cb2.to_be_bytes());
// pe[cb_va + 8..cb_va + 12] already zero (terminator).
let sections = vec![mk_section(".tls", tls_va, 0x100)];
let info = parse_tls(&pe, image_base, &sections).expect("parses");
assert_eq!(info.raw_data_start, image_base + raw_start);
assert_eq!(info.raw_data_end, image_base + raw_end);
assert_eq!(info.index_address, image_base + idx);
assert_eq!(info.callback_array, cb_array);
assert_eq!(info.zero_fill_size, zero_fill);
assert_eq!(info.callbacks.len(), 2);
assert_eq!(info.callbacks[0].address, cb1);
assert_eq!(info.callbacks[1].address, cb2);
}
}

View File

@@ -0,0 +1,58 @@
use super::{VfsDevice, VfsEntry, VfsError};
use std::path::{Path, PathBuf};
/// Host filesystem pass-through device.
pub struct HostPathDevice {
name: String,
root: PathBuf,
}
impl HostPathDevice {
pub fn new(name: impl Into<String>, root: impl AsRef<Path>) -> Self {
Self {
name: name.into(),
root: root.as_ref().to_path_buf(),
}
}
}
impl VfsDevice for HostPathDevice {
fn name(&self) -> &str {
&self.name
}
fn list_root(&self) -> Result<Vec<VfsEntry>, VfsError> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(&self.root)? {
let entry = entry?;
let metadata = entry.metadata()?;
entries.push(VfsEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_directory: metadata.is_dir(),
size: metadata.len(),
offset: 0,
// Host FS carries no Xbox attribute byte; synthesise the
// DIRECTORY/NORMAL split like canary's HostPathDevice.
attributes: if metadata.is_dir() { 0x10 } else { 0x80 },
});
}
Ok(entries)
}
fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
let full_path = self.root.join(path);
std::fs::read(&full_path).map_err(VfsError::from)
}
fn stat(&self, path: &str) -> Result<VfsEntry, VfsError> {
let full_path = self.root.join(path);
let metadata = std::fs::metadata(&full_path)?;
Ok(VfsEntry {
name: path.to_string(),
is_directory: metadata.is_dir(),
size: metadata.len(),
offset: 0,
attributes: if metadata.is_dir() { 0x10 } else { 0x80 },
})
}
}

View File

@@ -0,0 +1,346 @@
use super::{VfsDevice, VfsEntry, VfsError};
use std::io::{Read, Seek, SeekFrom};
/// XISO disc image device. Parses Xbox 360 disc images (GDFX/XISO format).
///
/// Caches the fully-resolved entry list at open() — GDFX is a directory
/// tree, and resolving any nested path (`dat/tables.pak`, `media/x.wav`)
/// requires descending into subdirectories. A prior version only scanned
/// the root buffer, so any file under a subdirectory was reported as
/// missing. We read each directory's buffer from disk once at open time
/// and emit full paths into `entries`.
pub struct DiscImageDevice {
name: String,
path: std::path::PathBuf,
game_offset: u64,
/// Flattened file + directory tree, each with its full path relative
/// to the partition root ("dat/tables.pak", etc.). Populated once at
/// `open()` so lookups are O(n) over a cached vec instead of rereading
/// the tree on every NtCreateFile.
entries: Vec<VfsEntry>,
}
/// XISO sector size
pub const SECTOR_SIZE: u64 = 0x800;
/// GDFX magic string
const GDFX_MAGIC: &[u8; 20] = b"MICROSOFT*XBOX*MEDIA";
/// File attribute: directory
const FILE_ATTRIBUTE_DIRECTORY: u8 = 0x10;
/// File attribute: read-only. Canary OR's this into every GDFX entry's
/// attribute byte because a pressed disc is inherently read-only
/// (`disc_image_device.cc:154`: `attributes | kFileAttributeReadOnly`).
const FILE_ATTRIBUTE_READONLY: u8 = 0x01;
/// Known game partition offsets to try
const LIKELY_OFFSETS: &[u64] = &[
0x0000_0000,
0x0000_FB20,
0x0002_0600,
0x0208_0000,
0x0FD9_0000,
];
impl DiscImageDevice {
pub fn open(name: impl Into<String>, path: &std::path::Path) -> Result<Self, VfsError> {
let mut file = std::fs::File::open(path)?;
// Find the game partition by locating the GDFX magic at sector 32
let mut game_offset = 0u64;
let mut magic_found = false;
let mut magic_buf = [0u8; 20];
for &offset in LIKELY_OFFSETS {
let magic_pos = offset + 32 * SECTOR_SIZE;
if file.seek(SeekFrom::Start(magic_pos)).is_ok()
&& file.read_exact(&mut magic_buf).is_ok()
&& magic_buf == *GDFX_MAGIC
{
game_offset = offset;
magic_found = true;
break;
}
}
if !magic_found {
return Err(VfsError::InvalidFormat(
"GDFX magic not found - not a valid XISO disc image".into(),
));
}
// Read root directory info from sector 32 header
let fs_ptr = game_offset + 32 * SECTOR_SIZE;
file.seek(SeekFrom::Start(fs_ptr + 20))?;
let mut buf4 = [0u8; 4];
file.read_exact(&mut buf4)?;
let root_sector = u32::from_le_bytes(buf4) as u64;
file.read_exact(&mut buf4)?;
let root_size = u32::from_le_bytes(buf4) as u64;
let root_byte_offset = game_offset + root_sector * SECTOR_SIZE;
// Read the root directory buffer into memory (typically small)
file.seek(SeekFrom::Start(root_byte_offset))?;
let mut root_buffer = vec![0u8; root_size as usize];
file.read_exact(&mut root_buffer)?;
let mut dev = Self {
name: name.into(),
path: path.to_path_buf(),
game_offset,
entries: Vec::new(),
};
dev.collect_entries(&mut file, &root_buffer, 0, "")?;
Ok(dev)
}
/// Walk one directory's B-tree buffer, emit each file/directory into
/// `out` with its full relative path, and recurse into subdirectory
/// buffers on disk.
///
/// `prefix` is the current parent path (empty at the root). Names
/// concatenate as `<prefix>/<name>` so the final path matches what
/// guest callers like `NtCreateFile("dat/tables.pak")` expect.
///
/// `file` is the already-open disc image handle, reused for every
/// subdirectory read so we don't pay a fresh open per directory on
/// deep trees.
fn collect_entries(
&mut self,
file: &mut std::fs::File,
buffer: &[u8],
ordinal: u16,
prefix: &str,
) -> Result<(), VfsError> {
let p = ordinal as usize * 4;
if p + 14 > buffer.len() {
return Ok(());
}
let node_l = u16::from_le_bytes([buffer[p], buffer[p + 1]]);
let node_r = u16::from_le_bytes([buffer[p + 2], buffer[p + 3]]);
let sector =
u32::from_le_bytes([buffer[p + 4], buffer[p + 5], buffer[p + 6], buffer[p + 7]]) as u64;
let length =
u32::from_le_bytes([buffer[p + 8], buffer[p + 9], buffer[p + 10], buffer[p + 11]])
as u64;
let attributes = buffer[p + 12];
let name_length = buffer[p + 13] as usize;
if p + 14 + name_length > buffer.len() {
return Ok(());
}
if node_l != 0 && node_l != 0xFFFF {
self.collect_entries(file, buffer, node_l, prefix)?;
}
let name = String::from_utf8_lossy(&buffer[p + 14..p + 14 + name_length]).to_string();
let is_directory = (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
// Match canary: the on-disc attribute byte (DIRECTORY/HIDDEN/SYSTEM/
// ARCHIVE/NORMAL bits as authored) OR the implicit READONLY bit for
// pressed media. We forward the FULL byte, not a path-shape guess, so
// attribute queries report exactly what the disc records.
let attributes = (attributes | FILE_ATTRIBUTE_READONLY) as u32;
let file_offset = self.game_offset + sector * SECTOR_SIZE;
let full_path = if prefix.is_empty() {
name.clone()
} else {
format!("{}/{}", prefix, name)
};
self.entries.push(VfsEntry {
name: full_path.clone(),
is_directory,
size: length,
offset: file_offset,
attributes,
});
// Descend into subdirectories. Zero-length directory entries exist
// (empty dirs) and must be skipped to avoid `read_exact` on 0 bytes.
if is_directory && length > 0 {
file.seek(SeekFrom::Start(file_offset))?;
let mut sub_buffer = vec![0u8; length as usize];
file.read_exact(&mut sub_buffer)?;
self.collect_entries(file, &sub_buffer, 0, &full_path)?;
}
if node_r != 0 && node_r != 0xFFFF {
self.collect_entries(file, buffer, node_r, prefix)?;
}
Ok(())
}
}
impl VfsDevice for DiscImageDevice {
fn name(&self) -> &str {
&self.name
}
fn list_root(&self) -> Result<Vec<VfsEntry>, VfsError> {
// Return the full flattened tree. Callers of this method are
// dump/debug paths (see `xenia-rs dumpxiso`), which want to see
// every file — root-only was the old flat-enumeration bug.
Ok(self.entries.clone())
}
fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
let entry = self
.entries
.iter()
.find(|e| e.name.eq_ignore_ascii_case(path) && !e.is_directory)
.ok_or_else(|| VfsError::NotFound(path.to_string()))?;
let offset = entry.offset;
let size = entry.size as usize;
// Read from file using seek
let mut file = std::fs::File::open(&self.path)?;
let file_len = file.seek(SeekFrom::End(0))?;
if offset + size as u64 > file_len {
return Err(VfsError::NotFound(format!(
"File data extends past end of image: {} (offset={:#x}, size={:#x}, image_len={:#x})",
path, offset, size, file_len
)));
}
file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; size];
let bytes_read = file.read(&mut buf)?;
if bytes_read < size {
// Try reading the rest
let mut total = bytes_read;
while total < size {
let n = file.read(&mut buf[total..])?;
if n == 0 {
return Err(VfsError::NotFound(format!(
"Short read: got {} of {} bytes for {}",
total, size, path
)));
}
total += n;
}
}
Ok(buf)
}
fn stat(&self, path: &str) -> Result<VfsEntry, VfsError> {
self.entries
.iter()
.find(|e| e.name.eq_ignore_ascii_case(path))
.cloned()
.ok_or_else(|| VfsError::NotFound(path.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression: the XISO reader used to only enumerate the root directory,
/// so any nested path (`dat/tables.pak`, `media/stream.xma`) failed to
/// open. Verified end-to-end by `browse` on the Sylpheed disc which
/// now lists 358 entries including `dat/*` files.
///
/// This test runs only if an XISO is available in the parent of the repo
/// root — matches the developer's local layout for the real disc. CI
/// machines without the disc simply skip the test (early-return Ok).
#[test]
fn nested_file_resolves_when_disc_present() {
let disc_path = std::path::Path::new(
"../../../Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
);
if !disc_path.exists() {
eprintln!("skipping: disc image not present at {:?}", disc_path);
return;
}
let dev = DiscImageDevice::open("disc", disc_path).expect("open xiso");
// Both a top-level and a nested file must be visible.
assert!(
dev.entries.iter().any(|e| e.name == "default.xex"),
"default.xex must be at the root"
);
assert!(
dev.entries
.iter()
.any(|e| e.name.eq_ignore_ascii_case("dat/tables.pak")),
"nested entry dat/tables.pak missing — subdirectory enumeration broken",
);
// And read_file must be able to fetch the nested bytes.
let bytes = dev
.read_file("dat/tables.pak")
.expect("read_file on nested path");
assert!(!bytes.is_empty(), "nested read returned empty buffer");
}
/// Build a one-node GDFX directory buffer in memory and parse it with
/// `collect_entries`, asserting the real on-disc attribute byte is
/// forwarded into `VfsEntry.attributes` (with READONLY OR'd in, matching
/// canary `disc_image_device.cc:154`) rather than synthesised from the
/// path shape.
fn parse_single_entry(name: &str, on_disc_attr: u8) -> VfsEntry {
// GDFX dirent: node_l(u16) node_r(u16) sector(u32) length(u32)
// attributes(u8) name_length(u8) name(bytes). The directory bit
// gates subdirectory descent; use length=0 so a "directory" entry
// is treated as an empty leaf and we don't recurse off the buffer.
let mut buf = Vec::new();
buf.extend_from_slice(&0u16.to_le_bytes()); // node_l
buf.extend_from_slice(&0u16.to_le_bytes()); // node_r
buf.extend_from_slice(&0u32.to_le_bytes()); // sector
buf.extend_from_slice(&0u32.to_le_bytes()); // length (0 => leaf)
buf.push(on_disc_attr); // attributes
buf.push(name.len() as u8); // name_length
buf.extend_from_slice(name.as_bytes());
let mut dev = DiscImageDevice {
name: "test".into(),
path: std::path::PathBuf::new(),
game_offset: 0,
entries: Vec::new(),
};
// `file` is only touched when descending into a non-empty directory;
// our length=0 entries never recurse, so a dummy handle is fine.
let mut file = std::fs::File::open("/dev/null").expect("open /dev/null");
dev.collect_entries(&mut file, &buf, 0, "").expect("parse");
assert_eq!(dev.entries.len(), 1);
dev.entries.into_iter().next().unwrap()
}
#[test]
fn directory_entry_reports_directory_attribute() {
// On-disc 0x10 (DIRECTORY) -> attributes carries 0x10 and READONLY.
let e = parse_single_entry("dat", FILE_ATTRIBUTE_DIRECTORY);
assert!(e.is_directory, "directory bit not decoded");
assert_ne!(
e.attributes & 0x10,
0,
"FILE_ATTRIBUTE_DIRECTORY must be set for a directory entry"
);
assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)");
}
#[test]
fn file_entry_has_no_directory_attribute() {
// On-disc 0x80 (NORMAL) -> not a directory; READONLY still OR'd in.
let e = parse_single_entry("default.xex", 0x80);
assert!(!e.is_directory, "non-directory misdecoded as directory");
assert_eq!(
e.attributes & 0x10,
0,
"FILE_ATTRIBUTE_DIRECTORY must be clear for a file entry"
);
assert_ne!(e.attributes & 0x80, 0, "NORMAL bit must be preserved");
assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)");
}
#[test]
fn archive_and_hidden_bits_are_preserved() {
// ARCHIVE(0x20) | HIDDEN(0x02) authored on disc must survive intact.
let e = parse_single_entry("save.dat", 0x20 | 0x02);
assert_eq!(e.attributes & 0x20, 0x20, "ARCHIVE bit dropped");
assert_eq!(e.attributes & 0x02, 0x02, "HIDDEN bit dropped");
assert_eq!(e.attributes & 0x10, 0, "spurious DIRECTORY bit");
}
}

View File

@@ -0,0 +1,43 @@
pub mod device;
pub mod disc_image;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum VfsError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid format: {0}")]
InvalidFormat(String),
#[error("File not found: {0}")]
NotFound(String),
}
/// A virtual filesystem entry (file or directory).
#[derive(Debug, Clone)]
pub struct VfsEntry {
pub name: String,
pub is_directory: bool,
pub size: u64,
pub offset: u64,
/// Xbox `FILE_ATTRIBUTE_*` bitmask for this entry, sourced from the
/// backing device's real on-disc metadata rather than inferred from
/// the path shape. For GDFX disc images this is the on-disc attribute
/// byte at dirent offset +12 OR'd with `FILE_ATTRIBUTE_READONLY`
/// (matches xenia-canary `disc_image_device.cc:154`:
/// `entry->attributes_ = attributes | kFileAttributeReadOnly`).
///
/// Bit layout (canary `vfs/entry.h:66-76`): READONLY=0x01, HIDDEN=0x02,
/// SYSTEM=0x04, DIRECTORY=0x10, ARCHIVE=0x20, NORMAL=0x80.
pub attributes: u32,
}
/// Trait for VFS device implementations (XISO, STFS, host path, etc.)
pub trait VfsDevice: Send + Sync {
fn name(&self) -> &str;
fn list_root(&self) -> Result<Vec<VfsEntry>, VfsError>;
fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError>;
fn stat(&self, path: &str) -> Result<VfsEntry, VfsError>;
}

View File

@@ -0,0 +1,26 @@
[package]
name = "sylpheed-xexdb"
version = "0.1.0"
edition = "2024"
description = "Static analysis of the title's XEX into a DuckDB database"
[[bin]]
name = "sylph-xexdb"
path = "src/bin/sylph-xexdb.rs"
[dependencies]
sylpheed-xex = { path = "../sylpheed-xex" }
sylpheed-ppc = { path = "../sylpheed-ppc" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
metrics = "0.23"
duckdb = { version = "1", features = ["bundled"] }
msvc-demangler = "0.11"
encoding_rs = "0.8"
clap = { version = "4", features = ["derive"] }
[build-dependencies]
serde_json = "1"

View File

@@ -0,0 +1,51 @@
# `sylph-xexdb` — the title's XEX, as a queryable database
```bash
sylph-xexdb dis <disc.iso|default.xex> --db sylpheed.db --analyze sql
tools/zq.py dis 0x82341a20 0x82341b00 # then query it
```
Extract the PE image, disassemble it, detect functions, resolve cross-references
and RTTI, and write the lot to **DuckDB**.
## Where it came from, and what did not come with it
This was `xenia-rs` — a from-scratch Rust Xbox 360 emulator, started because
Canary would not run on Linux. Canary later did, with fixes from us, and the
emulator was retired. **The static analysis is the part that stayed useful**, so
it came here and the emulator did not: no interpreter, no JIT, no scheduler, no
GPU, no kernel. About 17,000 lines of ~71,000.
That was possible because the coupling was three symbols — `decoder::decode`,
`disasm::DisasmItem`, `disasm::format` — now `sylpheed-ppc`.
| crate | what it is |
|---|---|
| `sylpheed-xex` | the XEX2 container: decrypt, LZX, PE image, resources, and the disc image it may live in |
| `sylpheed-ppc` | PowerPC decode and disassembly |
| `sylpheed-xexdb` | the analysis passes, the schema, and this binary |
## ⚠️ Two things that will mislead you
**The database is DuckDB.** `xenia-rs`'s own `--db` help said "SQLite" in two
places and was wrong. Query it with `python3 -c "import duckdb"`, or `zq.py`.
**`indirect_dispatch_candidates` is deliberately not a cross product.** A
`bcctrl` through `this->vptr` at offset 0 matches nearly every class, so one
site can claim 700+ callees. Sites past `--max-indirect-candidates` record a
truthful `candidate_count` with `truncated` set and emit **no** candidate rows.
On this title that is 6,556 of 6,983 sites, standing for 1,801,075 candidates
that are counted rather than materialised. An older database built without the
ceiling has ~1.8M more rows in that table and in `xrefs`, and they say nothing
extra.
## The export table
Import names come from `docs/reference/xbox360-exports.json` — 2,913 ordinals,
compiled in by `build.rs`.
🔴 It used to come from Canary's `xboxkrnl_table.inc` through **a relative path
to a sibling checkout**, which printed a warning and produced an empty table
whenever that checkout was not there. Every import in the database then resolved
to nothing, and the build still succeeded. Now the source is in this repository
and a missing file **fails the build**.

View File

@@ -0,0 +1,570 @@
# `xenia-analysis` schema reference
Authoritative documentation for the DuckDB tables and SQL views produced by
`xenia-rs dis --db sylpheed.db`. Track schema changes here alongside any
update to the `db_schema_golden` test fixture.
The base + disasm tables (`metadata`, `sections`, `imports`, `functions`,
`labels`, `instructions`, `xrefs`, opt-in `exec_trace` / `import_calls` /
`branch_trace`) are documented inline in `src/db.rs` doc comment. This file
collects layered analysis additions and forward-work notes.
---
## Layer M1 — `.pdata` boundary correction (landed)
### Schema additions
- `functions.pdata_validated BOOLEAN NOT NULL``true` when the row's
`address` matches a `RUNTIME_FUNCTION.BeginAddress` from `.pdata`. Linker
ground truth.
- `functions.pdata_length BIGINT NULL``function_length` (bytes) from the
matching pdata entry; `NULL` when the row is prologue-only.
- New table `pdata_entries(begin_address BIGINT PRIMARY KEY, end_address
BIGINT, function_length BIGINT, prolog_length BIGINT, flags BIGINT)` — every
parsed `.pdata` `RUNTIME_FUNCTION` entry (raw, before any merge with
prologue analysis).
- Index `idx_functions_pdata_validated` on `functions(pdata_validated)`.
### What this layer does
- Parses `.pdata` 8-byte `RUNTIME_FUNCTION` entries (PowerPC PE32 layout):
word 0 `BeginAddress` (absolute VA), word 1 packed
`{prolog_length:8, function_length:22, flags:2}`, both big-endian.
- Unions pdata `BeginAddress` values into the function-candidate set fed to
the prologue walker, so functions our prologue heuristic missed still get
rows.
- When pdata supplies a longer `function_length` than the prologue walk
found, extends `end_address` to the pdata-implied end (catches mis-split
where the walker stopped at an early `blr`).
- After the walker, performs a forward pass that trims `function.end` to the
next start when they overlap (catches mis-merge where one row spanned two
prologues — the audit-031 `sub_824D23B0` / `sub_824D29F0` case).
### What this layer does NOT do
- Does not adjust prolog-derived `frame_size` / `saved_gprs` from `.pdata`'s
`prolog_length` field — those remain prologue-only inferences.
- Does not classify functions further than the existing `is_leaf` /
`is_saverestore` columns. Class membership is M3.
- Does not detect functions whose entries are missing from BOTH `.pdata`
and the bl-target scan (extremely rare; would require executable-byte
linear sweep).
### Reference docs
- Microsoft PE32+ exception data spec for PowerPC RUNTIME_FUNCTION.
- xenia-canary `src/xenia/cpu/xex_module.cc:1570-1587` — canary's reference
parser (extracts `BeginAddress` only; we additionally decode word 1).
### Validation queries
```sql
-- All pdata entries found
SELECT COUNT(*) FROM pdata_entries; -- ~23073 for Sylpheed
-- Functions cross-validated against pdata
SELECT COUNT(*) FROM functions WHERE pdata_validated;
-- Functions detected ONLY by prologue (orphans of pdata)
SELECT COUNT(*) FROM functions WHERE NOT pdata_validated;
-- Pdata orphans NOT yet in functions (should be 0 after this layer)
SELECT COUNT(*) FROM pdata_entries p
LEFT JOIN functions f ON f.address = p.begin_address
WHERE f.address IS NULL;
-- Audit-031 mis-merge resolved: 0x824D29F0 should have its own row
SELECT name FROM functions WHERE address = 2186674160; -- 0x824D29F0
```
---
## Layer M2 — MSVC C++ name demangler (landed)
### Schema additions
- New table `demangled_names(address BIGINT NULL, mangled VARCHAR NOT NULL,
raw_demangled VARCHAR NOT NULL, namespace_path VARCHAR NULL,
class_name VARCHAR NULL, method_name VARCHAR NULL,
params_signature VARCHAR NULL)`.
- Indices on `address`, `class_name`, `method_name`.
### What this layer does
- Wraps `msvc_demangler::demangle` (a Rust port of LLVM's
`MicrosoftDemangle.cpp`) and splits the formatted output into structured
fields via a heuristic top-level parser (handles templates and nested parens
correctly).
- Populates `demangled_names` from any label whose name starts with `?` plus
any import name that happens to be mangled (defensive — typical kernel
imports use C names).
### What this layer does NOT do
- Does not parse the AST returned by `msvc_demangler::parse` — uses the formatted
string and a heuristic split. Adequate for typical class member functions
and RTTI strings; exotic template / lambda forms still get `raw_demangled`
populated but may have NULL structured fields.
- Does not yet ingest RTTI strings discovered in `.rdata` — that's M3's job;
M3 will append rows to this table at the addresses where it finds RTTI
TypeDescriptors.
### Reference docs
- `msvc-demangler` crate (`https://docs.rs/msvc-demangler/0.11`).
- LLVM `MicrosoftDemangle.cpp` (the parser this crate ports).
## Layer M3 — Vtable + RTTI detection (landed)
### Schema additions
- `vtables(address PK, length, col_address NULL, class_name, rtti_present,
base_classes_json NULL)` — every detected static vtable.
- `methods(vtable_address, slot, function_address, mangled_name NULL,
demangled_name NULL, PRIMARY KEY (vtable_address, slot))` — one row per
method slot.
- `classes(name PK, vtable_address, rtti_present, base_classes_json NULL)` —
deduped by class name (first-detected vtable wins).
- Indices: `methods.function_address`, `classes.rtti_present`.
### What this layer does
- Walks `.rdata` and `.data` looking for runs of ≥3 consecutive 4-byte BE
values where each value is a known function start (from M1's corrected
`functions` table). Single-2-method vtables are intentionally rejected to
control false-positive rate.
- Attempts the MSVC RTTI walk `vtable[-1] → CompleteObjectLocator → TypeDescriptor`
for each candidate. When successful, the demangled `class ClassName`
string fills `class_name` and a best-effort
`RTTIClassHierarchyDescriptor` walk fills `base_classes_json` (JSON array
of base class names).
- Falls back to `ANON_Class_<8-hex>` keyed by FNV-1a hash of the sorted
method-PC tuple when RTTI is absent (typical for shipped game binaries).
Identical vtables across the binary (multiple instances) collapse to the
same anonymous name.
### What this layer does NOT do
- Vtables built at runtime in heap-allocated memory (e.g. by ctors copying
static templates) are out of scope — only static `.rdata`/`.data` content.
- Multiple-inheritance "extra" vftables (one per base subobject) are detected
as independent vtables with no link between them.
- Inheritance-tree walking beyond `RTTIClassHierarchyDescriptor`'s direct
base list is not attempted.
### Reference docs
- openrce.org "Reversing Microsoft Visual C++" — RTTI layout articles
(CompleteObjectLocator at vtable[-1]; TypeDescriptor at COL+0xC; mangled
name at TD+0x8).
## Layer M4 — Class-aware probe targeting (landed)
CLI extension only — no schema changes. The probe-token grammar adds three
symbolic forms on top of the existing `0xADDR` literal:
- `Class::method` — joins `classes` × `methods` × `demangled_names` to find
every PC whose vtable belongs to that class and whose demangled
`method_name` matches.
- `Class::*` — joins `classes` × `methods` to find every method PC of that
class.
- `function_name` — falls back to `functions.name` lookup for free functions
/ saverestore stubs / labels.
Numeric tokens never touch the DB (preserves zero-IO fast path; lockstep
digest unaffected). Symbolic tokens require the DuckDB at `--probe-db PATH`
or `XENIA_PROBE_DB`; default is `sylpheed.db` next to the .iso when present.
Resolution happens BEFORE guest exec begins, so it cannot affect the
lockstep digest.
See `crates/xenia-analysis/src/lookup.rs`.
---
## Layer M5 — Indirect-dispatch reachability (landed)
### Schema additions
- New value `'ind_call'` in the `xrefs.kind` set.
- New SQL view `v_indirect_reachability_from_entry` — strict superset of
`v_reachability_from_entry`, taking `ind_call` edges in the BFS.
### What this layer does
- Walks each `FuncAnalysis.functions` entry with a per-basic-block register
tracker. Recognises the canonical static-vtable pattern:
`lis+addi → lwz off(rA) → mtctr → bcctrl`, where `rA` ends up holding a
known vtable's start address from M3.
- Honours the PowerPC ABI: `bl`-style calls (op 18 / 16 with LK=1) clobber
volatile r0..r12 + ctr but preserve non-volatile r13..r31, so a vtable
pointer parked in r30/r31 before a call survives.
- Treats every M3 `loc_*` label as a basic-block boundary (kills register
state) so jump-IN paths cannot induce false positives.
### What this layer does NOT do (and observed impact)
- Vtable pointer loaded from a `this`-pointer field
(`lwz r_vt, off(rA)` where `rA = this`) — by far the dominant pattern in
real C++ — is unresolvable without alias / points-to analysis.
- On Sylpheed: the layer detects 0 edges. The binary's 1,001 lis+addi
references into vtables are mostly constructor-side **vptr writes**
(`stw rVtable, vptr_offset(this)`), not direct dispatches. The renderer
hunt's audit-009 cluster therefore needs a future M5.5 with `this`-flow
tracking before this layer surfaces it.
### Reference docs
- IBM PowerPC ABI: register-save convention (volatile r0..r12 + ctr,
non-volatile r13..r31).
## Layer M7 — String / constant-pool detection (landed)
### Schema additions
- New table `strings(address PK, encoding, length, content)`.
- Index `idx_strings_encoding`.
### What this layer does
- Scans `.rdata` for runs of length ≥ 6 of printable ASCII bytes followed by
a NUL terminator.
- Scans `.rdata` for UTF-16LE runs of length ≥ 6 code units (printable-ASCII
basic plane only) followed by a u16 NUL terminator.
- Cross-reference is implicit: existing `xrefs.kind='ref'` rows whose
`target` falls in `strings.address`'s exact match set name the referencing
PCs. SQL: `SELECT s.content, x.source FROM xrefs x JOIN strings s
ON s.address = x.target WHERE x.kind='ref'`.
### What this layer does NOT do
- No UTF-8 multibyte / non-ASCII basic plane in either encoding.
- No `.data` scan (read-only-section bias).
- No multi-byte CJK encodings — Japanese text in localised builds may be
represented in shift_jis / utf-8 with non-printable bytes that this
scanner skips.
### Sylpheed yield
- 6,311 ASCII strings (including full embedded HLSL shader source).
- 0 UTF-16LE strings (binary uses ASCII / native CJK encoding).
- 9,132 lis+addi sites cross-reference into the detected strings — names
the source PCs that reference each string.
## Layer M6 — Extended store-class xrefs + `addr_mode` column (landed)
### Schema additions
- `xrefs.addr_mode VARCHAR NULL` — sub-classifies how the source instruction
computes its target. NULL for control-flow edges (call / ind_call / j /
br); one of the following tags for data edges:
- `d_form` — standard signed-16 displacement (lwz/stw/lfs/stfs/etc.)
- `lis_addi` — address materialised via `lis + addi` register tracking
- `lis_ori` — address materialised via `lis + ori`
- `multiword` — `lmw / stmw` (one xref per slot; up to 32-rS slots)
- `x_form_indexed` — `stwx / stbx / sthx / stwux / stbux / sthux / stdx /
stdux / lwzx / lbzx / lhzx / lhax / lwzux / lbzux / lhzux / lhaux / ldx /
ldux` — emitted only when both rA and rB are tracked constants
- `x_form_byterev` — `stwbrx / sthbrx / lwbrx / lhbrx`
- `atomic` — `stwcx. / stdcx.` reservation-conditional stores
- `dcbz` — cache-line clear (32-byte zero at rA+rB)
- Index `idx_xrefs_addr_mode`.
### What this layer does
- Tags every existing data xref with its addressing mode (`d_form` for the
bulk; `lis_addi` / `lis_ori` for the lift-and-add cases that produce
DataRef rows).
- Adds new dispatch for opcode 47 (`stmw`) and 46 (`lmw`), expanding to
per-slot DataWrite / DataRead rows.
- Adds new dispatch for opcode 31 X-form: stores, atomic, byte-reverse,
dcbz. X-form rows are emitted ONLY when both rA and rB resolve to known
constants (otherwise the address is runtime-dependent and we skip).
### What this layer does NOT do
- VMX / VMX128 vector stores (opcode 31 with vector XO codes) are not
emitted — they always have register-indexed addresses that the
lis+addi tracker can't usually resolve, and detecting them adds noise
without improving target resolution.
- The dominant runtime-of-stwx pattern (rA = base, rB = runtime index) is
not resolved — by design; mem-watch covers the runtime side per VERIFY-B.
### Sylpheed yield
- 28,834 `lis_addi` refs, 18,485 `d_form` reads, 3,288 `d_form` writes —
the existing baseline now properly tagged.
- **442 newly-detected `x_form_indexed` reads** — primarily lwzx/lhzx
reads from in-table dispatch (each pair (rA,rB) resolved statically).
- **40 newly-detected `atomic` writes** — every `stwcx.` site with a
resolvable address; useful for reservation-table audits.
- 9 `lis_ori` refs.
- 0 multiword / dcbz / byterev — these instructions exist in the binary
but are not in lis+addi-tracked code paths.
## Layer M8 + M11 — Function-pointer arrays beyond vtables (landed)
### Schema additions
- New table `function_pointer_arrays(address PK, length, kind)` where
`kind` is `'vtable'` (M3 re-emit), `'dispatch_table'` (M8), or
`'static_init'` (M11).
- New table `function_pointer_array_entries(array_address, slot,
function_address, PRIMARY KEY (array_address, slot))` — one row per
slot of every detected array (vtable + non-vtable).
- Indices on `function_pointer_arrays.kind` and
`function_pointer_array_entries.function_address`.
### What this layer does
- Walks `.rdata` (only — `.data` produces too many false positives) for
runs of ≥ 2 consecutive 4-byte BE values where each value is a known
function entry from M1's `functions` table.
- Skips runs whose start matches an M3 vtable head — those are re-emitted
in this table with `kind='vtable'` for unified queries but not
re-classified.
- Heuristically classifies non-vtable runs:
- `static_init` (M11): every entry's first instruction is `mfspr r12, LR`
AND the next is `stwu r1, -N(r1)` with `N ≤ 0x80` (or a save-stub `bl`).
Mirrors the typical C++ static-initialiser prologue.
- `dispatch_table` (M8): everything else.
### What this layer does NOT do
- Does not parse symbol-table-bracketed regions like `__xc_a` / `__xc_z`
/ `__xi_a` / `__xi_z` directly — Sylpheed's symbol table is stripped.
- Does not chain multi-segment static-init drivers; future M11.5 could
walk the entry-point's static-init driver call chain to surface
ground-truth ctor PCs.
- 2-slot runs in `.rdata` may be false positives where two struct fields
happen to alias function VAs; downstream queries should use a length
filter (`WHERE length >= 3`) when high precision matters.
### Sylpheed yield
- 722 vtables (M3 re-emit) + 388 dispatch_tables = 1,110 arrays in
`function_pointer_arrays`.
- 0 static_init detected — Sylpheed's ctors don't all match the
conservative prologue heuristic. Lengths concentrate at 2 slots
(typical of switch-case jump tables).
## Layer M9 — `has_eh` from `.pdata` exception flag (landed)
### Schema additions
- `functions.has_eh BOOLEAN NOT NULL` — true when `.pdata`'s exception-
handler-present bit (bit 31 of word 1, the high bit) is set.
- Index `idx_functions_has_eh`.
### What this layer does
- Derived directly from M1's already-parsed `pdata.flags` bit field (no
new parsing). The bit was always available in `pdata_entries.flags`;
this layer surfaces it as a first-class column on `functions`.
### What this layer does NOT do
- Does not parse the actual `__CxxFrameHandler` / `__C_specific_handler`
scope-table records that the exception bit gates. Walking those tables
would let us name try/catch ranges and per-state cleanup actions, but
is out of scope for a derive-only milestone.
### Sylpheed yield
- 2,975 of 23,073 pdata-validated functions have `has_eh=true` (12.9%) —
plausible MSVC C++ EH coverage rate. Largest EH function: 26,328 bytes
(`sub_823518F0`).
## Layer M10 — `.tls` section / TLS directory (landed)
### Schema additions
- New table `tls_info(raw_data_start, raw_data_end, index_address,
callback_array, zero_fill_size, characteristics)` — at most one row
(the IMAGE_TLS_DIRECTORY32).
- New table `tls_callbacks(slot PK, address)` — one row per resolved TLS
callback function.
### What this layer does
- Reads the first 24 bytes of the `.tls` section as an
`IMAGE_TLS_DIRECTORY32` and walks the zero-terminated callback array.
- All addresses stored as absolute VAs.
### What this layer does NOT do
- Does not parse the raw TLS template content (the variable initialiser
block); just records its start/end VAs.
### Sylpheed yield
- 0 rows — Sylpheed has no `.tls` section. Infrastructure ready for any
binary that uses `__declspec(thread)` storage.
## Layer M12 — `--lr-trace` runtime canary-diff harness (landed)
### Runtime additions (no DB)
- New CLI flag `--lr-trace=PC[,PC,...]` on `exec` — comma-separated PCs
to capture as JSONL records on every fire. Symbolic tokens (`Class::method`)
resolve via M4's lookup against `--probe-db`. Settable via
`XENIA_LR_TRACE`.
- New CLI flag `--lr-trace-out=PATH` — writes JSONL to a file (one
record per line). Stdout when omitted. Settable via `XENIA_LR_TRACE_OUT`.
- New kernel state fields `lr_trace_pcs: HashSet<u32>` +
`lr_trace_writer: Option<Mutex<File>>` and helper
`KernelState::fire_lr_trace_if_match(hw_id)` invoked from the
per-instruction probe slot.
### JSONL record fields
`pc, tid, hw, cycle, r3, r4, r5, r6, lr` — superset of what
xenia-canary's `--log_lr_on_pc` patch emits, with a cycle counter added
for cross-run reproducibility.
### What this layer does NOT do
- Does not capture VMX / FP register state (only GPRs r3..r6).
- Does not buffer / batch records — one `write_all` per fire. For
high-frequency probes (e.g. tight loops at >1M fires/sec), redirect
to a file and use a SSD.
### Determinism
Lockstep digest unaffected: probe firing happens after the per-instr
hooks for ctor/branch probes and only emits side-channel output. Verified
end-of-session: `check sylpheed.iso --stable-digest -n 2M` ×2 produced
byte-identical digests (`instructions=2000005`).
---
## Layer M5.5 — `this`-flow indirect-dispatch resolution (landed)
### Schema additions
- New table `vptr_writes(writer_pc, vtable_address, vptr_offset, writer_function)` —
every detected `stw rVtable, vptr_off(rThis)` site.
- New table `indirect_dispatch_sites(dispatch_pc PK, vptr_offset, slot, candidate_count)` —
one row per resolved dispatch.
- New table `indirect_dispatch_candidates(dispatch_pc, vtable_address, method_address)` —
one row per (dispatch × candidate vtable). Joined to existing
`xrefs.kind='ind_call'` edges (one ind_call row per candidate).
- New indices on `vptr_writes.vtable_address`, `vptr_writes.vptr_offset`,
`indirect_dispatch_candidates.method_address`,
`indirect_dispatch_candidates.vtable_address`,
`indirect_dispatch_sites.(vptr_offset, slot)`.
### What this layer does (class-membership inference)
1. **Phase 1 — vptr-write scan**: walk every function with the lis+addi
tracker; whenever `stw rA, off(rB)` writes a known M3 vtable address,
record `(vtable_addr, vptr_offset, writer_pc)`.
2. **Phase 2 — invert**: build `vtables_by_offset[vptr_off] = {V}` for the
set of vtables ever written at that offset.
3. **Phase 3 — dispatch detection**: walk back ≤16 instructions from each
`bcctrl`/`bctr LK=1`, find the canonical
`lwz vt, off(this); lwz fn, slot*4(vt); mtctr fn` chain. Extract
`(vptr_off, slot)`. Bail on register clobber, branch, or label
boundary.
4. **Phase 4 — emit**: for each `(dispatch_pc, vptr_off, slot)`, emit one
`xrefs.kind='ind_call'` row per candidate vtable that has a
matching slot. Multi-candidate rows are an over-approximation.
### What this layer does NOT do
- No alias resolution at multi-candidate sites — emits one edge per
matching vtable. Downstream queries should filter
`indirect_dispatch_sites WHERE candidate_count=1` for high-confidence
edges.
- No flow-sensitive analysis: register state is killed at every label
(basic-block boundary) and at `bl`/`bcl` calls (volatile r0..r12 +
ctr). We do NOT propagate values across calls in the chain-walker.
- No tracking of vptr writes via X-form indexed (`stwx`), VMX, or
multiword stores. Only D-form `stw rA, off(rB)`.
- Does not synthesise vptr writes for inlined / elided constructors.
If a class never has a writer at offset `vptr_off`, dispatches
through that offset find no candidates.
### Sylpheed yield
- 567 vptr writes covering 214 distinct vtables (~30% of M3's 722).
- 29 distinct vptr offsets used; offset 0 dominates (501/567 = 88%,
single-inheritance).
- **6,842 dispatch sites resolved**: 97 single-candidate
(high-confidence) + 6,745 multi-candidate (over-approximation).
- 687,963 `ind_call` xref rows total.
- **2,746 newly-reachable functions** via the M5 BFS view
(`v_indirect_reachability_from_entry`) compared to call/j/br alone.
- Audit-009 cluster (renderer plateau): functions newly visible
include `0x823BC9E0`, `0x823BC290`, `0x823BC5A0`, `0x823BB158`,
`0x823BB1E0`, `0x823BCAF0`, `0x823BC4C8` — actionable starting
points for the cluster's reachability hunt.
### Reference docs
- IBM PowerPC ABI (volatile/non-volatile register partition).
- Itanium C++ ABI on vtable layout (offset-from-`this` model adapted
by MSVC for Win32 PPC).
## Layer M9.5 — `__CxxFrameHandler` scope-table parsing (landed)
### Schema additions
- New table `eh_funcinfo(address PK, magic, max_state, p_unwind_map,
n_try_blocks, p_try_block_map, n_ip_map_entries, p_ip_to_state_map,
p_es_type_list, eh_flags)`.
- New table `eh_unwind_map(funcinfo_address, state_index, to_state, action_pc,
PRIMARY KEY (funcinfo_address, state_index))`.
- New table `eh_try_blocks(funcinfo_address, try_index, try_low, try_high,
catch_high, n_catches, p_handler_array,
PRIMARY KEY (funcinfo_address, try_index))`.
### What this layer does
- Magic-scans `.rdata` for the documented MSVC FuncInfo signatures
(0x19930520 / 0x19930521 / 0x19930522), reading 4-byte BE values
on 4-byte alignment.
- Sanity-checks `max_state` ≤ 10,000, `n_try_blocks` ≤ 1,000, all
internal pointers landing in valid sections.
- Walks `pUnwindMap` (8-byte UnwindMapEntry) and `pTryBlockMap`
(20-byte TryBlockMapEntry) into one row each.
### What this layer does NOT do
- Does not associate FuncInfo records with their owning function via
the `bl __CxxFrameHandler` registration site — joins to `functions`
by best-effort PC-range queries. A future M9.6 can chase the
registration to make the link explicit.
- Does not parse `pHandlerArray` (per-try-block catch type info).
### Sylpheed yield
- 2,588 FuncInfo records (all version 0x19930522).
- 10,019 unwind-map entries.
- 315 try-blocks across the binary.
## Layer M11.5 — Static-init driver chain detection (landed)
### Schema additions
- Reuses existing `function_pointer_arrays` table — drivers' arrays are
emitted with `kind='static_init'`, replacing M11's prologue-heuristic
output where the structurally-grounded pattern fires.
### What this layer does
- Walks every detected function looking for the canonical `_initterm`-
style loop: `lwz cursor; mtctr; bcctrl; addi cursor, cursor, 4`
bounded by a comparison against another constant register.
- Extracts `(array_start, array_end)` from the cursor's initial
constant value and the end-comparand register.
- Reads the array, validates each entry against
`func_analysis.functions`, and emits the array as `static_init`.
### What this layer does NOT do
- Doesn't handle drivers with multiple back-to-back trampoline loops.
- Doesn't follow `_initterm_e` return-status semantics — both
`_initterm` and `_initterm_e` match if the loop body matches.
### Sylpheed yield
- 0 drivers detected. Sylpheed's static-init structure does not match
the canonical CRT loop pattern; the binary likely calls ctors via
another mechanism (inline at the entry point, or via a different
driver shape). Infrastructure ready for any binary with the
documented MSVC pattern.
## Layer VMX — Vector-store xrefs (M6 follow-up, landed)
Extends the M6 X-form opcode-31 dispatch in `xref.rs` with AltiVec/VMX
vector loads and stores. New entries (XO codes):
- `lvx` (103), `lvxl` (359), `lvebx` (7), `lvehx` (39), `lvewx` (71)
— `addr_mode='x_form_indexed'`, `kind='read'`.
- `stvx` (231), `stvxl` (487), `stvebx` (135), `stvehx` (167),
`stvewx` (199) — `addr_mode='x_form_indexed'`, `kind='write'`.
Same constraint as M6: rows emitted only when both `rA` and `rB`
resolve to known constants (rare but useful).
### Sylpheed yield
- 110 `stvx` writes newly resolved.
## Layer SJIS+UTF-8 — Localised-string detection (M7 follow-up, landed)
Extends `xenia_analysis::strings::analyze` with two additional scanners.
### Shift_JIS detection
Per JIS X 0208: lead byte ∈ [0x81, 0x9F] [0xE0, 0xEF];
trail byte ∈ [0x40, 0x7E] [0x80, 0xFC]. Single-byte ASCII and JIS
half-width katakana (0xA1..=0xDF) are passed through. At least one
multi-byte pair must be present (so we don't double-count pure ASCII).
SJIS bytes are rendered as `\\xHH` escapes in the `content` column for
diagnostic readability — full SJIS→UTF-8 decoding is a future
enhancement.
### UTF-8 detection
Validates 2-byte (`110xxxxx 10xxxxxx`) and 3-byte
(`1110xxxx 10xxxxxx 10xxxxxx`) sequences plus printable ASCII. Skips
4-byte (supplementary plane) which is rare in game text.
### Sylpheed yield
- 790 Shift_JIS strings (Japanese debug + UI text, including
`[WARNING] ードに割り当てるエフェクトIDの指定がない ノードデータが見つからない` style mission strings).
- 39 UTF-8 strings.
- 6,311 ASCII strings (unchanged from M7).
## Forward work (not yet landed)
- **M9.6** — link `eh_funcinfo` records back to their owning functions
via `bl __CxxFrameHandler` registration sites + per-try-block
`pHandlerArray` parsing.
- **M11.6** — relax M11.5 to detect non-canonical static-init driver
shapes (`_initterm_e` with status return, custom drivers).
- Full SJIS → UTF-8 decoding in the `strings.content` column.
- VMX128 (opcode 4) vector-store xrefs — separate encoding space, low
ROI; document if Sylpheed's renderer cluster uses it.

View File

@@ -0,0 +1,81 @@
//! Generate the ordinal → export-name table that import resolution needs.
//!
//! 🔴 **THE SOURCE CHANGED, AND THE OLD ONE COULD FAIL SILENTLY.** In `xenia-rs`
//! this parsed Canary's `xboxkrnl_table.inc` / `xam_table.inc` through a
//! **relative path to a sibling checkout** — `../xenia-canary/src/…`. When that
//! path was not there (a container, another machine, a fresh clone) it printed a
//! `cargo:warning` and returned an empty table, and every import in the database
//! came out unresolved. A build that quietly produces a worse artifact is the
//! failure mode this project keeps meeting.
//!
//! The source is now `docs/reference/xbox360-exports.json`, which is **in this
//! repository** — 2,913 exports across `xboxkrnl.exe`, `xam.xex` and `xbdm.xex`,
//! adopted in Phase 2 of `docs/agents/CONSOLIDATION.md`. There is no sibling
//! checkout to be missing, and if the file is unreadable this **fails the
//! build** rather than degrading.
use std::path::Path;
use std::{env, fs, io::Write};
fn main() {
let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
let json_path = Path::new(&manifest)
.parent()
.and_then(Path::parent)
.unwrap()
.join("docs/reference/xbox360-exports.json");
println!("cargo:rerun-if-changed={}", json_path.display());
let raw = fs::read_to_string(&json_path).unwrap_or_else(|e| {
panic!(
"sylpheed-xexdb: cannot read the export table at {} ({e}).\n\
Import names would all resolve to None and the database would be \
silently poorer, so this is a hard error rather than a warning.",
json_path.display()
)
});
let doc: serde_json::Value =
serde_json::from_str(&raw).expect("export table is not valid JSON");
let out = Path::new(&env::var("OUT_DIR").unwrap()).join("ordinals.rs");
let mut f = fs::File::create(&out).unwrap();
writeln!(
f,
"/// Auto-generated from `docs/reference/xbox360-exports.json`."
)
.unwrap();
writeln!(
f,
"pub fn resolve_ordinal(lib: &str, ordinal: u16) -> Option<&'static str> {{"
)
.unwrap();
writeln!(f, " match lib {{").unwrap();
let mut total = 0usize;
for (module, file) in [
("xboxkrnl", "xboxkrnl.exe"),
("xam", "xam.xex"),
("xbdm", "xbdm.xex"),
] {
writeln!(f, " \"{file}\" => match ordinal {{").unwrap();
let exports = doc["modules"][module]["exports"]
.as_array()
.unwrap_or_else(|| panic!("no exports array for module {module}"));
for e in exports {
let (Some(ord), Some(name)) = (e["ordinal"].as_u64(), e["name"].as_str()) else {
continue;
};
if ord > u16::MAX as u64 {
continue;
}
writeln!(f, " 0x{ord:04X} => Some(\"{name}\"),").unwrap();
total += 1;
}
writeln!(f, " _ => None,\n }},").unwrap();
}
writeln!(f, " _ => None,\n }}\n}}").unwrap();
// A count in the build log, so "the table is there" is observable rather
// than assumed. 2,913 is what Phase 2 adopted.
println!("cargo:warning=sylpheed-xexdb: {total} ordinals from xbox360-exports.json");
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
//! MSVC C++ name demangling for Xbox 360 binaries.
//!
//! Wraps [`msvc_demangler::demangle`] (a Rust port of LLVM's
//! `MicrosoftDemangle.cpp`) and splits the resulting human-readable string
//! into structured fields (namespace path, class name, method name, params
//! signature) for storage in the `demangled_names` DB table.
//!
//! The structured split is heuristic — it operates on the formatted output,
//! not the parsed AST. This is good enough for typical RTTI strings of the
//! form `?AVClassName@Namespace@@` and standard member functions; exotic
//! template / lambda forms degrade gracefully (the structured fields end up
//! `None` while `raw_demangled` retains the full LLVM-style output).
//!
//! Reference: <https://docs.rs/msvc-demangler> (LLVM `MicrosoftDemangle.cpp` port).
use msvc_demangler::DemangleFlags;
/// Structured view of one demangled MSVC symbol.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Demangled {
/// Original mangled string.
pub mangled: String,
/// Full LLVM-style demangled output (e.g. `xe::apu::AudioSystem::Setup(void)`).
pub raw_demangled: String,
/// `::`-joined namespace path leading up to the class, e.g. `xe::apu`. None
/// when the symbol is at global scope.
pub namespace_path: Option<String>,
/// Class name for member functions, e.g. `AudioSystem`. None when the
/// symbol is a free function.
pub class_name: Option<String>,
/// Method or free-function name, e.g. `Setup`. None when the heuristic
/// could not separate the name from the rest of the demangled string.
pub method_name: Option<String>,
/// Parameter signature without the surrounding parens, e.g. `void` or
/// `int, char *`. None when not a function or no `(...)` was found.
pub params_signature: Option<String>,
}
/// Demangle one mangled MSVC C++ symbol. Returns `None` if the input does not
/// start with `?` (early-out for non-mangled names) OR if the underlying
/// demangler fails to parse it. Callers that want a "best effort" record
/// (NULL fields + raw=mangled) should use [`demangle_or_raw`] instead.
pub fn demangle(mangled: &str) -> Option<Demangled> {
if !mangled.starts_with('?') {
return None;
}
let raw = msvc_demangler::demangle(mangled, DemangleFlags::llvm()).ok()?;
Some(split_structured(mangled.to_string(), raw))
}
/// Demangle, or fall back to a record that just carries the original mangled
/// string in `raw_demangled` and leaves all structured fields `None`. Useful
/// for DB insert paths that want one row per mangled input regardless of
/// parser success.
pub fn demangle_or_raw(mangled: &str) -> Demangled {
if let Some(d) = demangle(mangled) {
return d;
}
Demangled {
mangled: mangled.to_string(),
raw_demangled: mangled.to_string(),
namespace_path: None,
class_name: None,
method_name: None,
params_signature: None,
}
}
/// Split a fully-formatted demangled string into structured fields.
///
/// Strategy:
/// 1. Find the first un-nested `(` — everything before it is the qualified
/// name; everything inside the matching parens is `params_signature`.
/// 2. Strip leading return-type tokens before the qualified name (everything
/// up to the LAST whitespace not inside `<...>` or `(...)` brackets).
/// 3. Split the qualified name on `::` (top-level only) — last segment is
/// `method_name`, second-to-last is `class_name`, the rest joined back
/// with `::` is `namespace_path`.
fn split_structured(mangled: String, raw: String) -> Demangled {
let raw_view = raw.as_str();
let (qualified_name, params) = match find_paren_split(raw_view) {
Some((before, inside)) => (before.trim_end().to_string(), Some(inside.to_string())),
None => (raw_view.to_string(), None),
};
// Drop any return-type prefix: keep everything after the last top-level
// whitespace boundary (where "top-level" means depth-0 in <...>/(...)).
let qname_clean = strip_return_type_prefix(&qualified_name);
let (namespace_path, class_name, method_name) = split_qname(&qname_clean);
Demangled {
mangled,
raw_demangled: raw,
namespace_path,
class_name,
method_name,
params_signature: params,
}
}
/// Returns `(text_before_paren, text_inside_outer_parens)` for the first
/// top-level `(` in `s`. Returns `None` when no top-level paren is present.
fn find_paren_split(s: &str) -> Option<(&str, &str)> {
let bytes = s.as_bytes();
let mut depth_angle: i32 = 0;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'<' => depth_angle += 1,
b'>' if depth_angle > 0 => depth_angle -= 1,
b'(' if depth_angle == 0 => {
// Find matching close at depth 0 on parens.
let mut depth_paren = 1i32;
let mut depth_angle2 = 0i32;
for (j, &b2) in bytes.iter().enumerate().skip(i + 1) {
match b2 {
b'<' => depth_angle2 += 1,
b'>' if depth_angle2 > 0 => depth_angle2 -= 1,
b'(' => depth_paren += 1,
b')' => {
depth_paren -= 1;
if depth_paren == 0 {
return Some((&s[..i], &s[i + 1..j]));
}
}
_ => {}
}
}
return None;
}
_ => {}
}
}
None
}
/// Strip a leading return-type token (everything up to and including the
/// last top-level whitespace). E.g. `void __cdecl Foo::Bar` → `Foo::Bar`.
fn strip_return_type_prefix(s: &str) -> String {
let bytes = s.as_bytes();
let mut depth_angle: i32 = 0;
let mut depth_paren: i32 = 0;
let mut last_ws_at: Option<usize> = None;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'<' => depth_angle += 1,
b'>' if depth_angle > 0 => depth_angle -= 1,
b'(' => depth_paren += 1,
b')' if depth_paren > 0 => depth_paren -= 1,
b' ' if depth_angle == 0 && depth_paren == 0 => last_ws_at = Some(i),
_ => {}
}
}
match last_ws_at {
Some(i) => s[i + 1..].to_string(),
None => s.to_string(),
}
}
/// Split a fully-qualified name on top-level `::` and tag the parts.
fn split_qname(qname: &str) -> (Option<String>, Option<String>, Option<String>) {
if qname.is_empty() {
return (None, None, None);
}
let parts = top_level_split_colon_colon(qname);
match parts.len() {
0 => (None, None, None),
1 => (None, None, Some(parts[0].clone())),
2 => (None, Some(parts[0].clone()), Some(parts[1].clone())),
_ => {
let n = parts.len();
let method = parts[n - 1].clone();
let class = parts[n - 2].clone();
let ns = parts[..n - 2].join("::");
(Some(ns), Some(class), Some(method))
}
}
}
/// Split on top-level `::` — `::` inside `<...>` or `(...)` is preserved.
fn top_level_split_colon_colon(s: &str) -> Vec<String> {
let bytes = s.as_bytes();
let mut depth_angle: i32 = 0;
let mut depth_paren: i32 = 0;
let mut out: Vec<String> = Vec::new();
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
match b {
b'<' => depth_angle += 1,
b'>' if depth_angle > 0 => depth_angle -= 1,
b'(' => depth_paren += 1,
b')' if depth_paren > 0 => depth_paren -= 1,
b':' if depth_angle == 0
&& depth_paren == 0
&& i + 1 < bytes.len()
&& bytes[i + 1] == b':' =>
{
out.push(s[start..i].to_string());
start = i + 2;
i += 2;
continue;
}
_ => {}
}
i += 1;
}
out.push(s[start..].to_string());
out.into_iter().filter(|p| !p.is_empty()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn early_out_on_non_mangled() {
assert!(demangle("plain_c_name").is_none());
assert!(demangle("Foo::Bar").is_none());
}
#[test]
fn demangle_or_raw_records_failures() {
let d = demangle_or_raw("not_mangled");
assert_eq!(d.mangled, "not_mangled");
assert_eq!(d.raw_demangled, "not_mangled");
assert!(d.method_name.is_none());
}
#[test]
fn simple_member_function() {
// ?Setup@AudioSystem@apu@xe@@QEAAXXZ → public: __cdecl xe::apu::AudioSystem::Setup(void)
let d = demangle("?Setup@AudioSystem@apu@xe@@QEAAXXZ").expect("should parse");
assert_eq!(d.method_name.as_deref(), Some("Setup"));
assert_eq!(d.class_name.as_deref(), Some("AudioSystem"));
assert_eq!(d.namespace_path.as_deref(), Some("xe::apu"));
assert_eq!(d.params_signature.as_deref(), Some("void"));
}
#[test]
fn rtti_type_descriptor_string() {
// RTTI TypeDescriptor mangled name format: ".?AVClassName@@" → "class ClassName".
// We strip the leading "." and call demangle on the "?AV…" part below in M3.
// For now confirm the demangler handles the minimal class form.
let d = demangle("?AVAudioSystem@apu@xe@@").expect("should parse");
assert!(
d.raw_demangled.contains("AudioSystem"),
"raw='{}'",
d.raw_demangled
);
}
#[test]
fn split_qname_handles_namespace_chain() {
let (ns, cls, m) = split_qname("a::b::c::Klass::method");
assert_eq!(ns.as_deref(), Some("a::b::c"));
assert_eq!(cls.as_deref(), Some("Klass"));
assert_eq!(m.as_deref(), Some("method"));
}
#[test]
fn paren_split_handles_template_in_args() {
// Templates inside the param list must not confuse paren matching.
let s = "void __cdecl Foo::Bar(std::vector<int>, std::map<a, b>)";
let (before, inside) = find_paren_split(s).expect("paren found");
assert_eq!(before, "void __cdecl Foo::Bar");
assert_eq!(inside, "std::vector<int>, std::map<a, b>");
}
#[test]
fn double_colon_inside_template_not_split() {
let parts = top_level_split_colon_colon("a::b<c::d>::e");
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

@@ -0,0 +1,186 @@
//! Analysis-side enrichment over [`sylpheed_ppc::disasm::iter_disasm`].
//!
//! Turns a stream of decoder-only [`sylpheed_ppc::disasm::DisasmItem`]s into a
//! stream of [`RichDisasmItem`]s carrying section name + enclosing function +
//! label name. The three sinks in [`crate::sinks`] (text, JSON, DuckDB) all
//! consume `RichDisasmItem`.
use std::collections::{BTreeSet, HashMap};
use sylpheed_ppc::disasm::DisasmItem;
use crate::func::FuncAnalysis;
/// `DisasmItem` plus the analysis context (section/function/label).
#[derive(Debug, Clone)]
pub struct RichDisasmItem<'a> {
pub item: DisasmItem,
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,
/// enclosing function, and label-at-address.
///
/// `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,
section_name: &'a str,
va_start: u32,
va_end: u32,
func_analysis: &'a FuncAnalysis,
labels: &'a HashMap<u32, String>,
data_words: &'a BTreeSet<u32>,
) -> impl Iterator<Item = RichDisasmItem<'a>> + 'a {
// (start, end) of the function currently being walked.
let mut current: Option<(u32, u32)> = None;
sylpheed_ppc::disasm::iter_disasm(image, image_base, va_start, va_end).map(move |item| {
// 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

@@ -0,0 +1,332 @@
//! M9.5 — MSVC `__CxxFrameHandler` scope-table parsing.
//!
//! When MSVC compiles C++ try/catch on Win32 PowerPC, the compiler emits
//! per-function `FuncInfo` records in `.rdata` containing the scope-state
//! tables that `__CxxFrameHandler` walks during unwinding. Each record
//! starts with one of the documented magic numbers:
//!
//! - `0x19930520` — original FuncInfo (no aligned-state-array)
//! - `0x19930521` — adds `pESTypeList` field
//! - `0x19930522` — adds `EHFlags` field
//!
//! Layout (4-byte little-endian on x86; **on Xbox 360 PowerPC PE the
//! struct is big-endian** because the binary is BE throughout):
//!
//! ```text
//! +0x00 uint32 magicNumber (one of 0x199305{20,21,22})
//! +0x04 int32 maxState (number of UnwindMapEntry rows)
//! +0x08 uint32 pUnwindMap (VA → UnwindMapEntry[])
//! +0x0C uint32 nTryBlocks
//! +0x10 uint32 pTryBlockMap (VA → TryBlockMapEntry[])
//! +0x14 uint32 nIPMapEntries (ignored on x86; present on PPC)
//! +0x18 uint32 pIPtoStateMap (VA → IPtoStateMapEntry[])
//! +0x1C uint32 pESTypeList (only when magic ≥ 0x19930521)
//! +0x20 uint32 EHFlags (only when magic = 0x19930522)
//! ```
//!
//! Each `UnwindMapEntry` is 8 bytes: `(toState i32, action u32)`.
//! Each `TryBlockMapEntry` is 20 bytes:
//! `(tryLow i32, tryHigh i32, catchHigh i32, nCatches u32, pHandlerArray u32)`.
//!
//! ### What this module does
//!
//! - Magic-scan `.rdata` for the three FuncInfo signatures (read as BE u32).
//! - Parse the FuncInfo record + walk the unwind map and try-block map.
//! - Skip records whose internal pointers don't land in valid sections,
//! or whose lengths exceed sane caps.
//!
//! ### What this module does NOT do
//!
//! - Does not associate a FuncInfo back to its owning function. The
//! `bl __CxxFrameHandler` registration would name that linkage, but
//! it requires walking all `has_eh=true` functions' prologues; a
//! future M9.6 can do that. For now the FuncInfo record stands on its
//! own — joins to `functions` by best-effort PC range queries.
//! - Does not parse the `pHandlerArray` per try-block (catch type info).
//!
//! Reference: LLVM `llvm/lib/CodeGen/AsmPrinter/WinException.cpp`,
//! Microsoft openrce.org documentation on FuncInfo.
use sylpheed_xex::pe::PeSection;
const MAGIC_OLD: u32 = 0x1993_0520;
const MAGIC_V21: u32 = 0x1993_0521;
const MAGIC_V22: u32 = 0x1993_0522;
#[derive(Debug, Clone, Copy)]
pub struct UnwindMapEntry {
pub to_state: i32,
pub action_pc: u32, // VA of the cleanup action; 0 if none
}
#[derive(Debug, Clone, Copy)]
pub struct TryBlockMapEntry {
pub try_low: i32,
pub try_high: i32,
pub catch_high: i32,
pub n_catches: u32,
pub p_handler_array: u32,
}
#[derive(Debug, Clone)]
pub struct EhFuncInfo {
pub address: u32, // VA of the FuncInfo record itself
pub magic: u32,
pub max_state: i32,
pub p_unwind_map: u32,
pub n_try_blocks: u32,
pub p_try_block_map: u32,
pub n_ip_map_entries: u32,
pub p_ip_to_state_map: u32,
pub p_es_type_list: Option<u32>,
pub eh_flags: Option<u32>,
pub unwind_map: Vec<UnwindMapEntry>,
pub try_blocks: Vec<TryBlockMapEntry>,
}
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(pe: &[u8], image_base: u32, sections: &[PeSection]) -> Vec<EhFuncInfo> {
let started = std::time::Instant::now();
let mut out: Vec<EhFuncInfo> = Vec::new();
// Compute the union of valid VA ranges across all sections — used to
// sanity-check internal pointers in the FuncInfo records.
let valid_ranges: Vec<(u32, u32)> = sections
.iter()
.map(|s| {
(
image_base + s.virtual_address,
image_base + s.virtual_address + s.virtual_size,
)
})
.collect();
let in_valid = |va: u32| valid_ranges.iter().any(|(lo, hi)| va >= *lo && va < *hi);
let read_u32 = |abs: u32| -> Option<u32> {
let off = abs.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
};
let read_i32 = |abs: u32| -> Option<i32> { read_u32(abs).map(|u| u as i32) };
for section in sections {
if section.name != ".rdata" {
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())];
let va_base = image_base + section.virtual_address;
// Walk on 4-byte alignment looking for the magic.
let mut i = 0;
while i + 4 <= bytes.len() {
if !i.is_multiple_of(4) {
i += 1;
continue;
}
let m = u32::from_be_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
if m == MAGIC_OLD || m == MAGIC_V21 || m == MAGIC_V22 {
let addr = va_base + i as u32;
if let Some(rec) = parse_funcinfo(addr, m, &read_u32, &read_i32, &in_valid) {
out.push(rec);
}
}
i += 4;
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
let n_unwind: usize = out.iter().map(|r| r.unwind_map.len()).sum();
let n_try: usize = out.iter().map(|r| r.try_blocks.len()).sum();
metrics::histogram!("analysis.phase_ms", "phase" => "eh_scope").record(elapsed_ms);
tracing::info!(
records = out.len(),
unwind_entries = n_unwind,
try_blocks = n_try,
elapsed_ms,
"M9.5 EH scope-table scan complete",
);
out
}
fn parse_funcinfo(
addr: u32,
magic: u32,
read_u32: &impl Fn(u32) -> Option<u32>,
read_i32: &impl Fn(u32) -> Option<i32>,
in_valid: &impl Fn(u32) -> bool,
) -> Option<EhFuncInfo> {
let max_state = read_i32(addr + 0x04)?;
let p_unwind_map = read_u32(addr + 0x08)?;
let n_try_blocks = read_u32(addr + 0x0C)?;
let p_try_block_map = read_u32(addr + 0x10)?;
let n_ip_map_entries = read_u32(addr + 0x14)?;
let p_ip_to_state_map = read_u32(addr + 0x18)?;
// Sanity caps: real FuncInfo records have max_state ≤ a few thousand,
// n_try_blocks ≤ a few hundred. Reject obviously bogus values that
// happened to alias the magic.
if !(0..=10_000).contains(&max_state) {
return None;
}
if n_try_blocks > 1_000 {
return None;
}
if n_ip_map_entries > 100_000 {
return None;
}
// Pointers must either be NULL or land in a valid section.
if p_unwind_map != 0 && !in_valid(p_unwind_map) {
return None;
}
if p_try_block_map != 0 && !in_valid(p_try_block_map) {
return None;
}
if p_ip_to_state_map != 0 && !in_valid(p_ip_to_state_map) {
return None;
}
let (p_es_type_list, eh_flags) = if magic == MAGIC_V21 {
(read_u32(addr + 0x1C), None)
} else if magic == MAGIC_V22 {
(read_u32(addr + 0x1C), read_u32(addr + 0x20))
} else {
(None, None)
};
// Walk unwind map (8-byte entries).
let mut unwind_map: Vec<UnwindMapEntry> = Vec::with_capacity(max_state as usize);
if p_unwind_map != 0 && max_state > 0 {
for i in 0..max_state {
let p = p_unwind_map.wrapping_add((i * 8) as u32);
let to_state = read_i32(p)?;
let action_pc = read_u32(p + 4)?;
unwind_map.push(UnwindMapEntry {
to_state,
action_pc,
});
}
}
// Walk try-block map (20-byte entries).
let mut try_blocks: Vec<TryBlockMapEntry> = Vec::with_capacity(n_try_blocks as usize);
if p_try_block_map != 0 && n_try_blocks > 0 {
for i in 0..n_try_blocks {
let p = p_try_block_map.wrapping_add(i * 20);
let try_low = read_i32(p)?;
let try_high = read_i32(p + 4)?;
let catch_high = read_i32(p + 8)?;
let n_catches = read_u32(p + 12)?;
let p_handler_a = read_u32(p + 16)?;
try_blocks.push(TryBlockMapEntry {
try_low,
try_high,
catch_high,
n_catches,
p_handler_array: p_handler_a,
});
}
}
Some(EhFuncInfo {
address: addr,
magic,
max_state,
p_unwind_map,
n_try_blocks,
p_try_block_map,
n_ip_map_entries,
p_ip_to_state_map,
p_es_type_list,
eh_flags,
unwind_map,
try_blocks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use sylpheed_xex::pe::PeSection;
fn mk_section(name: &str, va: u32, size: u32) -> PeSection {
PeSection {
name: name.into(),
virtual_address: va,
virtual_size: size,
raw_offset: va,
raw_size: size,
flags: 0x4000_0040,
}
}
fn write_be(pe: &mut [u8], at: usize, v: u32) {
pe[at..at + 4].copy_from_slice(&v.to_be_bytes());
}
fn write_be_i32(pe: &mut [u8], at: usize, v: i32) {
pe[at..at + 4].copy_from_slice(&v.to_be_bytes());
}
#[test]
fn parses_minimal_funcinfo_v0() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let mut pe = vec![0u8; 0x4000];
// FuncInfo at .rdata + 0x10.
let fi_off = (rdata_va + 0x10) as usize;
let fi_va = image_base + rdata_va + 0x10;
let unwind_off = (rdata_va + 0x80) as usize;
let unwind_va = image_base + rdata_va + 0x80;
write_be(&mut pe, fi_off, MAGIC_OLD); // magic
write_be_i32(&mut pe, fi_off + 4, 2); // maxState
write_be(&mut pe, fi_off + 8, unwind_va); // pUnwindMap
write_be(&mut pe, fi_off + 12, 0); // nTryBlocks
write_be(&mut pe, fi_off + 16, 0); // pTryBlockMap
write_be(&mut pe, fi_off + 20, 0); // nIPMapEntries
write_be(&mut pe, fi_off + 24, 0); // pIPtoStateMap
// Two unwind entries.
write_be_i32(&mut pe, unwind_off, -1); // to_state
write_be(&mut pe, unwind_off + 4, image_base + 0x500); // action_pc
write_be_i32(&mut pe, unwind_off + 8, 0);
write_be(&mut pe, unwind_off + 12, image_base + 0x600);
let sections = vec![mk_section(".rdata", rdata_va, 0x100)];
let recs = analyze(&pe, image_base, &sections);
assert_eq!(recs.len(), 1);
let r = &recs[0];
assert_eq!(r.address, fi_va);
assert_eq!(r.magic, MAGIC_OLD);
assert_eq!(r.max_state, 2);
assert_eq!(r.unwind_map.len(), 2);
assert_eq!(r.unwind_map[0].to_state, -1);
assert_eq!(r.unwind_map[0].action_pc, image_base + 0x500);
assert_eq!(r.try_blocks.len(), 0);
}
#[test]
fn rejects_bogus_max_state() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let mut pe = vec![0u8; 0x4000];
let fi_off = (rdata_va + 0x10) as usize;
write_be(&mut pe, fi_off, MAGIC_OLD);
write_be_i32(&mut pe, fi_off + 4, 0xFFFF); // bogus maxState
let sections = vec![mk_section(".rdata", rdata_va, 0x100)];
let recs = analyze(&pe, image_base, &sections);
assert_eq!(recs.len(), 0);
}
}

View File

@@ -0,0 +1,367 @@
//! Assembly text output formatter for Xbox 360 disassembly.
use std::collections::{BTreeSet, HashMap};
use std::io::Write;
use sylpheed_xex::header::ImportLibrary;
use sylpheed_xex::pe::PeSection;
use crate::disasm::enrich_section;
use crate::func::FuncAnalysis;
use crate::sinks::text::write_instr_line;
use crate::xref::{Xref, XrefKind, XrefMap, resolve_source_label};
/// Metadata passed to the formatter (avoids exposing full Xex2Header internals).
pub struct DisasmInfo<'a> {
pub image_base: u32,
pub entry_point: u32,
pub original_pe_name: Option<&'a str>,
pub title_id: Option<u32>,
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 sylpheed_xex::header::Xex2Header>,
}
/// Write full disassembly to the output stream.
pub fn write_asm(
out: &mut dyn Write,
pe: &[u8],
info: &DisasmInfo,
func_analysis: &FuncAnalysis,
labels: &HashMap<u32, String>,
import_map: &HashMap<u32, String>,
xrefs: &XrefMap,
data_annotations: &HashMap<u32, (u32, XrefKind)>,
data_words: &BTreeSet<u32>,
) -> anyhow::Result<()> {
// Header
writeln!(
out,
"; ============================================================================"
)?;
writeln!(out, "; Xbox 360 Disassembly — generated by xenia-rs")?;
if let Some(name) = info.original_pe_name {
writeln!(out, "; Original PE: {name}")?;
}
if let (Some(title_id), Some(media_id)) = (info.title_id, info.media_id) {
writeln!(
out,
"; Title ID: 0x{title_id:08X} Media ID: 0x{media_id:08X}"
)?;
}
writeln!(
out,
"; Image base: 0x{:08X} Entry point: 0x{:08X}",
info.image_base, info.entry_point
)?;
writeln!(
out,
"; Functions detected: {}",
func_analysis.functions.len()
)?;
writeln!(
out,
"; ============================================================================"
)?;
writeln!(out)?;
// Import declarations
if !info.import_libraries.is_empty() {
writeln!(
out,
"; ── Imports ─────────────────────────────────────────────────────────────────"
)?;
for lib in info.import_libraries {
writeln!(out, "; Library: {}", lib.name)?;
for imp in &lib.imports {
let resolved = crate::resolve_ordinal(&lib.name, imp.ordinal);
let name = resolved.unwrap_or("???");
let kind = if imp.record_type == 1 { "thunk" } else { "var" };
writeln!(
out,
"; [{kind}] 0x{:08X} ordinal 0x{:04X} = {}",
imp.address, imp.ordinal, name
)?;
}
}
writeln!(out)?;
}
// Disassemble each section
for section in info.sections {
writeln!(
out,
"; ── Section: {:8} VA=0x{:08X} Size=0x{:08X} Flags=0x{:08X} ──",
section.name, section.virtual_address, section.virtual_size, section.flags
)?;
let va_start = section.virtual_address;
let va_end = va_start + section.virtual_size;
let file_start = section.virtual_address as usize;
// Pre-sort data labels in this section for break-at-label hex dump
let section_labels_sorted: Vec<u32> = if !section.is_code() {
let sec_start = info.image_base + va_start;
let sec_end = info.image_base + va_end;
let mut addrs: Vec<u32> = labels
.keys()
.filter(|&&a| a >= sec_start && a < sec_end)
.copied()
.collect();
addrs.sort();
addrs
} else {
Vec::new()
};
if section.is_code() {
writeln!(out, ".text")?;
writeln!(out)?;
let mut in_function = false;
let abs_start = info.image_base + va_start;
let abs_end = info.image_base + va_end;
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;
// Function start? Emit separator + header
if let Some(fi) = func_analysis.get(abs_addr) {
if in_function {
writeln!(out, "; end function")?;
}
writeln!(out)?;
writeln!(
out,
"; ──────────────────────────────────────────────────────────────────────────"
)?;
let lbl = labels
.get(&abs_addr)
.cloned()
.unwrap_or_else(|| format!("sub_{abs_addr:08X}"));
if fi.is_saverestore {
writeln!(out, "; FUNCTION: {lbl} (save/restore GPR helper)")?;
} else if fi.is_leaf {
writeln!(out, "; FUNCTION: {lbl} (leaf)")?;
} else {
let mut details = Vec::new();
if fi.frame_size > 0 {
details.push(format!("frame={}", fi.frame_size));
}
if fi.saved_gprs > 0 {
let first_reg = 32 - fi.saved_gprs;
details.push(format!("saves r{first_reg}-r31"));
}
let detail_str = if details.is_empty() {
String::new()
} else {
format!(" ({})", details.join(", "))
};
writeln!(out, "; FUNCTION: {lbl}{detail_str}")?;
}
if let Some(xref_lines) = format_xrefs(abs_addr, xrefs, func_analysis, labels) {
for line in &xref_lines {
writeln!(out, "{line}")?;
}
}
writeln!(
out,
"; ──────────────────────────────────────────────────────────────────────────"
)?;
in_function = true;
}
// Label
if let Some(lbl) = labels.get(&abs_addr) {
if !func_analysis.is_function_start(abs_addr) {
writeln!(out)?;
if let Some(xref_lines) =
format_xrefs(abs_addr, xrefs, func_analysis, labels)
{
for line in &xref_lines {
writeln!(out, "{line}")?;
}
}
writeln!(out, "{lbl}:")?;
} else {
writeln!(out)?;
writeln!(out, "{lbl}:")?;
}
}
// Import thunk annotation
if let Some(imp_name) = import_map.get(&abs_addr) {
writeln!(out, " ; IMPORT: {imp_name}")?;
}
let data_annot = data_annotations.get(&abs_addr).copied();
write_instr_line(out, &ri, labels, info.sections, info.image_base, data_annot)?;
}
if in_function {
writeln!(out, "; end function")?;
}
} else {
// Data section: hex dump
writeln!(out, ".data")?;
writeln!(out)?;
let mut addr = va_start;
while addr < va_end {
let abs_addr = info.image_base + addr;
let off = (addr - va_start) as usize + file_start;
if let Some(lbl) = labels.get(&abs_addr) {
writeln!(out)?;
// Xrefs for data labels
if let Some(xref_lines) = format_xrefs(abs_addr, xrefs, func_analysis, labels) {
for line in &xref_lines {
writeln!(out, "{line}")?;
}
}
writeln!(out, "{lbl}:")?;
}
// Emit up to 16 bytes per line, but break at label boundaries
let mut line_end = std::cmp::min(addr + 16, va_end);
for &lbl_addr in &section_labels_sorted {
let lbl_va = lbl_addr - info.image_base;
if lbl_va > addr && lbl_va < line_end {
line_end = lbl_va;
break;
}
if lbl_va >= line_end {
break;
}
}
let byte_count = (line_end - addr) as usize;
if off + byte_count > pe.len() {
break;
}
write!(out, " {:08X}: ", abs_addr)?;
for i in 0..byte_count {
write!(out, "{:02X}", pe[off + i])?;
if i % 4 == 3 {
write!(out, " ")?;
}
}
// ASCII representation
let pad = (16 - byte_count) * 2 + (16 - byte_count) / 4;
write!(out, "{:>width$} |", "", width = pad)?;
for i in 0..byte_count {
let b = pe[off + i];
let ch = if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'.'
};
write!(out, "{ch}")?;
}
writeln!(out, "|")?;
addr = line_end;
}
}
writeln!(out)?;
}
Ok(())
}
const XREF_DISPLAY_LIMIT: usize = 8;
fn format_xrefs(
target: u32,
xrefs: &XrefMap,
func_analysis: &FuncAnalysis,
labels: &HashMap<u32, String>,
) -> Option<Vec<String>> {
let refs = xrefs.get(&target)?;
if refs.is_empty() {
return None;
}
let mut sorted: Vec<Xref> = refs.clone();
sorted.sort();
sorted.dedup();
let total = sorted.len();
let mut lines = Vec::new();
let calls = sorted.iter().filter(|x| x.kind == XrefKind::Call).count();
let jumps = sorted.iter().filter(|x| x.kind == XrefKind::Jump).count();
let branches = sorted.iter().filter(|x| x.kind == XrefKind::Branch).count();
let reads = sorted
.iter()
.filter(|x| x.kind == XrefKind::DataRead)
.count();
let writes = sorted
.iter()
.filter(|x| x.kind == XrefKind::DataWrite)
.count();
let data_refs = sorted
.iter()
.filter(|x| x.kind == XrefKind::DataRef)
.count();
let mut summary_parts = Vec::new();
if calls > 0 {
summary_parts.push(format!("{calls} call{}", if calls != 1 { "s" } else { "" }));
}
if jumps > 0 {
summary_parts.push(format!("{jumps} jump{}", if jumps != 1 { "s" } else { "" }));
}
if branches > 0 {
summary_parts.push(format!(
"{branches} branch{}",
if branches != 1 { "es" } else { "" }
));
}
if reads > 0 {
summary_parts.push(format!("{reads} read{}", if reads != 1 { "s" } else { "" }));
}
if writes > 0 {
summary_parts.push(format!(
"{writes} write{}",
if writes != 1 { "s" } else { "" }
));
}
if data_refs > 0 {
summary_parts.push(format!(
"{data_refs} ref{}",
if data_refs != 1 { "s" } else { "" }
));
}
lines.push(format!("; XREF: {} ({})", summary_parts.join(", "), total));
for (i, xref) in sorted.iter().enumerate() {
if i >= XREF_DISPLAY_LIMIT {
lines.push(format!("; ... and {} more", total - XREF_DISPLAY_LIMIT));
break;
}
let source_label = resolve_source_label(xref.source, func_analysis, labels);
lines.push(format!("; {} from {}", xref.kind.tag(), source_label));
}
Some(lines)
}

View File

@@ -0,0 +1,804 @@
//! Function boundary detection via PPC prologue/epilogue pattern matching.
//!
//! Strategy (multi-pass):
//! 1. Identify all `bl` (branch-and-link) targets — these are call sites,
//! hence very likely function entry points.
//! 2. Scan the save/restore GPR helper region and label it.
//! 3. For each candidate entry, look for prologue patterns:
//! a) `mfspr rN, LR` (typically r0 or r12)
//! b) `bl __savegprlr_NN` (call into save stub)
//! c) `stwu r1, -N(r1)` (allocate stack frame)
//! If a prologue is confirmed, record the function and its stack frame size.
//! 4. Walk forward from each function entry to find the epilogue:
//! a) `blr` (return)
//! b) `b __restgprlr_NN` (tail-branch into restore stub which returns)
//! Mark the function's end address.
//! 5. Detect leaf functions: `bl` targets that lack a prologue but eventually `blr`.
use std::collections::{BTreeMap, HashMap, HashSet};
/// Information about a detected function.
#[derive(Debug, Clone)]
pub struct FuncInfo {
/// Absolute start address.
pub start: u32,
/// Absolute end address (exclusive — one past last instruction).
pub end: u32,
/// Stack frame size (0 if unknown / leaf).
pub frame_size: u32,
/// Number of saved GPRs (via __savegprlr helper), 0 if unknown.
pub saved_gprs: u32,
/// True if this is a leaf function (no bl, no frame setup).
pub is_leaf: bool,
/// True if this is a save/restore GPR helper stub.
pub is_saverestore: bool,
/// True if `.pdata` has a RUNTIME_FUNCTION whose `BeginAddress` matches `start`.
/// Authoritative ground truth from the linker; rows without this flag are
/// prologue-detected only and may carry boundary errors.
pub pdata_validated: bool,
/// 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)
pub has_eh: bool,
}
/// Result of the function analysis pass.
pub struct FuncAnalysis {
/// address → FuncInfo for every detected function, sorted by address.
pub functions: BTreeMap<u32, FuncInfo>,
/// Addresses in the save-GPR region (start of __savegprlr block).
pub save_gpr_base: Option<u32>,
/// Addresses in the restore-GPR region (start of __restgprlr block).
pub restore_gpr_base: Option<u32>,
/// Raw `.pdata` entries from the binary, in original order. Empty when no
/// `.pdata` was supplied. Mirrored into the DB as `pdata_entries`.
pub pdata_entries: Vec<sylpheed_xex::pdata::PdataEntry>,
}
// ── Instruction field helpers ──────────────────────────────────────────────
fn op(instr: u32) -> u32 {
(instr >> 26) & 0x3F
}
fn bits(instr: u32, hi: u32, lo: u32) -> u32 {
(instr >> (31 - hi)) & ((1 << (hi - lo + 1)) - 1)
}
fn is_mfspr_lr(instr: u32) -> Option<u32> {
// mfspr rD, LR → opcode 31, xo=339, spr=8
if op(instr) != 31 {
return None;
}
let xo = bits(instr, 30, 21);
if xo != 339 {
return None;
}
let spr = (bits(instr, 20, 16) << 5) | bits(instr, 15, 11);
if spr != 8 {
return None;
}
Some(bits(instr, 10, 6)) // return rD
}
#[allow(dead_code)]
fn is_mtspr_lr(instr: u32) -> bool {
// mtspr LR, rS → opcode 31, xo=467, spr=8
if op(instr) != 31 {
return false;
}
let xo = bits(instr, 30, 21);
if xo != 467 {
return false;
}
let spr = (bits(instr, 20, 16) << 5) | bits(instr, 15, 11);
spr == 8
}
fn is_stwu_r1(instr: u32) -> Option<i32> {
// stwu r1, d(r1) → opcode 37, rS=1, rA=1
if op(instr) != 37 {
return None;
}
let rs = bits(instr, 10, 6);
let ra = bits(instr, 15, 11);
if rs != 1 || ra != 1 {
return None;
}
let d = ((instr & 0xFFFF) as i16) as i32;
Some(d) // negative = frame allocation
}
fn is_blr(instr: u32) -> bool {
instr == 0x4E800020
}
fn is_bctr(instr: u32) -> bool {
instr == 0x4E800420
}
fn is_bl(instr: u32) -> Option<u32> {
// bl target → opcode 18, LK=1, AA=0
if op(instr) != 18 {
return None;
}
if instr & 1 == 0 {
return None;
} // must have LK bit
if instr & 2 != 0 {
return None;
} // not absolute
// Return the signed offset
let li = instr & 0x03FFFFFC;
Some(li)
}
fn is_b(instr: u32) -> Option<u32> {
// b target → opcode 18, LK=0, AA=0
if op(instr) != 18 {
return None;
}
if instr & 1 != 0 {
return None;
} // no LK bit
if instr & 2 != 0 {
return None;
} // not absolute
Some(instr & 0x03FFFFFC)
}
fn sign_ext26(val: u32) -> i32 {
((val << 6) as i32) >> 6
}
fn bl_target(instr: u32, addr: u32) -> Option<u32> {
is_bl(instr).map(|off| addr.wrapping_add(sign_ext26(off) as u32))
}
fn b_target(instr: u32, addr: u32) -> Option<u32> {
is_b(instr).map(|off| addr.wrapping_add(sign_ext26(off) as u32))
}
// ── Read instruction from PE ───────────────────────────────────────────────
fn read_instr(pe: &[u8], abs_addr: u32, image_base: u32) -> Option<u32> {
let off = abs_addr.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
}
// ── Detect the save/restore GPR helper stubs ───────────────────────────────
//
// These are a well-known pattern emitted by the Xbox 360 linker.
// Save block: a cascade of `std rN, offset(r1)` for r14..r31 + `stw r12, -8(r1)` + `blr`
// Restore: a cascade of `ld rN, offset(r1)` for r14..r31 + `lwz r12, -8(r1)` + `mtspr LR, r12` + `blr`
//
// We detect the save block by finding 18 consecutive `std rN, ...(r1)` instructions
// for r14 through r31.
fn find_saverestore_stubs(
pe: &[u8],
image_base: u32,
code_ranges: &[(u32, u32)], // (abs_start, abs_end)
) -> (Option<u32>, Option<u32>) {
let mut save_base = None;
let mut restore_base = None;
for &(start, end) in code_ranges {
let mut addr = start;
while addr + 4 * 18 < end {
// Check if this is `std r14, ...(r1)` — opcode 62 (std), rS=14, rA=1
let instr = match read_instr(pe, addr, image_base) {
Some(i) => i,
None => {
addr += 4;
continue;
}
};
if op(instr) == 62
&& bits(instr, 10, 6) == 14
&& bits(instr, 15, 11) == 1
&& (instr & 3) == 0
{
// Verify it's a cascade: r14, r15, ..., r31
let mut ok = true;
for i in 0u32..18 {
let check = match read_instr(pe, addr + i * 4, image_base) {
Some(c) => c,
None => {
ok = false;
break;
}
};
if op(check) != 62 || bits(check, 10, 6) != 14 + i || bits(check, 15, 11) != 1 {
ok = false;
break;
}
}
if ok {
save_base = Some(addr);
// Restore block typically follows the save block
// After save: stw r12, -8(r1) + blr, then restore starts
let after_save = addr + 18 * 4 + 8; // skip stw r12 + blr
let check = read_instr(pe, after_save, image_base);
if let Some(c) = check {
// Should be `ld r14, ...(r1)` — opcode 58, rT=14, rA=1
if op(c) == 58 && bits(c, 10, 6) == 14 && bits(c, 15, 11) == 1 {
restore_base = Some(after_save);
}
}
break;
}
}
addr += 4;
}
if save_base.is_some() {
break;
}
}
(save_base, restore_base)
}
// ── Main analysis ──────────────────────────────────────────────────────────
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
entry_point: u32,
code_sections: &[(u32, u32, u32)], // (va_start, va_size, flags)
) -> FuncAnalysis {
analyze_with_pdata(pe, image_base, entry_point, code_sections, &[])
}
/// Same as [`analyze`] but also unions `.pdata` `RUNTIME_FUNCTION` entries
/// into the candidate set. Each surviving function carries `pdata_validated`
/// when its start matches a pdata `BeginAddress`, and `pdata_length` when
/// the linker-supplied length disagrees with the prologue walk.
///
/// Pdata entries that have no prologue match (orphans) are still emitted,
/// using the linker-supplied length to bound the function.
///
/// What this layer does NOT do:
/// - 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],
image_base: u32,
entry_point: u32,
code_sections: &[(u32, u32, u32)],
pdata: &[sylpheed_xex::pdata::PdataEntry],
) -> FuncAnalysis {
let started = std::time::Instant::now();
let code_ranges: Vec<(u32, u32)> = code_sections
.iter()
.map(|(va, sz, _)| (image_base + va, image_base + va + sz))
.collect();
// 1. Find save/restore stubs
let (save_base, restore_base) = find_saverestore_stubs(pe, image_base, &code_ranges);
if let Some(sb) = save_base {
tracing::debug!(addr = format_args!("{:#010x}", sb), "__savegprlr stub");
}
if let Some(rb) = restore_base {
tracing::debug!(addr = format_args!("{:#010x}", rb), "__restgprlr stub");
}
// Set of addresses in the save/restore region (to exclude from function detection)
let mut saverestore_addrs: HashSet<u32> = HashSet::new();
if let Some(sb) = save_base {
// Save block: 18 std + stw + blr = 20 instructions
for i in 0..20 {
saverestore_addrs.insert(sb + i * 4);
}
}
if let Some(rb) = restore_base {
// Restore block: 18 ld + lwz + mtspr + blr = 21 instructions
for i in 0..21 {
saverestore_addrs.insert(rb + i * 4);
}
}
// 2. Collect all bl targets as candidate function entries.
// Union: bl targets pdata BeginAddresses entry_point.
let mut call_targets: HashSet<u32> = HashSet::new();
call_targets.insert(entry_point);
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) = bl_target(instr, addr)
{
// Don't count calls into save/restore stubs as function entries
if !saverestore_addrs.contains(&target) {
call_targets.insert(target);
}
}
addr += 4;
}
}
// Index pdata by begin_address for O(1) prologue → length lookup.
let pdata_by_begin: HashMap<u32, &sylpheed_xex::pdata::PdataEntry> =
pdata.iter().map(|e| (e.begin_address, e)).collect();
for e in pdata {
if !saverestore_addrs.contains(&e.begin_address) {
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(),
tail_call_targets,
"function candidates (bl pdata tail-call)"
);
// 3. For each candidate, detect prologue and walk to epilogue. Pdata
// metadata is layered on after the prologue walk so a missing prologue
// still yields an entry when pdata covers it.
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
for &func_addr in &call_targets {
let pdata_entry = pdata_by_begin.get(&func_addr).copied();
if let Some(mut fi) = analyze_function(
pe,
image_base,
func_addr,
&code_ranges,
save_base,
restore_base,
) {
if let Some(p) = pdata_entry {
fi.pdata_validated = true;
fi.pdata_length = Some(p.function_length);
fi.pdata_prolog_length = Some(p.prolog_length);
// `flags` bit 1 mirrors packed-word bit 31 = exception handler
// registered (see `sylpheed_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;
// 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,
frame_size: 0,
saved_gprs: 0,
// 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,
},
);
}
}
// 4. Label save/restore stubs as special functions — one entry for the whole block
if let Some(sb) = save_base {
// The save block is one cascade: entry at each rN, falls through to blr
// Treat as a single function with the first entry point
let pe_sb = pdata_by_begin.get(&sb).copied();
functions.insert(
sb,
FuncInfo {
start: sb,
end: sb + 20 * 4, // 18 std + stw r12 + blr
frame_size: 0,
saved_gprs: 18,
is_leaf: true,
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),
},
);
}
if let Some(rb) = restore_base {
let pe_rb = pdata_by_begin.get(&rb).copied();
functions.insert(
rb,
FuncInfo {
start: rb,
end: rb + 21 * 4, // 18 ld + lwz r12 + mtspr LR + blr
frame_size: 0,
saved_gprs: 18,
is_leaf: true,
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. 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;
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "functions").record(elapsed_ms);
let pdata_validated_count = functions.values().filter(|f| f.pdata_validated).count();
tracing::info!(
functions = functions.len(),
pdata_entries = pdata.len(),
pdata_validated = pdata_validated_count,
interior_candidates_dropped = interior_dropped,
elapsed_ms,
"function detection complete"
);
FuncAnalysis {
functions,
save_gpr_base: save_base,
restore_gpr_base: restore_base,
pdata_entries: pdata.to_vec(),
}
}
/// 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],
image_base: u32,
func_addr: u32,
code_ranges: &[(u32, u32)],
save_base: Option<u32>,
restore_base: Option<u32>,
) -> Option<FuncInfo> {
// Verify the address is within a code section
let in_code = code_ranges
.iter()
.any(|&(s, e)| func_addr >= s && func_addr < e);
if !in_code {
return None;
}
let instr0 = read_instr(pe, func_addr, image_base)?;
let mut frame_size: u32 = 0;
let mut saved_gprs: u32 = 0;
let mut is_leaf = false;
let mut prologue_len: u32 = 0;
// Pattern A: mfspr rN, LR [+ bl __savegprlr_NN] + stwu r1, -N(r1)
if let Some(_lr_reg) = is_mfspr_lr(instr0) {
prologue_len = 4;
let instr1 = read_instr(pe, func_addr + 4, image_base).unwrap_or(0);
// Check if next is bl to save stub
if let Some(target) = bl_target(instr1, func_addr + 4)
&& let Some(sb) = save_base
&& target >= sb
&& target < sb + 18 * 4
{
let idx = (target - sb) / 4;
saved_gprs = 18 - idx;
prologue_len = 8;
}
// Next should be stwu r1, -N(r1)
let stwu_instr = read_instr(pe, func_addr + prologue_len, image_base).unwrap_or(0);
if let Some(d) = is_stwu_r1(stwu_instr) {
frame_size = (-d) as u32;
prologue_len += 4;
}
}
// Pattern B: stwu r1, -N(r1) without mfspr (rare but possible for leaf-ish functions)
else if let Some(d) = is_stwu_r1(instr0) {
frame_size = (-d) as u32;
prologue_len = 4;
is_leaf = true; // no LR save = likely leaf (or uses CTR)
}
// Pattern C: no prologue — leaf function, just code until blr
else {
is_leaf = true;
}
// Walk forward to find the end of the function
let max_range = code_ranges
.iter()
.find(|&&(s, e)| func_addr >= s && func_addr < e)
.map(|&(_, e)| e)
.unwrap_or(func_addr + 0x100000);
let mut end_addr = func_addr + 4;
let mut addr = func_addr + prologue_len;
let scan_limit = std::cmp::min(addr + 0x100000, max_range); // 1MB max function
while addr < scan_limit {
let instr = match read_instr(pe, addr, image_base) {
Some(i) => i,
None => break,
};
// Epilogue: blr
if is_blr(instr) {
end_addr = addr + 4;
// Check if the instruction after blr looks like padding or another function
// Sometimes there's trailing data after blr; we stop at the first blr
// that isn't inside a branch-over pattern
break;
}
// Epilogue: b __restgprlr_NN (tail branch into restore stub)
if let Some(target) = b_target(instr, addr)
&& let Some(rb) = restore_base
&& target >= rb
&& target < rb + 18 * 4
{
end_addr = addr + 4;
break;
}
// Epilogue: bctr (indirect tail call — end of function)
if is_bctr(instr) {
end_addr = addr + 4;
break;
}
addr += 4;
}
// If we didn't find any epilogue within a reasonable range, still emit
// the function but mark end at the scan point
if end_addr <= func_addr + 4 && prologue_len > 0 {
end_addr = addr;
}
// Don't emit zero-size "functions" for addresses that are just data
if end_addr <= func_addr + 4 && prologue_len == 0 {
return None;
}
Some(FuncInfo {
start: func_addr,
end: end_addr,
frame_size,
saved_gprs,
is_leaf,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
})
}
// ── Label generation ───────────────────────────────────────────────────────
impl FuncAnalysis {
/// Generate labels for all detected functions.
/// Call targets with confirmed prologues get `sub_XXXXXXXX`.
/// Save/restore entries get `__savegprlr_NN` / `__restgprlr_NN`.
pub fn generate_labels(&self) -> HashMap<u32, String> {
let mut labels = HashMap::new();
for (&addr, fi) in &self.functions {
if fi.is_saverestore {
// Label the block start, plus individual register entry points
if let Some(sb) = self.save_gpr_base
&& addr == sb
{
for i in 0u32..18 {
let reg = 14 + i;
labels.insert(sb + i * 4, format!("__savegprlr_{reg}"));
}
continue;
}
if let Some(rb) = self.restore_gpr_base
&& addr == rb
{
for i in 0u32..18 {
let reg = 14 + i;
labels.insert(rb + i * 4, format!("__restgprlr_{reg}"));
}
continue;
}
}
labels.insert(addr, format!("sub_{addr:08X}"));
}
labels
}
/// Returns true if `addr` is the start of a detected function.
pub fn is_function_start(&self, addr: u32) -> bool {
self.functions.contains_key(&addr)
}
/// Get info for the function starting at `addr`.
pub fn get(&self, addr: u32) -> Option<&FuncInfo> {
self.functions.get(&addr)
}
}

View File

@@ -0,0 +1,279 @@
//! Generic function-pointer array detection (M8 + M11).
//!
//! M3 already detects "vtable" candidates — runs of ≥3 contiguous function
//! pointers in `.rdata` / `.data` (with COL/RTTI walk on top). This module
//! widens the net:
//!
//! - **Dispatch tables** (M8): runs of ≥2 function pointers in `.rdata` /
//! `.data` that are NOT already classified as vtables. Captures switch
//! jump tables, callback registries, command tables, gameplay state
//! machines, etc.
//! - **Static initialiser tables** (M11): function-pointer arrays in
//! `.rdata` whose entries all have classic constructor-like prologues
//! (small frame; either leaf or calling well-known runtime helpers).
//! The MSVC convention names the bracketing symbols `__xc_a` /
//! `__xc_z` (C++ ctors) and `__xi_a` / `__xi_z` (C runtime), but the
//! names are stripped from Sylpheed; we classify by structure.
//!
//! All findings are written to a single `function_pointer_arrays` table
//! with a `kind` column — `"vtable"`, `"dispatch_table"`, or `"static_init"`.
//! Vtable rows are duplicated from M3's `vtables` table for join
//! convenience (so a single query covers all classification kinds).
//!
//! ### What this module does NOT do
//!
//! - No alias-based classification — `static_init` is heuristic and may
//! include any function-pointer array near the binary's `__xc_*` region.
//! - Does not parse the bracket symbols' actual addresses — we'd need
//! debug symbols, which Sylpheed doesn't ship.
//! - Two-element runs in `.data` are common false positives (struct fields
//! that happen to alias function entries); we only emit `dispatch_table`
//! rows for `.rdata`.
use std::collections::BTreeSet;
use sylpheed_xex::pe::PeSection;
use crate::vtables::Vtable;
/// One detected function-pointer array.
#[derive(Debug, Clone)]
pub struct FuncPtrArray {
pub address: u32,
pub length: u32,
pub kind: &'static str, // "vtable" | "dispatch_table" | "static_init"
/// Array entries (function VAs).
pub entries: Vec<u32>,
}
/// Run the pass. `vtables` is the M3 result — those addresses are skipped
/// in the dispatch-table scan to avoid duplication. `function_starts` is
/// the M1 corrected function-start set (used to validate that each array
/// entry actually points at a known function).
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
function_starts: &BTreeSet<u32>,
vtables: &[Vtable],
) -> Vec<FuncPtrArray> {
let started = std::time::Instant::now();
let vtable_addrs: BTreeSet<u32> = vtables.iter().map(|v| v.address).collect();
let mut out: Vec<FuncPtrArray> = Vec::new();
// Re-emit vtables in this table for unified-query convenience.
for v in vtables {
out.push(FuncPtrArray {
address: v.address,
length: v.length,
kind: "vtable",
entries: v.methods.clone(),
});
}
// Scan only .rdata for dispatch tables — .data has too many false
// positives from struct fields aliasing function VAs.
for section in sections {
if section.name != ".rdata" {
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())];
let va_base = image_base + section.virtual_address;
let mut i = 0usize;
while i + 8 <= bytes.len() {
if !i.is_multiple_of(4) {
i += 1;
continue;
}
let mut entries: Vec<u32> = Vec::new();
let mut j = i;
while j + 4 <= bytes.len() {
let val = u32::from_be_bytes([bytes[j], bytes[j + 1], bytes[j + 2], bytes[j + 3]]);
if function_starts.contains(&val) {
entries.push(val);
j += 4;
} else {
break;
}
}
if entries.len() >= 2 {
let address = va_base + (i as u32);
if !vtable_addrs.contains(&address) {
let kind = classify_run(image_base, &entries, pe);
out.push(FuncPtrArray {
address,
length: entries.len() as u32,
kind,
entries,
});
}
i += j - i;
} else {
i += 4;
}
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
let n_vt = out.iter().filter(|a| a.kind == "vtable").count();
let n_dt = out.iter().filter(|a| a.kind == "dispatch_table").count();
let n_si = out.iter().filter(|a| a.kind == "static_init").count();
metrics::histogram!("analysis.phase_ms", "phase" => "funcptr_arrays").record(elapsed_ms);
tracing::info!(
total = out.len(),
vtable = n_vt,
dispatch_table = n_dt,
static_init = n_si,
elapsed_ms,
"function-pointer array scan complete",
);
out
}
/// Classify a non-vtable function-pointer array. Currently distinguishes
/// only "static_init" (all entries have constructor-like prologues — a
/// brief mfspr+stwu prologue with a small frame) from "dispatch_table"
/// (anything else).
fn classify_run(image_base: u32, entries: &[u32], pe: &[u8]) -> &'static str {
// Heuristic: a static initialiser's prologue is small (frame ≤ 0x80,
// typically ≤ 0x40). If every entry's first instruction is mfspr+LR
// (opcode 31, xo 339, spr 8) followed by a small stwu, classify as
// static_init.
let mut all_ctor = true;
let mut any_ctor = false;
for &fn_va in entries {
if !is_ctor_like(pe, image_base, fn_va) {
all_ctor = false;
} else {
any_ctor = true;
}
}
if all_ctor && any_ctor && entries.len() >= 3 {
"static_init"
} else {
"dispatch_table"
}
}
/// True if the function at `fn_va` looks like a tiny C++ static initialiser:
/// `mfspr r12, LR` immediately followed by `stwu r1, -N(r1)` with `N ≤ 0x80`.
fn is_ctor_like(pe: &[u8], image_base: u32, fn_va: u32) -> bool {
let off = fn_va.wrapping_sub(image_base) as usize;
if off + 8 > pe.len() {
return false;
}
let i0 = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]);
let i1 = u32::from_be_bytes([pe[off + 4], pe[off + 5], pe[off + 6], pe[off + 7]]);
// i0: mfspr rD, LR — opcode 31, xo 339, spr 8.
let op0 = i0 >> 26;
let xo0 = (i0 >> 1) & 0x3FF;
let spr0 = (((i0 >> 11) & 0x1F) << 5) | ((i0 >> 16) & 0x1F);
if !(op0 == 31 && xo0 == 339 && spr0 == 8) {
return false;
}
// i1 must be stwu r1, -N(r1) with N ≤ 0x80, OR a `bl __savegprlr_*`
// followed eventually by stwu (full prologue). Allow either.
let op1 = i1 >> 26;
if op1 == 37 {
// stwu D-form: rS=1, rA=1
let rs = (i1 >> 21) & 0x1F;
let ra = (i1 >> 16) & 0x1F;
let d = ((i1 & 0xFFFF) as i16) as i32;
rs == 1 && ra == 1 && d <= 0 && (-d) <= 0x80
} else if op1 == 18 {
// bl __savegprlr_NN — accept; ctor with frame ≤ 0x80 is the
// common case, but if the compiler emits a save-stub call we
// can't easily verify the frame size without walking further.
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use sylpheed_xex::pe::PeSection;
fn mk_section(name: &str, va: u32, size: u32) -> PeSection {
PeSection {
name: name.into(),
virtual_address: va,
virtual_size: size,
raw_offset: va,
raw_size: size,
flags: 0x4000_0040,
}
}
fn write_be_u32(buf: &mut [u8], at: usize, val: u32) {
buf[at..at + 4].copy_from_slice(&val.to_be_bytes());
}
#[test]
fn detects_dispatch_table_in_rdata() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let mut pe = vec![0u8; 0x4000];
// Two consecutive function pointers, no vtable shadowing them.
let pcs = [image_base + 0x2000, image_base + 0x2010];
for (i, p) in pcs.iter().enumerate() {
write_be_u32(&mut pe, rdata_va as usize + i * 4, *p);
}
let sections = vec![mk_section(".rdata", rdata_va, 0x100)];
let mut starts = BTreeSet::new();
for &p in &pcs {
starts.insert(p);
}
let arrs = analyze(&pe, image_base, &sections, &starts, &[]);
assert_eq!(arrs.len(), 1);
assert_eq!(arrs[0].kind, "dispatch_table");
assert_eq!(arrs[0].length, 2);
}
#[test]
fn vtable_overrides_dispatch_classification() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let mut pe = vec![0u8; 0x4000];
let pcs = [
image_base + 0x2000,
image_base + 0x2010,
image_base + 0x2020,
];
for (i, p) in pcs.iter().enumerate() {
write_be_u32(&mut pe, rdata_va as usize + i * 4, *p);
}
let sections = vec![mk_section(".rdata", rdata_va, 0x100)];
let mut starts = BTreeSet::new();
for &p in &pcs {
starts.insert(p);
}
let vt = Vtable {
address: image_base + rdata_va,
length: 3,
col_address: None,
class_name: "ANON_test".into(),
rtti_present: false,
base_classes_json: None,
methods: pcs.to_vec(),
};
let arrs = analyze(&pe, image_base, &sections, &starts, &[vt]);
// Vtable + (no dispatch-table dup): the M3 vtable is re-emitted, but
// the scan also skips the same address from re-classification.
assert_eq!(arrs.len(), 1);
assert_eq!(arrs[0].kind, "vtable");
}
}

View File

@@ -0,0 +1,844 @@
//! M5.5 — `this`-flow indirect-dispatch resolution.
//!
//! M5 only resolved the canonical `lis+addi → lwz off(vt) → mtctr → bcctrl`
//! pattern (vtable address materialised statically; rare in real C++).
//! This layer closes the dominant case, where the dispatch reads through
//! the object's `vptr` field:
//!
//! ```text
//! lwz rVt, vptr_off(this) ; rVt = this->vptr
//! ... ; (rVt not clobbered)
//! lwz rFn, slot*4(rVt) ; rFn = vtable[slot]
//! ... ; (rFn / ctr not clobbered)
//! mtctr rFn
//! ...
//! bcctrl
//! ```
//!
//! Resolution strategy (class-membership inference):
//!
//! 1. **Phase 1 — vptr-write scan.** Walk every function with a tiny
//! register tracker (mirrors the lis+addi propagation in
//! `sylpheed_xexdb::xref`). Whenever a `stw rA, off(rB)` writes a
//! known M3 vtable address into `off(rB)`, record
//! `(vtable_addr, vptr_offset, writer_pc)`. These are constructor-
//! side vptr stores.
//!
//! 2. **Phase 2 — invert by offset.** Build
//! `vtables_by_offset[vptr_off] = set of vtables ever written at
//! that offset`. Most classes use offset 0 (single inheritance);
//! multiple-inheritance secondary vptrs land at non-zero offsets.
//!
//! 3. **Phase 3 — dispatch-site scan.** For each `bcctrl`, walk back
//! up to 16 instructions looking for the canonical sequence,
//! extracting `(vptr_off, slot)`. Bail on any clobber of the
//! tracked register, on any branch instruction, or on a label
//! boundary.
//!
//! 4. **Phase 4 — emit edges.** For each detected
//! `(dispatch_pc, vptr_off, slot)`:
//! - Look up all candidate vtables `V` where:
//! - `vtables_by_offset[vptr_off]` contains `V`, AND
//! - `V.length > slot`
//! - Emit one `ind_call` edge from `dispatch_pc` to
//! `V.methods[slot]` per candidate.
//!
//! Multi-candidate sites are an over-approximation: the analysis can't
//! distinguish without alias info which of the matching classes the
//! `this` register actually holds. Downstream queries can filter by
//! the exposed `candidate_count` column — single-candidate edges are
//! high-confidence, multi-candidate edges are reachability-only.
//!
//! ### What this layer does NOT do
//!
//! - No flow-sensitive analysis: register state is killed at every
//! label (basic-block boundary), and we do not propagate values
//! across calls (since the ABI's volatile/non-volatile partition is
//! unreliable for `this`-pointer chains).
//! - No alias resolution: a multi-candidate site emits one edge per
//! matching vtable, not the exact one used at runtime.
//! - Does not handle vptr writes via X-form indexed stores (`stwx`)
//! or VMX/VMX128 stores — only D-form `stw rA, off(rB)`. The MSVC
//! compiler uses D-form for all canonical vptr writes we've seen.
//! - Does not synthesise vptr writes for inlined / elided constructors.
//! If a class never has a writer at offset `vptr_off`, dispatches
//! through that offset will not find candidates.
//!
//! Reference: IBM PowerPC ABI, Itanium C++ ABI on vtable layout (the
//! same offset-from-`this` model applies on Win32 PPC).
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 {
pub dispatch_pc: u32,
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.
#[derive(Debug, Default)]
pub struct TypedIndirectResult {
pub dispatches: Vec<TypedDispatch>,
/// Phase-1 raw output, exposed for diagnostics.
pub vptr_writes: Vec<VptrWrite>,
}
/// One detected constructor-side vptr write.
#[derive(Debug, Clone, Copy)]
pub struct VptrWrite {
pub vtable_addr: u32,
pub vptr_offset: u32,
pub writer_pc: u32,
pub writer_function: u32,
}
const OP_ADDI: u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_BCCTR: u32 = 19;
const OP_LWZ: u32 = 32;
const OP_ORI: u32 = 24;
const OP_STW: u32 = 36;
const OP_X_FORM: u32 = 31;
/// Run the full M5.5 analysis.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
func_analysis: &FuncAnalysis,
vtables: &[Vtable],
labels: &HashMap<u32, String>,
max_candidates: usize,
) -> TypedIndirectResult {
let started = std::time::Instant::now();
let vtable_addrs: BTreeSet<u32> = vtables.iter().map(|v| v.address).collect();
let vtable_by_addr: BTreeMap<u32, &Vtable> = vtables.iter().map(|v| (v.address, v)).collect();
let block_boundaries: HashSet<u32> = labels.keys().copied().collect();
// Phase 1: scan for vptr writes.
let vptr_writes = scan_vptr_writes(
pe,
image_base,
func_analysis,
&vtable_addrs,
&block_boundaries,
);
// Phase 2: invert by offset.
let mut vtables_by_offset: HashMap<u32, HashSet<u32>> = HashMap::new();
for w in &vptr_writes {
vtables_by_offset
.entry(w.vptr_offset)
.or_default()
.insert(w.vtable_addr);
}
// Phase 3 + 4: scan dispatches and emit edges.
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.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);
tracing::info!(
vptr_writes = vptr_writes.len(),
offsets = vtables_by_offset.len(),
dispatches = dispatches.len(),
single = single_candidate,
multi = multi_candidate,
truncated,
max_candidates,
edges = total_edges,
elapsed_ms,
"M5.5 typed indirect-dispatch scan complete",
);
TypedIndirectResult {
dispatches,
vptr_writes,
}
}
fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option<u32> {
let off = addr.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
}
/// Phase 1 — find every `stw rA, off(rB)` where the lis+addi-tracked
/// value of `rA` equals a known vtable address.
fn scan_vptr_writes(
pe: &[u8],
image_base: u32,
func_analysis: &FuncAnalysis,
vtable_addrs: &BTreeSet<u32>,
block_boundaries: &HashSet<u32>,
) -> Vec<VptrWrite> {
let mut writes: Vec<VptrWrite> = Vec::new();
for (&fn_start, fi) in &func_analysis.functions {
if fi.is_saverestore {
continue;
}
let mut reg: [Option<u32>; 32] = [None; 32];
let mut pc = fn_start;
while pc < fi.end {
if pc != fn_start && block_boundaries.contains(&pc) {
reg = [None; 32];
}
let Some(instr) = read_instr(pe, image_base, pc) else {
break;
};
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
let ra = ((instr >> 16) & 0x1F) as usize;
let simm = ((instr & 0xFFFF) as i16) as i32;
let uimm = instr & 0xFFFF;
match op {
OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16),
OP_ADDIS => {
reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16));
}
OP_ADDI if ra != 0 => {
reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32));
}
OP_ADDI => reg[rd] = Some(simm as u32),
OP_ORI => {
let rs = rd;
reg[ra] = reg[rs].map(|b| b | uimm);
}
OP_STW => {
// `stw rS, off(rA)` — rS in bits 21..25, rA in 16..20.
if ra != 0
&& let Some(vtable_addr) = reg[rd]
&& vtable_addrs.contains(&vtable_addr)
{
// The vptr offset is the displacement; rB's value
// is irrelevant for class-membership inference.
writes.push(VptrWrite {
vtable_addr,
vptr_offset: simm as u32,
writer_pc: pc,
writer_function: fn_start,
});
}
// stw doesn't write to rD.
}
OP_LWZ => reg[rd] = None,
32..=35 | 40..=43 | 48..=51 => reg[rd] = None,
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
if xo != 444 && xo != 467 {
reg[rd] = None;
}
}
18 => {
// `bl` (LK=1) clobbers volatile r0..r12 + ctr. Plain
// `b` makes the next instruction unreachable; the
// label-based reset handles join points.
if (instr & 1) != 0 {
for r in 0..=12 {
reg[r] = None;
}
}
}
16 if (instr & 1) != 0 => {
for r in 0..=12 {
reg[r] = None;
}
}
_ => {}
}
pc = pc.wrapping_add(4);
}
}
writes
}
/// Phase 3 + 4 — scan every `bcctrl`/`bctr` instruction; for each, walk
/// backward up to 16 instructions to find the canonical
/// `lwz vt, vptr_off(this); lwz fn, slot(vt); mtctr fn; bcctrl` sequence.
/// Emit one `TypedDispatch` per dispatch site that resolves to ≥ 1
/// candidate vtable.
fn scan_dispatches_and_resolve(
pe: &[u8],
image_base: u32,
func_analysis: &FuncAnalysis,
block_boundaries: &HashSet<u32>,
vtables_by_offset: &HashMap<u32, HashSet<u32>>,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Vec<TypedDispatch> {
let mut out: Vec<TypedDispatch> = Vec::new();
for (&fn_start, fi) in &func_analysis.functions {
if fi.is_saverestore {
continue;
}
let mut pc = fn_start;
while pc < fi.end {
let Some(instr) = read_instr(pe, image_base, pc) else {
break;
};
let op = instr >> 26;
if op == OP_BCCTR {
let xo = (instr >> 1) & 0x3FF;
let lk = (instr & 1) != 0;
if xo == 528
&& lk
&& let Some(d) = try_resolve_dispatch_site(
pe,
image_base,
fn_start,
fi.end,
pc,
block_boundaries,
vtables_by_offset,
vtable_by_addr,
)
{
out.push(d);
}
}
pc = pc.wrapping_add(4);
}
}
out
}
/// Backwards scan from `bcctrl` at `pc` (looking back at most 16 instrs
/// within the same basic block). Returns `Some(_)` only when the full
/// `lwz vt, off(rA); lwz fn, slot(vt); mtctr fn` chain is present and the
/// `(vptr_off, slot)` pair has at least one candidate vtable.
fn try_resolve_dispatch_site(
pe: &[u8],
image_base: u32,
fn_start: u32,
_fn_end: u32,
bcctrl_pc: u32,
block_boundaries: &HashSet<u32>,
vtables_by_offset: &HashMap<u32, HashSet<u32>>,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<TypedDispatch> {
const LOOKBACK: u32 = 16;
// Walk back 1..LOOKBACK instrs to find `mtctr rFn`.
let mut mtctr_rs: Option<usize> = None;
let mut mtctr_pc: Option<u32> = None;
for i in 1..=LOOKBACK {
let p = bcctrl_pc.wrapping_sub(i * 4);
if p < fn_start {
break;
}
if block_boundaries.contains(&p) {
break;
}
let Some(instr) = read_instr(pe, image_base, p) else {
break;
};
let op = instr >> 26;
if op == OP_X_FORM {
let xo = (instr >> 1) & 0x3FF;
if xo == 467 {
let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F);
if spr == 9 {
mtctr_rs = Some(((instr >> 21) & 0x1F) as usize);
mtctr_pc = Some(p);
break;
}
}
}
}
let mtctr_rs = mtctr_rs?;
let mtctr_pc = mtctr_pc?;
// Walk back from mtctr to find `lwz rFn, slot(rVt)` defining mtctr_rs.
let mut slot: Option<u32> = None;
let mut vt_reg: Option<usize> = None;
let mut fn_lwz_pc: Option<u32> = None;
for i in 1..=LOOKBACK {
let p = mtctr_pc.wrapping_sub(i * 4);
if p < fn_start {
break;
}
if block_boundaries.contains(&p) {
break;
}
let Some(instr) = read_instr(pe, image_base, p) else {
break;
};
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
if op == OP_LWZ {
if rd == mtctr_rs {
let ra = ((instr >> 16) & 0x1F) as usize;
if ra == 0 {
return None;
}
let off = ((instr & 0xFFFF) as i16) as i32;
if off < 0 || (off % 4) != 0 {
return None;
}
slot = Some((off as u32) / 4);
vt_reg = Some(ra);
fn_lwz_pc = Some(p);
break;
}
// Other lwz; if it writes our target reg, it's a clobber, but
// the loop already keys on the lwz that produces the value, so
// no clobber check needed beyond seeing rd == mtctr_rs.
} else if writes_reg(instr, mtctr_rs as u32) {
return None;
}
}
let slot = slot?;
let vt_reg = vt_reg?;
let fn_lwz_pc = fn_lwz_pc?;
// Walk back from the fn-lwz to find `lwz rVt, vptr_off(rThis)` defining vt_reg.
let mut vptr_off: Option<u32> = None;
for i in 1..=LOOKBACK {
let p = fn_lwz_pc.wrapping_sub(i * 4);
if p < fn_start {
break;
}
if block_boundaries.contains(&p) {
break;
}
let Some(instr) = read_instr(pe, image_base, p) else {
break;
};
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
if op == OP_LWZ && rd == vt_reg {
let ra = ((instr >> 16) & 0x1F) as usize;
if ra == 0 {
return None;
}
let off = ((instr & 0xFFFF) as i16) as i32;
// Negative offsets are valid in C++ (multiple inheritance casts
// can produce them in some ABIs); reinterpret as u32 wrap.
vptr_off = Some(off as u32);
break;
}
if writes_reg(instr, vt_reg as u32) {
return None;
}
}
let vptr_off = vptr_off?;
// Phase 4 — resolve to candidate vtables.
let candidates = vtables_by_offset.get(&vptr_off)?;
let mut candidate_vtables: Vec<u32> = Vec::new();
let mut method_pcs: Vec<u32> = Vec::new();
for &vt_addr in candidates {
if let Some(vt) = vtable_by_addr.get(&vt_addr)
&& vt.length > slot
&& let Some(&method_pc) = vt.methods.get(slot as usize)
{
candidate_vtables.push(vt_addr);
method_pcs.push(method_pc);
}
}
if method_pcs.is_empty() {
return None;
}
let total_candidates = candidate_vtables.len();
Some(TypedDispatch {
dispatch_pc: bcctrl_pc,
vptr_offset: vptr_off,
slot,
candidate_vtables,
method_pcs,
total_candidates,
truncated: false,
})
}
/// Conservative "does this instruction write to register `r`" predicate.
/// Used to detect register clobbers between the value-producing lwz and
/// its consumer.
fn writes_reg(instr: u32, r: u32) -> bool {
let op = instr >> 26;
let rd = (instr >> 21) & 0x1F;
let _ra = (instr >> 16) & 0x1F;
match op {
// Most arithmetic / load opcodes use bits 21..25 = rD/rT.
14 | 15 | 32..=43 | 46 | 48..=51 => rd == r,
// ori/oris/xor/etc. opcodes 24..29 — rA in bits 16..20 is the dest.
24..=29 => ((instr >> 16) & 0x1F) == r,
// X-form: most write rD; some write rA. Check both, conservatively.
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
// Logical X-form (and/or/xor/etc.): rA is the dest.
// Logical X-form ops (and/or/xor/etc.) write rA, not rD.
if matches!(
xo,
26 | 28 | 60 | 124 | 284 | 316 | 444 | 476 | 536 | 539 | 922 | 954
) {
((instr >> 16) & 0x1F) == r
} else {
rd == r
}
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::func::FuncInfo;
use std::collections::BTreeMap;
fn mk_vtable(addr: u32, methods: Vec<u32>) -> Vtable {
Vtable {
address: addr,
length: methods.len() as u32,
col_address: None,
class_name: format!("ANON_{addr:08X}"),
rtti_present: false,
base_classes_json: None,
methods,
}
}
fn mk_func_analysis(start: u32, len: u32) -> FuncAnalysis {
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(
start,
FuncInfo {
start,
end: start + len,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
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(),
}
}
fn write_be(pe: &mut [u8], at: usize, v: u32) {
pe[at..at + 4].copy_from_slice(&v.to_be_bytes());
}
/// Encode a vptr-write site: `lis rN, hi(vt); addi rN, rN, lo(vt); stw rN, off(rOther)`.
fn enc_vptr_write(pe: &mut [u8], at: usize, vt: u32, write_off: i16, dest_reg: u32) {
let hi = (vt >> 16) as u16;
let lo = (vt & 0xFFFF) as i16;
let lis = ((15u32 << 26) | (3 << 21)) | (hi as u32);
let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32);
let stw = (36u32 << 26) | (3 << 21) | (dest_reg << 16) | ((write_off as u16) as u32);
write_be(pe, at, lis);
write_be(pe, at + 4, addi);
write_be(pe, at + 8, stw);
}
/// Encode a dispatch site:
/// lwz r4, vptr_off(r3) ; r4 = this->vptr
/// lwz r5, slot*4(r4) ; r5 = vptr[slot]
/// mtctr r5
/// bcctrl
fn enc_dispatch(pe: &mut [u8], at: usize, vptr_off: i16, slot: u32) {
let lwz_vt = (32u32 << 26) | (4 << 21) | (3 << 16) | ((vptr_off as u16) as u32);
let lwz_fn = (32u32 << 26) | (5 << 21) | (4 << 16) | ((slot * 4) & 0xFFFF);
// mtctr r5 = mtspr CTR(=9), r5: SPR_low (=9) → bits 16..20.
let mtctr = (31u32 << 26) | (5 << 21) | (9 << 16) | (467 << 1);
let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1;
write_be(pe, at, lwz_vt);
write_be(pe, at + 4, lwz_fn);
write_be(pe, at + 8, mtctr);
write_be(pe, at + 12, bcctrl);
}
#[test]
fn single_candidate_vtable_resolves_to_one_method() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
// Function A — constructor — at 0x82001000. Writes vt=0x82010000 at off=0.
let ctor_pc = 0x82001000u32;
enc_vptr_write(&mut pe, (ctor_pc - image_base) as usize, 0x82010000, 0, 31);
// Function B — dispatcher — at 0x82002000. Calls slot 2 of vptr at off 0.
let disp_pc = 0x82002000u32;
enc_dispatch(&mut pe, (disp_pc - image_base) as usize, 0, 2);
let bcctrl_pc = disp_pc + 12;
// Both functions in func_analysis (synthesise).
let mut fa = mk_func_analysis(ctor_pc, 0x40);
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,
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, usize::MAX);
assert_eq!(r.vptr_writes.len(), 1);
assert_eq!(r.vptr_writes[0].vtable_addr, 0x82010000);
assert_eq!(r.vptr_writes[0].vptr_offset, 0);
assert_eq!(r.dispatches.len(), 1);
let d = &r.dispatches[0];
assert_eq!(d.dispatch_pc, bcctrl_pc);
assert_eq!(d.vptr_offset, 0);
assert_eq!(d.slot, 2);
assert_eq!(d.method_pcs, vec![0xCC]);
assert_eq!(d.candidate_vtables, vec![0x82010000]);
}
/// 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.
let ctor_a = 0x82001000u32;
enc_vptr_write(&mut pe, (ctor_a - image_base) as usize, 0x82010000, 0, 31);
let ctor_b = 0x82001100u32;
enc_vptr_write(&mut pe, (ctor_b - image_base) as usize, 0x82010040, 0, 31);
// One dispatch at slot 1.
let disp = 0x82002000u32;
enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 1);
let mut fa = mk_func_analysis(ctor_a, 0x40);
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,
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,
pdata_prolog_length: None,
has_eh: false,
},
);
let vts = vec![
mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]),
mk_vtable(0x82010040, vec![0x55, 0x66, 0x77, 0x88]),
];
let labels: HashMap<u32, String> = HashMap::new();
(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);
let d = &r.dispatches[0];
assert_eq!(d.candidate_vtables.len(), 2);
assert!(d.method_pcs.contains(&0x22));
assert!(d.method_pcs.contains(&0x66));
}
#[test]
fn out_of_bounds_slot_yields_no_dispatch() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
let ctor = 0x82001000u32;
enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31);
let disp = 0x82002000u32;
// slot 10 — vtable only has 4 methods.
enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 10);
let mut fa = mk_func_analysis(ctor, 0x40);
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,
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, usize::MAX);
assert_eq!(r.dispatches.len(), 0);
}
#[test]
fn no_writer_at_offset_yields_no_dispatch() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
// ctor writes at offset 0
let ctor = 0x82001000u32;
enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31);
// dispatch reads from offset 8 — no class writes vptr there.
let disp = 0x82002000u32;
enc_dispatch(&mut pe, (disp - image_base) as usize, 8, 1);
let mut fa = mk_func_analysis(ctor, 0x40);
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,
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, 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

@@ -0,0 +1,511 @@
//! Indirect-dispatch reachability for vtable-bound `bcctrl`/`bctrl` sites.
//!
//! Walks each detected function with a tiny per-basic-block register tracker,
//! recognising the canonical MSVC PowerPC pattern that loads a slot from a
//! statically-addressed vtable into CTR and indirectly calls it:
//!
//! ```text
//! lis rA, hi
//! addi rA, rA, lo ; rA = vtable_address
//! lwz rB, slot*4(rA) ; rB = vtable[slot]
//! mtctr rB ; CTR = vtable[slot]
//! bcctrl ; indirect call → vtable[slot]
//! ```
//!
//! Pattern hits are emitted as `(source_pc, target_pc)` pairs that callers
//! insert into the `xrefs` table with `kind='ind_call'`.
//!
//! ### What this does NOT cover
//!
//! - Vtable pointer loaded from a `this`-pointer field (`lwz rA, off(this)`)
//! is the dominant pattern in real C++ code; resolving it requires
//! alias / points-to analysis that's far beyond this layer's scope.
//! - Indirect calls via function-pointer fields (callbacks) are similarly
//! unresolvable without object-flow analysis.
//! - Register state is intentionally killed at every label (basic-block
//! boundary) — we don't try to do flow-sensitive merging across joins.
//!
//! Reference: IBM PowerPC ABI on register-save convention, plus the
//! `sylpheed_xexdb::xref` `lis+addi`/`lis+ori` tracker which we mirror
//! conceptually.
use std::collections::{BTreeMap, HashMap, HashSet};
use crate::func::FuncAnalysis;
use crate::vtables::Vtable;
/// One detected indirect-call edge: `bcctrl` at `source` jumps to `target`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndirectEdge {
pub source: u32,
pub target: u32,
/// Vtable the source resolved through.
pub via_vtable: u32,
/// Method slot index within the vtable.
pub slot: u32,
}
#[derive(Debug, Clone, Copy)]
enum RegVal {
/// Register holds a known constant (e.g. after `lis+addi`).
Const(u32),
/// Register holds a method pointer loaded from a known vtable slot.
MethodPtr {
vtable_addr: u32,
slot: u32,
method_pc: u32,
},
}
const OP_ADDI: u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_BCCTR: u32 = 19; // also covers blr — distinguish via XO
const OP_LWZ: u32 = 32;
const OP_ORI: u32 = 24;
const OP_X_FORM: u32 = 31; // mtspr / mr / etc.
/// Run the static indirect-dispatch scan. Returns one edge per resolvable
/// `bcctrl` site.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
func_analysis: &FuncAnalysis,
vtables: &[Vtable],
labels: &HashMap<u32, String>,
) -> Vec<IndirectEdge> {
let started = std::time::Instant::now();
// Index vtables by their start VA so the lwz handler can decide
// whether a given Const(addr) is "really" a vtable.
let vtable_by_addr: BTreeMap<u32, &Vtable> = vtables.iter().map(|v| (v.address, v)).collect();
// Set of all "label"-bearing PCs in the analyzed binary. We treat each
// label as a basic-block boundary (anything `loc_*` is a jump target,
// so register state arriving at it is unreliable).
let mut block_boundaries: HashSet<u32> = HashSet::with_capacity(labels.len());
for &addr in labels.keys() {
block_boundaries.insert(addr);
}
let mut edges: Vec<IndirectEdge> = Vec::new();
for (&fn_start, fi) in &func_analysis.functions {
if fi.is_saverestore {
continue;
}
let mut reg: [Option<RegVal>; 32] = [None; 32];
let mut ctr: Option<RegVal> = None;
let mut pc = fn_start;
while pc < fi.end {
// Reset register state on basic-block entry. We don't reset on
// the function entry itself (PC == fn_start) because labels and
// function-starts coincide; the initial state is already None.
if pc != fn_start && block_boundaries.contains(&pc) {
reg = [None; 32];
ctr = None;
}
let instr = match read_instr(pe, image_base, pc) {
Some(i) => i,
None => break,
};
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
let ra = ((instr >> 16) & 0x1F) as usize;
let simm = ((instr & 0xFFFF) as i16) as i32;
let uimm = instr & 0xFFFF;
match op {
// lis rD, IMM (== addis rD, r0, IMM)
OP_ADDIS if ra == 0 => {
reg[rd] = Some(RegVal::Const(uimm << 16));
}
// addis rD, rA, IMM
OP_ADDIS => {
if let Some(RegVal::Const(b)) = reg[ra] {
reg[rd] = Some(RegVal::Const(b.wrapping_add(uimm << 16)));
} else {
reg[rd] = None;
}
}
// addi rD, rA, IMM
OP_ADDI if ra != 0 => {
if let Some(RegVal::Const(b)) = reg[ra] {
reg[rd] = Some(RegVal::Const(b.wrapping_add(simm as u32)));
} else {
reg[rd] = None;
}
}
// li rD, IMM (== addi rD, 0, IMM)
OP_ADDI => {
reg[rd] = Some(RegVal::Const(simm as u32));
}
// ori rA, rS, IMM — note operand order: bits 21..25 = rS, 16..20 = rA
OP_ORI => {
let rs = rd; // bits 21..25 = source
if let Some(RegVal::Const(b)) = reg[rs] {
reg[ra] = Some(RegVal::Const(b | uimm));
} else {
reg[ra] = None;
}
}
// lwz rD, off(rA) — try to resolve as vtable slot load.
OP_LWZ => {
if ra != 0
&& let Some(RegVal::Const(base)) = reg[ra]
{
let target = base.wrapping_add(simm as u32);
// Two-step lookup so we accept both:
// (a) base = exact vtable head, simm/4 = slot
// (b) base + simm = exact vtable head (rare;
// compiler hoists the slot offset into addi)
let resolved = resolve_vtable_slot(target, &vtable_by_addr)
.or_else(|| resolve_vtable_slot_via_off(base, simm, &vtable_by_addr));
reg[rd] = resolved.map(|(vt, slot, pc)| RegVal::MethodPtr {
vtable_addr: vt,
slot,
method_pc: pc,
});
} else {
reg[rd] = None;
}
}
// X-form: mtspr/mtctr, bcctrl, mr, etc.
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
match xo {
467 => {
// mtspr SPR, rS — PPC SPR field is split: high 5 bits
// in PPC bits 16:20 (= Rust bits 11..15), low 5 bits
// in PPC bits 11:15 (= Rust bits 16..20). Mirrors
// the convention in `func.rs::is_mfspr_lr`.
let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F);
if spr == 9 {
ctr = reg[rd];
}
// Otherwise no observable effect on tracked state.
}
// Anything that writes rD (most arithmetic, loads, etc.) clobbers it.
// Conservative: invalidate rD on any X-form that has rD in bits 21..25
// and is NOT a comparison or branch.
_ => {
// Heuristic: most X-form ops with non-zero RC encode rD; we
// invalidate to avoid stale Const propagation past arithmetic.
// This is over-eager but safe (false negatives on edges, never
// false positives).
reg[rd] = None;
}
}
}
// bcctr/bcctrl — opcode 19, XO=528. LK in low bit.
OP_BCCTR => {
let xo = (instr >> 1) & 0x3FF;
if xo == 528 {
let lk = (instr & 1) != 0;
if lk
&& let Some(RegVal::MethodPtr {
vtable_addr,
slot,
method_pc,
}) = ctr
{
edges.push(IndirectEdge {
source: pc,
target: method_pc,
via_vtable: vtable_addr,
slot,
});
}
// After the call, CTR is preserved but rD register
// values across the call boundary are not trustworthy.
// Don't touch reg state — most ABIs preserve only
// some regs anyway.
}
}
// op 18: b / bl / ba / bla. LK=1 is a call; LK=0 is an
// unconditional branch with no fall-through (next PC is
// reached only via a different basic block, which the
// label-based reset already handles). On a call, the
// PowerPC ABI marks r0..r12 + ctr as volatile and
// r13..r31 as non-volatile (callee-saved); preserve the
// non-volatile half so vtable pointers loaded into r30/r31
// before a `bl` survive the call.
18 => {
let lk = (instr & 1) != 0;
if lk {
for r in 0..=12 {
reg[r] = None;
}
ctr = None;
}
// LK=0 (`b`) makes fall-through unreachable; nothing to do —
// any next reachable PC will hit a label boundary.
}
// Conditional branches (op 16) fall through; preserve all reg
// state for the fall-through path. The label-based join-point
// invalidation bounds false-positive risk for jump-IN paths.
16 => {
let lk = (instr & 1) != 0;
if lk {
for r in 0..=12 {
reg[r] = None;
}
ctr = None;
}
}
// Stores and loads we don't track explicitly clobber rD only
// when rD is on the destination side; the conservative rule
// is "any non-recognised opcode that may write rD invalidates it".
36..=55 => {
// Loads write rD; stores don't. The safe pessimisation is
// to invalidate rD for the load family (32..=35, 40..=43, etc.)
// and leave it alone for stores. We've already handled lwz
// above; for the rest, invalidate rD.
if matches!(op, 32..=35 | 40..=43 | 48..=51) {
reg[rd] = None;
}
}
_ => {}
}
pc = pc.wrapping_add(4);
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "indirect").record(elapsed_ms);
tracing::info!(
edges = edges.len(),
elapsed_ms,
"indirect-dispatch scan complete"
);
edges
}
fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option<u32> {
let off = addr.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
}
/// `target = base + simm` where `target` is an exact vtable head (rare,
/// compiler hoists the slot offset into the addi).
fn resolve_vtable_slot_via_off(
base: u32,
simm: i32,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<(u32, u32, u32)> {
let target = base.wrapping_add(simm as u32);
if let Some(v) = vtable_by_addr.get(&target)
&& !v.methods.is_empty()
{
return Some((v.address, 0, v.methods[0]));
}
None
}
/// `target` is an absolute address. If it falls inside a known vtable's
/// `[address, address + length*4)` range AND is 4-aligned to a slot,
/// return `(vtable_addr, slot, method_pc)`.
fn resolve_vtable_slot(
target: u32,
vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<(u32, u32, u32)> {
// BTreeMap range search for the largest key ≤ target.
let (&vt_addr, vt) = vtable_by_addr.range(..=target).next_back()?;
if target < vt_addr {
return None;
}
let off = target - vt_addr;
if !off.is_multiple_of(4) {
return None;
}
let slot = off / 4;
if slot >= vt.length {
return None;
}
let method_pc = *vt.methods.get(slot as usize)?;
Some((vt_addr, slot, method_pc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::func::FuncInfo;
use std::collections::BTreeMap;
fn mk_vtable(addr: u32, methods: Vec<u32>) -> Vtable {
Vtable {
address: addr,
length: methods.len() as u32,
col_address: None,
class_name: "ANON_test".into(),
rtti_present: false,
base_classes_json: None,
methods,
}
}
/// Encode the canonical pattern at PC `start`:
/// lis r3, hi
/// addi r3, r3, lo ; r3 = vtable_addr
/// lwz r4, slot*4(r3) ; r4 = vtable[slot]
/// mtctr r4
/// bcctrl
fn encode_pattern(buf: &mut [u8], offset: usize, vtable_addr: u32, slot_off: i32) {
let hi = (vtable_addr >> 16) as u16;
let lo = (vtable_addr & 0xFFFF) as i16;
let lis = ((15u32 << 26) | (3 << 21)) | (hi as u32);
// addi r3, r3, lo (signed) — note: addi is treated as signed
let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32);
let lwz = (32u32 << 26) | (4 << 21) | (3 << 16) | ((slot_off as u16) as u32);
// mtctr r4 = mtspr CTR(=9), r4. SPR_low (=9) → Rust bits 16-20;
// SPR_high (=0) → Rust bits 11-15. Rc bit 0.
let mtctr = ((31u32 << 26) | (4 << 21) | (9 << 16)) | (467 << 1);
let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1; // bcctrl 20, 0
let words = [lis, addi, lwz, mtctr, bcctrl];
for (i, w) in words.iter().enumerate() {
buf[offset + i * 4..offset + i * 4 + 4].copy_from_slice(&w.to_be_bytes());
}
}
#[test]
fn detects_canonical_lis_addi_lwz_mtctr_bcctrl() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
// PE: just the .text we'll write the pattern into.
let mut pe = vec![0u8; 0x1100];
encode_pattern(&mut pe, text_va as usize, vtable_addr, 8); // slot 2
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(
pc_start,
FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
},
);
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])];
let labels: HashMap<u32, String> = HashMap::new();
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].source, pc_start + 4 * 4); // bcctrl at 5th instruction
assert_eq!(edges[0].target, 0xCC); // slot 2
assert_eq!(edges[0].via_vtable, vtable_addr);
assert_eq!(edges[0].slot, 2);
}
#[test]
fn out_of_range_slot_yields_no_edge() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
let mut pe = vec![0u8; 0x1100];
// Encode slot 12, but vtable only has 4 methods.
encode_pattern(&mut pe, text_va as usize, vtable_addr, 48);
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(
pc_start,
FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
},
);
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB, 0xCC, 0xDD])];
let labels: HashMap<u32, String> = HashMap::new();
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(edges.len(), 0);
}
#[test]
fn label_in_middle_kills_state() {
let image_base = 0x82000000u32;
let text_va = 0x1000u32;
let pc_start = image_base + text_va;
let vtable_addr = 0x82010000u32;
let mut pe = vec![0u8; 0x1100];
encode_pattern(&mut pe, text_va as usize, vtable_addr, 0);
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
functions.insert(
pc_start,
FuncInfo {
start: pc_start,
end: pc_start + 5 * 4,
frame_size: 0,
saved_gprs: 0,
is_leaf: false,
is_saverestore: false,
pdata_validated: false,
pdata_length: None,
pdata_prolog_length: None,
has_eh: false,
},
);
let func_analysis = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let vtables = vec![mk_vtable(vtable_addr, vec![0xAA, 0xBB])];
// Label between addi and lwz — must kill the Const tracking.
let mut labels: HashMap<u32, String> = HashMap::new();
labels.insert(pc_start + 8, "loc_mid".to_string());
let edges = analyze(&pe, image_base, &func_analysis, &vtables, &labels);
assert_eq!(
edges.len(),
0,
"label in middle of pattern must kill register state"
);
}
}

View File

@@ -0,0 +1,858 @@
//! 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 sylpheed_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 i = read(pc)?;
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 sylpheed_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

@@ -0,0 +1,46 @@
// 🔴 THREE LINTS ARE OFF FOR THIS CRATE, WITH REASONS, RATHER THAN SILENTLY.
//
// * `needless_range_loop` — nine sites index `reg[r]` where **`r` is the
// PowerPC register number**. The index is the meaning; an iterator hides
// which GPR a pattern matched, which is the whole content of these passes.
// * `too_many_arguments` — five analysis passes take the image, its base, the
// section table, the function list and several output sinks. Bundling those
// into a struct moves the list rather than shortening it, and this code
// arrived whole from a retired repository: a refactor here would be an
// unreviewed edit dressed as a lint fix.
// * `type_complexity` — one return type in `vtables.rs`, same argument.
//
// Everything else clippy asked for was fixed, including every doc-indent site.
// See `docs/agents/CONSOLIDATION.md` Phase 3.
#![allow(
clippy::needless_range_loop,
clippy::too_many_arguments,
clippy::type_complexity
)]
pub mod db;
pub mod demangle;
pub mod disasm;
pub mod eh_scope;
pub mod formatter;
pub mod func;
pub mod funcptr_arrays;
pub mod ind_dispatch_typed;
pub mod indirect;
pub mod jumptables;
pub mod lookup;
pub mod ppc;
pub mod rtti;
pub mod sinks;
pub mod sql_views;
pub mod static_init;
pub mod strings;
pub mod vtables;
pub mod xdbf;
pub mod xref;
mod ordinals;
pub use db::{BranchTraceEntry, DbWriter, ExecTraceEntry, ImportCallEntry};
pub use disasm::{RichDisasmItem, enrich_section};
pub use ordinals::resolve_ordinal;
pub use xref::{Xref, XrefKind, XrefMap, resolve_source_label};

View File

@@ -0,0 +1,227 @@
//! Symbolic-name resolution for runtime probes (M4).
//!
//! Lets `--pc-probe` / `--branch-probe` / `--ctor-probe` accept names like
//! `xe::apu::AudioSystem::Setup` or `MyClass::*` instead of bare PC literals.
//! Resolution joins the M3-produced `classes` × `methods` × `functions` tables
//! and the M2 `demangled_names` table.
//!
//! Numeric tokens (`0x824D6640`, `2186674160`) are returned unchanged; symbolic
//! tokens require a path to an existing `sylpheed.db` (passed by the caller).
//!
//! All DB access is read-only and happens before guest execution, so the
//! lockstep digest is unaffected.
use std::path::Path;
use anyhow::{Result, anyhow};
use duckdb::params;
/// Parse one probe token into one or more PCs.
///
/// Recognized forms:
/// - `0xADDR` / `ADDR` (decimal) → returns one PC unchanged.
/// - `Class::method` → all `methods.function_address` matching that
/// `class_name` + `method_name` pair.
/// - `Class::*` → all `methods.function_address` for that class.
/// - `func::Name` (free function) → falls back to `functions.name` lookup.
///
/// `db_path` is consulted ONLY if the token is non-numeric. When `db_path` is
/// `None` and the token is symbolic, returns an error suggesting the user
/// either pass `--db` or use a numeric address.
pub fn resolve_probe_token(db_path: Option<&Path>, token: &str) -> Result<Vec<u32>> {
let token = token.trim();
if token.is_empty() {
return Ok(vec![]);
}
if let Some(pc) = parse_numeric(token) {
return Ok(vec![pc]);
}
let db = db_path.ok_or_else(|| {
anyhow!(
"symbolic probe token {token:?} requires a sylpheed.db; \
pass --probe-db=PATH or use a numeric 0x… address",
)
})?;
if !db.exists() {
return Err(anyhow!("--probe-db not found: {}", db.display()));
}
let conn = duckdb::Connection::open_with_flags(
db,
duckdb::Config::default().access_mode(duckdb::AccessMode::ReadOnly)?,
)?;
// Class::method or Class::*
if let Some((class, method)) = token.split_once("::") {
if method == "*" {
return resolve_class_star(&conn, class);
}
// Try Class::method first, then fall back to functions.name lookup.
let pcs = resolve_class_method(&conn, class, method)?;
if !pcs.is_empty() {
return Ok(pcs);
}
}
// Last-resort: functions.name match (e.g. for `entry_point` or
// `__savegprlr_22`). Substring-free; user gets a clear error if missing.
resolve_function_name(&conn, token)
}
fn parse_numeric(token: &str) -> Option<u32> {
if let Some(hex) = token
.strip_prefix("0x")
.or_else(|| token.strip_prefix("0X"))
{
return u32::from_str_radix(hex, 16).ok();
}
token.parse::<u32>().ok()
}
fn resolve_class_method(conn: &duckdb::Connection, class: &str, method: &str) -> Result<Vec<u32>> {
// Two-step lookup so we can give better errors:
// 1. find matching methods rows joined to classes;
// 2. surface the function_address column.
let mut stmt = conn.prepare(
"SELECT DISTINCT m.function_address FROM methods m
JOIN classes c ON c.vtable_address = m.vtable_address
JOIN demangled_names dn ON dn.address = m.function_address
WHERE c.name = ? AND dn.method_name = ?",
)?;
let pcs: Vec<u32> = stmt
.query_map(params![class, method], |r| {
r.get::<_, i64>(0).map(|x| x as u32)
})?
.filter_map(|r| r.ok())
.collect();
Ok(pcs)
}
fn resolve_class_star(conn: &duckdb::Connection, class: &str) -> Result<Vec<u32>> {
let mut stmt = conn.prepare(
"SELECT DISTINCT m.function_address FROM methods m
JOIN classes c ON c.vtable_address = m.vtable_address
WHERE c.name = ?",
)?;
let pcs: Vec<u32> = stmt
.query_map(params![class], |r| r.get::<_, i64>(0).map(|x| x as u32))?
.filter_map(|r| r.ok())
.collect();
if pcs.is_empty() {
return Err(anyhow!(
"no class named {class:?} found in classes table — has --dis populated this DB?",
));
}
Ok(pcs)
}
fn resolve_function_name(conn: &duckdb::Connection, name: &str) -> Result<Vec<u32>> {
let mut stmt = conn.prepare("SELECT address FROM functions WHERE name = ?")?;
let pcs: Vec<u32> = stmt
.query_map(params![name], |r| r.get::<_, i64>(0).map(|x| x as u32))?
.filter_map(|r| r.ok())
.collect();
if pcs.is_empty() {
return Err(anyhow!(
"probe token {name:?} did not match any classes::methods or functions row",
));
}
Ok(pcs)
}
#[cfg(test)]
mod tests {
use super::*;
use duckdb::Connection;
fn build_synthetic_db(path: &Path) {
let conn = Connection::open(path).expect("open");
conn.execute_batch(
"
CREATE TABLE functions (
address BIGINT PRIMARY KEY,
name VARCHAR
);
CREATE TABLE classes (
name VARCHAR PRIMARY KEY,
vtable_address BIGINT,
rtti_present BOOLEAN,
base_classes_json VARCHAR
);
CREATE TABLE methods (
vtable_address BIGINT,
slot BIGINT,
function_address BIGINT,
mangled_name VARCHAR,
demangled_name VARCHAR,
PRIMARY KEY (vtable_address, slot)
);
CREATE TABLE demangled_names (
address BIGINT,
mangled VARCHAR,
raw_demangled VARCHAR,
namespace_path VARCHAR,
class_name VARCHAR,
method_name VARCHAR,
params_signature VARCHAR
);
INSERT INTO classes VALUES ('Foo', 11000, true, NULL);
INSERT INTO functions VALUES (12000, 'sub_2EE0'), (12100, 'sub_2F44');
INSERT INTO methods VALUES (11000, 0, 12000, NULL, NULL),
(11000, 1, 12100, NULL, NULL);
INSERT INTO demangled_names (address, mangled, raw_demangled, class_name, method_name)
VALUES (12000, '?bar@Foo@@QEAAXXZ', 'void Foo::bar(void)', 'Foo', 'bar'),
(12100, '?baz@Foo@@QEAAXXZ', 'void Foo::baz(void)', 'Foo', 'baz');
",
)
.expect("seed");
}
#[test]
fn numeric_passthrough_no_db_needed() {
let pcs = resolve_probe_token(None, "0x824D6640").unwrap();
assert_eq!(pcs, vec![0x824D6640]);
let pcs = resolve_probe_token(None, "2186095088").unwrap();
assert_eq!(pcs, vec![0x824D29F0]);
}
#[test]
fn symbolic_token_without_db_errors() {
let err = resolve_probe_token(None, "Foo::bar").unwrap_err();
assert!(format!("{err}").contains("requires a sylpheed.db"));
}
#[test]
fn class_method_resolves() {
let tmp = std::env::temp_dir().join("sylpheed_lookup_test.duckdb");
let _ = std::fs::remove_file(&tmp);
build_synthetic_db(&tmp);
let pcs = resolve_probe_token(Some(&tmp), "Foo::bar").unwrap();
assert_eq!(pcs, vec![12000]);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn class_star_returns_all_methods() {
let tmp = std::env::temp_dir().join("sylpheed_lookup_star.duckdb");
let _ = std::fs::remove_file(&tmp);
build_synthetic_db(&tmp);
let mut pcs = resolve_probe_token(Some(&tmp), "Foo::*").unwrap();
pcs.sort();
assert_eq!(pcs, vec![12000, 12100]);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn function_name_fallback() {
let tmp = std::env::temp_dir().join("sylpheed_lookup_fn.duckdb");
let _ = std::fs::remove_file(&tmp);
build_synthetic_db(&tmp);
let pcs = resolve_probe_token(Some(&tmp), "sub_2EE0").unwrap();
assert_eq!(pcs, vec![12000]);
let _ = std::fs::remove_file(&tmp);
}
}

View File

@@ -0,0 +1 @@
include!(concat!(env!("OUT_DIR"), "/ordinals.rs"));

View File

@@ -0,0 +1,31 @@
//! Back-compat shim. The full PPC disassembler now lives in
//! [`sylpheed_ppc::disasm`] (single source of truth, sitting on top of the
//! canonical decoder). This module preserves the legacy `Decoded { base, ext }`
//! surface so existing call sites keep compiling while the analysis crate
//! migrates to `DisasmText` directly.
use sylpheed_ppc::decoder::decode;
use sylpheed_ppc::disasm::format;
/// Decoded instruction carrying both base and (optional) extended mnemonic forms.
pub struct Decoded {
pub base: String,
pub ext: Option<String>,
}
impl Decoded {
/// Returns the preferred display form (extended if available, else base).
pub fn display(&self) -> &str {
self.ext.as_deref().unwrap_or(&self.base)
}
}
/// Disassemble one 32-bit big-endian PowerPC instruction.
pub fn disasm(instr: u32, addr: u32) -> Decoded {
let d = decode(instr, addr);
let t = format(&d);
Decoded {
base: t.disasm,
ext: t.ext_disasm,
}
}

View File

@@ -0,0 +1,566 @@
//! 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 sylpheed_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,
);
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,
);
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

@@ -0,0 +1,39 @@
//! 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, target_hex, section,
//! function, label, is_data.
use duckdb::{Appender, params};
use crate::disasm::RichDisasmItem;
/// Append every item to the appender. Returns the number of rows written.
/// Does NOT flush — the caller decides when to flush, since multiple
/// section iterators typically share one appender.
pub fn append_instructions<'a>(
appender: &mut Appender<'_>,
items: impl IntoIterator<Item = RichDisasmItem<'a>>,
) -> duckdb::Result<u64> {
let mut count: u64 = 0;
for ri in items {
let t = &ri.item.text;
appender.append_row(params![
ri.item.addr as i64,
ri.item.raw as i64,
t.mnemonic.as_str(),
t.operands.as_str(),
t.disasm.as_str(),
t.ext_mnemonic.as_deref(),
t.ext_operands.as_deref(),
t.ext_disasm.as_deref(),
t.branch_target.map(|t| t as i64),
ri.section,
ri.function.map(|f| f as i64),
ri.label,
ri.is_data,
])?;
count += 1;
}
Ok(count)
}

View File

@@ -0,0 +1,65 @@
//! JSON Lines sink — one structured row per line, constant memory.
//!
//! Suited for piping into `jq`, importing into pandas / DuckDB's
//! `read_json_auto`, or feeding downstream tooling that expects a
//! line-delimited stream rather than a single megaobject.
use std::io::{self, Write};
use serde::Serialize;
use crate::disasm::RichDisasmItem;
#[derive(Serialize)]
struct JsonRow<'a> {
addr: u32,
raw: u32,
mnemonic: &'a str,
operands: &'a str,
disasm: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
ext_mnemonic: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
ext_operands: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
ext_disasm: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
branch_target: Option<u32>,
section: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
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
/// number of rows written.
pub fn write_jsonl<'a, W: Write>(
out: &mut W,
items: impl IntoIterator<Item = RichDisasmItem<'a>>,
) -> io::Result<u64> {
let mut count: u64 = 0;
for ri in items {
let t = &ri.item.text;
let row = JsonRow {
addr: ri.item.addr,
raw: ri.item.raw,
mnemonic: &t.mnemonic,
operands: &t.operands,
disasm: &t.disasm,
ext_mnemonic: t.ext_mnemonic.as_deref(),
ext_operands: t.ext_operands.as_deref(),
ext_disasm: t.ext_disasm.as_deref(),
branch_target: t.branch_target,
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")?;
count += 1;
}
Ok(count)
}

View File

@@ -0,0 +1,8 @@
//! Output sinks for [`crate::disasm::RichDisasmItem`] streams.
//!
//! Each sink consumes the same iterator shape and writes to a different
//! medium: human-readable .asm text, JSON Lines, or DuckDB rows.
pub mod duckdb;
pub mod json;
pub mod text;

View File

@@ -0,0 +1,77 @@
//! Text sink — renders one .asm instruction line with optional
//! branch-target / data-ref annotations.
//!
//! The full `write_asm` orchestration (section headers, function prologue
//! info, xref comment blocks, hex-dump of data sections) stays in
//! [`crate::formatter`]; this sink only owns the per-instruction line.
use std::collections::HashMap;
use std::io::{self, Write};
use sylpheed_xex::pe::PeSection;
use crate::disasm::RichDisasmItem;
use crate::xref::{XrefKind, section_for_addr};
/// Render one instruction line:
/// ` 82000000: 60000000 nop`
/// ` 82000004: 4800FFFC bl 0x82000000 ; -> entry_point`
/// ` 82000010: 812A0000 lwz r9, 0(r10) ; [R] 0x828A0000 (.rdata) = dat_…`
pub fn write_instr_line<W: Write + ?Sized>(
out: &mut W,
item: &RichDisasmItem<'_>,
labels: &HashMap<u32, String>,
sections: &[PeSection],
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`
// field (cleaner than the legacy "find 0x in disasm string" regex).
let mut annotated = match item.item.text.branch_target {
Some(target) => match labels.get(&target) {
Some(lbl) => format!("{disasm_text:<40} ; -> {lbl}"),
None => disasm_text.to_string(),
},
None => disasm_text.to_string(),
};
if let Some((data_addr, kind)) = data_annotation {
let tag = match kind {
XrefKind::DataRead => "[R]",
XrefKind::DataWrite => "[W]",
_ => "[&]",
};
let sec = section_for_addr(data_addr, sections, image_base).unwrap_or("?");
let data_lbl = labels
.get(&data_addr)
.map(|s| format!(" = {s}"))
.unwrap_or_default();
if !annotated.contains("; ->") {
annotated = format!("{annotated:<40} ; {tag} 0x{data_addr:08X} ({sec}){data_lbl}");
} else {
annotated = format!("{annotated} {tag} 0x{data_addr:08X} ({sec}){data_lbl}");
}
}
writeln!(
out,
" {:08X}: {:08X} {}",
item.item.addr, item.item.raw, annotated
)
}

View File

@@ -0,0 +1,287 @@
//! Additive SQL views over the Phase-3 ingest tables.
//!
//! These views are created when `--analyze=sql` or `--analyze=both` is set.
//! They are *not* a replacement for the Rust passes ([`crate::xref`],
//! [`crate::func`]) — those still own data-ref resolution and prologue
//! pattern matching. The views cover the cleanly-relational parts:
//!
//! - branch xrefs (self-join on `instructions.target_hex`)
//! - call graph + reachability (recursive CTE over `xrefs`)
//! - convenience joins (function-first-instruction, imports-called)
//!
//! All views are read-only and stable across re-creation: dropping and
//! recreating the database via [`crate::db::DbWriter::open_fresh`] re-runs
//! these definitions.
//!
//! ## Cross-check semantics
//!
//! `v_branch_xrefs` is intended to produce *exactly* the same `(source,
//! target, kind)` tuples as the Rust `xref.rs` first pass — given the same
//! input image. [`crate::db::DbWriter::cross_check_branch_xrefs`] queries
//! the symmetric difference and returns the row counts; both should be
//! zero. A non-zero count means the formatter's `mnemonic` column or the
//! 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).
pub const ALL_VIEWS: &[(&str, &str)] = &[
("v_branch_xrefs", V_BRANCH_XREFS),
("v_call_graph", V_CALL_GRAPH),
("v_reachability_from_entry", V_REACHABILITY_FROM_ENTRY),
(
"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`.
///
/// Mirrors the kind classification in [`crate::xref::collect_branch_target`]
/// and the short tags returned by [`crate::xref::XrefKind::tag`] (which are
/// what `xrefs.kind` actually stores):
/// - I-form (`b`/`bl`/`ba`/`bla`): `bl`/`bla` → `"call"`, `b`/`ba` → `"j"`
/// - B-form (`bc`/`bcl`/`bca`/`bcla`): always → `"br"`
///
/// Indirect branches (`bclr`/`bcctr`) leave `target_hex` NULL and are
/// excluded from this view by design.
const V_BRANCH_XREFS: &str = "
CREATE OR REPLACE VIEW v_branch_xrefs AS
SELECT
address AS source,
target_hex AS target,
CASE
WHEN mnemonic IN ('bl', 'bla') THEN 'call'
WHEN mnemonic IN ('b', 'ba') THEN 'j'
WHEN mnemonic IN ('bc', 'bcl', 'bca', 'bcla') THEN 'br'
ELSE 'br'
END AS kind,
mnemonic AS instruction,
function AS source_func
FROM instructions
WHERE target_hex IS NOT NULL;
";
/// Call-graph edges resolved against function names.
///
/// Reads from `xrefs` (the Rust-pass table) — this is the canonical source
/// for *all* edge kinds, including indirect/data; SQL can't reconstruct the
/// data-ref edges cleanly because they require register tracking. For pure
/// branch edges, `v_branch_xrefs` produces equivalent rows directly from
/// `instructions`.
const V_CALL_GRAPH: &str = "
CREATE OR REPLACE VIEW v_call_graph AS
SELECT
x.source AS caller_addr,
cf.name AS caller_name,
x.target AS callee_addr,
tf.name AS callee_name,
x.kind AS edge_kind
FROM xrefs x
LEFT JOIN functions cf ON cf.address = x.source_func
LEFT JOIN functions tf ON tf.address = x.target
WHERE x.kind = 'call';
";
/// Transitive function-level reachability from the entry point over
/// call/jump/branch edges. Useful for finding dead code
/// (`SELECT address FROM functions
/// WHERE address NOT IN (SELECT addr FROM v_reachability_from_entry)`)
/// and for scoping analysis to the live subset.
///
/// Seeds from the function containing the `entry_point` label and walks
/// the recursive closure: a reachable function's instructions branch into
/// the functions enclosing the branch targets, which are then reachable
/// in turn. `UNION` (not `UNION ALL`) deduplicates to handle call-graph
/// cycles (recursive functions, mutually-recursive pairs).
const V_REACHABILITY_FROM_ENTRY: &str = "
CREATE OR REPLACE VIEW v_reachability_from_entry AS
WITH RECURSIVE reach(fn) AS (
SELECT i.function FROM instructions i
JOIN labels l ON l.address = i.address
WHERE l.name = 'entry_point' AND i.function IS NOT NULL
UNION
SELECT tgt.function FROM xrefs x
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', 'jt')
AND tgt.function IS NOT NULL
)
SELECT fn AS addr FROM reach;
";
/// Reachability extended over `kind='ind_call'` edges from M5. Strict
/// superset of `v_reachability_from_entry` — every fn there is also here,
/// plus any function reached only via a vtable bcctrl whose vtable+slot
/// the M5 dataflow could resolve. Sample 5 newly-reachable PCs in canary
/// before trusting widely; the analysis intentionally leaves out alias-
/// dependent indirect calls (vtable loaded from a `this` field).
const V_INDIRECT_REACHABILITY_FROM_ENTRY: &str = "
CREATE OR REPLACE VIEW v_indirect_reachability_from_entry AS
WITH RECURSIVE reach(fn) AS (
SELECT i.function FROM instructions i
JOIN labels l ON l.address = i.address
WHERE l.name = 'entry_point' AND i.function IS NOT NULL
UNION
SELECT tgt.function FROM xrefs x
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', 'jt')
AND tgt.function IS NOT NULL
)
SELECT fn AS addr FROM reach;
";
/// Convenience join: each function's first decoded instruction. Useful for
/// quickly inspecting prologue patterns without computing offsets manually.
const V_FUNCTION_FIRST_INSTRUCTION: &str = "
CREATE OR REPLACE VIEW v_function_first_instruction AS
SELECT
f.address AS function_addr,
f.name AS function_name,
i.raw AS first_raw,
i.disasm AS first_disasm,
i.ext_disasm AS first_ext_disasm
FROM functions f
JOIN instructions i ON i.address = f.address;
";
/// Per-function summary of which kernel/library imports it calls. Joins
/// xrefs (call edges) against the labels table to surface import names.
const V_IMPORTS_CALLED: &str = "
CREATE OR REPLACE VIEW v_imports_called AS
SELECT
x.source_func AS function_addr,
f.name AS function_name,
x.target AS import_addr,
l.name AS import_name
FROM xrefs x
JOIN labels l ON l.address = x.target
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

@@ -0,0 +1,471 @@
//! M11.5 — static-initialiser driver detection.
//!
//! MSVC's CRT static-init driver (`_initterm` / `_initterm_e` style)
//! is a tight loop that walks a function-pointer array between two
//! addresses, calling each non-null entry:
//!
//! ```text
//! loop_top:
//! cmpw[l] rA, rB ; compare cursor vs end
//! beq done
//! lwz rN, 0(rA) ; load fn ptr
//! cmpwi rN, 0 ; null-skip (optional)
//! beq skip
//! mtctr rN
//! bcctrl
//! skip:
//! addi rA, rA, 4
//! b loop_top
//! done:
//! ```
//!
//! Two static addresses (`rA` and `rB` at loop start) bracket the
//! function-pointer array. Detection strategy: scan every function for
//! the canonical pattern; when found, extract the array bounds and
//! emit one row in `function_pointer_arrays` with `kind='static_init'`.
//!
//! ### What this layer does
//!
//! - Walks each function looking for an `lwz; mtctr; bcctrl` sequence
//! inside a loop bounded by a comparison against another constant.
//! - When the loop's cursor register is observed to be incremented by
//! exactly 4 per iteration, classifies it as a static-init driver
//! and records the (start, end) array bounds.
//!
//! ### What this layer does NOT do
//!
//! - No support for back-to-back drivers sharing a common loop trampoline.
//! - No detection of the M11 prologue-style heuristic; M11.5 is
//! structure-grounded and replaces the prior heuristic where it fires.
//! - Does not handle CRT-style `_initterm_e` (the `_e` variant returns
//! a status); detection works for both as long as the loop shape
//! matches.
//!
//! Reference: Microsoft CRT `crt0.c::_initterm` source pattern.
use std::collections::{BTreeSet, HashMap, HashSet};
use crate::func::FuncAnalysis;
use crate::funcptr_arrays::FuncPtrArray;
use sylpheed_xex::pe::PeSection;
#[derive(Debug, Clone, Copy)]
pub struct StaticInitDriver {
/// VA of the driver function (the one containing the loop).
pub driver_function: u32,
/// VA of the array start.
pub array_start: u32,
/// VA one-past-end of the array.
pub array_end: u32,
/// Detected length in slots.
pub length: u32,
}
#[derive(Debug, Default)]
pub struct StaticInitResult {
pub drivers: Vec<StaticInitDriver>,
/// Newly-detected static-init arrays, ready to be merged into the
/// `function_pointer_arrays` table with `kind='static_init'`.
pub arrays: Vec<FuncPtrArray>,
}
const OP_ADDI: u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_BCCTR: u32 = 19;
const OP_LWZ: u32 = 32;
const OP_X_FORM: u32 = 31;
#[derive(Debug, Clone, Copy)]
enum RegVal {
Const(u32),
}
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
func_analysis: &FuncAnalysis,
function_starts: &BTreeSet<u32>,
labels: &HashMap<u32, String>,
) -> StaticInitResult {
let started = std::time::Instant::now();
let block_boundaries: HashSet<u32> = labels.keys().copied().collect();
let mut drivers: Vec<StaticInitDriver> = Vec::new();
for (&fn_start, fi) in &func_analysis.functions {
if fi.is_saverestore {
continue;
}
if let Some(d) =
scan_function_for_driver(pe, image_base, fn_start, fi.end, &block_boundaries)
{
drivers.push(d);
}
}
// Build arrays from the discovered drivers + section data.
let mut arrays: Vec<FuncPtrArray> = Vec::new();
for d in &drivers {
if let Some(entries) = read_array(
pe,
image_base,
sections,
d.array_start,
d.array_end,
function_starts,
) {
arrays.push(FuncPtrArray {
address: d.array_start,
length: entries.len() as u32,
kind: "static_init",
entries,
});
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "static_init").record(elapsed_ms);
tracing::info!(
drivers = drivers.len(),
arrays = arrays.len(),
elapsed_ms,
"M11.5 static-init driver scan complete",
);
StaticInitResult { drivers, arrays }
}
/// Read the function-pointer array between [start, end) from .rdata/.data.
/// NULL entries are skipped (CRT _initterm explicitly tolerates them).
/// Non-function-start entries cause us to bail (the driver bounds were
/// likely misidentified).
fn read_array(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
start: u32,
end: u32,
function_starts: &BTreeSet<u32>,
) -> Option<Vec<u32>> {
if end <= start || (end - start) > 4096 {
return None;
}
let _section = sections.iter().find(|s| {
let lo = image_base + s.virtual_address;
let hi = lo + s.virtual_size;
start >= lo && end <= hi && (s.name == ".rdata" || s.name == ".data")
})?;
let mut entries = Vec::new();
let mut p = start;
while p < end {
let off = p.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
let v = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]);
if v != 0 {
if !function_starts.contains(&v) {
return None;
}
entries.push(v);
}
p = p.wrapping_add(4);
}
if entries.is_empty() {
return None;
}
Some(entries)
}
/// Walk one function looking for the canonical static-init driver shape.
/// Returns Some when the loop's cursor register starts at a known constant
/// `rA`, terminates at another known constant `rB` via a compare, and
/// increments by 4 per iteration with an `lwz; mtctr; bcctrl` body.
fn scan_function_for_driver(
pe: &[u8],
image_base: u32,
fn_start: u32,
fn_end: u32,
block_boundaries: &HashSet<u32>,
) -> Option<StaticInitDriver> {
let mut reg: [Option<RegVal>; 32] = [None; 32];
// Pattern features observed during the walk.
let mut cursor_reg: Option<usize> = None;
let mut cursor_init: Option<u32> = None;
let mut end_reg: Option<usize> = None;
let mut end_init: Option<u32> = None;
let mut saw_lwz_through_cursor = false;
let mut saw_mtctr = false;
let mut saw_bcctrl = false;
let mut saw_addi_4 = false;
let mut pc = fn_start;
while pc < fn_end {
if pc != fn_start && block_boundaries.contains(&pc) {
// Heuristic: when we cross a basic-block boundary that
// is not the loop-top, accumulated state remains valid for
// pattern-matching purposes — but we drop register Const
// tracking to be safe.
reg = [None; 32];
}
let off = pc.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
break;
}
let instr = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]);
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
let ra = ((instr >> 16) & 0x1F) as usize;
let simm = ((instr & 0xFFFF) as i16) as i32;
let uimm = instr & 0xFFFF;
match op {
OP_ADDIS if ra == 0 => reg[rd] = Some(RegVal::Const(uimm << 16)),
OP_ADDIS => {
if let Some(RegVal::Const(b)) = reg[ra] {
reg[rd] = Some(RegVal::Const(b.wrapping_add(uimm << 16)));
} else {
reg[rd] = None;
}
}
OP_ADDI if ra != 0 => {
let prev = reg[ra];
if let Some(RegVal::Const(b)) = prev {
let v = b.wrapping_add(simm as u32);
reg[rd] = Some(RegVal::Const(v));
// Was this an `addi r, r, 4`? Mark cursor-increment.
if rd == ra && simm == 4 {
if Some(rd) == cursor_reg {
saw_addi_4 = true;
}
} else if cursor_reg.is_none() {
// First time we see a known-constant register that
// *could* be the cursor — defer the choice until we
// see a load through it.
cursor_init = Some(v);
cursor_reg = Some(rd);
} else if end_reg.is_none() && Some(rd) != cursor_reg {
end_init = Some(v);
end_reg = Some(rd);
}
} else {
reg[rd] = None;
}
}
OP_LWZ => {
if ra != 0 && Some(ra) == cursor_reg {
saw_lwz_through_cursor = true;
}
reg[rd] = None;
}
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
if xo == 467 {
let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F);
if spr == 9 && saw_lwz_through_cursor {
saw_mtctr = true;
}
}
if xo != 444 && xo != 467 {
reg[rd] = None;
}
}
OP_BCCTR => {
let xo = (instr >> 1) & 0x3FF;
let lk = (instr & 1) != 0;
if xo == 528 && lk && saw_mtctr {
saw_bcctrl = true;
}
}
18 => {
if (instr & 1) != 0 {
for r in 0..=12 {
reg[r] = None;
}
}
}
16 if (instr & 1) != 0 => {
for r in 0..=12 {
reg[r] = None;
}
}
_ => {}
}
pc = pc.wrapping_add(4);
}
// Validate that all four pattern features fired.
if !(saw_lwz_through_cursor && saw_mtctr && saw_bcctrl && saw_addi_4) {
return None;
}
let cursor_init = cursor_init?;
let end_init = end_init?;
if end_init <= cursor_init {
return None;
}
if end_init - cursor_init > 4096 {
return None;
}
Some(StaticInitDriver {
driver_function: fn_start,
array_start: cursor_init,
array_end: end_init,
length: (end_init - cursor_init) / 4,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::func::FuncInfo;
use std::collections::BTreeMap;
use sylpheed_xex::pe::PeSection;
fn mk_section(name: &str, va: u32, size: u32) -> PeSection {
PeSection {
name: name.into(),
virtual_address: va,
virtual_size: size,
raw_offset: va,
raw_size: size,
flags: 0x4000_0040,
}
}
fn write_be(pe: &mut [u8], at: usize, v: u32) {
pe[at..at + 4].copy_from_slice(&v.to_be_bytes());
}
#[test]
fn detects_canonical_initterm_loop() {
// Build a tiny driver that loops over a 3-entry array.
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
// Array at .rdata + 0x800: 3 function pointers.
let arr_va_lo = 0x800u32;
let fns = [
image_base + 0x2000,
image_base + 0x2010,
image_base + 0x2020,
];
for (i, p) in fns.iter().enumerate() {
write_be(&mut pe, arr_va_lo as usize + i * 4, *p);
}
let array_start = image_base + arr_va_lo;
let array_end = array_start + 12;
// Driver function at 0x82001000:
// lis r3, hi(array_start)
// addi r3, r3, lo(array_start)
// lis r4, hi(array_end)
// addi r4, r4, lo(array_end)
// lwz r5, 0(r3)
// mtctr r5
// bcctrl
// addi r3, r3, 4
// blr
let driver = 0x82001000u32;
let off = (driver - image_base) as usize;
let lis_r3 = (15u32 << 26) | (3 << 21) | (array_start >> 16);
let addi_r3 = (14u32 << 26) | (3 << 21) | (3 << 16) | ((array_start as u16) as u32);
let lis_r4 = (15u32 << 26) | (4 << 21) | (array_end >> 16);
let addi_r4 = (14u32 << 26) | (4 << 21) | (4 << 16) | ((array_end as u16) as u32);
let lwz = (32u32 << 26) | (5 << 21) | (3 << 16);
let mtctr = (31u32 << 26) | (5 << 21) | (9 << 16) | (467 << 1);
let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1;
let addi_inc = (14u32 << 26) | (3 << 21) | (3 << 16) | 4;
let blr = (19u32 << 26) | (20 << 21) | (16 << 1);
for (i, w) in [
lis_r3, addi_r3, lis_r4, addi_r4, lwz, mtctr, bcctrl, addi_inc, blr,
]
.iter()
.enumerate()
{
write_be(&mut pe, off + i * 4, *w);
}
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
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,
pdata_prolog_length: None,
has_eh: false,
},
);
let fa = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let sections = vec![mk_section(".rdata", 0x800, 0x100)];
let mut starts = BTreeSet::new();
for &p in &fns {
starts.insert(p);
}
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &sections, &fa, &starts, &labels);
assert_eq!(r.drivers.len(), 1, "should detect one driver");
let d = &r.drivers[0];
assert_eq!(d.driver_function, driver);
assert_eq!(d.array_start, array_start);
assert_eq!(d.array_end, array_end);
assert_eq!(d.length, 3);
assert_eq!(r.arrays.len(), 1);
assert_eq!(r.arrays[0].kind, "static_init");
assert_eq!(r.arrays[0].entries.len(), 3);
}
#[test]
fn rejects_function_without_pattern() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x4000];
let driver = 0x82001000u32;
// Just a blr — no driver pattern.
let blr = (19u32 << 26) | (20 << 21) | (16 << 1);
write_be(&mut pe, (driver - image_base) as usize, blr);
let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
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,
pdata_prolog_length: None,
has_eh: false,
},
);
let fa = FuncAnalysis {
functions,
save_gpr_base: None,
restore_gpr_base: None,
pdata_entries: Vec::new(),
};
let sections = vec![mk_section(".rdata", 0x800, 0x100)];
let starts: BTreeSet<u32> = BTreeSet::new();
let labels: HashMap<u32, String> = HashMap::new();
let r = analyze(&pe, image_base, &sections, &fa, &starts, &labels);
assert_eq!(r.drivers.len(), 0);
}
}

View File

@@ -0,0 +1,516 @@
//! 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.
//! Cross-references against `xrefs.target` are computed by the caller —
//! this module only finds the strings; downstream queries can join.
//!
//! ### What this layer does NOT do
//!
//! - No UTF-8 multibyte detection — Xbox 360 game binaries reliably use
//! ASCII for debug strings and UTF-16LE for localised text.
//! - 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.
//!
//! Extends the original ASCII / UTF-16LE pass with Shift_JIS detection
//! (Sylpheed is originally Japanese — likely yields mission/UI text
//! invisible to ASCII-only) and UTF-8 multi-byte detection.
//!
//! Reference: `objdump -s` `.rdata` walks rely on the same heuristic;
//! Shift_JIS lead/trail byte ranges per JIS X 0208.
use sylpheed_xex::pe::PeSection;
/// One detected string.
#[derive(Debug, Clone)]
pub struct DetectedString {
/// Absolute VA of the first byte.
pub address: u32,
/// `"ascii"` | `"utf16le"` | `"shift_jis"` | `"utf8"`.
pub encoding: &'static str,
/// Length in bytes (excluding the NUL terminator).
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 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 !matches!(section.name.as_str(), ".rdata" | ".data") {
continue;
}
let raw_start = section.virtual_address as usize;
// 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;
let n_ascii = out.iter().filter(|s| s.encoding == "ascii").count();
let n_utf16 = out.iter().filter(|s| s.encoding == "utf16le").count();
let n_sjis = out.iter().filter(|s| s.encoding == "shift_jis").count();
let n_utf8 = out.iter().filter(|s| s.encoding == "utf8").count();
metrics::histogram!("analysis.phase_ms", "phase" => "strings").record(elapsed_ms);
tracing::info!(
ascii = n_ascii,
utf16le = n_utf16,
shift_jis = n_sjis,
utf8 = n_utf8,
total = out.len(),
elapsed_ms,
"string scan complete"
);
out
}
const MIN_LEN: usize = 6;
fn is_printable_ascii(b: u8) -> bool {
// Printable + the common whitespace characters used in real strings.
matches!(b, 0x20..=0x7E | b'\t' | b'\n' | b'\r')
}
fn scan_ascii(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
let mut i = 0;
while i < bytes.len() {
if !is_printable_ascii(bytes[i]) {
i += 1;
continue;
}
let start = i;
while i < bytes.len() && is_printable_ascii(bytes[i]) {
i += 1;
}
let run_len = i - start;
// Require NUL termination and minimum length.
if run_len >= MIN_LEN && i < bytes.len() && bytes[i] == 0 {
let s = std::str::from_utf8(&bytes[start..i]).unwrap_or("");
out.push(DetectedString {
address: va_base + start as u32,
encoding: "ascii",
length: run_len as u32,
content: s.to_string(),
section: String::new(),
});
}
// Skip the NUL (if any) before continuing.
if i < bytes.len() && bytes[i] == 0 {
i += 1;
}
}
}
fn scan_utf16le(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
// UTF-16LE strings are 2-byte aligned in MSVC output. Walk on even
// offsets to avoid misaligned hits.
let mut i = 0;
while i + 2 <= bytes.len() {
if !i.is_multiple_of(2) {
i += 1;
continue;
}
let lo = bytes[i];
let hi = bytes[i + 1];
// Restrict scan-start to printable ASCII range with a zero high byte —
// this is what real Xbox 360 wide strings look like.
if hi != 0 || !is_printable_ascii(lo) {
i += 2;
continue;
}
let start = i;
let mut codeunits: Vec<u16> = Vec::new();
while i + 2 <= bytes.len() {
let l = bytes[i];
let h = bytes[i + 1];
if h != 0 || !is_printable_ascii(l) {
break;
}
codeunits.push((h as u16) << 8 | l as u16);
i += 2;
}
// Require NUL u16 terminator.
let nul_terminated = i + 2 <= bytes.len() && bytes[i] == 0 && bytes[i + 1] == 0;
if codeunits.len() >= MIN_LEN && nul_terminated {
let s: String = String::from_utf16_lossy(&codeunits);
out.push(DetectedString {
address: va_base + start as u32,
encoding: "utf16le",
length: ((i - start) as u32),
content: s,
section: String::new(),
});
}
// Skip past the terminator.
if nul_terminated {
i += 2;
}
}
}
/// 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)
}
/// 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)
}
/// 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() {
let start = i;
let mut has_multibyte = false;
let mut nbytes = 0;
while i < bytes.len() {
let b = bytes[i];
if is_sjis_lead(b) && i + 1 < bytes.len() && is_sjis_trail(bytes[i + 1]) {
has_multibyte = true;
nbytes += 2;
i += 2;
} else if is_printable_ascii(b) {
nbytes += 1;
i += 1;
} else {
break;
}
}
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;
}
}
i = end + 1; // skip NUL
} else {
i = start + 1;
if i < bytes.len() && bytes[i] == 0 {
i += 1;
}
}
}
}
/// Scan for UTF-8 strings carrying multi-byte sequences (we already
/// catch pure-ASCII via `scan_ascii`). Validates 2/3-byte sequences;
/// 4-byte (supplementary plane) is uncommon in game text and skipped.
fn scan_utf8(bytes: &[u8], va_base: u32, out: &mut Vec<DetectedString>) {
let mut i = 0;
while i < bytes.len() {
let start = i;
let mut has_multibyte = false;
let mut nbytes = 0;
while i < bytes.len() {
let b = bytes[i];
if b < 0x80 {
if !is_printable_ascii(b) {
break;
}
nbytes += 1;
i += 1;
} else if (b & 0xE0) == 0xC0 {
// 2-byte: 110xxxxx 10xxxxxx
if i + 1 >= bytes.len() || (bytes[i + 1] & 0xC0) != 0x80 {
break;
}
has_multibyte = true;
nbytes += 2;
i += 2;
} else if (b & 0xF0) == 0xE0 {
// 3-byte: 1110xxxx 10xxxxxx 10xxxxxx
if i + 2 >= bytes.len()
|| (bytes[i + 1] & 0xC0) != 0x80
|| (bytes[i + 2] & 0xC0) != 0x80
{
break;
}
has_multibyte = true;
nbytes += 3;
i += 3;
} else {
break;
}
}
if has_multibyte
&& nbytes >= MIN_LEN
&& i < bytes.len()
&& bytes[i] == 0
&& let Ok(s) = std::str::from_utf8(&bytes[start..i])
{
out.push(DetectedString {
address: va_base + start as u32,
encoding: "utf8",
length: nbytes as u32,
content: s.to_string(),
section: String::new(),
});
i += 1; // skip NUL
} else {
i = start + 1;
if i < bytes.len() && bytes[i] == 0 {
i += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_section(name: &str, va: u32, size: u32) -> PeSection {
PeSection {
name: name.into(),
virtual_address: va,
virtual_size: size,
raw_offset: va,
raw_size: size,
flags: 0x4000_0040,
}
}
#[test]
fn detects_ascii_string() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
let s = b"Hello, world!\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_eq!(strings.len(), 1);
assert_eq!(strings[0].encoding, "ascii");
assert_eq!(strings[0].content, "Hello, world!");
assert_eq!(strings[0].address, image_base + 0x1000);
}
#[test]
fn rejects_short_runs() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
let s = b"Hi\0longer string here\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_eq!(strings.len(), 1);
assert_eq!(strings[0].content, "longer string here");
}
#[test]
fn detects_utf16le_string() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
// "Hello!" in UTF-16LE + NUL u16
let s: &[u8] = b"H\0e\0l\0l\0o\0!\0\0\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);
// Both ASCII and UTF-16 may detect — UTF-16 should find it as wide;
// ASCII pass scans bytes and won't see this as a contiguous run
// because of the interleaved 0 bytes (non-printable).
let utf16: Vec<_> = strings.iter().filter(|s| s.encoding == "utf16le").collect();
assert!(utf16.iter().any(|s| s.content == "Hello!"));
}
#[test]
fn detects_shift_jis_string() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
// "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);
// 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]
fn detects_utf8_multibyte_string() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
let off = 0x1000usize;
// "Café" = 'C', 'a', 'f', 0xC3 0xA9 (é), then more ASCII to reach min length
let s: &[u8] = b"Caf\xC3\xA9eteria\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 u8s: Vec<_> = strings.iter().filter(|s| s.encoding == "utf8").collect();
assert_eq!(u8s.len(), 1);
assert_eq!(u8s[0].content, "Café".to_string() + "eteria");
}
#[test]
fn requires_nul_terminator() {
let image_base = 0x82000000u32;
let mut pe = vec![0u8; 0x1100];
// No trailing NUL — should NOT be detected.
let off = 0x1000usize;
let s = b"abcdefghij";
pe[off..off + s.len()].copy_from_slice(s);
// Fill rest of section with 0xFF so the run terminates cleanly without NUL.
for j in off + s.len()..off + 0x100 {
pe[j] = 0xFF;
}
let sections = vec![mk_section(".rdata", 0x1000, 0x100)];
let strings = analyze(&pe, image_base, &sections);
assert_eq!(strings.len(), 0);
}
}

View File

@@ -0,0 +1,963 @@
//! MSVC vtable + RTTI detection.
//!
//! Heuristic two-pass scan over the binary's read-only data sections. Pass 1
//! finds candidate vtables — runs of ≥3 contiguous big-endian u32 values that
//! all land on known function entries. Pass 2 attempts the MSVC RTTI walk
//! `vtable[-1] → CompleteObjectLocator → TypeDescriptor → mangled name`. When
//! RTTI is stripped (typical for shipped game binaries), each anonymous vtable
//! gets a deterministic name `ANON_Class_<hex>` keyed by a hash of its
//! sorted method PCs (so identical vtables across multiple class instances
//! collapse to one entry).
//!
//! What this module does NOT do:
//! - Vtables in heap-allocated memory (built at runtime by ctors) are out of
//! scope — only vtables present statically in `.rdata` / `.data`.
//! - RTTI inheritance (`BaseClassDescriptor` walk) is best-effort; we record
//! the first-level base list when present and leave it NULL otherwise.
//! - Multiple-inheritance "extra" vftables (one per base subobject) are
//! detected as independent vtables; we don't link them.
//!
//! Reference: openrce.org "Reversing Microsoft Visual C++" RTTI articles
//! (CompleteObjectLocator / TypeDescriptor / BaseClassDescriptor layout).
use std::collections::BTreeMap;
use sylpheed_xex::pe::PeSection;
use crate::demangle;
/// Maximum number of consecutive non-function slots tolerated inside an
/// anchor-recovered vtable before the run is considered terminated. MSVC
/// vtables can carry null / pure-virtual / unrecognised-thunk slots in their
/// head or interior; a small budget lets those through without merging two
/// physically-adjacent vtables. Kept small to avoid bridging the gap between
/// distinct tables.
const MAX_ANCHOR_GAP: usize = 2;
/// One detected vtable.
#[derive(Debug, Clone)]
pub struct Vtable {
/// Absolute VA of `vtable[0]` (first method slot).
pub address: u32,
/// Number of methods in the vtable.
pub length: u32,
/// Absolute VA of the `CompleteObjectLocator` from `vtable[-1]`, if it
/// looked like a valid pointer into `.rdata`. NULL when no RTTI / stripped.
pub col_address: Option<u32>,
/// Class name. Demangled from RTTI when available, otherwise the synthetic
/// `ANON_Class_<hex>` form.
pub class_name: String,
/// True when the COL → TypeDescriptor walk succeeded.
pub rtti_present: bool,
/// First-level base class names from `RTTIClassHierarchyDescriptor`, JSON-encoded.
/// `None` when not parseable.
pub base_classes_json: Option<String>,
/// One entry per slot: function VA in `.text`.
pub methods: Vec<u32>,
}
/// Run the vtable scan + RTTI walk. `function_starts` is the set of valid
/// `.text` function entry VAs from M1's corrected `functions` table.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
function_starts: &std::collections::BTreeSet<u32>,
) -> Vec<Vtable> {
analyze_with_anchors(
pe,
image_base,
sections,
function_starts,
&std::collections::BTreeSet::new(),
)
}
/// Like [`analyze`], but additionally recovers vtables whose base address is
/// known a-priori from a constructor vptr-write store (an "anchor"). The
/// contiguity heuristic in pass 1 fragments any vtable whose head region
/// contains words that don't resolve to recognised function entries (null /
/// pure-virtual / unrecognised thunk slots); those vtables are never emitted
/// and the downstream typed-dispatch resolver can't type objects of that
/// class. An anchor is a *content-independent* vtable signal — the ctor
/// literally installs `vtable_base` into `this+0` via
/// `addis/addi (or lis/ori) → stw rX, 0(rThis)` — so for every anchor not
/// already covered by a pass-1 run we synthesise a vtable starting at that
/// base, reading the fnptr-array run while *tolerating* up to
/// [`MAX_ANCHOR_GAP`] consecutive non-function slots before terminating.
///
/// `anchors` are absolute VAs of vtable bases (from
/// [`scan_vptr_write_constants`]). Existing pass-1 vtables are kept unchanged
/// (no regression): an anchor that already coincides with a detected vtable
/// base is skipped, and an anchor that lands *inside* an existing run is also
/// skipped (it's a sub-object pointer, not a fresh table).
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze_with_anchors(
pe: &[u8],
image_base: u32,
sections: &[PeSection],
function_starts: &std::collections::BTreeSet<u32>,
anchors: &std::collections::BTreeSet<u32>,
) -> Vec<Vtable> {
let started = std::time::Instant::now();
// Sections we'll scan for vtable bodies.
let scan_targets: Vec<&PeSection> = sections
.iter()
.filter(|s| matches!(s.name.as_str(), ".rdata" | ".data"))
.collect();
// 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();
for section in scan_targets {
let va_start = image_base + section.virtual_address;
let va_end = va_start + section.virtual_size;
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())];
let mut i = 0usize;
while i + 12 <= bytes.len() {
// Try to start a run at this 4-aligned offset.
if !i.is_multiple_of(4) {
i += 1;
continue;
}
let mut run_len = 0usize;
let mut methods: Vec<u32> = Vec::new();
let mut j = i;
while j + 4 <= bytes.len() {
let val = u32::from_be_bytes([bytes[j], bytes[j + 1], bytes[j + 2], bytes[j + 3]]);
if function_starts.contains(&val) {
methods.push(val);
run_len += 1;
j += 4;
} else {
break;
}
}
if run_len >= 3 {
let address = va_start + (i as u32);
candidates.push(Vtable {
address,
length: run_len as u32,
col_address: None,
class_name: synth_anon_name(&methods),
rtti_present: false,
base_classes_json: None,
methods,
});
i += run_len * 4;
} else {
i += 4;
}
}
let _ = (va_start, va_end);
}
// --- Anchor-driven recovery (vptr-write-anchored vtables) ---
//
// Build a coverage interval set from pass-1 runs so we don't re-emit a
// table for an anchor that already lies within an extracted vtable.
let mut covered: Vec<(u32, u32)> = candidates
.iter()
.map(|v| (v.address, v.address + v.length * 4))
.collect();
covered.sort_unstable();
let is_covered = |addr: u32, covered: &[(u32, u32)]| -> bool {
covered.iter().any(|&(s, e)| addr >= s && addr < e)
};
// Section lookup for "which scan target contains this VA?"
let scan_targets_va: Vec<(u32, u32, usize, usize)> = sections
.iter()
.filter(|s| matches!(s.name.as_str(), ".rdata" | ".data"))
.map(|s| {
let va = image_base + s.virtual_address;
(
va,
va + s.virtual_size,
s.virtual_address as usize,
(s.virtual_address + s.virtual_size) as usize,
)
})
.collect();
// Cap a recovered run at the *next anchor* so two physically-adjacent
// anchored vtables don't merge. We deliberately do NOT cap at pass-1
// fragments: a fragment is a sub-run the contiguity scan carved out of a
// larger table, and the anchor legitimately re-absorbs it (subsumed
// fragments are removed afterwards).
let anchor_bases: std::collections::BTreeSet<u32> = anchors.iter().copied().collect();
let mut recovered = 0usize;
let mut newly: Vec<Vtable> = Vec::new();
for &anchor in anchors {
if is_covered(anchor, &covered) {
continue;
}
// Locate the containing .rdata/.data section.
let Some(&(va_lo, va_hi, raw_lo, raw_hi)) = scan_targets_va
.iter()
.find(|&&(lo, hi, _, _)| anchor >= lo && anchor < hi)
else {
continue;
};
if anchor % 4 != 0 {
continue;
}
let raw_hi = raw_hi.min(pe.len());
// Read the fnptr-array run starting at the anchor. Tolerate small
// gaps of non-function slots (null / pure-virtual / unrecognised),
// but require the run to actually contain at least one real function
// (otherwise it's just data, not a vtable).
let next_base = anchor_bases.range((anchor + 4)..).next().copied();
let mut methods: Vec<u32> = Vec::new();
let mut gap = 0usize;
let mut real_fns = 0usize;
let mut off = (anchor - va_lo) as usize + raw_lo;
let mut va = anchor;
while off + 4 <= raw_hi && va < va_hi {
if let Some(nb) = next_base
&& va >= nb
{
break;
}
let val = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]);
if function_starts.contains(&val) {
methods.push(val);
real_fns += 1;
gap = 0;
} else {
// A non-function slot. Keep the slot (so downstream slot
// indexing stays aligned) but count toward the gap budget.
gap += 1;
if gap > MAX_ANCHOR_GAP {
// Drop the trailing gap slots — they belong past the
// table's end.
methods.truncate(methods.len().saturating_sub(gap - 1));
break;
}
methods.push(val);
}
off += 4;
va += 4;
}
// Trim any trailing non-function slots (the table ends at its last
// real method).
while methods
.last()
.is_some_and(|&m| !function_starts.contains(&m))
{
methods.pop();
}
if real_fns == 0 || methods.is_empty() {
continue;
}
let length = methods.len() as u32;
newly.push(Vtable {
address: anchor,
length,
col_address: None,
class_name: synth_anon_name(&methods),
rtti_present: false,
base_classes_json: None,
methods,
});
recovered += 1;
}
if recovered > 0 {
// Drop pass-1 fragments fully subsumed by a recovered (anchored)
// vtable — the anchor base is authoritative and the fragment was a
// contiguity-scan artifact of the same table. Keep fragments that
// only partially overlap (defensive; shouldn't happen for true
// sub-runs) so we never lose method coverage.
let recovered_spans: Vec<(u32, u32)> = newly
.iter()
.map(|v| (v.address, v.address + v.length * 4))
.collect();
candidates.retain(|v| {
!recovered_spans
.iter()
.any(|&(s, e)| v.address >= s && v.address + v.length * 4 <= e)
});
candidates.extend(newly);
tracing::info!(recovered, "vtables recovered from vptr-write anchors");
}
let _ = &covered;
// RTTI walk: for each candidate, look at vtable[-1].
let pe_image_base = image_base;
for v in &mut candidates {
if v.address < 4 {
continue;
}
let col_off = (v.address - pe_image_base - 4) as usize;
if col_off + 4 > pe.len() {
continue;
}
let col_ptr = u32::from_be_bytes([
pe[col_off],
pe[col_off + 1],
pe[col_off + 2],
pe[col_off + 3],
]);
if col_ptr == 0 {
continue;
}
if !is_in_ranges(col_ptr, &rdata_ranges) {
continue;
}
// 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, &typedesc_ranges)
&& let Some(class) = demangle_rtti_typename(&mangled)
{
v.col_address = Some(col_ptr);
v.class_name = class;
v.rtti_present = true;
v.base_classes_json =
read_class_hierarchy(pe, image_base, hierarchy_ptr, &rdata_ranges);
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
let rtti_count = candidates.iter().filter(|v| v.rtti_present).count();
metrics::histogram!("analysis.phase_ms", "phase" => "vtables").record(elapsed_ms);
tracing::info!(
vtables = candidates.len(),
rtti = rtti_count,
anon = candidates.len() - rtti_count,
elapsed_ms,
"vtable scan complete"
);
candidates
}
fn is_in_ranges(addr: u32, ranges: &[(u32, u32)]) -> bool {
ranges.iter().any(|&(s, e)| addr >= s && addr < e)
}
/// Read 4 big-endian bytes at absolute VA `addr` from the PE image.
fn read_be_u32(pe: &[u8], image_base: u32, addr: u32) -> Option<u32> {
let off = addr.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
}
/// Parse a `CompleteObjectLocator` at VA `col`. Returns
/// `(type_descriptor_ptr, class_hierarchy_descriptor_ptr)` on success.
///
/// Layout (32-bit MSVC):
/// ```text
/// +0x00 signature (0 for x86 without /GR-, can be 1)
/// +0x04 offset within complete object
/// +0x08 cdOffset (this-pointer adjuster)
/// +0x0C TypeDescriptor *
/// +0x10 RTTIClassHierarchyDescriptor *
/// ```
fn read_col(pe: &[u8], image_base: u32, col: u32) -> Option<(u32, u32)> {
let td = read_be_u32(pe, image_base, col + 0x0C)?;
let chd = read_be_u32(pe, image_base, col + 0x10)?;
if td == 0 {
return None;
}
Some((td, chd))
}
/// Read a TypeDescriptor's mangled-name string at VA `td`.
///
/// Layout: `+0x00` vftable ptr, `+0x04` "spare", `+0x08` zero-terminated
/// mangled name (e.g. `.?AVClassName@@`).
fn read_typedescriptor_name(
pe: &[u8],
image_base: u32,
td: u32,
rdata_ranges: &[(u32, u32)],
) -> Option<String> {
if !is_in_ranges(td, rdata_ranges) {
return None;
}
let name_va = td + 0x08;
let off = name_va.wrapping_sub(image_base) as usize;
if off + 1 > pe.len() {
return None;
}
// Read up to 256 bytes or until NUL.
let mut end = off;
while end < pe.len().min(off + 256) && pe[end] != 0 {
end += 1;
}
if end == off {
return None;
}
let s = std::str::from_utf8(&pe[off..end]).ok()?;
// Sanity: MSVC RTTI names always start with `.?A`.
if !s.starts_with(".?A") {
return None;
}
Some(s.to_string())
}
/// Demangle an RTTI type-name string of the form `.?AVClassName@ns@@`.
/// MSVC convention: leading `.` is the marker for an RTTI string; strip it
/// before passing to the demangler.
fn demangle_rtti_typename(rtti_name: &str) -> Option<String> {
let stripped = rtti_name.strip_prefix('.')?;
let raw = msvc_demangler::demangle(stripped, msvc_demangler::DemangleFlags::llvm()).ok()?;
// Output looks like `class xe::apu::AudioSystem` or `struct foo::Bar`.
let cls = raw
.strip_prefix("class ")
.or_else(|| raw.strip_prefix("struct "))
.or_else(|| raw.strip_prefix("union "))
.unwrap_or(&raw);
Some(cls.to_string())
}
/// Best-effort `RTTIClassHierarchyDescriptor` walk: read the
/// `BaseClassArray` entries and demangle each base's TypeDescriptor name.
/// Returns a JSON array string on success.
///
/// Layout:
/// ```text
/// RTTIClassHierarchyDescriptor:
/// +0x00 signature
/// +0x04 attributes
/// +0x08 numBaseClasses
/// +0x0C BaseClassArray * (-> array of BaseClassDescriptor *)
/// BaseClassDescriptor:
/// +0x00 TypeDescriptor *
/// +0x04 numContainedBases
/// ...
/// ```
fn read_class_hierarchy(
pe: &[u8],
image_base: u32,
chd: u32,
rdata_ranges: &[(u32, u32)],
) -> Option<String> {
if !is_in_ranges(chd, rdata_ranges) {
return None;
}
let num_bases = read_be_u32(pe, image_base, chd + 0x08)?;
if num_bases == 0 || num_bases > 256 {
return None;
} // sanity cap
let bca_ptr = read_be_u32(pe, image_base, chd + 0x0C)?;
if !is_in_ranges(bca_ptr, rdata_ranges) {
return None;
}
let mut names: Vec<String> = Vec::new();
for i in 0..num_bases {
let bcd_ptr = match read_be_u32(pe, image_base, bca_ptr + i * 4) {
Some(p) if is_in_ranges(p, rdata_ranges) => p,
_ => return None,
};
let td_ptr = match read_be_u32(pe, image_base, bcd_ptr) {
Some(p) if is_in_ranges(p, rdata_ranges) => p,
_ => return None,
};
let mangled = read_typedescriptor_name(pe, image_base, td_ptr, rdata_ranges)?;
let cls = demangle_rtti_typename(&mangled).unwrap_or(mangled);
names.push(cls);
}
serde_json::to_string(&names).ok()
}
/// Pre-pass: discover candidate vtable *bases* from constructor vptr-write
/// stores, independent of the static contiguity heuristic. A vptr install is
/// the canonical `addis/addi` (or `lis/ori`) immediate build of a constant
/// pointing into `.rdata` / `.data`, followed by `stw rX, 0(rThis)` — i.e. the
/// ctor writing the vtable pointer to `this+0`. We return the set of such
/// constants; these are fed to [`analyze_with_anchors`] so a vtable with
/// non-function head words isn't lost.
///
/// We only consider stores at displacement 0 (the primary vptr; secondary
/// MI vptrs land at non-zero offsets and are handled by the existing
/// contiguity scan / typed-dispatch resolver well enough). The register
/// tracker mirrors the lis+addi propagation used elsewhere and is reset at
/// every basic-block boundary (`block_boundaries`).
pub fn scan_vptr_write_constants(
pe: &[u8],
image_base: u32,
functions: &std::collections::BTreeMap<u32, (u32, bool)>, // start -> (end, is_saverestore)
sections: &[PeSection],
block_boundaries: &std::collections::HashSet<u32>,
) -> std::collections::BTreeSet<u32> {
// Ranges that a vtable base may legitimately live in.
let data_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 in_data = |a: u32| data_ranges.iter().any(|&(s, e)| a >= s && a < e);
const OP_ADDI: u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_ORI: u32 = 24;
const OP_STW: u32 = 36;
const OP_X_FORM: u32 = 31;
let read = |addr: u32| -> Option<u32> {
let off = addr.wrapping_sub(image_base) as usize;
if off + 4 > pe.len() {
return None;
}
Some(u32::from_be_bytes([
pe[off],
pe[off + 1],
pe[off + 2],
pe[off + 3],
]))
};
let mut anchors: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
for (&fn_start, &(fn_end, is_saverestore)) in functions {
if is_saverestore {
continue;
}
let mut reg: [Option<u32>; 32] = [None; 32];
let mut pc = fn_start;
while pc < fn_end {
if pc != fn_start && block_boundaries.contains(&pc) {
reg = [None; 32];
}
let Some(instr) = read(pc) else { break };
let op = instr >> 26;
let rd = ((instr >> 21) & 0x1F) as usize;
let ra = ((instr >> 16) & 0x1F) as usize;
let simm = ((instr & 0xFFFF) as i16) as i32;
let uimm = instr & 0xFFFF;
match op {
OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16),
OP_ADDIS => reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16)),
OP_ADDI if ra != 0 => reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32)),
OP_ADDI => reg[rd] = Some(simm as u32),
OP_ORI => {
let rs = rd;
reg[ra] = reg[rs].map(|b| b | uimm);
}
OP_STW => {
// `stw rS, off(rA)` with displacement 0 = primary vptr install.
if ra != 0
&& simm == 0
&& let Some(val) = reg[rd]
&& in_data(val)
{
anchors.insert(val);
}
}
32..=35 | 40..=43 | 48..=51 => reg[rd] = None,
OP_X_FORM => {
let xo = (instr >> 1) & 0x3FF;
if xo != 444 && xo != 467 {
reg[rd] = None;
} // keep `or`(444=mr)/`mtspr`-ish
}
18 | 16 if (instr & 1) != 0 => {
for r in 0..=12 {
reg[r] = None;
}
}
_ => {}
}
pc = pc.wrapping_add(4);
}
}
anchors
}
/// Synthetic name for an RTTI-stripped vtable, derived from a stable hash of
/// the sorted method-PC list. Two vtables with identical method ordering
/// collapse to the same anonymous name.
fn synth_anon_name(methods: &[u32]) -> String {
// FNV-1a 64-bit on the sorted PC list; we only use 32 bits for brevity.
let mut sorted = methods.to_vec();
sorted.sort_unstable();
let mut h: u64 = 0xcbf29ce484222325;
for pc in &sorted {
for b in pc.to_le_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
}
format!("ANON_Class_{:08X}", (h as u32))
}
/// Build the per-method `(vtable_address, slot, function_address)` list for
/// DB insertion, with optional demangled-name lookup for any function that
/// has a matching `?…` label. Skips slots whose function isn't in the
/// supplied label map.
pub fn methods_table(
vtables: &[Vtable],
labels: &std::collections::HashMap<u32, String>,
) -> Vec<(u32, u32, u32, Option<String>, Option<String>)> {
let mut out = Vec::new();
for v in vtables {
for (slot, &fn_va) in v.methods.iter().enumerate() {
let label = labels.get(&fn_va).cloned();
let demangled = label
.as_ref()
.and_then(|l| demangle::demangle(l).map(|d| d.raw_demangled));
out.push((v.address, slot as u32, fn_va, label, demangled));
}
}
out
}
/// Build a `class_name → Vtable` summary for the `classes` table. Multiple
/// vtables sharing the same class name (multiple instances at link time)
/// collapse via `BTreeMap` — the first detected vtable wins.
pub fn classes_table(vtables: &[Vtable]) -> Vec<(String, u32, bool, Option<String>)> {
let mut by_name: BTreeMap<String, &Vtable> = BTreeMap::new();
for v in vtables {
by_name.entry(v.class_name.clone()).or_insert(v);
}
by_name
.into_iter()
.map(|(name, v)| (name, v.address, v.rtti_present, v.base_classes_json.clone()))
.collect()
}
// ── 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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synth_anon_name_is_stable() {
let a = synth_anon_name(&[0x82001000, 0x82001100, 0x82001200]);
let b = synth_anon_name(&[0x82001200, 0x82001000, 0x82001100]);
assert_eq!(a, b, "anon name must be order-independent");
}
#[test]
fn synth_anon_name_differs_for_different_methods() {
let a = synth_anon_name(&[0x82001000, 0x82001100]);
let b = synth_anon_name(&[0x82002000, 0x82002100]);
assert_ne!(a, b);
}
#[test]
fn detects_3_method_vtable_in_rdata() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let text_va = 0x2000u32;
let rdata_size = 16u32;
let text_size = 0x100u32;
// PE buffer big enough for both sections.
let total = (text_va + text_size) as usize;
let mut pe = vec![0u8; total];
// Vtable: 3 method PCs at .rdata start, all valid function entries.
let m: [u32; 3] = [
image_base + text_va,
image_base + text_va + 0x10,
image_base + text_va + 0x20,
];
for (i, val) in m.iter().enumerate() {
pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4]
.copy_from_slice(&val.to_be_bytes());
}
let sections = vec![
PeSection {
name: ".rdata".into(),
virtual_address: rdata_va,
virtual_size: rdata_size,
raw_offset: rdata_va,
raw_size: rdata_size,
flags: 0x4000_0040,
},
PeSection {
name: ".text".into(),
virtual_address: text_va,
virtual_size: text_size,
raw_offset: text_va,
raw_size: text_size,
flags: 0x6000_0020,
},
];
let mut function_starts = std::collections::BTreeSet::new();
for &pc in &m {
function_starts.insert(pc);
}
let vtables = analyze(&pe, image_base, &sections, &function_starts);
assert_eq!(vtables.len(), 1);
assert_eq!(vtables[0].length, 3);
assert_eq!(vtables[0].address, image_base + rdata_va);
assert!(vtables[0].class_name.starts_with("ANON_Class_"));
assert!(!vtables[0].rtti_present);
}
#[test]
fn anchor_recovers_vtable_with_nonfn_head() {
// A vtable whose head has a null + an unrecognised word, so the
// contiguity scan (≥3 contiguous known fns) fragments it. The anchor
// (from a ctor vptr-write) must recover the whole table from its base.
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let text_va = 0x2000u32;
let rdata_size = 0x40u32;
let text_size = 0x100u32;
let total = (text_va + text_size) as usize;
let mut pe = vec![0u8; total];
let f0 = image_base + text_va;
let f1 = image_base + text_va + 0x10;
let f2 = image_base + text_va + 0x20;
// Slots: [null, NONFN(0xDEAD), f0, f1, f2]
let slots: [u32; 5] = [0, 0xDEADBEEF, f0, f1, f2];
for (i, val) in slots.iter().enumerate() {
pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4]
.copy_from_slice(&val.to_be_bytes());
}
let sections = vec![
PeSection {
name: ".rdata".into(),
virtual_address: rdata_va,
virtual_size: rdata_size,
raw_offset: rdata_va,
raw_size: rdata_size,
flags: 0x4000_0040,
},
PeSection {
name: ".text".into(),
virtual_address: text_va,
virtual_size: text_size,
raw_offset: text_va,
raw_size: text_size,
flags: 0x6000_0020,
},
];
let mut function_starts = std::collections::BTreeSet::new();
for &pc in &[f0, f1, f2] {
function_starts.insert(pc);
}
// Without an anchor: the head gap (null + nonfn = 2 slots) means the
// contiguous run is only [f0,f1,f2]=3 starting at +0x08, so pass-1
// still finds it but at the WRONG base (0x...1008), not the true base.
let no_anchor = analyze(&pe, image_base, &sections, &function_starts);
assert!(
!no_anchor.iter().any(|v| v.address == image_base + rdata_va),
"without anchor the table is not recovered at its true base"
);
// With the anchor at the true base:
let mut anchors = std::collections::BTreeSet::new();
anchors.insert(image_base + rdata_va);
let with_anchor =
analyze_with_anchors(&pe, image_base, &sections, &function_starts, &anchors);
let v = with_anchor
.iter()
.find(|v| v.address == image_base + rdata_va)
.expect("anchor must recover vtable at its true base");
// length spans through f2 (slot 4): 5 slots.
assert_eq!(v.length, 5, "table spans null/nonfn head through last fn");
assert_eq!(v.methods[2], f0);
assert_eq!(v.methods[4], f2);
}
#[test]
fn scan_vptr_write_constants_finds_ctor_store() {
// Encode a ctor: addis r11,r0,0x8201; addi r11,r11,lo; stw r11,0(r31)
// installing vtable base 0x8200A908 into this+0.
let image_base = 0x82000000u32;
let ctor = 0x82001000u32;
let mut pe = vec![0u8; 0x4000];
// Lay out a tiny .rdata at 0x...A900 so the constant lands in-range.
let vt_base = 0x8200A908u32; // 0x82010000 - 22264
let addis = ((15u32 << 26) | (11 << 21)) | 0x8201;
let lo = (vt_base & 0xFFFF) as i16; // -22264
let addi = ((14u32 << 26) | (11 << 21)) | ((lo as u16) as u32);
// addi r11,r0,lo would set r11=lo (sign-extended); we need addis+addi
// chained. Re-encode addis into r11 from r0, then addi r11,r11,lo.
let addi2 = (14u32 << 26) | (11 << 21) | (11 << 16) | ((lo as u16) as u32);
let stw = (36u32 << 26) | (11 << 21) | (31 << 16); // stw r11,0(r31)
let at = (ctor - image_base) as usize;
pe[at..at + 4].copy_from_slice(&addis.to_be_bytes());
pe[at + 4..at + 8].copy_from_slice(&addi2.to_be_bytes());
pe[at + 8..at + 12].copy_from_slice(&stw.to_be_bytes());
let _ = addi;
let sections = vec![PeSection {
name: ".rdata".into(),
virtual_address: 0xA900,
virtual_size: 0x200,
raw_offset: 0xA900,
raw_size: 0x200,
flags: 0x4000_0040,
}];
let mut funcs: std::collections::BTreeMap<u32, (u32, bool)> =
std::collections::BTreeMap::new();
funcs.insert(ctor, (ctor + 0x40, false));
let anchors = scan_vptr_write_constants(
&pe,
image_base,
&funcs,
&sections,
&std::collections::HashSet::new(),
);
assert!(
anchors.contains(&vt_base),
"ctor vptr store must yield anchor {vt_base:#x}, got {anchors:?}"
);
}
#[test]
fn rejects_2_method_run() {
let image_base = 0x82000000u32;
let rdata_va = 0x1000u32;
let text_va = 0x2000u32;
let total = (text_va + 0x100) as usize;
let mut pe = vec![0u8; total];
let m: [u32; 2] = [image_base + text_va, image_base + text_va + 0x10];
for (i, val) in m.iter().enumerate() {
pe[rdata_va as usize + i * 4..rdata_va as usize + (i + 1) * 4]
.copy_from_slice(&val.to_be_bytes());
}
let sections = vec![
PeSection {
name: ".rdata".into(),
virtual_address: rdata_va,
virtual_size: 8,
raw_offset: rdata_va,
raw_size: 8,
flags: 0x4000_0040,
},
PeSection {
name: ".text".into(),
virtual_address: text_va,
virtual_size: 0x100,
raw_offset: text_va,
raw_size: 0x100,
flags: 0x6000_0020,
},
];
let mut function_starts = std::collections::BTreeSet::new();
for &pc in &m {
function_starts.insert(pc);
}
let vtables = analyze(&pe, image_base, &sections, &function_starts);
assert_eq!(
vtables.len(),
0,
"runs of 2 must be rejected to keep false-positive rate down"
);
}
}

View File

@@ -0,0 +1,478 @@
//! 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 `sylpheed_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

@@ -0,0 +1,650 @@
//! Cross-reference analysis for Xbox 360 PE images.
use crate::func::FuncAnalysis;
use std::collections::HashMap;
use sylpheed_xex::pe::PeSection;
// ── Cross-reference types ────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
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
DataWrite, // stw, stb, sth, stfs, stfd, etc. to resolved address
DataRef, // address computed via lis+addi/ori but not directly loaded/stored
}
impl XrefKind {
pub fn tag(self) -> &'static str {
match self {
XrefKind::Call => "call",
XrefKind::IndirectCall => "ind_call",
XrefKind::JumpTable => "jt",
XrefKind::Jump => "j",
XrefKind::Branch => "br",
XrefKind::DataRead => "read",
XrefKind::DataWrite => "write",
XrefKind::DataRef => "ref",
}
}
pub fn is_data(self) -> bool {
matches!(
self,
XrefKind::DataRead | XrefKind::DataWrite | XrefKind::DataRef
)
}
pub fn db_tag(self) -> &'static str {
self.tag()
}
}
/// Sub-classification of how `source`'s instruction computes its target
/// address. Only meaningful for data xrefs (`read` / `write` / `ref`); call
/// / jump / branch / ind_call rows store `None`.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum AddrMode {
/// Standard signed-16 displacement: `lwz rD, simm(rA)`, `stw rS, simm(rA)`,
/// FP D-forms (`lfs/lfd/stfs/stfd`), update variants. The dominant case.
DForm,
/// Address materialised via `lis + addi` register tracking — no
/// load/store yet at this site.
LisAddi,
/// Address materialised via `lis + ori` register tracking.
LisOri,
/// Multi-word D-form: `lmw / stmw rS, simm(rA)` — emits one xref per
/// register slot (32-rS slots starting at the resolved base).
Multiword,
/// X-form indexed: `stwx / stbx / sthx / stwux / stbux / sthux / stdx /
/// stdux` plus AltiVec/VMX vector stores `stvx / stvxl / stvebx /
/// stvehx / stvewx`. Static resolution requires both rA and rB
/// constant. (M6 + VMX follow-up.)
XFormIndexed,
/// X-form byte-reverse: `stwbrx / sthbrx / lwbrx / lhbrx`.
XFormByteRev,
/// Reservation/atomic store-conditional: `stwcx. / stdcx.`.
Atomic,
/// Cache-line clear: `dcbz rA, rB` — clears 32 bytes at rA+rB.
DCBZ,
}
impl AddrMode {
pub fn tag(self) -> &'static str {
match self {
AddrMode::DForm => "d_form",
AddrMode::LisAddi => "lis_addi",
AddrMode::LisOri => "lis_ori",
AddrMode::Multiword => "multiword",
AddrMode::XFormIndexed => "x_form_indexed",
AddrMode::XFormByteRev => "x_form_byterev",
AddrMode::Atomic => "atomic",
AddrMode::DCBZ => "dcbz",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Xref {
pub source: u32,
pub kind: XrefKind,
/// `None` for control-flow edges; `Some(...)` for data edges.
pub addr_mode: Option<AddrMode>,
}
pub type XrefMap = HashMap<u32, Vec<Xref>>;
/// Result of cross-reference analysis.
pub struct XrefResult {
pub labels: HashMap<u32, String>,
pub xrefs: XrefMap,
pub data_annotations: HashMap<u32, (u32, XrefKind)>,
}
/// Perform full cross-reference analysis on a PE image.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base), entry_point = format_args!("{:#010x}", entry_point)))]
pub fn analyze_xrefs(
pe: &[u8],
image_base: u32,
entry_point: u32,
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();
let mut labels: HashMap<u32, String> = func_labels;
labels.insert(entry_point, "entry_point".to_string());
// Add import thunks as labels
for (addr, name) in import_map {
labels.insert(*addr, format!("__imp_{}", name.replace("::", "_")));
}
// First pass: collect branch targets + cross-references from code sections
let mut xrefs: XrefMap = HashMap::new();
for section in sections {
if !section.is_code() {
continue;
}
let va_start = section.virtual_address;
let va_end = va_start + section.virtual_size;
let file_start = section.virtual_address as usize;
let mut addr = va_start;
while addr < va_end {
let abs_addr = image_base + addr;
let off = (addr - va_start) as usize + file_start;
if off + 4 > pe.len() {
break;
}
let instr = u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]);
if !data_words.contains(&abs_addr) {
collect_branch_target(instr, abs_addr, &mut labels, &mut xrefs);
}
addr += 4;
}
}
// Second pass: resolve data references via lis+load/store pattern matching
let mut data_annotations: HashMap<u32, (u32, XrefKind)> = HashMap::new();
// Build set of valid data address ranges for filtering false positives
let data_ranges: Vec<(u32, u32)> = sections
.iter()
.map(|s| {
(
image_base + s.virtual_address,
image_base + s.virtual_address + s.virtual_size,
)
})
.collect();
for section in sections {
if !section.is_code() {
continue;
}
let va_start = section.virtual_address;
let va_end = va_start + section.virtual_size;
let file_start = section.virtual_address as usize;
// Register state: track lis results. reg_hi[r] = Some(high_16_bits << 16)
let mut reg_hi: [Option<u32>; 32] = [None; 32];
let mut addr = va_start;
while addr < va_end {
let abs_addr = image_base + addr;
let off = (addr - va_start) as usize + file_start;
if off + 4 > pe.len() {
break;
}
let instr = u32::from_be_bytes([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;
let simm = ((instr & 0xFFFF) as i16) as i32;
let uimm = instr & 0xFFFF;
// Reset tracking on function boundaries (prologue = mfspr rN, LR)
if opcode == 31 {
let xo = (instr >> 1) & 0x3FF;
if xo == 339 {
// mfspr
let spr = (((instr >> 16) & 0x1F) << 5) | ((instr >> 11) & 0x1F);
if spr == 8 {
// LR
reg_hi = [None; 32];
}
}
}
match opcode {
// lis rD, IMM (encoded as addis rD, r0, IMM)
15 if ra == 0 => {
reg_hi[rd] = Some(uimm << 16);
}
// addis rD, rA, IMM (rA != 0) — if rA has known lis, update
15 if ra != 0 => {
if let Some(base) = reg_hi[ra] {
reg_hi[rd] = Some(base.wrapping_add(uimm << 16));
} else {
reg_hi[rd] = None;
}
}
// addi rD, rA, IMM — compute full address if rA has known lis
14 if ra != 0 => {
if let Some(base) = reg_hi[ra] {
let data_addr = base.wrapping_add(simm as u32);
if is_in_ranges(data_addr, &data_ranges) {
data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRef));
xrefs.entry(data_addr).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataRef,
addr_mode: Some(AddrMode::LisAddi),
});
labels
.entry(data_addr)
.or_insert_with(|| format!("dat_{data_addr:08X}"));
}
reg_hi[rd] = Some(data_addr); // propagate for chained access
} else {
reg_hi[rd] = None;
}
}
// ori rA, rS, UIMM — compute full address
24 => {
let rs = rd; // source is bits 21-25 for ori
if let Some(base) = reg_hi[rs] {
let data_addr = base | uimm;
if is_in_ranges(data_addr, &data_ranges) {
data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRef));
xrefs.entry(data_addr).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataRef,
addr_mode: Some(AddrMode::LisOri),
});
labels
.entry(data_addr)
.or_insert_with(|| format!("dat_{data_addr:08X}"));
}
reg_hi[ra] = Some(data_addr);
} else {
reg_hi[ra] = None;
}
}
// Load instructions: lwz, lbz, lhz, lha, lfs, lfd, lwzu, etc.
32 | 33 | 34 | 35 | 40 | 41 | 42 | 43 | 48 | 49 | 50 | 51 => {
if ra != 0
&& let Some(base) = reg_hi[ra]
{
let data_addr = base.wrapping_add(simm as u32);
if is_in_ranges(data_addr, &data_ranges) {
data_annotations.insert(abs_addr, (data_addr, XrefKind::DataRead));
xrefs.entry(data_addr).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataRead,
addr_mode: Some(AddrMode::DForm),
});
labels
.entry(data_addr)
.or_insert_with(|| format!("dat_{data_addr:08X}"));
}
}
// Load into rD may clobber the tracked value
reg_hi[rd] = None;
}
// lmw rD, simm(rA) — D-form multi-word load. Reads (32-rD)
// consecutive 4-byte words starting at base+simm into
// rD..r31. Emits one DataRead per slot.
46 => {
if ra != 0
&& let Some(base) = reg_hi[ra]
{
let mut addr_w = base.wrapping_add(simm as u32);
for _slot in (rd as u32)..32 {
if is_in_ranges(addr_w, &data_ranges) {
data_annotations.insert(abs_addr, (addr_w, XrefKind::DataRead));
xrefs.entry(addr_w).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataRead,
addr_mode: Some(AddrMode::Multiword),
});
labels
.entry(addr_w)
.or_insert_with(|| format!("dat_{addr_w:08X}"));
}
addr_w = addr_w.wrapping_add(4);
}
}
reg_hi[rd] = None;
}
// Store instructions: stw, stb, sth, stfs, stfd, stwu, etc.
36 | 37 | 38 | 39 | 44 | 45 | 52 | 53 | 54 | 55 => {
if ra != 0
&& let Some(base) = reg_hi[ra]
{
let data_addr = base.wrapping_add(simm as u32);
if is_in_ranges(data_addr, &data_ranges) {
data_annotations.insert(abs_addr, (data_addr, XrefKind::DataWrite));
xrefs.entry(data_addr).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataWrite,
addr_mode: Some(AddrMode::DForm),
});
labels
.entry(data_addr)
.or_insert_with(|| format!("dat_{data_addr:08X}"));
}
}
}
// stmw rS, simm(rA) — D-form multi-word store. Writes
// (32-rS) consecutive 4-byte words from rS..r31 to
// base+simm onward. Emits one DataWrite per slot.
47 => {
if ra != 0
&& let Some(base) = reg_hi[ra]
{
let mut addr_w = base.wrapping_add(simm as u32);
for _slot in (rd as u32)..32 {
if is_in_ranges(addr_w, &data_ranges) {
data_annotations.insert(abs_addr, (addr_w, XrefKind::DataWrite));
xrefs.entry(addr_w).or_default().push(Xref {
source: abs_addr,
kind: XrefKind::DataWrite,
addr_mode: Some(AddrMode::Multiword),
});
labels
.entry(addr_w)
.or_insert_with(|| format!("dat_{addr_w:08X}"));
}
addr_w = addr_w.wrapping_add(4);
}
}
}
// X-form: opcode 31 — indexed loads/stores, atomic ops, dcbz.
// We can't statically resolve `rA + rB` without tracking rB
// too; we record an xref ONLY when rB is also a known
// constant (rare) OR when rB is r0 (which encodes as zero).
// Falls through to the generic-clobber arm afterwards via
// the explicit reg_hi update.
31 => {
let xo = (instr >> 1) & 0x3FF;
let rb = ((instr >> 11) & 0x1F) as usize;
let resolve_rab = |reg_hi: &[Option<u32>; 32]| -> Option<u32> {
let a = if ra == 0 { Some(0u32) } else { reg_hi[ra] };
let b = if rb == 0 { Some(0u32) } else { reg_hi[rb] };
match (a, b) {
(Some(av), Some(bv)) => Some(av.wrapping_add(bv)),
_ => None,
}
};
let mode_for_xo = |xo: u32| -> Option<(AddrMode, XrefKind)> {
match xo {
// Atomic store-conditional
150 => Some((AddrMode::Atomic, XrefKind::DataWrite)), // stwcx.
214 => Some((AddrMode::Atomic, XrefKind::DataWrite)), // stdcx.
// Byte-reverse stores
662 => Some((AddrMode::XFormByteRev, XrefKind::DataWrite)), // stwbrx
918 => Some((AddrMode::XFormByteRev, XrefKind::DataWrite)), // sthbrx
// Byte-reverse loads
534 => Some((AddrMode::XFormByteRev, XrefKind::DataRead)), // lwbrx
790 => Some((AddrMode::XFormByteRev, XrefKind::DataRead)), // lhbrx
// dcbz — cache-line zero (32-byte clear). Treat as a write.
1014 => Some((AddrMode::DCBZ, XrefKind::DataWrite)),
// Plain X-form indexed stores (the common ones)
151 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stwx
215 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stbx
407 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // sthx
183 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stwux
247 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stbux
439 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // sthux
149 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stdx
181 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stdux
// Plain X-form indexed loads
23 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lwzx
87 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lbzx
279 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhzx
343 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhax
55 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lwzux
119 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lbzux
311 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhzux
375 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lhaux
21 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // ldx
53 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // ldux
// AltiVec/VMX (opcode 31) loads & stores. Element
// variants store one byte/halfword/word; full
// `stvx` stores 16 bytes. Address resolution still
// requires both rA and rB constant — common only
// in static-table setup loops.
231 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvx
487 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvxl
135 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvebx
167 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvehx
199 => Some((AddrMode::XFormIndexed, XrefKind::DataWrite)), // stvewx
// AltiVec/VMX loads — same XO range, kind=read.
103 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvx
359 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvxl
7 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvebx
39 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvehx
71 => Some((AddrMode::XFormIndexed, XrefKind::DataRead)), // lvewx
_ => None,
}
};
if let Some((addr_mode, kind)) = mode_for_xo(xo)
&& let Some(data_addr) = resolve_rab(&reg_hi)
&& is_in_ranges(data_addr, &data_ranges)
{
data_annotations.insert(abs_addr, (data_addr, kind));
xrefs.entry(data_addr).or_default().push(Xref {
source: abs_addr,
kind,
addr_mode: Some(addr_mode),
});
labels
.entry(data_addr)
.or_insert_with(|| format!("dat_{data_addr:08X}"));
}
// Fall through: any X-form op may write rD; invalidate.
reg_hi[rd] = None;
}
// Any other instruction writing to rD: invalidate
_ => {
// Conservatively invalidate for instructions that modify rD
// (most ALU ops, loads, etc.)
if opcode != 18 && opcode != 16 && opcode != 17 {
// skip branch/sc
reg_hi[rd] = None;
}
}
}
addr += 4;
}
}
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("analysis.phase_ms", "phase" => "xrefs").record(elapsed_ms);
let total_xrefs: usize = xrefs.values().map(|v| v.len()).sum();
tracing::info!(
labels = labels.len(),
xrefs = total_xrefs,
data_annotations = data_annotations.len(),
elapsed_ms,
"xref analysis complete"
);
XrefResult {
labels,
xrefs,
data_annotations,
}
}
fn collect_branch_target(
instr: u32,
addr: u32,
labels: &mut HashMap<u32, String>,
xrefs: &mut XrefMap,
) {
let op = (instr >> 26) & 0x3F;
match op {
18 => {
// I-form: b/bl/ba/bla
let li = sign_ext26(instr & 0x03FFFFFC);
let aa = instr & 2 != 0;
let lk = instr & 1 != 0;
let target = if aa {
li as u32
} else {
addr.wrapping_add(li as u32)
};
labels
.entry(target)
.or_insert_with(|| format!("loc_{target:08X}"));
let kind = if lk { XrefKind::Call } else { XrefKind::Jump };
xrefs.entry(target).or_default().push(Xref {
source: addr,
kind,
addr_mode: None,
});
}
16 => {
// B-form: bc/bcl
let bd = sign_ext16(instr & 0xFFFC);
let aa = instr & 2 != 0;
let target = if aa {
bd as u32
} else {
addr.wrapping_add(bd as u32)
};
labels
.entry(target)
.or_insert_with(|| format!("loc_{target:08X}"));
xrefs.entry(target).or_default().push(Xref {
source: addr,
kind: XrefKind::Branch,
addr_mode: None,
});
}
_ => {}
}
}
fn sign_ext16(val: u32) -> i32 {
((val << 16) as i32) >> 16
}
fn sign_ext26(val: u32) -> i32 {
((val << 6) as i32) >> 6
}
fn is_in_ranges(addr: u32, ranges: &[(u32, u32)]) -> bool {
ranges
.iter()
.any(|&(start, end)| addr >= start && addr < end)
}
/// Find which section a data address falls in.
pub fn section_for_addr(addr: u32, sections: &[PeSection], image_base: u32) -> Option<&str> {
for s in sections {
let start = image_base + s.virtual_address;
let end = start + s.virtual_size;
if addr >= start && addr < end {
return Some(&s.name);
}
}
None
}
/// Resolve a source address to "function_name+0xNN" or just "0xADDR".
pub fn resolve_source_label(
addr: u32,
func_analysis: &FuncAnalysis,
labels: &HashMap<u32, String>,
) -> String {
// Direct label hit?
if let Some(lbl) = labels.get(&addr) {
return lbl.clone();
}
// Find the containing function (largest start <= addr)
if let Some((&func_start, _fi)) = func_analysis.functions.range(..=addr).next_back()
&& let Some(func_label) = labels.get(&func_start)
{
let offset = addr - func_start;
return format!("{func_label}+0x{offset:X}");
}
format!("0x{addr:08X}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn addr_mode_tags_are_distinct() {
let modes = [
AddrMode::DForm,
AddrMode::LisAddi,
AddrMode::LisOri,
AddrMode::Multiword,
AddrMode::XFormIndexed,
AddrMode::XFormByteRev,
AddrMode::Atomic,
AddrMode::DCBZ,
];
let tags: std::collections::HashSet<&str> = modes.iter().map(|m| m.tag()).collect();
assert_eq!(
tags.len(),
modes.len(),
"every AddrMode variant must have a unique tag"
);
}
#[test]
fn xref_struct_carries_addr_mode_for_data_edges() {
let x = Xref {
source: 0x1234,
kind: XrefKind::DataWrite,
addr_mode: Some(AddrMode::DForm),
};
assert_eq!(x.addr_mode.unwrap().tag(), "d_form");
}
#[test]
fn xref_struct_addr_mode_is_none_for_call_edges() {
let x = Xref {
source: 0x1234,
kind: XrefKind::Call,
addr_mode: None,
};
assert!(x.addr_mode.is_none());
}
}

View File

@@ -0,0 +1,568 @@
//! DB schema golden — locks the column layout (names + types) of every
//! table written by `DbWriter`. A schema change here without a fixture
//! update fails the test, forcing a conscious decision before downstream
//! query consumers break.
//!
//! The fixture is constructed in-process (no XEX/ISO needed): a small
//! synthetic PE-shaped byte slice with one `.text` section of 4
//! instructions, plus an empty import-library list and one detected
//! function.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Write;
use duckdb::Connection;
use sylpheed_xex::pe::PeSection;
use sylpheed_xexdb::DbWriter;
use sylpheed_xexdb::formatter::DisasmInfo;
use sylpheed_xexdb::func::{FuncAnalysis, FuncInfo};
use sylpheed_xexdb::rtti::RttiResult;
use sylpheed_xexdb::xref::XrefMap;
/// Build a 16-byte `.text` section: 4 instructions (mflr / nop / blr / nop).
fn synthetic_pe() -> (
Vec<u8>,
Vec<PeSection>,
Vec<sylpheed_xex::header::ImportLibrary>,
) {
// VA layout: image_base + 0x1000 = .text start (so RVA = 0x1000).
// The DB writer expects pe[rva] to hold the byte at that RVA, so the
// buffer must be at least 0x1000 + section_size bytes long.
const RVA: usize = 0x1000;
const TEXT: [u32; 4] = [
// mfspr r12, LR (a.k.a. mflr r12) — opcode 31, xo 339, spr 8 (LR).
// Encoded with spr halves swapped per the ISA: spr_field = (8<<5).
(31u32 << 26) | (12 << 21) | ((8 << 5) << 11) | (339 << 1),
0x60000000, // nop (ori r0, r0, 0)
(19u32 << 26) | (20 << 21) | (16 << 1), // blr (bclr 20, 0)
0x60000000, // nop
];
let mut pe = vec![0u8; RVA + 16];
for (i, &word) in TEXT.iter().enumerate() {
pe[RVA + i * 4..RVA + i * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
let sections = vec![PeSection {
name: ".text".to_string(),
virtual_address: 0x1000,
virtual_size: 16,
raw_offset: 0x1000,
raw_size: 16,
flags: 0x60000020, // CODE | EXECUTE | READ
}];
let import_libraries = vec![]; // No imports in the fixture.
(pe, sections, import_libraries)
}
fn synthetic_func_analysis(image_base: u32) -> FuncAnalysis {
// Single function covering all four .text instructions.
let entry = image_base + 0x1000;
let mut functions = BTreeMap::new();
functions.insert(
entry,
FuncInfo {
start: entry,
end: entry + 16,
frame_size: 0,
saved_gprs: 0,
is_leaf: true,
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(),
}
}
#[test]
fn db_schema_matches_expected_columns() {
let (pe, sections, libs) = synthetic_pe();
let image_base = 0x82000000u32;
let entry = image_base + 0x1000;
let info = DisasmInfo {
image_base,
entry_point: entry,
original_pe_name: Some("synthetic.exe"),
title_id: Some(0xDEADBEEF),
media_id: Some(0xCAFEF00D),
sections: &sections,
import_libraries: &libs,
xex_header: None,
};
let func_analysis = synthetic_func_analysis(image_base);
let mut labels: HashMap<u32, String> = HashMap::new();
labels.insert(entry, "entry_point".to_string());
let xrefs: XrefMap = XrefMap::new();
let tmp = std::env::temp_dir().join("sylpheed_xexdb_schema_golden.duckdb");
let _ = std::fs::remove_file(&tmp);
{
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, &BTreeSet::new())
.expect("ingest_instructions");
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");
}
let conn = Connection::open(&tmp).expect("reopen DB");
// Lock the column layout per table. Pairs are (name, type).
let expected: &[(&str, &[(&str, &str)])] = &[
("metadata", &[("key", "VARCHAR"), ("value", "VARCHAR")]),
(
"sections",
&[
("name", "VARCHAR"),
("virtual_address", "BIGINT"),
("virtual_size", "BIGINT"),
("raw_offset", "BIGINT"),
("raw_size", "BIGINT"),
("flags", "BIGINT"),
("is_code", "BOOLEAN"),
],
),
(
"imports",
&[
("library", "VARCHAR"),
("ordinal", "BIGINT"),
("name", "VARCHAR"),
("record_type", "BIGINT"),
("address", "BIGINT"),
],
),
(
"instructions",
&[
("address", "BIGINT"),
("raw", "BIGINT"),
("mnemonic", "VARCHAR"),
("operands", "VARCHAR"),
("disasm", "VARCHAR"),
("ext_mnemonic", "VARCHAR"),
("ext_operands", "VARCHAR"),
("ext_disasm", "VARCHAR"),
("target_hex", "BIGINT"),
("section", "VARCHAR"),
("function", "BIGINT"),
("label", "VARCHAR"),
("is_data", "BOOLEAN"),
],
),
(
"functions",
&[
("address", "BIGINT"),
("name", "VARCHAR"),
("end_address", "BIGINT"),
("frame_size", "BIGINT"),
("saved_gprs", "BIGINT"),
("is_leaf", "BOOLEAN"),
("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"),
("function_length", "BIGINT"),
("prolog_length", "BIGINT"),
("flags", "BIGINT"),
],
),
(
"labels",
&[
("address", "BIGINT"),
("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"),
("raw_demangled", "VARCHAR"),
("namespace_path", "VARCHAR"),
("class_name", "VARCHAR"),
("method_name", "VARCHAR"),
("params_signature", "VARCHAR"),
],
),
(
"vtables",
&[
("address", "BIGINT"),
("length", "BIGINT"),
("col_address", "BIGINT"),
("class_name", "VARCHAR"),
("rtti_present", "BOOLEAN"),
("base_classes_json", "VARCHAR"),
],
),
(
"methods",
&[
("vtable_address", "BIGINT"),
("slot", "BIGINT"),
("function_address", "BIGINT"),
("mangled_name", "VARCHAR"),
("demangled_name", "VARCHAR"),
],
),
(
"classes",
&[
("name", "VARCHAR"),
("vtable_address", "BIGINT"),
("rtti_present", "BOOLEAN"),
("base_classes_json", "VARCHAR"),
],
),
(
"strings",
&[
("address", "BIGINT"),
("encoding", "VARCHAR"),
("length", "BIGINT"),
("content", "VARCHAR"),
("section", "VARCHAR"),
],
),
(
"tls_info",
&[
("raw_data_start", "BIGINT"),
("raw_data_end", "BIGINT"),
("index_address", "BIGINT"),
("callback_array", "BIGINT"),
("zero_fill_size", "BIGINT"),
("characteristics", "BIGINT"),
],
),
(
"tls_callbacks",
&[("slot", "BIGINT"), ("address", "BIGINT")],
),
(
"function_pointer_arrays",
&[
("address", "BIGINT"),
("length", "BIGINT"),
("kind", "VARCHAR"),
],
),
(
"function_pointer_array_entries",
&[
("array_address", "BIGINT"),
("slot", "BIGINT"),
("function_address", "BIGINT"),
],
),
(
"indirect_dispatch_sites",
&[
("dispatch_pc", "BIGINT"),
("vptr_offset", "BIGINT"),
("slot", "BIGINT"),
("candidate_count", "BIGINT"),
("truncated", "BOOLEAN"),
],
),
(
"indirect_dispatch_candidates",
&[
("dispatch_pc", "BIGINT"),
("vtable_address", "BIGINT"),
("method_address", "BIGINT"),
],
),
(
"vptr_writes",
&[
("writer_pc", "BIGINT"),
("vtable_address", "BIGINT"),
("vptr_offset", "BIGINT"),
("writer_function", "BIGINT"),
],
),
(
"eh_funcinfo",
&[
("address", "BIGINT"),
("magic", "BIGINT"),
("max_state", "BIGINT"),
("p_unwind_map", "BIGINT"),
("n_try_blocks", "BIGINT"),
("p_try_block_map", "BIGINT"),
("n_ip_map_entries", "BIGINT"),
("p_ip_to_state_map", "BIGINT"),
("p_es_type_list", "BIGINT"),
("eh_flags", "BIGINT"),
],
),
(
"eh_unwind_map",
&[
("funcinfo_address", "BIGINT"),
("state_index", "BIGINT"),
("to_state", "BIGINT"),
("action_pc", "BIGINT"),
],
),
(
"eh_try_blocks",
&[
("funcinfo_address", "BIGINT"),
("try_index", "BIGINT"),
("try_low", "BIGINT"),
("try_high", "BIGINT"),
("catch_high", "BIGINT"),
("n_catches", "BIGINT"),
("p_handler_array", "BIGINT"),
],
),
(
"xrefs",
&[
("source", "BIGINT"),
("target", "BIGINT"),
("kind", "VARCHAR"),
("addr_mode", "VARCHAR"),
("instruction", "VARCHAR"),
("source_func", "BIGINT"),
("source_label", "VARCHAR"),
("target_label", "VARCHAR"),
],
),
];
let mut errs: Vec<String> = Vec::new();
for (table, cols) in expected {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info('{}')", table))
.unwrap_or_else(|e| panic!("prepare PRAGMA for {table}: {e}"));
let rows: Vec<(String, String)> = stmt
.query_map([], |row| {
let name: String = row.get(1)?;
let ty: String = row.get(2)?;
Ok((name, ty))
})
.expect("query")
.map(|r| r.unwrap())
.collect();
if rows.len() != cols.len() {
writeln!(
std::io::stderr(),
"{table}: column count mismatch (got {}, expected {})",
rows.len(),
cols.len()
)
.ok();
errs.push(format!("{table}: count {} vs {}", rows.len(), cols.len()));
}
for (i, (got, expected_col)) in rows.iter().zip(cols.iter()).enumerate() {
if got.0 != expected_col.0 || got.1 != expected_col.1 {
errs.push(format!(
"{table} col {i}: got ({}, {}) expected ({}, {})",
got.0, got.1, expected_col.0, expected_col.1
));
}
}
}
assert!(
errs.is_empty(),
"schema drift detected:\n {}",
errs.join("\n ")
);
// Verify row counts in the populated tables.
let n_instr: i64 = conn
.query_row("SELECT COUNT(*) FROM instructions", [], |r| r.get(0))
.unwrap();
assert_eq!(
n_instr, 4,
"expected 4 instruction rows from the synthetic PE"
);
// The synthetic mflr should produce target_hex = NULL, blr likewise (indirect).
let n_with_target: i64 = conn
.query_row("SELECT COUNT(target_hex) FROM instructions", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
n_with_target, 0,
"indirect-only fixture should have no direct branch targets"
);
// SQL views must be queryable. The `_` in SQL LIKE is a single-char
// wildcard, so we list the names explicitly rather than `LIKE 'v_%'`
// (which also matches DuckDB's built-in `views` system view).
let expected_views = [
"v_branch_xrefs",
"v_call_graph",
"v_function_first_instruction",
"v_imports_called",
"v_indirect_reachability_from_entry",
"v_reachability_from_entry",
];
for v in expected_views {
let exists: i64 = conn
.query_row(
"SELECT COUNT(*) FROM duckdb_views() WHERE view_name = ?",
[v],
|r| r.get(0),
)
.unwrap();
assert_eq!(exists, 1, "missing SQL view: {v}");
}
let _ = std::fs::remove_file(&tmp);
}

View File

@@ -0,0 +1,145 @@
//! Analysis-side goldens: every row in the xenia-cpu fixtures must
//! round-trip cleanly through the [`sylpheed_xexdb::ppc`] shim. This
//! pins the shim's behaviour to the canonical `sylpheed_ppc::disasm::format`
//! output so that any future refactor of the shim layer surfaces here.
//!
//! Loads the same JSON fixtures committed under
//! `crates/xenia-cpu/tests/golden/`. No separate analysis-side fixture
//! files — the cpu canon is the source of truth.
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct GoldenRow {
label: String,
raw: String,
addr: String,
mnemonic: String,
operands: String,
#[serde(default)]
ext_mnemonic: Option<String>,
#[serde(default)]
ext_operands: Option<String>,
#[serde(default)]
branch_target: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GoldenFile {
rows: Vec<GoldenRow>,
}
fn cpu_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("sylpheed-ppc")
.join("tests")
.join("golden")
.join(name)
}
fn parse_hex(s: &str) -> u32 {
let trimmed = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.unwrap_or(s);
u32::from_str_radix(trimmed, 16).expect("hex u32")
}
/// Verify the shim's `Decoded { base, ext }` mirrors the canonical fields
/// from `sylpheed_ppc::disasm::format` for every fixture row.
fn check_fixture(fixture_name: &str) {
let path = cpu_fixture(fixture_name);
assert!(
path.exists(),
"missing fixture {} — run `cargo test -p sylpheed-ppc --test disasm_goldens` to (re)generate it",
path.display()
);
let src = std::fs::read_to_string(&path).unwrap();
let golden: GoldenFile = serde_json::from_str(&src).unwrap();
for row in &golden.rows {
let raw = parse_hex(&row.raw);
let addr = parse_hex(&row.addr);
let canonical = sylpheed_ppc::disasm::format(&sylpheed_ppc::decode(raw, addr));
let shim = sylpheed_xexdb::ppc::disasm(raw, addr);
assert_eq!(
shim.base, canonical.disasm,
"shim.base drifted for {} (raw={})",
row.label, row.raw,
);
assert_eq!(
shim.ext, canonical.ext_disasm,
"shim.ext drifted for {} (raw={})",
row.label, row.raw,
);
// Also pin against the fixture's structured fields — guards against
// someone changing the cpu canon without regenerating the fixture.
assert_eq!(
canonical.mnemonic, row.mnemonic,
"mnemonic drift: {}",
row.label
);
assert_eq!(
canonical.operands, row.operands,
"operands drift: {}",
row.label
);
assert_eq!(
canonical.ext_mnemonic, row.ext_mnemonic,
"ext_mnemonic drift: {}",
row.label
);
assert_eq!(
canonical.ext_operands, row.ext_operands,
"ext_operands drift: {}",
row.label
);
let target_str = canonical.branch_target.map(|t| format!("0x{t:08X}"));
assert_eq!(
target_str, row.branch_target,
"branch_target drift: {}",
row.label
);
}
}
#[test]
fn analysis_shim_matches_base_mnemonics() {
check_fixture("base_mnemonics.json");
}
#[test]
fn analysis_shim_matches_extended_mnemonics() {
check_fixture("extended_mnemonics.json");
}
#[test]
fn analysis_shim_matches_vmx128_registers() {
check_fixture("vmx128_registers.json");
}
/// Spot-check that the shim's `display()` returns the extended form when
/// present and falls back to the base otherwise. This is the contract
/// `formatter.rs` and the .asm output rely on.
#[test]
fn shim_display_prefers_extended() {
// ori r0, r0, 0 → base "ori r0, r0, 0x0", ext "nop"
let d = sylpheed_xexdb::ppc::disasm(0x60000000, 0);
assert_eq!(d.display(), "nop");
// addi r3, r1, 16 → no extended form, display falls back to base
let raw = (14u32 << 26) | (3 << 21) | (1 << 16) | 16;
let d = sylpheed_xexdb::ppc::disasm(raw, 0);
assert!(
d.ext.is_none(),
"addi r3, r1, 16 has no extended form (only addi r3, r0, … → li)"
);
assert_eq!(d.display(), d.base);
}

225
tools/zq.py Executable file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Sylpheed static-analysis helper over DuckDB `sylpheed.db`.
Hides the gotchas: DECIMAL bounds (DuckDB rejects 0x literals), read-only connect,
and the fact that the engine vtable / rdata is NOT in the DB.
🔴 THAT LAST LINE USED TO SAY "read it from guest memory with
`xenia-rs exec ... --dump-addr=0x<va>`". That emulator is retired, and the
command no longer exists -- see docs/agents/CONSOLIDATION.md. Two replacements,
both static, neither needing anything to run:
* the extracted PE is a FLAT VA DUMP: byte offset = VA - 0x82000000, so
`dd`/`xxd` on `*.pe` reads any address directly;
* `sylph-xexdb extract <xex>` regenerates that `.pe` (byte-identical, verified)
alongside a metadata JSON.
Usage:
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 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
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>? '
'ORDER BY address DESC LIMIT 1', [pc, pc]).fetchall()
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, args = sys.argv[1], sys.argv[2:]
if cmd == 'dis':
cmd_dis(int(args[0], 16), int(args[1], 16))
elif cmd == 'fn':
print(_fn(int(args[0], 16)))
elif cmd == 'xref':
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(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':
for a, m, o in c.execute("SELECT address,mnemonic,operands FROM instructions "
"WHERE operands LIKE ? ORDER BY address", [f'%{args[0]}%']).fetchall():
print(H(a), m, o, ' in', _fn(a))
elif cmd == 'find':
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()