Files
Sylpheed/crates/sylpheed-xexdb/src/bin/sylph-xexdb.rs
2026-09-13 19:31:49 +02:00

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: &sections,
};
// 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: &sections,
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, &sections);
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, &sections, &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, &sections, &func_analysis, &import_map, &jt_data_words,
);
// Feed the recovered `switch` edges into the xref graph, so case bodies
// stop looking unreachable and get a label of their own.
let mut jt_edges = 0usize;
for jt in &jump_tables {
for target in jt.distinct_targets() {
xref_result.xrefs
.entry(target)
.or_default()
.push(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, &sections, &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, &sections);
let rtti_anchors = rtti.vtable_anchors();
let rtti_new_anchors = rtti_anchors.difference(&vtable_anchors).count();
vtable_anchors.extend(rtti_anchors.iter().copied());
info!(
rtti_vtables = rtti_anchors.len(),
new_anchors = rtti_new_anchors,
"RTTI anchors merged",
);
let mut vtables = sylpheed_xexdb::vtables::analyze_with_anchors(
&pe_image, base, &sections, &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, &sections);
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, &sections);
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, &sections, &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, &sections, &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, &sections);
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: &sections,
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 &sections {
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, &section.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);
}
}