11 Commits

Author SHA1 Message Date
MechaCat02
b6ee119824 Merge feature/m8-resolve-imports: Xenia-style import resolution 2026-03-29 17:36:03 +02:00
MechaCat02
2425e8177e feat: add import resolution with Xenia-style thunk stubs (M8)
- resolve_imports() rewrites PE import records in-place:
  - Variable slots: 0xD000BEEF | (ordinal & 0xFFF) << 16
  - Thunk stubs: li r3, 0 / li r4, <ordinal> / mtspr CTR, r11 / bctr
- New -r/--resolve-imports flag on the extract command
- Without the flag, extraction is unchanged (byte-for-byte identical)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:35:57 +02:00
MechaCat02
956f5e8ba8 Merge feature/m7-import-decode: export database and import record decoding 2026-03-29 17:32:54 +02:00
MechaCat02
0d42fc1b06 feat: add export database and import record decoding (M7)
- New src/exports.rs: embeds doc/xbox360_exports.md via include_str!
  and lazily parses it into a lookup table (OnceLock). Covers 2,913
  exports across xboxkrnl.exe, xam.xex, and xbdm.xex.
- New src/imports.rs: decodes import records from extracted PE images
  by reading the u32 at each import address, extracting record type
  (variable/thunk) and ordinal, and resolving against the export DB.
- inspect command now shows a full resolved imports table with names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:32:49 +02:00
MechaCat02
dee636c09f docs: add Xbox 360 system exports reference
Auto-generated from Xenia Canary source. Covers 2,913 exports
across xboxkrnl.exe, xam.xex, and xbdm.xex with ordinals, names,
types, and implementation status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 17:26:43 +02:00
MechaCat02
4ed8fadd4c feat: show decrypted session key in inspect output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:24:23 +01:00
MechaCat02
ee5e0b60f8 fix: include Cargo.lock changes from v0.6.0 version bump
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:00:13 +01:00
MechaCat02
1e773e6cdc Merge feature/m6-extract-pe: PE verification and complete extraction 2026-03-28 21:51:57 +01:00
MechaCat02
475e1d555c feat: add PE verification and complete extraction pipeline (M6)
Add PE header verification (MZ signature, PE signature, POWERPCBE
machine type) to the extraction pipeline. Implement master key trial
with validation for encrypted files. Update CLI extract command to
show encryption/compression info. Update README with extract usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:51:52 +01:00
MechaCat02
ba3b5a0ac3 Merge feature/m5-decompression: PE image decompression and extraction 2026-03-28 21:48:28 +01:00
MechaCat02
c665868b1b feat: add PE image decompression and extraction pipeline (M5)
Implement full decrypt + decompress pipeline for XEX2 PE extraction:
- decompress.rs: None, Basic (zero-fill), and Normal (LZX) decompression
- extract.rs: orchestrates decryption then decompression
- Wire up CLI extract command to write PE files
- LZX decompression via lzxd crate with per-frame chunk processing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:48:23 +01:00
13 changed files with 4309 additions and 29 deletions

9
Cargo.lock generated
View File

