wip: extract the xexdb tool closure
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user