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, } /// 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, path: &std::path::Path) -> Result { 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 `/` 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, 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, 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 { 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"); } }