diff --git a/Cargo.lock b/Cargo.lock index 38e39b3..f4db7ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "xex2tractor" -version = "0.7.0" +version = "0.8.0" dependencies = [ "aes", "cbc", diff --git a/Cargo.toml b/Cargo.toml index b5bd156..75753a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xex2tractor" -version = "0.7.0" +version = "0.8.0" edition = "2024" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" license = "MIT" diff --git a/src/imports.rs b/src/imports.rs index ae44e69..0939a34 100644 --- a/src/imports.rs +++ b/src/imports.rs @@ -109,6 +109,118 @@ pub fn decode_import_records(pe_image: &[u8], xex: &Xex2File) -> Result, +} + +impl fmt::Display for ImportResolutionSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Resolved {} imports ({} variables, {} thunks)", + self.total, self.variables_written, self.thunks_written + )?; + for (lib, count) in &self.per_library { + write!(f, "\n {lib}: {count}")?; + } + Ok(()) + } +} + +/// Resolves imports in the PE image by writing Xenia-style values. +/// +/// For variable imports (type 0): writes `0xD000BEEF | (ordinal & 0xFFF) << 16`. +/// For thunk imports (type 1): rewrites the first 8 bytes to valid PowerPC: +/// - word 0: `li r3, 0` (0x38600000) +/// - word 1: `li r4, ` (0x38800000 | ordinal) +/// - words 2-3 are left unchanged (already mtspr CTR, r11 + bctr) +pub fn resolve_imports( + pe_image: &mut [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 total = 0; + let mut variables_written = 0; + let mut thunks_written = 0; + let mut per_library: Vec<(String, usize)> = Vec::new(); + + for lib in &imports.libraries { + let mut lib_count = 0; + + 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; + + match record_type_byte { + 0x00 => { + // Variable: write 0xD000BEEF | (ordinal & 0xFFF) << 16 + let resolved_val: u32 = + 0xD000BEEF | ((ordinal as u32 & 0xFFF) << 16); + pe_image[pe_offset..pe_offset + 4] + .copy_from_slice(&resolved_val.to_be_bytes()); + variables_written += 1; + } + 0x01 => { + // Thunk: rewrite first 8 bytes to valid PPC + if pe_offset + 16 > pe_image.len() { + return Err(Xex2Error::InvalidPeImage(format!( + "thunk at 0x{addr:08X} (offset 0x{pe_offset:08X}) extends past PE image" + ))); + } + // li r3, 0 + let word0: u32 = 0x38600000; + // li r4, + let word1: u32 = 0x38800000 | ordinal as u32; + pe_image[pe_offset..pe_offset + 4] + .copy_from_slice(&word0.to_be_bytes()); + pe_image[pe_offset + 4..pe_offset + 8] + .copy_from_slice(&word1.to_be_bytes()); + // words 2-3 (mtspr CTR, r11 + bctr) left unchanged + thunks_written += 1; + } + _ => { + // Unknown record type — skip + } + } + + lib_count += 1; + total += 1; + } + + per_library.push((lib.name.clone(), lib_count)); + } + + Ok(ImportResolutionSummary { + total, + variables_written, + thunks_written, + per_library, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -118,4 +230,19 @@ mod tests { assert_eq!(ImportRecordType::Variable.to_string(), "variable"); assert_eq!(ImportRecordType::Thunk.to_string(), "thunk"); } + + #[test] + fn test_resolution_summary_display() { + let summary = ImportResolutionSummary { + total: 10, + variables_written: 5, + thunks_written: 5, + per_library: vec![("xboxkrnl.exe".into(), 10)], + }; + let s = summary.to_string(); + assert!(s.contains("10 imports")); + assert!(s.contains("5 variables")); + assert!(s.contains("5 thunks")); + assert!(s.contains("xboxkrnl.exe: 10")); + } } diff --git a/src/main.rs b/src/main.rs index 9425620..2c5b0f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,9 @@ enum Command { file: PathBuf, /// Output path for the extracted PE file (default: same name with .exe extension) output: Option, + /// Resolve imports by writing Xenia-style thunk stubs and variable slots + #[arg(short = 'r', long = "resolve-imports")] + resolve_imports: bool, }, } @@ -31,7 +34,11 @@ fn main() { match cli.command { Command::Inspect { file } => cmd_inspect(&file), - Command::Extract { file, output } => cmd_extract(&file, output), + Command::Extract { + file, + output, + resolve_imports, + } => cmd_extract(&file, output, resolve_imports), } } @@ -61,7 +68,7 @@ fn cmd_inspect(path: &PathBuf) { } } -fn cmd_extract(path: &PathBuf, output: Option) { +fn cmd_extract(path: &PathBuf, output: Option, resolve_imports: bool) { let output_path = output.unwrap_or_else(|| path.with_extension("exe")); let data = read_file(path); @@ -73,7 +80,7 @@ fn cmd_extract(path: &PathBuf, output: Option) { println!("Compression: {}", fmt.compression_type); } - let pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) { + let mut pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) { Ok(img) => img, Err(e) => { eprintln!("Error extracting PE image: {e}"); @@ -81,6 +88,16 @@ fn cmd_extract(path: &PathBuf, output: Option) { } }; + if resolve_imports { + match xex2tractor::imports::resolve_imports(&mut pe_image, &xex) { + Ok(summary) => println!("{summary}"), + Err(e) => { + eprintln!("Error resolving imports: {e}"); + process::exit(1); + } + } + } + if let Err(e) = std::fs::write(&output_path, &pe_image) { eprintln!("Error writing {}: {e}", output_path.display()); process::exit(1); diff --git a/tests/integration.rs b/tests/integration.rs index a38fe69..52761e2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -552,6 +552,113 @@ fn test_decode_import_names_resolved() { assert!(has_dbg_break, "should find DbgBreakPoint import"); } +#[test] +fn test_resolve_imports_variables() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + let summary = imports::resolve_imports(&mut pe_image, &xex).unwrap(); + assert!(summary.variables_written > 0); + assert!(summary.thunks_written > 0); + assert_eq!( + summary.total, + summary.variables_written + summary.thunks_written + ); + + // Check first xam.xex variable at PE offset 0x600 (ordinal 0x028C) + // Should be 0xD000BEEF | (0x28C & 0xFFF) << 16 = 0xD28CBEEF + let val = u32::from_be_bytes([ + pe_image[0x600], + pe_image[0x601], + pe_image[0x602], + pe_image[0x603], + ]); + assert_eq!(val, 0xD28CBEEF, "variable slot should have 0xD000BEEF pattern"); +} + +#[test] +fn test_resolve_imports_thunks() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + imports::resolve_imports(&mut pe_image, &xex).unwrap(); + + // Check first xam.xex thunk at PE offset 0x84DA7C (ordinal 0x028C) + let off = 0x0084DA7C; + let w0 = u32::from_be_bytes([pe_image[off], pe_image[off + 1], pe_image[off + 2], pe_image[off + 3]]); + let w1 = u32::from_be_bytes([pe_image[off + 4], pe_image[off + 5], pe_image[off + 6], pe_image[off + 7]]); + let w2 = u32::from_be_bytes([pe_image[off + 8], pe_image[off + 9], pe_image[off + 10], pe_image[off + 11]]); + let w3 = u32::from_be_bytes([pe_image[off + 12], pe_image[off + 13], pe_image[off + 14], pe_image[off + 15]]); + + assert_eq!(w0, 0x38600000, "thunk word 0 should be li r3, 0"); + assert_eq!(w1, 0x3880028C, "thunk word 1 should be li r4, 0x028C"); + assert_eq!(w2, 0x7D6903A6, "thunk word 2 should be mtspr CTR, r11"); + assert_eq!(w3, 0x4E800420, "thunk word 3 should be bctr"); +} + +#[test] +fn test_extract_without_resolve_unchanged() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let pe_unresolved = extract::extract_pe_image(&data, &xex).unwrap(); + + // Without resolve, variable at 0x600 should be the raw descriptor + let val = u32::from_be_bytes([ + pe_unresolved[0x600], + pe_unresolved[0x601], + pe_unresolved[0x602], + pe_unresolved[0x603], + ]); + assert_eq!(val, 0x0000028C, "unresolved variable should be raw descriptor"); + + // Thunk word 0 should be the record marker, not a PPC instruction + let off = 0x0084DA7C; + let w0 = u32::from_be_bytes([ + pe_unresolved[off], + pe_unresolved[off + 1], + pe_unresolved[off + 2], + pe_unresolved[off + 3], + ]); + assert_eq!(w0, 0x0100028C, "unresolved thunk word 0 should be record marker"); +} + +#[test] +fn test_resolve_imports_pe_still_valid() { + let data = sample_data(); + let xex = xex2tractor::parse(&data).unwrap(); + let mut pe_image = extract::extract_pe_image(&data, &xex).unwrap(); + + imports::resolve_imports(&mut pe_image, &xex).unwrap(); + + // PE verification should still pass (MZ + PE headers untouched) + assert!(extract::verify_pe_image(&pe_image).is_ok()); +} + +#[test] +fn test_cli_extract_resolve_imports() { + let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); + let output_path = format!( + "{}/target/test_resolve_output.exe", + env!("CARGO_MANIFEST_DIR") + ); + let _ = std::fs::remove_file(&output_path); + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor")) + .args(["extract", "-r", &path, &output_path]) + .output() + .expect("failed to run xex2tractor"); + + assert!(output.status.success(), "CLI extract -r should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Resolved"), "should print resolution summary"); + assert!(stdout.contains("variables"), "should mention variables"); + assert!(stdout.contains("thunks"), "should mention thunks"); + + let _ = std::fs::remove_file(&output_path); +} + #[test] fn test_cli_inspect_shows_resolved_imports() { let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));