xenia-vfs/xex: cache full disc tree; instrument XEX load

DiscImageDevice now walks the GDFX tree at open() and caches every
file/dir entry by full relative path; the previous root-only scan
returned ENOENT for any path under a subdirectory (dat/tables.pak,
media/x.wav). Lookups become O(n) over the cached vec.

xex::load_image gains a tracing span plus per-load metrics
(xex.load_image_ms histogram, xex.bytes_{in,out} counters) so the
observability subscriber the app installs can see decompression cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-01 16:28:32 +02:00
parent 45e15d7885
commit f1fadb5398
4 changed files with 131 additions and 40 deletions

View File

@@ -229,7 +229,7 @@ fn parse_import_libraries(data: &[u8], headers: &[Xex2OptionalHeader]) -> Vec<Im
let name = std::str::from_utf8(&data[start..end]).unwrap_or("???").to_string();
spos += name.len() + 1;
// 4-byte alignment
if spos % 4 != 0 { spos += 4 - (spos % 4); }
if !spos.is_multiple_of(4) { spos += 4 - (spos % 4); }
strings.push(name);
}
@@ -359,21 +359,31 @@ pub fn get_stack_size(header: &Xex2Header) -> u32 {
/// Load the XEX image data into a flat buffer (decompressing if needed).
/// Returns the decompressed image bytes ready to map into guest memory.
#[tracing::instrument(skip_all, fields(bytes = data.len()))]
pub fn load_image(data: &[u8], header: &Xex2Header) -> io::Result<Vec<u8>> {
let started = std::time::Instant::now();
let source = &data[header.header_size as usize..];
let bytes_in = source.len();
match &header.file_format_info {
let output = match &header.file_format_info {
Some(info) if info.compression_type == COMPRESSION_BASIC => {
load_basic_compressed(source, info)
tracing::debug!(compression = "basic", "decompressing");
load_basic_compressed(source, info)?
}
Some(info) if info.compression_type == COMPRESSION_NORMAL => {
load_normal_compressed(source, info, header)
tracing::debug!(compression = "normal/LZX", "decompressing");
load_normal_compressed(source, info, header)?
}
_ => {
// Uncompressed (or no format info = treat as uncompressed)
Ok(source.to_vec())
}
}
_ => source.to_vec(),
};
let elapsed_ms = started.elapsed().as_millis() as f64;
metrics::histogram!("xex.load_image_ms").record(elapsed_ms);
metrics::counter!("xex.bytes_in").increment(bytes_in as u64);
metrics::counter!("xex.bytes_out").increment(output.len() as u64);
let ratio = if bytes_in == 0 { 0.0 } else { output.len() as f64 / bytes_in as f64 };
tracing::info!(bytes_in, bytes_out = output.len(), ratio, elapsed_ms, "image loaded");
Ok(output)
}
/// Load basic compressed image data.
@@ -425,6 +435,7 @@ const XEX2_RETAIL_KEY: [u8; 16] = [
const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16];
/// AES-128-CBC decryption with zero IV (matching Xbox 360 XEX decryption).
#[tracing::instrument(skip_all, fields(bytes = input.len()))]
fn aes_decrypt_cbc(key: &[u8; 16], input: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(key.into());
let mut output = vec![0u8; input.len()];
@@ -523,6 +534,7 @@ fn deblock(input: &[u8], first_block_size: u32) -> io::Result<Vec<u8>> {
/// Load normal (LZX) compressed image data.
/// Pipeline: decrypt → de-block → LZX decompress (pure Rust)
#[tracing::instrument(skip_all, fields(bytes_in = source.len()))]
fn load_normal_compressed(source: &[u8], info: &FileFormatInfo, header: &Xex2Header) -> io::Result<Vec<u8>> {
let uncompressed_size = header.security_info.as_ref()
.map(|s| s.image_size as usize)