Files
xenia-rs/crates/xenia-vfs/src/disc_image.rs
MechaCat02 f1fadb5398 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>
2026-05-01 16:28:32 +02:00

264 lines
9.4 KiB
Rust

use crate::{VfsDevice, VfsEntry, VfsError};
use std::io::{Read, Seek, SeekFrom};
/// XISO disc image device. Parses Xbox 360 disc images (GDFX/XISO format).
///
/// Caches the fully-resolved entry list at open() — GDFX is a directory
/// tree, and resolving any nested path (`dat/tables.pak`, `media/x.wav`)
/// requires descending into subdirectories. A prior version only scanned
/// the root buffer, so any file under a subdirectory was reported as
/// missing. We read each directory's buffer from disk once at open time
/// and emit full paths into `entries`.
pub struct DiscImageDevice {
name: String,
path: std::path::PathBuf,
game_offset: u64,
/// Flattened file + directory tree, each with its full path relative
/// to the partition root ("dat/tables.pak", etc.). Populated once at
/// `open()` so lookups are O(n) over a cached vec instead of rereading
/// the tree on every NtCreateFile.
entries: Vec<VfsEntry>,
}
/// XISO sector size
pub const SECTOR_SIZE: u64 = 0x800;
/// GDFX magic string
const GDFX_MAGIC: &[u8; 20] = b"MICROSOFT*XBOX*MEDIA";
/// File attribute: directory
const FILE_ATTRIBUTE_DIRECTORY: u8 = 0x10;
/// Known game partition offsets to try
const LIKELY_OFFSETS: &[u64] = &[
0x0000_0000,
0x0000_FB20,
0x0002_0600,
0x0208_0000,
0x0FD9_0000,
];
impl DiscImageDevice {
pub fn open(name: impl Into<String>, path: &std::path::Path) -> Result<Self, VfsError> {
let mut file = std::fs::File::open(path)?;
// Find the game partition by locating the GDFX magic at sector 32
let mut game_offset = 0u64;
let mut magic_found = false;
let mut magic_buf = [0u8; 20];
for &offset in LIKELY_OFFSETS {
let magic_pos = offset + 32 * SECTOR_SIZE;
if file.seek(SeekFrom::Start(magic_pos)).is_ok()
&& file.read_exact(&mut magic_buf).is_ok()
&& magic_buf == *GDFX_MAGIC
{
game_offset = offset;
magic_found = true;
break;
}
}
if !magic_found {
return Err(VfsError::InvalidFormat(
"GDFX magic not found - not a valid XISO disc image".into(),
));
}
// Read root directory info from sector 32 header
let fs_ptr = game_offset + 32 * SECTOR_SIZE;
file.seek(SeekFrom::Start(fs_ptr + 20))?;
let mut buf4 = [0u8; 4];
file.read_exact(&mut buf4)?;
let root_sector = u32::from_le_bytes(buf4) as u64;
file.read_exact(&mut buf4)?;
let root_size = u32::from_le_bytes(buf4) as u64;
let root_byte_offset = game_offset + root_sector * SECTOR_SIZE;
// Read the root directory buffer into memory (typically small)
file.seek(SeekFrom::Start(root_byte_offset))?;
let mut root_buffer = vec![0u8; root_size as usize];
file.read_exact(&mut root_buffer)?;
let mut dev = Self {
name: name.into(),
path: path.to_path_buf(),
game_offset,
entries: Vec::new(),
};
dev.collect_entries(&mut file, &root_buffer, 0, "")?;
Ok(dev)
}
/// Walk one directory's B-tree buffer, emit each file/directory into
/// `out` with its full relative path, and recurse into subdirectory
/// buffers on disk.
///
/// `prefix` is the current parent path (empty at the root). Names
/// concatenate as `<prefix>/<name>` so the final path matches what
/// guest callers like `NtCreateFile("dat/tables.pak")` expect.
///
/// `file` is the already-open disc image handle, reused for every
/// subdirectory read so we don't pay a fresh open per directory on
/// deep trees.
fn collect_entries(
&mut self,
file: &mut std::fs::File,
buffer: &[u8],
ordinal: u16,
prefix: &str,
) -> Result<(), VfsError> {
let p = ordinal as usize * 4;
if p + 14 > buffer.len() {
return Ok(());
}
let node_l = u16::from_le_bytes([buffer[p], buffer[p + 1]]);
let node_r = u16::from_le_bytes([buffer[p + 2], buffer[p + 3]]);
let sector = u32::from_le_bytes([buffer[p + 4], buffer[p + 5], buffer[p + 6], buffer[p + 7]]) as u64;
let length = u32::from_le_bytes([buffer[p + 8], buffer[p + 9], buffer[p + 10], buffer[p + 11]]) as u64;
let attributes = buffer[p + 12];
let name_length = buffer[p + 13] as usize;
if p + 14 + name_length > buffer.len() {
return Ok(());
}
if node_l != 0 && node_l != 0xFFFF {
self.collect_entries(file, buffer, node_l, prefix)?;
}
let name = String::from_utf8_lossy(&buffer[p + 14..p + 14 + name_length]).to_string();
let is_directory = (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
let file_offset = self.game_offset + sector * SECTOR_SIZE;
let full_path = if prefix.is_empty() {
name.clone()
} else {
format!("{}/{}", prefix, name)
};
self.entries.push(VfsEntry {
name: full_path.clone(),
is_directory,
size: length,
offset: file_offset,
});
// Descend into subdirectories. Zero-length directory entries exist
// (empty dirs) and must be skipped to avoid `read_exact` on 0 bytes.
if is_directory && length > 0 {
file.seek(SeekFrom::Start(file_offset))?;
let mut sub_buffer = vec![0u8; length as usize];
file.read_exact(&mut sub_buffer)?;
self.collect_entries(file, &sub_buffer, 0, &full_path)?;
}
if node_r != 0 && node_r != 0xFFFF {
self.collect_entries(file, buffer, node_r, prefix)?;
}
Ok(())
}
}
impl VfsDevice for DiscImageDevice {
fn name(&self) -> &str {
&self.name
}
fn list_root(&self) -> Result<Vec<VfsEntry>, VfsError> {
// Return the full flattened tree. Callers of this method are
// dump/debug paths (see `xenia-rs dumpxiso`), which want to see
// every file — root-only was the old flat-enumeration bug.
Ok(self.entries.clone())
}
fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
let entry = self
.entries
.iter()
.find(|e| e.name.eq_ignore_ascii_case(path) && !e.is_directory)
.ok_or_else(|| VfsError::NotFound(path.to_string()))?;
let offset = entry.offset;
let size = entry.size as usize;
// Read from file using seek
let mut file = std::fs::File::open(&self.path)?;
let file_len = file.seek(SeekFrom::End(0))?;
if offset + size as u64 > file_len {
return Err(VfsError::NotFound(format!(
"File data extends past end of image: {} (offset={:#x}, size={:#x}, image_len={:#x})",
path, offset, size, file_len
)));
}
file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; size];
let bytes_read = file.read(&mut buf)?;
if bytes_read < size {
// Try reading the rest
let mut total = bytes_read;
while total < size {
let n = file.read(&mut buf[total..])?;
if n == 0 {
return Err(VfsError::NotFound(format!(
"Short read: got {} of {} bytes for {}",
total, size, path
)));
}
total += n;
}
}
Ok(buf)
}
fn stat(&self, path: &str) -> Result<VfsEntry, VfsError> {
self.entries
.iter()
.find(|e| e.name.eq_ignore_ascii_case(path))
.cloned()
.ok_or_else(|| VfsError::NotFound(path.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression: the XISO reader used to only enumerate the root directory,
/// so any nested path (`dat/tables.pak`, `media/stream.xma`) failed to
/// open. Verified end-to-end by `browse` on the Sylpheed disc which
/// now lists 358 entries including `dat/*` files.
///
/// This test runs only if an XISO is available in the parent of the repo
/// root — matches the developer's local layout for the real disc. CI
/// machines without the disc simply skip the test (early-return Ok).
#[test]
fn nested_file_resolves_when_disc_present() {
let disc_path = std::path::Path::new(
"../../../Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
);
if !disc_path.exists() {
eprintln!("skipping: disc image not present at {:?}", disc_path);
return;
}
let dev = DiscImageDevice::open("disc", disc_path).expect("open xiso");
// Both a top-level and a nested file must be visible.
assert!(
dev.entries.iter().any(|e| e.name == "default.xex"),
"default.xex must be at the root"
);
assert!(
dev.entries
.iter()
.any(|e| e.name.eq_ignore_ascii_case("dat/tables.pak")),
"nested entry dat/tables.pak missing — subdirectory enumeration broken",
);
// And read_file must be able to fetch the nested bytes.
let bytes = dev
.read_file("dat/tables.pak")
.expect("read_file on nested path");
assert!(!bytes.is_empty(), "nested read returned empty buffer");
}
}