From 5e19182cc60ce6b706b7bc3b736d92d94a4d2674 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sat, 16 May 2026 23:14:35 +1000 Subject: [PATCH 1/2] xiso support phase1 --- Cargo.lock | 159 ++++++++++++++++++++ fatxlib/Cargo.toml | 1 + fatxlib/examples/list_xiso.rs | 167 +++++++++++++++++++++ fatxlib/src/error.rs | 3 + fatxlib/src/lib.rs | 1 + fatxlib/src/xiso/mod.rs | 246 +++++++++++++++++++++++++++++++ fatxlib/tests/fixtures/tiny.xiso | Bin 0 -> 131072 bytes fatxlib/tests/xiso_reader.rs | 108 ++++++++++++++ 8 files changed, 685 insertions(+) create mode 100644 fatxlib/examples/list_xiso.rs create mode 100644 fatxlib/src/xiso/mod.rs create mode 100644 fatxlib/tests/fixtures/tiny.xiso create mode 100644 fatxlib/tests/xiso_reader.rs diff --git a/Cargo.lock b/Cargo.lock index 12aeddd..53540e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,29 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -103,6 +126,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -133,6 +165,18 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitbybit" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec187a89ab07e209270175faf9e07ceb2755d984954e58a2296e325ddece2762" +dependencies = [ + "arbitrary-int", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -230,6 +274,19 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciso" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42222171e20c6a5e2c83cc5295f4a55c27c9397acff30dbae4f3baeffae47f51" +dependencies = [ + "arbitrary-int", + "async-trait", + "bitbybit", + "lz4_flex", + "maybe-async", +] + [[package]] name = "clap" version = "4.6.1" @@ -508,6 +565,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -590,6 +656,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "time", + "xdvdfs", ] [[package]] @@ -893,6 +960,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "lab" version = "0.11.0" @@ -962,6 +1038,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +dependencies = [ + "twox-hash", +] + [[package]] name = "mac_address" version = "1.1.8" @@ -972,6 +1057,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1305,6 +1401,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-bitfield" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "095c6eb206c97ddef87ce3d7e3e492b017093d80bce62317afdf0665df514ade" +dependencies = [ + "proc-bitfield-macros", + "static_assertions", +] + +[[package]] +name = "proc-bitfield-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89409e6b315ead7f4c4d9a79e27dc1e11272f930cbb1fb3d31f2fc64671deb77" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1621,6 +1737,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1676,6 +1801,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1923,6 +2058,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typenum" version = "1.20.0" @@ -2377,6 +2518,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "xdvdfs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1777c1ccae32a2185ac98adae015aed3fb06242084b5e7f8b08a811fe0e7b936" +dependencies = [ + "arrayvec", + "async-trait", + "bincode", + "ciso", + "encoding_rs", + "maybe-async", + "proc-bitfield", + "serde", + "serde-big-array", + "sha3", +] + [[package]] name = "xtafkit" version = "1.1.0" diff --git a/fatxlib/Cargo.toml b/fatxlib/Cargo.toml index 1051b9c..dbefc24 100644 --- a/fatxlib/Cargo.toml +++ b/fatxlib/Cargo.toml @@ -16,6 +16,7 @@ libc = "0.2" phf = "0.13" hmac = "0.13" sha1 = "0.11" +xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "sync"] } [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31", features = ["fs", "ioctl"] } diff --git a/fatxlib/examples/list_xiso.rs b/fatxlib/examples/list_xiso.rs new file mode 100644 index 0000000..f882ea4 --- /dev/null +++ b/fatxlib/examples/list_xiso.rs @@ -0,0 +1,167 @@ +//! Diagnostic for the XDVDFS reader. Point it at any XISO file and it will +//! walk the directory tree and print every file's image-relative path, +//! absolute byte offset into the source, and size. Optionally extracts a +//! single named file by streaming it through `XisoImage::read_into`. +//! +//! Usage: +//! cargo run -p fatxlib --release --example list_xiso -- +//! cargo run -p fatxlib --release --example list_xiso -- \ +//! --extract + +use std::env; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; +use std::process; +use std::time::Instant; + +use fatxlib::xiso::XisoImage; + +fn human_size(n: u64) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; + let mut v = n as f64; + let mut i = 0; + while v >= 1024.0 && i + 1 < UNITS.len() { + v /= 1024.0; + i += 1; + } + if i == 0 { + format!("{} {}", n, UNITS[i]) + } else { + format!("{:.2} {}", v, UNITS[i]) + } +} + +fn print_usage_and_exit() -> ! { + eprintln!("usage: list_xiso [--extract ]"); + process::exit(2); +} + +fn main() { + let mut args = env::args().skip(1); + let Some(iso_path) = args.next() else { + print_usage_and_exit(); + }; + + let mut extract: Option<(String, PathBuf)> = None; + while let Some(flag) = args.next() { + match flag.as_str() { + "--extract" => { + let on_iso = args.next().unwrap_or_else(|| print_usage_and_exit()); + let dest = args.next().unwrap_or_else(|| print_usage_and_exit()); + extract = Some((on_iso, PathBuf::from(dest))); + } + other => { + eprintln!("unknown argument: {}", other); + print_usage_and_exit(); + } + } + } + + println!("Opening {}", iso_path); + let started = Instant::now(); + let file = File::open(&iso_path).unwrap_or_else(|e| { + eprintln!("Error opening {}: {}", iso_path, e); + process::exit(1); + }); + let mut img = XisoImage::open(file).unwrap_or_else(|e| { + eprintln!("Error parsing XDVDFS volume: {}", e); + process::exit(1); + }); + let layout = img + .layout() + .map(|l| format!("{} (0x{:08X})", l.name, l.offset)) + .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); + println!("Opened in {:?} [layout: {}]", started.elapsed(), layout); + + let walk_started = Instant::now(); + let files = img.walk_files().unwrap_or_else(|e| { + eprintln!("Error walking directory tree: {}", e); + process::exit(1); + }); + println!( + "Walked {} files in {:?}", + files.len(), + walk_started.elapsed() + ); + + let mut total_bytes: u64 = 0; + for f in &files { + total_bytes += f.size; + println!( + " {:48} @0x{:010X} {}", + f.path, + f.offset, + human_size(f.size) + ); + } + println!("Total: {} files, {}", files.len(), human_size(total_bytes)); + + if let Some((on_iso, dest)) = extract { + let entry = files + .iter() + .find(|f| { + f.path == on_iso + || f.path == on_iso.trim_start_matches('/') + || f.path.trim_start_matches('/') == on_iso.trim_start_matches('/') + }) + .unwrap_or_else(|| { + eprintln!( + "No file matching {:?} in the image. Path is image-relative; \ + check the listing above for the exact spelling.", + on_iso + ); + process::exit(1); + }); + + println!(); + println!( + "Extracting {} ({}) → {}", + entry.path, + human_size(entry.size), + dest.display() + ); + + if let Some(parent) = dest.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).unwrap_or_else(|e| { + eprintln!("Error creating destination directory: {}", e); + process::exit(1); + }); + } + + let out = File::create(&dest).unwrap_or_else(|e| { + eprintln!("Error creating {}: {}", dest.display(), e); + process::exit(1); + }); + let mut out = BufWriter::new(out); + + let mut last_pct: u64 = 0; + let mut cb = |read: u64, total: u64| { + let pct = (read * 100).checked_div(total).unwrap_or(100); + if pct >= last_pct + 5 || pct == 100 { + last_pct = pct; + eprint!("\r {}% ({}/{})", pct, human_size(read), human_size(total)); + } + }; + + let extract_started = Instant::now(); + let n = img + .read_into(entry, &mut out, None, Some(&mut cb)) + .unwrap_or_else(|e| { + eprintln!("\nError reading file: {}", e); + process::exit(1); + }); + let elapsed = extract_started.elapsed(); + eprintln!(); + let secs = elapsed.as_secs_f64().max(1e-6); + let throughput = (n as f64) / secs / (1024.0 * 1024.0); + println!( + "Wrote {} in {:?} ({:.1} MiB/s)", + human_size(n), + elapsed, + throughput + ); + } +} diff --git a/fatxlib/src/error.rs b/fatxlib/src/error.rs index 4468536..b2a7e5e 100644 --- a/fatxlib/src/error.rs +++ b/fatxlib/src/error.rs @@ -52,6 +52,9 @@ pub enum FatxError { #[error("No FATX partition found at the expected offset")] NoPartitionFound, + + #[error("{0}")] + Other(String), } pub type Result = std::result::Result; diff --git a/fatxlib/src/lib.rs b/fatxlib/src/lib.rs index fbeb826..2e92796 100644 --- a/fatxlib/src/lib.rs +++ b/fatxlib/src/lib.rs @@ -34,6 +34,7 @@ pub mod stfs; pub mod titles; pub mod types; pub mod volume; +pub mod xiso; pub mod xuids; pub use error::{FatxError, Result}; diff --git a/fatxlib/src/xiso/mod.rs b/fatxlib/src/xiso/mod.rs new file mode 100644 index 0000000..bb4a60b --- /dev/null +++ b/fatxlib/src/xiso/mod.rs @@ -0,0 +1,246 @@ +//! Reader for Xbox XDVDFS (XISO) disc images, wrapping the `xdvdfs` crate +//! with a synchronous façade. +//! +//! `xdvdfs` is normally async via `maybe-async`; we depend on it with the +//! `sync` feature which compiles every `#[maybe_async]` function into its +//! synchronous form. As a result, no async runtime is needed in our +//! dependency tree. +//! +//! ## Smart offset detection +//! +//! Real Xbox 360 disc dumps have a video partition before the XDVDFS data +//! partition, so the volume descriptor isn't at byte 0x10000 of the file — +//! it's at one of four known offsets depending on the disc generation: +//! +//! | Layout | Pre-partition offset | +//! |---|---| +//! | Raw / trimmed XISO | 0 | +//! | XGD1 | 0x18300000 (≈ 387 MiB) | +//! | XGD2 | 0xFD90000 (≈ 254 MiB) | +//! | XGD3 | 0x2080000 (≈ 32 MiB) | +//! +//! [`XisoImage::open`] tries each of these in order and keeps whichever one +//! produces a valid volume descriptor. Reads through this reader are +//! offset-corrected transparently — you never see the pre-partition bytes. +//! +//! ## API shape +//! +//! ```ignore +//! let file = std::fs::File::open("game.iso")?; +//! let mut img = XisoImage::open(file)?; +//! eprintln!("detected pre-partition offset: 0x{:X}", img.partition_offset()); +//! for entry in img.walk_files()? { +//! // entry.path is image-relative +//! // entry.size is the file length in bytes +//! // entry.offset is the byte offset within the data partition +//! // (NOT including the pre-partition padding) +//! } +//! let mut out = std::io::sink(); +//! img.read_into(&entry, &mut out, None, None)?; +//! ``` + +use std::io::{Read, Seek, Write}; + +use xdvdfs::blockdev::BlockDeviceRead; +use xdvdfs::layout::{DirectoryEntryNode, DirectoryEntryTable, VolumeDescriptor}; +use xdvdfs::read; + +use crate::error::{FatxError, Result}; + +/// XDVDFS sector size — every offset/length in the on-disk format is +/// expressed in sectors of this size. +pub const SECTOR_SIZE: u64 = xdvdfs::layout::SECTOR_SIZE as u64; + +/// Default chunk size for [`XisoImage::read_into`] — 1 MiB. +pub const DEFAULT_CHUNK: usize = 1 << 20; + +/// One row of the XGD layout table: a human-readable name and the byte +/// offset where the data partition starts on that layout. +#[derive(Debug, Clone, Copy)] +pub struct XgdLayout { + pub name: &'static str, + pub offset: u64, +} + +/// XGD layouts probed by [`XisoImage::open`], in the order they're tried. +/// This is the **single source of truth** for both the probing logic and +/// any human-facing display — hex literals here, no magic decimals +/// anywhere else. +pub const LAYOUTS: &[XgdLayout] = &[ + XgdLayout { + name: "raw / trimmed XISO", + offset: 0x00000000, + }, // no pre-partition + XgdLayout { + name: "XGD1", + offset: 0x18300000, + }, // ≈ 387 MiB + XgdLayout { + name: "XGD2", + offset: 0x0FD90000, + }, // ≈ 254 MiB + XgdLayout { + name: "XGD3", + offset: 0x02080000, + }, // ≈ 32 MiB +]; + +/// A file in an XDVDFS image. `path` is image-relative with forward slashes; +/// `size` is the file length; `offset` is the byte offset WITHIN the data +/// partition (i.e., it does NOT include the pre-partition padding — +/// [`XisoImage::read_into`] applies the offset automatically). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XisoFile { + pub path: String, + pub size: u64, + pub offset: u64, +} + +/// An opened XDVDFS image. Tracks the detected pre-partition offset so reads +/// are transparently shifted into the data partition. +pub struct XisoImage { + source: R, + volume: VolumeDescriptor, + partition_offset: u64, +} + +impl XisoImage { + /// Open an XDVDFS image. Probes each entry in [`LAYOUTS`] until one + /// yields a valid volume descriptor; that offset is kept and all + /// subsequent reads are translated through it. + pub fn open(mut source: R) -> Result { + for layout in LAYOUTS { + let mut shifted = ShiftedSource { + inner: &mut source, + offset: layout.offset, + }; + if let Ok(volume) = read::read_volume(&mut shifted) { + return Ok(Self { + source, + volume, + partition_offset: layout.offset, + }); + } + } + Err(FatxError::Other(format!( + "xdvdfs: no valid volume descriptor at any known partition offset (tried {} layouts)", + LAYOUTS.len() + ))) + } + + /// Pre-data-partition byte offset detected at open time. `0` for raw + /// XISO; one of the XGD values otherwise. + pub fn partition_offset(&self) -> u64 { + self.partition_offset + } + + /// The [`XgdLayout`] row matching this image's detected offset. + /// Always returns `Some` because [`open`] only succeeds on an offset + /// drawn from [`LAYOUTS`]. + pub fn layout(&self) -> Option<&'static XgdLayout> { + LAYOUTS.iter().find(|l| l.offset == self.partition_offset) + } + + /// Walk the entire directory tree, returning every file (not directories) + /// as a flat list with image-relative paths and data-partition-relative + /// byte offsets. + pub fn walk_files(&mut self) -> Result> { + let mut shifted = ShiftedSource { + inner: &mut self.source, + offset: self.partition_offset, + }; + let mut out = Vec::new(); + let root = self.volume.root_table; + walk_recursive(&mut shifted, &root, String::new(), &mut out)?; + Ok(out) + } + + /// Stream a file's bytes into `dest`. Reads in chunks of `chunk_size` + /// (defaults to [`DEFAULT_CHUNK`]); invokes `progress(read, total)` after + /// each chunk if provided. Returns total bytes written. + pub fn read_into( + &mut self, + file: &XisoFile, + dest: &mut W, + chunk_size: Option, + mut progress: Option<&mut dyn FnMut(u64, u64)>, + ) -> Result { + let chunk = chunk_size.unwrap_or(DEFAULT_CHUNK).max(1); + let mut buf = vec![0u8; chunk]; + let mut written: u64 = 0; + let mut offset = file.offset; + let total = file.size; + + let mut shifted = ShiftedSource { + inner: &mut self.source, + offset: self.partition_offset, + }; + while written < total { + let want = ((total - written) as usize).min(buf.len()); + BlockDeviceRead::::read(&mut shifted, offset, &mut buf[..want]) + .map_err(FatxError::Io)?; + dest.write_all(&buf[..want]).map_err(FatxError::Io)?; + offset += want as u64; + written += want as u64; + if let Some(cb) = progress.as_deref_mut() { + cb(written, total); + } + } + Ok(written) + } +} + +/// Thin block-device adapter that adds a constant offset to every read. +/// Lets us reuse `xdvdfs`'s reader against an arbitrary pre-partition +/// padding without re-implementing the format. +struct ShiftedSource<'a, R: Read + Seek + Send + Sync> { + inner: &'a mut R, + offset: u64, +} + +impl BlockDeviceRead for ShiftedSource<'_, R> { + fn read(&mut self, offset: u64, buffer: &mut [u8]) -> std::io::Result<()> { + BlockDeviceRead::::read(self.inner, offset + self.offset, buffer) + } +} + +/// Recurse through the directory tree. `prefix` is the parent directory path +/// (empty at the root); each entry is prefixed with it to form the full path. +fn walk_recursive( + dev: &mut D, + table: &DirectoryEntryTable, + prefix: String, + out: &mut Vec, +) -> Result<()> +where + D: BlockDeviceRead, +{ + let entries: Vec = table + .walk_dirent_tree(dev) + .map_err(|e| FatxError::Other(format!("xdvdfs: walk failed ({e:?})")))?; + for entry in entries { + let name = entry + .name_str::() + .map_err(|e| FatxError::Other(format!("xdvdfs: bad filename ({e:?})")))?; + let path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + if entry.node.dirent.is_directory() { + if let Some(sub) = entry.node.dirent.dirent_table() { + walk_recursive(dev, &sub, path, out)?; + } + } else { + let size = entry.node.dirent.data.size as u64; + let offset = entry + .node + .dirent + .data + .offset::(0) + .map_err(|e| FatxError::Other(format!("xdvdfs: bad offset ({e:?})")))?; + out.push(XisoFile { path, size, offset }); + } + } + Ok(()) +} diff --git a/fatxlib/tests/fixtures/tiny.xiso b/fatxlib/tests/fixtures/tiny.xiso new file mode 100644 index 0000000000000000000000000000000000000000..fb17b0a0269c634591386e670443035b5ca7d25a GIT binary patch literal 131072 zcmeI$yH3ME5Cza}5U+*`Q7(@vMZ_=glu}rcfFiApH;NUbpx_|$^~R<~bQFosNVCOO zdrvc?4M7ngK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB=CIvdP`k^m+RDGMe2^XQS-lesXi( z!(785K!5-N0t9wK;I~;H!hX2u)1CGmkIOXAH+3^^i_~@95c+aB4njED%Tih8{jDNE zfB*pk1h!pZk?MLC+a{kct0vB?w%M%H(J=HC0RjXF5FoJ20#|*wv%cfu+v>AQ0zVT!zSsF{TCD0atz+@kqz^v;yS(tpMt}eT0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U eAV7cs0RjXF5FkK+009C72oNAZfB=Di5cmcKOx6Ve literal 0 HcmV?d00001 diff --git a/fatxlib/tests/xiso_reader.rs b/fatxlib/tests/xiso_reader.rs new file mode 100644 index 0000000..c2dcebe --- /dev/null +++ b/fatxlib/tests/xiso_reader.rs @@ -0,0 +1,108 @@ +//! Integration tests for the xdvdfs-backed XISO reader. + +use std::fs::File; +use std::io::Cursor; + +use fatxlib::xiso::XisoImage; + +// --------------------------------------------------------------------------- +// Negative paths — always runnable +// --------------------------------------------------------------------------- + +#[test] +fn rejects_obviously_non_xiso_source() { + let buf = vec![0u8; 4096]; + let cursor = Cursor::new(buf); + assert!( + XisoImage::open(cursor).is_err(), + "all-zero buffer should not parse as XDVDFS" + ); +} + +#[test] +fn rejects_too_small_source() { + // The XDVDFS volume descriptor lives at sector 32 (offset 0x10000). + // Anything smaller than that can't possibly be valid. + let buf = vec![0u8; 1024]; + let cursor = Cursor::new(buf); + assert!( + XisoImage::open(cursor).is_err(), + "tiny buffer should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// Positive paths — require a fixture XISO at tests/fixtures/tiny.xiso +// --------------------------------------------------------------------------- + +const FIXTURE: &str = "tests/fixtures/tiny.xiso"; + +fn open_fixture() -> Option> { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(FIXTURE); + if !path.exists() { + eprintln!("skipping: fixture missing at {}", path.display()); + return None; + } + let file = File::open(&path).expect("open fixture"); + Some(XisoImage::open(file).expect("parse fixture")) +} + +#[test] +fn walks_fixture_image() { + let Some(mut img) = open_fixture() else { + return; + }; + let files = img.walk_files().expect("walk"); + assert!( + !files.is_empty(), + "fixture should contain at least one file" + ); + let names: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert!( + names.iter().any(|n| n.ends_with("default.xbe")), + "expected default.xbe in fixture; got {:?}", + names + ); +} + +#[test] +fn streams_a_file_into_buffer() { + let Some(mut img) = open_fixture() else { + return; + }; + let files = img.walk_files().expect("walk"); + let first = files.first().expect("at least one file in fixture"); + + let mut sink = Vec::new(); + let n = img + .read_into(first, &mut sink, Some(64 * 1024), None) + .expect("read_into"); + assert_eq!(n as usize, sink.len()); + assert_eq!(n, first.size); +} + +#[test] +fn streams_invokes_progress_callback() { + let Some(mut img) = open_fixture() else { + return; + }; + let files = img.walk_files().expect("walk"); + let first = files.first().expect("at least one file in fixture"); + + let mut progress_calls: Vec<(u64, u64)> = Vec::new(); + let mut sink = Vec::new(); + { + let mut cb = |read: u64, total: u64| progress_calls.push((read, total)); + img.read_into(first, &mut sink, Some(64), Some(&mut cb)) + .expect("read_into with progress"); + } + if first.size > 0 { + assert!( + !progress_calls.is_empty(), + "progress should fire at least once" + ); + let (last_read, last_total) = *progress_calls.last().unwrap(); + assert_eq!(last_read, first.size); + assert_eq!(last_total, first.size); + } +} From 35e6eb2721cbb86f79943983820f0bf3e9c2f8bb Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sat, 16 May 2026 23:44:22 +1000 Subject: [PATCH 2/2] TUI integration --- CLAUDE.md | 20 +- CONTRIBUTING.md | 20 +- fatxlib/src/volume.rs | 131 ++++++++++++++ fatxlib/src/xiso/mod.rs | 50 +++++ fatxlib/tests/integration.rs | 44 +++++ fatxlib/tests/xiso_reader.rs | 79 ++++++++ src/tui.rs | 342 ++++++++++++++++++++++++++++++++++- 7 files changed, 676 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f08ba9d..37d5db0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,18 @@ cargo test --workspace ``` Library tests in `fatxlib/tests/` exercise the filesystem, title catalog, slot-aware display, STFS parser, and Account decryption. CLI integration tests in `tests/cli_integration.rs` exercise `ls`/`scan`/`mkimage` only. +### fmt + clippy on every change (hard rule) +**Every code change must end with `cargo fmt --all` applied and `cargo clippy --workspace --all-targets -- -D warnings` passing.** Both. Every time. No "I'll fix it in the next commit" — fix it in this one. + +```bash +cargo fmt --all # apply formatting +cargo clippy --workspace --all-targets -- -D warnings # zero tolerance for warnings +``` + +`.githooks/pre-commit` enforces this at the git layer (install with `git config core.hooksPath .githooks`), but the rule applies in the editor, not just at commit time. If clippy flags something, fix it before claiming completion. Don't `#[allow(...)]` to silence a warning without first understanding the lint — real fixes, except for genuinely-misfiring lints (rare). + +Claude should do this automatically after any `Edit` / `Write` to `*.rs` or `Cargo.toml`. No reminders needed. + ### Bug-Driven Testing Rule **Every bug fix MUST include a regression test.** When a bug is found — whether from user reports, logs, or code review — write a test that reproduces the failure BEFORE fixing it, then verify the fix makes it pass. This applies to all crates. No exceptions. Claude should do this automatically without being asked. @@ -71,7 +83,11 @@ cargo run -p fatxlib --example check_profile -- /path/to/profile-file - **Default branch**: `main` - Commit and push at each milestone (working feature, major fix, etc.) +### XISO / disc-image support +- `fatxlib::xiso` wraps `xdvdfs` (sync feature, no async runtime) and exposes `XisoImage::{open, walk_files, read_into, file_reader, read_at}` plus a `LAYOUTS` table for raw / XGD1 / XGD2 / XGD3 pre-partition offsets. +- TUI upload (`u`) sniffs every local file with `XisoImage::open`. On a hit, the user is prompted **Extract contents (Y/n)** — default extracts via `IoCmd::ExtractXiso`, `n` falls back to raw `WriteFile`. Extraction streams each entry through `XisoFileReader` → `FatxVolume::create_file_from_reader`, which keeps the working set at one cluster regardless of image size. +- Useful because Aurora / FreeStyle Dash / XBMC4XBOX scan the drive for loose `default.xex` / `default.xbe` and launch them directly; STFS-wrapped GoD packaging is **not** required for those dashboards. + ## Future Work (Deferred) - Eager / deferred-sync auto-resolve for files inside STFS content-type folders (Marketplace/Arcade/etc.) — currently on-demand only -- `extract-xiso` integration for on-the-fly ISO extraction during copy -- `iso2god`-style ISO → Games-on-Demand conversion (cherry-picked from iso2god-rs, refactored for streaming) +- `iso2god`-style ISO → Games-on-Demand conversion (cherry-picked from iso2god-rs, refactored for streaming) — needed only for Xbox 360 BC, which requires STFS GoD packages; alt-dashboard playback already works via the XISO extract flow above diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9e3d8a..b7b6bd7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,9 +44,23 @@ sudo ./target/release/xtafkit ls /dev/rdiskN --partition "360 Data" / ## Code Style -- Run `cargo fmt` before committing -- Run `cargo clippy` and address warnings -- Follow existing patterns in the codebase +Every commit must pass formatting and clippy with no warnings: + +```bash +cargo fmt --all -- --check # must produce no diff +cargo clippy --workspace --all-targets -- -D warnings # must exit clean +``` + +Install the project's pre-commit hook to enforce this locally (it also runs the test suite): + +```bash +git config core.hooksPath .githooks +``` + +CI runs the same checks; PRs that don't pass will be sent back. Beyond that: + +- Follow existing patterns in the codebase. +- Don't `#[allow(clippy::...)]` to silence a lint without first understanding what it's catching — real fixes only, except for genuinely-misfiring lints (rare). ## License diff --git a/fatxlib/src/volume.rs b/fatxlib/src/volume.rs index 6ceb389..1169290 100644 --- a/fatxlib/src/volume.rs +++ b/fatxlib/src/volume.rs @@ -1307,6 +1307,137 @@ impl FatxVolume { } } + /// Streaming variant of [`create_file`]: allocates a cluster chain for + /// `size` bytes up front, then pulls cluster-sized chunks from `reader` + /// and writes them in order. Lets callers create multi-GiB files + /// without materializing the whole payload in memory — the working set + /// is one cluster (typically 16 KiB). + /// + /// `reader` must produce exactly `size` bytes (the last chunk may be + /// short; it's zero-padded to a full cluster on disk). Premature EOF + /// returns an error and any clusters allocated so far are freed. + /// + /// `progress(written, total)` fires after each cluster write if provided. + /// + /// Strict semantics: if `path` already exists the call fails with + /// [`FatxError::FileExists`]; callers wanting overwrite must delete first + /// or build their own helper analogous to [`create_or_replace_file`]. + pub fn create_file_from_reader( + &mut self, + path: &str, + size: u64, + mut reader: R, + mut progress: Option<&mut dyn FnMut(u64, u64)>, + ) -> Result<()> { + let (parent_path, filename) = split_path(path); + Self::validate_filename(filename)?; + + if size > u32::MAX as u64 { + return Err(FatxError::Io(std::io::Error::other(format!( + "file too large for FATX directory entry ({} bytes > 4 GiB - 1)", + size + )))); + } + + if self.resolve_path(path).is_ok() { + return Err(FatxError::FileExists(path.to_string())); + } + + let parent = self.resolve_path(parent_path)?; + if !parent.attributes.contains(FileAttributes::DIRECTORY) { + return Err(FatxError::NotADirectory(parent_path.to_string())); + } + + let cluster_size = self.superblock.cluster_size() as usize; + let clusters_needed = if size == 0 { + 1 + } else { + (size as usize).div_ceil(cluster_size) + }; + + let first_cluster = self.allocate_chain(clusters_needed)?; + + let result = (|| -> Result<()> { + let chain = self.read_chain(first_cluster)?; + let mut written: u64 = 0; + let mut cluster_buf = vec![0u8; cluster_size]; + + for &cluster in &chain { + let remaining = size - written; + let want = (remaining as usize).min(cluster_size); + if want == 0 { + break; + } + // Fill the buffer with exactly `want` bytes from the reader; + // anything past `want` keeps its zero padding from the alloc. + cluster_buf[..want].fill(0); + let mut filled = 0; + while filled < want { + let n = reader.read(&mut cluster_buf[filled..want]).map_err(|e| { + FatxError::Io(std::io::Error::other(format!("reader error: {e}"))) + })?; + if n == 0 { + return Err(FatxError::Io(std::io::Error::other(format!( + "reader EOF after {} bytes; expected {}", + written + filled as u64, + size + )))); + } + filled += n; + } + if want < cluster_size { + cluster_buf[want..].fill(0); + } + self.write_cluster(cluster, &cluster_buf)?; + written += want as u64; + if let Some(cb) = progress.as_deref_mut() { + cb(written, size); + } + } + + let now = time::OffsetDateTime::now_utc(); + let date = DirectoryEntry::encode_date(now.year() as u16, now.month() as u8, now.day()); + let time = DirectoryEntry::encode_time(now.hour(), now.minute(), now.second()); + + let mut filename_raw = [0xFFu8; MAX_FILENAME_LEN]; + let name_bytes = filename.as_bytes(); + filename_raw[..name_bytes.len()].copy_from_slice(name_bytes); + + let entry = DirectoryEntry { + filename_len: name_bytes.len() as u8, + attributes: FileAttributes::ARCHIVE, + filename_raw, + first_cluster, + file_size: size as u32, + creation_time: time, + creation_date: date, + write_time: time, + write_date: date, + access_time: time, + access_date: date, + }; + + self.add_dirent_to_directory(parent.first_cluster, &entry)?; + Ok(()) + })(); + + if let Err(err) = result { + if let Err(cleanup_err) = self.free_chain(first_cluster) { + warn!( + "create_file_from_reader cleanup for '{}' failed after error {}: {}", + path, err, cleanup_err + ); + } + return Err(err); + } + + info!( + "Streamed file '{}' ({} bytes, {} clusters)", + filename, size, clusters_needed + ); + Ok(()) + } + /// Create a new directory at the specified path. pub fn create_directory(&mut self, path: &str) -> Result<()> { let (parent_path, dirname) = split_path(path); diff --git a/fatxlib/src/xiso/mod.rs b/fatxlib/src/xiso/mod.rs index bb4a60b..1742220 100644 --- a/fatxlib/src/xiso/mod.rs +++ b/fatxlib/src/xiso/mod.rs @@ -188,6 +188,56 @@ impl XisoImage { } Ok(written) } + + /// Read `buf.len()` bytes from a data-partition-relative `offset`. + /// Used by [`XisoFileReader`] so its [`Read`] impl can pull bytes one + /// chunk at a time without holding an internal `&mut R`. + pub fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<()> { + let mut shifted = ShiftedSource { + inner: &mut self.source, + offset: self.partition_offset, + }; + BlockDeviceRead::::read(&mut shifted, offset, buf).map_err(FatxError::Io) + } + + /// Borrow this image as a [`Read`] adapter scoped to a single file. + /// Returned reader is a cursor into `file`'s byte range; reading past EOF + /// returns `Ok(0)`. Useful for piping into APIs that consume a `Read` + /// (e.g. [`crate::volume::FatxVolume::create_file_from_reader`]). + pub fn file_reader<'a>(&'a mut self, file: &XisoFile) -> XisoFileReader<'a, R> { + XisoFileReader { + image: self, + file_offset: file.offset, + bytes_remaining: file.size, + } + } +} + +/// `Read`-compatible cursor over a single [`XisoFile`]. +/// +/// Each call to [`Read::read`] pulls a chunk straight from the underlying +/// image source through [`XisoImage::read_at`]; nothing is buffered above +/// the kernel layer. EOF (`Ok(0)`) is reached after the file's declared +/// `size` bytes have been served. +pub struct XisoFileReader<'a, R: Read + Seek + Send + Sync> { + image: &'a mut XisoImage, + file_offset: u64, + bytes_remaining: u64, +} + +impl Read for XisoFileReader<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.bytes_remaining == 0 || buf.is_empty() { + return Ok(0); + } + let want = (buf.len() as u64).min(self.bytes_remaining) as usize; + self.image + .read_at(self.file_offset, &mut buf[..want]) + .map_err(|e| std::io::Error::other(e.to_string()))?; + self.file_offset += want as u64; + self.bytes_remaining -= want as u64; + Ok(want) + } } /// Thin block-device adapter that adds a constant offset to every read. diff --git a/fatxlib/tests/integration.rs b/fatxlib/tests/integration.rs index 57c732e..f0e5f46 100644 --- a/fatxlib/tests/integration.rs +++ b/fatxlib/tests/integration.rs @@ -195,6 +195,50 @@ fn test_create_and_read_file() { assert_eq!(read_data, test_data); } +#[test] +fn test_create_file_from_reader_roundtrip() { + let (_tmp, mut vol) = common::create_fatx_image(4); + + let payload: Vec = (0..50_000u32).map(|i| (i % 256) as u8).collect(); + let cursor = Cursor::new(payload.clone()); + vol.create_file_from_reader("/streamed.bin", payload.len() as u64, cursor, None) + .expect("stream create"); + + let read = vol.read_file_by_path("/streamed.bin").expect("read back"); + assert_eq!(read.len(), payload.len()); + assert_eq!(read, payload); +} + +#[test] +fn test_create_file_from_reader_premature_eof_frees_chain() { + let (_tmp, mut vol) = common::create_fatx_image(4); + + // Reader has only 100 bytes but we promise 50_000. + let short = vec![0xAAu8; 100]; + let initial_free = vol.stats().expect("stats").free_clusters; + + let result = vol.create_file_from_reader("/short.bin", 50_000, Cursor::new(short), None); + assert!(matches!(result, Err(FatxError::Io(_))), "got {:?}", result); + + // The aborted file should not exist, and the cluster chain we allocated + // for it must be back in the free pool. + assert!(vol.read_file_by_path("/short.bin").is_err()); + let final_free = vol.stats().expect("stats").free_clusters; + assert_eq!( + final_free, initial_free, + "premature-EOF must free every cluster it allocated" + ); +} + +#[test] +fn test_create_file_from_reader_rejects_duplicate() { + let (_tmp, mut vol) = common::create_fatx_image(2); + + vol.create_file("/dup.bin", b"x").expect("create initial"); + let result = vol.create_file_from_reader("/dup.bin", 1, Cursor::new([0u8; 1]), None); + assert!(matches!(result, Err(FatxError::FileExists(_)))); +} + #[test] fn test_create_empty_file() { let (_tmp, mut vol) = common::create_fatx_image(2); diff --git a/fatxlib/tests/xiso_reader.rs b/fatxlib/tests/xiso_reader.rs index c2dcebe..46bee7e 100644 --- a/fatxlib/tests/xiso_reader.rs +++ b/fatxlib/tests/xiso_reader.rs @@ -1,5 +1,7 @@ //! Integration tests for the xdvdfs-backed XISO reader. +mod common; + use std::fs::File; use std::io::Cursor; @@ -81,6 +83,83 @@ fn streams_a_file_into_buffer() { assert_eq!(n, first.size); } +#[test] +fn file_reader_matches_read_into() { + use std::io::Read; + let Some(mut img) = open_fixture() else { + return; + }; + let files = img.walk_files().expect("walk"); + let first = files.first().expect("at least one file in fixture").clone(); + + let mut via_read_into = Vec::new(); + img.read_into(&first, &mut via_read_into, None, None) + .expect("read_into"); + + let mut via_reader = Vec::new(); + img.file_reader(&first) + .read_to_end(&mut via_reader) + .expect("read_to_end"); + + assert_eq!(via_reader.len() as u64, first.size); + assert_eq!( + via_reader, via_read_into, + "file_reader output must match read_into byte for byte" + ); +} + +#[test] +fn extract_fixture_into_fatx_volume() { + let Some(mut img) = open_fixture() else { + return; + }; + let files = img.walk_files().expect("walk"); + assert!(!files.is_empty(), "fixture must have at least one file"); + + let (_tmp, mut vol) = common::create_fatx_image(4); + + for f in &files { + // Create parent directories as needed (e.g. /Media/). + let fatx_path = if f.path.starts_with('/') { + f.path.clone() + } else { + format!("/{}", f.path) + }; + if let Some(slash) = fatx_path.rfind('/') + && slash > 0 + { + let parent = &fatx_path[..slash]; + // create_directory is strict on existence; ignore "already exists" + // because we may share parents across siblings. + match vol.create_directory(parent) { + Ok(()) | Err(fatxlib::error::FatxError::FileExists(_)) => {} + Err(e) => panic!("mkdir {parent}: {e}"), + } + } + + let reader = img.file_reader(f); + vol.create_file_from_reader(&fatx_path, f.size, reader, None) + .unwrap_or_else(|e| panic!("stream {} -> {}: {}", f.path, fatx_path, e)); + } + + // Verify every extracted file matches what read_into produces. + for f in &files { + let fatx_path = if f.path.starts_with('/') { + f.path.clone() + } else { + format!("/{}", f.path) + }; + let mut expected = Vec::new(); + img.read_into(f, &mut expected, None, None) + .expect("read expected"); + let got = vol.read_file_by_path(&fatx_path).expect("read fatx"); + assert_eq!( + got, expected, + "extracted {fatx_path} does not match XISO source" + ); + } +} + #[test] fn streams_invokes_progress_callback() { let Some(mut img) = open_fixture() else { diff --git a/src/tui.rs b/src/tui.rs index 6050c1e..d235607 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -14,7 +14,11 @@ //! Enter / → Open directory / show file info //! Backspace / ← Go up one directory //! d Download selected file to local disk -//! u Upload a local file or directory into current directory +//! u Upload a local file or directory into current directory. +//! If the file parses as an XDVDFS/XISO disc image, the +//! TUI asks whether to extract the contents into a new +//! subfolder (preferred for alt dashboards) or copy the +//! raw bytes as-is. //! m Create new directory (mkdir) //! D Delete selected file/directory //! r Rename selected file/directory @@ -56,6 +60,7 @@ use ratatui::{ use fatxlib::partition::format_size; use fatxlib::types::FileAttributes; use fatxlib::volume::FatxVolume; +use fatxlib::xiso::XisoImage; // =========================================================================== // Display types @@ -133,6 +138,13 @@ enum IoCmd { local_path: PathBuf, fatx_dest: String, }, + /// Open `source` as an XDVDFS image and stream every file inside it into + /// `dest_dir` on the FATX volume, recreating the directory tree. The dest + /// directory itself is created by the worker; it must not already exist. + ExtractXiso { + source: PathBuf, + dest_dir: String, + }, Mkdir { path: String, }, @@ -211,6 +223,9 @@ enum InputMode { RenameName, ConfirmDelete, ConfirmCleanup, + /// Confirmation prompt after detecting an XISO during upload — y extracts + /// the contents, n falls back to a raw byte copy. + ConfirmExtractXiso, } struct App { @@ -232,6 +247,9 @@ struct App { cancel_flag: Arc, /// Pending cleanup paths awaiting user confirmation. pending_cleanup: Vec<(String, bool, u64)>, + /// Local XISO path stashed between the upload prompt and the + /// "extract or raw copy?" confirmation prompt. + pending_xiso_upload: Option, /// Current listing sort order. Toggleable with `s`. sort_mode: SortMode, } @@ -254,6 +272,30 @@ fn unescape_path(s: &str) -> String { result } +/// Sniff whether `path` looks like an Xbox XDVDFS disc image by trying to +/// parse a volume descriptor at one of the known XGD layout offsets. +/// Cheap — only reads a handful of sectors near the volume descriptor. +fn is_xiso(path: &std::path::Path) -> bool { + match std::fs::File::open(path) { + Ok(file) => XisoImage::open(file).is_ok(), + Err(_) => false, + } +} + +/// Image-relative paths we always strip when extracting an XISO. Currently +/// just `$SystemUpdate/*` — dashboard update payload that alt dashboards +/// never launch and that wastes tens to hundreds of MiB on the destination +/// drive. `image_path` is expected to use forward slashes; the match is +/// case-insensitive against the first segment. +fn is_xiso_junk(image_path: &str) -> bool { + let first = image_path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or(""); + first.eq_ignore_ascii_case("$SystemUpdate") +} + fn dirs_or_home() -> PathBuf { std::env::var("HOME") .map(PathBuf::from) @@ -278,6 +320,7 @@ impl App { is_busy: false, cancel_flag, pending_cleanup: Vec::new(), + pending_xiso_upload: None, sort_mode: SortMode::ByName, } } @@ -582,6 +625,183 @@ fn io_worker( } } + IoCmd::ExtractXiso { source, dest_dir } => { + cancel_flag.store(false, Ordering::Relaxed); + + let display_source = source + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| source.display().to_string()); + + let file = match fs::File::open(&source) { + Ok(f) => f, + Err(e) => { + let _ = resp_tx.send(IoResp::Error { + message: format!("Open {}: {}", source.display(), e), + }); + continue; + } + }; + let mut img = match XisoImage::open(file) { + Ok(img) => img, + Err(e) => { + let _ = resp_tx.send(IoResp::Error { + message: format!("Parse {}: {}", source.display(), e), + }); + continue; + } + }; + let entries = match img.walk_files() { + Ok(v) => v, + Err(e) => { + let _ = resp_tx.send(IoResp::Error { + message: format!("Walk {}: {}", source.display(), e), + }); + continue; + } + }; + + // Materialize the destination directory itself before any files. + if let Err(e) = vol.create_directory(&dest_dir) { + let _ = resp_tx.send(IoResp::Error { + message: format!("Create '{}': {}", dest_dir, e), + }); + continue; + } + + // Xbox 360 disc images carry a `$SystemUpdate` folder with + // the dashboard update payload. Alt dashboards never run it, + // and copying it just wastes hundreds of MiB of FATX space — + // so partition them out before counting totals so the per-file + // progress denominator reflects what we'll actually write. + let (kept, skipped): ( + Vec<&fatxlib::xiso::XisoFile>, + Vec<&fatxlib::xiso::XisoFile>, + ) = entries + .iter() + .partition(|e| !is_xiso_junk(&e.path.replace('\\', "/"))); + let total_files = kept.len(); + let total_bytes: u64 = kept.iter().map(|f| f.size).sum(); + let skipped_files = skipped.len(); + let skipped_bytes: u64 = skipped.iter().map(|f| f.size).sum(); + + let mut files_done = 0usize; + let mut bytes_done: u64 = 0; + let mut files_since_flush = 0usize; + let mut bytes_since_flush = 0u64; + let mut cancelled = false; + let mut failed = false; + + for entry in &kept { + if cancel_flag.load(Ordering::Relaxed) { + cancelled = true; + break; + } + + // Compose the FATX path; entry.path is image-relative + // (no leading slash). Normalize to forward slashes — they + // already are, but be defensive. + let normalized = entry.path.replace('\\', "/"); + let fatx_path = format!("{}/{}", dest_dir.trim_end_matches('/'), normalized); + + // Ensure every parent directory exists. + if let Some(parent_end) = fatx_path.rfind('/') + && parent_end > 0 + { + let parent = &fatx_path[..parent_end]; + if let Err(e) = ensure_dir_chain(&mut vol, parent) { + let _ = resp_tx.send(IoResp::Error { + message: format!("Create dir '{}': {}", parent, e), + }); + failed = true; + break; + } + } + + files_done += 1; + let short_name = normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_string(); + let _ = resp_tx.send(IoResp::Progress { + message: format!( + "[{}/{}] {} ({}) — {}/{}", + files_done, + total_files, + short_name, + format_size(entry.size), + format_size(bytes_done), + format_size(total_bytes), + ), + }); + + let reader = img.file_reader(entry); + match vol.create_file_from_reader(&fatx_path, entry.size, reader, None) { + Ok(()) => { + bytes_done += entry.size; + files_since_flush += 1; + bytes_since_flush += entry.size; + } + Err(e) => { + let _ = resp_tx.send(IoResp::Error { + message: format!("{}: {}", fatx_path, e), + }); + failed = true; + break; + } + } + + // Flush periodically so a long extract survives a yank. + if files_since_flush >= 100 || bytes_since_flush >= 256 * 1024 * 1024 { + if let Err(e) = vol.flush() { + let _ = resp_tx.send(IoResp::Error { + message: format!("Periodic flush failed: {}", e), + }); + failed = true; + break; + } + files_since_flush = 0; + bytes_since_flush = 0; + } + } + + let _ = vol.flush(); + + if cancelled { + let _ = resp_tx.send(IoResp::Cancelled { + message: format!( + "Extract cancelled — {}/{} files written ({})", + files_done.saturating_sub(1), + total_files, + format_size(bytes_done) + ), + }); + } else if failed { + // Error message already sent above; nothing else to do. + } else { + let skipped_note = if skipped_files > 0 { + format!( + "; skipped $SystemUpdate ({} files, {})", + skipped_files, + format_size(skipped_bytes) + ) + } else { + String::new() + }; + let _ = resp_tx.send(IoResp::Done { + message: format!( + "Extracted {} → {} ({} files, {}{})", + display_source, + dest_dir, + files_done, + format_size(bytes_done), + skipped_note, + ), + }); + } + } + IoCmd::Mkdir { path } => match vol.create_directory(&path) { Ok(_) => { let _ = vol.flush(); @@ -767,6 +987,40 @@ fn collect_files(local_dir: &PathBuf, fatx_dir: &str, out: &mut Vec<(PathBuf, St } } +/// Ensure every directory segment in an absolute FATX path exists, creating +/// any that are missing. Used by [`IoCmd::ExtractXiso`] so an XISO with +/// nested subfolders (e.g. `/Halo/Media/movie.bik`) can drop files anywhere +/// without the caller pre-walking the tree. A pre-existing segment that +/// happens to be a regular file is reported as an error. +fn ensure_dir_chain( + vol: &mut FatxVolume, + fatx_dir: &str, +) -> fatxlib::error::Result<()> { + use fatxlib::error::FatxError; + + let trimmed = fatx_dir.trim_end_matches('/'); + if trimmed.is_empty() { + return Ok(()); + } + let parts: Vec<&str> = trimmed.split('/').filter(|p| !p.is_empty()).collect(); + let mut acc = String::new(); + for part in parts { + acc.push('/'); + acc.push_str(part); + match vol.create_directory(&acc) { + Ok(()) => {} + Err(FatxError::FileExists(_)) => { + let existing = vol.resolve_path(&acc)?; + if !existing.is_directory() { + return Err(FatxError::NotADirectory(acc.clone())); + } + } + Err(e) => return Err(e), + } + } + Ok(()) +} + /// Recursively create directory structure on the FATX volume. fn create_dirs_recursive(vol: &mut FatxVolume, local_dir: &PathBuf, fatx_dir: &str) { match vol.create_directory(fatx_dir) { @@ -1174,6 +1428,9 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) match key.code { KeyCode::Esc => { app.input_mode = InputMode::Normal; + // If the user was answering the XISO extract/raw prompt, drop the + // stashed path so the next upload starts clean. + app.pending_xiso_upload = None; app.set_status("Cancelled."); } KeyCode::Enter => { @@ -1197,8 +1454,10 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) }); app.is_busy = true; } else if app.input_prompt.starts_with("Upload ") { - // Upload file or directory — unescape shell backslashes (e.g. Call\ of\ Duty) - let path = PathBuf::from(unescape_path(&input)); + // Upload file or directory — unescape shell backslashes + // (e.g. Call\ of\ Duty) and trim leading/trailing whitespace + // (drag-and-drop into the terminal often appends a space). + let path = PathBuf::from(unescape_path(input.trim())); if !path.exists() { app.set_error(&format!("Not found: {}", input)); return; @@ -1215,6 +1474,18 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) local_path: path.clone(), fatx_dest, }); + app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + app.is_busy = true; + } else if is_xiso(&path) { + // Detected an Xbox disc image. Ask the user whether to + // extract the contents (preferred — alt dashboards launch + // loose game files directly) or fall back to raw copy. + app.pending_xiso_upload = Some(path.clone()); + app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + app.input_mode = InputMode::ConfirmExtractXiso; + app.input_prompt = + format!("Detected XISO '{}'. Extract contents? (Y/n):", filename); + app.input_buffer.clear(); } else { let fatx_path = app.full_path(&filename); app.set_status(&format!("Uploading '{}'...", filename)); @@ -1222,9 +1493,49 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) local_path: path.clone(), fatx_path, }); + app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); + app.is_busy = true; + } + } else if app.input_prompt.starts_with("Detected XISO") { + // Y / empty (default) → extract; N → fall back to raw byte copy. + let extract = input.is_empty() + || input.eq_ignore_ascii_case("y") + || input.eq_ignore_ascii_case("yes"); + let path = match app.pending_xiso_upload.take() { + Some(p) => p, + None => { + app.set_error("Internal: missing pending XISO path."); + return; + } + }; + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "iso".to_string()); + + if extract { + // Default subfolder name = file stem (no extension); fall + // back to the whole filename if the path has no extension. + let stem = path + .file_stem() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.clone()); + let dest_dir = app.full_path(&stem); + app.set_status(&format!("Extracting '{}' → {}...", filename, dest_dir)); + let _ = cmd_tx.send(IoCmd::ExtractXiso { + source: path, + dest_dir, + }); + app.is_busy = true; + } else { + let fatx_path = app.full_path(&filename); + app.set_status(&format!("Uploading '{}' (raw)...", filename)); + let _ = cmd_tx.send(IoCmd::WriteFile { + local_path: path, + fatx_path, + }); + app.is_busy = true; } - app.download_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); - app.is_busy = true; } else if app.input_prompt.starts_with("New directory") { // Mkdir if !input.is_empty() { @@ -1283,6 +1594,7 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) KeyCode::Char(c) => { if app.input_mode == InputMode::ConfirmDelete || app.input_mode == InputMode::ConfirmCleanup + || app.input_mode == InputMode::ConfirmExtractXiso { app.input_buffer = c.to_string(); } else { @@ -1459,4 +1771,24 @@ mod tests { fn test_unescape_empty() { assert_eq!(unescape_path(""), ""); } + + #[test] + fn test_is_xiso_junk_systemupdate() { + assert!(is_xiso_junk("$SystemUpdate")); + assert!(is_xiso_junk("$SystemUpdate/su20076000_00000000")); + assert!(is_xiso_junk("/$SystemUpdate/anything")); + } + + #[test] + fn test_is_xiso_junk_case_insensitive() { + assert!(is_xiso_junk("$SYSTEMUPDATE/foo")); + assert!(is_xiso_junk("$systemupdate/foo")); + } + + #[test] + fn test_is_xiso_junk_does_not_match_substring() { + assert!(!is_xiso_junk("default.xbe")); + assert!(!is_xiso_junk("Media/$SystemUpdate")); // not the first segment + assert!(!is_xiso_junk("MyGame$SystemUpdate/foo")); + } }