//! XISO / XDVDFS disc image reading. //! //! Xbox 360 game discs use the XDVDFS filesystem (also called XISO). //! This module wraps the `xdvdfs` crate to extract game files into memory //! or onto disk for further processing. //! //! ## Reference projects //! - xdvdfs (Rust): https://github.com/antangelo/xdvdfs //! - extract-xiso (C): https://github.com/XboxDev/extract-xiso //! - Xenia emulator: https://github.com/xenia-canary/xenia-canary use std::io::{Read, Seek}; use std::path::Path; use anyhow::{Context, Result}; use tracing::{debug, info}; use xdvdfs::blockdev::OffsetWrapper; use xdvdfs::layout::VolumeDescriptor; /// A handle to an open XISO image. /// /// The inner file is wrapped in an [`OffsetWrapper`] which probes the four /// known XGD partition offsets (raw XISO, XGD1, XGD2, XGD3) at open time, /// so both single-layer raw dumps **and** full dual-layer disc images are /// supported transparently. pub struct XisoReader { volume: VolumeDescriptor, /// Offset-aware block device — all sector reads are shifted by the /// detected partition start offset automatically. file: OffsetWrapper, } impl XisoReader { pub async fn open(file: F) -> Result { // OffsetWrapper::new probes four known partition offsets: // 0x00000000 — raw XISO (trimmed, sector 0 = XDVDFS start) // 0x183E0000 — XGD1 (original Xbox) // 0x0FD90000 — XGD2 (Xbox 360, most retail titles) // 0x02080000 — XGD3 (Xbox 360, later dual-layer titles) let mut wrapper = OffsetWrapper::new(file).await.map_err(|e| { anyhow::anyhow!( "No valid XDVDFS partition found in this disc image. \ Tried raw XISO, XGD1, XGD2, and XGD3 offsets. \ Is this a valid Xbox 360 (or original Xbox) disc image? \ (internal error: {e:?})" ) })?; // Re-read the volume descriptor via the wrapper (now at the correct offset). let volume = xdvdfs::read::read_volume(&mut wrapper) .await .context("Found XDVDFS partition but failed to parse volume descriptor")?; info!( "Opened XISO: root directory table at sector {}", { let s = volume.root_table.region.sector; s } ); Ok(Self { volume, file: wrapper }) } /// List all files in the disc image, recursively (directories excluded). pub async fn list_all_files(&mut self) -> Result> { let entries = self .volume .root_table .file_tree(&mut self.file) .await .context("Failed to walk XISO file tree")?; let mut paths = Vec::new(); for (parent, entry) in &entries { if entry.node.dirent.is_directory() { continue; } let name = entry .name_str::() .unwrap_or(std::borrow::Cow::Borrowed("")); // file_tree builds parent paths with a leading slash (e.g. "/dat"). // Trim it so we get "dat/filename" instead of "/dat/filename". let full_path = format!("{}/{}", parent, name); paths.push(full_path.trim_start_matches('/').to_string()); } Ok(paths) } /// Read the raw bytes of a file by its path inside the ISO. pub async fn read_file(&mut self, path: &str) -> Result> { let dirent = self .volume .root_table .walk_path(&mut self.file, path) .await .with_context(|| format!("File not found in ISO: {}", path))?; let data = dirent .node .dirent .read_data_all(&mut self.file) .await .with_context(|| format!("Failed to read file data: {}", path))?; debug!("Read {} bytes from {}", data.len(), path); Ok(data.into_vec()) } /// Extract all files from the ISO into a directory on disk. pub async fn extract_all(&mut self, output_dir: &Path) -> Result { info!("Extracting ISO to {}", output_dir.display()); std::fs::create_dir_all(output_dir).context("Failed to create output directory")?; let entries = self .volume .root_table .file_tree(&mut self.file) .await .context("Failed to walk XISO file tree")?; let mut stats = ExtractStats::default(); for (parent, entry) in &entries { if entry.node.dirent.is_directory() { continue; } let name = entry .name_str::() .unwrap_or(std::borrow::Cow::Borrowed("")); let rel_path = format!("{}/{}", parent, name); let rel_path = rel_path.trim_start_matches('/'); let disk_path = output_dir.join(rel_path.replace('/', std::path::MAIN_SEPARATOR_STR)); if let Some(parent_dir) = disk_path.parent() { std::fs::create_dir_all(parent_dir).with_context(|| { format!("Failed to create dir: {}", parent_dir.display()) })?; } let data = entry .node .dirent .read_data_all(&mut self.file) .await .with_context(|| format!("Failed to read: {}", rel_path))?; std::fs::write(&disk_path, &*data) .with_context(|| format!("Failed to write: {}", disk_path.display()))?; stats.files_extracted += 1; stats.bytes_extracted += data.len() as u64; debug!("Extracted {}", rel_path); } info!( "Extraction complete: {} files, {} bytes", stats.files_extracted, stats.bytes_extracted ); Ok(stats) } } /// Open a disc image from a file path. /// /// Accepts raw XISO dumps and full XGD1/XGD2/XGD3 disc images — the correct /// partition offset is detected automatically. pub async fn open_iso(path: &Path) -> Result> { let file = std::fs::File::open(path) .with_context(|| format!("Cannot open disc image: {}", path.display()))?; XisoReader::open(file).await } /// Statistics from an extraction operation. #[derive(Default, Debug)] pub struct ExtractStats { pub files_extracted: usize, pub bytes_extracted: u64, } #[cfg(test)] mod tests { use super::*; #[tokio::test] #[ignore = "requires a real ISO image — set SYLPHEED_ISO env var"] async fn test_list_iso() { let iso_path = std::env::var("SYLPHEED_ISO").expect("Set SYLPHEED_ISO to your ISO path"); let mut reader = open_iso(Path::new(&iso_path)).await.unwrap(); let files = reader.list_all_files().await.unwrap(); for f in &files { println!("{}", f); } println!("Total: {} files", files.len()); } }