Merge feature/m1-main-header: parse and display XEX2 main header

This commit is contained in:
MechaCat02
2026-03-28 18:52:49 +01:00
9 changed files with 572 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
tests/data/

View File

@@ -2,12 +2,42 @@
A tool for extracting and inspecting Xbox 360 XEX2 executable files, written in Rust.
## Usage
```sh
xex2tractor <file.xex>
```
### Example Output
```
=== XEX2 Header ===
Magic: XEX2 (0x58455832)
Module Flags: 0x00000001 [TITLE]
Header Size: 0x00003000 (12288 bytes)
Reserved: 0x00000000
Security Offset: 0x00000090
Header Count: 15
```
## Building
```sh
cargo build --release
```
## Testing
Place a sample XEX2 file at `tests/data/default.xex`, then run:
```sh
cargo test
```
## Documentation
See [doc/xex2_format.md](doc/xex2_format.md) for the XEX2 file format specification.
## License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.

16
src/display.rs Normal file
View File

@@ -0,0 +1,16 @@
/// Pretty-print formatting for parsed XEX2 structures.
use crate::header::Xex2Header;
/// Prints the XEX2 main header in a human-readable format.
pub fn display_header(header: &Xex2Header) {
println!("=== XEX2 Header ===");
println!("Magic: XEX2 (0x{:08X})", header.magic);
println!("Module Flags: {}", header.module_flags);
println!(
"Header Size: 0x{:08X} ({} bytes)",
header.header_size, header.header_size
);
println!("Reserved: 0x{:08X}", header.reserved);
println!("Security Offset: 0x{:08X}", header.security_offset);
println!("Header Count: {}", header.header_count);
}

71
src/error.rs Normal file
View File

