diff --git a/Cargo.lock b/Cargo.lock index 8a9606b..38e39b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "xex2tractor" -version = "0.6.1" +version = "0.7.0" dependencies = [ "aes", "cbc", diff --git a/Cargo.toml b/Cargo.toml index 910a5b5..b5bd156 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xex2tractor" -version = "0.6.1" +version = "0.7.0" edition = "2024" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" license = "MIT" diff --git a/src/display.rs b/src/display.rs index 996c2da..72a7062 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,6 +1,7 @@ /// Pretty-print formatting for parsed XEX2 structures. use crate::crypto; use crate::header::Xex2Header; +use crate::imports::ResolvedImport; use crate::optional::{ format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders, }; @@ -314,3 +315,44 @@ pub fn display_security_info(security: &SecurityInfo) { " Total mapped size: 0x{address_offset:X} ({address_offset} bytes)" ); } + +/// Prints resolved import records grouped by library. +pub fn display_resolved_imports(imports: &[ResolvedImport]) { + // Count per library + let mut lib_counts: Vec<(String, usize)> = Vec::new(); + for imp in imports { + if let Some(entry) = lib_counts.iter_mut().find(|(name, _)| *name == imp.library) { + entry.1 += 1; + } else { + lib_counts.push((imp.library.clone(), 1)); + } + } + + let summary: Vec = lib_counts + .iter() + .map(|(name, count)| format!("{name}: {count}")) + .collect(); + println!(); + println!( + "=== Resolved Imports ({}) ===", + summary.join(", ") + ); + + let mut current_lib = ""; + for imp in imports { + if imp.library != current_lib { + println!(); + println!(" [{}]", imp.library); + current_lib = &imp.library; + } + + let name = imp + .export + .as_ref() + .map_or("", |e| &e.name); + println!( + " 0x{:08X} {:>8} ordinal 0x{:04X} {}", + imp.record.address, imp.record.record_type, imp.record.ordinal, name + ); + } +} diff --git a/src/exports.rs b/src/exports.rs new file mode 100644 index 0000000..dbce3ea --- /dev/null +++ b/src/exports.rs @@ -0,0 +1,194 @@ +/// Xbox 360 system export database. +/// +/// Parses the embedded `doc/xbox360_exports.md` at first access and provides +/// ordinal-to-name lookups for xboxkrnl.exe, xam.xex, and xbdm.xex. +use std::collections::HashMap; +use std::sync::OnceLock; + +static EXPORT_DB: OnceLock = OnceLock::new(); + +const EXPORTS_MD: &str = include_str!("../doc/xbox360_exports.md"); + +/// Information about a single Xbox 360 system export. +#[derive(Debug, Clone)] +pub struct ExportInfo { + pub ordinal: u16, + pub name: String, + pub is_function: bool, +} + +/// Lookup table mapping `(library_filename, ordinal)` to export info. +struct ExportDatabase { + /// Key: lowercase library filename (e.g. "xboxkrnl.exe"), value: ordinal -> ExportInfo + modules: HashMap>, +} + +/// Returns the total number of exports in the database. +pub fn total_count() -> usize { + let db = get_db(); + db.modules.values().map(|m| m.len()).sum() +} + +/// Returns the number of exports for a given library. +pub fn module_count(library: &str) -> usize { + let db = get_db(); + db.modules + .get(&library.to_ascii_lowercase()) + .map_or(0, |m| m.len()) +} + +/// Looks up an export by library filename and ordinal. +pub fn lookup(library: &str, ordinal: u16) -> Option<&'static ExportInfo> { + let db = get_db(); + db.modules + .get(&library.to_ascii_lowercase())? + .get(&ordinal) +} + +fn get_db() -> &'static ExportDatabase { + EXPORT_DB.get_or_init(|| parse_exports_md(EXPORTS_MD)) +} + +/// Parses the markdown export database into a lookup structure. +/// +/// Expected format per module section: +/// ```text +/// ## module_name (filename.exe) +/// ... +/// | 0xNNN | FunctionName | function | status | ... | +/// ``` +fn parse_exports_md(md: &str) -> ExportDatabase { + let mut modules: HashMap> = HashMap::new(); + let mut current_file: Option = None; + + for line in md.lines() { + // Detect section headers: "## xboxkrnl (xboxkrnl.exe)" + if let Some(rest) = line.strip_prefix("## ") { + if let (Some(paren_start), Some(paren_end)) = + (rest.find('('), rest.find(')')) + { + let filename = rest[paren_start + 1..paren_end].trim().to_ascii_lowercase(); + current_file = Some(filename); + } else { + current_file = None; + } + continue; + } + + // Parse table rows: "| 0xNNN | Name | function/variable | ... |" + let Some(ref file) = current_file else { + continue; + }; + if !line.starts_with("| 0x") { + continue; + } + + let cols: Vec<&str> = line.split('|').collect(); + // cols[0] is empty (before first |), cols[1] = ordinal, cols[2] = name, cols[3] = type + if cols.len() < 4 { + continue; + } + + let ordinal_str = cols[1].trim(); + let name = cols[2].trim(); + let type_str = cols[3].trim(); + + let Some(ordinal) = parse_hex_ordinal(ordinal_str) else { + continue; + }; + + let entry = ExportInfo { + ordinal, + name: name.to_string(), + is_function: type_str == "function", + }; + + modules + .entry(file.clone()) + .or_default() + .insert(ordinal, entry); + } + + ExportDatabase { modules } +} + +fn parse_hex_ordinal(s: &str) -> Option { + let hex = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))?; + u16::from_str_radix(hex, 16).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_total_count() { + assert_eq!(total_count(), 2913); + } + + #[test] + fn test_module_counts() { + assert_eq!(module_count("xboxkrnl.exe"), 922); + assert_eq!(module_count("xam.xex"), 1736); + assert_eq!(module_count("xbdm.xex"), 255); + } + + #[test] + fn test_module_count_case_insensitive() { + assert_eq!(module_count("XBOXKRNL.EXE"), 922); + assert_eq!(module_count("Xam.Xex"), 1736); + } + + #[test] + fn test_module_count_unknown() { + assert_eq!(module_count("unknown.dll"), 0); + } + + #[test] + fn test_lookup_xboxkrnl_known() { + let info = lookup("xboxkrnl.exe", 0x009).unwrap(); + assert_eq!(info.name, "ExAllocatePool"); + assert!(info.is_function); + assert_eq!(info.ordinal, 0x009); + } + + #[test] + fn test_lookup_xboxkrnl_variable() { + let info = lookup("xboxkrnl.exe", 0x00C).unwrap(); + assert_eq!(info.name, "ExConsoleGameRegion"); + assert!(!info.is_function); + } + + #[test] + fn test_lookup_xam_known() { + let info = lookup("xam.xex", 0x001).unwrap(); + assert_eq!(info.name, "NetDll_WSAStartup"); + assert!(info.is_function); + } + + #[test] + fn test_lookup_xbdm_known() { + let info = lookup("xbdm.xex", 0x001).unwrap(); + assert_eq!(info.name, "DmAllocatePool"); + assert!(info.is_function); + } + + #[test] + fn test_lookup_unknown_ordinal() { + assert!(lookup("xboxkrnl.exe", 0xFFFF).is_none()); + } + + #[test] + fn test_lookup_unknown_library() { + assert!(lookup("nonexistent.dll", 0x001).is_none()); + } + + #[test] + fn test_parse_hex_ordinal() { + assert_eq!(parse_hex_ordinal("0x001"), Some(1)); + assert_eq!(parse_hex_ordinal("0x3A3"), Some(0x3A3)); + assert_eq!(parse_hex_ordinal("0xFFFF"), Some(0xFFFF)); + assert_eq!(parse_hex_ordinal("invalid"), None); + assert_eq!(parse_hex_ordinal(""), None); + } +} diff --git a/src/imports.rs b/src/imports.rs new file mode 100644 index 0000000..ae44e69 --- /dev/null +++ b/src/imports.rs @@ -0,0 +1,121 @@ +/// Import record decoding and resolution for XEX2 PE images. +/// +/// Reads import records from the extracted PE image, decodes their type and +/// ordinal, and resolves them against the Xbox 360 export database. +use std::fmt; + +use crate::error::{Result, Xex2Error}; +use crate::exports::{self, ExportInfo}; +use crate::util::read_u32_be; +use crate::Xex2File; + +/// The type of an import record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportRecordType { + /// A 4-byte variable slot (record_type 0x00). + Variable, + /// A 16-byte function thunk stub (record_type 0x01). + Thunk, +} + +impl fmt::Display for ImportRecordType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ImportRecordType::Variable => write!(f, "variable"), + ImportRecordType::Thunk => write!(f, "thunk"), + } + } +} + +/// A decoded import record from the PE image. +#[derive(Debug, Clone)] +pub struct ImportRecord { + /// The original memory address from the XEX2 import table. + pub address: u32, + /// Offset within the PE image (address - load_address). + pub pe_offset: usize, + /// Whether this is a variable slot or function thunk. + pub record_type: ImportRecordType, + /// The ordinal number identifying the imported function/variable. + pub ordinal: u16, +} + +/// An import record resolved against the export database. +#[derive(Debug, Clone)] +pub struct ResolvedImport { + /// The decoded import record. + pub record: ImportRecord, + /// The library filename (e.g. "xboxkrnl.exe"). + pub library: String, + /// The export info if the ordinal was found in the database. + pub export: Option, +} + +/// Decodes all import records from the PE image and resolves them against +/// the export database. +/// +/// Iterates over each import library's addresses, reads the u32 value at +/// the corresponding PE offset, extracts record_type and ordinal, then +/// looks up the ordinal in the export database. +pub fn decode_import_records(pe_image: &[u8], xex: &Xex2File) -> Result> { + let imports = xex + .optional_headers + .import_libraries + .as_ref() + .ok_or_else(|| Xex2Error::InvalidPeImage("no import libraries header".into()))?; + + let load_address = xex.security_info.load_address; + let mut resolved = Vec::new(); + + for lib in &imports.libraries { + for &addr in &lib.import_addresses { + let pe_offset = (addr.wrapping_sub(load_address)) as usize; + + let raw = read_u32_be(pe_image, pe_offset).map_err(|_| { + Xex2Error::InvalidPeImage(format!( + "import address 0x{addr:08X} (offset 0x{pe_offset:08X}) out of bounds" + )) + })?; + + let record_type_byte = (raw >> 24) & 0xFF; + let ordinal = (raw & 0xFFFF) as u16; + + let record_type = match record_type_byte { + 0x00 => ImportRecordType::Variable, + 0x01 => ImportRecordType::Thunk, + _ => { + // Unknown record types — treat as variable (best effort) + ImportRecordType::Variable + } + }; + + let record = ImportRecord { + address: addr, + pe_offset, + record_type, + ordinal, + }; + + let export = exports::lookup(&lib.name, ordinal).cloned(); + + resolved.push(ResolvedImport { + record, + library: lib.name.clone(), + export, + }); + } + } + + Ok(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_import_record_type_display() { + assert_eq!(ImportRecordType::Variable.to_string(), "variable"); + assert_eq!(ImportRecordType::Thunk.to_string(), "thunk"); + } +} diff --git a/src/lib.rs b/src/lib.rs index fffdc46..d204151 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,8 +10,10 @@ pub mod crypto; pub mod decompress; pub mod display; pub mod error; +pub mod exports; pub mod extract; pub mod header; +pub mod imports; pub mod optional; pub mod security; pub mod util; diff --git a/src/main.rs b/src/main.rs index a201982..9425620 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,23 @@ fn cmd_inspect(path: &PathBuf) { xex2tractor::display::display_header(&xex.header); xex2tractor::display::display_optional_headers(&xex.optional_headers); xex2tractor::display::display_security_info(&xex.security_info); + + // Decode and display resolved imports (requires extracting the PE image) + if xex.optional_headers.import_libraries.is_some() { + match xex2tractor::extract::extract_pe_image(&data, &xex) { + Ok(pe_image) => match xex2tractor::imports::decode_import_records(&pe_image, &xex) { + Ok(resolved) => { + xex2tractor::display::display_resolved_imports(&resolved); + } + Err(e) => { + eprintln!("Warning: could not decode imports: {e}"); + } + }, + Err(e) => { + eprintln!("Warning: could not extract PE for import resolution: {e}"); + } + } + } } fn cmd_extract(path: &PathBuf, output: Option) { diff --git a/tests/integration.rs b/tests/integration.rs index c7466e8..a38fe69 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,8 @@ use xex2tractor::crypto; +use xex2tractor::exports; use xex2tractor::extract; use xex2tractor::header::{ModuleFlags, XEX2_MAGIC}; +use xex2tractor::imports::{self, ImportRecordType}; use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags}; use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags}; @@ -440,3 +442,137 @@ fn test_cli_extract_shows_format_info() { let _ = std::fs::remove_file(&output_path); } + +// ── Export database tests ──────────────────────────────────────────────────── + +#[test] +fn test_export_db_total_count() { + assert_eq!(exports::total_count(), 2913); +} + +#[test] +fn test_export_db_module_counts() { + assert_eq!(exports::module_count("xboxkrnl.exe"), 922); + assert_eq!(exports::module_count("xam.xex"), 1736); + assert_eq!(exports::module_count("xbdm.xex"), 255); +} + +#[test] +fn test_export_db_lookup_known() { + let info = exports::lookup("xboxkrnl.exe", 0x009).unwrap(); + assert_eq!(info.name, "ExAllocatePool"); + assert!(info.is_function); +} + +#[test] +fn test_export_db_lookup_unknown_ordinal() { + assert!(exports::lookup("xboxkrnl.exe", 0xFFFF).is_none()); +} + +// ── Import record decoding tests ───────────────────────────────────────────── + +#[test] +fn test_decode_import_records() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + let resolved = imports::decode_import_records(&pe_image, &xex).unwrap(); + + // Sample has 104 xam.xex + 294 xboxkrnl.exe = 398 total import records + assert_eq!(resolved.len(), 398); +} + +#[test] +fn test_decode_import_record_types() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + let resolved = imports::decode_import_records(&pe_image, &xex).unwrap(); + + let variables = resolved + .iter() + .filter(|r| r.record.record_type == ImportRecordType::Variable) + .count(); + let thunks = resolved + .iter() + .filter(|r| r.record.record_type == ImportRecordType::Thunk) + .count(); + + // Every import should be either variable or thunk + assert_eq!(variables + thunks, resolved.len()); + // Both types should be present + assert!(variables > 0); + assert!(thunks > 0); +} + +#[test] +fn test_decode_import_alternating_pattern() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + let resolved = imports::decode_import_records(&pe_image, &xex).unwrap(); + + // Check first few xam.xex entries: variable, thunk, variable, thunk, ... + let xam: Vec<_> = resolved.iter().filter(|r| r.library == "xam.xex").collect(); + assert!(xam.len() >= 4); + assert_eq!(xam[0].record.record_type, ImportRecordType::Variable); + assert_eq!(xam[1].record.record_type, ImportRecordType::Thunk); + assert_eq!(xam[2].record.record_type, ImportRecordType::Variable); + assert_eq!(xam[3].record.record_type, ImportRecordType::Thunk); + + // Variable and thunk for same function share the same ordinal + assert_eq!(xam[0].record.ordinal, xam[1].record.ordinal); +} + +#[test] +fn test_decode_import_names_resolved() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + let resolved = imports::decode_import_records(&pe_image, &xex).unwrap(); + + // Most imports should resolve to known names + let named = resolved.iter().filter(|r| r.export.is_some()).count(); + assert!( + named > resolved.len() / 2, + "expected most imports to resolve, got {named}/{}", + resolved.len() + ); + + // Check a specific known import is present + let has_dbg_break = resolved.iter().any(|r| { + r.export + .as_ref() + .is_some_and(|e| e.name == "DbgBreakPoint") + }); + assert!(has_dbg_break, "should find DbgBreakPoint import"); +} + +#[test] +fn test_cli_inspect_shows_resolved_imports() { + let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) + .args(["inspect", &path]) + .output() + .expect("failed to run xex2tractor"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Resolved Imports"), + "inspect should show resolved imports section" + ); + assert!( + stdout.contains("xboxkrnl.exe"), + "inspect should show xboxkrnl.exe imports" + ); + assert!( + stdout.contains("xam.xex"), + "inspect should show xam.xex imports" + ); +}