@@ -200,6 +200,12 @@ version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "lzxd"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -282,9 +288,10 @@ dependencies = [
[[package]]
name = "xex2tractor"
version = "0.4.0"
version = "0.8.0"
dependencies = [
"aes",
"cbc",
"clap",
"lzxd",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "xex2tractor"
version = "0.4.0"
version = "0.8.0"
edition = "2024"
description = "A tool for extracting and inspecting Xbox 360 XEX2 executable files"
license = "MIT"
@@ -9,3 +9,4 @@ license = "MIT"
aes = "0.8.4"
cbc = "0.1.2"
clap = { version = "4.6.0", features = ["derive"] }
lzxd = "0.2.6"

View File

@@ -4,11 +4,15 @@ A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in
## Usage
### Inspect
Display XEX2 file information (headers, security info, etc.):
```sh
xex2tractor <file.xex>
xex2tractor inspect <file.xex>
```
### Example Output
#### Example Output
```
=== XEX2 Header ===
@@ -35,16 +39,9 @@ Header Count: 15
[FILE_FORMAT_INFO]
Encryption: Normal (AES-128-CBC)
Compression: Normal (LZX)
[STATIC_LIBRARIES] (12 libraries)
XAPILIB 2.0.3215.0 (Unknown(64))
D3D9 2.0.3215.1 (Unknown(64))
Window Size: 0x8000 (32 KB)
...
[IMPORT_LIBRARIES] (2 libraries)
xam.xex v2.0.4552.0 (min v2.0.4552.0) - 104 imports
xboxkrnl.exe v2.0.4552.0 (min v2.0.4552.0) - 294 imports
=== Security Info ===
Header Size: 0x00000F34 (3892 bytes)
Image Size: 0x00920000 (9568256 bytes)
@@ -54,12 +51,32 @@ Load Address: 0x82000000
Region: 0xFFFFFFFF [ALL REGIONS]
Allowed Media Types: 0x00000004 [DVD_CD]
...
Page Descriptors (146 entries, 64KB pages):
#0 Unknown(0) 19 pages ( 1245184 bytes) offset +0x00000000 SHA1: B136058FBBAD...
...
```
### Extract
Extract the decrypted and decompressed PE image from a XEX2 file:
```sh
xex2tractor extract <file.xex> [output.exe]
```
If no output path is given, defaults to the input filename with `.exe` extension.
#### Example
```sh
$ xex2tractor extract default.xex default.exe
Encryption: Normal (AES-128-CBC)
Compression: Normal (LZX)
Extracted PE image (9568256 bytes) -> default.exe
```
Supports:
- AES-128-CBC decryption (retail, devkit, and XEX1 master keys)
- No compression, basic (zero-fill), and normal (LZX) decompression
- PE header verification (MZ signature, PE signature, POWERPCBE machine type)
## Building
```sh

2947
doc/xbox360_exports.md Normal file

File diff suppressed because it is too large Load Diff

187
src/decompress.rs Normal file
View File

@@ -0,0 +1,187 @@
/// Decompression routines for XEX2 PE image payloads.
///
/// Supports three compression modes:
/// - None: raw data copy
/// - Basic: block-based data copy with zero-fill gaps
/// - Normal: de-blocking + LZX frame-by-frame decompression
use crate::error::{Result, Xex2Error};
use crate::optional::{BasicCompressionBlock, CompressedBlockInfo};
use crate::util::{read_u16_be, read_u32_be};
/// Returns the payload data as-is (no compression).
pub fn decompress_none(data: &[u8]) -> Vec<u8> {
data.to_vec()
}
/// Decompresses basic (zero-fill) compressed data.
///
/// Each block specifies `data_size` bytes to copy from the source, followed by
/// `zero_size` bytes of zeros.
pub fn decompress_basic(data: &[u8], blocks: &[BasicCompressionBlock]) -> Result<Vec<u8>> {
let total_size: u64 = blocks
.iter()
.map(|b| b.data_size as u64 + b.zero_size as u64)
.sum();
let mut output = Vec::with_capacity(total_size as usize);
let mut src_offset = 0usize;
for block in blocks {
let ds = block.data_size as usize;
let zs = block.zero_size as usize;
if src_offset + ds > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"basic block reads past end of data: offset {src_offset} + size {ds} > {}",
data.len()
)));
}
output.extend_from_slice(&data[src_offset..src_offset + ds]);
output.resize(output.len() + zs, 0);
src_offset += ds;
}
Ok(output)
}
/// Decompresses normal (LZX) compressed data.
///
/// Walks the chained block structure, extracting compressed LZX frames, then
/// decompresses each frame using the lzxd crate. Each 2-byte chunk_size within
/// a block corresponds to one LZX frame of up to 32KB uncompressed output.
pub fn decompress_normal(
data: &[u8],
window_size: u32,
first_block: &CompressedBlockInfo,
image_size: u32,
) -> Result<Vec<u8>> {
let ws = match window_size {
0x8000 => lzxd::WindowSize::KB32,
0x10000 => lzxd::WindowSize::KB64,
0x20000 => lzxd::WindowSize::KB128,
0x40000 => lzxd::WindowSize::KB256,
other => {
return Err(Xex2Error::DecompressionFailed(format!(
"unsupported LZX window size: 0x{other:X}"
)));
}
};
let mut decoder = lzxd::Lzxd::new(ws);
let mut output = Vec::with_capacity(image_size as usize);
let mut remaining = image_size as usize;
let mut source_offset = 0usize;
let mut current_block = first_block.clone();
while current_block.block_size != 0 && remaining > 0 {
if source_offset + current_block.block_size as usize > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"block at offset {source_offset} extends past data (block_size={}, data_len={})",
current_block.block_size,
data.len()
)));
}
let block_end = source_offset + current_block.block_size as usize;
// Read next block info from start of this block's data (24 bytes)
let next_block_size = read_u32_be(data, source_offset)?;
let mut next_block_hash = [0u8; 20];
next_block_hash.copy_from_slice(&data[source_offset + 4..source_offset + 24]);
// Skip past the 24-byte block header
let mut chunk_offset = source_offset + 24;
// Process each compressed chunk (= one LZX frame)
while chunk_offset < block_end && remaining > 0 {
let chunk_size = read_u16_be(data, chunk_offset)? as usize;
chunk_offset += 2;
if chunk_size == 0 {
break;
}
if chunk_offset + chunk_size > block_end {
return Err(Xex2Error::DecompressionFailed(format!(
"chunk at offset {chunk_offset} extends past block end {block_end}"
)));
}
// Each chunk decompresses to up to 32KB (MAX_CHUNK_SIZE)
let frame_output_size = remaining.min(lzxd::MAX_CHUNK_SIZE);
let compressed_chunk = &data[chunk_offset..chunk_offset + chunk_size];
let decompressed = decoder
.decompress_next(compressed_chunk, frame_output_size)
.map_err(|e| Xex2Error::DecompressionFailed(format!("LZX error: {e}")))?;
output.extend_from_slice(decompressed);
remaining -= decompressed.len();
chunk_offset += chunk_size;
}
// Advance to next block
source_offset = block_end;
current_block = CompressedBlockInfo {
block_size: next_block_size,
block_hash: next_block_hash,
};
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decompress_none() {
let data = vec![1, 2, 3, 4, 5];
let result = decompress_none(&data);
assert_eq!(result, data);
}
#[test]
fn test_decompress_basic_simple() {
let data = vec![0xAA, 0xBB, 0xCC, 0xDD];
let blocks = vec![
BasicCompressionBlock {
data_size: 2,
zero_size: 3,
},
BasicCompressionBlock {
data_size: 2,
zero_size: 1,
},
];
let result = decompress_basic(&data, &blocks).unwrap();
assert_eq!(result, vec![0xAA, 0xBB, 0, 0, 0, 0xCC, 0xDD, 0]);
}
#[test]
fn test_decompress_basic_empty() {
let result = decompress_basic(&[], &[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_decompress_basic_zero_only() {
let blocks = vec![BasicCompressionBlock {
data_size: 0,
zero_size: 10,
}];
let result = decompress_basic(&[], &blocks).unwrap();
assert_eq!(result, vec![0u8; 10]);
}
#[test]
fn test_decompress_basic_overflow() {
let data = vec![0xAA];
let blocks = vec![BasicCompressionBlock {
data_size: 100,
zero_size: 0,
}];
assert!(decompress_basic(&data, &blocks).is_err());
}
}

View File

@@ -1,5 +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,
};
@@ -266,6 +268,11 @@ pub fn display_security_info(security: &SecurityInfo) {
"AES Key (encrypted): {}",
format_hex_bytes(&security.aes_key)
);
let session_key = crypto::derive_session_key(&security.aes_key);
println!(
"AES Key (decrypted): {}",
format_hex_bytes(&session_key)
);
if security.export_table == 0 {
println!("Export Table: 0x00000000 (none)");
@@ -308,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<String> = 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("<unknown>", |e| &e.name);
println!(
" 0x{:08X} {:>8} ordinal 0x{:04X} {}",
imp.record.address, imp.record.record_type, imp.record.ordinal, name
);
}
}

View File

@@ -22,6 +22,8 @@ pub enum Xex2Error {
DecryptionFailed,
/// Decompression failed.
DecompressionFailed(String),
/// The extracted PE image is invalid.
InvalidPeImage(String),
}
impl fmt::Display for Xex2Error {
@@ -49,6 +51,7 @@ impl fmt::Display for Xex2Error {
write!(f, "decryption failed: no master key produced valid output")
}
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
Xex2Error::InvalidPeImage(msg) => write!(f, "invalid PE image: {msg}"),
}
}
}

194
src/exports.rs Normal file
View File

@@ -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<ExportDatabase> = 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<String, HashMap<u16, ExportInfo>>,
}
/// 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<String, HashMap<u16, ExportInfo>> = HashMap::new();
let mut current_file: Option<String> = 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<u16> {
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);
}
}

211
src/extract.rs Normal file
View File

@@ -0,0 +1,211 @@
/// PE image extraction pipeline: decrypt → decompress → verify → raw PE bytes.
use crate::crypto;
use crate::decompress;
use crate::error::{Result, Xex2Error};
use crate::optional::{CompressionInfo, EncryptionType};
use crate::Xex2File;
/// Expected MZ (DOS) signature at the start of a PE image.
const MZ_SIGNATURE: u16 = 0x5A4D;
/// Expected PE signature: "PE\0\0" = 0x00004550.
const PE_SIGNATURE: u32 = 0x00004550;
/// IMAGE_FILE_MACHINE_POWERPCBE — Xbox 360 PowerPC big-endian.
const MACHINE_POWERPCBE: u16 = 0x01F2;
/// Extracts the PE image from a parsed XEX2 file.
///
/// Reads the encrypted/compressed payload from `data` (the full XEX2 file),
/// decrypts it if needed, decompresses it based on the file format info,
/// verifies the PE headers, and returns the raw PE image bytes.
pub fn extract_pe_image(data: &[u8], xex: &Xex2File) -> Result<Vec<u8>> {
let fmt = xex
.optional_headers
.file_format_info
.as_ref()
.ok_or_else(|| {
Xex2Error::DecompressionFailed("missing FILE_FORMAT_INFO header".into())
})?;
let payload_offset = xex.header.header_size as usize;
if payload_offset > data.len() {
return Err(Xex2Error::DecompressionFailed(format!(
"header_size (0x{:X}) exceeds file size (0x{:X})",
payload_offset,
data.len()
)));
}
// Copy payload so we can decrypt in-place
let mut payload = data[payload_offset..].to_vec();
// Step 1: Decrypt if needed
if fmt.encryption_type == EncryptionType::Normal {
let session_key = crypto::derive_session_key_with_validation(
&xex.security_info.aes_key,
|key| {
// Quick validation: decrypt the first 16 bytes and check for patterns
// that indicate a valid decryption (non-random data).
// Full MZ validation happens after decompression.
let mut test_block = payload[..16.min(payload.len())].to_vec();
crypto::decrypt_in_place(key, &mut test_block);
// For uncompressed data, check MZ signature directly
if matches!(fmt.compression_info, CompressionInfo::None) {
test_block.len() >= 2 && test_block[0] == 0x4D && test_block[1] == 0x5A
} else {
// For compressed data, any successful decryption might be valid.
// Accept the key if the decrypted data isn't all zeros or all 0xFF.
!test_block.iter().all(|&b| b == 0)
&& !test_block.iter().all(|&b| b == 0xFF)
}
},
)?;
crypto::decrypt_in_place(&session_key, &mut payload);
}
// Step 2: Decompress based on compression type
let pe_image = match &fmt.compression_info {
CompressionInfo::None => decompress::decompress_none(&payload),
CompressionInfo::Basic { blocks } => decompress::decompress_basic(&payload, blocks)?,
CompressionInfo::Normal {
window_size,
first_block,
} => decompress::decompress_normal(
&payload,
*window_size,
first_block,
xex.security_info.image_size,
)?,
CompressionInfo::Delta => {
return Err(Xex2Error::DecompressionFailed(
"delta compression is not supported".into(),
));
}
};
// Step 3: Verify PE headers
verify_pe_image(&pe_image)?;
Ok(pe_image)
}
/// Verifies that the extracted data is a valid PE image.
///
/// Checks:
/// - MZ (DOS) signature at offset 0
/// - PE signature at e_lfanew offset
/// - Machine type is POWERPCBE (0x01F2)
pub fn verify_pe_image(pe_data: &[u8]) -> Result<()> {
if pe_data.len() < 0x40 {
return Err(Xex2Error::InvalidPeImage(
"PE image too small for DOS header".into(),
));
}
// Check MZ signature (little-endian per PE spec)
let mz = u16::from_le_bytes([pe_data[0], pe_data[1]]);
if mz != MZ_SIGNATURE {
return Err(Xex2Error::InvalidPeImage(format!(
"invalid MZ signature: 0x{mz:04X} (expected 0x{MZ_SIGNATURE:04X})"
)));
}
// Read e_lfanew (little-endian per PE spec)
let e_lfanew = u32::from_le_bytes([
pe_data[0x3C],
pe_data[0x3D],
pe_data[0x3E],
pe_data[0x3F],
]) as usize;
if e_lfanew + 6 > pe_data.len() {
return Err(Xex2Error::InvalidPeImage(format!(
"e_lfanew (0x{e_lfanew:X}) points past end of image"
)));
}
// Check PE signature
let pe_sig = u32::from_le_bytes([
pe_data[e_lfanew],
pe_data[e_lfanew + 1],
pe_data[e_lfanew + 2],
pe_data[e_lfanew + 3],
]);
if pe_sig != PE_SIGNATURE {
return Err(Xex2Error::InvalidPeImage(format!(
"invalid PE signature: 0x{pe_sig:08X} (expected 0x{PE_SIGNATURE:08X})"
)));
}
// Check machine type (little-endian per PE spec)
let machine = u16::from_le_bytes([pe_data[e_lfanew + 4], pe_data[e_lfanew + 5]]);
if machine != MACHINE_POWERPCBE {
return Err(Xex2Error::InvalidPeImage(format!(
"unexpected machine type: 0x{machine:04X} (expected 0x{MACHINE_POWERPCBE:04X} POWERPCBE)"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_verify_pe_image_too_small() {
let data = vec![0x4D, 0x5A]; // MZ but too short
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_bad_mz() {
let mut data = vec![0u8; 0x100];
data[0] = 0x00; // Not MZ
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_bad_pe_sig() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A; // MZ
// e_lfanew = 0x80 (little-endian)
data[0x3C] = 0x80;
// No PE signature at 0x80
assert!(verify_pe_image(&data).is_err());
}
#[test]
fn test_verify_pe_image_valid() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A; // MZ
data[0x3C] = 0x80; // e_lfanew = 0x80
// PE signature at 0x80
data[0x80] = b'P';
data[0x81] = b'E';
data[0x82] = 0;
data[0x83] = 0;
// Machine = 0x01F2 (little-endian: F2 01)
data[0x84] = 0xF2;
data[0x85] = 0x01;
assert!(verify_pe_image(&data).is_ok());
}
#[test]
fn test_verify_pe_image_wrong_machine() {
let mut data = vec![0u8; 0x200];
data[0] = 0x4D;
data[1] = 0x5A;
data[0x3C] = 0x80;
data[0x80] = b'P';
data[0x81] = b'E';
data[0x82] = 0;
data[0x83] = 0;
data[0x84] = 0x4C; // x86 machine type
data[0x85] = 0x01;
assert!(verify_pe_image(&data).is_err());
}
}

248
src/imports.rs Normal file
View File

@@ -0,0 +1,248 @@
/// 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<ExportInfo>,
}
/// 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<Vec<ResolvedImport>> {
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)
}
/// 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)]
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");
}
#[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

@@ -7,9 +7,13 @@
//! structured information from XEX2 files.
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;

View File

@@ -23,6 +23,9 @@ enum Command {
file: PathBuf,
/// Output path for the extracted PE file (default: same name with .exe extension)
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 {
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),
}
}
@@ -42,18 +49,65 @@ 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<PathBuf>) {
let _output_path = output.unwrap_or_else(|| path.with_extension("exe"));
fn cmd_extract(path: &PathBuf, output: Option<PathBuf>, resolve_imports: bool) {
let output_path = output.unwrap_or_else(|| path.with_extension("exe"));
let data = read_file(path);
let _xex = parse_xex(&data);
let xex = parse_xex(&data);
// TODO(M5): decrypt + decompress pipeline
// TODO(M6): verify PE and write to output_path
eprintln!("Error: extraction not yet implemented (coming in M5/M6)");
// Display encryption/compression info
if let Some(ref fmt) = xex.optional_headers.file_format_info {
println!("Encryption: {}", fmt.encryption_type);
println!("Compression: {}", fmt.compression_type);
}
let mut pe_image = match xex2tractor::extract::extract_pe_image(&data, &xex) {
Ok(img) => img,
Err(e) => {
eprintln!("Error extracting PE image: {e}");
process::exit(1);
}
};
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);
}
println!(
"Extracted PE image ({} bytes) -> {}",
pe_image.len(),
output_path.display()
);
}
fn read_file(path: &PathBuf) -> Vec<u8> {

View File

@@ -1,5 +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};
@@ -311,16 +314,372 @@ fn test_cli_inspect_missing_file() {
assert!(!output.status.success());
}
// ── Extraction tests ─────────────────────────────────────────────────────────
#[test]
fn test_cli_extract_not_yet_implemented() {
fn test_extract_pe_image() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// Output size should match security_info.image_size
assert_eq!(pe_image.len(), xex.security_info.image_size as usize);
}
#[test]
fn test_extract_pe_starts_with_mz() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// PE image must start with MZ signature (0x4D5A)
assert_eq!(pe_image[0], 0x4D, "first byte should be 'M'");
assert_eq!(pe_image[1], 0x5A, "second byte should be 'Z'");
}
#[test]
fn test_extract_pe_has_valid_pe_header() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// Read e_lfanew from DOS header (offset 0x3C, little-endian per PE spec)
let e_lfanew = u32::from_le_bytes([
pe_image[0x3C],
pe_image[0x3D],
pe_image[0x3E],
pe_image[0x3F],
]) as usize;
// PE signature at e_lfanew: "PE\0\0"
assert_eq!(&pe_image[e_lfanew..e_lfanew + 4], b"PE\0\0");
// Machine type at e_lfanew + 4: 0x01F2 (IMAGE_FILE_MACHINE_POWERPCBE, little-endian)
let machine = u16::from_le_bytes([pe_image[e_lfanew + 4], pe_image[e_lfanew + 5]]);
assert_eq!(machine, 0x01F2, "machine should be POWERPCBE");
}
#[test]
fn test_cli_extract_writes_file() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output_path = format!("{}/target/test_extract_output.exe", env!("CARGO_MANIFEST_DIR"));
// Clean up any previous test output
let _ = std::fs::remove_file(&output_path);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path, &output_path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI extract should succeed");
// Verify output file exists and starts with MZ
let extracted = std::fs::read(&output_path).expect("should be able to read extracted file");
assert!(extracted.len() > 2);
assert_eq!(extracted[0], 0x4D); // 'M'
assert_eq!(extracted[1], 0x5A); // 'Z'
// Clean up
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn test_cli_extract_default_output_path() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let expected_output = format!("{}/tests/data/default.exe", env!("CARGO_MANIFEST_DIR"));
// Clean up any previous test output
let _ = std::fs::remove_file(&expected_output);
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path])
.output()
.expect("failed to run xex2tractor");
// Extract should fail with "not yet implemented" for now
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not yet implemented"));
assert!(output.status.success(), "CLI extract should succeed");
// Verify default output path was used
assert!(
std::fs::metadata(&expected_output).is_ok(),
"default output file should exist"
);
// Clean up
let _ = std::fs::remove_file(&expected_output);
}
#[test]
fn test_extract_pe_verification_runs() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let pe_image = extract::extract_pe_image(&data, &xex).unwrap();
// verify_pe_image should succeed on the extracted image
assert!(extract::verify_pe_image(&pe_image).is_ok());
}
#[test]
fn test_cli_extract_shows_format_info() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output_path = format!("{}/target/test_extract_info.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", &path, &output_path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Encryption:"));
assert!(stdout.contains("Compression:"));
assert!(stdout.contains("Extracted PE image"));
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_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"));
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"
);
}