@@ -0,0 +1,71 @@
/// Error types for XEX2 parsing operations.
use std::fmt;
/// All errors that can occur during XEX2 file parsing.
#[derive(Debug)]
pub enum Xex2Error {
/// An I/O error occurred while reading the file.
Io(std::io::Error),
/// The file does not start with the expected XEX2 magic bytes (0x58455832).
InvalidMagic(u32),
/// The file or buffer is too small to contain the expected data.
FileTooSmall { expected: usize, actual: usize },
/// An offset points outside the file boundaries.
InvalidOffset {
name: &'static str,
offset: u32,
file_size: usize,
},
/// A string field contains invalid UTF-8.
Utf8Error(std::str::Utf8Error),
}
impl fmt::Display for Xex2Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Xex2Error::Io(err) => write!(f, "I/O error: {err}"),
Xex2Error::InvalidMagic(magic) => {
write!(f, "invalid magic: 0x{magic:08X} (expected 0x58455832)")
}
Xex2Error::FileTooSmall { expected, actual } => {
write!(f, "file too small: need {expected} bytes, got {actual}")
}
Xex2Error::InvalidOffset {
name,
offset,
file_size,
} => {
write!(
f,
"invalid offset for {name}: 0x{offset:08X} exceeds file size {file_size}"
)
}
Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"),
}
}
}
impl std::error::Error for Xex2Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Xex2Error::Io(err) => Some(err),
Xex2Error::Utf8Error(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Xex2Error {
fn from(err: std::io::Error) -> Self {
Xex2Error::Io(err)
}
}
impl From<std::str::Utf8Error> for Xex2Error {
fn from(err: std::str::Utf8Error) -> Self {
Xex2Error::Utf8Error(err)
}
}
/// Convenience type alias for XEX2 parsing results.
pub type Result<T> = std::result::Result<T, Xex2Error>;

208
src/header.rs Normal file
View File

@@ -0,0 +1,208 @@
/// XEX2 main header parsing.
///
/// The main header is located at the very beginning of the XEX2 file (offset 0x00)
/// and contains the magic bytes, module flags, header size, security info offset,
/// and the count of optional header entries that follow.
use std::fmt;
use crate::error::{Result, Xex2Error};
use crate::util::read_u32_be;
/// Expected magic value at offset 0x00: ASCII "XEX2" = 0x58455832.
pub const XEX2_MAGIC: u32 = 0x58455832;
/// Size of the fixed portion of the main header (before optional header entries).
pub const HEADER_SIZE: usize = 0x18;
/// The parsed XEX2 main header.
#[derive(Debug, Clone)]
pub struct Xex2Header {
/// Magic bytes — must be `XEX2_MAGIC` (0x58455832).
pub magic: u32,
/// Bitfield indicating the module type (title, DLL, patch, etc.).
pub module_flags: ModuleFlags,
/// Total size of all headers in bytes. The PE image data starts at this offset.
pub header_size: u32,
/// Reserved field (typically 0).
pub reserved: u32,
/// File offset to the `xex2_security_info` structure.
pub security_offset: u32,
/// Number of optional header entries following the main header.
pub header_count: u32,
}
/// Parses the XEX2 main header from the beginning of `data`.
///
/// Validates that the magic bytes match `XEX2_MAGIC` and that the buffer is
/// large enough to contain the fixed header fields.
pub fn parse_header(data: &[u8]) -> Result<Xex2Header> {
if data.len() < HEADER_SIZE {
return Err(Xex2Error::FileTooSmall {
expected: HEADER_SIZE,
actual: data.len(),
});
}
let magic = read_u32_be(data, 0x00)?;
if magic != XEX2_MAGIC {
return Err(Xex2Error::InvalidMagic(magic));
}
Ok(Xex2Header {
magic,
module_flags: ModuleFlags(read_u32_be(data, 0x04)?),
header_size: read_u32_be(data, 0x08)?,
reserved: read_u32_be(data, 0x0C)?,
security_offset: read_u32_be(data, 0x10)?,
header_count: read_u32_be(data, 0x14)?,
})
}
/// Wrapper around the module flags bitmask from the XEX2 header.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModuleFlags(pub u32);
impl ModuleFlags {
pub const TITLE: u32 = 0x00000001;
pub const EXPORTS_TO_TITLE: u32 = 0x00000002;
pub const SYSTEM_DEBUGGER: u32 = 0x00000004;
pub const DLL_MODULE: u32 = 0x00000008;
pub const MODULE_PATCH: u32 = 0x00000010;
pub const PATCH_FULL: u32 = 0x00000020;
pub const PATCH_DELTA: u32 = 0x00000040;
pub const USER_MODE: u32 = 0x00000080;
/// All known flags paired with their display names, in bit order.
const FLAGS: &[(u32, &str)] = &[
(Self::TITLE, "TITLE"),
(Self::EXPORTS_TO_TITLE, "EXPORTS_TO_TITLE"),
(Self::SYSTEM_DEBUGGER, "SYSTEM_DEBUGGER"),
(Self::DLL_MODULE, "DLL_MODULE"),
(Self::MODULE_PATCH, "MODULE_PATCH"),
(Self::PATCH_FULL, "PATCH_FULL"),
(Self::PATCH_DELTA, "PATCH_DELTA"),
(Self::USER_MODE, "USER_MODE"),
];
/// Returns the raw `u32` value.
pub fn bits(self) -> u32 {
self.0
}
/// Returns a list of human-readable flag names that are set.
pub fn flag_names(self) -> Vec<&'static str> {
Self::FLAGS
.iter()
.filter(|(bit, _)| self.0 & bit != 0)
.map(|(_, name)| *name)
.collect()
}
}
impl fmt::Display for ModuleFlags {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let names = self.flag_names();
if names.is_empty() {
write!(f, "0x{:08X}", self.0)
} else {
write!(f, "0x{:08X} [{}]", self.0, names.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a minimal valid XEX2 header buffer with the given field values.
fn make_header(
magic: u32,
module_flags: u32,
header_size: u32,
reserved: u32,
security_offset: u32,
header_count: u32,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE);
buf.extend_from_slice(&magic.to_be_bytes());
buf.extend_from_slice(&module_flags.to_be_bytes());
buf.extend_from_slice(&header_size.to_be_bytes());
buf.extend_from_slice(&reserved.to_be_bytes());
buf.extend_from_slice(&security_offset.to_be_bytes());
buf.extend_from_slice(&header_count.to_be_bytes());
buf
}
#[test]
fn test_parse_valid_header() {
let data = make_header(XEX2_MAGIC, 0x01, 0x3000, 0, 0x90, 15);
let header = parse_header(&data).unwrap();
assert_eq!(header.magic, XEX2_MAGIC);
assert_eq!(header.module_flags, ModuleFlags(0x01));
assert_eq!(header.header_size, 0x3000);
assert_eq!(header.reserved, 0);
assert_eq!(header.security_offset, 0x90);
assert_eq!(header.header_count, 15);
}
#[test]
fn test_invalid_magic() {
let data = make_header(0xDEADBEEF, 0, 0, 0, 0, 0);
let err = parse_header(&data).unwrap_err();
assert!(matches!(err, Xex2Error::InvalidMagic(0xDEADBEEF)));
}
#[test]
fn test_file_too_small() {
let data = [0u8; 10];
let err = parse_header(&data).unwrap_err();
assert!(matches!(
err,
Xex2Error::FileTooSmall {
expected: HEADER_SIZE,
..
}
));
}
#[test]
fn test_module_flags_display_title() {
let flags = ModuleFlags(0x01);
assert_eq!(flags.to_string(), "0x00000001 [TITLE]");
}
#[test]
fn test_module_flags_display_multiple() {
let flags = ModuleFlags(0x09); // TITLE | DLL_MODULE
assert_eq!(flags.to_string(), "0x00000009 [TITLE, DLL_MODULE]");
}
#[test]
fn test_module_flags_display_none() {
let flags = ModuleFlags(0);
assert_eq!(flags.to_string(), "0x00000000");
}
#[test]
fn test_module_flags_display_all() {
let flags = ModuleFlags(0xFF);
let names = flags.flag_names();
assert_eq!(names.len(), 8);
assert_eq!(names[0], "TITLE");
assert_eq!(names[7], "USER_MODE");
}
/// Test against the actual default.xex sample file.
#[test]
fn test_parse_sample_header() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let data = std::fs::read(&path).expect("sample file should exist at tests/data/default.xex");
let header = parse_header(&data).unwrap();
assert_eq!(header.magic, XEX2_MAGIC);
assert_eq!(header.module_flags, ModuleFlags(0x00000001));
assert_eq!(header.header_size, 0x00003000);
assert_eq!(header.reserved, 0x00000000);
assert_eq!(header.security_offset, 0x00000090);
assert_eq!(header.header_count, 15);
}
}

