Merge feature/m8-resolve-imports: Xenia-style import resolution

This commit is contained in:
MechaCat02
2026-03-29 17:36:03 +02:00
5 changed files with 256 additions and 5 deletions

2
Cargo.lock generated
View File

@@ -288,7 +288,7 @@ dependencies = [
[[package]] [[package]]
name = "xex2tractor" name = "xex2tractor"
version = "0.7.0" version = "0.8.0"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "xex2tractor" name = "xex2tractor"
version = "0.7.0" version = "0.8.0"
edition = "2024" edition = "2024"
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files" description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
license = "MIT" license = "MIT"

View File

@@ -109,6 +109,118 @@ pub fn decode_import_records(pe_image: &[u8], xex: &Xex2File) -> Result<Vec<Reso
Ok(resolved) Ok(resolved)
} }
/// Summary of import resolution results.
#[derive(Debug)]
pub struct ImportResolutionSummary {
/// Total number of import records processed.
pub total: usize,
/// Number of variable slots written.
pub variables_written: usize,
/// Number of thunk stubs written.
pub thunks_written: usize,
/// Per-library breakdown: (library_name, count).
pub per_library: Vec<(String, usize)>,
}
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, <ordinal>` (0x38800000 | ordinal)
/// - words 2-3 are left unchanged (already mtspr CTR, r11 + bctr)
pub fn resolve_imports(
pe_image: &mut [u8],
xex: &Xex2File,
) -> Result<ImportResolutionSummary> {
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, <ordinal>
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -118,4 +230,19 @@ mod tests {
assert_eq!(ImportRecordType::Variable.to_string(), "variable"); assert_eq!(ImportRecordType::Variable.to_string(), "variable");
assert_eq!(ImportRecordType::Thunk.to_string(), "thunk"); 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"));
}
} }

View File

@@ -23,6 +23,9 @@ enum Command {
file: PathBuf, file: PathBuf,
/// Output path for the extracted PE file (default: same name with .exe extension) /// Output path for the extracted PE file (default: same name with .exe extension)
output: Option<PathBuf>, output: Option<PathBuf>,
/// 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 { match cli.command {
Command::Inspect { file } => cmd_inspect(&file), 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<PathBuf>) { fn cmd_extract(path: &PathBuf, output: Option<PathBuf>, resolve_imports: bool) {
let output_path = output.unwrap_or_else(|| path.with_extension("exe")); let output_path = output.unwrap_or_else(|| path.with_extension("exe"));
let data = read_file(path); let data = read_file(path);
@@ -73,7 +80,7 @@ fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
println!("Compression: {}", fmt.compression_type); 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, Ok(img) => img,
Err(e) => { Err(e) => {
eprintln!("Error extracting PE image: {e}"); eprintln!("Error extracting PE image: {e}");
@@ -81,6 +88,16 @@ fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
} }
}; };
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) { if let Err(e) = std::fs::write(&output_path, &pe_image) {
eprintln!("Error writing {}: {e}", output_path.display()); eprintln!("Error writing {}: {e}", output_path.display());
process::exit(1); process::exit(1);

View File

@@ -552,6 +552,113 @@ fn test_decode_import_names_resolved() {
assert!(has_dbg_break, "should find DbgBreakPoint import"); 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] #[test]
fn test_cli_inspect_shows_resolved_imports() { fn test_cli_inspect_shows_resolved_imports() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR")); let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));