826 lines
32 KiB
Rust
826 lines
32 KiB
Rust
//! `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);
|
|
}
|
|
}
|