32
src/lib.rs Normal file
View File

@@ -0,0 +1,32 @@
//! # xex2tractor
//!
//! A library for parsing Xbox 360 XEX2 executable files.
//!
//! XEX2 is the executable format used by the Xbox 360 console. This crate
//! provides types and functions to parse the binary format and extract
//! structured information from XEX2 files.
pub mod display;
pub mod error;
pub mod header;
pub mod util;
use error::Result;
use header::Xex2Header;
/// A parsed XEX2 file containing all extracted structures.
#[derive(Debug)]
pub struct Xex2File {
/// The main XEX2 header (magic, flags, sizes, offsets).
pub header: Xex2Header,
}
/// Parses an XEX2 file from a byte slice.
///
/// The `data` slice should contain the entire XEX2 file contents.
/// Returns a [`Xex2File`] with all successfully parsed structures.
pub fn parse(data: &[u8]) -> Result<Xex2File> {
let header = header::parse_header(data)?;
Ok(Xex2File { header })
}

View File

@@ -1,3 +1,29 @@
use std::process;
fn main() {
println!("Hello, world!");
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("Usage: xex2tractor <file.xex>");
process::exit(1);
}
};
let data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) => {
eprintln!("Error reading {path}: {e}");
process::exit(1);
}
};
let xex = match xex2tractor::parse(&data) {
Ok(x) => x,
Err(e) => {
eprintln!("Error parsing XEX2: {e}");
process::exit(1);
}
};
xex2tractor::display::display_header(&xex.header);
}

117
src/util.rs Normal file
View File

