wip: extract the xexdb tool closure
This commit is contained in:
763
Cargo.lock
generated
763
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
|
||||
10
crates/sylpheed-ppc/Cargo.toml
Normal file
10
crates/sylpheed-ppc/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[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"
|
||||
37
crates/sylpheed-ppc/examples/decode_table_check.rs
Normal file
37
crates/sylpheed-ppc/examples/decode_table_check.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! 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(())
|
||||
}
|
||||
1245
crates/sylpheed-ppc/src/decoder.rs
Normal file
1245
crates/sylpheed-ppc/src/decoder.rs
Normal file
File diff suppressed because it is too large
Load Diff
2128
crates/sylpheed-ppc/src/disasm.rs
Normal file
2128
crates/sylpheed-ppc/src/disasm.rs
Normal file
File diff suppressed because it is too large
Load Diff
12
crates/sylpheed-ppc/src/lib.rs
Normal file
12
crates/sylpheed-ppc/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! 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;
|
||||
308
crates/sylpheed-ppc/src/opcode.rs
Normal file
308
crates/sylpheed-ppc/src/opcode.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
15
crates/sylpheed-xex/Cargo.toml
Normal file
15
crates/sylpheed-xex/Cargo.toml
Normal 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"
|
||||
139
crates/sylpheed-xex/src/header.rs
Normal file
139
crates/sylpheed-xex/src/header.rs
Normal 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;
|
||||
}
|
||||
16
crates/sylpheed-xex/src/lib.rs
Normal file
16
crates/sylpheed-xex/src/lib.rs
Normal 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 vfs;
|
||||
pub mod header;
|
||||
pub mod loader;
|
||||
pub mod lzx;
|
||||
pub mod pe;
|
||||
pub mod pdata;
|
||||
pub mod resources;
|
||||
pub mod tls;
|
||||
|
||||
pub use header::Xex2Header;
|
||||
591
crates/sylpheed-xex/src/loader.rs
Normal file
591
crates/sylpheed-xex/src/loader.rs
Normal file
@@ -0,0 +1,591 @@
|
||||
use crate::header::*;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
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 {
|
||||
s if s == 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)
|
||||
}
|
||||
692
crates/sylpheed-xex/src/lzx.rs
Normal file
692
crates/sylpheed-xex/src/lzx.rs
Normal file
@@ -0,0 +1,692 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
219
crates/sylpheed-xex/src/pdata.rs
Normal file
219
crates/sylpheed-xex/src/pdata.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! 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, §ions);
|
||||
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
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, §ions).is_empty());
|
||||
}
|
||||
}
|
||||
68
crates/sylpheed-xex/src/pe.rs
Normal file
68
crates/sylpheed-xex/src/pe.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! 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)
|
||||
}
|
||||
127
crates/sylpheed-xex/src/resources.rs
Normal file
127
crates/sylpheed-xex/src/resources.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
//! XEX `XEX_HEADER_RESOURCE_INFO` (key `0x000002FF`) — the embedded resource table.
|
||||
//!
|
||||
//! The header points at a length-prefixed table of fixed 16-byte records:
|
||||
//!
|
||||
//! ```text
|
||||
//! u32 size total table size in bytes, including this field
|
||||
//! record[] entries (size - 4) / 16 of:
|
||||
//! char[8] name resource name, NUL-padded (the title's is its
|
||||
//! title id in uppercase hex, e.g. "535107D4")
|
||||
//! u32 address absolute VA of the resource inside the loaded image
|
||||
//! u32 size resource length in bytes
|
||||
//! ```
|
||||
//!
|
||||
//! For a title the named resource is its **XDBF/SPA package** — achievements,
|
||||
//! localized strings, and images. See `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());
|
||||
}
|
||||
}
|
||||
172
crates/sylpheed-xex/src/tls.rs
Normal file
172
crates/sylpheed-xex/src/tls.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! `.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, §ions).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, §ions).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);
|
||||
}
|
||||
}
|
||||
58
crates/sylpheed-xex/src/vfs/device.rs
Normal file
58
crates/sylpheed-xex/src/vfs/device.rs
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
343
crates/sylpheed-xex/src/vfs/disc_image.rs
Normal file
343
crates/sylpheed-xex/src/vfs/disc_image.rs
Normal file
@@ -0,0 +1,343 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
43
crates/sylpheed-xex/src/vfs/mod.rs
Normal file
43
crates/sylpheed-xex/src/vfs/mod.rs
Normal 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>;
|
||||
}
|
||||
23
crates/sylpheed-xexdb/Cargo.toml
Normal file
23
crates/sylpheed-xexdb/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[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"] }
|
||||
570
crates/sylpheed-xexdb/SCHEMA.md
Normal file
570
crates/sylpheed-xexdb/SCHEMA.md
Normal 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.
|
||||
87
crates/sylpheed-xexdb/build.rs
Normal file
87
crates/sylpheed-xexdb/build.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! Build script: parse xenia's xboxkrnl_table.inc and xam_table.inc to generate
|
||||
//! ordinal->name lookup tables at compile time.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
fn parse_table(path: &Path) -> Vec<(u32, String, String)> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("cargo:warning=could not read {}: {}", path.display(), e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
// XE_EXPORT(module, 0xNNNNNNNN, Name, kType),
|
||||
if !line.starts_with("XE_EXPORT(") { continue; }
|
||||
let inner = match line.strip_prefix("XE_EXPORT(").and_then(|s| s.strip_suffix("),")) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let parts: Vec<&str> = inner.splitn(4, ',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 { continue; }
|
||||
let module = parts[0].to_string();
|
||||
let ordinal = match u32::from_str_radix(parts[1].trim_start_matches("0x").trim_start_matches("0X"), 16) {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = parts[2].to_string();
|
||||
entries.push((ordinal, name, module));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let out_dir = env::var("OUT_DIR").unwrap();
|
||||
let dest = Path::new(&out_dir).join("ordinals.rs");
|
||||
let mut f = fs::File::create(&dest).unwrap();
|
||||
|
||||
// Locate xenia tables relative to the workspace root
|
||||
// crates/xenia-analysis/ -> ../../ -> workspace root -> ../xenia-canary/
|
||||
let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let workspace_root = Path::new(&manifest).parent().unwrap().parent().unwrap();
|
||||
let project_root = workspace_root.parent().unwrap();
|
||||
|
||||
let krnl_path = project_root
|
||||
.join("xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_table.inc");
|
||||
let xam_path = project_root
|
||||
.join("xenia-canary/src/xenia/kernel/xam/xam_table.inc");
|
||||
|
||||
println!("cargo:rerun-if-changed={}", krnl_path.display());
|
||||
println!("cargo:rerun-if-changed={}", xam_path.display());
|
||||
|
||||
let krnl = parse_table(&krnl_path);
|
||||
let xam = parse_table(&xam_path);
|
||||
|
||||
writeln!(f, "/// Auto-generated from xenia's export tables.").unwrap();
|
||||
writeln!(f, "pub fn resolve_ordinal(lib: &str, ordinal: u16) -> Option<&'static str> {{").unwrap();
|
||||
writeln!(f, " match lib {{").unwrap();
|
||||
|
||||
// xboxkrnl.exe
|
||||
writeln!(f, " \"xboxkrnl.exe\" => match ordinal {{").unwrap();
|
||||
for (ord, name, _) in &krnl {
|
||||
writeln!(f, " 0x{ord:04X} => Some(\"{name}\"),").unwrap();
|
||||
}
|
||||
writeln!(f, " _ => None,").unwrap();
|
||||
writeln!(f, " }},").unwrap();
|
||||
|
||||
// xam.xex
|
||||
writeln!(f, " \"xam.xex\" => match ordinal {{").unwrap();
|
||||
for (ord, name, _) in &xam {
|
||||
writeln!(f, " 0x{ord:04X} => Some(\"{name}\"),").unwrap();
|
||||
}
|
||||
writeln!(f, " _ => None,").unwrap();
|
||||
writeln!(f, " }},").unwrap();
|
||||
|
||||
writeln!(f, " _ => None,").unwrap();
|
||||
writeln!(f, " }}").unwrap();
|
||||
writeln!(f, "}}").unwrap();
|
||||
|
||||
eprintln!("ordinals.rs: {} xboxkrnl + {} xam entries", krnl.len(), xam.len());
|
||||
}
|
||||
825
crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs
Normal file
825
crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
//! `sylph-xexdb` — static analysis of the title's XEX: extract, disassemble,
|
||||
//! and build the DuckDB database the RE work queries through `tools/zq.py`.
|
||||
//!
|
||||
//! This was `xenia-rs`'s CLI. When that emulator was retired the five commands
|
||||
//! that do static analysis came here and the rest — `exec`, `check`, the 4,233
|
||||
//! line `cmd_exec_inner` — did not. The oracle is Xenia Canary now.
|
||||
//!
|
||||
//! ⚠️ The database is **DuckDB**, not SQLite. `xenia-rs`'s own `--db` help said
|
||||
//! SQLite in two places and was wrong; `docs/agents/CONSOLIDATION.md` Phase 3.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use tracing::{debug, info, instrument, warn};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "sylph-xexdb", about = "XEX static analysis: extract, disassemble, and build the analysis DB")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
/// Tracing filter, e.g. `info` or `debug,sylpheed_xexdb=trace`.
|
||||
#[arg(long, global = true)]
|
||||
log_filter: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
|
||||
/// Display XEX header information
|
||||
Info {
|
||||
/// Path to XEX file
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Disassemble a XEX file from its entry point (or an arbitrary address via `--at`)
|
||||
Disasm {
|
||||
/// Path to XEX file
|
||||
path: String,
|
||||
/// Number of instructions to disassemble
|
||||
#[arg(short = 'n', default_value = "64")]
|
||||
count: usize,
|
||||
/// Start address (hex with or without `0x` prefix). Defaults to
|
||||
/// the XEX entry point. Must fall inside the loaded image range.
|
||||
///
|
||||
/// Example: `--at 0x824be9a0` to inspect a graphics-interrupt callback.
|
||||
#[arg(long, value_parser = parse_hex_u32)]
|
||||
at: Option<u32>,
|
||||
},
|
||||
|
||||
/// Browse XISO disc image contents
|
||||
Browse {
|
||||
/// Path to XISO file
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// Extract PE image and metadata from a XEX file
|
||||
Extract {
|
||||
/// Path to XEX or ISO file
|
||||
path: String,
|
||||
/// Output directory (default: same directory as input)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
/// Write base tables (metadata, sections, imports) to a SQLite database
|
||||
#[arg(long)]
|
||||
db: Option<String>,
|
||||
},
|
||||
|
||||
/// Full disassembly with function detection, cross-references, and optional database
|
||||
Dis {
|
||||
/// Path to XEX or ISO file
|
||||
path: String,
|
||||
/// Output .asm file (default: stdout)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
/// Output SQLite database (also includes the base extract tables)
|
||||
#[arg(long)]
|
||||
db: Option<String>,
|
||||
/// Output JSON Lines file: one structured row per instruction with
|
||||
/// section/function/label/branch_target columns. Suitable for
|
||||
/// `jq`, pandas, or DuckDB's `read_json_auto`.
|
||||
#[arg(long)]
|
||||
json: Option<String>,
|
||||
/// Choose how analysis tables are produced when `--db` is set.
|
||||
///
|
||||
/// - `rust` (default): only the Rust passes (`func.rs`, `xref.rs`)
|
||||
/// populate `functions`/`labels`/`xrefs`. No SQL views.
|
||||
/// - `sql`: Rust passes still run (function detection and data-ref
|
||||
/// resolution are Rust-only by design); additive SQL views
|
||||
/// (`v_branch_xrefs`, `v_call_graph`, `v_reachability_from_entry`,
|
||||
/// `v_function_first_instruction`, `v_imports_called`) are
|
||||
/// created on top of the same tables.
|
||||
/// - `both`: same as `sql`, plus a Rust-vs-SQL cross-check on
|
||||
/// branch xrefs. Disagreement is logged as a warning (non-fatal).
|
||||
#[arg(long, value_enum, default_value_t = AnalyzeMode::Rust)]
|
||||
analyze: AnalyzeMode,
|
||||
/// Ceiling on candidates materialised per unresolved virtual-call site.
|
||||
///
|
||||
/// A `bcctrl` through `this->vptr` is resolved by matching
|
||||
/// `(vptr_offset, slot)` against every class installing a vtable at
|
||||
/// that offset. At offset 0 that matches almost every class, so the
|
||||
/// result is a cross product rather than an answer — one site can
|
||||
/// claim 700+ callees. Sites above this ceiling are still recorded in
|
||||
/// `indirect_dispatch_sites` (with `truncated` set and a truthful
|
||||
/// `candidate_count`), but emit no `indirect_dispatch_candidates` rows
|
||||
/// and no `ind_call` xrefs. Raise it to get the full cross product back.
|
||||
#[arg(long, default_value_t = sylpheed_xexdb::ind_dispatch_typed::DEFAULT_MAX_CANDIDATES)]
|
||||
max_indirect_candidates: usize,
|
||||
/// Suppress assembly text output (DB-only mode)
|
||||
#[arg(long)]
|
||||
quiet: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn parse_hex_u32(s: &str) -> Result<u32, String> {
|
||||
let t = s.trim_start_matches("0x").trim_start_matches("0X");
|
||||
u32::from_str_radix(t, 16).map_err(|e| format!("bad hex address `{s}`: {e}"))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let filter = cli.log_filter.clone().unwrap_or_else(|| "info".to_string());
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
|
||||
.init();
|
||||
match cli.command {
|
||||
Commands::Info { path } => cmd_info(&path),
|
||||
Commands::Disasm { path, count, at } => cmd_disasm(&path, count, at),
|
||||
Commands::Browse { path } => cmd_browse(&path),
|
||||
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()),
|
||||
Commands::Dis { path, output, db, json, analyze, max_indirect_candidates, quiet } =>
|
||||
cmd_dis(&path, output.as_deref(), db.as_deref(), json.as_deref(), analyze, max_indirect_candidates, quiet),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_info(path: &str) -> Result<()> {
|
||||
let started = Instant::now();
|
||||
let data = load_xex_data(path)?;
|
||||
let header = sylpheed_xex::loader::parse_xex2_header(&data)?;
|
||||
|
||||
println!("=== XEX2 Header ===");
|
||||
println!("Magic: {:#010x}", header.magic);
|
||||
println!("Module Flags: {:#010x}", header.module_flags);
|
||||
println!("Header Size: {:#x}", header.header_size);
|
||||
println!("Headers: {}", header.header_count);
|
||||
|
||||
if let Some(entry) = sylpheed_xex::loader::get_entry_point(&header) {
|
||||
println!("Entry Point: {:#010x}", entry);
|
||||
}
|
||||
if let Some(base) = sylpheed_xex::loader::get_image_base(&header) {
|
||||
println!("Image Base: {:#010x}", base);
|
||||
}
|
||||
|
||||
println!("\n=== Optional Headers ===");
|
||||
for h in &header.optional_headers {
|
||||
println!(" Key: {:#010x} Value: {:#010x}", h.key, h.value);
|
||||
}
|
||||
|
||||
if let Some(ref sec) = header.security_info {
|
||||
println!("\n=== Security Info ===");
|
||||
println!("Image Size: {:#x}", sec.image_size);
|
||||
println!("Load Address: {:#010x}", sec.load_address);
|
||||
println!("Image Flags: {:#010x}", sec.image_flags);
|
||||
println!("Page Descs: {}", sec.page_descriptors.len());
|
||||
}
|
||||
|
||||
if let Some(ref ffi) = header.file_format_info {
|
||||
println!("\n=== File Format ===");
|
||||
println!("Encryption: {}", match ffi.encryption_type {
|
||||
0 => "None", 1 => "Normal (AES)", _ => "Unknown"
|
||||
});
|
||||
println!("Compression: {}", match ffi.compression_type {
|
||||
0 => "None", 1 => "Basic", 2 => "Normal (LZX)", _ => "Unknown"
|
||||
});
|
||||
if !ffi.basic_blocks.is_empty() {
|
||||
println!("Basic blocks: {}", ffi.basic_blocks.len());
|
||||
}
|
||||
if ffi.normal_window_size != 0 {
|
||||
println!("LZX Window: {:#x}", ffi.normal_window_size);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref name) = header.original_pe_name {
|
||||
println!("\nOriginal PE: {}", name);
|
||||
}
|
||||
|
||||
if let Some(ref ei) = header.execution_info {
|
||||
println!("\n=== Execution Info ===");
|
||||
println!("Title ID: {:#010x}", ei.title_id);
|
||||
println!("Media ID: {:#010x}", ei.media_id);
|
||||
println!("Disc: {} of {}", ei.disc_number, ei.disc_count);
|
||||
}
|
||||
|
||||
if !header.import_libraries.is_empty() {
|
||||
println!("\n=== Import Libraries ===");
|
||||
for lib in &header.import_libraries {
|
||||
println!(" {} (v{:#010x}, {} imports)", lib.name, lib.version_cur, lib.imports.len());
|
||||
}
|
||||
}
|
||||
|
||||
info!(wall_ms = started.elapsed().as_millis() as u64, "info complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clap parser for `--at` — accepts decimal, 0x-prefixed hex, or bare hex.
|
||||
fn parse_hex_u32(s: &str) -> Result<u32, String> {
|
||||
let t = s.trim();
|
||||
let (digits, radix) = if let Some(rest) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) {
|
||||
(rest, 16)
|
||||
} else if t.chars().all(|c| c.is_ascii_digit()) {
|
||||
(t, 10)
|
||||
} else {
|
||||
(t, 16)
|
||||
};
|
||||
u32::from_str_radix(digits, radix)
|
||||
.map_err(|e| format!("invalid u32 {:?}: {e} (try `0x824be9a0`)", t))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(path = %path, count))]
|
||||
|
||||
fn cmd_disasm(path: &str, count: usize, at: Option<u32>) -> Result<()> {
|
||||
let started = Instant::now();
|
||||
let data = load_xex_data(path)?;
|
||||
let header = sylpheed_xex::loader::parse_xex2_header(&data)?;
|
||||
|
||||
let entry = sylpheed_xex::loader::get_entry_point(&header)
|
||||
.ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?;
|
||||
let base = sylpheed_xex::loader::get_image_base(&header)
|
||||
.ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?;
|
||||
|
||||
info!(entry = format_args!("{:#010x}", entry), base = format_args!("{:#010x}", base), "XEX entry/base");
|
||||
|
||||
let image_data = sylpheed_xex::loader::load_image(&data, &header)?;
|
||||
info!(bytes = image_data.len(), "image decompressed");
|
||||
|
||||
let start = at.unwrap_or(entry);
|
||||
let label = if at.is_some() { "requested address" } else { "entry point" };
|
||||
println!("Disassembly from {} {:#010x} ({} instructions):\n", label, start, count);
|
||||
|
||||
if start < base {
|
||||
return Err(anyhow::anyhow!(
|
||||
"address {:#x} is below image base {:#x}",
|
||||
start,
|
||||
base
|
||||
));
|
||||
}
|
||||
let offset = (start - base) as usize;
|
||||
if offset + count * 4 > image_data.len() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"address {:#x} (offset {:#x}) + {} instructions extends past image end ({:#x} bytes)",
|
||||
start,
|
||||
offset,
|
||||
count,
|
||||
image_data.len()
|
||||
));
|
||||
}
|
||||
let block = sylpheed_ppc::disasm::disassemble_block(&image_data[offset..], start, count);
|
||||
for (addr, text) in block {
|
||||
println!(" {:#010x}: {}", addr, text);
|
||||
}
|
||||
|
||||
info!(wall_ms = started.elapsed().as_millis() as u64, "disasm complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(path = %path, ui))]
|
||||
|
||||
fn cmd_browse(path: &str) -> Result<()> {
|
||||
use sylpheed_xex::vfs::VfsDevice;
|
||||
|
||||
let disc = sylpheed_xex::vfs::disc_image::DiscImageDevice::open("disc", std::path::Path::new(path))
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open disc image: {}", e))?;
|
||||
|
||||
println!("=== XISO Contents: {} ===", path);
|
||||
match disc.list_root() {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let kind = if entry.is_directory { "DIR " } else { "FILE" };
|
||||
println!(" {} {:>10} {}", kind, entry.size, entry.name);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!(%e, "error listing contents"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper: load XEX, parse header, decompress PE, resolve imports, parse sections.
|
||||
#[instrument(skip_all, fields(path = %path))]
|
||||
/// Load a XEX and prepare it for analysis.
|
||||
///
|
||||
/// Returns the parsed header, the decompressed image, its sections, and the
|
||||
/// **raw XEX bytes**. The raw bytes are needed because optional-header values
|
||||
/// are file offsets into the container, not image VAs — the resource table
|
||||
/// (and so the embedded XDBF package) is only reachable through them.
|
||||
fn load_and_prepare(path: &str) -> Result<(sylpheed_xex::Xex2Header, Vec<u8>, Vec<sylpheed_xex::pe::PeSection>, Vec<u8>)> {
|
||||
let data = load_xex_data(path)?;
|
||||
let mut header = sylpheed_xex::loader::parse_xex2_header(&data)?;
|
||||
|
||||
let entry = sylpheed_xex::loader::get_entry_point(&header)
|
||||
.ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?;
|
||||
let base = sylpheed_xex::loader::get_image_base(&header)
|
||||
.ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?;
|
||||
|
||||
info!(
|
||||
entry = format_args!("{:#010x}", entry),
|
||||
base = format_args!("{:#010x}", base),
|
||||
"XEX entry/base"
|
||||
);
|
||||
|
||||
let pe_image = sylpheed_xex::loader::load_image(&data, &header)?;
|
||||
info!(bytes = pe_image.len(), "image decompressed");
|
||||
|
||||
// Resolve import ordinals and record types from the PE image
|
||||
sylpheed_xex::loader::resolve_imports(&mut header, &pe_image);
|
||||
|
||||
// Parse PE sections
|
||||
let sections = sylpheed_xex::pe::parse_sections(&pe_image)?;
|
||||
info!(sections = sections.len(), "parsed PE sections");
|
||||
|
||||
Ok((header, pe_image, sections, data))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(path = %path))]
|
||||
|
||||
fn cmd_extract(path: &str, output_dir: Option<&str>, db_path: Option<&str>) -> Result<()> {
|
||||
use serde::Serialize;
|
||||
|
||||
let (header, pe_image, sections, _xex_data) = load_and_prepare(path)?;
|
||||
|
||||
let entry = sylpheed_xex::loader::get_entry_point(&header).unwrap();
|
||||
let base = sylpheed_xex::loader::get_image_base(&header).unwrap();
|
||||
let image_size = header.security_info.as_ref().map(|s| s.image_size).unwrap_or(0);
|
||||
|
||||
// Build JSON-serializable info struct
|
||||
#[derive(Serialize)]
|
||||
struct Xex2Info<'a> {
|
||||
module_flags: u32,
|
||||
image_base: u32,
|
||||
entry_point: u32,
|
||||
image_size: u32,
|
||||
original_pe_name: Option<&'a str>,
|
||||
execution_info: &'a Option<sylpheed_xex::header::ExecutionInfo>,
|
||||
import_libraries: &'a [sylpheed_xex::header::ImportLibrary],
|
||||
sections: &'a [sylpheed_xex::pe::PeSection],
|
||||
}
|
||||
|
||||
let info = Xex2Info {
|
||||
module_flags: header.module_flags,
|
||||
image_base: base,
|
||||
entry_point: entry,
|
||||
image_size,
|
||||
original_pe_name: header.original_pe_name.as_deref(),
|
||||
execution_info: &header.execution_info,
|
||||
import_libraries: &header.import_libraries,
|
||||
sections: §ions,
|
||||
};
|
||||
|
||||
// Determine output directory
|
||||
let input_path = std::path::Path::new(path);
|
||||
let out_dir = match output_dir {
|
||||
Some(d) => std::path::PathBuf::from(d),
|
||||
None => input_path.parent().unwrap_or(std::path::Path::new(".")).to_path_buf(),
|
||||
};
|
||||
std::fs::create_dir_all(&out_dir)?;
|
||||
|
||||
let stem = input_path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("output");
|
||||
|
||||
// Write PE image
|
||||
let pe_path = out_dir.join(format!("{stem}.pe"));
|
||||
std::fs::write(&pe_path, &pe_image)?;
|
||||
info!(
|
||||
path = %pe_path.display(),
|
||||
bytes = pe_image.len(),
|
||||
"wrote PE image"
|
||||
);
|
||||
|
||||
// Write JSON metadata
|
||||
let json_path = out_dir.join(format!("{stem}.xex.json"));
|
||||
let json = serde_json::to_string_pretty(&info)?;
|
||||
std::fs::write(&json_path, &json)?;
|
||||
info!(path = %json_path.display(), "wrote metadata JSON");
|
||||
|
||||
// Print summary
|
||||
let total_imports: usize = header.import_libraries.iter().map(|l| l.imports.len()).sum();
|
||||
println!("Extracted: {} sections, {} import libraries ({} imports)",
|
||||
sections.len(), header.import_libraries.len(), total_imports);
|
||||
if let Some(ref ei) = header.execution_info {
|
||||
println!("Title ID: 0x{:08X} Media ID: 0x{:08X}", ei.title_id, ei.media_id);
|
||||
}
|
||||
|
||||
// Write base tables to SQLite if requested
|
||||
if let Some(db) = db_path {
|
||||
let disasm_info = sylpheed_xexdb::formatter::DisasmInfo {
|
||||
image_base: base,
|
||||
entry_point: entry,
|
||||
original_pe_name: header.original_pe_name.as_deref(),
|
||||
title_id: header.execution_info.as_ref().map(|e| e.title_id),
|
||||
media_id: header.execution_info.as_ref().map(|e| e.media_id),
|
||||
sections: §ions,
|
||||
import_libraries: &header.import_libraries,
|
||||
xex_header: Some(&header),
|
||||
};
|
||||
info!(db = %db, "writing base tables");
|
||||
let mut w = sylpheed_xexdb::DbWriter::open_fresh(std::path::Path::new(db))?;
|
||||
w.write_base(&disasm_info)?;
|
||||
info!(db = %db, "database written");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(path = %path))]
|
||||
|
||||
fn cmd_dis(
|
||||
path: &str,
|
||||
output: Option<&str>,
|
||||
db_path: Option<&str>,
|
||||
json_path: Option<&str>,
|
||||
analyze: AnalyzeMode,
|
||||
max_indirect_candidates: usize,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let started = Instant::now();
|
||||
let (header, pe_image, sections, xex_data) = load_and_prepare(path)?;
|
||||
|
||||
let entry = sylpheed_xex::loader::get_entry_point(&header).unwrap();
|
||||
let base = sylpheed_xex::loader::get_image_base(&header).unwrap();
|
||||
|
||||
// Build import address -> name map
|
||||
let mut import_map: HashMap<u32, String> = HashMap::new();
|
||||
for lib in &header.import_libraries {
|
||||
for imp in &lib.imports {
|
||||
let resolved = sylpheed_xexdb::resolve_ordinal(&lib.name, imp.ordinal);
|
||||
let name = match resolved {
|
||||
Some(n) => format!("{}::{}", lib.name, n),
|
||||
None => format!("{}::ordinal_{:#06X}", lib.name, imp.ordinal),
|
||||
};
|
||||
import_map.insert(imp.address, name);
|
||||
}
|
||||
}
|
||||
info!(thunks = import_map.len(), "resolved import thunks");
|
||||
|
||||
// Function analysis (with .pdata-validated boundaries when present)
|
||||
let code_sections: Vec<(u32, u32, u32)> = sections.iter()
|
||||
.filter(|s| s.is_code())
|
||||
.map(|s| (s.virtual_address, s.virtual_size, s.flags))
|
||||
.collect();
|
||||
let pdata_entries = sylpheed_xex::pdata::parse_pdata(&pe_image, base, §ions);
|
||||
info!(pdata_entries = pdata_entries.len(), "parsed .pdata RUNTIME_FUNCTION entries");
|
||||
let func_analysis = sylpheed_xexdb::func::analyze_with_pdata(
|
||||
&pe_image, base, entry, &code_sections, &pdata_entries,
|
||||
);
|
||||
info!(
|
||||
functions = func_analysis.functions.len(),
|
||||
pdata_validated = func_analysis.functions.values().filter(|f| f.pdata_validated).count(),
|
||||
"function detection complete",
|
||||
);
|
||||
|
||||
// M12 — switch / jump-table recovery. Emits one `jt` xref per distinct
|
||||
// case body so the case bodies stop looking unreachable, and reports the
|
||||
// table extents so the linear disassembler can flag them as data.
|
||||
let jump_tables = sylpheed_xexdb::jumptables::analyze(
|
||||
&pe_image, base, §ions, &func_analysis,
|
||||
);
|
||||
let jt_data_words = sylpheed_xexdb::jumptables::data_word_addresses(&jump_tables);
|
||||
info!(
|
||||
jump_tables = jump_tables.len(),
|
||||
cases = jump_tables.iter().map(|t| t.targets.len()).sum::<usize>(),
|
||||
data_words = jt_data_words.len(),
|
||||
"jump-table recovery complete",
|
||||
);
|
||||
|
||||
// Cross-reference analysis
|
||||
let mut xref_result = sylpheed_xexdb::xref::analyze_xrefs_skipping(
|
||||
&pe_image, base, entry, §ions, &func_analysis, &import_map, &jt_data_words,
|
||||
);
|
||||
|
||||
// Feed the recovered `switch` edges into the xref graph, so case bodies
|
||||
// stop looking unreachable and get a label of their own.
|
||||
let mut jt_edges = 0usize;
|
||||
for jt in &jump_tables {
|
||||
for target in jt.distinct_targets() {
|
||||
xref_result.xrefs
|
||||
.entry(target)
|
||||
.or_default()
|
||||
.push(sylpheed_xexdb::xref::Xref {
|
||||
source: jt.bctr_pc,
|
||||
kind: sylpheed_xexdb::xref::XrefKind::JumpTable,
|
||||
addr_mode: None,
|
||||
});
|
||||
xref_result.labels
|
||||
.entry(target)
|
||||
.or_insert_with(|| format!("case_{target:08X}"));
|
||||
jt_edges += 1;
|
||||
}
|
||||
xref_result.labels
|
||||
.entry(jt.table_address)
|
||||
.or_insert_with(|| format!("jpt_{:08X}", jt.table_address));
|
||||
}
|
||||
info!(case_edges = jt_edges, "switch edges added to xref graph");
|
||||
let total_xrefs: usize = xref_result.xrefs.values().map(|v| v.len()).sum();
|
||||
info!(
|
||||
labels = xref_result.labels.len(),
|
||||
xrefs = total_xrefs,
|
||||
"xref analysis complete"
|
||||
);
|
||||
|
||||
// Vtable + RTTI scan (M3). Uses M1's corrected function-start set as the
|
||||
// pointer-validity oracle; runs over .rdata + .data.
|
||||
let function_starts: std::collections::BTreeSet<u32> =
|
||||
func_analysis.functions.keys().copied().collect();
|
||||
// Anchor discovery: recover vtable bases from constructor vptr-write
|
||||
// stores so a vtable with non-function head words (null / pure-virtual /
|
||||
// unrecognised thunk slots) isn't fragmented away by the contiguity
|
||||
// heuristic. (Fixes e.g. the XMV engine vtable 0x8200a908.)
|
||||
let vptr_anchor_funcs: std::collections::BTreeMap<u32, (u32, bool)> = func_analysis
|
||||
.functions
|
||||
.iter()
|
||||
.map(|(&s, fi)| (s, (fi.end, fi.is_saverestore)))
|
||||
.collect();
|
||||
let vptr_block_boundaries: std::collections::HashSet<u32> =
|
||||
xref_result.labels.keys().copied().collect();
|
||||
let mut vtable_anchors = sylpheed_xexdb::vtables::scan_vptr_write_constants(
|
||||
&pe_image, base, &vptr_anchor_funcs, §ions, &vptr_block_boundaries,
|
||||
);
|
||||
info!(vtable_anchors = vtable_anchors.len(), "vptr-write anchor scan complete");
|
||||
|
||||
// M13 — authoritative MSVC RTTI walk. Every `vftable[-1] -> COL` link the
|
||||
// linker emitted is an anchor the heuristic scan must not miss, and the
|
||||
// class names it recovers override anything the contiguity scan guessed.
|
||||
let rtti = sylpheed_xexdb::rtti::analyze(&pe_image, base, §ions);
|
||||
let rtti_anchors = rtti.vtable_anchors();
|
||||
let rtti_new_anchors = rtti_anchors.difference(&vtable_anchors).count();
|
||||
vtable_anchors.extend(rtti_anchors.iter().copied());
|
||||
info!(
|
||||
rtti_vtables = rtti_anchors.len(),
|
||||
new_anchors = rtti_new_anchors,
|
||||
"RTTI anchors merged",
|
||||
);
|
||||
|
||||
let mut vtables = sylpheed_xexdb::vtables::analyze_with_anchors(
|
||||
&pe_image, base, §ions, &function_starts, &vtable_anchors,
|
||||
);
|
||||
let named = sylpheed_xexdb::vtables::apply_rtti_names(&mut vtables, &rtti);
|
||||
let vtables = vtables;
|
||||
let rtti_count = vtables.iter().filter(|v| v.rtti_present).count();
|
||||
info!(
|
||||
vtables = vtables.len(),
|
||||
rtti = rtti_count,
|
||||
rtti_named = named,
|
||||
anon = vtables.len() - rtti_count,
|
||||
"vtable scan complete",
|
||||
);
|
||||
|
||||
// Indirect-dispatch reachability (M5). Walks each function looking for
|
||||
// the canonical lis+addi → lwz off(vtable) → mtctr → bcctrl pattern and
|
||||
// emits one xref edge per resolvable site. Inserted into xrefs as
|
||||
// kind='ind_call'.
|
||||
let indirect_edges = sylpheed_xexdb::indirect::analyze(
|
||||
&pe_image, base, &func_analysis, &vtables, &xref_result.labels,
|
||||
);
|
||||
info!(indirect_edges = indirect_edges.len(), "indirect-dispatch scan complete");
|
||||
for edge in &indirect_edges {
|
||||
xref_result.xrefs
|
||||
.entry(edge.target)
|
||||
.or_default()
|
||||
.push(sylpheed_xexdb::xref::Xref {
|
||||
source: edge.source,
|
||||
kind: sylpheed_xexdb::xref::XrefKind::IndirectCall,
|
||||
addr_mode: None,
|
||||
});
|
||||
}
|
||||
|
||||
// String / constant-pool detection (M7).
|
||||
let strings = sylpheed_xexdb::strings::analyze(&pe_image, base, §ions);
|
||||
info!(strings = strings.len(), "string scan complete");
|
||||
|
||||
// .tls directory parse (M10). None for binaries without a .tls section.
|
||||
let tls_info = sylpheed_xex::tls::parse_tls(&pe_image, base, §ions);
|
||||
if let Some(ref t) = tls_info {
|
||||
info!(callbacks = t.callbacks.len(), "tls directory parsed");
|
||||
} else {
|
||||
info!("no .tls section present");
|
||||
}
|
||||
|
||||
// Generic function-pointer-array scan (M8 + M11). Re-emits M3 vtables
|
||||
// plus dispatch tables and static-init tables in `.rdata`.
|
||||
let mut fparrays = sylpheed_xexdb::funcptr_arrays::analyze(
|
||||
&pe_image, base, §ions, &function_starts, &vtables,
|
||||
);
|
||||
|
||||
// M11.5 — static-init driver chain detection. Replaces M11's prologue
|
||||
// heuristic with a structurally-grounded result where the driver
|
||||
// function shape matches.
|
||||
let static_init = sylpheed_xexdb::static_init::analyze(
|
||||
&pe_image, base, §ions, &func_analysis, &function_starts,
|
||||
&xref_result.labels,
|
||||
);
|
||||
info!(
|
||||
static_init_drivers = static_init.drivers.len(),
|
||||
static_init_arrays = static_init.arrays.len(),
|
||||
"M11.5 static-init driver scan complete",
|
||||
);
|
||||
// Merge M11.5 results into the funcptr_arrays vector. If an array's
|
||||
// address already exists from M8/M11, upgrade its kind from
|
||||
// 'dispatch_table'/'static_init' to a definitive 'static_init'.
|
||||
let static_init_addrs: std::collections::HashSet<u32> =
|
||||
static_init.arrays.iter().map(|a| a.address).collect();
|
||||
fparrays.retain(|a| !static_init_addrs.contains(&a.address));
|
||||
for a in &static_init.arrays {
|
||||
fparrays.push(a.clone());
|
||||
}
|
||||
info!(
|
||||
funcptr_arrays = fparrays.len(),
|
||||
dispatch_tables = fparrays.iter().filter(|a| a.kind == "dispatch_table").count(),
|
||||
static_inits = fparrays.iter().filter(|a| a.kind == "static_init").count(),
|
||||
"function-pointer array set finalised",
|
||||
);
|
||||
|
||||
// M9.5 — MSVC __CxxFrameHandler scope-table magic-scan.
|
||||
let eh_records = sylpheed_xexdb::eh_scope::analyze(&pe_image, base, §ions);
|
||||
info!(
|
||||
eh_funcinfo = eh_records.len(),
|
||||
eh_unwind_entries = eh_records.iter().map(|r| r.unwind_map.len()).sum::<usize>(),
|
||||
eh_try_blocks = eh_records.iter().map(|r| r.try_blocks.len()).sum::<usize>(),
|
||||
"M9.5 EH scope-table scan complete",
|
||||
);
|
||||
|
||||
// M5.5 — typed indirect-dispatch resolution (this->vptr → method).
|
||||
let typed_ind = sylpheed_xexdb::ind_dispatch_typed::analyze(
|
||||
&pe_image, base, &func_analysis, &vtables, &xref_result.labels,
|
||||
max_indirect_candidates,
|
||||
);
|
||||
let single = typed_ind.dispatches.iter().filter(|d| d.total_candidates == 1).count();
|
||||
let multi = typed_ind.dispatches.len() - single;
|
||||
let typed_edges: usize = typed_ind.dispatches.iter().map(|d| d.method_pcs.len()).sum();
|
||||
info!(
|
||||
vptr_writes = typed_ind.vptr_writes.len(),
|
||||
dispatches = typed_ind.dispatches.len(),
|
||||
single_candidate = single,
|
||||
multi_candidate = multi,
|
||||
edges = typed_edges,
|
||||
"M5.5 typed indirect-dispatch scan complete",
|
||||
);
|
||||
// Add ind_call edges for every (dispatch_pc, method) candidate. Sites the
|
||||
// resolver could not narrow contribute nothing here — `method_pcs` is
|
||||
// empty for them — which keeps `xrefs` a table of evidence rather than of
|
||||
// possibilities.
|
||||
for d in &typed_ind.dispatches {
|
||||
for &method_pc in &d.method_pcs {
|
||||
xref_result.xrefs
|
||||
.entry(method_pc)
|
||||
.or_default()
|
||||
.push(sylpheed_xexdb::xref::Xref {
|
||||
source: d.dispatch_pc,
|
||||
kind: sylpheed_xexdb::xref::XrefKind::IndirectCall,
|
||||
addr_mode: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// XDBF/SPA — the title metadata package the XEX names via its resource
|
||||
// table (achievements, localized strings, images). Located through the
|
||||
// resource table rather than by scanning for the magic, so the entry
|
||||
// table's own accounting is what decides what exists.
|
||||
let resources = sylpheed_xex::resources::parse_resources(&xex_data, &header);
|
||||
let xdbf = resources.iter().find_map(|r| {
|
||||
let off = r.image_offset(base)?;
|
||||
let x = sylpheed_xexdb::xdbf::analyze(&pe_image, off)?;
|
||||
info!(
|
||||
resource = %r.name,
|
||||
address = format_args!("{:#010x}", r.address),
|
||||
size = r.size,
|
||||
entries = x.entries.len(),
|
||||
achievements = x.achievements.len(),
|
||||
string_tables = x.string_tables.len(),
|
||||
images = x.images.len(),
|
||||
"XDBF package found",
|
||||
);
|
||||
Some(x)
|
||||
});
|
||||
if xdbf.is_none() && !resources.is_empty() {
|
||||
info!(resources = resources.len(), "resource table present but no XDBF package");
|
||||
}
|
||||
|
||||
// Build DisasmInfo
|
||||
let disasm_info = sylpheed_xexdb::formatter::DisasmInfo {
|
||||
image_base: base,
|
||||
entry_point: entry,
|
||||
original_pe_name: header.original_pe_name.as_deref(),
|
||||
title_id: header.execution_info.as_ref().map(|e| e.title_id),
|
||||
media_id: header.execution_info.as_ref().map(|e| e.media_id),
|
||||
sections: §ions,
|
||||
import_libraries: &header.import_libraries,
|
||||
xex_header: Some(&header),
|
||||
};
|
||||
|
||||
// SQLite database output (base + ingest + analyze layers)
|
||||
if let Some(db) = db_path {
|
||||
info!(db = %db, analyze = ?analyze, "writing database");
|
||||
let mut w = sylpheed_xexdb::DbWriter::open_fresh(std::path::Path::new(db))?;
|
||||
w.write_base(&disasm_info)?;
|
||||
w.ingest_instructions(
|
||||
&pe_image, &disasm_info, &func_analysis, &xref_result.labels, &jt_data_words,
|
||||
)?;
|
||||
w.write_analysis_results(
|
||||
&pe_image,
|
||||
&disasm_info,
|
||||
&func_analysis,
|
||||
&xref_result.labels,
|
||||
&xref_result.xrefs,
|
||||
&vtables,
|
||||
&strings,
|
||||
&fparrays,
|
||||
Some(&typed_ind),
|
||||
&eh_records,
|
||||
&jump_tables,
|
||||
&rtti,
|
||||
xdbf.as_ref(),
|
||||
)?;
|
||||
w.write_tls(tls_info.as_ref())?;
|
||||
if matches!(analyze, AnalyzeMode::Sql | AnalyzeMode::Both) {
|
||||
w.create_sql_views()?;
|
||||
info!(db = %db, "SQL views created");
|
||||
}
|
||||
if matches!(analyze, AnalyzeMode::Both) {
|
||||
let (sql_only, rust_only) = w.cross_check_branch_xrefs()?;
|
||||
if sql_only == 0 && rust_only == 0 {
|
||||
info!(db = %db, "Rust/SQL branch xrefs agree");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
db = %db,
|
||||
sql_only,
|
||||
rust_only,
|
||||
"Rust/SQL branch xref disagreement — investigate formatter mnemonic vs xref.rs kind classification"
|
||||
);
|
||||
}
|
||||
}
|
||||
info!(db = %db, "database written");
|
||||
}
|
||||
|
||||
// JSON Lines output: one row per instruction, structured columns.
|
||||
if let Some(json) = json_path {
|
||||
info!(json = %json, "writing JSON Lines");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(json)?);
|
||||
let mut total: u64 = 0;
|
||||
for section in §ions {
|
||||
if !section.is_code() { continue; }
|
||||
let abs_start = base + section.virtual_address;
|
||||
let abs_end = abs_start + section.virtual_size;
|
||||
let items = sylpheed_xexdb::enrich_section(
|
||||
&pe_image, base, §ion.name, abs_start, abs_end,
|
||||
&func_analysis, &xref_result.labels, &jt_data_words,
|
||||
);
|
||||
total += sylpheed_xexdb::sinks::json::write_jsonl(&mut out, items)?;
|
||||
}
|
||||
info!(json = %json, rows = total, "JSON Lines written");
|
||||
}
|
||||
|
||||
// Assembly output (skipped when --quiet and no --output specified)
|
||||
if !quiet || output.is_some() {
|
||||
let mut out: Box<dyn std::io::Write> = match output {
|
||||
Some(path) => Box::new(std::io::BufWriter::new(std::fs::File::create(path)?)),
|
||||
None => Box::new(std::io::BufWriter::new(std::io::stdout().lock())),
|
||||
};
|
||||
|
||||
sylpheed_xexdb::formatter::write_asm(
|
||||
&mut *out,
|
||||
&pe_image,
|
||||
&disasm_info,
|
||||
&func_analysis,
|
||||
&xref_result.labels,
|
||||
&import_map,
|
||||
&xref_result.xrefs,
|
||||
&xref_result.data_annotations,
|
||||
&jt_data_words,
|
||||
)?;
|
||||
|
||||
if let Some(path) = output {
|
||||
info!(path, "wrote disassembly");
|
||||
}
|
||||
}
|
||||
|
||||
info!(wall_ms = started.elapsed().as_millis() as u64, "dis complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_hex_u32;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_u32_accepts_0x_prefix() {
|
||||
assert_eq!(parse_hex_u32("0x824be9a0").unwrap(), 0x824be9a0);
|
||||
assert_eq!(parse_hex_u32("0X82000000").unwrap(), 0x82000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_u32_accepts_bare_hex() {
|
||||
// No 0x prefix, contains hex letters — treated as hex.
|
||||
assert_eq!(parse_hex_u32("824be9a0").unwrap(), 0x824be9a0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_u32_accepts_decimal() {
|
||||
// All digits, no 0x — treated as decimal.
|
||||
assert_eq!(parse_hex_u32("1000").unwrap(), 1000);
|
||||
assert_eq!(parse_hex_u32("0").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_u32_rejects_garbage() {
|
||||
assert!(parse_hex_u32("not a number").is_err());
|
||||
assert!(parse_hex_u32("0xZZZ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_u32_tolerates_whitespace() {
|
||||
assert_eq!(parse_hex_u32(" 0x82000000 ").unwrap(), 0x82000000);
|
||||
}
|
||||
}
|
||||
1957
crates/sylpheed-xexdb/src/db.rs
Normal file
1957
crates/sylpheed-xexdb/src/db.rs
Normal file
File diff suppressed because it is too large
Load Diff
376
crates/sylpheed-xexdb/src/demangle.rs
Normal file
376
crates/sylpheed-xexdb/src/demangle.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
154
crates/sylpheed-xexdb/src/disasm.rs
Normal file
154
crates/sylpheed-xexdb/src/disasm.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
296
crates/sylpheed-xexdb/src/eh_scope.rs
Normal file
296
crates/sylpheed-xexdb/src/eh_scope.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
//! 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, §ions);
|
||||
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, §ions);
|
||||
assert_eq!(recs.len(), 0);
|
||||
}
|
||||
}
|
||||
281
crates/sylpheed-xexdb/src/formatter.rs
Normal file
281
crates/sylpheed-xexdb/src/formatter.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! 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::{XrefKind, Xref, 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, §ion.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 §ion_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)
|
||||
}
|
||||
714
crates/sylpheed-xexdb/src/func.rs
Normal file
714
crates/sylpheed-xexdb/src/func.rs
Normal file
@@ -0,0 +1,714 @@
|
||||
//! 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::{HashMap, HashSet, BTreeMap};
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
257
crates/sylpheed-xexdb/src/funcptr_arrays.rs
Normal file
257
crates/sylpheed-xexdb/src/funcptr_arrays.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! 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, §ions, &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, §ions, &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");
|
||||
}
|
||||
}
|
||||
711
crates/sylpheed-xexdb/src/ind_dispatch_typed.rs
Normal file
711
crates/sylpheed-xexdb/src/ind_dispatch_typed.rs
Normal file
@@ -0,0 +1,711 @@
|
||||
//! 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 | 25 | 26 | 27 | 28 | 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) | 0 << 16 | (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());
|
||||
}
|
||||
|
||||
}
|
||||
474
crates/sylpheed-xexdb/src/indirect.rs
Normal file
474
crates/sylpheed-xexdb/src/indirect.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
//! 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) | (0 << 16) | (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) | (0 << 11) | (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");
|
||||
}
|
||||
}
|
||||
758
crates/sylpheed-xexdb/src/jumptables.rs
Normal file
758
crates/sylpheed-xexdb/src/jumptables.rs
Normal file
@@ -0,0 +1,758 @@
|
||||
//! Switch-statement (jump-table) recovery for MSVC PowerPC `bctr` dispatch.
|
||||
//!
|
||||
//! The Xbox 360 MSVC compiler lowers a dense `switch` to a table of **absolute
|
||||
//! target VAs** that is emitted *inline in `.text`*, immediately after the
|
||||
//! dispatching `bctr` in the common case. The canonical shape is:
|
||||
//!
|
||||
//! ```text
|
||||
//! cmplwi rIdx, N ; bound check — N is the largest case index
|
||||
//! bgt default ; out-of-range → default label
|
||||
//! lis r12, tab@h ; addis r12, r0, hi
|
||||
//! addi r12, r12, tab@l ; r12 = &table
|
||||
//! rlwinm r0, rIdx, 2, 22, 29 ; r0 = idx * 4
|
||||
//! lwzx r0, r12, r0 ; r0 = table[idx]
|
||||
//! mtctr r0
|
||||
//! bctr ; → case body
|
||||
//! .long case0, case1, ... ; the table itself, inline in .text
|
||||
//! ```
|
||||
//!
|
||||
//! Sparse switches add a second, byte-wide *index map* read with `lbzx`:
|
||||
//! `slot = map[idx]` then `target = table[slot]`, which lets several case
|
||||
//! values share one body without a full-width table. Both tables are
|
||||
//! recovered; for the two-level form the emitted `targets` vector is already
|
||||
//! **expanded per case value** (`targets[i] = table[map[i]]`), so consumers
|
||||
//! never have to redo the indirection.
|
||||
//!
|
||||
//! # Why this matters
|
||||
//!
|
||||
//! Without this pass the `bctr` is a dead end: the case bodies have no
|
||||
//! incoming edge (they are unreachable in `v_reachability_from_entry`), and —
|
||||
//! worse — the table words themselves are linearly disassembled as if they
|
||||
//! were instructions, so `instructions` carries thousands of bogus rows in the
|
||||
//! middle of otherwise correct functions. This module fixes both: it emits
|
||||
//! `jump` xrefs for every case target and reports the table extents as
|
||||
//! data-in-code regions so the disassembler can mark those words.
|
||||
//!
|
||||
//! # Validation
|
||||
//!
|
||||
//! A candidate table is accepted only while its entries land **inside the
|
||||
//! enclosing function** (per the `.pdata`-validated boundary from
|
||||
//! [`crate::func`]). That is not a heuristic softener — a `switch` always
|
||||
//! branches within its own function — and on the reference title it holds for
|
||||
//! 100% of recovered entries, which is what lets the scan terminate a table
|
||||
//! without needing the `cmplwi` bound. When the enclosing function is unknown
|
||||
//! the weaker "inside some code section" test is used instead.
|
||||
//!
|
||||
//! # Limits
|
||||
//!
|
||||
//! - Only `bctr` (tail dispatch) is considered. `bctrl` is a call through a
|
||||
//! function pointer — that is [`crate::indirect`]'s job, not a switch.
|
||||
//! - The constant tracker is a straight-line, single-block model over a fixed
|
||||
//! backward window. Table bases materialised across a branch, or through a
|
||||
//! register the model conservatively invalidates, are missed (no false
|
||||
//! positives result — target validation still gates every emission).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use 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 Some(i) = read(pc) else { return None };
|
||||
match op(i) {
|
||||
// addis rT, rA, SIMM (lis when rA == 0)
|
||||
15 => {
|
||||
let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] };
|
||||
regs[rt(i)] = base.map(|b| b.wrapping_add(uimm(i) << 16));
|
||||
}
|
||||
// addi rT, rA, SIMM (li when rA == 0)
|
||||
14 => {
|
||||
let base = if ra(i) == 0 { Some(0) } else { regs[ra(i)] };
|
||||
regs[rt(i)] = base.map(|b| b.wrapping_add(simm(i) as u32));
|
||||
}
|
||||
// ori / oris rA, rS, UIMM
|
||||
24 => regs[ra(i)] = regs[rt(i)].map(|b| b | uimm(i)),
|
||||
25 => regs[ra(i)] = regs[rt(i)].map(|b| b | (uimm(i) << 16)),
|
||||
// cmplwi / cmpwi rA, IMM — the switch's bound check.
|
||||
10 | 11 => bound = Some(uimm(i)),
|
||||
31 => {
|
||||
match xo(i) {
|
||||
23 => { // lwzx rT, rA, rB — the table read
|
||||
lwzx_dest = Some(rt(i));
|
||||
lwzx_regs = Some((regs[ra(i)], regs[rb(i)]));
|
||||
lwzx_index_tainted = from_lbzx[ra(i)] || from_lbzx[rb(i)];
|
||||
regs[rt(i)] = None;
|
||||
from_lbzx[rt(i)] = false;
|
||||
}
|
||||
87 => { // lbzx rT, rA, rB — the sparse index-map read
|
||||
lbzx_regs = Some((regs[ra(i)], regs[rb(i)]));
|
||||
regs[rt(i)] = None;
|
||||
from_lbzx = [false; 32];
|
||||
from_lbzx[rt(i)] = true;
|
||||
}
|
||||
467 if is_mtctr(i) => ctr_src = Some(rt(i)),
|
||||
// `mr rA, rS` is `or rA, rS, rS` — propagate constant + taint.
|
||||
444 if rt(i) == rb(i) => {
|
||||
regs[ra(i)] = regs[rt(i)];
|
||||
from_lbzx[ra(i)] = from_lbzx[rt(i)];
|
||||
}
|
||||
// `add rT, rA, rB` / `slw rA, rS, rB` also carry the index.
|
||||
266 => {
|
||||
regs[rt(i)] = None;
|
||||
from_lbzx[rt(i)] = from_lbzx[ra(i)] || from_lbzx[rb(i)];
|
||||
}
|
||||
24 => {
|
||||
regs[ra(i)] = None;
|
||||
from_lbzx[ra(i)] = from_lbzx[rt(i)];
|
||||
}
|
||||
_ => {
|
||||
if let Some(d) = op31_dest(i) {
|
||||
regs[d] = None;
|
||||
from_lbzx[d] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// rlwinm / rlwnm / rlwimi write rA — and are how a byte index gets
|
||||
// scaled to a word offset, so they carry the taint through.
|
||||
20 | 21 | 23 => {
|
||||
regs[ra(i)] = None;
|
||||
from_lbzx[ra(i)] = from_lbzx[rt(i)];
|
||||
}
|
||||
// D/DS-form GPR loads write rT; the update forms also write rA.
|
||||
32 | 34 | 40 | 42 => regs[rt(i)] = None,
|
||||
33 | 35 | 41 | 43 => { regs[rt(i)] = None; regs[ra(i)] = None; }
|
||||
// DS-form: bits 30..31 pick ld(0) / ldu(1) / lwa(2).
|
||||
58 => {
|
||||
regs[rt(i)] = None;
|
||||
if i & 3 == 1 { regs[ra(i)] = None; }
|
||||
}
|
||||
// lmw loads rT..r31.
|
||||
46 => for r in rt(i)..32 { regs[r] = None; },
|
||||
// FP loads touch no GPR — except the update forms, which write rA.
|
||||
// Plain stores write no register at all (their `rT` field is the
|
||||
// *source*), so a tracked base that merely gets spilled survives.
|
||||
37 | 39 | 45 | 49 | 51 | 53 | 55 => regs[ra(i)] = None,
|
||||
// DS-form: bits 30..31 pick std(0) / stdu(1); only stdu writes rA.
|
||||
62 if i & 3 == 1 => regs[ra(i)] = None,
|
||||
// Immediate ALU: 7/8/12/13 write rT, 28/29 (andi./andis.) write rA.
|
||||
7 | 8 | 12 | 13 => regs[rt(i)] = None,
|
||||
28 | 29 => regs[ra(i)] = None,
|
||||
_ => {}
|
||||
}
|
||||
pc = pc.wrapping_add(4);
|
||||
}
|
||||
|
||||
// CTR must actually be loaded from the table read — otherwise the `lwzx`
|
||||
// in the window belongs to unrelated code and the `bctr` is a plain
|
||||
// indirect tail-call.
|
||||
if ctr_src.is_none() || ctr_src != lwzx_dest {
|
||||
rejects.not_table_driven += 1;
|
||||
return None;
|
||||
}
|
||||
let Some((a_val, b_val)) = lwzx_regs else {
|
||||
rejects.not_table_driven += 1;
|
||||
return None;
|
||||
};
|
||||
|
||||
// Exactly one operand of the table read must be a constant that lands in
|
||||
// code — the other is the scaled index. Two constants is ambiguous.
|
||||
let table_address = match (a_val.filter(|&v| in_code(v)), b_val.filter(|&v| in_code(v))) {
|
||||
(Some(v), None) | (None, Some(v)) => v,
|
||||
_ => {
|
||||
rejects.base_unresolved += 1;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Same test for the sparse index-map base — but only when the byte that
|
||||
// `lbzx` produced actually reached the table read as its index.
|
||||
let index_map_address = lbzx_regs.filter(|_| lwzx_index_tainted).and_then(|(a, b)| {
|
||||
match (a.filter(|&v| in_code(v)), b.filter(|&v| in_code(v))) {
|
||||
(Some(v), None) | (None, Some(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
// A recovered target is valid only inside the enclosing function; that is
|
||||
// exact for a `switch`, and on the reference title it holds for 100% of
|
||||
// recovered entries. Where no `.pdata` range covers the dispatch there is
|
||||
// no trustworthy bound, so the compiler's own `cmplwi` bound is required
|
||||
// instead and targets are only checked for being code at all.
|
||||
if enclosing.is_none() && bound.is_none() {
|
||||
rejects.base_unresolved += 1;
|
||||
return None;
|
||||
}
|
||||
let valid = |t: u32| match enclosing {
|
||||
Some((s, e)) => t >= s && t < e,
|
||||
None => in_code(t),
|
||||
};
|
||||
|
||||
if let (Some(map_addr), Some(n)) = (index_map_address, bound) {
|
||||
// Two-level: expand map[0..=bound] through the table in one shot.
|
||||
let count = n.saturating_add(1).min(MAX_ENTRIES);
|
||||
let mut targets = Vec::with_capacity(count as usize);
|
||||
let mut max_slot = 0u32;
|
||||
for i in 0..count {
|
||||
let byte_off = map_addr.wrapping_add(i);
|
||||
let word = read(byte_off & !3)?;
|
||||
let slot = (word >> (8 * (3 - (byte_off & 3)))) & 0xFF;
|
||||
let t = read(table_address.wrapping_add(slot * 4))?;
|
||||
if !valid(t) { break; }
|
||||
max_slot = max_slot.max(slot);
|
||||
targets.push(t);
|
||||
}
|
||||
if targets.len() < 2 {
|
||||
rejects.too_few_entries += 1;
|
||||
return None;
|
||||
}
|
||||
let n_read = targets.len() as u32;
|
||||
return Some(JumpTable {
|
||||
bctr_pc,
|
||||
function: enclosing.map(|(s, _)| s),
|
||||
table_address,
|
||||
entry_count: n_read,
|
||||
table_slots: max_slot + 1,
|
||||
index_map_address,
|
||||
index_map_count: Some(n_read),
|
||||
bound,
|
||||
kind: "indexed",
|
||||
targets,
|
||||
});
|
||||
}
|
||||
|
||||
// Dense: read consecutive absolute targets until one leaves the function.
|
||||
let cap = bound.map(|n| n.saturating_add(1)).unwrap_or(MAX_ENTRIES).min(MAX_ENTRIES);
|
||||
let mut targets = Vec::new();
|
||||
for i in 0..cap {
|
||||
let Some(t) = read(table_address.wrapping_add(i * 4)) else { break };
|
||||
if !valid(t) { break; }
|
||||
targets.push(t);
|
||||
}
|
||||
if targets.len() < 2 {
|
||||
rejects.too_few_entries += 1;
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JumpTable {
|
||||
bctr_pc,
|
||||
function: enclosing.map(|(s, _)| s),
|
||||
table_address,
|
||||
entry_count: targets.len() as u32,
|
||||
table_slots: targets.len() as u32,
|
||||
index_map_address: None,
|
||||
index_map_count: None,
|
||||
bound,
|
||||
kind: "direct",
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Collapse every recovered table's raw extents into a sorted, merged interval
|
||||
/// list of data-in-code byte ranges.
|
||||
pub fn data_regions(tables: &[JumpTable]) -> Vec<(u32, u32)> {
|
||||
let mut regions: Vec<(u32, u32)> = tables
|
||||
.iter()
|
||||
.flat_map(|t| t.data_regions())
|
||||
.filter(|&(_, len)| len > 0)
|
||||
.collect();
|
||||
regions.sort_unstable();
|
||||
|
||||
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(regions.len());
|
||||
for (addr, len) in regions {
|
||||
match merged.last_mut() {
|
||||
Some((p_addr, p_len)) if addr <= p_addr.wrapping_add(*p_len) => {
|
||||
let end = addr.wrapping_add(len).max(p_addr.wrapping_add(*p_len));
|
||||
*p_len = end.wrapping_sub(*p_addr);
|
||||
}
|
||||
_ => merged.push((addr, len)),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Expand merged byte ranges into the set of 4-byte-aligned word addresses they
|
||||
/// cover — the granularity at which `instructions` rows are marked.
|
||||
pub fn data_word_addresses(tables: &[JumpTable]) -> BTreeSet<u32> {
|
||||
let mut set = BTreeSet::new();
|
||||
for (addr, len) in data_regions(tables) {
|
||||
let start = addr & !3;
|
||||
let end = addr.wrapping_add(len).div_ceil(4) * 4;
|
||||
let mut a = start;
|
||||
while a < end {
|
||||
set.insert(a);
|
||||
a = a.wrapping_add(4);
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
use 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, §ions, &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, §ions, &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, §ions, &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, §ions, &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, §ions, &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));
|
||||
}
|
||||
}
|
||||
26
crates/sylpheed-xexdb/src/lib.rs
Normal file
26
crates/sylpheed-xexdb/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
pub mod ppc;
|
||||
pub mod func;
|
||||
pub mod xref;
|
||||
pub mod db;
|
||||
pub mod disasm;
|
||||
pub mod formatter;
|
||||
pub mod sinks;
|
||||
pub mod sql_views;
|
||||
pub mod demangle;
|
||||
pub mod vtables;
|
||||
pub mod lookup;
|
||||
pub mod indirect;
|
||||
pub mod ind_dispatch_typed;
|
||||
pub mod strings;
|
||||
pub mod funcptr_arrays;
|
||||
pub mod eh_scope;
|
||||
pub mod static_init;
|
||||
pub mod xdbf;
|
||||
pub mod jumptables;
|
||||
pub mod rtti;
|
||||
|
||||
mod ordinals;
|
||||
pub use ordinals::resolve_ordinal;
|
||||
pub use xref::{XrefKind, Xref, XrefMap, resolve_source_label};
|
||||
pub use db::{DbWriter, ExecTraceEntry, ImportCallEntry, BranchTraceEntry};
|
||||
pub use disasm::{RichDisasmItem, enrich_section};
|
||||
222
crates/sylpheed-xexdb/src/lookup.rs
Normal file
222
crates/sylpheed-xexdb/src/lookup.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! 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::{anyhow, Result};
|
||||
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);
|
||||
}
|
||||
}
|
||||
1
crates/sylpheed-xexdb/src/ordinals.rs
Normal file
1
crates/sylpheed-xexdb/src/ordinals.rs
Normal file
@@ -0,0 +1 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/ordinals.rs"));
|
||||
28
crates/sylpheed-xexdb/src/ppc.rs
Normal file
28
crates/sylpheed-xexdb/src/ppc.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! 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 }
|
||||
}
|
||||
453
crates/sylpheed-xexdb/src/rtti.rs
Normal file
453
crates/sylpheed-xexdb/src/rtti.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
//! MSVC RTTI recovery — the authoritative source of C++ class identity.
|
||||
//!
|
||||
//! [`crate::vtables`] finds vtables *bottom-up*, by looking for runs of words
|
||||
//! that happen to be function entries, and only then tries the RTTI walk. That
|
||||
//! misses every table whose head holds a null / pure-virtual / thunk slot, and
|
||||
//! it cannot see a class that has no such run at all. This module works
|
||||
//! *top-down* from the RTTI structures the linker emitted, which is exact:
|
||||
//! a `CompleteObjectLocator` names its class, and the word that points at a
|
||||
//! COL is by definition `vftable[-1]`.
|
||||
//!
|
||||
//! ## Structure layout (32-bit MSVC, big-endian on Xbox 360)
|
||||
//!
|
||||
//! ```text
|
||||
//! TypeDescriptor (in .data — it is written at startup)
|
||||
//! +0 void* pVFTable -> type_info's own vftable (identical for all TDs)
|
||||
//! +4 void* spare
|
||||
//! +8 char name[] -> ".?AVFoo@Bar@@", NUL-terminated
|
||||
//!
|
||||
//! RTTICompleteObjectLocator (in .rdata)
|
||||
//! +0 u32 signature -> 0 for 32-bit images
|
||||
//! +4 u32 offset -> this-offset of the subobject this vftable serves
|
||||
//! +8 u32 cdOffset -> constructor-displacement offset
|
||||
//! +12 TypeDescriptor*
|
||||
//! +16 RTTIClassHierarchyDescriptor*
|
||||
//!
|
||||
//! RTTIClassHierarchyDescriptor (in .rdata)
|
||||
//! +0 u32 signature
|
||||
//! +4 u32 attributes -> bit 0 = multiple inheritance, bit 1 = virtual
|
||||
//! +8 u32 numBaseClasses
|
||||
//! +12 RTTIBaseClassDescriptor** pBaseClassArray
|
||||
//!
|
||||
//! RTTIBaseClassDescriptor (in .rdata)
|
||||
//! +0 TypeDescriptor*
|
||||
//! +4 u32 numContainedBases
|
||||
//! +8 i32 PMD.mdisp -> member displacement
|
||||
//! +12 i32 PMD.pdisp -> vbtable displacement (-1 = not virtual)
|
||||
//! +16 i32 PMD.vdisp -> displacement inside the vbtable
|
||||
//! +20 u32 attributes
|
||||
//! ```
|
||||
//!
|
||||
//! A vtable is located at `col_ref + 4` for every word `col_ref` whose value is
|
||||
//! a validated COL address. `offset` distinguishes the primary vftable
|
||||
//! (`offset == 0`) from the extra vftables a multiply-inheriting class emits
|
||||
//! for its secondary base subobjects — those are linked to the same class
|
||||
//! rather than being reported as unrelated tables.
|
||||
//!
|
||||
//! ## Limits
|
||||
//!
|
||||
//! - Only statically-emitted RTTI is seen; a class whose RTTI the linker
|
||||
//! stripped stays anonymous and is left to [`crate::vtables`].
|
||||
//! - Vtable *length* is measured by walking forward from `vftable[0]` while the
|
||||
//! words are plausible method pointers, stopping at the next COL reference or
|
||||
//! at a known label — the linker does not record it.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use 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 + 0x000);
|
||||
emit_class(&mut img, da + 0x200, ".?AVDerived@ns@@",
|
||||
rd + 0x400, 0, rd + 0x500, rd + 0x580, rd + 0x600, Some(da + 0x100),
|
||||
rd + 0x040);
|
||||
|
||||
let r = analyze(&img.0, BASE, §ions());
|
||||
|
||||
assert_eq!(r.type_descriptors.len(), 2);
|
||||
let derived = r.type_descriptors.iter()
|
||||
.find(|t| t.mangled_name.contains("Derived")).unwrap();
|
||||
assert_eq!(derived.demangled_name, "ns::Derived");
|
||||
|
||||
assert_eq!(r.locators.len(), 2);
|
||||
// vftable[0] is one word past the word holding the COL pointer.
|
||||
assert_eq!(r.vtable_to_locator.get(&(rd + 0x044)), Some(&(rd + 0x400)));
|
||||
assert!(r.vtable_anchors().contains(&(rd + 0x004)));
|
||||
|
||||
let names = r.vtable_class_names();
|
||||
assert_eq!(names.get(&(rd + 0x044)), Some(&("ns::Derived".to_string(), 0)));
|
||||
|
||||
// Derived's hierarchy lists itself at index 0 and Foo at index 1.
|
||||
let mut bases: Vec<_> = r.base_classes.iter()
|
||||
.filter(|b| b.class_hierarchy == rd + 0x500)
|
||||
.collect();
|
||||
bases.sort_by_key(|b| b.index);
|
||||
assert_eq!(bases.len(), 2);
|
||||
assert_eq!(bases[1].name, "ns::Foo");
|
||||
assert_eq!(bases[1].mdisp, 4);
|
||||
assert_eq!(bases[1].pdisp, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_base_vftable_keeps_its_subobject_offset() {
|
||||
let mut img = Image::new();
|
||||
let rd = BASE + RDATA_RVA;
|
||||
let da = BASE + DATA_RVA;
|
||||
emit_class(&mut img, da + 0x100, ".?AVMulti@@",
|
||||
rd + 0x100, 0x8, rd + 0x200, rd + 0x280, rd + 0x300, None,
|
||||
rd + 0x000);
|
||||
|
||||
let r = analyze(&img.0, BASE, §ions());
|
||||
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, §ions());
|
||||
assert!(r.locators.is_empty());
|
||||
assert!(r.type_descriptors.is_empty());
|
||||
}
|
||||
}
|
||||
39
crates/sylpheed-xexdb/src/sinks/duckdb.rs
Normal file
39
crates/sylpheed-xexdb/src/sinks/duckdb.rs
Normal 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)
|
||||
}
|
||||
65
crates/sylpheed-xexdb/src/sinks/json.rs
Normal file
65
crates/sylpheed-xexdb/src/sinks/json.rs
Normal 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)
|
||||
}
|
||||
8
crates/sylpheed-xexdb/src/sinks/mod.rs
Normal file
8
crates/sylpheed-xexdb/src/sinks/mod.rs
Normal 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;
|
||||
71
crates/sylpheed-xexdb/src/sinks/text.rs
Normal file
71
crates/sylpheed-xexdb/src/sinks/text.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! 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 xenia_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)
|
||||
}
|
||||
285
crates/sylpheed-xexdb/src/sql_views.rs
Normal file
285
crates/sylpheed-xexdb/src/sql_views.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! 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');
|
||||
";
|
||||
399
crates/sylpheed-xexdb/src/static_init.rs
Normal file
399
crates/sylpheed-xexdb/src/static_init.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
//! 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) as u32);
|
||||
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) as u32);
|
||||
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, §ions, &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, §ions, &fa, &starts, &labels);
|
||||
assert_eq!(r.drivers.len(), 0);
|
||||
}
|
||||
}
|
||||
479
crates/sylpheed-xexdb/src/strings.rs
Normal file
479
crates/sylpheed-xexdb/src/strings.rs
Normal file
@@ -0,0 +1,479 @@
|
||||
//! 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, §ions);
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
// 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, §ions);
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
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, §ions);
|
||||
assert_eq!(strings.len(), 0);
|
||||
}
|
||||
}
|
||||
841
crates/sylpheed-xexdb/src/vtables.rs
Normal file
841
crates/sylpheed-xexdb/src/vtables.rs
Normal file
@@ -0,0 +1,841 @@
|
||||
//! 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 = match read_typedescriptor_name(pe, image_base, td_ptr, rdata_ranges) {
|
||||
Some(s) => s,
|
||||
None => return None,
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
#[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, §ions, &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, §ions, &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, §ions, &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) | (0 << 16) | 0x8201;
|
||||
let lo = (vt_base & 0xFFFF) as i16; // -22264
|
||||
let addi = (14u32 << 26) | (11 << 21) | (0 << 16) | ((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) | 0; // 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, §ions, &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, §ions, &function_starts);
|
||||
assert_eq!(vtables.len(), 0, "runs of 2 must be rejected to keep false-positive rate down");
|
||||
}
|
||||
}
|
||||
|
||||
// ── RTTI relabelling ───────────────────────────────────────────────────────
|
||||
|
||||
/// Overwrite heuristic vtable identity with the authoritative RTTI walk.
|
||||
///
|
||||
/// [`analyze_with_anchors`] names a table either from its own inline COL walk
|
||||
/// or, failing that, with a synthetic `ANON_Class_<hash>`. [`crate::rtti`]
|
||||
/// resolves the same question top-down from the structures the linker emitted,
|
||||
/// which is exact — so wherever the two disagree, RTTI wins. Rows RTTI knows
|
||||
/// nothing about keep their heuristic name.
|
||||
///
|
||||
/// `base_classes_json` is rebuilt here as the class's full linearised base list
|
||||
/// (excluding index 0, which is the class itself), which is strictly more than
|
||||
/// the first-level list the inline walk produced.
|
||||
///
|
||||
/// Returns the number of vtables that gained a real class name.
|
||||
pub fn apply_rtti_names(vtables: &mut [Vtable], rtti: &crate::rtti::RttiResult) -> usize {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let names = rtti.vtable_class_names();
|
||||
let locator_by_vtable: BTreeMap<u32, &crate::rtti::CompleteObjectLocator> = rtti
|
||||
.locators
|
||||
.iter()
|
||||
.filter_map(|c| c.vtable_address.map(|v| (v, c)))
|
||||
.collect();
|
||||
|
||||
// class-hierarchy VA → base class names, in the linker's order.
|
||||
let mut bases_by_chd: BTreeMap<u32, Vec<&str>> = BTreeMap::new();
|
||||
for b in &rtti.base_classes {
|
||||
if b.index == 0 { continue; } // index 0 is the class itself
|
||||
bases_by_chd.entry(b.class_hierarchy).or_default().push(b.name.as_str());
|
||||
}
|
||||
|
||||
let mut named = 0usize;
|
||||
for vt in vtables.iter_mut() {
|
||||
let Some((class_name, offset)) = names.get(&vt.address) else { continue };
|
||||
// A secondary-base vftable belongs to the same class but is a distinct
|
||||
// table; keep them apart by suffixing the subobject offset.
|
||||
vt.class_name = if *offset == 0 {
|
||||
class_name.clone()
|
||||
} else {
|
||||
format!("{class_name}#base+0x{offset:X}")
|
||||
};
|
||||
vt.rtti_present = true;
|
||||
if let Some(col) = locator_by_vtable.get(&vt.address) {
|
||||
vt.col_address = Some(col.address);
|
||||
vt.base_classes_json = bases_by_chd.get(&col.class_hierarchy).map(|names| {
|
||||
let items: Vec<String> = names
|
||||
.iter()
|
||||
.map(|n| format!("\"{}\"", n.replace('\\', "\\\\").replace('"', "\\\"")))
|
||||
.collect();
|
||||
format!("[{}]", items.join(","))
|
||||
});
|
||||
}
|
||||
named += 1;
|
||||
}
|
||||
named
|
||||
}
|
||||
450
crates/sylpheed-xexdb/src/xdbf.rs
Normal file
450
crates/sylpheed-xexdb/src/xdbf.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! XDBF / SPA — the title metadata package embedded in the XEX.
|
||||
//!
|
||||
//! A title's `XEX_HEADER_RESOURCE_INFO` names one resource whose body is an
|
||||
//! **XDBF** ("Xbox DataBase File") container, in its SPA flavour: achievement
|
||||
//! definitions, one string table per shipped language, PNG images, and the
|
||||
//! matchmaking / leaderboard / presence schema.
|
||||
//!
|
||||
//! ```text
|
||||
//! XdbfHeader 24 bytes magic 'XDBF', version, entry_count, entry_used,
|
||||
//! free_count, free_used
|
||||
//! XdbfEntry[] 18 each namespace u16, id u64, offset u32, size u32
|
||||
//! XdbfFileLoc[] 8 each the free-space table
|
||||
//! data entry offsets are relative to the end of the two tables
|
||||
//! ```
|
||||
//!
|
||||
//! Each entry's body starts with a section header — `magic, version, size`,
|
||||
//! plus a `u16 count` for the table-shaped ones.
|
||||
//!
|
||||
//! Entries are enumerated from the **entry table**, not by scanning for section
|
||||
//! magics. Scanning is what the project's earlier `tools/xach_dump.py` does, and
|
||||
//! on this title it finds a phantom seventh `XSTR` (the byte pattern occurs
|
||||
//! outside any declared entry) where the entry table declares six — which shifts
|
||||
//! every language index derived from the scan order.
|
||||
//!
|
||||
//! Layouts follow the reference implementation in xenia-canary
|
||||
//! (`src/xenia/kernel/xam/xdbf/{xdbf_io,spa_info}.h`), which in turn cites
|
||||
//! freestyledash `Tools/XEX/SPA.{h,cpp}`.
|
||||
|
||||
/// `XDBF` big-endian.
|
||||
const XDBF_MAGIC: u32 = 0x5844_4246;
|
||||
|
||||
/// The well-known entry id carrying the title's own name (in the string-table
|
||||
/// namespace) and its icon (in the image namespace) — canary's `kXdbfIdTitle`.
|
||||
pub const ID_TITLE: u64 = 0x8000;
|
||||
|
||||
const NS_METADATA: u16 = 1;
|
||||
const NS_IMAGE: u16 = 2;
|
||||
const NS_STRING_TABLE: u16 = 3;
|
||||
|
||||
/// One row of the container's entry table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct XdbfEntry {
|
||||
/// 1 = metadata, 2 = image, 3 = string table.
|
||||
pub namespace: u16,
|
||||
/// Entry id. For metadata entries this is the section fourcc as an integer;
|
||||
/// for string tables it is the [`XLanguage`] value; for images, the image id.
|
||||
pub id: u64,
|
||||
/// Absolute offset of the entry body within the image buffer.
|
||||
pub offset: usize,
|
||||
/// Entry body length in bytes.
|
||||
pub size: usize,
|
||||
/// The body's leading fourcc, when it has one (`XACH`, `XSTR`, …).
|
||||
pub magic: Option<String>,
|
||||
}
|
||||
|
||||
/// One achievement definition (`XACH`, 36-byte records).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Achievement {
|
||||
pub id: u16,
|
||||
/// String id of the achievement's name.
|
||||
pub label_id: u16,
|
||||
/// String id of the description shown once unlocked.
|
||||
pub description_id: u16,
|
||||
/// String id of the description shown while locked.
|
||||
pub unachieved_id: u16,
|
||||
pub image_id: u32,
|
||||
pub gamerscore: u16,
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// One localized string table (`XSTR`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StringTable {
|
||||
/// `XLanguage` value; the entry id.
|
||||
pub language: u32,
|
||||
/// `(string id, value)` in table order.
|
||||
pub strings: Vec<(u16, String)>,
|
||||
}
|
||||
|
||||
/// `XTHD` — the title header.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TitleHeader {
|
||||
pub title_id: u32,
|
||||
pub title_type: u32,
|
||||
pub major: u16,
|
||||
pub minor: u16,
|
||||
pub build: u16,
|
||||
pub revision: u16,
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
/// An embedded image (namespace 2). Bodies are raw files, in practice PNG.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Image {
|
||||
pub id: u64,
|
||||
pub offset: usize,
|
||||
pub size: usize,
|
||||
/// `"png"` when the body carries the PNG signature, else `"unknown"`.
|
||||
pub format: &'static str,
|
||||
}
|
||||
|
||||
/// Everything recovered from one XDBF package.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Xdbf {
|
||||
/// Offset of the container within the image buffer.
|
||||
pub base: usize,
|
||||
pub version: u32,
|
||||
pub entries: Vec<XdbfEntry>,
|
||||
pub achievements: Vec<Achievement>,
|
||||
pub string_tables: Vec<StringTable>,
|
||||
pub images: Vec<Image>,
|
||||
pub title: Option<TitleHeader>,
|
||||
/// `XSTC` default language (an `XLanguage` value).
|
||||
pub default_language: Option<u32>,
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*b.get(o)?, *b.get(o + 1)?]))
|
||||
}
|
||||
fn be32(b: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*b.get(o)?, *b.get(o + 1)?, *b.get(o + 2)?, *b.get(o + 3)?,
|
||||
]))
|
||||
}
|
||||
fn be64(b: &[u8], o: usize) -> Option<u64> {
|
||||
let hi = be32(b, o)? as u64;
|
||||
let lo = be32(b, o + 4)? as u64;
|
||||
Some((hi << 32) | lo)
|
||||
}
|
||||
|
||||
/// Render a fourcc as text when all four bytes are printable ASCII.
|
||||
fn fourcc(v: u32) -> Option<String> {
|
||||
let b = v.to_be_bytes();
|
||||
b.iter()
|
||||
.all(|c| (0x20..0x7F).contains(c))
|
||||
.then(|| String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// Human-readable name for an `XLanguage` value.
|
||||
pub fn language_name(v: u32) -> &'static str {
|
||||
match v {
|
||||
1 => "English",
|
||||
2 => "Japanese",
|
||||
3 => "German",
|
||||
4 => "French",
|
||||
5 => "Spanish",
|
||||
6 => "Italian",
|
||||
7 => "Korean",
|
||||
8 => "Chinese (Traditional)",
|
||||
9 => "Portuguese",
|
||||
10 => "Chinese (Simplified)",
|
||||
11 => "Polish",
|
||||
12 => "Russian",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the XDBF package at `base` within `image`.
|
||||
///
|
||||
/// Returns `None` when there is no XDBF magic there — callers locate the
|
||||
/// package via `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());
|
||||
}
|
||||
}
|
||||
563
crates/sylpheed-xexdb/src/xref.rs
Normal file
563
crates/sylpheed-xexdb/src/xref.rs
Normal file
@@ -0,0 +1,563 @@
|
||||
//! Cross-reference analysis for Xbox 360 PE images.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use sylpheed_xex::pe::PeSection;
|
||||
use crate::func::FuncAnalysis;
|
||||
|
||||
// ── 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(®_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());
|
||||
}
|
||||
}
|
||||
450
crates/sylpheed-xexdb/tests/db_schema_golden.rs
Normal file
450
crates/sylpheed-xexdb/tests/db_schema_golden.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! 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_xexdb::DbWriter;
|
||||
use sylpheed_xexdb::formatter::DisasmInfo;
|
||||
use sylpheed_xexdb::func::{FuncAnalysis, FuncInfo};
|
||||
use sylpheed_xexdb::rtti::RttiResult;
|
||||
use sylpheed_xexdb::xref::XrefMap;
|
||||
use sylpheed_xex::pe::PeSection;
|
||||
|
||||
/// 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: §ions,
|
||||
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);
|
||||
}
|
||||
123
crates/sylpheed-xexdb/tests/disasm_goldens.rs
Normal file
123
crates/sylpheed-xexdb/tests/disasm_goldens.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! 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("xenia-cpu")
|
||||
.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 xenia-cpu --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);
|
||||
}
|
||||
216
tools/zq.py
Executable file
216
tools/zq.py
Executable file
@@ -0,0 +1,216 @@
|
||||
#!/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 (read it from guest
|
||||
memory with `xenia-rs exec ... --dump-addr=0x<va>` instead).
|
||||
|
||||
Usage:
|
||||
zq.py dis <lo_hex> <hi_hex> # disassemble [lo,hi) (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()
|
||||
Reference in New Issue
Block a user