Initial commit: xenia-rs workspace for Xbox 360 RE

Rust reimplementation of the xenia Xbox 360 emulator targeting reverse-
engineering and preservation, initially scoped to Project Sylpheed.

Includes:
- XEX2 loader (LZX decompression, AES decryption, PE parsing)
- XISO / XGD2 disc image VFS
- PPC interpreter with 200+ opcodes and VMX128 decoding
- Static analyzer: functions, cross-references, labels, asm + SQLite output
- HLE kernel covering the xboxkrnl/xam subset used by Sylpheed init
- Debugger with in-memory and SQLite-backed execution tracing
- `xenia-rs` CLI with extract/dis/exec commands that produce cumulative,
  superset SQLite databases and opt-in instruction/import/branch traces

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-16 23:11:49 +02:00
commit c694bb3f43
63 changed files with 13456 additions and 0 deletions

View File

@@ -0,0 +1,812 @@
use std::collections::HashMap;
use anyhow::Result;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
use xenia_kernel::ModuleId;
use xenia_memory::MemoryAccess;
#[derive(Parser)]
#[command(name = "xenia-rs")]
#[command(about = "Xbox 360 emulator for reverse engineering and preservation")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Disassemble a XEX file from its entry point
Disasm {
/// Path to XEX file
path: String,
/// Number of instructions to disassemble
#[arg(short = 'n', default_value = "64")]
count: usize,
},
/// Load and execute a XEX file with tracing
Exec {
/// Path to XEX file
path: String,
/// Maximum instructions to execute before stopping (unlimited if omitted)
#[arg(short = 'n')]
max_instructions: Option<u64>,
/// SQLite database to write to. Includes the full static analysis
/// that `dis --db` would produce, plus any opt-in trace tables.
#[arg(long)]
db: Option<String>,
/// Log each executed instruction to the `exec_trace` table
#[arg(long)]
trace_instructions: bool,
/// Log kernel/import calls to the `import_calls` table
#[arg(long)]
trace_imports: bool,
/// Log taken branches (calls, returns, jumps) to the `branch_trace` table
#[arg(long)]
trace_branches: bool,
/// Suppress banners, kernel-call logs, and final register dump
/// (only errors, faults, halts, and the summary line are printed)
#[arg(long)]
quiet: bool,
},
/// Browse XISO disc image contents
Browse {
/// Path to XISO file
path: String,
},
/// Display XEX header information
Info {
/// Path to XEX 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>,
/// Suppress assembly text output (DB-only mode)
#[arg(long)]
quiet: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
// Bump default log level to `warn` for quiet exec runs so kernel-call
// tracing::info! spam is filtered out. RUST_LOG still wins if set.
let exec_quiet = matches!(&cli.command, Commands::Exec { quiet: true, .. });
let default_level = if exec_quiet { "warn" } else { "info" };
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(default_level.parse()?))
.init();
match cli.command {
Commands::Disasm { path, count } => cmd_disasm(&path, count),
Commands::Exec {
path,
max_instructions,
db,
trace_instructions,
trace_imports,
trace_branches,
quiet,
} => cmd_exec(
&path,
max_instructions,
db.as_deref(),
trace_instructions,
trace_imports,
trace_branches,
quiet,
),
Commands::Browse { path } => cmd_browse(&path),
Commands::Info { path } => cmd_info(&path),
Commands::Extract { path, output, db } => cmd_extract(&path, output.as_deref(), db.as_deref()),
Commands::Dis { path, output, db, quiet } => cmd_dis(&path, output.as_deref(), db.as_deref(), quiet),
}
}
/// Load XEX data from a path. If the path is an ISO, extract default.xex from it.
fn load_xex_data(path: &str) -> Result<Vec<u8>> {
let lower = path.to_lowercase();
if lower.ends_with(".iso") || lower.ends_with(".xiso") {
use xenia_vfs::VfsDevice;
println!("Detected disc image, extracting default.xex...");
let disc = xenia_vfs::disc_image::DiscImageDevice::open("disc", std::path::Path::new(path))
.map_err(|e| anyhow::anyhow!("Failed to open disc image: {}", e))?;
disc.read_file("default.xex")
.map_err(|e| anyhow::anyhow!("Failed to extract default.xex from disc image: {}", e))
} else {
Ok(std::fs::read(path)?)
}
}
fn cmd_info(path: &str) -> Result<()> {
let data = load_xex_data(path)?;
let header = xenia_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) = xenia_xex::loader::get_entry_point(&header) {
println!("Entry Point: {:#010x}", entry);
}
if let Some(base) = xenia_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());
}
}
Ok(())
}
fn cmd_disasm(path: &str, count: usize) -> Result<()> {
let data = load_xex_data(path)?;
let header = xenia_xex::loader::parse_xex2_header(&data)?;
let entry = xenia_xex::loader::get_entry_point(&header)
.ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?;
let base = xenia_xex::loader::get_image_base(&header)
.ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?;
println!("Entry point: {:#010x}, Image base: {:#010x}", entry, base);
// Load and decompress the image
let image_data = xenia_xex::loader::load_image(&data, &header)?;
println!("Image loaded: {} bytes decompressed", image_data.len());
println!("Disassembly from entry point ({} instructions):\n", count);
let entry_offset = (entry - base) as usize;
if entry_offset + count * 4 <= image_data.len() {
let block = xenia_cpu::disasm::disassemble_block(&image_data[entry_offset..], entry, count);
for (addr, text) in block {
println!(" {:#010x}: {}", addr, text);
}
} else {
println!(" (entry point offset {:#x} is outside image bounds, image is {:#x} bytes)", entry_offset, image_data.len());
}
Ok(())
}
fn cmd_exec(
path: &str,
max_instructions: Option<u64>,
db_path: Option<&str>,
trace_instructions: bool,
trace_imports: bool,
trace_branches: bool,
quiet: bool,
) -> Result<()> {
let data = load_xex_data(path)?;
let mut header = xenia_xex::loader::parse_xex2_header(&data)?;
let entry = xenia_xex::loader::get_entry_point(&header)
.ok_or_else(|| anyhow::anyhow!("No entry point found"))?;
let base = xenia_xex::loader::get_image_base(&header)
.ok_or_else(|| anyhow::anyhow!("No image base found"))?;
if !quiet {
if let Some(ref ffi) = header.file_format_info {
println!("Compression: {} (encryption: {})",
match ffi.compression_type {
0 => "none", 1 => "basic", 2 => "normal (LZX)", _ => "unknown"
},
match ffi.encryption_type {
0 => "none", 1 => "normal (AES)", _ => "unknown"
});
}
if !header.import_libraries.is_empty() {
println!("Import libraries:");
for lib in &header.import_libraries {
println!(" {} ({} imports)", lib.name, lib.imports.len());
}
}
println!("Loading XEX: entry={:#010x} base={:#010x}", entry, base);
}
// Allocate guest memory
let mut mem = xenia_memory::GuestMemory::new()
.map_err(|e| anyhow::anyhow!("Failed to allocate guest memory: {}", e))?;
// Load and decompress the XEX image
let image_data = xenia_xex::loader::load_image(&data, &header)?;
// Resolve import ordinals from PE image
xenia_xex::loader::resolve_imports(&mut header, &image_data);
let alloc_size = ((image_data.len() + 4095) & !4095) as u32;
let rw = xenia_memory::page_table::MemoryProtect::READ
| xenia_memory::page_table::MemoryProtect::WRITE;
mem.alloc(base, alloc_size, rw)
.map_err(|e| anyhow::anyhow!("Failed to allocate guest memory region: {}", e))?;
mem.write_bulk(base, &image_data);
// ── Phase 1: Build import thunk map ──────────────────────────────────
let mut thunk_map: HashMap<u32, (ModuleId, u16, String)> = HashMap::new();
for lib in &header.import_libraries {
let module = match lib.name.as_str() {
"xboxkrnl.exe" => ModuleId::Xboxkrnl,
"xam.xex" => ModuleId::Xam,
_ => continue,
};
for imp in &lib.imports {
if imp.record_type == 1 {
let name = xenia_analysis::resolve_ordinal(&lib.name, imp.ordinal)
.map(|s| s.to_string())
.unwrap_or_else(|| format!("ordinal_{:#06X}", imp.ordinal));
thunk_map.insert(imp.address, (module, imp.ordinal, name));
}
}
}
if !quiet {
println!("Import thunks mapped: {}", thunk_map.len());
}
// ── Phase 2: CPU initialization per xenia-canary ─────────────────────
// Allocate stack (1MB at 0x70000000)
let stack_base = 0x7000_0000u32;
let stack_size = 0x10_0000u32;
mem.alloc(stack_base, stack_size, rw)
.map_err(|e| anyhow::anyhow!("Failed to allocate stack: {}", e))?;
// Allocate PCR (Processor Control Region) and TLS
let pcr_addr = 0x7FFF_0000u32;
let tls_addr = 0x7FFE_0000u32;
mem.alloc(pcr_addr, 0x1000, rw)?;
mem.alloc(tls_addr, 0x1000, rw)?;
// Initialize PCR structure
mem.write_u32(pcr_addr, tls_addr); // PCR->tls_ptr
mem.write_u32(pcr_addr + 0x100, 0x1000); // PCR->current_thread (fake)
mem.write_u32(pcr_addr + 0x150, 0); // PCR->dpc_active
// Set up CPU context per xenia-canary/cpu/thread_state.cc
let mut ctx = xenia_cpu::PpcContext::new();
ctx.pc = entry;
ctx.gpr[1] = (stack_base + stack_size - 0x80) as u64; // Stack pointer
ctx.gpr[2] = 0x2000_0000; // RTOC
ctx.gpr[13] = pcr_addr as u64; // PCR/TLS pointer
ctx.msr = 0x9030; // Hardware-dumped MSR
// ── Phase 3: Data export patching (variable imports) ─────────────────
for lib in &header.import_libraries {
for imp in &lib.imports {
if imp.record_type != 0 { continue; } // Only variable entries
let addr = imp.address;
match (lib.name.as_str(), imp.ordinal) {
("xboxkrnl.exe", 0x0158) => {
// XboxKrnlVersion: {major=2, minor=0, build=20000, qfe=0}
mem.write_u16(addr, 2);
mem.write_u16(addr + 2, 0);
mem.write_u16(addr + 4, 20000);
mem.write_u16(addr + 6, 0);
}
("xboxkrnl.exe", 0x0193) => {
// XexExecutableModuleHandle -> image base
mem.write_u32(addr, base);
}
("xboxkrnl.exe", 0x01C0) => {
// VdGpuClockInMHz
mem.write_u32(addr, 500);
}
_ => {
// All other variable exports: write 0
mem.write_u32(addr, 0);
}
}
}
}
// ── Phase 4: Set up kernel ───────────────────────────────────────────
let mut kernel = xenia_kernel::KernelState::new();
kernel.image_base = base;
// ── Phase 5: Set up SQLite DB with full static analysis + opt-in traces ──
let mut db_writer: Option<xenia_analysis::DbWriter> = None;
if let Some(db) = db_path {
use std::collections::HashMap;
let sections = xenia_xex::pe::parse_sections(&image_data)?;
// Build import address -> name map (for xref analysis)
let mut import_map: HashMap<u32, String> = HashMap::new();
for lib in &header.import_libraries {
for imp in &lib.imports {
let resolved = xenia_analysis::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);
}
}
// Function + xref analysis
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 func_analysis = xenia_analysis::func::analyze(&image_data, base, entry, &code_sections);
eprintln!("Functions detected: {}", func_analysis.functions.len());
let xref_result = xenia_analysis::xref::analyze_xrefs(
&image_data, base, entry, &sections, &func_analysis, &import_map,
);
let total_xrefs: usize = xref_result.xrefs.values().map(|v| v.len()).sum();
eprintln!("Labels: {}, Cross-references: {}", xref_result.labels.len(), total_xrefs);
let disasm_info = xenia_analysis::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,
};
eprintln!("Writing database to {db}...");
let mut w = xenia_analysis::DbWriter::open_fresh(std::path::Path::new(db))?;
w.write_base(&disasm_info)?;
w.write_disasm(&image_data, &disasm_info, &func_analysis, &xref_result.labels, &xref_result.xrefs)?;
w.prepare_trace_tables(trace_instructions, trace_imports, trace_branches)?;
db_writer = Some(w);
}
// Set up debugger (in-memory trace disabled when any file-based trace is on)
let mut debugger = xenia_debugger::Debugger::new();
debugger.paused = false;
debugger.step_mode = xenia_debugger::StepMode::Run;
debugger.trace_enabled = !trace_instructions;
if !quiet {
match max_instructions {
Some(n) => println!("Starting execution (max {n} instructions)...\n"),
None => println!("Starting execution (unlimited)...\n"),
}
}
// ── Phase 6: Execution loop with thunk interception ──────────────────
use xenia_cpu::interpreter::{step, StepResult};
use xenia_cpu::PpcOpcode;
let mut instruction_count: u64 = 0;
let mut unimpl_count: u64 = 0;
let mut import_count: u64 = 0;
loop {
if let Some(limit) = max_instructions {
if instruction_count >= limit {
println!("\nReached max instruction count ({limit})");
break;
}
}
// ── Import thunk interception ──
if let Some((module, ordinal, name)) = thunk_map.get(&ctx.pc) {
let module = *module;
let ordinal_u32 = *ordinal as u32;
let thunk_pc = ctx.pc;
let args = [ctx.gpr[3], ctx.gpr[4], ctx.gpr[5], ctx.gpr[6]];
kernel.call_export(module, ordinal_u32, &mut ctx, &mut mem);
if let Some(ref mut db) = db_writer {
db.log_import_call(xenia_analysis::ImportCallEntry {
address: thunk_pc,
cycle: ctx.cycle_count,
module: match module {
ModuleId::Xboxkrnl => "xboxkrnl.exe".to_string(),
ModuleId::Xam => "xam.xex".to_string(),
ModuleId::Xbdm => "xbdm.xex".to_string(),
},
ordinal: *ordinal,
name: name.clone(),
arg_r3: args[0],
arg_r4: args[1],
arg_r5: args[2],
arg_r6: args[3],
return_value: ctx.gpr[3],
});
}
// Simulate blr (return to caller)
ctx.pc = ctx.lr as u32;
ctx.cycle_count += 1;
instruction_count += 1;
import_count += 1;
continue;
}
// Check if PC is in mapped memory
if !mem.is_mapped(ctx.pc) {
println!("[{:>8}] FAULT: PC {:#010x} is in unmapped memory", instruction_count, ctx.pc);
break;
}
// Pre-step debugger
debugger.pre_step(&ctx, &mem);
let pc_before = ctx.pc;
// Decode the instruction word before step() so we can classify branches
let raw_before = mem.read_u32(pc_before);
let opcode_before = xenia_cpu::decode(raw_before, pc_before).opcode;
let result = step(&mut ctx, &mut mem);
instruction_count += 1;
if let Some(ref mut db) = db_writer {
db.log_instruction(xenia_analysis::ExecTraceEntry {
address: pc_before,
cycle: ctx.cycle_count,
r3: ctx.gpr[3],
r4: ctx.gpr[4],
lr: ctx.lr,
sp: ctx.gpr[1],
});
// Branch detection — fallthrough (pc_before + 4) means untaken conditional
if opcode_before.is_branch() && ctx.pc != pc_before.wrapping_add(4) {
let lk = (raw_before & 1) == 1;
let kind: &'static str = if lk {
"call"
} else if opcode_before == PpcOpcode::bclrx {
"return"
} else if opcode_before == PpcOpcode::bcctrx {
"jump"
} else {
"branch"
};
db.log_branch(xenia_analysis::BranchTraceEntry {
cycle: ctx.cycle_count,
source: pc_before,
target: ctx.pc,
kind,
lr: ctx.lr,
});
}
}
// Post-step debugger
debugger.post_step(&ctx, &mem);
match result {
StepResult::Continue => {}
StepResult::SystemCall => {
tracing::warn!("SYSCALL at {:#010x}", pc_before);
}
StepResult::Unimplemented(op) => {
unimpl_count += 1;
if unimpl_count <= 50 {
println!("[{:>8}] UNIMPL: {:?} at {:#010x}", instruction_count, op, pc_before);
} else if unimpl_count == 51 {
println!(" (suppressing further UNIMPL messages)");
}
}
StepResult::Trap => {
tracing::warn!("TRAP at {:#010x}", pc_before);
}
StepResult::Halted => {
println!("[{:>8}] HALTED", instruction_count);
break;
}
}
if debugger.should_break() {
println!("[{:>8}] BREAK at {:#010x}", instruction_count, ctx.pc);
break;
}
}
// Finalize trace DB (flush buffers + create indices)
if let Some(ref mut db) = db_writer {
db.finalize_traces()?;
}
if !quiet {
println!("\n=== Final State ===");
println!("PC: {:#010x}", ctx.pc);
println!("LR: {:#010x}", ctx.lr as u32);
println!("CTR: {:#010x}", ctx.ctr as u32);
println!("CR: {:#010x}", ctx.cr());
println!("XER: CA={} OV={} SO={}", ctx.xer_ca, ctx.xer_ov, ctx.xer_so);
for i in 0..32 {
if ctx.gpr[i] != 0 {
println!("r{:<2}: {:#018x}", i, ctx.gpr[i]);
}
}
}
println!("Executed {} instructions ({} import calls, {} unimplemented)",
instruction_count, import_count, unimpl_count);
if !quiet && db_writer.is_none() {
println!("Trace log: {} entries", debugger.trace_log.len());
}
Ok(())
}
fn cmd_browse(path: &str) -> Result<()> {
use xenia_vfs::VfsDevice;
let disc = xenia_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) => println!(" Error listing contents: {}", e),
}
Ok(())
}
/// Helper: load XEX, parse header, decompress PE, resolve imports, parse sections.
fn load_and_prepare(path: &str) -> Result<(xenia_xex::Xex2Header, Vec<u8>, Vec<xenia_xex::pe::PeSection>)> {
let data = load_xex_data(path)?;
let mut header = xenia_xex::loader::parse_xex2_header(&data)?;
let entry = xenia_xex::loader::get_entry_point(&header)
.ok_or_else(|| anyhow::anyhow!("No entry point found in XEX2 header"))?;
let base = xenia_xex::loader::get_image_base(&header)
.ok_or_else(|| anyhow::anyhow!("No image base found in XEX2 header"))?;
eprintln!("Entry point: {:#010x}, Image base: {:#010x}", entry, base);
let pe_image = xenia_xex::loader::load_image(&data, &header)?;
eprintln!("Image loaded: {} bytes decompressed", pe_image.len());
// Resolve import ordinals and record types from the PE image
xenia_xex::loader::resolve_imports(&mut header, &pe_image);
// Parse PE sections
let sections = xenia_xex::pe::parse_sections(&pe_image)?;
eprintln!("PE sections: {}", sections.len());
Ok((header, pe_image, sections))
}
fn cmd_extract(path: &str, output_dir: Option<&str>, db_path: Option<&str>) -> Result<()> {
use serde::Serialize;
let (header, pe_image, sections) = load_and_prepare(path)?;
let entry = xenia_xex::loader::get_entry_point(&header).unwrap();
let base = xenia_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<xenia_xex::header::ExecutionInfo>,
import_libraries: &'a [xenia_xex::header::ImportLibrary],
sections: &'a [xenia_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)?;
eprintln!("Wrote PE image: {} ({} bytes)", pe_path.display(), pe_image.len());
// 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)?;
eprintln!("Wrote metadata: {}", json_path.display());
// 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 = xenia_analysis::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,
};
eprintln!("Writing base tables to {db}...");
let mut w = xenia_analysis::DbWriter::open_fresh(std::path::Path::new(db))?;
w.write_base(&disasm_info)?;
eprintln!("Database written: {db}");
}
Ok(())
}
fn cmd_dis(path: &str, output: Option<&str>, db_path: Option<&str>, quiet: bool) -> Result<()> {
use std::collections::HashMap;
let (header, pe_image, sections) = load_and_prepare(path)?;
let entry = xenia_xex::loader::get_entry_point(&header).unwrap();
let base = xenia_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 = xenia_analysis::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);
}
}
eprintln!("Resolved {} import thunks", import_map.len());
// Function analysis
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 func_analysis = xenia_analysis::func::analyze(&pe_image, base, entry, &code_sections);
eprintln!("Functions detected: {}", func_analysis.functions.len());
// Cross-reference analysis
let xref_result = xenia_analysis::xref::analyze_xrefs(
&pe_image, base, entry, &sections, &func_analysis, &import_map,
);
let total_xrefs: usize = xref_result.xrefs.values().map(|v| v.len()).sum();
eprintln!("Labels: {}, Cross-references: {}", xref_result.labels.len(), total_xrefs);
// Build DisasmInfo
let disasm_info = xenia_analysis::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,
};
// SQLite database output (base + disasm layers)
if let Some(db) = db_path {
eprintln!("Writing database to {db}...");
let mut w = xenia_analysis::DbWriter::open_fresh(std::path::Path::new(db))?;
w.write_base(&disasm_info)?;
w.write_disasm(
&pe_image,
&disasm_info,
&func_analysis,
&xref_result.labels,
&xref_result.xrefs,
)?;
eprintln!("Database written: {db}");
}
// 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())),
};
xenia_analysis::formatter::write_asm(
&mut *out,
&pe_image,
&disasm_info,
&func_analysis,
&xref_result.labels,
&import_map,
&xref_result.xrefs,
&xref_result.data_annotations,
)?;
if let Some(path) = output {
eprintln!("Wrote disassembly: {path}");
}
}
Ok(())
}