@@ -0,0 +1,117 @@
/// Big-endian binary read helpers with bounds checking.
use crate::error::{Result, Xex2Error};
/// Reads a big-endian `u32` from `data` at the given byte `offset`.
pub fn read_u32_be(data: &[u8], offset: usize) -> Result<u32> {
let end = offset + 4;
if end > data.len() {
return Err(Xex2Error::FileTooSmall {
expected: end,
actual: data.len(),
});
}
Ok(u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
}
/// Reads a big-endian `u16` from `data` at the given byte `offset`.
pub fn read_u16_be(data: &[u8], offset: usize) -> Result<u16> {
let end = offset + 2;
if end > data.len() {
return Err(Xex2Error::FileTooSmall {
expected: end,
actual: data.len(),
});
}
Ok(u16::from_be_bytes([data[offset], data[offset + 1]]))
}
/// Reads a single byte from `data` at the given `offset`.
pub fn read_u8(data: &[u8], offset: usize) -> Result<u8> {
if offset >= data.len() {
return Err(Xex2Error::FileTooSmall {
expected: offset + 1,
actual: data.len(),
});
}
Ok(data[offset])
}
/// Returns a byte slice of `len` bytes from `data` starting at `offset`.
pub fn read_bytes(data: &[u8], offset: usize, len: usize) -> Result<&[u8]> {
let end = offset + len;
if end > data.len() {
return Err(Xex2Error::FileTooSmall {
expected: end,
actual: data.len(),
});
}
Ok(&data[offset..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_u32_be() {
let data = [0x58, 0x45, 0x58, 0x32];
assert_eq!(read_u32_be(&data, 0).unwrap(), 0x58455832);
}
#[test]
fn test_read_u32_be_with_offset() {
let data = [0x00, 0x00, 0x58, 0x45, 0x58, 0x32];
assert_eq!(read_u32_be(&data, 2).unwrap(), 0x58455832);
}
#[test]
fn test_read_u16_be() {
let data = [0x12, 0x34];
assert_eq!(read_u16_be(&data, 0).unwrap(), 0x1234);
}
#[test]
fn test_read_u8() {
let data = [0xAB, 0xCD];
assert_eq!(read_u8(&data, 1).unwrap(), 0xCD);
}
#[test]
fn test_read_bytes() {
let data = [0x01, 0x02, 0x03, 0x04, 0x05];
assert_eq!(read_bytes(&data, 1, 3).unwrap(), &[0x02, 0x03, 0x04]);
}
#[test]
fn test_read_u32_be_out_of_bounds() {
let data = [0x00, 0x01];
let err = read_u32_be(&data, 0).unwrap_err();
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 }));
}
#[test]
fn test_read_u16_be_out_of_bounds() {
let data = [0x00];
let err = read_u16_be(&data, 0).unwrap_err();
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 2, actual: 1 }));
}
#[test]
fn test_read_u8_out_of_bounds() {
let data: [u8; 0] = [];
let err = read_u8(&data, 0).unwrap_err();
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 1, actual: 0 }));
}
#[test]
fn test_read_bytes_out_of_bounds() {
let data = [0x01, 0x02];
let err = read_bytes(&data, 1, 3).unwrap_err();
assert!(matches!(err, Xex2Error::FileTooSmall { expected: 4, actual: 2 }));
}
}

70
tests/integration.rs Normal file
View File

@@ -0,0 +1,70 @@
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
fn sample_data() -> Vec<u8> {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
std::fs::read(&path).expect("sample file should exist at tests/data/default.xex")
}
#[test]
fn test_full_parse() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
assert_eq!(xex.header.magic, XEX2_MAGIC);
assert_eq!(xex.header.module_flags, ModuleFlags(0x00000001));
assert_eq!(xex.header.header_size, 0x00003000);
assert_eq!(xex.header.reserved, 0x00000000);
assert_eq!(xex.header.security_offset, 0x00000090);
assert_eq!(xex.header.header_count, 15);
}
#[test]
fn test_parse_empty_file() {
let data = vec![];
assert!(xex2tractor::parse(&data).is_err());
}
#[test]
fn test_parse_invalid_magic() {
let mut data = sample_data();
// Corrupt the magic bytes
data[0] = 0x00;
assert!(xex2tractor::parse(&data).is_err());
}
#[test]
fn test_cli_runs_with_sample() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg(&path)
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("XEX2 Header"), "should display header section");
assert!(stdout.contains("0x58455832"), "should display magic value");
assert!(stdout.contains("TITLE"), "should display module flag name");
assert!(stdout.contains("Header Count: 15"), "should show 15 headers");
}
#[test]
fn test_cli_no_args() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.output()
.expect("failed to run xex2tractor");
assert!(!output.status.success(), "CLI should fail without args");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Usage"), "should print usage message");
}
#[test]
fn test_cli_missing_file() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg("/nonexistent/file.xex")
.output()
.expect("failed to run xex2tractor");
assert!(!output.status.success(), "CLI should fail with missing file");
}