From 61604ae913938e8623b85a26f41ff71151535adc Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 00:59:08 +1000 Subject: [PATCH 01/12] vendor in and initial support for god2iso on the fly --- Cargo.lock | 78 +++++++ NOTICE | 59 ++++++ fatxlib/Cargo.toml | 2 + fatxlib/examples/iso2god.rs | 87 ++++++++ fatxlib/src/iso2god/convert.rs | 273 +++++++++++++++++++++++++ fatxlib/src/iso2god/executable/mod.rs | 107 ++++++++++ fatxlib/src/iso2god/executable/xbe.rs | 57 ++++++ fatxlib/src/iso2god/executable/xex.rs | 142 +++++++++++++ fatxlib/src/iso2god/god/con_header.rs | 122 +++++++++++ fatxlib/src/iso2god/god/empty_live.bin | Bin 0 -> 45056 bytes fatxlib/src/iso2god/god/file_layout.rs | 62 ++++++ fatxlib/src/iso2god/god/gdf_sector.rs | 132 ++++++++++++ fatxlib/src/iso2god/god/hash_list.rs | 60 ++++++ fatxlib/src/iso2god/god/mod.rs | 79 +++++++ fatxlib/src/iso2god/mod.rs | 24 +++ fatxlib/src/lib.rs | 1 + fatxlib/tests/fixtures/tiny.xiso | Bin 131072 -> 458752 bytes fatxlib/tests/iso2god_roundtrip.rs | 154 ++++++++++++++ fatxlib/tests/xiso_reader.rs | 4 +- 19 files changed, 1441 insertions(+), 2 deletions(-) create mode 100644 fatxlib/examples/iso2god.rs create mode 100644 fatxlib/src/iso2god/convert.rs create mode 100644 fatxlib/src/iso2god/executable/mod.rs create mode 100644 fatxlib/src/iso2god/executable/xbe.rs create mode 100644 fatxlib/src/iso2god/executable/xex.rs create mode 100644 fatxlib/src/iso2god/god/con_header.rs create mode 100644 fatxlib/src/iso2god/god/empty_live.bin create mode 100644 fatxlib/src/iso2god/god/file_layout.rs create mode 100644 fatxlib/src/iso2god/god/gdf_sector.rs create mode 100644 fatxlib/src/iso2god/god/hash_list.rs create mode 100644 fatxlib/src/iso2god/god/mod.rs create mode 100644 fatxlib/src/iso2god/mod.rs create mode 100644 fatxlib/tests/iso2god_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 53540e7..c639e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "castaway" version = "0.2.4" @@ -643,10 +649,12 @@ name = "fatxlib" version = "1.1.0" dependencies = [ "bitflags 2.11.1", + "byteorder", "hmac", "libc", "log", "nix 0.31.3", + "num_enum", "phf 0.13.1", "phf_codegen 0.13.1", "proptest", @@ -1168,6 +1176,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1421,6 +1451,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2058,6 +2097,36 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -2424,6 +2493,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/NOTICE b/NOTICE index f84c12a..c3ef958 100644 --- a/NOTICE +++ b/NOTICE @@ -16,3 +16,62 @@ Licensed under the Apache License, Version 2.0. xtafkit and the upstream fatx-rs are both licensed under the Apache License, Version 2.0. See LICENSE for the full license text. + +Third-party code vendored under Apache-2.0-compatible licenses: + +iso2god (in fatxlib/src/iso2god/) +--------------------------------- +Vendored from QAston/iso2god-rs (xdvdfx branch), itself a fork of +iliazeus/iso2god-rs. Both are MIT-licensed: + + Copyright (c) 2023 Ilia Pozdnyakov + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Upstream sources: + https://github.com/iliazeus/iso2god-rs (parent) + https://github.com/QAston/iso2god-rs/tree/xdvdfx (vendored branch) + +Local adaptations made when vendoring: + - anyhow::Error replaced with fatxlib's FatxError so callers see one + error type across the library. + - Intra-crate imports rewritten from `crate::god` / `crate::executable` + to `crate::iso2god::god` / `crate::iso2god::executable`. + - The upstream `src/game_list/` (~5 KLOC compiled-in title catalog) + was NOT vendored; fatxlib's existing `titles` module covers the + same purpose with broader data. + +XellLaunch2_retail.xex (in fatxlib/tests/fixtures/tiny.xiso) +----------------------------------------------------------- +XellLaunch is a public homebrew launcher from the Free60.org project +that boots `xell-2f.bin` (the XeLL bootloader) from a CON / LIVE +container. We embed `XellLaunch2_retail.xex` inside the `tiny.xiso` +test fixture so the round-trip and walk tests can exercise our XEX +parser and full ISO → GoD pipeline against a real Xbox 360 executable +without shipping a copyrighted game. The XEX is consumed as a data +file for testing only; it is not linked into the xtafkit binary. + +Upstream: + https://free60project.github.io/wiki/XeLL.html + +XellLaunch carries the Title ID 0xFFFF011D (homebrew / dev range). +If you have license concerns about this fixture, delete +`fatxlib/tests/fixtures/tiny.xiso` and the dependent tests will +gracefully skip. diff --git a/fatxlib/Cargo.toml b/fatxlib/Cargo.toml index dbefc24..d115928 100644 --- a/fatxlib/Cargo.toml +++ b/fatxlib/Cargo.toml @@ -16,6 +16,8 @@ libc = "0.2" phf = "0.13" hmac = "0.13" sha1 = "0.11" +byteorder = "1.5" +num_enum = "0.7" xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "sync"] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/fatxlib/examples/iso2god.rs b/fatxlib/examples/iso2god.rs new file mode 100644 index 0000000..0459e15 --- /dev/null +++ b/fatxlib/examples/iso2god.rs @@ -0,0 +1,87 @@ +//! Minimal CLI wrapper around [`fatxlib::iso2god::convert_iso`]. +//! +//! Mirrors the surface of upstream's `iso2god` binary so the Plan C bench +//! harness can run our build in apples-to-apples fashion. Argument shape: +//! +//! ```text +//! iso2god [--trim] [--dry-run] [--game-title TITLE] +//! ``` +//! +//! Differences vs upstream: +//! - `--trim` is a flag without an argument (matches upstream `--trim`, +//! defaults to no trim if absent; pass to enable from-end trim). +//! - We don't expose `-j N` because `convert_iso` is single-threaded for now. + +use std::env; +use std::path::PathBuf; +use std::process; +use std::time::Instant; + +use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso}; + +fn usage_and_exit() -> ! { + eprintln!("usage: iso2god [--trim] [--dry-run] [--game-title TITLE] "); + process::exit(2); +} + +fn main() { + let mut args = env::args().skip(1); + let mut trim = TrimMode::None; + let mut dry_run = false; + let mut game_title: Option = None; + let mut positional: Vec = Vec::new(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--trim" => trim = TrimMode::FromEnd, + "--dry-run" => dry_run = true, + "--game-title" => { + game_title = Some(args.next().unwrap_or_else(|| usage_and_exit())); + } + "-h" | "--help" => usage_and_exit(), + _ => positional.push(arg), + } + } + + if positional.len() != 2 { + usage_and_exit(); + } + let source = PathBuf::from(&positional[0]); + let dest = PathBuf::from(&positional[1]); + + let started = Instant::now(); + let mut last_stage = String::new(); + let mut progress_cb = |stage: &str, current: u64, total: u64| { + if stage != last_stage { + eprintln!("[{stage}] {current}/{total}"); + last_stage = stage.to_string(); + } else if total > 0 && (current == total || current.is_multiple_of(total.max(1) / 10 + 1)) { + eprintln!("[{stage}] {current}/{total}"); + } + }; + + let mut opts = ConvertOptions { + trim, + game_title: game_title.as_deref(), + dry_run, + progress: Some(&mut progress_cb), + }; + + match convert_iso(&source, &dest, &mut opts) { + Ok(report) => { + let elapsed = started.elapsed(); + eprintln!(); + eprintln!("Title ID: {:08X}", report.title_id); + eprintln!("Media ID: {:08X}", report.media_id); + eprintln!("Content: {:?}", report.content_type); + eprintln!("Block count: {}", report.block_count); + eprintln!("Part count: {}", report.part_count); + eprintln!("Data size: {} bytes", report.data_size); + eprintln!("Elapsed: {:?}", elapsed); + } + Err(e) => { + eprintln!("convert_iso failed: {e}"); + process::exit(1); + } + } +} diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs new file mode 100644 index 0000000..3f6253c --- /dev/null +++ b/fatxlib/src/iso2god/convert.rs @@ -0,0 +1,273 @@ +//! Public entry point for ISO → Games-on-Demand conversion. +//! +//! Mirrors the flow of QAston/iso2god-rs `xdvdfx`'s `src/bin/iso2god.rs::main`, +//! translated onto fatxlib's error type and with a 1 MiB BufReader wrapping +//! the source (Plan C finding: upstream's default 8 KiB buffer leaves ~5 s of +//! avoidable I/O wait per 8.7 GiB ISO). +//! +//! Single-threaded for now — upstream uses rayon for parallelism, but the +//! Plan C bench ran `-j 1` and `convert_iso` matches that for apples-to-apples +//! re-measurement. Parallel mode can land later as an opt-in flag. + +use std::fs::{self, File}; +use std::io::{BufReader, Seek, SeekFrom, Write}; +use std::path::Path; + +use crate::error::{FatxError, Result}; +use crate::iso2god::executable::TitleInfo; +use crate::iso2god::god::{self, ConHeaderBuilder, ContentType, FileLayout, HashList}; + +/// Buffer capacity for the source-ISO reader. 1 MiB — Plan C measured that +/// upstream's default-cap (8 KiB) `BufReader` leaves ~5 s of avoidable I/O +/// wait per 8.7 GiB ISO; pushing the buffer up to a megabyte reclaims most +/// of it without OS-level read-ahead tuning. +pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; + +/// Progress callback shape: `(stage, current, total)` where `stage` is one +/// of `"parts"`, `"mht"`, `"header"`. +pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64, u64); + +/// How to size the output GoD relative to the source ISO. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TrimMode { + /// Walk the directory tree, find the max `(offset + size)`, and pack + /// only that many bytes. This is upstream's default and matches the + /// Python port. + #[default] + FromEnd, + /// Pack every byte from the start of the data partition to the end of + /// the source file. Larger output, but useful when the directory tree + /// is suspect. + None, +} + +/// Knobs the caller can adjust per conversion. +#[derive(Default)] +pub struct ConvertOptions<'a> { + pub trim: TrimMode, + /// Override the human-readable game title written into the CON header. + /// `None` leaves the slot blank — fatxlib's [`crate::titles`] catalog is + /// not consulted here; callers that want auto-fill should resolve the + /// title ID themselves and pass the result through. + pub game_title: Option<&'a str>, + /// When true, read metadata and return the [`ConvertReport`] without + /// touching `dest_dir`. + pub dry_run: bool, + /// Optional progress callback. Stages: "scan", "parts", "mht", "header". + /// `current`/`total` are stage-relative. + pub progress: Option>, +} + +/// Metadata extracted from the source ISO and the resulting layout sizing. +#[derive(Debug, Clone, Copy)] +pub struct ConvertReport { + pub title_id: u32, + pub media_id: u32, + pub content_type: ContentType, + pub part_count: u64, + pub block_count: u64, + /// Bytes of the source partition packed into the GoD parts (post-trim). + pub data_size: u64, +} + +/// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. +/// +/// Writes: +/// - `///.data/Data0000..DataN` +/// - `///` (CON header) +/// +/// Returns a [`ConvertReport`] describing what was produced (or what *would* +/// have been, when `opts.dry_run` is set). +pub fn convert_iso( + source_iso: &Path, + dest_dir: &Path, + opts: &mut ConvertOptions<'_>, +) -> Result { + let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; + + let img = File::open(source_iso).map_err(FatxError::Io)?; + let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); + let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; + + let volume = xdvdfs::read::read_volume(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; + + let title_info = TitleInfo::from_image(&mut xiso, volume)?; + let exe_info = title_info.execution_info; + let content_type = title_info.content_type; + + // Pull the partition offset out from the wrapper — upstream calls this + // "root_offset" and uses it as the per-part `seek` target. + let root_offset = { + xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; + xiso.get_mut().stream_position().map_err(FatxError::Io)? + }; + + let data_size = match opts.trim { + TrimMode::FromEnd => volume + .root_table + .file_tree(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? + .iter() + .map(|dirent| { + if dirent.1.node.dirent.data.is_empty() { + return 0; + } + let offset = dirent + .1 + .node + .dirent + .data + .offset::(0) + .unwrap_or(0); + offset + dirent.1.node.dirent.data.size() as u64 + }) + .max() + .unwrap_or(0), + TrimMode::None => source_iso_file_meta.len() - root_offset, + }; + + let block_count = data_size.div_ceil(god::BLOCK_SIZE); + let part_count = block_count.div_ceil(god::BLOCKS_PER_PART); + + let report = ConvertReport { + title_id: exe_info.title_id, + media_id: exe_info.media_id, + content_type, + part_count, + block_count, + data_size, + }; + + if opts.dry_run { + return Ok(report); + } + + let file_layout = FileLayout::new(dest_dir, &exe_info, content_type); + + ensure_empty_dir(&file_layout.data_dir_path())?; + + // ---- Write the Data parts (sequential, matches `-j 1` upstream) ----- + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", 0, part_count); + } + + for part_index in 0..part_count { + let part_path = file_layout.part_file_path(part_index); + let part_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(&part_path) + .map_err(FatxError::Io)?; + + // Fresh source reader per part so we can `seek_relative` from a known + // starting point (root_offset). Buffered for the same I/O reasons as + // the metadata read above. + let mut iso_data_volume = BufReader::with_capacity( + SOURCE_BUFFER_SIZE, + File::open(source_iso).map_err(FatxError::Io)?, + ); + iso_data_volume + .seek(SeekFrom::Start(root_offset)) + .map_err(FatxError::Io)?; + + god::write_part(iso_data_volume, part_index, part_file)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", part_index + 1, part_count); + } + } + + // ---- MHT hash chain (last part → first; in-place update) ------------ + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", 0, part_count); + } + + let mut mht = read_part_mht(&file_layout, part_count - 1)?; + for prev_part_index in (0..part_count - 1).rev() { + let mut prev_mht = read_part_mht(&file_layout, prev_part_index)?; + prev_mht.add_hash(&mht.digest()); + write_part_mht(&file_layout, prev_part_index, &prev_mht)?; + mht = prev_mht; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", part_count - prev_part_index, part_count); + } + } + + let last_part_size = fs::metadata(file_layout.part_file_path(part_count - 1)) + .map_err(FatxError::Io)? + .len(); + + // ---- CON header (final step) ---------------------------------------- + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 0, 1); + } + + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&exe_info) + .with_block_counts(block_count as u32, 0) + .with_data_parts_info( + part_count as u32, + last_part_size + (part_count - 1) * god::BLOCK_SIZE * 0xa290, + ) + .with_content_type(content_type) + .with_mht_hash(&mht.digest()); + + if let Some(game_title) = opts.game_title { + con_header = con_header.with_game_title(game_title); + } + + let con_header = con_header.finalize(); + + let mut con_header_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(file_layout.con_header_file_path()) + .map_err(FatxError::Io)?; + + con_header_file + .write_all(&con_header) + .map_err(FatxError::Io)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 1, 1); + } + + Ok(report) +} + +// --- internal helpers -------------------------------------------------- + +fn ensure_empty_dir(path: &Path) -> Result<()> { + if fs::exists(path).map_err(FatxError::Io)? { + fs::remove_dir_all(path).map_err(FatxError::Io)?; + } + fs::create_dir_all(path).map_err(FatxError::Io)?; + Ok(()) +} + +fn read_part_mht(file_layout: &FileLayout, part_index: u64) -> Result { + let part_file = file_layout.part_file_path(part_index); + let mut part_file = File::options() + .read(true) + .open(part_file) + .map_err(FatxError::Io)?; + HashList::read(&mut part_file) +} + +fn write_part_mht(file_layout: &FileLayout, part_index: u64, mht: &HashList) -> Result<()> { + let part_file = file_layout.part_file_path(part_index); + let mut part_file = File::options() + .write(true) + .open(part_file) + .map_err(FatxError::Io)?; + mht.write(&mut part_file)?; + Ok(()) +} diff --git a/fatxlib/src/iso2god/executable/mod.rs b/fatxlib/src/iso2god/executable/mod.rs new file mode 100644 index 0000000..0066467 --- /dev/null +++ b/fatxlib/src/iso2god/executable/mod.rs @@ -0,0 +1,107 @@ +use crate::error::{FatxError, Result}; +use crate::iso2god::god::ContentType; +use byteorder::{BE, LE, ReadBytesExt}; +use std::io::{Read, Seek, SeekFrom}; +use xdvdfs::{blockdev::BlockDeviceRead, layout::VolumeDescriptor}; + +pub mod xbe; +pub mod xex; + +#[derive(Clone, Debug)] +pub struct TitleExecutionInfo { + pub media_id: u32, + pub version: u32, + pub base_version: u32, + pub title_id: u32, + pub platform: u8, + pub executable_type: u8, + pub disc_number: u8, + pub disc_count: u8, +} + +pub struct TitleInfo { + pub content_type: ContentType, + pub execution_info: TitleExecutionInfo, +} + +impl TitleExecutionInfo { + pub fn from_xex(mut reader: R) -> Result { + Ok(TitleExecutionInfo { + media_id: reader.read_u32::().map_err(FatxError::Io)?, + version: reader.read_u32::().map_err(FatxError::Io)?, + base_version: reader.read_u32::().map_err(FatxError::Io)?, + title_id: reader.read_u32::().map_err(FatxError::Io)?, + platform: reader.read_u8().map_err(FatxError::Io)?, + executable_type: reader.read_u8().map_err(FatxError::Io)?, + disc_number: reader.read_u8().map_err(FatxError::Io)?, + disc_count: reader.read_u8().map_err(FatxError::Io)?, + }) + } + + pub fn from_xbe(mut reader: R) -> Result { + reader.seek(SeekFrom::Current(8)).map_err(FatxError::Io)?; + let title_id = reader.read_u32::().map_err(FatxError::Io)?; + + reader.seek(SeekFrom::Current(164)).map_err(FatxError::Io)?; + let version = reader.read_u32::().map_err(FatxError::Io)?; + + Ok(TitleExecutionInfo { + media_id: 0, + version, + base_version: 0, + title_id, + platform: 0, + executable_type: 0, + disc_number: 1, + disc_count: 1, + }) + } +} + +impl TitleInfo { + pub fn from_image + Seek, E: std::fmt::Debug>( + xiso: &mut R, + volume: VolumeDescriptor, + ) -> Result { + if let Ok(direntnode) = volume.root_table.walk_path(xiso, "Default.xex") { + let mut data = direntnode + .node + .dirent + .read_data_all(xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs read Default.xex: {e:?}")))?; + let mut data_slice = std::io::Cursor::new(data.as_mut()); + + let default_xex_header = xex::XexHeader::read(&mut data_slice) + .map_err(|e| FatxError::Other(format!("error reading default.xex: {e}")))?; + let execution_info = default_xex_header.fields.execution_info.ok_or_else(|| { + FatxError::Other("no execution info in default.xex header".to_string()) + })?; + + Ok(TitleInfo { + content_type: ContentType::GamesOnDemand, + execution_info, + }) + } else if let Ok(direntnode) = volume.root_table.walk_path(xiso, "default.xbe") { + let mut data = direntnode + .node + .dirent + .read_data_all(xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs read default.xbe: {e:?}")))?; + let mut data_slice = std::io::Cursor::new(data.as_mut()); + let default_xbe_header = xbe::XbeHeader::read(&mut data_slice) + .map_err(|e| FatxError::Other(format!("error reading default.xbe: {e}")))?; + let execution_info = default_xbe_header.fields.execution_info.ok_or_else(|| { + FatxError::Other("no execution info in default.xbe header".to_string()) + })?; + + Ok(TitleInfo { + content_type: ContentType::XboxOriginal, + execution_info, + }) + } else { + Err(FatxError::Other( + "no executable found in this image".to_string(), + )) + } + } +} diff --git a/fatxlib/src/iso2god/executable/xbe.rs b/fatxlib/src/iso2god/executable/xbe.rs new file mode 100644 index 0000000..ace429e --- /dev/null +++ b/fatxlib/src/iso2god/executable/xbe.rs @@ -0,0 +1,57 @@ +use crate::error::{FatxError, Result}; +use crate::iso2god::executable::TitleExecutionInfo; +use byteorder::{LE, ReadBytesExt}; +use std::io::{Read, Seek, SeekFrom}; + +pub struct XbeHeader { + // We only need these fields to get the cert address + pub dw_base_addr: u32, + pub dw_certificate_addr: u32, + pub fields: XbeHeaderFields, +} + +#[derive(Clone, Default, Debug)] +pub struct XbeHeaderFields { + pub execution_info: Option, +} + +impl XbeHeader { + pub fn read(mut reader: R) -> Result { + Self::check_magic_bytes(&mut reader)?; + + // Offset 0x0104 + reader.seek(SeekFrom::Current(256)).map_err(FatxError::Io)?; + let dw_base_addr = reader.read_u32::().map_err(FatxError::Io)?; + + // Offset 0x0118 + reader.seek(SeekFrom::Current(16)).map_err(FatxError::Io)?; + let dw_certificate_addr = reader.read_u32::().map_err(FatxError::Io)?; + + let offset = reader.stream_position().map_err(FatxError::Io)? - 284; + let cert_address = dw_certificate_addr - dw_base_addr; + reader + .seek(SeekFrom::Start(offset + (cert_address as u64))) + .map_err(FatxError::Io)?; + + Ok(XbeHeader { + dw_base_addr, + dw_certificate_addr, + fields: XbeHeaderFields { + execution_info: Some(TitleExecutionInfo::from_xbe(reader)?), + }, + }) + } + + fn check_magic_bytes(mut reader: R) -> Result<()> { + let mut magic_bytes = [0u8; 4]; + reader.read_exact(&mut magic_bytes).map_err(FatxError::Io)?; + + if &magic_bytes != b"XBEH" { + return Err(FatxError::Other( + "missing 'XBEH' magic bytes in XBE header".to_string(), + )); + } + + Ok(()) + } +} diff --git a/fatxlib/src/iso2god/executable/xex.rs b/fatxlib/src/iso2god/executable/xex.rs new file mode 100644 index 0000000..1d3bbd7 --- /dev/null +++ b/fatxlib/src/iso2god/executable/xex.rs @@ -0,0 +1,142 @@ +use std::io::{Read, Seek, SeekFrom}; + +use byteorder::{BE, ReadBytesExt}; + +use bitflags::bitflags; +use num_enum::TryFromPrimitive; + +use crate::error::{FatxError, Result}; +use crate::iso2god::executable::TitleExecutionInfo; + +#[derive(Clone, Debug)] +pub struct XexHeader { + pub module_flags: XexModuleFlags, + pub code_offset: u32, + pub certificate_offset: u32, + pub fields: XexHeaderFields, +} + +bitflags! { + // based on https://free60.org/System-Software/Formats/XEX/#xex-header + #[derive(Clone, Copy, PartialEq, Eq, Debug)] + pub struct XexModuleFlags: u32 { + const TITLE_MODULE = 0x01; + const EXPORTS_TO_TITLE = 0x02; + const SYSTEM_DEBUGGER = 0x04; + const DLL_MODULE = 0x08; + const MODULE_PATCH = 0x10; + const FULL_PATCH = 0x20; + const DELTA_PATCH = 0x40; + const USER_MODE = 0x80; + } +} + +#[derive(Clone, Default, Debug)] +pub struct XexHeaderFields { + pub execution_info: Option, + // other fields will be added if and when necessary +} + +// based on https://free60.org/System-Software/Formats/XEX/#header-ids +#[repr(u32)] +#[derive(Clone, Debug, PartialEq, Eq, TryFromPrimitive)] +#[allow(dead_code)] +enum XexHeaderFieldId { + ResourceInfo = 0x_00_00_02_ff, + BaseFileFormat = 0x_00_00_03_ff, + BaseReference = 0x_00_00_04_05, + DeltaPatchDescriptor = 0x_00_00_05_ff, + BoundingPath = 0x_00_00_80_ff, + DeviceId = 0x_00_00_81_05, + OriginalBaseAddress = 0x_00_01_00_01, + EntryPoint = 0x_00_01_01_00, + ImageBaseAddress = 0x_00_01_02_01, + ImportLibraries = 0x_00_01_03_ff, + ChecksumTimestamp = 0x_00_01_80_02, + EnabledForCallcap = 0x_00_01_81_02, + EnabledForFastcap = 0x_00_01_82_00, + OriginalPeName = 0x_00_01_83_ff, + StaticLibraries = 0x_00_02_00_ff, + TlsInfo = 0x_00_02_01_04, + DefaultStackSize = 0x_00_02_02_00, + DefaultFilesystemCacheSize = 0x_00_02_03_01, + DefaultHeapSize = 0x_00_02_04_01, + PageHeapSizeAndFlags = 0x_00_02_80_02, + SystemFlags = 0x_00_03_00_00, + ExecutionId = 0x_00_04_00_06, + ServiceIdList = 0x_00_04_01_ff, + TitleWorkspaceSize = 0x_00_04_02_01, + GameRatings = 0x_00_04_03_10, + LanKey = 0x_00_04_04_04, + Xbox360Logo = 0x_00_04_05_ff, + MultidiscMediaIds = 0x_00_04_06_ff, + AlternateTitleIds = 0x_00_04_07_ff, + AdditionalTitleMemory = 0x_00_04_08_01, + ExportsByName = 0x_00_e1_04_02, +} + +impl XexHeader { + pub fn read(mut reader: R) -> Result { + Self::check_magic_bytes(&mut reader)?; + Self::read_checked(reader) + } + + fn check_magic_bytes(mut reader: R) -> Result<()> { + let mut buf = [0_u8; 4]; + reader.read_exact(&mut buf).map_err(FatxError::Io)?; + + reader.seek(SeekFrom::Current(-4)).map_err(FatxError::Io)?; + + if buf != "XEX2".as_bytes() { + return Err(FatxError::Other( + "missing 'XEX2' magic bytes in XEX header".to_string(), + )); + } + + Ok(()) + } + + fn read_checked(mut reader: R) -> Result { + let header_offset = reader.stream_position().map_err(FatxError::Io)?; + + let _ = reader.read_u32::().map_err(FatxError::Io)?; + + let module_flags = reader.read_u32::().map_err(FatxError::Io)?; + let module_flags = XexModuleFlags::from_bits_truncate(module_flags); + + let code_offset = reader.read_u32::().map_err(FatxError::Io)?; + + let _ = reader.read_u32::().map_err(FatxError::Io)?; + + let certificate_offset = reader.read_u32::().map_err(FatxError::Io)?; + + let mut fields: XexHeaderFields = Default::default(); + let field_count = reader.read_u32::().map_err(FatxError::Io)?; + + for _ in 0..field_count { + let key = reader.read_u32::().map_err(FatxError::Io)?; + let value = reader.read_u32::().map_err(FatxError::Io)?; + + let key = XexHeaderFieldId::try_from(key).ok(); + type Key = XexHeaderFieldId; + + if let Some(Key::ExecutionId) = key { + let offset = reader.stream_position().map_err(FatxError::Io)?; + reader + .seek(SeekFrom::Start(header_offset + (value as u64))) + .map_err(FatxError::Io)?; + fields.execution_info = Some(TitleExecutionInfo::from_xex(&mut reader)?); + reader + .seek(SeekFrom::Start(offset)) + .map_err(FatxError::Io)?; + }; + } + + Ok(XexHeader { + module_flags, + code_offset, + certificate_offset, + fields, + }) + } +} diff --git a/fatxlib/src/iso2god/god/con_header.rs b/fatxlib/src/iso2god/god/con_header.rs new file mode 100644 index 0000000..de34198 --- /dev/null +++ b/fatxlib/src/iso2god/god/con_header.rs @@ -0,0 +1,122 @@ +use byteorder::{BE, ByteOrder, LE}; + +use sha1::{Digest, Sha1}; + +use crate::iso2god::executable::TitleExecutionInfo; + +const EMPTY_LIVE: &[u8] = include_bytes!("empty_live.bin"); + +pub struct ConHeaderBuilder { + buffer: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContentType { + GamesOnDemand = 0x7000, + XboxOriginal = 0x5000, +} + +impl Default for ConHeaderBuilder { + fn default() -> Self { + Self::new() + } +} + +impl ConHeaderBuilder { + pub fn new() -> Self { + ConHeaderBuilder { + buffer: Vec::from(EMPTY_LIVE), + } + } + + fn write_u8(&mut self, offset: usize, value: u8) { + self.buffer[offset] = value; + } + + fn write_u16_be(&mut self, offset: usize, value: u16) { + BE::write_u16(&mut self.buffer[offset..], value); + } + + fn write_u24_be(&mut self, offset: usize, value: u32) { + BE::write_u24(&mut self.buffer[offset..], value); + } + + fn write_u32_be(&mut self, offset: usize, value: u32) { + BE::write_u32(&mut self.buffer[offset..], value); + } + + fn write_u32_le(&mut self, offset: usize, value: u32) { + LE::write_u32(&mut self.buffer[offset..], value); + } + + fn write_bytes(&mut self, offset: usize, buf: &[u8]) { + self.buffer[offset..offset + buf.len()].copy_from_slice(buf); + } + + fn write_utf16_be(&mut self, offset: usize, s: &str) { + for (i, c) in s.encode_utf16().chain([0]).enumerate() { + self.write_u16_be(offset + i * 2, c); + } + } + + pub fn with_block_counts(mut self, blocks_allocated: u32, blocks_not_allocated: u16) -> Self { + self.write_u24_be(0x0392, blocks_allocated); + self.write_u16_be(0x0395, blocks_not_allocated); + self + } + + pub fn with_content_type(mut self, content_type: ContentType) -> Self { + self.write_u32_be(0x0344, content_type as u32); + self + } + + pub fn with_data_parts_info(mut self, part_count: u32, parts_total_size: u64) -> Self { + self.write_u32_le(0x03a0, part_count); // sic! + self.write_u32_be(0x03a4, (parts_total_size / 0x0100) as u32); + self + } + + pub fn with_execution_info(mut self, exe_info: &TitleExecutionInfo) -> Self { + // TODO: maybe just pick a suitable repr() for the struct, and write it whole? + self.write_u32_be(0x0354, exe_info.media_id); + self.write_u32_be(0x0360, exe_info.title_id); + self.write_u8(0x0364, exe_info.platform); + self.write_u8(0x0365, exe_info.executable_type); + self.write_u8(0x0366, exe_info.disc_number); + self.write_u8(0x0367, exe_info.disc_count); + self + } + + pub fn with_game_icon(mut self, png_bytes: Option<&[u8]>) -> Self { + let png_bytes = png_bytes.unwrap_or(&[]); + assert!(png_bytes.len() <= 0x0400); + + self.write_u32_be(0x1712, png_bytes.len() as u32); + self.write_u32_be(0x1716, png_bytes.len() as u32); + self.write_bytes(0x171a, png_bytes); + self.write_bytes(0x571a, png_bytes); + self + } + + pub fn with_game_title(mut self, game_title: &str) -> Self { + self.write_utf16_be(0x0411, game_title); + self.write_utf16_be(0x1691, game_title); + self + } + + pub fn with_mht_hash(mut self, mht_hash: &[u8; 20]) -> Self { + self.write_bytes(0x037d, mht_hash); + self + } + + pub fn finalize(mut self) -> Vec { + self.buffer[0x035b] = 0; + self.buffer[0x035f] = 0; + self.buffer[0x0391] = 0; + + let digest: [u8; 20] = Sha1::digest(&self.buffer[0x0344..(0x0344 + 0xacbc)]).into(); + self.write_bytes(0x032c, &digest); + + self.buffer + } +} diff --git a/fatxlib/src/iso2god/god/empty_live.bin b/fatxlib/src/iso2god/god/empty_live.bin new file mode 100644 index 0000000000000000000000000000000000000000..0bc7abd5c1128e08ba974d77d503c721af329522 GIT binary patch literal 45056 zcmeIuKP!b%7y$5xQZgvt;0`7Uld$+-NT$nTx^Y8s35&@jS!6LQi&DnB8+--6gvnx3 zJeS)g78w-%p0{(}=RM~+&)fOUPA^P`An*-==X!jj^k0@-m;3Gx?^}oa=C2+f_Qz+Q zpQcVDuG%BEYWHpabm_MIu8oYQrnmQpxwECE { + base_path: &'a Path, + exe_info: &'a TitleExecutionInfo, + content_type: ContentType, +} + +impl<'a> FileLayout<'a> { + pub fn new( + base_path: &'a Path, + exe_info: &'a TitleExecutionInfo, + content_type: ContentType, + ) -> FileLayout<'a> { + FileLayout { + base_path, + exe_info, + content_type, + } + } + + fn title_id_string(&self) -> String { + format!("{:08X}", self.exe_info.title_id) + } + + fn content_type_string(&self) -> String { + format!("{:08X}", self.content_type as u32) + } + + fn media_id_string(&self) -> String { + match self.content_type { + ContentType::GamesOnDemand => { + format!("{:08X}", self.exe_info.media_id) + } + ContentType::XboxOriginal => { + format!("{:08X}", self.exe_info.title_id) + } + } + } + + pub fn data_dir_path(&self) -> PathBuf { + self.base_path + .join(self.title_id_string()) + .join(self.content_type_string()) + .join(self.media_id_string() + ".data") + } + + pub fn part_file_path(&'a self, part_index: u64) -> PathBuf { + self.data_dir_path().join(format!("Data{:04}", part_index)) + } + + pub fn con_header_file_path(&self) -> PathBuf { + self.base_path + .join(self.title_id_string()) + .join(self.content_type_string()) + .join(self.media_id_string()) + } +} diff --git a/fatxlib/src/iso2god/god/gdf_sector.rs b/fatxlib/src/iso2god/god/gdf_sector.rs new file mode 100644 index 0000000..7364190 --- /dev/null +++ b/fatxlib/src/iso2god/god/gdf_sector.rs @@ -0,0 +1,132 @@ +#[rustfmt::skip] +pub const GDF_SECTOR: [u8; 2055] = [ + 1, 0x43, 0x44, 0x30, 0x30, 0x31, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x17, 0x4b, 0, 0, 0, 0, 0x4b, 0x17, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, + 0, 8, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x30, 0x30, 0x30, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0, 0x30, 0x30, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0, 0x30, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0, + 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, + 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0xff, 0x43, 0x44, 0x30, 0x30, 0x31, 1, +]; diff --git a/fatxlib/src/iso2god/god/hash_list.rs b/fatxlib/src/iso2god/god/hash_list.rs new file mode 100644 index 0000000..c37865a --- /dev/null +++ b/fatxlib/src/iso2god/god/hash_list.rs @@ -0,0 +1,60 @@ +use std::io::{Read, Write}; + +use sha1::{Digest, Sha1}; + +use crate::error::{FatxError, Result}; + +pub struct HashList { + buffer: [u8; 4096], + len: usize, +} + +impl Default for HashList { + fn default() -> Self { + Self::new() + } +} + +impl HashList { + pub fn bytes(&self) -> &[u8; 4096] { + &self.buffer + } + + pub fn new() -> HashList { + HashList { + buffer: [0u8; 4096], + len: 0, + } + } + + pub fn read(mut reader: R) -> Result { + let mut buffer = [0u8; 4096]; + reader.read_exact(&mut buffer).map_err(FatxError::Io)?; + + let len = buffer + .chunks(20) + .position(|c| *c == [0u8; 20]) + .map(|p| p * 20) + .unwrap_or(buffer.len()); + + Ok(HashList { buffer, len }) + } + + pub fn add_hash(&mut self, hash: &[u8; 20]) { + self.buffer[self.len..self.len + 20].copy_from_slice(hash); + self.len += 20; + } + + pub fn add_block_hash(&mut self, block: &[u8]) { + self.add_hash(&Sha1::digest(block).into()) + } + + pub fn digest(&self) -> [u8; 20] { + Sha1::digest(self.buffer).into() + } + + pub fn write(&self, mut writer: W) -> Result<()> { + writer.write_all(&self.buffer).map_err(FatxError::Io)?; + Ok(()) + } +} diff --git a/fatxlib/src/iso2god/god/mod.rs b/fatxlib/src/iso2god/god/mod.rs new file mode 100644 index 0000000..cba267e --- /dev/null +++ b/fatxlib/src/iso2god/god/mod.rs @@ -0,0 +1,79 @@ +use std::io::{Read, Seek, SeekFrom, Write}; + +use crate::error::{FatxError, Result}; + +mod con_header; +pub use con_header::*; + +mod file_layout; +pub use file_layout::*; + +mod gdf_sector; +pub use gdf_sector::*; + +mod hash_list; +pub use hash_list::*; + +pub const BLOCKS_PER_PART: u64 = 0xa1c4; +pub const BLOCKS_PER_SUBPART: u64 = 0xcc; +pub const BLOCK_SIZE: u64 = 0x1000; +pub const SUBPARTS_PER_PART: u32 = 0xcb; +pub const SUBPART_SIZE: u64 = BLOCK_SIZE * BLOCKS_PER_SUBPART; + +pub fn write_part( + mut data_volume: R, + part_index: u64, + mut part_file: W, +) -> Result<()> { + data_volume + .seek_relative((part_index * BLOCKS_PER_PART * BLOCK_SIZE) as i64) + .map_err(FatxError::Io)?; + + let mut master_hash_list = HashList::new(); + + let master_hash_list_position = part_file.stream_position().map_err(FatxError::Io)?; + master_hash_list.write(&mut part_file)?; + + let mut subpart_buf = Vec::with_capacity(SUBPART_SIZE as usize); + + for _subpart_index in 0..SUBPARTS_PER_PART { + data_volume + .by_ref() + .take(SUBPART_SIZE) + .read_to_end(&mut subpart_buf) + .map_err(FatxError::Io)?; + + if subpart_buf.is_empty() { + break; + } + + let mut sub_hash_list = HashList::new(); + + for block in subpart_buf.chunks(BLOCK_SIZE as usize) { + sub_hash_list.add_block_hash(block); + } + + sub_hash_list.write(&mut part_file)?; + master_hash_list.add_block_hash(sub_hash_list.bytes()); + + // using io::copy here to benefit from potential reflink optimizations + // https://doc.rust-lang.org/std/io/fn.copy.html#platform-specific-behavior + data_volume + .seek_relative(0 - subpart_buf.len() as i64) + .map_err(FatxError::Io)?; + std::io::copy(&mut data_volume.by_ref().take(SUBPART_SIZE), &mut part_file) + .map_err(FatxError::Io)?; + + if subpart_buf.len() < SUBPART_SIZE as usize { + break; + } + subpart_buf.clear(); + } + + part_file + .seek(SeekFrom::Start(master_hash_list_position)) + .map_err(FatxError::Io)?; + master_hash_list.write(&mut part_file)?; + + Ok(()) +} diff --git a/fatxlib/src/iso2god/mod.rs b/fatxlib/src/iso2god/mod.rs new file mode 100644 index 0000000..9281d46 --- /dev/null +++ b/fatxlib/src/iso2god/mod.rs @@ -0,0 +1,24 @@ +//! ISO → Games-on-Demand conversion pipeline. +//! +//! Vendored from [QAston/iso2god-rs `xdvdfx` branch](https://github.com/QAston/iso2god-rs/tree/xdvdfx) +//! (parent: [iliazeus/iso2god-rs](https://github.com/iliazeus/iso2god-rs); +//! both MIT-licensed). We keep the upstream module shape (`god/`, `executable/`) +//! so we can re-sync against new upstream commits with minimal diff. Local +//! deviations from upstream: +//! +//! - `anyhow::Error` → [`crate::error::FatxError`] so errors flow through +//! the same channel as the rest of fatxlib. +//! - Intra-crate `use crate::god` / `use crate::executable` imports rewritten +//! to `use crate::iso2god::god` / `use crate::iso2god::executable`. +//! - The original `src/game_list/` (4.9 KLOC of compiled-in title catalog) is +//! dropped; fatxlib already has a richer catalog via [`crate::titles`]. +//! - The upstream binary (`src/bin/iso2god.rs`) lives elsewhere — fatxlib only +//! provides the library surface; the CLI/TUI wraps it in `xtafkit`. +//! +//! See `NOTICE` at the repo root for the full attribution. + +pub mod executable; +pub mod god; + +mod convert; +pub use convert::{ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso}; diff --git a/fatxlib/src/lib.rs b/fatxlib/src/lib.rs index 2e92796..03ad71e 100644 --- a/fatxlib/src/lib.rs +++ b/fatxlib/src/lib.rs @@ -28,6 +28,7 @@ pub mod content_types; pub mod display; pub mod error; +pub mod iso2god; pub mod partition; pub mod platform; pub mod stfs; diff --git a/fatxlib/tests/fixtures/tiny.xiso b/fatxlib/tests/fixtures/tiny.xiso index fb17b0a0269c634591386e670443035b5ca7d25a..3ea4ed5fa70a26e7130f91e71fffb8f096a2a1e2 100644 GIT binary patch literal 458752 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 O+NV8e3o^vfb6IwnZN*=L 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%0ANPpyf@1dPtk>Eo% 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 Option { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny.xiso"); + if p.exists() { Some(p) } else { None } +} + +#[test] +fn converts_fixture_into_valid_god_package() { + let Some(iso) = fixture_path() else { + eprintln!("skipping: fatxlib/tests/fixtures/tiny.xiso missing"); + return; + }; + + let tmp = tempfile::TempDir::new().expect("tempdir"); + let dest = tmp.path(); + + let mut opts = ConvertOptions { + trim: TrimMode::FromEnd, + game_title: Some("XellLaunch2 fixture"), + dry_run: false, + progress: None, + }; + + let report = convert_iso(&iso, dest, &mut opts).expect("convert_iso"); + + assert!(report.title_id != 0, "title id should be non-zero"); + assert!( + report.part_count >= 1, + "fixture must produce at least one Data part; got {:?}", + report + ); + assert!(report.block_count >= 1); + + // CON header lives at /// + let title_hex = format!("{:08X}", report.title_id); + let ctype_hex = format!("{:08X}", report.content_type as u32); + let media_hex = if matches!( + report.content_type, + fatxlib::iso2god::god::ContentType::XboxOriginal + ) { + title_hex.clone() + } else { + format!("{:08X}", report.media_id) + }; + + let con_header_path = dest.join(&title_hex).join(&ctype_hex).join(&media_hex); + let data_dir = dest + .join(&title_hex) + .join(&ctype_hex) + .join(format!("{}.data", media_hex)); + let first_part = data_dir.join("Data0000"); + + assert!( + con_header_path.exists(), + "CON header missing at {}", + con_header_path.display() + ); + assert!( + first_part.exists(), + "Data0000 missing at {}", + first_part.display() + ); + + let con_header_size = fs::metadata(&con_header_path).expect("stat header").len(); + assert_eq!( + con_header_size, 0xB000, + "CON header should be 45 056 bytes (empty_live template)" + ); + + let first_part_size = fs::metadata(&first_part).expect("stat data").len(); + assert!( + first_part_size > 0, + "Data0000 should be non-empty; got {} bytes", + first_part_size + ); + + // CON header should start with "LIVE" (`empty_live.bin` magic). + let head = fs::read(&con_header_path).expect("read header"); + assert_eq!( + &head[..4], + b"LIVE", + "CON header missing LIVE magic; got {:?}", + &head[..4] + ); +} + +#[test] +fn fixture_dry_run_does_not_create_files() { + let Some(iso) = fixture_path() else { + return; + }; + + let tmp = tempfile::TempDir::new().expect("tempdir"); + let dest = tmp.path(); + + let mut opts = ConvertOptions { + trim: TrimMode::FromEnd, + game_title: None, + dry_run: true, + progress: None, + }; + + let report = convert_iso(&iso, dest, &mut opts).expect("dry-run convert"); + assert!(report.part_count >= 1); + + let entries: Vec<_> = fs::read_dir(dest) + .expect("readdir") + .filter_map(|e| e.ok()) + .collect(); + assert!( + entries.is_empty(), + "dry_run should not write anything; found {:?}", + entries.iter().map(|e| e.path()).collect::>() + ); +} + +#[test] +fn fixture_extracts_expected_title_id() { + // XellLaunch2_retail's TitleID is 0xFFFF011D (homebrew/dev range). + // If this assertion fires, either the fixture changed or the XEX + // parser drifted. + let Some(iso) = fixture_path() else { + return; + }; + + let tmp = tempfile::TempDir::new().expect("tempdir"); + let mut opts = ConvertOptions { + dry_run: true, + ..Default::default() + }; + + let report = convert_iso(&iso, tmp.path(), &mut opts).expect("dry-run convert"); + assert_eq!( + report.title_id, 0xFFFF011D, + "expected XellLaunch2_retail TitleID; fixture may have changed" + ); +} diff --git a/fatxlib/tests/xiso_reader.rs b/fatxlib/tests/xiso_reader.rs index 46bee7e..f1055bb 100644 --- a/fatxlib/tests/xiso_reader.rs +++ b/fatxlib/tests/xiso_reader.rs @@ -61,8 +61,8 @@ fn walks_fixture_image() { ); 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.iter().any(|n| n.ends_with("default.xex")), + "expected default.xex (XellLaunch2_retail) in fixture; got {:?}", names ); } From 7bb775c9840d8ba5addc7761d464ef80e5788592 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 01:37:17 +1000 Subject: [PATCH 02/12] performance and integration --- Cargo.lock | 65 +++++++++++++++++++++++++++ fatxlib/Cargo.toml | 8 ++++ fatxlib/examples/iso2god.rs | 25 ++++++----- fatxlib/src/iso2god/convert.rs | 57 ++++++++++++----------- fatxlib/src/iso2god/god/con_header.rs | 5 +-- fatxlib/src/iso2god/god/hash_list.rs | 7 ++- fatxlib/src/iso2god/god/mod.rs | 43 ++++++++++-------- fatxlib/src/iso2god/mod.rs | 22 +++++++++ fatxlib/tests/iso2god_roundtrip.rs | 8 ++-- 9 files changed, 175 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c639e7c..d4ac436 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -655,6 +655,7 @@ dependencies = [ "log", "nix 0.31.3", "num_enum", + "openssl", "phf 0.13.1", "phf_codegen 0.13.1", "proptest", @@ -714,6 +715,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "futures-core" version = "0.3.32" @@ -1219,6 +1235,43 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -1391,6 +1444,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2204,6 +2263,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/fatxlib/Cargo.toml b/fatxlib/Cargo.toml index d115928..f023195 100644 --- a/fatxlib/Cargo.toml +++ b/fatxlib/Cargo.toml @@ -18,8 +18,16 @@ hmac = "0.13" sha1 = "0.11" byteorder = "1.5" num_enum = "0.7" +openssl = { version = "0.10", optional = true } xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "sync"] } +[features] +default = ["openssl-hash"] +# Route iso2god's hot-path SHA-1 through `openssl::sha::sha1` (ARMv8 SHA +# on Apple Silicon, SHA-NI on x86). Disable if the host's OpenSSL is +# hard to find; everything falls back to the RustCrypto `sha1` crate. +openssl-hash = ["dep:openssl"] + [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31", features = ["fs", "ioctl"] } diff --git a/fatxlib/examples/iso2god.rs b/fatxlib/examples/iso2god.rs index 0459e15..95092d6 100644 --- a/fatxlib/examples/iso2god.rs +++ b/fatxlib/examples/iso2god.rs @@ -1,16 +1,14 @@ -//! Minimal CLI wrapper around [`fatxlib::iso2god::convert_iso`]. -//! -//! Mirrors the surface of upstream's `iso2god` binary so the Plan C bench -//! harness can run our build in apples-to-apples fashion. Argument shape: +//! Minimal CLI wrapper around [`fatxlib::iso2god::convert_iso`]. Argument +//! shape: //! //! ```text -//! iso2god [--trim] [--dry-run] [--game-title TITLE] +//! iso2god [--trim | --no-trim] [--dry-run] [--game-title TITLE] //! ``` //! -//! Differences vs upstream: -//! - `--trim` is a flag without an argument (matches upstream `--trim`, -//! defaults to no trim if absent; pass to enable from-end trim). -//! - We don't expose `-j N` because `convert_iso` is single-threaded for now. +//! `--trim` (from-end) is the default — almost everyone wants the trimmed +//! output. Pass `--no-trim` to convert the full source partition. +//! +//! `-j N` isn't exposed; `convert_iso` is single-threaded. use std::env; use std::path::PathBuf; @@ -20,13 +18,17 @@ use std::time::Instant; use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso}; fn usage_and_exit() -> ! { - eprintln!("usage: iso2god [--trim] [--dry-run] [--game-title TITLE] "); + eprintln!( + "usage: iso2god [--trim | --no-trim] [--dry-run] [--game-title TITLE] " + ); process::exit(2); } fn main() { let mut args = env::args().skip(1); - let mut trim = TrimMode::None; + // Default to from-end trim. Pass --no-trim to convert the full + // source partition. + let mut trim = TrimMode::FromEnd; let mut dry_run = false; let mut game_title: Option = None; let mut positional: Vec = Vec::new(); @@ -34,6 +36,7 @@ fn main() { while let Some(arg) = args.next() { match arg.as_str() { "--trim" => trim = TrimMode::FromEnd, + "--no-trim" => trim = TrimMode::None, "--dry-run" => dry_run = true, "--game-title" => { game_title = Some(args.next().unwrap_or_else(|| usage_and_exit())); diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index 3f6253c..7bb425f 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -1,26 +1,27 @@ //! Public entry point for ISO → Games-on-Demand conversion. //! -//! Mirrors the flow of QAston/iso2god-rs `xdvdfx`'s `src/bin/iso2god.rs::main`, -//! translated onto fatxlib's error type and with a 1 MiB BufReader wrapping -//! the source (Plan C finding: upstream's default 8 KiB buffer leaves ~5 s of -//! avoidable I/O wait per 8.7 GiB ISO). +//! Walks the source ISO via xdvdfs, computes the GoD file layout, writes +//! each Data part with its embedded hash tree, and finalizes the CON +//! header. See `NOTICE` and the [`crate::iso2god`] module doc for the +//! upstream sources this code descends from. //! -//! Single-threaded for now — upstream uses rayon for parallelism, but the -//! Plan C bench ran `-j 1` and `convert_iso` matches that for apples-to-apples -//! re-measurement. Parallel mode can land later as an opt-in flag. +//! Single-threaded. The metadata pre-pass uses a 1 MiB `BufReader` to cut +//! syscall tax on the file-tree walk; per-part data reads go straight +//! against the file (a fixed-size subpart read into a pre-allocated +//! buffer makes an interposing reader pure overhead). A multi-threaded +//! mode could land later as an opt-in flag. use std::fs::{self, File}; -use std::io::{BufReader, Seek, SeekFrom, Write}; +use std::io::{BufReader, BufWriter, Seek, SeekFrom, Write}; use std::path::Path; use crate::error::{FatxError, Result}; use crate::iso2god::executable::TitleInfo; use crate::iso2god::god::{self, ConHeaderBuilder, ContentType, FileLayout, HashList}; -/// Buffer capacity for the source-ISO reader. 1 MiB — Plan C measured that -/// upstream's default-cap (8 KiB) `BufReader` leaves ~5 s of avoidable I/O -/// wait per 8.7 GiB ISO; pushing the buffer up to a megabyte reclaims most -/// of it without OS-level read-ahead tuning. +/// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — +/// large enough that the default 8 KiB capacity's syscall tax disappears +/// on multi-GiB ISOs, without requiring OS-level read-ahead tuning. pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; /// Progress callback shape: `(stage, current, total)` where `stage` is one @@ -31,8 +32,8 @@ pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64, u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TrimMode { /// Walk the directory tree, find the max `(offset + size)`, and pack - /// only that many bytes. This is upstream's default and matches the - /// Python port. + /// only that many bytes. The default — yields the smallest output + /// without changing on-disk meaning. #[default] FromEnd, /// Pack every byte from the start of the data partition to the end of @@ -97,8 +98,8 @@ pub fn convert_iso( let exe_info = title_info.execution_info; let content_type = title_info.content_type; - // Pull the partition offset out from the wrapper — upstream calls this - // "root_offset" and uses it as the per-part `seek` target. + // Pull the partition offset out from the wrapper; the per-part + // readers use it as their `seek` target. let root_offset = { xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; xiso.get_mut().stream_position().map_err(FatxError::Io)? @@ -148,7 +149,7 @@ pub fn convert_iso( ensure_empty_dir(&file_layout.data_dir_path())?; - // ---- Write the Data parts (sequential, matches `-j 1` upstream) ----- + // ---- Write the Data parts (sequential) ------------------------------ if let Some(cb) = opts.progress.as_deref_mut() { cb("parts", 0, part_count); @@ -162,14 +163,20 @@ pub fn convert_iso( .truncate(true) .open(&part_path) .map_err(FatxError::Io)?; - - // Fresh source reader per part so we can `seek_relative` from a known - // starting point (root_offset). Buffered for the same I/O reasons as - // the metadata read above. - let mut iso_data_volume = BufReader::with_capacity( - SOURCE_BUFFER_SIZE, - File::open(source_iso).map_err(FatxError::Io)?, - ); + // Wrap the part output in a 1 MiB BufWriter so the interleaved + // 4 KiB hash-list writes and the larger subpart writes don't + // each turn into separate syscalls. The subpart writes themselves + // bypass the buffer (they're larger than the free space), but + // the hash writes ride on top of them for free. + let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); + + // Fresh source reader per part so we can `seek` from a known + // starting point (root_offset). Deliberately UNbuffered — the + // inner hot loop in `god::write_part` reads exactly SUBPART_SIZE + // (~832 KiB) per pass into a pre-allocated buffer; an interposing + // BufReader at that read size just adds an extra memcpy through + // its internal buffer with no syscall-batching benefit. + let mut iso_data_volume = File::open(source_iso).map_err(FatxError::Io)?; iso_data_volume .seek(SeekFrom::Start(root_offset)) .map_err(FatxError::Io)?; diff --git a/fatxlib/src/iso2god/god/con_header.rs b/fatxlib/src/iso2god/god/con_header.rs index de34198..34dcdd5 100644 --- a/fatxlib/src/iso2god/god/con_header.rs +++ b/fatxlib/src/iso2god/god/con_header.rs @@ -1,8 +1,7 @@ use byteorder::{BE, ByteOrder, LE}; -use sha1::{Digest, Sha1}; - use crate::iso2god::executable::TitleExecutionInfo; +use crate::iso2god::sha1_digest; const EMPTY_LIVE: &[u8] = include_bytes!("empty_live.bin"); @@ -114,7 +113,7 @@ impl ConHeaderBuilder { self.buffer[0x035f] = 0; self.buffer[0x0391] = 0; - let digest: [u8; 20] = Sha1::digest(&self.buffer[0x0344..(0x0344 + 0xacbc)]).into(); + let digest = sha1_digest(&self.buffer[0x0344..(0x0344 + 0xacbc)]); self.write_bytes(0x032c, &digest); self.buffer diff --git a/fatxlib/src/iso2god/god/hash_list.rs b/fatxlib/src/iso2god/god/hash_list.rs index c37865a..161df18 100644 --- a/fatxlib/src/iso2god/god/hash_list.rs +++ b/fatxlib/src/iso2god/god/hash_list.rs @@ -1,8 +1,7 @@ use std::io::{Read, Write}; -use sha1::{Digest, Sha1}; - use crate::error::{FatxError, Result}; +use crate::iso2god::sha1_digest; pub struct HashList { buffer: [u8; 4096], @@ -46,11 +45,11 @@ impl HashList { } pub fn add_block_hash(&mut self, block: &[u8]) { - self.add_hash(&Sha1::digest(block).into()) + self.add_hash(&sha1_digest(block)) } pub fn digest(&self) -> [u8; 20] { - Sha1::digest(self.buffer).into() + sha1_digest(&self.buffer) } pub fn write(&self, mut writer: W) -> Result<()> { diff --git a/fatxlib/src/iso2god/god/mod.rs b/fatxlib/src/iso2god/god/mod.rs index cba267e..f01f646 100644 --- a/fatxlib/src/iso2god/god/mod.rs +++ b/fatxlib/src/iso2god/god/mod.rs @@ -34,40 +34,47 @@ pub fn write_part( let master_hash_list_position = part_file.stream_position().map_err(FatxError::Io)?; master_hash_list.write(&mut part_file)?; - let mut subpart_buf = Vec::with_capacity(SUBPART_SIZE as usize); + // Pre-allocated subpart buffer — avoids `take + read_to_end`'s repeated + // grow/check ceremony and the Vec-append work that came with it. We read + // straight into a fixed-size buffer and slice off the actual length. + let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; for _subpart_index in 0..SUBPARTS_PER_PART { - data_volume - .by_ref() - .take(SUBPART_SIZE) - .read_to_end(&mut subpart_buf) - .map_err(FatxError::Io)?; - - if subpart_buf.is_empty() { + // Fill subpart_buf one read at a time. The last subpart may be + // short — that's fine, we slice with `got` below. + let mut got = 0usize; + while got < subpart_buf.len() { + let n = data_volume + .read(&mut subpart_buf[got..]) + .map_err(FatxError::Io)?; + if n == 0 { + break; + } + got += n; + } + if got == 0 { break; } + let subpart = &subpart_buf[..got]; let mut sub_hash_list = HashList::new(); - for block in subpart_buf.chunks(BLOCK_SIZE as usize) { + for block in subpart.chunks(BLOCK_SIZE as usize) { sub_hash_list.add_block_hash(block); } sub_hash_list.write(&mut part_file)?; master_hash_list.add_block_hash(sub_hash_list.bytes()); - // using io::copy here to benefit from potential reflink optimizations - // https://doc.rust-lang.org/std/io/fn.copy.html#platform-specific-behavior - data_volume - .seek_relative(0 - subpart_buf.len() as i64) - .map_err(FatxError::Io)?; - std::io::copy(&mut data_volume.by_ref().take(SUBPART_SIZE), &mut part_file) - .map_err(FatxError::Io)?; + // Write the subpart we already buffered. An earlier shape + // seeked back and re-read via `io::copy` (a `reflink` hint for + // CoW filesystems), but APFS doesn't honor reflink on partial- + // file writes — the re-read just doubled I/O without benefit. + part_file.write_all(subpart).map_err(FatxError::Io)?; - if subpart_buf.len() < SUBPART_SIZE as usize { + if got < SUBPART_SIZE as usize { break; } - subpart_buf.clear(); } part_file diff --git a/fatxlib/src/iso2god/mod.rs b/fatxlib/src/iso2god/mod.rs index 9281d46..b75fa4b 100644 --- a/fatxlib/src/iso2god/mod.rs +++ b/fatxlib/src/iso2god/mod.rs @@ -22,3 +22,25 @@ pub mod god; mod convert; pub use convert::{ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso}; + +/// Single hot-path SHA-1 entry point used by [`god::HashList`] and +/// [`god::ConHeaderBuilder`]. With the `openssl-hash` feature (default on) +/// this routes to `openssl::sha::sha1`, which uses ARMv8 SHA on Apple +/// Silicon and SHA-NI on x86. Without the feature it falls back to the +/// portable-Rust `sha1` crate. +/// +/// On hardware that exposes accelerated SHA-1, the OpenSSL path can be +/// measurably faster for large workloads. Disable the feature to drop +/// the dependency if the build environment can't reach a system OpenSSL. +#[inline] +pub(crate) fn sha1_digest(data: &[u8]) -> [u8; 20] { + #[cfg(feature = "openssl-hash")] + { + openssl::sha::sha1(data) + } + #[cfg(not(feature = "openssl-hash"))] + { + use sha1::{Digest, Sha1}; + Sha1::digest(data).into() + } +} diff --git a/fatxlib/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index 3362344..717eb06 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -6,10 +6,10 @@ //! project). The XEX has valid `XEX2` magic + execution-info fields, so //! `TitleInfo::from_image` parses it cleanly and the full pipeline runs. //! -//! Plan C already proved byte-identical output across iliazeus, QAston, -//! and the Python port on a real game ISO, so this test focuses on -//! "the pipeline runs to completion and the output is shaped correctly", -//! not byte-equality. +//! Focuses on "the pipeline runs to completion and the output is shaped +//! correctly", not byte-equality — the GoD format is deterministic, and +//! byte-equality is best validated against an external reference +//! conversion when one is available. use std::fs; use std::path::PathBuf; From 96df78133ace94bf37997c347970c151453db96d Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 01:54:59 +1000 Subject: [PATCH 03/12] tui integration --- fatxlib/examples/iso2god.rs | 1 + fatxlib/src/iso2god/convert.rs | 15 ++ fatxlib/tests/iso2god_roundtrip.rs | 2 + src/tui.rs | 377 +++++++++++++++++++++++++---- 4 files changed, 350 insertions(+), 45 deletions(-) diff --git a/fatxlib/examples/iso2god.rs b/fatxlib/examples/iso2god.rs index 95092d6..f0fef47 100644 --- a/fatxlib/examples/iso2god.rs +++ b/fatxlib/examples/iso2god.rs @@ -68,6 +68,7 @@ fn main() { game_title: game_title.as_deref(), dry_run, progress: Some(&mut progress_cb), + should_abort: None, }; match convert_iso(&source, &dest, &mut opts) { diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index 7bb425f..95f7fb1 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -57,6 +57,11 @@ pub struct ConvertOptions<'a> { /// Optional progress callback. Stages: "scan", "parts", "mht", "header". /// `current`/`total` are stage-relative. pub progress: Option>, + /// Optional cancellation hook. Checked before each part write and + /// before each MHT-chain step; returning `true` aborts the conversion + /// with a clean error rather than partial silent failure. Mid-part + /// cancellation is not supported. + pub should_abort: Option<&'a dyn Fn() -> bool>, } /// Metadata extracted from the source ISO and the resulting layout sizing. @@ -156,6 +161,11 @@ pub fn convert_iso( } for part_index in 0..part_count { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other("convert_iso: cancelled".to_string())); + } let part_path = file_layout.part_file_path(part_index); let part_file = File::options() .write(true) @@ -196,6 +206,11 @@ pub fn convert_iso( let mut mht = read_part_mht(&file_layout, part_count - 1)?; for prev_part_index in (0..part_count - 1).rev() { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other("convert_iso: cancelled".to_string())); + } let mut prev_mht = read_part_mht(&file_layout, prev_part_index)?; prev_mht.add_hash(&mht.digest()); write_part_mht(&file_layout, prev_part_index, &prev_mht)?; diff --git a/fatxlib/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index 717eb06..4e2bf83 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -36,6 +36,7 @@ fn converts_fixture_into_valid_god_package() { game_title: Some("XellLaunch2 fixture"), dry_run: false, progress: None, + should_abort: None, }; let report = convert_iso(&iso, dest, &mut opts).expect("convert_iso"); @@ -115,6 +116,7 @@ fn fixture_dry_run_does_not_create_files() { game_title: None, dry_run: true, progress: None, + should_abort: None, }; let report = convert_iso(&iso, dest, &mut opts).expect("dry-run convert"); diff --git a/src/tui.rs b/src/tui.rs index d235607..5b9ed83 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -16,9 +16,13 @@ //! d Download selected file to local disk //! 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. +//! TUI asks how to bring it onto the drive: +//! (x)tract — stream the file tree into // +//! (g)oD — convert to a Games-on-Demand package +//! rooted at //00007000/... +//! (r)aw — copy the source ISO byte-for-byte +//! Default is GoD when cwd is inside `/Content//`, +//! extract otherwise. //! m Create new directory (mkdir) //! D Delete selected file/directory //! r Rename selected file/directory @@ -107,6 +111,24 @@ impl DisplayEntry { } } +/// What to do with a local XISO that's being uploaded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XisoUploadAction { + /// Walk the XISO and stream each file into `//`. + /// Default when cwd is anywhere other than directly inside an XUID + /// folder (e.g. `/Games/`, `/`, an arbitrary user folder). + Extract, + /// Convert the XISO into a Games-on-Demand package — writes + /// `//00007000/{,.data/}`. Default when cwd + /// is directly inside `/Content//`, since that's exactly + /// where Xbox 360 BC looks for GoD packages. + God, + /// Copy the source ISO byte-for-byte to `/`. Useful + /// when the user wants to preserve the disc image as-is for later + /// extraction or conversion elsewhere. + Raw, +} + /// How to order the directory listing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SortMode { @@ -145,6 +167,15 @@ enum IoCmd { source: PathBuf, dest_dir: String, }, + /// Convert `source` (an XDVDFS image) to a Games-on-Demand package + /// rooted at `dest_dir` on the FATX volume. Writes + /// `//00007000/{,.data/Data0000..N}`. + /// The worker resolves the human-readable game title from + /// [`fatxlib::titles`] before writing the CON header. + ConvertXisoToGod { + source: PathBuf, + dest_dir: String, + }, Mkdir { path: String, }, @@ -223,9 +254,12 @@ 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, + /// Three-way prompt after detecting an XISO during upload: + /// `x` extracts the contents into a stem-named subfolder, + /// `g` converts to a Games-on-Demand package (Title-ID tree under cwd), + /// `r` falls back to a raw byte copy of the source file. + /// The default action on bare Enter depends on cwd context. + ConfirmXisoUpload, } struct App { @@ -247,9 +281,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, + /// Local XISO path + default action stashed between the upload prompt + /// and the three-way confirmation prompt (extract / GoD / raw). + pending_xiso_upload: Option<(PathBuf, XisoUploadAction)>, /// Current listing sort order. Toggleable with `s`. sort_mode: SortMode, } @@ -802,6 +836,173 @@ fn io_worker( } } + IoCmd::ConvertXisoToGod { 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()); + + // Stage the conversion in a local temp dir, then upload the + // resulting Title-ID tree into FATX. A future refactor can + // plumb `convert_iso` to write straight to FATX via a sink + // trait; the staging approach is simple, correct, and reuses + // the already-tested `copy_from_host_with_control` path. + let staging = + std::env::temp_dir().join(format!("xtafkit-iso2god-{}", std::process::id())); + if let Err(e) = fs::create_dir_all(&staging) { + let _ = resp_tx.send(IoResp::Error { + message: format!("Create staging dir: {}", e), + }); + continue; + } + + // Dry-run pass first so we can resolve the human-readable + // title before the real convert opens the file. + let mut dry_opts = fatxlib::iso2god::ConvertOptions { + dry_run: true, + ..Default::default() + }; + let report = match fatxlib::iso2god::convert_iso(&source, &staging, &mut dry_opts) { + Ok(r) => r, + Err(e) => { + let _ = fs::remove_dir_all(&staging); + let _ = resp_tx.send(IoResp::Error { + message: format!("Parse {}: {}", source.display(), e), + }); + continue; + } + }; + let resolved_name = fatxlib::titles::lookup(report.title_id).map(|t| t.name); + + let _ = resp_tx.send(IoResp::Progress { + message: format!( + "Converting {} ({}) → {}/{:08X}/00007000/{:08X}...", + display_source, + resolved_name.unwrap_or("unknown title"), + dest_dir.trim_end_matches('/'), + report.title_id, + report.media_id, + ), + }); + + // Wire the convert_iso progress + cancel hooks. The two + // closures share the same lifetime so they can both go into + // ConvertOptions without lifetime gymnastics. + let cancel_flag_inner = cancel_flag.clone(); + let abort_fn = move || cancel_flag_inner.load(Ordering::Relaxed); + let resp_tx_inner = resp_tx.clone(); + let mut last_stage = String::new(); + let mut progress_cb = move |stage: &str, current: u64, total: u64| { + let denom = total.max(1); + let stride = (denom / 20).max(1); + if stage != last_stage + || current == 0 + || current == total + || current.is_multiple_of(stride) + { + let _ = resp_tx_inner.send(IoResp::Progress { + message: format!("[{}] {}/{}", stage, current, total), + }); + last_stage = stage.to_string(); + } + }; + + let mut opts = fatxlib::iso2god::ConvertOptions { + trim: fatxlib::iso2god::TrimMode::FromEnd, + game_title: resolved_name, + dry_run: false, + progress: Some(&mut progress_cb), + should_abort: Some(&abort_fn), + }; + + let convert_result = fatxlib::iso2god::convert_iso(&source, &staging, &mut opts); + + match convert_result { + Ok(_) => {} + Err(e) => { + let _ = fs::remove_dir_all(&staging); + let msg = format!("{}", e); + if msg.contains("cancelled") { + let _ = resp_tx.send(IoResp::Cancelled { + message: format!("GoD conversion cancelled ({})", display_source), + }); + } else { + let _ = resp_tx.send(IoResp::Error { + message: format!("convert_iso: {}", msg), + }); + } + continue; + } + } + + let _ = resp_tx.send(IoResp::Progress { + message: "Uploading GoD package to FATX...".to_string(), + }); + + // Upload the staged tree into FATX. The temp dir's name is a + // generated UUID we don't want as a folder on the drive, so + // pass `dest_dir` without a trailing slash — that drops the + // temp dir's CHILDREN (the Title-ID folder we want) directly + // under cwd. + let upload_cancel = cancel_flag.clone(); + let upload_abort = move || upload_cancel.load(Ordering::Relaxed); + let resp_tx_upload = resp_tx.clone(); + let upload_progress = move |path: &str, bytes_done: u64, total: u64| { + let msg = if total > 0 { + format!( + "Uploading: {} ({}/{})", + path, + format_size(bytes_done), + format_size(total) + ) + } else { + format!("Uploading: {}", path) + }; + let _ = resp_tx_upload.send(IoResp::Progress { message: msg }); + }; + + let upload_dest = dest_dir.trim_end_matches('/').to_string(); + let upload_result = vol.copy_from_host_with_control( + &staging, + &upload_dest, + Some(&upload_progress), + Some(&upload_abort), + 100, + 256 * 1024 * 1024, + ); + let _ = fs::remove_dir_all(&staging); + let _ = vol.flush(); + + match upload_result { + Ok((files, _dirs, bytes)) => { + let _ = resp_tx.send(IoResp::Done { + message: format!( + "Converted {} → {}/{:08X}/00007000/{:08X} ({} files, {})", + display_source, + upload_dest, + report.title_id, + report.media_id, + files, + format_size(bytes), + ), + }); + } + Err(e) => { + if cancel_flag.load(Ordering::Relaxed) { + let _ = resp_tx.send(IoResp::Cancelled { + message: format!("GoD upload cancelled ({})", display_source), + }); + } else { + let _ = resp_tx.send(IoResp::Error { + message: format!("GoD upload: {}", e), + }); + } + } + } + } + IoCmd::Mkdir { path } => match vol.create_directory(&path) { Ok(_) => { let _ = vol.flush(); @@ -1477,14 +1678,35 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) 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()); + // Detected an Xbox disc image. Pick the default action + // based on cwd: inside a per-user Content folder (where + // GoD packages live), default to GoD; everywhere else + // default to extract (works for alt dashboards). + let default = if fatxlib::display::folder_slot(&app.cwd) + == fatxlib::display::FolderSlot::TitleId + { + XisoUploadAction::God + } else { + XisoUploadAction::Extract + }; + let prompt = match default { + XisoUploadAction::Extract => format!( + "Detected XISO '{}'. e(X)tract / (g)oD / (r)aw / Esc:", + filename + ), + XisoUploadAction::God => format!( + "Detected XISO '{}'. e(x)tract / (G)oD / (r)aw / Esc:", + filename + ), + XisoUploadAction::Raw => format!( + "Detected XISO '{}'. e(x)tract / (g)oD / (R)aw / Esc:", + filename + ), + }; + app.pending_xiso_upload = Some((path.clone(), default)); 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_mode = InputMode::ConfirmXisoUpload; + app.input_prompt = prompt; app.input_buffer.clear(); } else { let fatx_path = app.full_path(&filename); @@ -1497,44 +1719,72 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) 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, + let (path, default) = match app.pending_xiso_upload.take() { + Some(pair) => pair, None => { app.set_error("Internal: missing pending XISO path."); return; } }; + let trimmed = input.trim(); + let action = if trimmed.is_empty() { + default + } else { + match trimmed.chars().next().map(|c| c.to_ascii_lowercase()) { + Some('x') => XisoUploadAction::Extract, + Some('g') => XisoUploadAction::God, + Some('r') => XisoUploadAction::Raw, + _ => { + app.set_error(&format!( + "Unknown choice {:?} — expected x, g, or r.", + trimmed + )); + 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; + match action { + XisoUploadAction::Extract => { + // Subfolder name = file stem; fall back to filename + // if there's no extension to strip. + 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; + } + XisoUploadAction::God => { + let dest_dir = app.cwd.clone(); + app.set_status(&format!( + "Converting '{}' to GoD under {}...", + filename, dest_dir + )); + let _ = cmd_tx.send(IoCmd::ConvertXisoToGod { + source: path, + dest_dir, + }); + app.is_busy = true; + } + XisoUploadAction::Raw => { + 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; + } } } else if app.input_prompt.starts_with("New directory") { // Mkdir @@ -1594,7 +1844,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_mode == InputMode::ConfirmXisoUpload { app.input_buffer = c.to_string(); } else { @@ -1785,6 +2035,43 @@ mod tests { assert!(is_xiso_junk("$systemupdate/foo")); } + #[test] + fn test_xiso_upload_default_god_inside_xuid_folder() { + // cwd directly inside /Content// should default to GoD, + // because that's where Xbox 360 BC looks for title-id folders. + assert_eq!( + fatxlib::display::folder_slot("/Content/0000000000000000"), + fatxlib::display::FolderSlot::TitleId + ); + assert_eq!( + fatxlib::display::folder_slot("/Content/E0001A0BC2E16C4D"), + fatxlib::display::FolderSlot::TitleId + ); + } + + #[test] + fn test_xiso_upload_default_extract_elsewhere() { + // Anywhere outside `/Content//` should default to extract. + assert_ne!( + fatxlib::display::folder_slot("/"), + fatxlib::display::FolderSlot::TitleId + ); + assert_ne!( + fatxlib::display::folder_slot("/Games"), + fatxlib::display::FolderSlot::TitleId + ); + assert_ne!( + fatxlib::display::folder_slot("/Content"), + fatxlib::display::FolderSlot::TitleId + ); + // Deeper than the XUID folder: we're inside a title-id folder + // already, so children are content-type folders — extract default. + assert_ne!( + fatxlib::display::folder_slot("/Content/0000000000000000/4D530002"), + fatxlib::display::FolderSlot::TitleId + ); + } + #[test] fn test_is_xiso_junk_does_not_match_substring() { assert!(!is_xiso_junk("default.xbe")); From 92c5f810c7cec68074ca6587462fb98b3e2fd1f8 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 03:03:57 +1000 Subject: [PATCH 04/12] better separation and reusing iso2god xex utils --- fatxlib/src/{iso2god => }/executable/mod.rs | 4 + fatxlib/src/{iso2god => }/executable/xbe.rs | 2 +- fatxlib/src/{iso2god => }/executable/xex.rs | 2 +- fatxlib/src/iso2god/convert.rs | 341 +++++++++++++++++++- fatxlib/src/iso2god/god/con_header.rs | 2 +- fatxlib/src/iso2god/god/file_layout.rs | 2 +- fatxlib/src/iso2god/mod.rs | 16 +- fatxlib/src/lib.rs | 1 + fatxlib/src/xiso/mod.rs | 36 +++ fatxlib/tests/iso2god_roundtrip.rs | 90 +++++- fatxlib/tests/xiso_reader.rs | 15 + src/tui.rs | 293 +++++++++++------ 12 files changed, 684 insertions(+), 120 deletions(-) rename fatxlib/src/{iso2god => }/executable/mod.rs (93%) rename fatxlib/src/{iso2god => }/executable/xbe.rs (97%) rename fatxlib/src/{iso2god => }/executable/xex.rs (98%) diff --git a/fatxlib/src/iso2god/executable/mod.rs b/fatxlib/src/executable/mod.rs similarity index 93% rename from fatxlib/src/iso2god/executable/mod.rs rename to fatxlib/src/executable/mod.rs index 0066467..36244a6 100644 --- a/fatxlib/src/iso2god/executable/mod.rs +++ b/fatxlib/src/executable/mod.rs @@ -1,5 +1,9 @@ use crate::error::{FatxError, Result}; use crate::iso2god::god::ContentType; +// NB: ContentType lives in `iso2god::god` because it's part of the GoD +// container format's CON header. We pull it in here only because TitleInfo +// reports it alongside the execution info. If iso2god ever moves out to a +// sibling crate, this `use` becomes the seam to revisit. use byteorder::{BE, LE, ReadBytesExt}; use std::io::{Read, Seek, SeekFrom}; use xdvdfs::{blockdev::BlockDeviceRead, layout::VolumeDescriptor}; diff --git a/fatxlib/src/iso2god/executable/xbe.rs b/fatxlib/src/executable/xbe.rs similarity index 97% rename from fatxlib/src/iso2god/executable/xbe.rs rename to fatxlib/src/executable/xbe.rs index ace429e..54baada 100644 --- a/fatxlib/src/iso2god/executable/xbe.rs +++ b/fatxlib/src/executable/xbe.rs @@ -1,5 +1,5 @@ use crate::error::{FatxError, Result}; -use crate::iso2god::executable::TitleExecutionInfo; +use crate::executable::TitleExecutionInfo; use byteorder::{LE, ReadBytesExt}; use std::io::{Read, Seek, SeekFrom}; diff --git a/fatxlib/src/iso2god/executable/xex.rs b/fatxlib/src/executable/xex.rs similarity index 98% rename from fatxlib/src/iso2god/executable/xex.rs rename to fatxlib/src/executable/xex.rs index 1d3bbd7..21c01cd 100644 --- a/fatxlib/src/iso2god/executable/xex.rs +++ b/fatxlib/src/executable/xex.rs @@ -6,7 +6,7 @@ use bitflags::bitflags; use num_enum::TryFromPrimitive; use crate::error::{FatxError, Result}; -use crate::iso2god::executable::TitleExecutionInfo; +use crate::executable::TitleExecutionInfo; #[derive(Clone, Debug)] pub struct XexHeader { diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index 95f7fb1..4ed07e5 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -12,12 +12,16 @@ //! mode could land later as an opt-in flag. use std::fs::{self, File}; -use std::io::{BufReader, BufWriter, Seek, SeekFrom, Write}; +use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}; use std::path::Path; use crate::error::{FatxError, Result}; -use crate::iso2god::executable::TitleInfo; -use crate::iso2god::god::{self, ConHeaderBuilder, ContentType, FileLayout, HashList}; +use crate::executable::TitleInfo; +use crate::iso2god::god::{ + self, BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ContentType, FileLayout, HashList, + SUBPART_SIZE, SUBPARTS_PER_PART, +}; +use crate::volume::FatxVolume; /// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — /// large enough that the default 8 KiB capacity's syscall tax disappears @@ -293,3 +297,334 @@ fn write_part_mht(file_layout: &FileLayout, part_index: u64, mht: &HashList) -> mht.write(&mut part_file)?; Ok(()) } + +// =========================================================================== +// Streaming variant: write the GoD package straight into a FatxVolume. +// =========================================================================== + +/// Maximum bytes a single Data part file can occupy. Equals `4 KiB +/// master_hash_list + SUBPARTS_PER_PART × (4 KiB sub_hash_list + +/// SUBPART_SIZE)`, which is exactly `BLOCK_SIZE * 0xa290` — the magic +/// constant the CON header uses to describe a full part. +const MAX_PART_BYTES: usize = 4096 + (SUBPARTS_PER_PART as usize) * (4096 + SUBPART_SIZE as usize); + +/// Convert an ISO directly into a Games-on-Demand package rooted at +/// `dest_dir` on a FATX volume — no local staging. +/// +/// Same output as [`convert_iso`] but bypasses the local filesystem +/// entirely: each Data part is built in a reused in-memory buffer +/// (~163 MiB) and streamed into FATX via +/// [`FatxVolume::create_file_from_reader`]. After all parts are written, +/// the MHT chain pass happens in memory and each part's first 4 KiB +/// (the master hash list) is patched on disk with a single +/// read-modify-write at the cluster level. +/// +/// Peak RAM: one part buffer (~163 MiB) plus the per-part master hash +/// list vector (~108 KiB total for a 27-part game). +pub fn convert_iso_to_fatx( + source_iso: &Path, + vol: &mut FatxVolume, + dest_dir: &str, + opts: &mut ConvertOptions<'_>, +) -> Result +where + T: Read + Seek + Write, +{ + // --- Metadata pass (mirrors convert_iso) -------------------------------- + let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; + + let img = File::open(source_iso).map_err(FatxError::Io)?; + let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); + let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; + + let volume = xdvdfs::read::read_volume(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; + + let title_info = TitleInfo::from_image(&mut xiso, volume)?; + let exe_info = title_info.execution_info; + let content_type = title_info.content_type; + + let root_offset = { + xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; + xiso.get_mut().stream_position().map_err(FatxError::Io)? + }; + + let data_size = match opts.trim { + TrimMode::FromEnd => volume + .root_table + .file_tree(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? + .iter() + .map(|dirent| { + if dirent.1.node.dirent.data.is_empty() { + return 0; + } + let off = dirent + .1 + .node + .dirent + .data + .offset::(0) + .unwrap_or(0); + off + dirent.1.node.dirent.data.size() as u64 + }) + .max() + .unwrap_or(0), + TrimMode::None => source_iso_file_meta.len() - root_offset, + }; + + let block_count = data_size.div_ceil(BLOCK_SIZE); + let part_count = block_count.div_ceil(BLOCKS_PER_PART); + + let report = ConvertReport { + title_id: exe_info.title_id, + media_id: exe_info.media_id, + content_type, + part_count, + block_count, + data_size, + }; + + if opts.dry_run { + return Ok(report); + } + if part_count == 0 { + return Err(FatxError::Other( + "convert_iso_to_fatx: source has no data to convert".to_string(), + )); + } + + // --- Compose FATX paths ------------------------------------------------- + let title_id_str = format!("{:08X}", exe_info.title_id); + let content_type_str = format!("{:08X}", content_type as u32); + let media_id_str = match content_type { + ContentType::GamesOnDemand => format!("{:08X}", exe_info.media_id), + ContentType::XboxOriginal => format!("{:08X}", exe_info.title_id), + }; + let dest_root = dest_dir.trim_end_matches('/'); + let title_dir = format!("{}/{}", dest_root, title_id_str); + let content_dir = format!("{}/{}", title_dir, content_type_str); + let con_header_path = format!("{}/{}", content_dir, media_id_str); + let data_dir = format!("{}/{}.data", content_dir, media_id_str); + + ensure_fatx_dir(vol, &title_dir)?; + ensure_fatx_dir(vol, &content_dir)?; + ensure_fatx_dir(vol, &data_dir)?; + + // --- Write Data parts straight into FATX ------------------------------- + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", 0, part_count); + } + + let mut part_buf = vec![0u8; MAX_PART_BYTES]; + let mut master_lists: Vec = Vec::with_capacity(part_count as usize); + let mut last_part_size: u64 = 0; + + for part_index in 0..part_count { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other( + "convert_iso_to_fatx: cancelled".to_string(), + )); + } + + // Fresh source reader per part — matches `convert_iso`'s pattern. + // Unbuffered: each subpart read pulls exactly SUBPART_SIZE bytes + // straight into part_buf, no interposing BufReader copy. + let mut iso = File::open(source_iso).map_err(FatxError::Io)?; + iso.seek(SeekFrom::Start(root_offset)) + .map_err(FatxError::Io)?; + + let (len, master) = fill_part_buf(&mut iso, part_index, &mut part_buf)?; + let part_path = format!("{}/Data{:04}", data_dir, part_index); + let reader = Cursor::new(&part_buf[..len]); + + // Forward per-cluster byte progress from `create_file_from_reader` + // up to the caller — each part takes seconds on a slow USB drive, + // and the caller (e.g. the TUI) handles rate-limiting / throughput + // computation. We temporarily move `opts.progress` into a local so + // the inner closure can borrow it exclusively, then restore it + // after the write. + let mut outer = opts.progress.take(); + let part_idx_now = part_index; + let part_count_now = part_count; + { + let mut inner = |bytes: u64, total: u64| { + if let Some(cb) = outer.as_deref_mut() { + // Encode "part X/Y" into the stage label so the caller + // can render bytes / throughput / etc as it sees fit. + let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); + cb(&stage, bytes, total); + } + }; + vol.create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; + } + opts.progress = outer; + + master_lists.push(master); + last_part_size = len as u64; + + // No vol.flush() here. Each flush forces a positional FAT write + // through the slow USB stack and costs hundreds of milliseconds + // per call — and a mid-conversion crash leaves an invalid GoD + // package either way, so partial flushes don't buy meaningful + // recoverability. We flush once at the end of the parts loop, + // again after the MHT patches, and once more after the CON + // header. + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", part_index + 1, part_count); + } + } + let _ = vol.flush(); + + // --- MHT chain pass (in memory, then patch each part's first cluster) -- + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", 0, part_count); + } + for i in (0..(part_count as usize).saturating_sub(1)).rev() { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other( + "convert_iso_to_fatx: cancelled".to_string(), + )); + } + let next_digest = master_lists[i + 1].digest(); + master_lists[i].add_hash(&next_digest); + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", (part_count as u64) - 1 - (i as u64), part_count); + } + } + + for (i, master) in master_lists.iter().enumerate() { + let part_path = format!("{}/Data{:04}", data_dir, i); + overwrite_part_master(vol, &part_path, master.bytes())?; + } + let _ = vol.flush(); + + // --- CON header -------------------------------------------------------- + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 0, 1); + } + + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&exe_info) + .with_block_counts(block_count as u32, 0) + .with_data_parts_info( + part_count as u32, + last_part_size + (part_count - 1) * BLOCK_SIZE * 0xa290, + ) + .with_content_type(content_type) + .with_mht_hash(&master_lists[0].digest()); + if let Some(title) = opts.game_title { + con_header = con_header.with_game_title(title); + } + let con_bytes = con_header.finalize(); + let con_len = con_bytes.len() as u64; + vol.create_file_from_reader(&con_header_path, con_len, Cursor::new(con_bytes), None)?; + let _ = vol.flush(); + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 1, 1); + } + + Ok(report) +} + +/// Build one Data part directly in `out`. Returns the actual number of +/// bytes used (the last part is usually shorter than [`MAX_PART_BYTES`]) +/// and the master hash list for that part. `out` must be at least +/// [`MAX_PART_BYTES`] long. +fn fill_part_buf( + data_volume: &mut R, + part_index: u64, + out: &mut [u8], +) -> Result<(usize, HashList)> { + data_volume + .seek_relative((part_index * BLOCKS_PER_PART * BLOCK_SIZE) as i64) + .map_err(FatxError::Io)?; + + let mut master = HashList::new(); + + // First 4 KiB reserved for the master hash list — filled in at the end. + let mut cursor = 4096usize; + let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; + + for _ in 0..SUBPARTS_PER_PART { + let mut got = 0usize; + while got < subpart_buf.len() { + let n = data_volume + .read(&mut subpart_buf[got..]) + .map_err(FatxError::Io)?; + if n == 0 { + break; + } + got += n; + } + if got == 0 { + break; + } + let subpart = &subpart_buf[..got]; + + let mut sub_hash = HashList::new(); + for block in subpart.chunks(BLOCK_SIZE as usize) { + sub_hash.add_block_hash(block); + } + + out[cursor..cursor + 4096].copy_from_slice(sub_hash.bytes()); + cursor += 4096; + out[cursor..cursor + got].copy_from_slice(subpart); + cursor += got; + + master.add_block_hash(sub_hash.bytes()); + + if got < SUBPART_SIZE as usize { + break; + } + } + + out[0..4096].copy_from_slice(master.bytes()); + Ok((cursor, master)) +} + +/// Read the file's first cluster, overwrite its first 4 KiB with +/// `new_master`, write the cluster back. Used to patch each Data part's +/// master hash list after the MHT chain pass. +fn overwrite_part_master( + vol: &mut FatxVolume, + path: &str, + new_master: &[u8; 4096], +) -> Result<()> +where + T: Read + Seek + Write, +{ + let entry = vol.resolve_path(path)?; + let first_cluster = entry.first_cluster; + let cluster_size = vol.superblock.cluster_size() as usize; + let mut cluster_buf = vec![0u8; cluster_size]; + vol.read_cluster(first_cluster, &mut cluster_buf)?; + cluster_buf[..new_master.len()].copy_from_slice(new_master); + vol.write_cluster(first_cluster, &cluster_buf)?; + Ok(()) +} + +/// Create a directory on the FATX volume if it doesn't already exist. +/// Errors out if the path resolves to a regular file. +fn ensure_fatx_dir(vol: &mut FatxVolume, path: &str) -> Result<()> +where + T: Read + Seek + Write, +{ + match vol.create_directory(path) { + Ok(()) => Ok(()), + Err(FatxError::FileExists(_)) => { + let existing = vol.resolve_path(path)?; + if !existing.is_directory() { + return Err(FatxError::NotADirectory(path.to_string())); + } + Ok(()) + } + Err(e) => Err(e), + } +} diff --git a/fatxlib/src/iso2god/god/con_header.rs b/fatxlib/src/iso2god/god/con_header.rs index 34dcdd5..ffe8e3e 100644 --- a/fatxlib/src/iso2god/god/con_header.rs +++ b/fatxlib/src/iso2god/god/con_header.rs @@ -1,6 +1,6 @@ use byteorder::{BE, ByteOrder, LE}; -use crate::iso2god::executable::TitleExecutionInfo; +use crate::executable::TitleExecutionInfo; use crate::iso2god::sha1_digest; const EMPTY_LIVE: &[u8] = include_bytes!("empty_live.bin"); diff --git a/fatxlib/src/iso2god/god/file_layout.rs b/fatxlib/src/iso2god/god/file_layout.rs index 169d233..2c5829d 100644 --- a/fatxlib/src/iso2god/god/file_layout.rs +++ b/fatxlib/src/iso2god/god/file_layout.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use crate::iso2god::executable::TitleExecutionInfo; +use crate::executable::TitleExecutionInfo; use super::*; diff --git a/fatxlib/src/iso2god/mod.rs b/fatxlib/src/iso2god/mod.rs index b75fa4b..8e48f62 100644 --- a/fatxlib/src/iso2god/mod.rs +++ b/fatxlib/src/iso2god/mod.rs @@ -2,14 +2,15 @@ //! //! Vendored from [QAston/iso2god-rs `xdvdfx` branch](https://github.com/QAston/iso2god-rs/tree/xdvdfx) //! (parent: [iliazeus/iso2god-rs](https://github.com/iliazeus/iso2god-rs); -//! both MIT-licensed). We keep the upstream module shape (`god/`, `executable/`) -//! so we can re-sync against new upstream commits with minimal diff. Local -//! deviations from upstream: +//! both MIT-licensed). Local deviations from upstream: //! //! - `anyhow::Error` → [`crate::error::FatxError`] so errors flow through //! the same channel as the rest of fatxlib. -//! - Intra-crate `use crate::god` / `use crate::executable` imports rewritten -//! to `use crate::iso2god::god` / `use crate::iso2god::executable`. +//! - Upstream's `src/executable/` lives at [`crate::executable`] now — it +//! gets shared with [`crate::xiso`] for folder-name resolution and isn't +//! specific to GoD conversion. +//! - Intra-crate `use crate::god` imports rewritten to +//! `use crate::iso2god::god`. //! - The original `src/game_list/` (4.9 KLOC of compiled-in title catalog) is //! dropped; fatxlib already has a richer catalog via [`crate::titles`]. //! - The upstream binary (`src/bin/iso2god.rs`) lives elsewhere — fatxlib only @@ -17,11 +18,12 @@ //! //! See `NOTICE` at the repo root for the full attribution. -pub mod executable; pub mod god; mod convert; -pub use convert::{ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso}; +pub use convert::{ + ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso, convert_iso_to_fatx, +}; /// Single hot-path SHA-1 entry point used by [`god::HashList`] and /// [`god::ConHeaderBuilder`]. With the `openssl-hash` feature (default on) diff --git a/fatxlib/src/lib.rs b/fatxlib/src/lib.rs index 03ad71e..be8192c 100644 --- a/fatxlib/src/lib.rs +++ b/fatxlib/src/lib.rs @@ -28,6 +28,7 @@ pub mod content_types; pub mod display; pub mod error; +pub mod executable; pub mod iso2god; pub mod partition; pub mod platform; diff --git a/fatxlib/src/xiso/mod.rs b/fatxlib/src/xiso/mod.rs index 1742220..ccea4b7 100644 --- a/fatxlib/src/xiso/mod.rs +++ b/fatxlib/src/xiso/mod.rs @@ -141,6 +141,28 @@ impl XisoImage { LAYOUTS.iter().find(|l| l.offset == self.partition_offset) } + /// Parse the embedded `Default.xex` (Xbox 360) or `default.xbe` + /// (Original Xbox) and return the title's execution info — TitleID, + /// MediaID, version, content type, etc. Returns `None` if the image + /// has neither executable. + /// + /// Useful for resolving a human-readable game title via + /// [`crate::titles::lookup`] before extracting, so on-drive folder + /// names track the game rather than the local filename. + pub fn title_info(&mut self) -> Result> { + let mut shifted = ShiftedSource { + inner: &mut self.source, + offset: self.partition_offset, + }; + match crate::executable::TitleInfo::from_image(&mut shifted, self.volume) { + Ok(info) => Ok(Some(info)), + Err(crate::error::FatxError::Other(msg)) if msg.contains("no executable found") => { + Ok(None) + } + Err(e) => Err(e), + } + } + /// Walk the entire directory tree, returning every file (not directories) /// as a flat list with image-relative paths and data-partition-relative /// byte offsets. @@ -254,6 +276,20 @@ impl BlockDeviceRead for ShiftedSo } } +// `xdvdfs::executable::TitleInfo::from_image` requires `R: BlockDeviceRead + Seek`. +// We pass the inner Seek through, shifting `Start` positions into the data +// partition; `Current` / `End` are forwarded unchanged. +impl Seek for ShiftedSource<'_, R> { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + let adjusted = match pos { + std::io::SeekFrom::Start(s) => std::io::SeekFrom::Start(s + self.offset), + other => other, + }; + let abs = self.inner.seek(adjusted)?; + Ok(abs.saturating_sub(self.offset)) + } +} + /// 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( diff --git a/fatxlib/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index 4e2bf83..bbcea54 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -1,4 +1,5 @@ -//! Integration smoke test for [`fatxlib::iso2god::convert_iso`]. +//! Integration smoke test for [`fatxlib::iso2god::convert_iso`] and +//! [`fatxlib::iso2god::convert_iso_to_fatx`]. //! //! Runs end-to-end against the bundled `tiny.xiso` fixture — a synthetic //! XISO packed via `xdvdfs pack` that contains a real `default.xex` @@ -11,10 +12,12 @@ //! byte-equality is best validated against an external reference //! conversion when one is available. +mod common; + use std::fs; use std::path::PathBuf; -use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso}; +use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso, convert_iso_to_fatx}; fn fixture_path() -> Option { let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny.xiso"); @@ -154,3 +157,86 @@ fn fixture_extracts_expected_title_id() { "expected XellLaunch2_retail TitleID; fixture may have changed" ); } + +#[test] +fn streams_fixture_into_fatx_volume() { + let Some(iso) = fixture_path() else { + return; + }; + let (_tmp, mut vol) = common::create_fatx_image(8); + + let mut opts = ConvertOptions { + trim: TrimMode::FromEnd, + game_title: Some("XellLaunch2 fixture"), + dry_run: false, + progress: None, + should_abort: None, + }; + + let report = convert_iso_to_fatx(&iso, &mut vol, "/", &mut opts).expect("convert_iso_to_fatx"); + + assert!(report.title_id != 0); + assert!(report.part_count >= 1); + + // The Title-ID tree should live at the FATX root. + let title_dir = format!("/{:08X}", report.title_id); + let content_dir = format!("{}/{:08X}", title_dir, report.content_type as u32); + let media_id_hex = if matches!( + report.content_type, + fatxlib::iso2god::god::ContentType::XboxOriginal + ) { + format!("{:08X}", report.title_id) + } else { + format!("{:08X}", report.media_id) + }; + let con_header_path = format!("{}/{}", content_dir, media_id_hex); + let data_dir = format!("{}/{}.data", content_dir, media_id_hex); + let first_part_path = format!("{}/Data0000", data_dir); + + let header_bytes = vol + .read_file_by_path(&con_header_path) + .expect("read CON header from FATX"); + assert_eq!( + header_bytes.len(), + 0xB000, + "CON header should be 45 056 bytes" + ); + assert_eq!( + &header_bytes[..4], + b"LIVE", + "CON header missing LIVE magic; got {:?}", + &header_bytes[..4] + ); + + let first_part_bytes = vol + .read_file_by_path(&first_part_path) + .expect("read Data0000 from FATX"); + assert!( + !first_part_bytes.is_empty(), + "Data0000 should be non-empty on FATX" + ); +} + +#[test] +fn streaming_dry_run_writes_nothing_to_fatx() { + let Some(iso) = fixture_path() else { + return; + }; + let (_tmp, mut vol) = common::create_fatx_image(4); + + let initial_free = vol.stats().expect("stats").free_clusters; + + let mut opts = ConvertOptions { + dry_run: true, + ..Default::default() + }; + let report = + convert_iso_to_fatx(&iso, &mut vol, "/", &mut opts).expect("dry-run convert_iso_to_fatx"); + assert!(report.part_count >= 1); + + let final_free = vol.stats().expect("stats").free_clusters; + assert_eq!( + final_free, initial_free, + "dry-run must not allocate any clusters" + ); +} diff --git a/fatxlib/tests/xiso_reader.rs b/fatxlib/tests/xiso_reader.rs index f1055bb..b41a15c 100644 --- a/fatxlib/tests/xiso_reader.rs +++ b/fatxlib/tests/xiso_reader.rs @@ -160,6 +160,21 @@ fn extract_fixture_into_fatx_volume() { } } +#[test] +fn fixture_title_info_returns_xellaunch() { + let Some(mut img) = open_fixture() else { + return; + }; + let info = img + .title_info() + .expect("title_info should succeed on fixture") + .expect("fixture has a Default.xex, so title_info must be Some"); + assert_eq!( + info.execution_info.title_id, 0xFFFF011D, + "fixture default.xex is XellLaunch2_retail (TitleID 0xFFFF011D)" + ); +} + #[test] fn streams_invokes_progress_callback() { let Some(mut img) = open_fixture() else { diff --git a/src/tui.rs b/src/tui.rs index 5b9ed83..8f4498a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -316,6 +316,56 @@ fn is_xiso(path: &std::path::Path) -> bool { } } +/// Resolve the destination folder name for an XISO extract by reading the +/// embedded `Default.xex` / `default.xbe` and looking the TitleID up in +/// [`fatxlib::titles`]. Returns the catalog-known game name, sanitized for +/// FATX's filename rules. Returns `None` if the image has no parsable +/// executable, the TitleID isn't in the catalog, or the resulting name is +/// empty after sanitization — callers should fall back to the file stem. +fn xiso_folder_name(path: &std::path::Path) -> Option { + let file = std::fs::File::open(path).ok()?; + let mut img = XisoImage::open(file).ok()?; + let info = img.title_info().ok().flatten()?; + let resolved = fatxlib::titles::lookup(info.execution_info.title_id)?; + let sanitized = sanitize_fatx_filename(resolved.name); + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } +} + +/// Coerce a free-form string into something FATX will accept as a filename. +/// Replaces characters the filesystem rejects with `-`, collapses runs of +/// whitespace, trims edge punctuation, and truncates to FATX's 42-byte +/// filename limit. +fn sanitize_fatx_filename(raw: &str) -> String { + const MAX_LEN: usize = 42; + // FATX rejects: < > : " / \ | ? * (plus controls). Replace with '-'. + let mut cleaned: String = raw + .chars() + .map(|c| match c { + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '-', + c if c.is_control() => '-', + c => c, + }) + .collect(); + while cleaned.contains(" ") { + cleaned = cleaned.replace(" ", " "); + } + let trimmed = cleaned.trim_matches(['.', ' ']); + if trimmed.len() <= MAX_LEN { + trimmed.to_string() + } else { + trimmed + .chars() + .take(MAX_LEN) + .collect::() + .trim_end_matches(['.', ' ', '-']) + .to_string() + } +} + /// 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 @@ -844,30 +894,22 @@ fn io_worker( .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| source.display().to_string()); - // Stage the conversion in a local temp dir, then upload the - // resulting Title-ID tree into FATX. A future refactor can - // plumb `convert_iso` to write straight to FATX via a sink - // trait; the staging approach is simple, correct, and reuses - // the already-tested `copy_from_host_with_control` path. - let staging = - std::env::temp_dir().join(format!("xtafkit-iso2god-{}", std::process::id())); - if let Err(e) = fs::create_dir_all(&staging) { - let _ = resp_tx.send(IoResp::Error { - message: format!("Create staging dir: {}", e), - }); - continue; - } + let upload_dest = dest_dir.trim_end_matches('/').to_string(); - // Dry-run pass first so we can resolve the human-readable - // title before the real convert opens the file. + // Dry-run first so we can resolve the human-readable title + // and announce the destination before the streaming pass. let mut dry_opts = fatxlib::iso2god::ConvertOptions { dry_run: true, ..Default::default() }; - let report = match fatxlib::iso2god::convert_iso(&source, &staging, &mut dry_opts) { + let report = match fatxlib::iso2god::convert_iso_to_fatx( + &source, + &mut vol, + &dest_dir, + &mut dry_opts, + ) { Ok(r) => r, Err(e) => { - let _ = fs::remove_dir_all(&staging); let _ = resp_tx.send(IoResp::Error { message: format!("Parse {}: {}", source.display(), e), }); @@ -881,32 +923,75 @@ fn io_worker( "Converting {} ({}) → {}/{:08X}/00007000/{:08X}...", display_source, resolved_name.unwrap_or("unknown title"), - dest_dir.trim_end_matches('/'), + upload_dest, report.title_id, report.media_id, ), }); - // Wire the convert_iso progress + cancel hooks. The two - // closures share the same lifetime so they can both go into - // ConvertOptions without lifetime gymnastics. + // Wire progress + cancel hooks. Both closures share the + // same lifetime so they can co-exist in ConvertOptions. let cancel_flag_inner = cancel_flag.clone(); let abort_fn = move || cancel_flag_inner.load(Ordering::Relaxed); let resp_tx_inner = resp_tx.clone(); let mut last_stage = String::new(); + let mut last_emit_at: Option = None; + let mut last_emit_bytes: u64 = 0; let mut progress_cb = move |stage: &str, current: u64, total: u64| { - let denom = total.max(1); - let stride = (denom / 20).max(1); - if stage != last_stage - || current == 0 - || current == total - || current.is_multiple_of(stride) - { + let stage_changed = stage != last_stage; + let now = std::time::Instant::now(); + + // Byte-level stages ("part X/Y"): rate-limit to ~200 ms + // intervals, and compute MiB/s from the delta between + // emits. Stage transitions always emit so the user sees + // each part's first tick immediately. + if stage.starts_with("part ") { + if !stage_changed + && let Some(t) = last_emit_at + && now.duration_since(t).as_millis() < 200 + { + return; + } + let throughput = if !stage_changed { + last_emit_at + .map(|t| { + let dt = now.duration_since(t).as_secs_f64(); + let dbytes = current.saturating_sub(last_emit_bytes); + let rate = (dbytes as f64) / dt / (1024.0 * 1024.0); + format!(" @ {:.1} MiB/s", rate) + }) + .unwrap_or_default() + } else { + String::new() + }; + let msg = format!( + "[{}] {} / {}{}", + stage, + format_size(current), + format_size(total), + throughput + ); + let _ = resp_tx_inner.send(IoResp::Progress { message: msg }); + } else { + // Integer-milestone stages (parts / mht / header): + // keep the 5 % throttle, render the raw count. + let denom = total.max(1); + let stride = (denom / 20).max(1); + if !stage_changed + && current != 0 + && current != total + && !current.is_multiple_of(stride) + { + return; + } let _ = resp_tx_inner.send(IoResp::Progress { message: format!("[{}] {}/{}", stage, current, total), }); - last_stage = stage.to_string(); } + + last_stage = stage.to_string(); + last_emit_at = Some(now); + last_emit_bytes = current; }; let mut opts = fatxlib::iso2god::ConvertOptions { @@ -917,86 +1002,35 @@ fn io_worker( should_abort: Some(&abort_fn), }; - let convert_result = fatxlib::iso2god::convert_iso(&source, &staging, &mut opts); - - match convert_result { - Ok(_) => {} - Err(e) => { - let _ = fs::remove_dir_all(&staging); - let msg = format!("{}", e); - if msg.contains("cancelled") { - let _ = resp_tx.send(IoResp::Cancelled { - message: format!("GoD conversion cancelled ({})", display_source), - }); - } else { - let _ = resp_tx.send(IoResp::Error { - message: format!("convert_iso: {}", msg), - }); - } - continue; - } - } - - let _ = resp_tx.send(IoResp::Progress { - message: "Uploading GoD package to FATX...".to_string(), - }); - - // Upload the staged tree into FATX. The temp dir's name is a - // generated UUID we don't want as a folder on the drive, so - // pass `dest_dir` without a trailing slash — that drops the - // temp dir's CHILDREN (the Title-ID folder we want) directly - // under cwd. - let upload_cancel = cancel_flag.clone(); - let upload_abort = move || upload_cancel.load(Ordering::Relaxed); - let resp_tx_upload = resp_tx.clone(); - let upload_progress = move |path: &str, bytes_done: u64, total: u64| { - let msg = if total > 0 { - format!( - "Uploading: {} ({}/{})", - path, - format_size(bytes_done), - format_size(total) - ) - } else { - format!("Uploading: {}", path) - }; - let _ = resp_tx_upload.send(IoResp::Progress { message: msg }); - }; - - let upload_dest = dest_dir.trim_end_matches('/').to_string(); - let upload_result = vol.copy_from_host_with_control( - &staging, - &upload_dest, - Some(&upload_progress), - Some(&upload_abort), - 100, - 256 * 1024 * 1024, - ); - let _ = fs::remove_dir_all(&staging); - let _ = vol.flush(); - - match upload_result { - Ok((files, _dirs, bytes)) => { + match fatxlib::iso2god::convert_iso_to_fatx(&source, &mut vol, &dest_dir, &mut opts) + { + Ok(r) => { + let _ = vol.flush(); + // Rough total: per-part overhead (4 KiB master + + // 4 KiB × subparts) plus the CON header. Reporting + // the source-side data size is close enough. let _ = resp_tx.send(IoResp::Done { message: format!( - "Converted {} → {}/{:08X}/00007000/{:08X} ({} files, {})", + "Converted {} → {}/{:08X}/00007000/{:08X} ({} parts, ~{})", display_source, upload_dest, - report.title_id, - report.media_id, - files, - format_size(bytes), + r.title_id, + r.media_id, + r.part_count, + format_size(r.data_size), ), }); } Err(e) => { - if cancel_flag.load(Ordering::Relaxed) { + let _ = vol.flush(); + let msg = format!("{}", e); + if msg.contains("cancelled") { let _ = resp_tx.send(IoResp::Cancelled { - message: format!("GoD upload cancelled ({})", display_source), + message: format!("GoD conversion cancelled ({})", display_source), }); } else { let _ = resp_tx.send(IoResp::Error { - message: format!("GoD upload: {}", e), + message: format!("GoD convert: {}", msg), }); } } @@ -1534,9 +1568,8 @@ fn handle_normal_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) } } KeyCode::Char('u') => { - let default = app.download_dir.to_string_lossy().to_string(); app.input_prompt = format!("Upload file/directory to '{}':", app.cwd); - app.input_buffer = default; + app.input_buffer.clear(); app.input_mode = InputMode::UploadPath; } KeyCode::Char('m') => { @@ -1750,13 +1783,18 @@ fn handle_input_key(app: &mut App, cmd_tx: &mpsc::Sender, key: KeyEvent) match action { XisoUploadAction::Extract => { - // Subfolder name = file stem; fall back to filename - // if there's no extension to strip. + // Prefer a catalog-resolved folder name over the + // local filename stem: a disc named `disc1.iso` + // with TitleID 0x4D5307E6 should land at + // `/Halo 3 [4D5307E6]/` rather than `/disc1/`. + // Falls back to the file stem on catalog miss or + // unreadable XEX/XBE. 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); + let resolved = xiso_folder_name(&path).unwrap_or(stem); + let dest_dir = app.full_path(&resolved); app.set_status(&format!("Extracting '{}' → {}...", filename, dest_dir)); let _ = cmd_tx.send(IoCmd::ExtractXiso { source: path, @@ -1946,12 +1984,17 @@ fn ui(frame: &mut Frame, app: &mut App) { if app.input_mode != InputMode::Normal { let input_text = format!(" {} {}", app.input_prompt, app.input_buffer); let input_bar = Paragraph::new(input_text) - .style(Style::default().fg(Color::LightYellow).bg(Color::Blue)) + .style( + Style::default() + .fg(Color::Yellow) + .bg(Color::DarkGray) + .bold(), + ) .block( Block::default() .title(" Input (Enter to confirm, Esc to cancel) ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::LightYellow)), + .border_style(Style::default().fg(Color::Yellow).bold()), ); frame.render_widget(input_bar, chunks[2]); @@ -2035,6 +2078,48 @@ mod tests { assert!(is_xiso_junk("$systemupdate/foo")); } + #[test] + fn test_sanitize_fatx_filename_replaces_illegal_chars() { + assert_eq!( + sanitize_fatx_filename("Halo: Combat Evolved"), + "Halo- Combat Evolved" + ); + assert_eq!( + sanitize_fatx_filename(r"Tom Clancy's R6: Vegas "), + "Tom Clancy's R6- Vegas -DEMO-" + ); + assert_eq!( + sanitize_fatx_filename("path/with\\slashes"), + "path-with-slashes" + ); + } + + #[test] + fn test_sanitize_fatx_filename_truncates_to_42_bytes() { + let long = "A Really Long Game Subtitle That Definitely Will Not Fit"; + let s = sanitize_fatx_filename(long); + assert!(s.len() <= 42, "got {:?} ({} bytes)", s, s.len()); + // Truncation should be at a word boundary or just under 42 bytes; + // never end with a dangling separator. + assert!(!s.ends_with(' ')); + assert!(!s.ends_with('-')); + assert!(!s.ends_with('.')); + } + + #[test] + fn test_sanitize_fatx_filename_collapses_runs_of_whitespace() { + assert_eq!( + sanitize_fatx_filename("Halo 3 Anniversary"), + "Halo 3 Anniversary" + ); + } + + #[test] + fn test_sanitize_fatx_filename_trims_edges() { + assert_eq!(sanitize_fatx_filename(" Halo 3 "), "Halo 3"); + assert_eq!(sanitize_fatx_filename("...Halo 3..."), "Halo 3"); + } + #[test] fn test_xiso_upload_default_god_inside_xuid_folder() { // cwd directly inside /Content// should default to GoD, From fbecf9f4076a8a1fd5813782d7b3ec6084f609c7 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 03:16:36 +1000 Subject: [PATCH 05/12] exposing iso2god and xtract xiso --- fatxlib/src/iso2god/convert.rs | 28 +- fatxlib/src/iso2god/god/mod.rs | 13 +- fatxlib/tests/iso2god_roundtrip.rs | 85 ++++++ src/main.rs | 433 +++++++++++++++++++++++++++++ 4 files changed, 551 insertions(+), 8 deletions(-) diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index 4ed07e5..e41628c 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -194,8 +194,9 @@ pub fn convert_iso( iso_data_volume .seek(SeekFrom::Start(root_offset)) .map_err(FatxError::Io)?; + let remaining_bytes = part_payload_bytes(data_size, part_index); - god::write_part(iso_data_volume, part_index, part_file)?; + god::write_part(iso_data_volume, part_index, remaining_bytes, part_file)?; if let Some(cb) = opts.progress.as_deref_mut() { cb("parts", part_index + 1, part_count); @@ -308,6 +309,15 @@ fn write_part_mht(file_layout: &FileLayout, part_index: u64, mht: &HashList) -> /// constant the CON header uses to describe a full part. const MAX_PART_BYTES: usize = 4096 + (SUBPARTS_PER_PART as usize) * (4096 + SUBPART_SIZE as usize); +fn part_payload_bytes(data_size: u64, part_index: u64) -> u64 { + let part_start = part_index + .saturating_mul(BLOCKS_PER_PART) + .saturating_mul(BLOCK_SIZE); + data_size + .saturating_sub(part_start) + .min(BLOCKS_PER_PART * BLOCK_SIZE) +} + /// Convert an ISO directly into a Games-on-Demand package rooted at /// `dest_dir` on a FATX volume — no local staging. /// @@ -437,7 +447,8 @@ where iso.seek(SeekFrom::Start(root_offset)) .map_err(FatxError::Io)?; - let (len, master) = fill_part_buf(&mut iso, part_index, &mut part_buf)?; + let remaining_bytes = part_payload_bytes(data_size, part_index); + let (len, master) = fill_part_buf(&mut iso, part_index, remaining_bytes, &mut part_buf)?; let part_path = format!("{}/Data{:04}", data_dir, part_index); let reader = Cursor::new(&part_buf[..len]); @@ -540,6 +551,7 @@ where fn fill_part_buf( data_volume: &mut R, part_index: u64, + remaining_bytes: u64, out: &mut [u8], ) -> Result<(usize, HashList)> { data_volume @@ -551,12 +563,17 @@ fn fill_part_buf( // First 4 KiB reserved for the master hash list — filled in at the end. let mut cursor = 4096usize; let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; + let mut bytes_left = remaining_bytes; for _ in 0..SUBPARTS_PER_PART { + if bytes_left == 0 { + break; + } + let want = (subpart_buf.len() as u64).min(bytes_left) as usize; let mut got = 0usize; - while got < subpart_buf.len() { + while got < want { let n = data_volume - .read(&mut subpart_buf[got..]) + .read(&mut subpart_buf[got..want]) .map_err(FatxError::Io)?; if n == 0 { break; @@ -577,10 +594,11 @@ fn fill_part_buf( cursor += 4096; out[cursor..cursor + got].copy_from_slice(subpart); cursor += got; + bytes_left -= got as u64; master.add_block_hash(sub_hash.bytes()); - if got < SUBPART_SIZE as usize { + if got < want { break; } } diff --git a/fatxlib/src/iso2god/god/mod.rs b/fatxlib/src/iso2god/god/mod.rs index f01f646..9c0e173 100644 --- a/fatxlib/src/iso2god/god/mod.rs +++ b/fatxlib/src/iso2god/god/mod.rs @@ -23,6 +23,7 @@ pub const SUBPART_SIZE: u64 = BLOCK_SIZE * BLOCKS_PER_SUBPART; pub fn write_part( mut data_volume: R, part_index: u64, + remaining_bytes: u64, mut part_file: W, ) -> Result<()> { data_volume @@ -38,14 +39,19 @@ pub fn write_part( // grow/check ceremony and the Vec-append work that came with it. We read // straight into a fixed-size buffer and slice off the actual length. let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; + let mut bytes_left = remaining_bytes; for _subpart_index in 0..SUBPARTS_PER_PART { + if bytes_left == 0 { + break; + } // Fill subpart_buf one read at a time. The last subpart may be // short — that's fine, we slice with `got` below. + let want = (subpart_buf.len() as u64).min(bytes_left) as usize; let mut got = 0usize; - while got < subpart_buf.len() { + while got < want { let n = data_volume - .read(&mut subpart_buf[got..]) + .read(&mut subpart_buf[got..want]) .map_err(FatxError::Io)?; if n == 0 { break; @@ -71,8 +77,9 @@ pub fn write_part( // CoW filesystems), but APFS doesn't honor reflink on partial- // file writes — the re-read just doubled I/O without benefit. part_file.write_all(subpart).map_err(FatxError::Io)?; + bytes_left -= got as u64; - if got < SUBPART_SIZE as usize { + if got < want { break; } } diff --git a/fatxlib/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index bbcea54..67aec2c 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -15,6 +15,8 @@ mod common; use std::fs; +use std::fs::OpenOptions; +use std::io::Write; use std::path::PathBuf; use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso, convert_iso_to_fatx}; @@ -24,6 +26,26 @@ fn fixture_path() -> Option { if p.exists() { Some(p) } else { None } } +fn padded_fixture_path() -> Option<(tempfile::TempDir, PathBuf)> { + let source = fixture_path()?; + let tmp = tempfile::TempDir::new().expect("tempdir"); + let padded = tmp.path().join("tiny-padded.xiso"); + fs::copy(&source, &padded).expect("copy padded fixture"); + let mut file = OpenOptions::new() + .append(true) + .open(&padded) + .expect("open padded fixture"); + file.write_all(&vec![0xA5; 16 * 1024 * 1024]) + .expect("append padding"); + Some((tmp, padded)) +} + +fn expected_part_len(payload_bytes: u64) -> u64 { + let subpart_size = fatxlib::iso2god::god::SUBPART_SIZE; + let subparts = payload_bytes.div_ceil(subpart_size); + 4096 + (subparts * 4096) + payload_bytes +} + #[test] fn converts_fixture_into_valid_god_package() { let Some(iso) = fixture_path() else { @@ -240,3 +262,66 @@ fn streaming_dry_run_writes_nothing_to_fatx() { "dry-run must not allocate any clusters" ); } + +#[test] +fn trim_ignores_appended_tail_padding_for_file_output() { + let Some((_tmp, iso)) = padded_fixture_path() else { + return; + }; + + let out = tempfile::TempDir::new().expect("tempdir"); + let mut opts = ConvertOptions { + trim: TrimMode::FromEnd, + ..Default::default() + }; + + let report = convert_iso(&iso, out.path(), &mut opts).expect("convert padded iso"); + assert_eq!(report.part_count, 1, "fixture should stay single-part"); + + let title_hex = format!("{:08X}", report.title_id); + let ctype_hex = format!("{:08X}", report.content_type as u32); + let media_hex = format!("{:08X}", report.media_id); + let data_path = out + .path() + .join(title_hex) + .join(ctype_hex) + .join(format!("{}.data/Data0000", media_hex)); + + let actual = fs::metadata(&data_path).expect("stat data part").len(); + let expected = expected_part_len(report.data_size); + assert_eq!( + actual, expected, + "trimmed conversion should ignore appended bytes beyond the XDVDFS payload" + ); +} + +#[test] +fn trim_ignores_appended_tail_padding_for_fatx_output() { + let Some((_tmp, iso)) = padded_fixture_path() else { + return; + }; + + let (_img_tmp, mut vol) = common::create_fatx_image(64); + let mut opts = ConvertOptions { + trim: TrimMode::FromEnd, + ..Default::default() + }; + + let report = + convert_iso_to_fatx(&iso, &mut vol, "/", &mut opts).expect("convert padded iso to fatx"); + assert_eq!(report.part_count, 1, "fixture should stay single-part"); + + let data_path = format!( + "/{:08X}/{:08X}/{:08X}.data/Data0000", + report.title_id, report.content_type as u32, report.media_id + ); + let actual = vol + .read_file_by_path(&data_path) + .expect("read data part") + .len() as u64; + let expected = expected_part_len(report.data_size); + assert_eq!( + actual, expected, + "streaming FATX conversion should ignore appended bytes beyond the XDVDFS payload" + ); +} diff --git a/src/main.rs b/src/main.rs index 998046b..756a2d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,6 +172,47 @@ enum Commands { #[arg(long)] no_save: bool, }, + /// Extract every file from an Xbox / Xbox 360 XISO disc image to a + /// local directory. Useful for inspecting an ISO's contents or for + /// feeding loose game files to alt dashboards (Aurora / FreeStyle / XBMC4XBOX). + Extract { + /// Source XISO file + iso: PathBuf, + /// Destination directory (created if missing) + dest: PathBuf, + /// Skip the `$SystemUpdate` folder (dashboard update payload that + /// alt dashboards never run). On by default; pass + /// `--keep-systemupdate` to write it out anyway. + #[arg(long, action = clap::ArgAction::SetTrue)] + keep_systemupdate: bool, + /// Print what would be extracted without writing anything. + #[arg(long)] + dry_run: bool, + }, + /// Convert an Xbox 360 XISO into a Games-on-Demand package in a local + /// directory. Writes `///{,.data/}`. + God { + /// Source XISO file + iso: PathBuf, + /// Destination directory (the title-id tree lands underneath) + dest: PathBuf, + /// How much of the source partition to pack: + /// `from-end` (default) — walk the file tree, pack only the + /// content range. + /// `none` — pack everything from the start of the data partition + /// to the end of the source file. + #[arg(long, value_parser = ["from-end", "none"], default_value = "from-end")] + trim: String, + /// Print the parsed metadata (TitleID, MediaID, data_size, part_count) + /// without writing anything. + #[arg(long)] + dry_run: bool, + /// Override the human-readable title written into the CON header. + /// Defaults to the catalog name for the parsed TitleID, or blank + /// if the catalog doesn't know it. + #[arg(long)] + game_title: Option, + }, } // =========================================================================== @@ -1087,5 +1128,397 @@ fn main() { } } } + + Some(Commands::Extract { + iso, + dest, + keep_systemupdate, + dry_run, + }) => run_extract(&iso, &dest, keep_systemupdate, dry_run, json), + + Some(Commands::God { + iso, + dest, + trim, + dry_run, + game_title, + }) => run_god(&iso, &dest, &trim, dry_run, game_title.as_deref(), json), + } +} + +// =========================================================================== +// `xtafkit extract` — XISO → local directory +// =========================================================================== + +fn run_extract( + iso: &std::path::Path, + dest: &std::path::Path, + keep_systemupdate: bool, + dry_run: bool, + json: bool, +) { + use std::io::BufWriter; + use std::time::Instant; + + let file = match File::open(iso) { + Ok(f) => f, + Err(e) => { + cli_error(json, &format!("open {}: {}", iso.display(), e)); + return; + } + }; + let mut img = match fatxlib::xiso::XisoImage::open(file) { + Ok(i) => i, + Err(e) => { + cli_error(json, &format!("parse {}: {}", iso.display(), e)); + return; + } + }; + let entries = match img.walk_files() { + Ok(v) => v, + Err(e) => { + cli_error(json, &format!("walk {}: {}", iso.display(), e)); + return; + } + }; + + // Partition into kept vs skipped so totals reflect what will actually + // be written. Matches the policy the TUI uses on the upload path. + let (kept, skipped): (Vec<&fatxlib::xiso::XisoFile>, Vec<&fatxlib::xiso::XisoFile>) = + if keep_systemupdate { + (entries.iter().collect(), Vec::new()) + } else { + entries.iter().partition(|e| !is_systemupdate(&e.path)) + }; + 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 layout = img + .layout() + .map(|l| format!("{} (0x{:08X})", l.name, l.offset)) + .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); + + if dry_run { + if json { + println!( + "{}", + serde_json::json!({ + "iso": iso.display().to_string(), + "layout": layout, + "files": total_files, + "bytes": total_bytes, + "skipped_files": skipped_files, + "skipped_bytes": skipped_bytes, + "entries": entries.iter().map(|e| { + serde_json::json!({ + "path": e.path, + "offset": e.offset, + "size": e.size, + "skipped": !keep_systemupdate && is_systemupdate(&e.path), + }) + }).collect::>(), + }) + ); + } else { + println!("ISO: {}", iso.display()); + println!("Layout: {}", layout); + println!("Files: {} ({})", total_files, format_size(total_bytes)); + if skipped_files > 0 { + println!( + "Skipped: {} files in $SystemUpdate ({})", + skipped_files, + format_size(skipped_bytes) + ); + } + println!(); + for e in &entries { + let s = !keep_systemupdate && is_systemupdate(&e.path); + let tag = if s { "skip " } else { "keep " }; + println!( + " {} {:48} @0x{:010X} {}", + tag, + e.path, + e.offset, + format_size(e.size) + ); + } + println!(); + println!("(dry-run; nothing written)"); + } + return; + } + + if let Err(e) = std::fs::create_dir_all(dest) { + cli_error(json, &format!("create_dir_all {}: {}", dest.display(), e)); + return; + } + + let started = Instant::now(); + let mut files_done = 0usize; + let mut bytes_done: u64 = 0; + let last_progress = std::cell::Cell::new(Instant::now()); + + for e in &kept { + let normalized = e.path.replace('\\', "/"); + let local = dest.join(&normalized); + if let Some(parent) = local.parent() + && let Err(err) = std::fs::create_dir_all(parent) + { + cli_error( + json, + &format!("create_dir_all {}: {}", parent.display(), err), + ); + return; + } + let out = match File::create(&local) { + Ok(f) => BufWriter::new(f), + Err(err) => { + cli_error(json, &format!("create {}: {}", local.display(), err)); + return; + } + }; + let mut out = out; + let bytes_done_ref = &mut bytes_done; + let last_progress_ref = &last_progress; + let mut cb = |read: u64, _total: u64| { + // Throttled per-file byte progress for stderr. + if !json && last_progress_ref.get().elapsed().as_millis() > 250 { + eprint!( + "\r [{}/{}] {} ({}/{}) ", + files_done + 1, + total_files, + short_name(&normalized), + format_size(*bytes_done_ref + read), + format_size(total_bytes), + ); + let _ = io::stderr().flush(); + last_progress_ref.set(Instant::now()); + } + }; + let written = match img.read_into(e, &mut out, None, Some(&mut cb)) { + Ok(n) => n, + Err(err) => { + if !json { + eprintln!(); + } + cli_error(json, &format!("read {}: {}", e.path, err)); + return; + } + }; + if let Err(err) = out.flush() { + cli_error(json, &format!("flush {}: {}", local.display(), err)); + return; + } + bytes_done += written; + files_done += 1; + } + let elapsed = started.elapsed(); + if !json { + eprint!("\r{:80}\r", ""); + } + if json { + println!( + "{}", + serde_json::json!({ + "iso": iso.display().to_string(), + "dest": dest.display().to_string(), + "files": files_done, + "bytes": bytes_done, + "skipped_files": skipped_files, + "skipped_bytes": skipped_bytes, + "elapsed_secs": elapsed.as_secs_f64(), + }) + ); + } else { + println!( + "Extracted {} files ({}) → {} in {:?}", + files_done, + format_size(bytes_done), + dest.display(), + elapsed, + ); + if skipped_files > 0 { + println!( + "Skipped {} files in $SystemUpdate ({})", + skipped_files, + format_size(skipped_bytes) + ); + } + } +} + +fn is_systemupdate(image_path: &str) -> bool { + image_path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .eq_ignore_ascii_case("$SystemUpdate") +} + +fn short_name(p: &str) -> &str { + p.rsplit('/').next().unwrap_or(p) +} + +// =========================================================================== +// `xtafkit god` — XISO → local Games-on-Demand package +// =========================================================================== + +fn run_god( + iso: &std::path::Path, + dest: &std::path::Path, + trim: &str, + dry_run: bool, + game_title: Option<&str>, + json: bool, +) { + use std::time::Instant; + + let trim_mode = match trim { + "from-end" => fatxlib::iso2god::TrimMode::FromEnd, + "none" => fatxlib::iso2god::TrimMode::None, + other => { + cli_error(json, &format!("invalid --trim {:?}", other)); + return; + } + }; + + // Catalog-fill the game title from the dry-run report, unless the + // caller passed --game-title explicitly. + let mut dry_opts = fatxlib::iso2god::ConvertOptions { + dry_run: true, + ..Default::default() + }; + let report = match fatxlib::iso2god::convert_iso(iso, dest, &mut dry_opts) { + Ok(r) => r, + Err(e) => { + cli_error(json, &format!("parse {}: {}", iso.display(), e)); + return; + } + }; + let resolved_name = fatxlib::titles::lookup(report.title_id).map(|t| t.name); + let effective_title = game_title.or(resolved_name); + + if dry_run { + if json { + println!( + "{}", + serde_json::json!({ + "iso": iso.display().to_string(), + "title_id": format!("{:08X}", report.title_id), + "media_id": format!("{:08X}", report.media_id), + "name": resolved_name.unwrap_or("(unknown)"), + "content_type": format!("{:?}", report.content_type), + "data_size": report.data_size, + "block_count": report.block_count, + "part_count": report.part_count, + }) + ); + } else { + println!("ISO: {}", iso.display()); + println!("Title ID: {:08X}", report.title_id); + println!("Media ID: {:08X}", report.media_id); + println!( + "Name: {}", + resolved_name.unwrap_or("(unknown — catalog miss)") + ); + println!("Content: {:?}", report.content_type); + println!( + "Data size: {} bytes ({})", + report.data_size, + format_size(report.data_size) + ); + println!("Block count: {}", report.block_count); + println!("Part count: {}", report.part_count); + println!(); + println!("(dry-run; nothing written)"); + } + return; + } + + if let Err(e) = std::fs::create_dir_all(dest) { + cli_error(json, &format!("create_dir_all {}: {}", dest.display(), e)); + return; + } + + let started = Instant::now(); + let last_progress = std::cell::Cell::new(Instant::now()); + let mut last_stage = String::new(); + let mut progress_cb = |stage: &str, current: u64, total: u64| { + let stage_changed = stage != last_stage; + if json { + return; + } + if stage_changed || last_progress.get().elapsed().as_millis() > 250 { + if stage.starts_with("part ") { + eprint!( + "\r [{}] {} / {} ", + stage, + format_size(current), + format_size(total) + ); + } else { + eprint!("\r [{}] {}/{} ", stage, current, total); + } + let _ = io::stderr().flush(); + last_progress.set(Instant::now()); + last_stage = stage.to_string(); + } + }; + + let mut opts = fatxlib::iso2god::ConvertOptions { + trim: trim_mode, + game_title: effective_title, + dry_run: false, + progress: Some(&mut progress_cb), + should_abort: None, + }; + + let result = fatxlib::iso2god::convert_iso(iso, dest, &mut opts); + if !json { + eprint!("\r{:80}\r", ""); + } + let elapsed = started.elapsed(); + match result { + Ok(r) => { + if json { + println!( + "{}", + serde_json::json!({ + "iso": iso.display().to_string(), + "dest": dest.display().to_string(), + "title_id": format!("{:08X}", r.title_id), + "media_id": format!("{:08X}", r.media_id), + "name": resolved_name.unwrap_or("(unknown)"), + "data_size": r.data_size, + "part_count": r.part_count, + "elapsed_secs": elapsed.as_secs_f64(), + }) + ); + } else { + println!( + "Converted {} → {}/{:08X}/{:08X?}/... ({} parts, {} of data) in {:?}", + iso.display(), + dest.display(), + r.title_id, + r.content_type as u32, + r.part_count, + format_size(r.data_size), + elapsed, + ); + } + } + Err(e) => cli_error(json, &format!("convert_iso: {}", e)), + } +} + +fn cli_error(json: bool, msg: &str) { + if json { + println!("{}", serde_json::json!({"error": msg})); + process::exit(0); } + eprintln!("Error: {}", msg); + process::exit(1); } From d97eeae819758728e6dd08b3f727e1bc9ef9bac0 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 03:18:28 +1000 Subject: [PATCH 06/12] trim bug --- src/main.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 756a2d7..8491910 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1388,6 +1388,7 @@ fn run_god( // Catalog-fill the game title from the dry-run report, unless the // caller passed --game-title explicitly. let mut dry_opts = fatxlib::iso2god::ConvertOptions { + trim: trim_mode, dry_run: true, ..Default::default() }; @@ -1492,21 +1493,28 @@ fn run_god( "title_id": format!("{:08X}", r.title_id), "media_id": format!("{:08X}", r.media_id), "name": resolved_name.unwrap_or("(unknown)"), + "content_type": format!("{:?}", r.content_type), "data_size": r.data_size, + "block_count": r.block_count, "part_count": r.part_count, "elapsed_secs": elapsed.as_secs_f64(), }) ); } else { + let resolved_label = resolved_name.unwrap_or("(unknown — catalog miss)"); println!( - "Converted {} → {}/{:08X}/{:08X?}/... ({} parts, {} of data) in {:?}", + "ISO: {}\nTitle ID: {:08X}\nMedia ID: {:08X}\nName: {}\nContent: {:?}\nData size: {} bytes ({})\nBlock count: {}\nPart count: {}\nDest: {}\nElapsed: {:?}", iso.display(), - dest.display(), r.title_id, - r.content_type as u32, - r.part_count, + r.media_id, + resolved_label, + r.content_type, + r.data_size, format_size(r.data_size), - elapsed, + r.block_count, + r.part_count, + dest.display(), + elapsed ); } } From e63cefbe6fc4143438b2573ea518820b4120a056 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 03:37:06 +1000 Subject: [PATCH 07/12] trim options --- Cargo.lock | 75 +++++++++++++++++- fatxlib/Cargo.toml | 3 +- fatxlib/examples/iso2god.rs | 23 +++--- fatxlib/src/iso2god/convert.rs | 123 ++++++++++++++++++++++++++--- fatxlib/tests/iso2god_roundtrip.rs | 67 ++++++++++++++-- src/main.rs | 14 ++-- src/tui.rs | 3 +- 7 files changed, 273 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4ac436..91594f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,6 +365,27 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -922,6 +943,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -993,6 +1023,21 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lab" version = "0.11.0" @@ -1450,6 +1495,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1668,7 +1722,7 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "strum", @@ -1720,7 +1774,7 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "strum", @@ -2228,7 +2282,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -2396,6 +2450,20 @@ dependencies = [ "semver", ] +[[package]] +name = "wax" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d12a78aa0bab22d2f26ed1a96df7ab58e8a93506a3e20adb47c51a93b4e1357" +dependencies = [ + "const_format", + "itertools 0.11.0", + "nom", + "pori", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2677,6 +2745,7 @@ dependencies = [ "serde", "serde-big-array", "sha3", + "wax", ] [[package]] diff --git a/fatxlib/Cargo.toml b/fatxlib/Cargo.toml index f023195..8154ba2 100644 --- a/fatxlib/Cargo.toml +++ b/fatxlib/Cargo.toml @@ -19,7 +19,8 @@ sha1 = "0.11" byteorder = "1.5" num_enum = "0.7" openssl = { version = "0.10", optional = true } -xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "sync"] } +tempfile = "3" +xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "write", "sync"] } [features] default = ["openssl-hash"] diff --git a/fatxlib/examples/iso2god.rs b/fatxlib/examples/iso2god.rs index f0fef47..52c1cc0 100644 --- a/fatxlib/examples/iso2god.rs +++ b/fatxlib/examples/iso2god.rs @@ -2,11 +2,12 @@ //! shape: //! //! ```text -//! iso2god [--trim | --no-trim] [--dry-run] [--game-title TITLE] +//! iso2god [--trim MODE] [--dry-run] [--game-title TITLE] //! ``` //! -//! `--trim` (from-end) is the default — almost everyone wants the trimmed -//! output. Pass `--no-trim` to convert the full source partition. +//! `--trim preserve-layout` is the default. Pass `--trim none` to convert +//! the full source partition, or `--trim compact` to rebuild a dense XDVDFS +//! image first. //! //! `-j N` isn't exposed; `convert_iso` is single-threaded. @@ -19,24 +20,28 @@ use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso}; fn usage_and_exit() -> ! { eprintln!( - "usage: iso2god [--trim | --no-trim] [--dry-run] [--game-title TITLE] " + "usage: iso2god [--trim preserve-layout|none|compact] [--dry-run] [--game-title TITLE] " ); process::exit(2); } fn main() { let mut args = env::args().skip(1); - // Default to from-end trim. Pass --no-trim to convert the full - // source partition. - let mut trim = TrimMode::FromEnd; + let mut trim = TrimMode::PreserveLayout; let mut dry_run = false; let mut game_title: Option = None; let mut positional: Vec = Vec::new(); while let Some(arg) = args.next() { match arg.as_str() { - "--trim" => trim = TrimMode::FromEnd, - "--no-trim" => trim = TrimMode::None, + "--trim" => { + trim = match args.next().as_deref() { + Some("preserve-layout") => TrimMode::PreserveLayout, + Some("none") => TrimMode::None, + Some("compact") => TrimMode::Compact, + _ => usage_and_exit(), + }; + } "--dry-run" => dry_run = true, "--game-title" => { game_title = Some(args.next().unwrap_or_else(|| usage_and_exit())); diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index e41628c..6e81311 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -22,6 +22,9 @@ use crate::iso2god::god::{ SUBPART_SIZE, SUBPARTS_PER_PART, }; use crate::volume::FatxVolume; +use tempfile::NamedTempFile; +use xdvdfs::write::fs::XDVDFSFilesystem; +use xdvdfs::write::img::{ProgressInfo, create_xdvdfs_image}; /// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — /// large enough that the default 8 KiB capacity's syscall tax disappears @@ -29,21 +32,25 @@ use crate::volume::FatxVolume; pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; /// Progress callback shape: `(stage, current, total)` where `stage` is one -/// of `"parts"`, `"mht"`, `"header"`. +/// of `"compact"`, `"parts"`, `"mht"`, `"header"`. pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64, u64); /// How to size the output GoD relative to the source ISO. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TrimMode { - /// Walk the directory tree, find the max `(offset + size)`, and pack - /// only that many bytes. The default — yields the smallest output - /// without changing on-disk meaning. + /// Walk the existing directory tree, find the max `(offset + size)`, + /// and pack only that many bytes. Preserves any mastered holes inside + /// the XDVDFS layout while trimming trailing slack after the highest + /// file extent. This is the historical/default behavior. #[default] - FromEnd, + PreserveLayout, /// Pack every byte from the start of the data partition to the end of /// the source file. Larger output, but useful when the directory tree /// is suspect. None, + /// Rebuild the XDVDFS image densely into a temporary file, then feed + /// that compact image through the normal GoD pipeline. + Compact, } /// Knobs the caller can adjust per conversion. @@ -80,6 +87,72 @@ pub struct ConvertReport { pub data_size: u64, } +fn cancelled(op: &str) -> FatxError { + FatxError::Other(format!("{op}: cancelled")) +} + +fn build_compact_xiso( + source_iso: &Path, + mut progress: Option>, + should_abort: Option<&dyn Fn() -> bool>, +) -> Result { + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_xiso")); + } + + let source = File::open(source_iso).map_err(FatxError::Io)?; + let source = xdvdfs::blockdev::OffsetWrapper::new(source) + .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; + let mut fs = XDVDFSFilesystem::new(source) + .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; + + let temp = NamedTempFile::new().map_err(FatxError::Io)?; + let mut out = temp.reopen().map_err(FatxError::Io)?; + let mut total_files = 0u64; + let mut written_files = 0u64; + + create_xdvdfs_image(&mut fs, &mut out, |info| { + if let Some(abort) = should_abort + && abort() + { + return; + } + if let Some(cb) = progress.as_deref_mut() { + match info { + ProgressInfo::FileCount(count) => { + total_files = count as u64; + cb("compact", 0, total_files.max(1)); + } + ProgressInfo::FileAdded(_, _) => { + written_files += 1; + cb( + "compact", + written_files, + total_files.max(written_files).max(1), + ); + } + ProgressInfo::FinishedPacking => { + let total = total_files.max(written_files).max(1); + cb("compact", total, total); + } + _ => {} + } + } + }) + .map_err(|e| FatxError::Other(format!("xdvdfs compact: {e}")))?; + + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_xiso")); + } + + out.sync_all().map_err(FatxError::Io)?; + Ok(temp) +} + /// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. /// /// Writes: @@ -88,11 +161,24 @@ pub struct ConvertReport { /// /// Returns a [`ConvertReport`] describing what was produced (or what *would* /// have been, when `opts.dry_run` is set). -pub fn convert_iso( +pub fn convert_iso<'a>( source_iso: &Path, dest_dir: &Path, - opts: &mut ConvertOptions<'_>, + opts: &'a mut ConvertOptions<'a>, ) -> Result { + if matches!(opts.trim, TrimMode::Compact) { + let compact = + build_compact_xiso(source_iso, opts.progress.as_deref_mut(), opts.should_abort)?; + let mut forwarded_opts = ConvertOptions { + trim: TrimMode::PreserveLayout, + game_title: opts.game_title, + dry_run: opts.dry_run, + progress: None, + should_abort: opts.should_abort, + }; + return convert_iso(compact.path(), dest_dir, &mut forwarded_opts); + } + let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; let img = File::open(source_iso).map_err(FatxError::Io)?; @@ -115,7 +201,7 @@ pub fn convert_iso( }; let data_size = match opts.trim { - TrimMode::FromEnd => volume + TrimMode::PreserveLayout => volume .root_table .file_tree(&mut xiso) .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? @@ -136,6 +222,7 @@ pub fn convert_iso( .max() .unwrap_or(0), TrimMode::None => source_iso_file_meta.len() - root_offset, + TrimMode::Compact => unreachable!("compact handled before metadata pass"), }; let block_count = data_size.div_ceil(god::BLOCK_SIZE); @@ -331,15 +418,28 @@ fn part_payload_bytes(data_size: u64, part_index: u64) -> u64 { /// /// Peak RAM: one part buffer (~163 MiB) plus the per-part master hash /// list vector (~108 KiB total for a 27-part game). -pub fn convert_iso_to_fatx( +pub fn convert_iso_to_fatx<'a, T>( source_iso: &Path, vol: &mut FatxVolume, dest_dir: &str, - opts: &mut ConvertOptions<'_>, + opts: &'a mut ConvertOptions<'a>, ) -> Result where T: Read + Seek + Write, { + if matches!(opts.trim, TrimMode::Compact) { + let compact = + build_compact_xiso(source_iso, opts.progress.as_deref_mut(), opts.should_abort)?; + let mut forwarded_opts = ConvertOptions { + trim: TrimMode::PreserveLayout, + game_title: opts.game_title, + dry_run: opts.dry_run, + progress: None, + should_abort: opts.should_abort, + }; + return convert_iso_to_fatx(compact.path(), vol, dest_dir, &mut forwarded_opts); + } + // --- Metadata pass (mirrors convert_iso) -------------------------------- let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; @@ -361,7 +461,7 @@ where }; let data_size = match opts.trim { - TrimMode::FromEnd => volume + TrimMode::PreserveLayout => volume .root_table .file_tree(&mut xiso) .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? @@ -382,6 +482,7 @@ where .max() .unwrap_or(0), TrimMode::None => source_iso_file_meta.len() - root_offset, + TrimMode::Compact => unreachable!("compact handled before metadata pass"), }; let block_count = data_size.div_ceil(BLOCK_SIZE); diff --git a/fatxlib/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index 67aec2c..338a0e0 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -57,7 +57,7 @@ fn converts_fixture_into_valid_god_package() { let dest = tmp.path(); let mut opts = ConvertOptions { - trim: TrimMode::FromEnd, + trim: TrimMode::PreserveLayout, game_title: Some("XellLaunch2 fixture"), dry_run: false, progress: None, @@ -137,7 +137,7 @@ fn fixture_dry_run_does_not_create_files() { let dest = tmp.path(); let mut opts = ConvertOptions { - trim: TrimMode::FromEnd, + trim: TrimMode::PreserveLayout, game_title: None, dry_run: true, progress: None, @@ -188,7 +188,7 @@ fn streams_fixture_into_fatx_volume() { let (_tmp, mut vol) = common::create_fatx_image(8); let mut opts = ConvertOptions { - trim: TrimMode::FromEnd, + trim: TrimMode::PreserveLayout, game_title: Some("XellLaunch2 fixture"), dry_run: false, progress: None, @@ -239,6 +239,63 @@ fn streams_fixture_into_fatx_volume() { ); } +#[test] +fn compact_mode_converts_fixture_into_valid_god_package() { + let Some(iso) = fixture_path() else { + return; + }; + + let tmp = tempfile::TempDir::new().expect("tempdir"); + let dest = tmp.path(); + + let mut opts = ConvertOptions { + trim: TrimMode::Compact, + game_title: Some("XellLaunch2 fixture"), + dry_run: false, + progress: None, + should_abort: None, + }; + + let report = convert_iso(&iso, dest, &mut opts).expect("compact convert_iso"); + assert!(report.title_id != 0); + assert!(report.part_count >= 1); + + let title_hex = format!("{:08X}", report.title_id); + let ctype_hex = format!("{:08X}", report.content_type as u32); + let media_hex = format!("{:08X}", report.media_id); + let con_header_path = dest.join(&title_hex).join(&ctype_hex).join(&media_hex); + assert!(con_header_path.exists(), "compact CON header missing"); +} + +#[test] +fn compact_mode_streams_fixture_into_fatx_volume() { + let Some(iso) = fixture_path() else { + return; + }; + + let (_tmp, mut vol) = common::create_fatx_image(8); + let mut opts = ConvertOptions { + trim: TrimMode::Compact, + game_title: Some("XellLaunch2 fixture"), + dry_run: false, + progress: None, + should_abort: None, + }; + + let report = + convert_iso_to_fatx(&iso, &mut vol, "/", &mut opts).expect("compact convert_iso_to_fatx"); + let data_path = format!( + "/{:08X}/{:08X}/{:08X}.data/Data0000", + report.title_id, report.content_type as u32, report.media_id + ); + assert!( + !vol.read_file_by_path(&data_path) + .expect("read compact Data0000") + .is_empty(), + "compact Data0000 should be non-empty on FATX" + ); +} + #[test] fn streaming_dry_run_writes_nothing_to_fatx() { let Some(iso) = fixture_path() else { @@ -271,7 +328,7 @@ fn trim_ignores_appended_tail_padding_for_file_output() { let out = tempfile::TempDir::new().expect("tempdir"); let mut opts = ConvertOptions { - trim: TrimMode::FromEnd, + trim: TrimMode::PreserveLayout, ..Default::default() }; @@ -303,7 +360,7 @@ fn trim_ignores_appended_tail_padding_for_fatx_output() { let (_img_tmp, mut vol) = common::create_fatx_image(64); let mut opts = ConvertOptions { - trim: TrimMode::FromEnd, + trim: TrimMode::PreserveLayout, ..Default::default() }; diff --git a/src/main.rs b/src/main.rs index 8491910..fef5d18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -174,7 +174,7 @@ enum Commands { }, /// Extract every file from an Xbox / Xbox 360 XISO disc image to a /// local directory. Useful for inspecting an ISO's contents or for - /// feeding loose game files to alt dashboards (Aurora / FreeStyle / XBMC4XBOX). + /// feeding loose game files to alt dashboards. Extract { /// Source XISO file iso: PathBuf, @@ -197,11 +197,14 @@ enum Commands { /// Destination directory (the title-id tree lands underneath) dest: PathBuf, /// How much of the source partition to pack: - /// `from-end` (default) — walk the file tree, pack only the - /// content range. + /// `preserve-layout` (default) — walk the file tree, pack only + /// through the highest used extent while preserving mastered + /// holes inside the XDVDFS layout. /// `none` — pack everything from the start of the data partition /// to the end of the source file. - #[arg(long, value_parser = ["from-end", "none"], default_value = "from-end")] + /// `compact` — rebuild a dense XDVDFS image first, then convert + /// that compact image into GoD. + #[arg(long, value_parser = ["preserve-layout", "none", "compact"], default_value = "preserve-layout")] trim: String, /// Print the parsed metadata (TitleID, MediaID, data_size, part_count) /// without writing anything. @@ -1377,8 +1380,9 @@ fn run_god( use std::time::Instant; let trim_mode = match trim { - "from-end" => fatxlib::iso2god::TrimMode::FromEnd, + "preserve-layout" => fatxlib::iso2god::TrimMode::PreserveLayout, "none" => fatxlib::iso2god::TrimMode::None, + "compact" => fatxlib::iso2god::TrimMode::Compact, other => { cli_error(json, &format!("invalid --trim {:?}", other)); return; diff --git a/src/tui.rs b/src/tui.rs index 8f4498a..c8d2dbe 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -899,6 +899,7 @@ fn io_worker( // Dry-run first so we can resolve the human-readable title // and announce the destination before the streaming pass. let mut dry_opts = fatxlib::iso2god::ConvertOptions { + trim: fatxlib::iso2god::TrimMode::Compact, dry_run: true, ..Default::default() }; @@ -995,7 +996,7 @@ fn io_worker( }; let mut opts = fatxlib::iso2god::ConvertOptions { - trim: fatxlib::iso2god::TrimMode::FromEnd, + trim: fatxlib::iso2god::TrimMode::Compact, game_title: resolved_name, dry_run: false, progress: Some(&mut progress_cb), From 825ee0ca23c96a0a08d90bb6c82d9a7af0c03550 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 03:50:05 +1000 Subject: [PATCH 08/12] streaming virtual xdvdfs --- fatxlib/Cargo.toml | 1 - fatxlib/src/iso2god/convert.rs | 648 +++++++++++++++++++++++++++++---- 2 files changed, 582 insertions(+), 67 deletions(-) diff --git a/fatxlib/Cargo.toml b/fatxlib/Cargo.toml index 8154ba2..245d22d 100644 --- a/fatxlib/Cargo.toml +++ b/fatxlib/Cargo.toml @@ -19,7 +19,6 @@ sha1 = "0.11" byteorder = "1.5" num_enum = "0.7" openssl = { version = "0.10", optional = true } -tempfile = "3" xdvdfs = { version = "0.8", default-features = false, features = ["std", "read", "write", "sync"] } [features] diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso2god/convert.rs index 6e81311..fb7fc4f 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso2god/convert.rs @@ -11,20 +11,23 @@ //! buffer makes an interposing reader pure overhead). A multi-threaded //! mode could land later as an opt-in flag. +use std::collections::{BTreeMap, HashMap}; use std::fs::{self, File}; use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}; use std::path::Path; use crate::error::{FatxError, Result}; +use crate::executable::TitleExecutionInfo; use crate::executable::TitleInfo; use crate::iso2god::god::{ self, BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ContentType, FileLayout, HashList, SUBPART_SIZE, SUBPARTS_PER_PART, }; use crate::volume::FatxVolume; -use tempfile::NamedTempFile; -use xdvdfs::write::fs::XDVDFSFilesystem; -use xdvdfs::write::img::{ProgressInfo, create_xdvdfs_image}; +use xdvdfs::layout::{DirectoryEntryTable, SECTOR_SIZE, VolumeDescriptor}; +use xdvdfs::write::dirtab::DirectoryEntryTableWriter; +use xdvdfs::write::fs::{FileEntry, FileType, Filesystem, PathVec, XDVDFSFilesystem}; +use xdvdfs::write::sector::SectorAllocator; /// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — /// large enough that the default 8 KiB capacity's syscall tax disappears @@ -32,7 +35,7 @@ use xdvdfs::write::img::{ProgressInfo, create_xdvdfs_image}; pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; /// Progress callback shape: `(stage, current, total)` where `stage` is one -/// of `"compact"`, `"parts"`, `"mht"`, `"header"`. +/// of `"parts"`, `"mht"`, `"header"`. pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64, u64); /// How to size the output GoD relative to the source ISO. @@ -48,8 +51,8 @@ pub enum TrimMode { /// the source file. Larger output, but useful when the directory tree /// is suspect. None, - /// Rebuild the XDVDFS image densely into a temporary file, then feed - /// that compact image through the normal GoD pipeline. + /// Rebuild the XDVDFS image densely as a virtual layout and stream + /// those bytes directly through the GoD pipeline. Compact, } @@ -91,66 +94,361 @@ fn cancelled(op: &str) -> FatxError { FatxError::Other(format!("{op}: cancelled")) } -fn build_compact_xiso( - source_iso: &Path, - mut progress: Option>, - should_abort: Option<&dyn Fn() -> bool>, -) -> Result { - if let Some(abort) = should_abort - && abort() - { - return Err(cancelled("compact_xiso")); +type SourceOffsetDevice = xdvdfs::blockdev::OffsetWrapper; +type SourceFilesystem = XDVDFSFilesystem; + +#[derive(Clone)] +struct CompactTreeEntry { + dir: PathVec, + listing: Vec, +} + +enum CompactRegionData { + Bytes(Box<[u8]>), + Source { source_offset: u64 }, +} + +struct CompactRegion { + start: u64, + len: u64, + data: CompactRegionData, +} + +struct CompactImagePlan { + data_size: u64, + regions: Vec, +} + +struct CompactSource { + exe_info: TitleExecutionInfo, + content_type: ContentType, + partition_offset: u64, + plan: CompactImagePlan, +} + +struct CompactImageReader<'a> { + source: File, + partition_offset: u64, + plan: &'a CompactImagePlan, + cursor: u64, +} + +impl CompactSource { + fn open_reader(&self, source_iso: &Path) -> Result> { + Ok(CompactImageReader { + source: File::open(source_iso).map_err(FatxError::Io)?, + partition_offset: self.partition_offset, + plan: &self.plan, + cursor: 0, + }) } +} - let source = File::open(source_iso).map_err(FatxError::Io)?; - let source = xdvdfs::blockdev::OffsetWrapper::new(source) - .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; - let mut fs = XDVDFSFilesystem::new(source) - .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; +impl CompactImageReader<'_> { + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + buf.fill(0); + if buf.is_empty() { + return Ok(()); + } + + let end = offset.saturating_add(buf.len() as u64); + let mut idx = self + .plan + .regions + .partition_point(|region| region.start.saturating_add(region.len) <= offset); + + while idx < self.plan.regions.len() { + let region = &self.plan.regions[idx]; + let region_end = region.start.saturating_add(region.len); + if region.start >= end { + break; + } + + let overlap_start = offset.max(region.start); + let overlap_end = end.min(region_end); + if overlap_start < overlap_end { + let dst_start = (overlap_start - offset) as usize; + let dst_end = (overlap_end - offset) as usize; + let dst = &mut buf[dst_start..dst_end]; + let src_offset = overlap_start - region.start; + match ®ion.data { + CompactRegionData::Bytes(bytes) => { + let src_start = src_offset as usize; + let src_end = src_start + dst.len(); + dst.copy_from_slice(&bytes[src_start..src_end]); + } + CompactRegionData::Source { source_offset } => { + self.source.seek(SeekFrom::Start( + self.partition_offset + source_offset + src_offset, + ))?; + self.source.read_exact(dst)?; + } + } + } + idx += 1; + } + + Ok(()) + } +} - let temp = NamedTempFile::new().map_err(FatxError::Io)?; - let mut out = temp.reopen().map_err(FatxError::Io)?; - let mut total_files = 0u64; - let mut written_files = 0u64; +impl Read for CompactImageReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.cursor >= self.plan.data_size || buf.is_empty() { + return Ok(0); + } + let want = ((self.plan.data_size - self.cursor) as usize).min(buf.len()); + self.read_at(self.cursor, &mut buf[..want])?; + self.cursor += want as u64; + Ok(want) + } +} - create_xdvdfs_image(&mut fs, &mut out, |info| { +impl Seek for CompactImageReader<'_> { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let len = self.plan.data_size as i128; + let next = match pos { + SeekFrom::Start(pos) => pos as i128, + SeekFrom::Current(delta) => self.cursor as i128 + delta as i128, + SeekFrom::End(delta) => len + delta as i128, + }; + if next < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "negative seek in CompactImageReader", + )); + } + self.cursor = next as u64; + Ok(self.cursor) + } +} + +fn is_systemupdate_path(path: &str) -> bool { + path.trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .eq_ignore_ascii_case("$SystemUpdate") +} + +fn xdvdfs_other(ctx: &str, err: E) -> FatxError { + FatxError::Other(format!("{ctx}: {err:?}")) +} + +fn collect_source_file_offsets( + volume: VolumeDescriptor, + xiso: &mut SourceOffsetDevice, +) -> Result> { + let mut out = HashMap::new(); + let entries = volume + .root_table + .file_tree(xiso) + .map_err(|e| xdvdfs_other("xdvdfs file_tree", e))?; + for (parent, entry) in entries { + if entry.node.dirent.is_directory() || entry.node.dirent.data.is_empty() { + continue; + } + let name = entry + .name_str::() + .map_err(|e| xdvdfs_other("xdvdfs bad filename", e))?; + let path = if parent.is_empty() { + name.to_string() + } else { + format!("{}/{}", parent.trim_start_matches('/'), name) + }; + out.insert( + path, + entry + .node + .dirent + .data + .offset::(0) + .map_err(|e| xdvdfs_other("xdvdfs bad offset", e))?, + ); + } + Ok(out) +} + +fn collect_compact_tree( + fs: &mut SourceFilesystem, + should_abort: Option<&dyn Fn() -> bool>, +) -> Result> { + let mut dirs = vec![PathVec::default()]; + let mut out = Vec::new(); + + while let Some(dir) = dirs.pop() { if let Some(abort) = should_abort && abort() { - return; + return Err(cancelled("compact_tree")); } - if let Some(cb) = progress.as_deref_mut() { - match info { - ProgressInfo::FileCount(count) => { - total_files = count as u64; - cb("compact", 0, total_files.max(1)); - } - ProgressInfo::FileAdded(_, _) => { - written_files += 1; - cb( - "compact", - written_files, - total_files.max(written_files).max(1), - ); + + let mut listing = + >::read_dir(fs, &dir) + .map_err(|e| FatxError::Other(format!("xdvdfs compact read_dir: {e}")))?; + listing.retain(|entry| { + let path = PathVec::from_base(&dir, &entry.name).as_string(); + !is_systemupdate_path(&path) + }); + + for entry in &listing { + if matches!(entry.file_type, FileType::Directory) { + dirs.push(PathVec::from_base(&dir, &entry.name)); + } + } + + out.push(CompactTreeEntry { dir, listing }); + } + + Ok(out) +} + +fn build_compact_dirent_tables( + tree: &[CompactTreeEntry], +) -> Result> { + let mut dirent_tables: BTreeMap = BTreeMap::new(); + + for entry in tree.iter().rev() { + let mut dirtab = DirectoryEntryTableWriter::default(); + for child in &entry.listing { + match child.file_type { + FileType::Directory => { + let child_path = PathVec::from_base(&entry.dir, &child.name); + let dir_size = dirent_tables + .get(&child_path) + .ok_or_else(|| { + FatxError::Other(format!( + "xdvdfs compact: missing dirtab for {}", + child_path.as_string() + )) + })? + .dirtab_size(); + dirtab + .add_dir::(&child.name, dir_size) + .map_err(|e| xdvdfs_other("xdvdfs add_dir", e))?; } - ProgressInfo::FinishedPacking => { - let total = total_files.max(written_files).max(1); - cb("compact", total, total); + FileType::File => { + let size = child + .len + .try_into() + .map_err(|_| FatxError::Other(format!("file too large: {}", child.len)))?; + dirtab + .add_file::(&child.name, size) + .map_err(|e| xdvdfs_other("xdvdfs add_file", e))?; } - _ => {} } } - }) - .map_err(|e| FatxError::Other(format!("xdvdfs compact: {e}")))?; + dirtab + .compute_size::() + .map_err(|e| xdvdfs_other("xdvdfs compute_size", e))?; + dirent_tables.insert(entry.dir.clone(), dirtab); + } + + Ok(dirent_tables) +} +fn build_compact_source( + source_iso: &Path, + should_abort: Option<&dyn Fn() -> bool>, +) -> Result { if let Some(abort) = should_abort && abort() { - return Err(cancelled("compact_xiso")); + return Err(cancelled("compact_source")); } - out.sync_all().map_err(FatxError::Io)?; - Ok(temp) + let file = File::open(source_iso).map_err(FatxError::Io)?; + let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(file) + .map_err(|e| xdvdfs_other("xdvdfs offset detect", e))?; + let volume = + xdvdfs::read::read_volume(&mut xiso).map_err(|e| xdvdfs_other("xdvdfs read_volume", e))?; + let title_info = TitleInfo::from_image(&mut xiso, volume)?; + let exe_info = title_info.execution_info.clone(); + let content_type = title_info.content_type; + let partition_offset = { + xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; + xiso.get_mut().stream_position().map_err(FatxError::Io)? + }; + + let file_offsets = collect_source_file_offsets(volume, &mut xiso)?; + let mut fs = XDVDFSFilesystem::new(xiso) + .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; + let tree = collect_compact_tree(&mut fs, should_abort)?; + let dirent_tables = build_compact_dirent_tables(&tree)?; + + let mut dir_sectors = BTreeMap::new(); + let mut allocator = SectorAllocator::default(); + let (root_path, root_dirtab) = dirent_tables + .first_key_value() + .ok_or_else(|| FatxError::Other("xdvdfs compact: empty directory tree".into()))?; + let root_sector = allocator.allocate_contiguous(root_dirtab.dirtab_size() as u64); + let root_table = DirectoryEntryTable::new(root_dirtab.dirtab_size(), root_sector); + dir_sectors.insert(root_path.clone(), root_sector as u64); + + let volume_bytes = VolumeDescriptor::new(root_table) + .serialize::() + .map_err(|e| xdvdfs_other("xdvdfs serialize volume", e))?; + let mut regions = vec![CompactRegion { + start: 32 * SECTOR_SIZE as u64, + len: volume_bytes.len() as u64, + data: CompactRegionData::Bytes(Box::from(volume_bytes)), + }]; + + for (path, dirtab) in dirent_tables { + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_source")); + } + + let sector = *dir_sectors + .get(&path) + .ok_or_else(|| FatxError::Other(format!("missing sector for {}", path.as_string())))?; + let repr = dirtab + .disk_repr::(&mut allocator) + .map_err(|e| xdvdfs_other("xdvdfs disk_repr", e))?; + regions.push(CompactRegion { + start: sector * SECTOR_SIZE as u64, + len: repr.entry_table.len() as u64, + data: CompactRegionData::Bytes(repr.entry_table), + }); + + for entry in repr.file_listing { + let child_path = PathVec::from_base(&path, &entry.name); + if entry.is_dir { + dir_sectors.insert(child_path, entry.sector); + continue; + } + + let logical_path = child_path.as_string(); + let logical_path = logical_path.trim_start_matches('/').to_string(); + let source_offset = *file_offsets.get(&logical_path).ok_or_else(|| { + FatxError::Other(format!( + "xdvdfs compact: missing source offset for {}", + logical_path + )) + })?; + regions.push(CompactRegion { + start: entry.sector * SECTOR_SIZE as u64, + len: entry.size, + data: CompactRegionData::Source { source_offset }, + }); + } + } + + regions.sort_by_key(|region| region.start); + let data_size = regions + .iter() + .map(|region| region.start + region.len) + .max() + .unwrap_or(0); + + Ok(CompactSource { + exe_info, + content_type, + partition_offset, + plan: CompactImagePlan { data_size, regions }, + }) } /// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. @@ -167,16 +465,111 @@ pub fn convert_iso<'a>( opts: &'a mut ConvertOptions<'a>, ) -> Result { if matches!(opts.trim, TrimMode::Compact) { - let compact = - build_compact_xiso(source_iso, opts.progress.as_deref_mut(), opts.should_abort)?; - let mut forwarded_opts = ConvertOptions { - trim: TrimMode::PreserveLayout, - game_title: opts.game_title, - dry_run: opts.dry_run, - progress: None, - should_abort: opts.should_abort, + let compact = build_compact_source(source_iso, opts.should_abort)?; + let block_count = compact.plan.data_size.div_ceil(god::BLOCK_SIZE); + let part_count = block_count.div_ceil(god::BLOCKS_PER_PART); + let report = ConvertReport { + title_id: compact.exe_info.title_id, + media_id: compact.exe_info.media_id, + content_type: compact.content_type, + part_count, + block_count, + data_size: compact.plan.data_size, }; - return convert_iso(compact.path(), dest_dir, &mut forwarded_opts); + if opts.dry_run { + return Ok(report); + } + + let file_layout = FileLayout::new(dest_dir, &compact.exe_info, compact.content_type); + ensure_empty_dir(&file_layout.data_dir_path())?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", 0, part_count); + } + + for part_index in 0..part_count { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other("convert_iso: cancelled".to_string())); + } + let part_path = file_layout.part_file_path(part_index); + let part_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(&part_path) + .map_err(FatxError::Io)?; + let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); + let remaining_bytes = part_payload_bytes(compact.plan.data_size, part_index); + let iso_data_volume = compact.open_reader(source_iso)?; + + god::write_part(iso_data_volume, part_index, remaining_bytes, part_file)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", part_index + 1, part_count); + } + } + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", 0, part_count); + } + + let mut mht = read_part_mht(&file_layout, part_count - 1)?; + for prev_part_index in (0..part_count - 1).rev() { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other("convert_iso: cancelled".to_string())); + } + let mut prev_mht = read_part_mht(&file_layout, prev_part_index)?; + prev_mht.add_hash(&mht.digest()); + write_part_mht(&file_layout, prev_part_index, &prev_mht)?; + mht = prev_mht; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", part_count - prev_part_index, part_count); + } + } + + let last_part_size = fs::metadata(file_layout.part_file_path(part_count - 1)) + .map_err(FatxError::Io)? + .len(); + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 0, 1); + } + + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&compact.exe_info) + .with_block_counts(block_count as u32, 0) + .with_data_parts_info( + part_count as u32, + last_part_size + (part_count - 1) * god::BLOCK_SIZE * 0xa290, + ) + .with_content_type(compact.content_type) + .with_mht_hash(&mht.digest()); + + if let Some(game_title) = opts.game_title { + con_header = con_header.with_game_title(game_title); + } + + let con_header = con_header.finalize(); + let mut con_header_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(file_layout.con_header_file_path()) + .map_err(FatxError::Io)?; + con_header_file + .write_all(&con_header) + .map_err(FatxError::Io)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 1, 1); + } + + return Ok(report); } let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; @@ -428,16 +821,139 @@ where T: Read + Seek + Write, { if matches!(opts.trim, TrimMode::Compact) { - let compact = - build_compact_xiso(source_iso, opts.progress.as_deref_mut(), opts.should_abort)?; - let mut forwarded_opts = ConvertOptions { - trim: TrimMode::PreserveLayout, - game_title: opts.game_title, - dry_run: opts.dry_run, - progress: None, - should_abort: opts.should_abort, + let compact = build_compact_source(source_iso, opts.should_abort)?; + let block_count = compact.plan.data_size.div_ceil(BLOCK_SIZE); + let part_count = block_count.div_ceil(BLOCKS_PER_PART); + let report = ConvertReport { + title_id: compact.exe_info.title_id, + media_id: compact.exe_info.media_id, + content_type: compact.content_type, + part_count, + block_count, + data_size: compact.plan.data_size, + }; + if opts.dry_run { + return Ok(report); + } + if part_count == 0 { + return Err(FatxError::Other( + "convert_iso_to_fatx: source has no data to convert".to_string(), + )); + } + + let title_id_str = format!("{:08X}", compact.exe_info.title_id); + let content_type_str = format!("{:08X}", compact.content_type as u32); + let media_id_str = match compact.content_type { + ContentType::GamesOnDemand => format!("{:08X}", compact.exe_info.media_id), + ContentType::XboxOriginal => format!("{:08X}", compact.exe_info.title_id), }; - return convert_iso_to_fatx(compact.path(), vol, dest_dir, &mut forwarded_opts); + let dest_root = dest_dir.trim_end_matches('/'); + let title_dir = format!("{}/{}", dest_root, title_id_str); + let content_dir = format!("{}/{}", title_dir, content_type_str); + let con_header_path = format!("{}/{}", content_dir, media_id_str); + let data_dir = format!("{}/{}.data", content_dir, media_id_str); + + ensure_fatx_dir(vol, &title_dir)?; + ensure_fatx_dir(vol, &content_dir)?; + ensure_fatx_dir(vol, &data_dir)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", 0, part_count); + } + + let mut part_buf = vec![0u8; MAX_PART_BYTES]; + let mut master_lists: Vec = Vec::with_capacity(part_count as usize); + let mut last_part_size: u64 = 0; + + for part_index in 0..part_count { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other( + "convert_iso_to_fatx: cancelled".to_string(), + )); + } + + let remaining_bytes = part_payload_bytes(compact.plan.data_size, part_index); + let mut iso = compact.open_reader(source_iso)?; + let (len, master) = + fill_part_buf(&mut iso, part_index, remaining_bytes, &mut part_buf)?; + let part_path = format!("{}/Data{:04}", data_dir, part_index); + let reader = Cursor::new(&part_buf[..len]); + + let mut outer = opts.progress.take(); + let part_idx_now = part_index; + let part_count_now = part_count; + { + let mut inner = |bytes: u64, total: u64| { + if let Some(cb) = outer.as_deref_mut() { + let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); + cb(&stage, bytes, total); + } + }; + vol.create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; + } + opts.progress = outer; + + master_lists.push(master); + last_part_size = len as u64; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", part_index + 1, part_count); + } + } + let _ = vol.flush(); + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", 0, part_count); + } + for i in (0..(part_count as usize).saturating_sub(1)).rev() { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other( + "convert_iso_to_fatx: cancelled".to_string(), + )); + } + let next_digest = master_lists[i + 1].digest(); + master_lists[i].add_hash(&next_digest); + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", (part_count as u64) - 1 - (i as u64), part_count); + } + } + + for (i, master) in master_lists.iter().enumerate() { + let part_path = format!("{}/Data{:04}", data_dir, i); + overwrite_part_master(vol, &part_path, master.bytes())?; + } + let _ = vol.flush(); + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 0, 1); + } + + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&compact.exe_info) + .with_block_counts(block_count as u32, 0) + .with_data_parts_info( + part_count as u32, + last_part_size + (part_count - 1) * BLOCK_SIZE * 0xa290, + ) + .with_content_type(compact.content_type) + .with_mht_hash(&master_lists[0].digest()); + if let Some(title) = opts.game_title { + con_header = con_header.with_game_title(title); + } + let con_bytes = con_header.finalize(); + let con_len = con_bytes.len() as u64; + vol.create_file_from_reader(&con_header_path, con_len, Cursor::new(con_bytes), None)?; + let _ = vol.flush(); + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 1, 1); + } + + return Ok(report); } // --- Metadata pass (mirrors convert_iso) -------------------------------- From 8e9ca36ed0a6cac46712dc9a346bf3175bde79cb Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 04:05:20 +1000 Subject: [PATCH 09/12] refactor modules --- CLAUDE.md | 2 +- NOTICE | 2 +- fatxlib/examples/iso2god.rs | 13 +- fatxlib/examples/list_xiso.rs | 2 +- fatxlib/src/executable/mod.rs | 6 +- fatxlib/src/iso/compact.rs | 386 ++++++++++++++++ fatxlib/src/iso/extract.rs | 59 +++ .../src/{iso2god => iso}/god/con_header.rs | 3 +- fatxlib/src/{iso2god => iso/god}/convert.rs | 426 ++---------------- .../src/{iso2god => iso}/god/empty_live.bin | Bin .../src/{iso2god => iso}/god/file_layout.rs | 0 .../src/{iso2god => iso}/god/gdf_sector.rs | 0 fatxlib/src/{iso2god => iso}/god/hash_list.rs | 3 +- fatxlib/src/{iso2god => iso}/god/mod.rs | 40 ++ fatxlib/src/{xiso/mod.rs => iso/image.rs} | 0 fatxlib/src/iso/mod.rs | 10 + fatxlib/src/iso/policy.rs | 47 ++ fatxlib/src/iso2god/mod.rs | 48 -- fatxlib/src/lib.rs | 3 +- fatxlib/tests/iso2god_roundtrip.rs | 12 +- fatxlib/tests/xiso_reader.rs | 2 +- src/main.rs | 73 +-- src/tui.rs | 84 ++-- 23 files changed, 655 insertions(+), 566 deletions(-) create mode 100644 fatxlib/src/iso/compact.rs create mode 100644 fatxlib/src/iso/extract.rs rename fatxlib/src/{iso2god => iso}/god/con_header.rs (99%) rename fatxlib/src/{iso2god => iso/god}/convert.rs (69%) rename fatxlib/src/{iso2god => iso}/god/empty_live.bin (100%) rename fatxlib/src/{iso2god => iso}/god/file_layout.rs (100%) rename fatxlib/src/{iso2god => iso}/god/gdf_sector.rs (100%) rename fatxlib/src/{iso2god => iso}/god/hash_list.rs (97%) rename fatxlib/src/{iso2god => iso}/god/mod.rs (63%) rename fatxlib/src/{xiso/mod.rs => iso/image.rs} (100%) create mode 100644 fatxlib/src/iso/mod.rs create mode 100644 fatxlib/src/iso/policy.rs delete mode 100644 fatxlib/src/iso2god/mod.rs diff --git a/CLAUDE.md b/CLAUDE.md index 37d5db0..1c3bc9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ cargo run -p fatxlib --example check_profile -- /path/to/profile-file - 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. +- `fatxlib::iso::image` 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. diff --git a/NOTICE b/NOTICE index c3ef958..5277d63 100644 --- a/NOTICE +++ b/NOTICE @@ -53,7 +53,7 @@ Local adaptations made when vendoring: - anyhow::Error replaced with fatxlib's FatxError so callers see one error type across the library. - Intra-crate imports rewritten from `crate::god` / `crate::executable` - to `crate::iso2god::god` / `crate::iso2god::executable`. + to `crate::iso::god` / `crate::executable`. - The upstream `src/game_list/` (~5 KLOC compiled-in title catalog) was NOT vendored; fatxlib's existing `titles` module covers the same purpose with broader data. diff --git a/fatxlib/examples/iso2god.rs b/fatxlib/examples/iso2god.rs index 52c1cc0..ee70460 100644 --- a/fatxlib/examples/iso2god.rs +++ b/fatxlib/examples/iso2god.rs @@ -1,13 +1,12 @@ -//! Minimal CLI wrapper around [`fatxlib::iso2god::convert_iso`]. Argument +//! Minimal CLI wrapper around [`fatxlib::iso::god::convert_iso`]. Argument //! shape: //! //! ```text //! iso2god [--trim MODE] [--dry-run] [--game-title TITLE] //! ``` //! -//! `--trim preserve-layout` is the default. Pass `--trim none` to convert -//! the full source partition, or `--trim compact` to rebuild a dense XDVDFS -//! image first. +//! `--trim compact` is the default. Pass `--trim preserve-layout` to retain +//! mastered holes, or `--trim none` to convert the full source partition. //! //! `-j N` isn't exposed; `convert_iso` is single-threaded. @@ -16,18 +15,18 @@ use std::path::PathBuf; use std::process; use std::time::Instant; -use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso}; +use fatxlib::iso::god::{ConvertOptions, TrimMode, convert_iso}; fn usage_and_exit() -> ! { eprintln!( - "usage: iso2god [--trim preserve-layout|none|compact] [--dry-run] [--game-title TITLE] " + "usage: iso2god [--trim compact|preserve-layout|none] [--dry-run] [--game-title TITLE] " ); process::exit(2); } fn main() { let mut args = env::args().skip(1); - let mut trim = TrimMode::PreserveLayout; + let mut trim = TrimMode::Compact; let mut dry_run = false; let mut game_title: Option = None; let mut positional: Vec = Vec::new(); diff --git a/fatxlib/examples/list_xiso.rs b/fatxlib/examples/list_xiso.rs index f882ea4..e647e28 100644 --- a/fatxlib/examples/list_xiso.rs +++ b/fatxlib/examples/list_xiso.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use std::process; use std::time::Instant; -use fatxlib::xiso::XisoImage; +use fatxlib::iso::image::XisoImage; fn human_size(n: u64) -> String { const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; diff --git a/fatxlib/src/executable/mod.rs b/fatxlib/src/executable/mod.rs index 36244a6..79e37eb 100644 --- a/fatxlib/src/executable/mod.rs +++ b/fatxlib/src/executable/mod.rs @@ -1,9 +1,5 @@ use crate::error::{FatxError, Result}; -use crate::iso2god::god::ContentType; -// NB: ContentType lives in `iso2god::god` because it's part of the GoD -// container format's CON header. We pull it in here only because TitleInfo -// reports it alongside the execution info. If iso2god ever moves out to a -// sibling crate, this `use` becomes the seam to revisit. +use crate::iso::god::ContentType; use byteorder::{BE, LE, ReadBytesExt}; use std::io::{Read, Seek, SeekFrom}; use xdvdfs::{blockdev::BlockDeviceRead, layout::VolumeDescriptor}; diff --git a/fatxlib/src/iso/compact.rs b/fatxlib/src/iso/compact.rs new file mode 100644 index 0000000..4fa5e08 --- /dev/null +++ b/fatxlib/src/iso/compact.rs @@ -0,0 +1,386 @@ +//! Compact virtual XDVDFS layout planning. +//! +//! Builds an in-memory plan for a dense XDVDFS image without materializing a +//! temporary `.iso` on disk. Metadata regions are synthesized in memory; file +//! regions are read lazily from the source image when the reader is consumed. + +use std::collections::{BTreeMap, HashMap}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use crate::error::{FatxError, Result}; +use crate::executable::{TitleExecutionInfo, TitleInfo}; + +use super::god::ContentType; +use super::policy::is_systemupdate_path; + +use xdvdfs::layout::{DirectoryEntryTable, SECTOR_SIZE, VolumeDescriptor}; +use xdvdfs::write::dirtab::DirectoryEntryTableWriter; +use xdvdfs::write::fs::{FileEntry, FileType, Filesystem, PathVec, XDVDFSFilesystem}; +use xdvdfs::write::sector::SectorAllocator; + +type SourceOffsetDevice = xdvdfs::blockdev::OffsetWrapper; +type SourceFilesystem = XDVDFSFilesystem; + +#[derive(Clone)] +struct CompactTreeEntry { + dir: PathVec, + listing: Vec, +} + +enum CompactRegionData { + Bytes(Box<[u8]>), + Source { source_offset: u64 }, +} + +struct CompactRegion { + start: u64, + len: u64, + data: CompactRegionData, +} + +struct CompactImagePlan { + data_size: u64, + regions: Vec, +} + +pub(crate) struct CompactSource { + exe_info: TitleExecutionInfo, + content_type: ContentType, + partition_offset: u64, + plan: CompactImagePlan, +} + +pub(crate) struct CompactImageReader<'a> { + source: File, + partition_offset: u64, + plan: &'a CompactImagePlan, + cursor: u64, +} + +impl CompactSource { + pub(crate) fn open_reader(&self, source_iso: &Path) -> Result> { + Ok(CompactImageReader { + source: File::open(source_iso).map_err(FatxError::Io)?, + partition_offset: self.partition_offset, + plan: &self.plan, + cursor: 0, + }) + } + + pub(crate) fn exe_info(&self) -> &TitleExecutionInfo { + &self.exe_info + } + + pub(crate) fn content_type(&self) -> ContentType { + self.content_type + } + + pub(crate) fn data_size(&self) -> u64 { + self.plan.data_size + } +} + +impl CompactImageReader<'_> { + fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + buf.fill(0); + if buf.is_empty() { + return Ok(()); + } + + let end = offset.saturating_add(buf.len() as u64); + let mut idx = self + .plan + .regions + .partition_point(|region| region.start.saturating_add(region.len) <= offset); + + while idx < self.plan.regions.len() { + let region = &self.plan.regions[idx]; + let region_end = region.start.saturating_add(region.len); + if region.start >= end { + break; + } + + let overlap_start = offset.max(region.start); + let overlap_end = end.min(region_end); + if overlap_start < overlap_end { + let dst_start = (overlap_start - offset) as usize; + let dst_end = (overlap_end - offset) as usize; + let dst = &mut buf[dst_start..dst_end]; + let src_offset = overlap_start - region.start; + match ®ion.data { + CompactRegionData::Bytes(bytes) => { + let src_start = src_offset as usize; + let src_end = src_start + dst.len(); + dst.copy_from_slice(&bytes[src_start..src_end]); + } + CompactRegionData::Source { source_offset } => { + self.source.seek(SeekFrom::Start( + self.partition_offset + source_offset + src_offset, + ))?; + self.source.read_exact(dst)?; + } + } + } + idx += 1; + } + + Ok(()) + } +} + +impl Read for CompactImageReader<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.cursor >= self.plan.data_size || buf.is_empty() { + return Ok(0); + } + let want = ((self.plan.data_size - self.cursor) as usize).min(buf.len()); + self.read_at(self.cursor, &mut buf[..want])?; + self.cursor += want as u64; + Ok(want) + } +} + +impl Seek for CompactImageReader<'_> { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + let len = self.plan.data_size as i128; + let next = match pos { + SeekFrom::Start(pos) => pos as i128, + SeekFrom::Current(delta) => self.cursor as i128 + delta as i128, + SeekFrom::End(delta) => len + delta as i128, + }; + if next < 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "negative seek in CompactImageReader", + )); + } + self.cursor = next as u64; + Ok(self.cursor) + } +} + +fn cancelled(op: &str) -> FatxError { + FatxError::Other(format!("{op}: cancelled")) +} + +fn xdvdfs_other(ctx: &str, err: E) -> FatxError { + FatxError::Other(format!("{ctx}: {err:?}")) +} + +fn collect_source_file_offsets( + volume: VolumeDescriptor, + xiso: &mut SourceOffsetDevice, +) -> Result> { + let mut out = HashMap::new(); + let entries = volume + .root_table + .file_tree(xiso) + .map_err(|e| xdvdfs_other("xdvdfs file_tree", e))?; + for (parent, entry) in entries { + if entry.node.dirent.is_directory() || entry.node.dirent.data.is_empty() { + continue; + } + let name = entry + .name_str::() + .map_err(|e| xdvdfs_other("xdvdfs bad filename", e))?; + let path = if parent.is_empty() { + name.to_string() + } else { + format!("{}/{}", parent.trim_start_matches('/'), name) + }; + out.insert( + path, + entry + .node + .dirent + .data + .offset::(0) + .map_err(|e| xdvdfs_other("xdvdfs bad offset", e))?, + ); + } + Ok(out) +} + +fn collect_compact_tree( + fs: &mut SourceFilesystem, + should_abort: Option<&dyn Fn() -> bool>, +) -> Result> { + let mut dirs = vec![PathVec::default()]; + let mut out = Vec::new(); + + while let Some(dir) = dirs.pop() { + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_tree")); + } + + let mut listing = + >::read_dir(fs, &dir) + .map_err(|e| FatxError::Other(format!("xdvdfs compact read_dir: {e}")))?; + listing.retain(|entry| { + let path = PathVec::from_base(&dir, &entry.name).as_string(); + !is_systemupdate_path(&path) + }); + + for entry in &listing { + if matches!(entry.file_type, FileType::Directory) { + dirs.push(PathVec::from_base(&dir, &entry.name)); + } + } + + out.push(CompactTreeEntry { dir, listing }); + } + + Ok(out) +} + +fn build_compact_dirent_tables( + tree: &[CompactTreeEntry], +) -> Result> { + let mut dirent_tables: BTreeMap = BTreeMap::new(); + + for entry in tree.iter().rev() { + let mut dirtab = DirectoryEntryTableWriter::default(); + for child in &entry.listing { + match child.file_type { + FileType::Directory => { + let child_path = PathVec::from_base(&entry.dir, &child.name); + let dir_size = dirent_tables + .get(&child_path) + .ok_or_else(|| { + FatxError::Other(format!( + "xdvdfs compact: missing dirtab for {}", + child_path.as_string() + )) + })? + .dirtab_size(); + dirtab + .add_dir::(&child.name, dir_size) + .map_err(|e| xdvdfs_other("xdvdfs add_dir", e))?; + } + FileType::File => { + let size = child + .len + .try_into() + .map_err(|_| FatxError::Other(format!("file too large: {}", child.len)))?; + dirtab + .add_file::(&child.name, size) + .map_err(|e| xdvdfs_other("xdvdfs add_file", e))?; + } + } + } + dirtab + .compute_size::() + .map_err(|e| xdvdfs_other("xdvdfs compute_size", e))?; + dirent_tables.insert(entry.dir.clone(), dirtab); + } + + Ok(dirent_tables) +} + +pub(crate) fn build_compact_source( + source_iso: &Path, + should_abort: Option<&dyn Fn() -> bool>, +) -> Result { + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_source")); + } + + let file = File::open(source_iso).map_err(FatxError::Io)?; + let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(file) + .map_err(|e| xdvdfs_other("xdvdfs offset detect", e))?; + let volume = + xdvdfs::read::read_volume(&mut xiso).map_err(|e| xdvdfs_other("xdvdfs read_volume", e))?; + let title_info = TitleInfo::from_image(&mut xiso, volume)?; + let exe_info = title_info.execution_info.clone(); + let content_type = title_info.content_type; + let partition_offset = { + xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; + xiso.get_mut().stream_position().map_err(FatxError::Io)? + }; + + let file_offsets = collect_source_file_offsets(volume, &mut xiso)?; + let mut fs = XDVDFSFilesystem::new(xiso) + .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; + let tree = collect_compact_tree(&mut fs, should_abort)?; + let dirent_tables = build_compact_dirent_tables(&tree)?; + + let mut dir_sectors = BTreeMap::new(); + let mut allocator = SectorAllocator::default(); + let (root_path, root_dirtab) = dirent_tables + .first_key_value() + .ok_or_else(|| FatxError::Other("xdvdfs compact: empty directory tree".into()))?; + let root_sector = allocator.allocate_contiguous(root_dirtab.dirtab_size() as u64); + let root_table = DirectoryEntryTable::new(root_dirtab.dirtab_size(), root_sector); + dir_sectors.insert(root_path.clone(), root_sector as u64); + + let volume_bytes = VolumeDescriptor::new(root_table) + .serialize::() + .map_err(|e| xdvdfs_other("xdvdfs serialize volume", e))?; + let mut regions = vec![CompactRegion { + start: 32 * SECTOR_SIZE as u64, + len: volume_bytes.len() as u64, + data: CompactRegionData::Bytes(Box::from(volume_bytes)), + }]; + + for (path, dirtab) in dirent_tables { + if let Some(abort) = should_abort + && abort() + { + return Err(cancelled("compact_source")); + } + + let sector = *dir_sectors + .get(&path) + .ok_or_else(|| FatxError::Other(format!("missing sector for {}", path.as_string())))?; + let repr = dirtab + .disk_repr::(&mut allocator) + .map_err(|e| xdvdfs_other("xdvdfs disk_repr", e))?; + regions.push(CompactRegion { + start: sector * SECTOR_SIZE as u64, + len: repr.entry_table.len() as u64, + data: CompactRegionData::Bytes(repr.entry_table), + }); + + for entry in repr.file_listing { + let child_path = PathVec::from_base(&path, &entry.name); + if entry.is_dir { + dir_sectors.insert(child_path, entry.sector); + continue; + } + + let logical_path = child_path.as_string(); + let logical_path = logical_path.trim_start_matches('/').to_string(); + let source_offset = *file_offsets.get(&logical_path).ok_or_else(|| { + FatxError::Other(format!( + "xdvdfs compact: missing source offset for {}", + logical_path + )) + })?; + regions.push(CompactRegion { + start: entry.sector * SECTOR_SIZE as u64, + len: entry.size, + data: CompactRegionData::Source { source_offset }, + }); + } + } + + regions.sort_by_key(|region| region.start); + let data_size = regions + .iter() + .map(|region| region.start + region.len) + .max() + .unwrap_or(0); + + Ok(CompactSource { + exe_info, + content_type, + partition_offset, + plan: CompactImagePlan { data_size, regions }, + }) +} diff --git a/fatxlib/src/iso/extract.rs b/fatxlib/src/iso/extract.rs new file mode 100644 index 0000000..3a013d4 --- /dev/null +++ b/fatxlib/src/iso/extract.rs @@ -0,0 +1,59 @@ +//! ISO extraction planning shared by CLI/TUI callers. + +use std::io::{Read, Seek}; + +use crate::error::Result; + +use super::image::{XisoFile, XisoImage}; +use super::policy::{FilteredIsoFiles, filter_entries, is_systemupdate_path}; + +#[derive(Debug, Clone)] +pub struct ExtractPlan { + pub layout: String, + pub entries: Vec, + pub kept: Vec, + pub skipped: Vec, + pub kept_bytes: u64, + pub skipped_bytes: u64, +} + +impl ExtractPlan { + pub fn kept_files(&self) -> usize { + self.kept.len() + } + + pub fn skipped_files(&self) -> usize { + self.skipped.len() + } + + pub fn is_skipped(&self, entry: &XisoFile, keep_systemupdate: bool) -> bool { + !keep_systemupdate && is_systemupdate_path(&entry.path) + } +} + +pub fn plan_extract( + img: &mut XisoImage, + keep_systemupdate: bool, +) -> Result { + let entries = img.walk_files()?; + let FilteredIsoFiles { + kept, + skipped, + kept_bytes, + skipped_bytes, + } = filter_entries(&entries, keep_systemupdate); + + let layout = img + .layout() + .map(|layout| format!("{} (0x{:08X})", layout.name, layout.offset)) + .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); + + Ok(ExtractPlan { + layout, + entries, + kept, + skipped, + kept_bytes, + skipped_bytes, + }) +} diff --git a/fatxlib/src/iso2god/god/con_header.rs b/fatxlib/src/iso/god/con_header.rs similarity index 99% rename from fatxlib/src/iso2god/god/con_header.rs rename to fatxlib/src/iso/god/con_header.rs index ffe8e3e..4c648dc 100644 --- a/fatxlib/src/iso2god/god/con_header.rs +++ b/fatxlib/src/iso/god/con_header.rs @@ -1,7 +1,8 @@ use byteorder::{BE, ByteOrder, LE}; use crate::executable::TitleExecutionInfo; -use crate::iso2god::sha1_digest; + +use super::sha1_digest; const EMPTY_LIVE: &[u8] = include_bytes!("empty_live.bin"); diff --git a/fatxlib/src/iso2god/convert.rs b/fatxlib/src/iso/god/convert.rs similarity index 69% rename from fatxlib/src/iso2god/convert.rs rename to fatxlib/src/iso/god/convert.rs index fb7fc4f..660657c 100644 --- a/fatxlib/src/iso2god/convert.rs +++ b/fatxlib/src/iso/god/convert.rs @@ -2,8 +2,7 @@ //! //! Walks the source ISO via xdvdfs, computes the GoD file layout, writes //! each Data part with its embedded hash tree, and finalizes the CON -//! header. See `NOTICE` and the [`crate::iso2god`] module doc for the -//! upstream sources this code descends from. +//! header. See `NOTICE` for the upstream sources this code descends from. //! //! Single-threaded. The metadata pre-pass uses a 1 MiB `BufReader` to cut //! syscall tax on the file-tree walk; per-part data reads go straight @@ -11,23 +10,19 @@ //! buffer makes an interposing reader pure overhead). A multi-threaded //! mode could land later as an opt-in flag. -use std::collections::{BTreeMap, HashMap}; use std::fs::{self, File}; use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}; use std::path::Path; use crate::error::{FatxError, Result}; -use crate::executable::TitleExecutionInfo; use crate::executable::TitleInfo; -use crate::iso2god::god::{ - self, BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ContentType, FileLayout, HashList, +use crate::iso::compact::build_compact_source; +use crate::volume::FatxVolume; + +use super::{ + self as god, BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ContentType, FileLayout, HashList, SUBPART_SIZE, SUBPARTS_PER_PART, }; -use crate::volume::FatxVolume; -use xdvdfs::layout::{DirectoryEntryTable, SECTOR_SIZE, VolumeDescriptor}; -use xdvdfs::write::dirtab::DirectoryEntryTableWriter; -use xdvdfs::write::fs::{FileEntry, FileType, Filesystem, PathVec, XDVDFSFilesystem}; -use xdvdfs::write::sector::SectorAllocator; /// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — /// large enough that the default 8 KiB capacity's syscall tax disappears @@ -44,8 +39,7 @@ pub enum TrimMode { /// Walk the existing directory tree, find the max `(offset + size)`, /// and pack only that many bytes. Preserves any mastered holes inside /// the XDVDFS layout while trimming trailing slack after the highest - /// file extent. This is the historical/default behavior. - #[default] + /// file extent. PreserveLayout, /// Pack every byte from the start of the data partition to the end of /// the source file. Larger output, but useful when the directory tree @@ -53,6 +47,7 @@ pub enum TrimMode { None, /// Rebuild the XDVDFS image densely as a virtual layout and stream /// those bytes directly through the GoD pipeline. + #[default] Compact, } @@ -90,367 +85,6 @@ pub struct ConvertReport { pub data_size: u64, } -fn cancelled(op: &str) -> FatxError { - FatxError::Other(format!("{op}: cancelled")) -} - -type SourceOffsetDevice = xdvdfs::blockdev::OffsetWrapper; -type SourceFilesystem = XDVDFSFilesystem; - -#[derive(Clone)] -struct CompactTreeEntry { - dir: PathVec, - listing: Vec, -} - -enum CompactRegionData { - Bytes(Box<[u8]>), - Source { source_offset: u64 }, -} - -struct CompactRegion { - start: u64, - len: u64, - data: CompactRegionData, -} - -struct CompactImagePlan { - data_size: u64, - regions: Vec, -} - -struct CompactSource { - exe_info: TitleExecutionInfo, - content_type: ContentType, - partition_offset: u64, - plan: CompactImagePlan, -} - -struct CompactImageReader<'a> { - source: File, - partition_offset: u64, - plan: &'a CompactImagePlan, - cursor: u64, -} - -impl CompactSource { - fn open_reader(&self, source_iso: &Path) -> Result> { - Ok(CompactImageReader { - source: File::open(source_iso).map_err(FatxError::Io)?, - partition_offset: self.partition_offset, - plan: &self.plan, - cursor: 0, - }) - } -} - -impl CompactImageReader<'_> { - fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { - buf.fill(0); - if buf.is_empty() { - return Ok(()); - } - - let end = offset.saturating_add(buf.len() as u64); - let mut idx = self - .plan - .regions - .partition_point(|region| region.start.saturating_add(region.len) <= offset); - - while idx < self.plan.regions.len() { - let region = &self.plan.regions[idx]; - let region_end = region.start.saturating_add(region.len); - if region.start >= end { - break; - } - - let overlap_start = offset.max(region.start); - let overlap_end = end.min(region_end); - if overlap_start < overlap_end { - let dst_start = (overlap_start - offset) as usize; - let dst_end = (overlap_end - offset) as usize; - let dst = &mut buf[dst_start..dst_end]; - let src_offset = overlap_start - region.start; - match ®ion.data { - CompactRegionData::Bytes(bytes) => { - let src_start = src_offset as usize; - let src_end = src_start + dst.len(); - dst.copy_from_slice(&bytes[src_start..src_end]); - } - CompactRegionData::Source { source_offset } => { - self.source.seek(SeekFrom::Start( - self.partition_offset + source_offset + src_offset, - ))?; - self.source.read_exact(dst)?; - } - } - } - idx += 1; - } - - Ok(()) - } -} - -impl Read for CompactImageReader<'_> { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - if self.cursor >= self.plan.data_size || buf.is_empty() { - return Ok(0); - } - let want = ((self.plan.data_size - self.cursor) as usize).min(buf.len()); - self.read_at(self.cursor, &mut buf[..want])?; - self.cursor += want as u64; - Ok(want) - } -} - -impl Seek for CompactImageReader<'_> { - fn seek(&mut self, pos: SeekFrom) -> std::io::Result { - let len = self.plan.data_size as i128; - let next = match pos { - SeekFrom::Start(pos) => pos as i128, - SeekFrom::Current(delta) => self.cursor as i128 + delta as i128, - SeekFrom::End(delta) => len + delta as i128, - }; - if next < 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "negative seek in CompactImageReader", - )); - } - self.cursor = next as u64; - Ok(self.cursor) - } -} - -fn is_systemupdate_path(path: &str) -> bool { - path.trim_start_matches('/') - .split('/') - .next() - .unwrap_or("") - .eq_ignore_ascii_case("$SystemUpdate") -} - -fn xdvdfs_other(ctx: &str, err: E) -> FatxError { - FatxError::Other(format!("{ctx}: {err:?}")) -} - -fn collect_source_file_offsets( - volume: VolumeDescriptor, - xiso: &mut SourceOffsetDevice, -) -> Result> { - let mut out = HashMap::new(); - let entries = volume - .root_table - .file_tree(xiso) - .map_err(|e| xdvdfs_other("xdvdfs file_tree", e))?; - for (parent, entry) in entries { - if entry.node.dirent.is_directory() || entry.node.dirent.data.is_empty() { - continue; - } - let name = entry - .name_str::() - .map_err(|e| xdvdfs_other("xdvdfs bad filename", e))?; - let path = if parent.is_empty() { - name.to_string() - } else { - format!("{}/{}", parent.trim_start_matches('/'), name) - }; - out.insert( - path, - entry - .node - .dirent - .data - .offset::(0) - .map_err(|e| xdvdfs_other("xdvdfs bad offset", e))?, - ); - } - Ok(out) -} - -fn collect_compact_tree( - fs: &mut SourceFilesystem, - should_abort: Option<&dyn Fn() -> bool>, -) -> Result> { - let mut dirs = vec![PathVec::default()]; - let mut out = Vec::new(); - - while let Some(dir) = dirs.pop() { - if let Some(abort) = should_abort - && abort() - { - return Err(cancelled("compact_tree")); - } - - let mut listing = - >::read_dir(fs, &dir) - .map_err(|e| FatxError::Other(format!("xdvdfs compact read_dir: {e}")))?; - listing.retain(|entry| { - let path = PathVec::from_base(&dir, &entry.name).as_string(); - !is_systemupdate_path(&path) - }); - - for entry in &listing { - if matches!(entry.file_type, FileType::Directory) { - dirs.push(PathVec::from_base(&dir, &entry.name)); - } - } - - out.push(CompactTreeEntry { dir, listing }); - } - - Ok(out) -} - -fn build_compact_dirent_tables( - tree: &[CompactTreeEntry], -) -> Result> { - let mut dirent_tables: BTreeMap = BTreeMap::new(); - - for entry in tree.iter().rev() { - let mut dirtab = DirectoryEntryTableWriter::default(); - for child in &entry.listing { - match child.file_type { - FileType::Directory => { - let child_path = PathVec::from_base(&entry.dir, &child.name); - let dir_size = dirent_tables - .get(&child_path) - .ok_or_else(|| { - FatxError::Other(format!( - "xdvdfs compact: missing dirtab for {}", - child_path.as_string() - )) - })? - .dirtab_size(); - dirtab - .add_dir::(&child.name, dir_size) - .map_err(|e| xdvdfs_other("xdvdfs add_dir", e))?; - } - FileType::File => { - let size = child - .len - .try_into() - .map_err(|_| FatxError::Other(format!("file too large: {}", child.len)))?; - dirtab - .add_file::(&child.name, size) - .map_err(|e| xdvdfs_other("xdvdfs add_file", e))?; - } - } - } - dirtab - .compute_size::() - .map_err(|e| xdvdfs_other("xdvdfs compute_size", e))?; - dirent_tables.insert(entry.dir.clone(), dirtab); - } - - Ok(dirent_tables) -} - -fn build_compact_source( - source_iso: &Path, - should_abort: Option<&dyn Fn() -> bool>, -) -> Result { - if let Some(abort) = should_abort - && abort() - { - return Err(cancelled("compact_source")); - } - - let file = File::open(source_iso).map_err(FatxError::Io)?; - let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(file) - .map_err(|e| xdvdfs_other("xdvdfs offset detect", e))?; - let volume = - xdvdfs::read::read_volume(&mut xiso).map_err(|e| xdvdfs_other("xdvdfs read_volume", e))?; - let title_info = TitleInfo::from_image(&mut xiso, volume)?; - let exe_info = title_info.execution_info.clone(); - let content_type = title_info.content_type; - let partition_offset = { - xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; - xiso.get_mut().stream_position().map_err(FatxError::Io)? - }; - - let file_offsets = collect_source_file_offsets(volume, &mut xiso)?; - let mut fs = XDVDFSFilesystem::new(xiso) - .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; - let tree = collect_compact_tree(&mut fs, should_abort)?; - let dirent_tables = build_compact_dirent_tables(&tree)?; - - let mut dir_sectors = BTreeMap::new(); - let mut allocator = SectorAllocator::default(); - let (root_path, root_dirtab) = dirent_tables - .first_key_value() - .ok_or_else(|| FatxError::Other("xdvdfs compact: empty directory tree".into()))?; - let root_sector = allocator.allocate_contiguous(root_dirtab.dirtab_size() as u64); - let root_table = DirectoryEntryTable::new(root_dirtab.dirtab_size(), root_sector); - dir_sectors.insert(root_path.clone(), root_sector as u64); - - let volume_bytes = VolumeDescriptor::new(root_table) - .serialize::() - .map_err(|e| xdvdfs_other("xdvdfs serialize volume", e))?; - let mut regions = vec![CompactRegion { - start: 32 * SECTOR_SIZE as u64, - len: volume_bytes.len() as u64, - data: CompactRegionData::Bytes(Box::from(volume_bytes)), - }]; - - for (path, dirtab) in dirent_tables { - if let Some(abort) = should_abort - && abort() - { - return Err(cancelled("compact_source")); - } - - let sector = *dir_sectors - .get(&path) - .ok_or_else(|| FatxError::Other(format!("missing sector for {}", path.as_string())))?; - let repr = dirtab - .disk_repr::(&mut allocator) - .map_err(|e| xdvdfs_other("xdvdfs disk_repr", e))?; - regions.push(CompactRegion { - start: sector * SECTOR_SIZE as u64, - len: repr.entry_table.len() as u64, - data: CompactRegionData::Bytes(repr.entry_table), - }); - - for entry in repr.file_listing { - let child_path = PathVec::from_base(&path, &entry.name); - if entry.is_dir { - dir_sectors.insert(child_path, entry.sector); - continue; - } - - let logical_path = child_path.as_string(); - let logical_path = logical_path.trim_start_matches('/').to_string(); - let source_offset = *file_offsets.get(&logical_path).ok_or_else(|| { - FatxError::Other(format!( - "xdvdfs compact: missing source offset for {}", - logical_path - )) - })?; - regions.push(CompactRegion { - start: entry.sector * SECTOR_SIZE as u64, - len: entry.size, - data: CompactRegionData::Source { source_offset }, - }); - } - } - - regions.sort_by_key(|region| region.start); - let data_size = regions - .iter() - .map(|region| region.start + region.len) - .max() - .unwrap_or(0); - - Ok(CompactSource { - exe_info, - content_type, - partition_offset, - plan: CompactImagePlan { data_size, regions }, - }) -} - /// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. /// /// Writes: @@ -466,21 +100,21 @@ pub fn convert_iso<'a>( ) -> Result { if matches!(opts.trim, TrimMode::Compact) { let compact = build_compact_source(source_iso, opts.should_abort)?; - let block_count = compact.plan.data_size.div_ceil(god::BLOCK_SIZE); + let block_count = compact.data_size().div_ceil(god::BLOCK_SIZE); let part_count = block_count.div_ceil(god::BLOCKS_PER_PART); let report = ConvertReport { - title_id: compact.exe_info.title_id, - media_id: compact.exe_info.media_id, - content_type: compact.content_type, + title_id: compact.exe_info().title_id, + media_id: compact.exe_info().media_id, + content_type: compact.content_type(), part_count, block_count, - data_size: compact.plan.data_size, + data_size: compact.data_size(), }; if opts.dry_run { return Ok(report); } - let file_layout = FileLayout::new(dest_dir, &compact.exe_info, compact.content_type); + let file_layout = FileLayout::new(dest_dir, compact.exe_info(), compact.content_type()); ensure_empty_dir(&file_layout.data_dir_path())?; if let Some(cb) = opts.progress.as_deref_mut() { @@ -501,7 +135,7 @@ pub fn convert_iso<'a>( .open(&part_path) .map_err(FatxError::Io)?; let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); - let remaining_bytes = part_payload_bytes(compact.plan.data_size, part_index); + let remaining_bytes = part_payload_bytes(compact.data_size(), part_index); let iso_data_volume = compact.open_reader(source_iso)?; god::write_part(iso_data_volume, part_index, remaining_bytes, part_file)?; @@ -541,13 +175,13 @@ pub fn convert_iso<'a>( } let mut con_header = ConHeaderBuilder::new() - .with_execution_info(&compact.exe_info) + .with_execution_info(compact.exe_info()) .with_block_counts(block_count as u32, 0) .with_data_parts_info( part_count as u32, last_part_size + (part_count - 1) * god::BLOCK_SIZE * 0xa290, ) - .with_content_type(compact.content_type) + .with_content_type(compact.content_type()) .with_mht_hash(&mht.digest()); if let Some(game_title) = opts.game_title { @@ -822,15 +456,15 @@ where { if matches!(opts.trim, TrimMode::Compact) { let compact = build_compact_source(source_iso, opts.should_abort)?; - let block_count = compact.plan.data_size.div_ceil(BLOCK_SIZE); + let block_count = compact.data_size().div_ceil(BLOCK_SIZE); let part_count = block_count.div_ceil(BLOCKS_PER_PART); let report = ConvertReport { - title_id: compact.exe_info.title_id, - media_id: compact.exe_info.media_id, - content_type: compact.content_type, + title_id: compact.exe_info().title_id, + media_id: compact.exe_info().media_id, + content_type: compact.content_type(), part_count, block_count, - data_size: compact.plan.data_size, + data_size: compact.data_size(), }; if opts.dry_run { return Ok(report); @@ -841,11 +475,11 @@ where )); } - let title_id_str = format!("{:08X}", compact.exe_info.title_id); - let content_type_str = format!("{:08X}", compact.content_type as u32); - let media_id_str = match compact.content_type { - ContentType::GamesOnDemand => format!("{:08X}", compact.exe_info.media_id), - ContentType::XboxOriginal => format!("{:08X}", compact.exe_info.title_id), + let title_id_str = format!("{:08X}", compact.exe_info().title_id); + let content_type_str = format!("{:08X}", compact.content_type() as u32); + let media_id_str = match compact.content_type() { + ContentType::GamesOnDemand => format!("{:08X}", compact.exe_info().media_id), + ContentType::XboxOriginal => format!("{:08X}", compact.exe_info().title_id), }; let dest_root = dest_dir.trim_end_matches('/'); let title_dir = format!("{}/{}", dest_root, title_id_str); @@ -874,7 +508,7 @@ where )); } - let remaining_bytes = part_payload_bytes(compact.plan.data_size, part_index); + let remaining_bytes = part_payload_bytes(compact.data_size(), part_index); let mut iso = compact.open_reader(source_iso)?; let (len, master) = fill_part_buf(&mut iso, part_index, remaining_bytes, &mut part_buf)?; @@ -933,13 +567,13 @@ where } let mut con_header = ConHeaderBuilder::new() - .with_execution_info(&compact.exe_info) + .with_execution_info(compact.exe_info()) .with_block_counts(block_count as u32, 0) .with_data_parts_info( part_count as u32, last_part_size + (part_count - 1) * BLOCK_SIZE * 0xa290, ) - .with_content_type(compact.content_type) + .with_content_type(compact.content_type()) .with_mht_hash(&master_lists[0].digest()); if let Some(title) = opts.game_title { con_header = con_header.with_game_title(title); diff --git a/fatxlib/src/iso2god/god/empty_live.bin b/fatxlib/src/iso/god/empty_live.bin similarity index 100% rename from fatxlib/src/iso2god/god/empty_live.bin rename to fatxlib/src/iso/god/empty_live.bin diff --git a/fatxlib/src/iso2god/god/file_layout.rs b/fatxlib/src/iso/god/file_layout.rs similarity index 100% rename from fatxlib/src/iso2god/god/file_layout.rs rename to fatxlib/src/iso/god/file_layout.rs diff --git a/fatxlib/src/iso2god/god/gdf_sector.rs b/fatxlib/src/iso/god/gdf_sector.rs similarity index 100% rename from fatxlib/src/iso2god/god/gdf_sector.rs rename to fatxlib/src/iso/god/gdf_sector.rs diff --git a/fatxlib/src/iso2god/god/hash_list.rs b/fatxlib/src/iso/god/hash_list.rs similarity index 97% rename from fatxlib/src/iso2god/god/hash_list.rs rename to fatxlib/src/iso/god/hash_list.rs index 161df18..11b5a04 100644 --- a/fatxlib/src/iso2god/god/hash_list.rs +++ b/fatxlib/src/iso/god/hash_list.rs @@ -1,7 +1,8 @@ use std::io::{Read, Write}; use crate::error::{FatxError, Result}; -use crate::iso2god::sha1_digest; + +use super::sha1_digest; pub struct HashList { buffer: [u8; 4096], diff --git a/fatxlib/src/iso2god/god/mod.rs b/fatxlib/src/iso/god/mod.rs similarity index 63% rename from fatxlib/src/iso2god/god/mod.rs rename to fatxlib/src/iso/god/mod.rs index 9c0e173..ead9ffa 100644 --- a/fatxlib/src/iso2god/god/mod.rs +++ b/fatxlib/src/iso/god/mod.rs @@ -1,7 +1,29 @@ +//! ISO → Games-on-Demand conversion pipeline. +//! +//! Vendored from [QAston/iso2god-rs `xdvdfx` branch](https://github.com/QAston/iso2god-rs/tree/xdvdfx) +//! (parent: [iliazeus/iso2god-rs](https://github.com/iliazeus/iso2god-rs); +//! both MIT-licensed). Local deviations from upstream: +//! +//! - `anyhow::Error` → [`crate::error::FatxError`] so errors flow through +//! the same channel as the rest of fatxlib. +//! - Upstream's `src/executable/` lives at [`crate::executable`] now and is +//! shared with the XDVDFS image reader. +//! - The original `src/game_list/` (4.9 KLOC of compiled-in title catalog) is +//! dropped; fatxlib already has a richer catalog via [`crate::titles`]. +//! - The upstream binary (`src/bin/iso2god.rs`) lives elsewhere — fatxlib only +//! provides the library surface; the CLI/TUI wraps it in `xtafkit`. +//! +//! See `NOTICE` at the repo root for the full attribution. + use std::io::{Read, Seek, SeekFrom, Write}; use crate::error::{FatxError, Result}; +mod convert; +pub use convert::{ + ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso, convert_iso_to_fatx, +}; + mod con_header; pub use con_header::*; @@ -14,6 +36,24 @@ pub use gdf_sector::*; mod hash_list; pub use hash_list::*; +/// Single hot-path SHA-1 entry point used by [`HashList`] and +/// [`ConHeaderBuilder`]. With the `openssl-hash` feature (default on) +/// this routes to `openssl::sha::sha1`, which uses ARMv8 SHA on Apple +/// Silicon and SHA-NI on x86. Without the feature it falls back to the +/// portable-Rust `sha1` crate. +#[inline] +pub(crate) fn sha1_digest(data: &[u8]) -> [u8; 20] { + #[cfg(feature = "openssl-hash")] + { + openssl::sha::sha1(data) + } + #[cfg(not(feature = "openssl-hash"))] + { + use sha1::{Digest, Sha1}; + Sha1::digest(data).into() + } +} + pub const BLOCKS_PER_PART: u64 = 0xa1c4; pub const BLOCKS_PER_SUBPART: u64 = 0xcc; pub const BLOCK_SIZE: u64 = 0x1000; diff --git a/fatxlib/src/xiso/mod.rs b/fatxlib/src/iso/image.rs similarity index 100% rename from fatxlib/src/xiso/mod.rs rename to fatxlib/src/iso/image.rs diff --git a/fatxlib/src/iso/mod.rs b/fatxlib/src/iso/mod.rs new file mode 100644 index 0000000..e7bbec6 --- /dev/null +++ b/fatxlib/src/iso/mod.rs @@ -0,0 +1,10 @@ +//! ISO-domain functionality. +//! +//! This namespace groups operations on XDVDFS/XISO images independently of +//! FATX/XTAF transport concerns. + +pub mod compact; +pub mod extract; +pub mod god; +pub mod image; +pub mod policy; diff --git a/fatxlib/src/iso/policy.rs b/fatxlib/src/iso/policy.rs new file mode 100644 index 0000000..07fc93c --- /dev/null +++ b/fatxlib/src/iso/policy.rs @@ -0,0 +1,47 @@ +//! Shared policy decisions for ISO-derived operations. + +use super::image::XisoFile; + +#[derive(Debug, Clone)] +pub struct FilteredIsoFiles { + pub kept: Vec, + pub skipped: Vec, + pub kept_bytes: u64, + pub skipped_bytes: u64, +} + +impl FilteredIsoFiles { + pub fn kept_files(&self) -> usize { + self.kept.len() + } + + pub fn skipped_files(&self) -> usize { + self.skipped.len() + } +} + +pub fn is_systemupdate_path(path: &str) -> bool { + path.trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .eq_ignore_ascii_case("$SystemUpdate") +} + +pub fn filter_entries(entries: &[XisoFile], keep_systemupdate: bool) -> FilteredIsoFiles { + let (kept, skipped): (Vec<_>, Vec<_>) = if keep_systemupdate { + (entries.to_vec(), Vec::new()) + } else { + entries + .iter() + .cloned() + .partition(|entry| !is_systemupdate_path(&entry.path)) + }; + + FilteredIsoFiles { + kept_bytes: kept.iter().map(|entry| entry.size).sum(), + skipped_bytes: skipped.iter().map(|entry| entry.size).sum(), + kept, + skipped, + } +} diff --git a/fatxlib/src/iso2god/mod.rs b/fatxlib/src/iso2god/mod.rs deleted file mode 100644 index 8e48f62..0000000 --- a/fatxlib/src/iso2god/mod.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! ISO → Games-on-Demand conversion pipeline. -//! -//! Vendored from [QAston/iso2god-rs `xdvdfx` branch](https://github.com/QAston/iso2god-rs/tree/xdvdfx) -//! (parent: [iliazeus/iso2god-rs](https://github.com/iliazeus/iso2god-rs); -//! both MIT-licensed). Local deviations from upstream: -//! -//! - `anyhow::Error` → [`crate::error::FatxError`] so errors flow through -//! the same channel as the rest of fatxlib. -//! - Upstream's `src/executable/` lives at [`crate::executable`] now — it -//! gets shared with [`crate::xiso`] for folder-name resolution and isn't -//! specific to GoD conversion. -//! - Intra-crate `use crate::god` imports rewritten to -//! `use crate::iso2god::god`. -//! - The original `src/game_list/` (4.9 KLOC of compiled-in title catalog) is -//! dropped; fatxlib already has a richer catalog via [`crate::titles`]. -//! - The upstream binary (`src/bin/iso2god.rs`) lives elsewhere — fatxlib only -//! provides the library surface; the CLI/TUI wraps it in `xtafkit`. -//! -//! See `NOTICE` at the repo root for the full attribution. - -pub mod god; - -mod convert; -pub use convert::{ - ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso, convert_iso_to_fatx, -}; - -/// Single hot-path SHA-1 entry point used by [`god::HashList`] and -/// [`god::ConHeaderBuilder`]. With the `openssl-hash` feature (default on) -/// this routes to `openssl::sha::sha1`, which uses ARMv8 SHA on Apple -/// Silicon and SHA-NI on x86. Without the feature it falls back to the -/// portable-Rust `sha1` crate. -/// -/// On hardware that exposes accelerated SHA-1, the OpenSSL path can be -/// measurably faster for large workloads. Disable the feature to drop -/// the dependency if the build environment can't reach a system OpenSSL. -#[inline] -pub(crate) fn sha1_digest(data: &[u8]) -> [u8; 20] { - #[cfg(feature = "openssl-hash")] - { - openssl::sha::sha1(data) - } - #[cfg(not(feature = "openssl-hash"))] - { - use sha1::{Digest, Sha1}; - Sha1::digest(data).into() - } -} diff --git a/fatxlib/src/lib.rs b/fatxlib/src/lib.rs index be8192c..4cff031 100644 --- a/fatxlib/src/lib.rs +++ b/fatxlib/src/lib.rs @@ -29,14 +29,13 @@ pub mod content_types; pub mod display; pub mod error; pub mod executable; -pub mod iso2god; +pub mod iso; pub mod partition; pub mod platform; 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/tests/iso2god_roundtrip.rs b/fatxlib/tests/iso2god_roundtrip.rs index 338a0e0..3f742ec 100644 --- a/fatxlib/tests/iso2god_roundtrip.rs +++ b/fatxlib/tests/iso2god_roundtrip.rs @@ -1,5 +1,5 @@ -//! Integration smoke test for [`fatxlib::iso2god::convert_iso`] and -//! [`fatxlib::iso2god::convert_iso_to_fatx`]. +//! Integration smoke test for [`fatxlib::iso::god::convert_iso`] and +//! [`fatxlib::iso::god::convert_iso_to_fatx`]. //! //! Runs end-to-end against the bundled `tiny.xiso` fixture — a synthetic //! XISO packed via `xdvdfs pack` that contains a real `default.xex` @@ -19,7 +19,7 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; -use fatxlib::iso2god::{ConvertOptions, TrimMode, convert_iso, convert_iso_to_fatx}; +use fatxlib::iso::god::{ConvertOptions, TrimMode, convert_iso, convert_iso_to_fatx}; fn fixture_path() -> Option { let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tiny.xiso"); @@ -41,7 +41,7 @@ fn padded_fixture_path() -> Option<(tempfile::TempDir, PathBuf)> { } fn expected_part_len(payload_bytes: u64) -> u64 { - let subpart_size = fatxlib::iso2god::god::SUBPART_SIZE; + let subpart_size = fatxlib::iso::god::SUBPART_SIZE; let subparts = payload_bytes.div_ceil(subpart_size); 4096 + (subparts * 4096) + payload_bytes } @@ -79,7 +79,7 @@ fn converts_fixture_into_valid_god_package() { let ctype_hex = format!("{:08X}", report.content_type as u32); let media_hex = if matches!( report.content_type, - fatxlib::iso2god::god::ContentType::XboxOriginal + fatxlib::iso::god::ContentType::XboxOriginal ) { title_hex.clone() } else { @@ -205,7 +205,7 @@ fn streams_fixture_into_fatx_volume() { let content_dir = format!("{}/{:08X}", title_dir, report.content_type as u32); let media_id_hex = if matches!( report.content_type, - fatxlib::iso2god::god::ContentType::XboxOriginal + fatxlib::iso::god::ContentType::XboxOriginal ) { format!("{:08X}", report.title_id) } else { diff --git a/fatxlib/tests/xiso_reader.rs b/fatxlib/tests/xiso_reader.rs index b41a15c..ebf64f5 100644 --- a/fatxlib/tests/xiso_reader.rs +++ b/fatxlib/tests/xiso_reader.rs @@ -5,7 +5,7 @@ mod common; use std::fs::File; use std::io::Cursor; -use fatxlib::xiso::XisoImage; +use fatxlib::iso::image::XisoImage; // --------------------------------------------------------------------------- // Negative paths — always runnable diff --git a/src/main.rs b/src/main.rs index fef5d18..cadfba5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,14 +197,14 @@ enum Commands { /// Destination directory (the title-id tree lands underneath) dest: PathBuf, /// How much of the source partition to pack: - /// `preserve-layout` (default) — walk the file tree, pack only + /// `compact` (default) — rebuild a dense XDVDFS image first, then + /// convert that compact image into GoD. + /// `preserve-layout` — walk the file tree, pack only /// through the highest used extent while preserving mastered /// holes inside the XDVDFS layout. /// `none` — pack everything from the start of the data partition /// to the end of the source file. - /// `compact` — rebuild a dense XDVDFS image first, then convert - /// that compact image into GoD. - #[arg(long, value_parser = ["preserve-layout", "none", "compact"], default_value = "preserve-layout")] + #[arg(long, value_parser = ["compact", "preserve-layout", "none"], default_value = "compact")] trim: String, /// Print the parsed metadata (TitleID, MediaID, data_size, part_count) /// without writing anything. @@ -1170,38 +1170,24 @@ fn run_extract( return; } }; - let mut img = match fatxlib::xiso::XisoImage::open(file) { + let mut img = match fatxlib::iso::image::XisoImage::open(file) { Ok(i) => i, Err(e) => { cli_error(json, &format!("parse {}: {}", iso.display(), e)); return; } }; - let entries = match img.walk_files() { - Ok(v) => v, + let plan = match fatxlib::iso::extract::plan_extract(&mut img, keep_systemupdate) { + Ok(plan) => plan, Err(e) => { cli_error(json, &format!("walk {}: {}", iso.display(), e)); return; } }; - - // Partition into kept vs skipped so totals reflect what will actually - // be written. Matches the policy the TUI uses on the upload path. - let (kept, skipped): (Vec<&fatxlib::xiso::XisoFile>, Vec<&fatxlib::xiso::XisoFile>) = - if keep_systemupdate { - (entries.iter().collect(), Vec::new()) - } else { - entries.iter().partition(|e| !is_systemupdate(&e.path)) - }; - 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 layout = img - .layout() - .map(|l| format!("{} (0x{:08X})", l.name, l.offset)) - .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); + let total_files = plan.kept_files(); + let total_bytes = plan.kept_bytes; + let skipped_files = plan.skipped_files(); + let skipped_bytes = plan.skipped_bytes; if dry_run { if json { @@ -1209,24 +1195,24 @@ fn run_extract( "{}", serde_json::json!({ "iso": iso.display().to_string(), - "layout": layout, + "layout": plan.layout, "files": total_files, "bytes": total_bytes, "skipped_files": skipped_files, "skipped_bytes": skipped_bytes, - "entries": entries.iter().map(|e| { + "entries": plan.entries.iter().map(|e| { serde_json::json!({ "path": e.path, "offset": e.offset, "size": e.size, - "skipped": !keep_systemupdate && is_systemupdate(&e.path), + "skipped": plan.is_skipped(e, keep_systemupdate), }) }).collect::>(), }) ); } else { println!("ISO: {}", iso.display()); - println!("Layout: {}", layout); + println!("Layout: {}", plan.layout); println!("Files: {} ({})", total_files, format_size(total_bytes)); if skipped_files > 0 { println!( @@ -1236,8 +1222,8 @@ fn run_extract( ); } println!(); - for e in &entries { - let s = !keep_systemupdate && is_systemupdate(&e.path); + for e in &plan.entries { + let s = plan.is_skipped(e, keep_systemupdate); let tag = if s { "skip " } else { "keep " }; println!( " {} {:48} @0x{:010X} {}", @@ -1263,7 +1249,7 @@ fn run_extract( let mut bytes_done: u64 = 0; let last_progress = std::cell::Cell::new(Instant::now()); - for e in &kept { + for e in &plan.kept { let normalized = e.path.replace('\\', "/"); let local = dest.join(&normalized); if let Some(parent) = local.parent() @@ -1352,15 +1338,6 @@ fn run_extract( } } -fn is_systemupdate(image_path: &str) -> bool { - image_path - .trim_start_matches('/') - .split('/') - .next() - .unwrap_or("") - .eq_ignore_ascii_case("$SystemUpdate") -} - fn short_name(p: &str) -> &str { p.rsplit('/').next().unwrap_or(p) } @@ -1380,9 +1357,9 @@ fn run_god( use std::time::Instant; let trim_mode = match trim { - "preserve-layout" => fatxlib::iso2god::TrimMode::PreserveLayout, - "none" => fatxlib::iso2god::TrimMode::None, - "compact" => fatxlib::iso2god::TrimMode::Compact, + "preserve-layout" => fatxlib::iso::god::TrimMode::PreserveLayout, + "none" => fatxlib::iso::god::TrimMode::None, + "compact" => fatxlib::iso::god::TrimMode::Compact, other => { cli_error(json, &format!("invalid --trim {:?}", other)); return; @@ -1391,12 +1368,12 @@ fn run_god( // Catalog-fill the game title from the dry-run report, unless the // caller passed --game-title explicitly. - let mut dry_opts = fatxlib::iso2god::ConvertOptions { + let mut dry_opts = fatxlib::iso::god::ConvertOptions { trim: trim_mode, dry_run: true, ..Default::default() }; - let report = match fatxlib::iso2god::convert_iso(iso, dest, &mut dry_opts) { + let report = match fatxlib::iso::god::convert_iso(iso, dest, &mut dry_opts) { Ok(r) => r, Err(e) => { cli_error(json, &format!("parse {}: {}", iso.display(), e)); @@ -1473,7 +1450,7 @@ fn run_god( } }; - let mut opts = fatxlib::iso2god::ConvertOptions { + let mut opts = fatxlib::iso::god::ConvertOptions { trim: trim_mode, game_title: effective_title, dry_run: false, @@ -1481,7 +1458,7 @@ fn run_god( should_abort: None, }; - let result = fatxlib::iso2god::convert_iso(iso, dest, &mut opts); + let result = fatxlib::iso::god::convert_iso(iso, dest, &mut opts); if !json { eprint!("\r{:80}\r", ""); } diff --git a/src/tui.rs b/src/tui.rs index c8d2dbe..1d33842 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -61,10 +61,10 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; +use fatxlib::iso::image::XisoImage; use fatxlib::partition::format_size; use fatxlib::types::FileAttributes; use fatxlib::volume::FatxVolume; -use fatxlib::xiso::XisoImage; // =========================================================================== // Display types @@ -366,20 +366,6 @@ fn sanitize_fatx_filename(raw: &str) -> String { } } -/// 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) @@ -735,8 +721,8 @@ fn io_worker( continue; } }; - let entries = match img.walk_files() { - Ok(v) => v, + let plan = match fatxlib::iso::extract::plan_extract(&mut img, false) { + Ok(plan) => plan, Err(e) => { let _ = resp_tx.send(IoResp::Error { message: format!("Walk {}: {}", source.display(), e), @@ -753,21 +739,10 @@ fn io_worker( 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 total_files = plan.kept_files(); + let total_bytes = plan.kept_bytes; + let skipped_files = plan.skipped_files(); + let skipped_bytes = plan.skipped_bytes; let mut files_done = 0usize; let mut bytes_done: u64 = 0; @@ -776,7 +751,7 @@ fn io_worker( let mut cancelled = false; let mut failed = false; - for entry in &kept { + for entry in &plan.kept { if cancel_flag.load(Ordering::Relaxed) { cancelled = true; break; @@ -898,12 +873,12 @@ fn io_worker( // Dry-run first so we can resolve the human-readable title // and announce the destination before the streaming pass. - let mut dry_opts = fatxlib::iso2god::ConvertOptions { - trim: fatxlib::iso2god::TrimMode::Compact, + let mut dry_opts = fatxlib::iso::god::ConvertOptions { + trim: fatxlib::iso::god::TrimMode::Compact, dry_run: true, ..Default::default() }; - let report = match fatxlib::iso2god::convert_iso_to_fatx( + let report = match fatxlib::iso::god::convert_iso_to_fatx( &source, &mut vol, &dest_dir, @@ -995,16 +970,17 @@ fn io_worker( last_emit_bytes = current; }; - let mut opts = fatxlib::iso2god::ConvertOptions { - trim: fatxlib::iso2god::TrimMode::Compact, + let mut opts = fatxlib::iso::god::ConvertOptions { + trim: fatxlib::iso::god::TrimMode::Compact, game_title: resolved_name, dry_run: false, progress: Some(&mut progress_cb), should_abort: Some(&abort_fn), }; - match fatxlib::iso2god::convert_iso_to_fatx(&source, &mut vol, &dest_dir, &mut opts) - { + match fatxlib::iso::god::convert_iso_to_fatx( + &source, &mut vol, &dest_dir, &mut opts, + ) { Ok(r) => { let _ = vol.flush(); // Rough total: per-part overhead (4 KiB master + @@ -2068,15 +2044,23 @@ mod tests { #[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")); + assert!(fatxlib::iso::policy::is_systemupdate_path("$SystemUpdate")); + assert!(fatxlib::iso::policy::is_systemupdate_path( + "$SystemUpdate/su20076000_00000000" + )); + assert!(fatxlib::iso::policy::is_systemupdate_path( + "/$SystemUpdate/anything" + )); } #[test] fn test_is_xiso_junk_case_insensitive() { - assert!(is_xiso_junk("$SYSTEMUPDATE/foo")); - assert!(is_xiso_junk("$systemupdate/foo")); + assert!(fatxlib::iso::policy::is_systemupdate_path( + "$SYSTEMUPDATE/foo" + )); + assert!(fatxlib::iso::policy::is_systemupdate_path( + "$systemupdate/foo" + )); } #[test] @@ -2160,8 +2144,12 @@ mod tests { #[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")); + assert!(!fatxlib::iso::policy::is_systemupdate_path("default.xbe")); + assert!(!fatxlib::iso::policy::is_systemupdate_path( + "Media/$SystemUpdate" + )); // not the first segment + assert!(!fatxlib::iso::policy::is_systemupdate_path( + "MyGame$SystemUpdate/foo" + )); } } From 9a5032d4b7efa1af7083dd2423e4e8477f96f9b0 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 04:11:03 +1000 Subject: [PATCH 10/12] centralize manifest --- fatxlib/src/executable/mod.rs | 1 + fatxlib/src/iso/compact.rs | 89 ++++++++--------- fatxlib/src/iso/extract.rs | 59 ------------ fatxlib/src/iso/manifest.rs | 176 ++++++++++++++++++++++++++++++++++ fatxlib/src/iso/mod.rs | 2 +- fatxlib/src/iso/policy.rs | 38 -------- src/main.rs | 24 ++--- src/tui.rs | 9 +- 8 files changed, 236 insertions(+), 162 deletions(-) delete mode 100644 fatxlib/src/iso/extract.rs create mode 100644 fatxlib/src/iso/manifest.rs diff --git a/fatxlib/src/executable/mod.rs b/fatxlib/src/executable/mod.rs index 79e37eb..df4c29b 100644 --- a/fatxlib/src/executable/mod.rs +++ b/fatxlib/src/executable/mod.rs @@ -19,6 +19,7 @@ pub struct TitleExecutionInfo { pub disc_count: u8, } +#[derive(Clone, Debug)] pub struct TitleInfo { pub content_type: ContentType, pub execution_info: TitleExecutionInfo, diff --git a/fatxlib/src/iso/compact.rs b/fatxlib/src/iso/compact.rs index 4fa5e08..ff7c50f 100644 --- a/fatxlib/src/iso/compact.rs +++ b/fatxlib/src/iso/compact.rs @@ -4,16 +4,17 @@ //! temporary `.iso` on disk. Metadata regions are synthesized in memory; file //! regions are read lazily from the source image when the reader is consumed. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; use crate::error::{FatxError, Result}; -use crate::executable::{TitleExecutionInfo, TitleInfo}; +use crate::executable::TitleExecutionInfo; use super::god::ContentType; -use super::policy::is_systemupdate_path; +use super::image::XisoImage; +use super::manifest::{IsoFilterPolicy, build_manifest}; use xdvdfs::layout::{DirectoryEntryTable, SECTOR_SIZE, VolumeDescriptor}; use xdvdfs::write::dirtab::DirectoryEntryTableWriter; @@ -169,43 +170,11 @@ fn xdvdfs_other(ctx: &str, err: E) -> FatxError { FatxError::Other(format!("{ctx}: {err:?}")) } -fn collect_source_file_offsets( - volume: VolumeDescriptor, - xiso: &mut SourceOffsetDevice, -) -> Result> { - let mut out = HashMap::new(); - let entries = volume - .root_table - .file_tree(xiso) - .map_err(|e| xdvdfs_other("xdvdfs file_tree", e))?; - for (parent, entry) in entries { - if entry.node.dirent.is_directory() || entry.node.dirent.data.is_empty() { - continue; - } - let name = entry - .name_str::() - .map_err(|e| xdvdfs_other("xdvdfs bad filename", e))?; - let path = if parent.is_empty() { - name.to_string() - } else { - format!("{}/{}", parent.trim_start_matches('/'), name) - }; - out.insert( - path, - entry - .node - .dirent - .data - .offset::(0) - .map_err(|e| xdvdfs_other("xdvdfs bad offset", e))?, - ); - } - Ok(out) -} - fn collect_compact_tree( fs: &mut SourceFilesystem, should_abort: Option<&dyn Fn() -> bool>, + kept_paths: &HashSet, + kept_dirs: &HashSet, ) -> Result> { let mut dirs = vec![PathVec::default()]; let mut out = Vec::new(); @@ -222,7 +191,11 @@ fn collect_compact_tree( .map_err(|e| FatxError::Other(format!("xdvdfs compact read_dir: {e}")))?; listing.retain(|entry| { let path = PathVec::from_base(&dir, &entry.name).as_string(); - !is_systemupdate_path(&path) + let path = normalize_path(&path); + match entry.file_type { + FileType::Directory => kept_dirs.contains(path), + FileType::File => kept_paths.contains(path), + } }); for entry in &listing { @@ -291,23 +264,33 @@ pub(crate) fn build_compact_source( return Err(cancelled("compact_source")); } - let file = File::open(source_iso).map_err(FatxError::Io)?; - let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(file) - .map_err(|e| xdvdfs_other("xdvdfs offset detect", e))?; - let volume = - xdvdfs::read::read_volume(&mut xiso).map_err(|e| xdvdfs_other("xdvdfs read_volume", e))?; - let title_info = TitleInfo::from_image(&mut xiso, volume)?; - let exe_info = title_info.execution_info.clone(); - let content_type = title_info.content_type; - let partition_offset = { - xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; - xiso.get_mut().stream_position().map_err(FatxError::Io)? + let manifest = { + let file = File::open(source_iso).map_err(FatxError::Io)?; + let mut img = XisoImage::open(file)?; + build_manifest( + &mut img, + IsoFilterPolicy { + keep_systemupdate: false, + }, + )? }; + let title_info = manifest + .title_info + .clone() + .ok_or_else(|| FatxError::Other("xdvdfs compact: no executable found".into()))?; + let exe_info = title_info.execution_info; + let content_type = title_info.content_type; + let partition_offset = manifest.partition_offset; + let file_offsets: HashMap = manifest.kept_offset_map(); + let kept_paths = manifest.kept_path_set(); + let kept_dirs = manifest.kept_dir_set(); - let file_offsets = collect_source_file_offsets(volume, &mut xiso)?; + let file = File::open(source_iso).map_err(FatxError::Io)?; + let xiso = xdvdfs::blockdev::OffsetWrapper::new(file) + .map_err(|e| xdvdfs_other("xdvdfs offset detect", e))?; let mut fs = XDVDFSFilesystem::new(xiso) .ok_or_else(|| FatxError::Other("xdvdfs compact: could not open source image".into()))?; - let tree = collect_compact_tree(&mut fs, should_abort)?; + let tree = collect_compact_tree(&mut fs, should_abort, &kept_paths, &kept_dirs)?; let dirent_tables = build_compact_dirent_tables(&tree)?; let mut dir_sectors = BTreeMap::new(); @@ -384,3 +367,7 @@ pub(crate) fn build_compact_source( plan: CompactImagePlan { data_size, regions }, }) } + +fn normalize_path(path: &str) -> &str { + path.trim_start_matches('/') +} diff --git a/fatxlib/src/iso/extract.rs b/fatxlib/src/iso/extract.rs deleted file mode 100644 index 3a013d4..0000000 --- a/fatxlib/src/iso/extract.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! ISO extraction planning shared by CLI/TUI callers. - -use std::io::{Read, Seek}; - -use crate::error::Result; - -use super::image::{XisoFile, XisoImage}; -use super::policy::{FilteredIsoFiles, filter_entries, is_systemupdate_path}; - -#[derive(Debug, Clone)] -pub struct ExtractPlan { - pub layout: String, - pub entries: Vec, - pub kept: Vec, - pub skipped: Vec, - pub kept_bytes: u64, - pub skipped_bytes: u64, -} - -impl ExtractPlan { - pub fn kept_files(&self) -> usize { - self.kept.len() - } - - pub fn skipped_files(&self) -> usize { - self.skipped.len() - } - - pub fn is_skipped(&self, entry: &XisoFile, keep_systemupdate: bool) -> bool { - !keep_systemupdate && is_systemupdate_path(&entry.path) - } -} - -pub fn plan_extract( - img: &mut XisoImage, - keep_systemupdate: bool, -) -> Result { - let entries = img.walk_files()?; - let FilteredIsoFiles { - kept, - skipped, - kept_bytes, - skipped_bytes, - } = filter_entries(&entries, keep_systemupdate); - - let layout = img - .layout() - .map(|layout| format!("{} (0x{:08X})", layout.name, layout.offset)) - .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); - - Ok(ExtractPlan { - layout, - entries, - kept, - skipped, - kept_bytes, - skipped_bytes, - }) -} diff --git a/fatxlib/src/iso/manifest.rs b/fatxlib/src/iso/manifest.rs new file mode 100644 index 0000000..b1d9062 --- /dev/null +++ b/fatxlib/src/iso/manifest.rs @@ -0,0 +1,176 @@ +//! Shared ISO manifest planning. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek}; + +use crate::error::Result; +use crate::executable::TitleInfo; + +use super::image::{XisoFile, XisoImage}; +use super::policy::is_systemupdate_path; + +#[derive(Debug, Clone, Copy, Default)] +pub struct IsoFilterPolicy { + pub keep_systemupdate: bool, +} + +impl IsoFilterPolicy { + pub fn keeps(&self, path: &str) -> bool { + self.keep_systemupdate || !is_systemupdate_path(path) + } +} + +#[derive(Debug, Clone)] +pub struct ManifestEntry { + pub file: XisoFile, + pub skipped: bool, +} + +impl ManifestEntry { + pub fn path(&self) -> &str { + &self.file.path + } +} + +#[derive(Debug, Clone)] +pub struct IsoManifest { + pub layout: String, + pub partition_offset: u64, + pub title_info: Option, + pub entries: Vec, + pub kept_bytes: u64, + pub skipped_bytes: u64, +} + +impl IsoManifest { + pub fn kept_files(&self) -> usize { + self.entries.iter().filter(|entry| !entry.skipped).count() + } + + pub fn skipped_files(&self) -> usize { + self.entries.iter().filter(|entry| entry.skipped).count() + } + + pub fn kept(&self) -> impl Iterator { + self.entries + .iter() + .filter(|entry| !entry.skipped) + .map(|entry| &entry.file) + } + + pub fn skipped(&self) -> impl Iterator { + self.entries + .iter() + .filter(|entry| entry.skipped) + .map(|entry| &entry.file) + } + + pub fn kept_path_set(&self) -> HashSet { + self.kept() + .map(|entry| normalize_path(&entry.path).to_string()) + .collect() + } + + pub fn kept_dir_set(&self) -> HashSet { + let mut dirs = HashSet::from([String::new()]); + for entry in self.kept() { + let mut prefix = String::new(); + let path = normalize_path(&entry.path); + for component in path.split('/').take(path.matches('/').count()) { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(component); + dirs.insert(prefix.clone()); + } + } + dirs + } + + pub fn kept_offset_map(&self) -> HashMap { + self.kept() + .map(|entry| (normalize_path(&entry.path).to_string(), entry.offset)) + .collect() + } +} + +pub fn build_manifest( + img: &mut XisoImage, + policy: IsoFilterPolicy, +) -> Result { + let files = img.walk_files()?; + let entries: Vec = files + .into_iter() + .map(|file| ManifestEntry { + skipped: !policy.keeps(&file.path), + file, + }) + .collect(); + let kept_bytes = entries + .iter() + .filter(|entry| !entry.skipped) + .map(|entry| entry.file.size) + .sum(); + let skipped_bytes = entries + .iter() + .filter(|entry| entry.skipped) + .map(|entry| entry.file.size) + .sum(); + let layout = img + .layout() + .map(|layout| format!("{} (0x{:08X})", layout.name, layout.offset)) + .unwrap_or_else(|| format!("unknown @ 0x{:08X}", img.partition_offset())); + + Ok(IsoManifest { + layout, + partition_offset: img.partition_offset(), + title_info: img.title_info()?, + entries, + kept_bytes, + skipped_bytes, + }) +} + +fn normalize_path(path: &str) -> &str { + path.trim_start_matches('/') +} + +#[cfg(test)] +mod tests { + use super::{IsoManifest, ManifestEntry}; + use crate::iso::image::XisoFile; + + #[test] + fn kept_dir_set_contains_all_parent_directories() { + let manifest = IsoManifest { + layout: String::new(), + partition_offset: 0, + title_info: None, + kept_bytes: 12, + skipped_bytes: 0, + entries: vec![ + ManifestEntry { + file: XisoFile { + path: "default.xex".to_string(), + size: 4, + offset: 0, + }, + skipped: false, + }, + ManifestEntry { + file: XisoFile { + path: "Media/Videos/intro.bik".to_string(), + size: 8, + offset: 4, + }, + skipped: false, + }, + ], + }; + + let dirs = manifest.kept_dir_set(); + assert!(dirs.contains("")); + assert!(dirs.contains("Media")); + assert!(dirs.contains("Media/Videos")); + } +} diff --git a/fatxlib/src/iso/mod.rs b/fatxlib/src/iso/mod.rs index e7bbec6..3b6ed7b 100644 --- a/fatxlib/src/iso/mod.rs +++ b/fatxlib/src/iso/mod.rs @@ -4,7 +4,7 @@ //! FATX/XTAF transport concerns. pub mod compact; -pub mod extract; pub mod god; pub mod image; +pub mod manifest; pub mod policy; diff --git a/fatxlib/src/iso/policy.rs b/fatxlib/src/iso/policy.rs index 07fc93c..ff75a4b 100644 --- a/fatxlib/src/iso/policy.rs +++ b/fatxlib/src/iso/policy.rs @@ -1,25 +1,5 @@ //! Shared policy decisions for ISO-derived operations. -use super::image::XisoFile; - -#[derive(Debug, Clone)] -pub struct FilteredIsoFiles { - pub kept: Vec, - pub skipped: Vec, - pub kept_bytes: u64, - pub skipped_bytes: u64, -} - -impl FilteredIsoFiles { - pub fn kept_files(&self) -> usize { - self.kept.len() - } - - pub fn skipped_files(&self) -> usize { - self.skipped.len() - } -} - pub fn is_systemupdate_path(path: &str) -> bool { path.trim_start_matches('/') .split('/') @@ -27,21 +7,3 @@ pub fn is_systemupdate_path(path: &str) -> bool { .unwrap_or("") .eq_ignore_ascii_case("$SystemUpdate") } - -pub fn filter_entries(entries: &[XisoFile], keep_systemupdate: bool) -> FilteredIsoFiles { - let (kept, skipped): (Vec<_>, Vec<_>) = if keep_systemupdate { - (entries.to_vec(), Vec::new()) - } else { - entries - .iter() - .cloned() - .partition(|entry| !is_systemupdate_path(&entry.path)) - }; - - FilteredIsoFiles { - kept_bytes: kept.iter().map(|entry| entry.size).sum(), - skipped_bytes: skipped.iter().map(|entry| entry.size).sum(), - kept, - skipped, - } -} diff --git a/src/main.rs b/src/main.rs index cadfba5..0de69cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1177,7 +1177,10 @@ fn run_extract( return; } }; - let plan = match fatxlib::iso::extract::plan_extract(&mut img, keep_systemupdate) { + let plan = match fatxlib::iso::manifest::build_manifest( + &mut img, + fatxlib::iso::manifest::IsoFilterPolicy { keep_systemupdate }, + ) { Ok(plan) => plan, Err(e) => { cli_error(json, &format!("walk {}: {}", iso.display(), e)); @@ -1202,10 +1205,10 @@ fn run_extract( "skipped_bytes": skipped_bytes, "entries": plan.entries.iter().map(|e| { serde_json::json!({ - "path": e.path, - "offset": e.offset, - "size": e.size, - "skipped": plan.is_skipped(e, keep_systemupdate), + "path": e.file.path, + "offset": e.file.offset, + "size": e.file.size, + "skipped": e.skipped, }) }).collect::>(), }) @@ -1223,14 +1226,13 @@ fn run_extract( } println!(); for e in &plan.entries { - let s = plan.is_skipped(e, keep_systemupdate); - let tag = if s { "skip " } else { "keep " }; + let tag = if e.skipped { "skip " } else { "keep " }; println!( " {} {:48} @0x{:010X} {}", tag, - e.path, - e.offset, - format_size(e.size) + e.file.path, + e.file.offset, + format_size(e.file.size) ); } println!(); @@ -1249,7 +1251,7 @@ fn run_extract( let mut bytes_done: u64 = 0; let last_progress = std::cell::Cell::new(Instant::now()); - for e in &plan.kept { + for e in plan.kept() { let normalized = e.path.replace('\\', "/"); let local = dest.join(&normalized); if let Some(parent) = local.parent() diff --git a/src/tui.rs b/src/tui.rs index 1d33842..458f73d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -721,7 +721,12 @@ fn io_worker( continue; } }; - let plan = match fatxlib::iso::extract::plan_extract(&mut img, false) { + let plan = match fatxlib::iso::manifest::build_manifest( + &mut img, + fatxlib::iso::manifest::IsoFilterPolicy { + keep_systemupdate: false, + }, + ) { Ok(plan) => plan, Err(e) => { let _ = resp_tx.send(IoResp::Error { @@ -751,7 +756,7 @@ fn io_worker( let mut cancelled = false; let mut failed = false; - for entry in &plan.kept { + for entry in plan.kept() { if cancel_flag.load(Ordering::Relaxed) { cancelled = true; break; From 5d785bee1b3636b26d78f6ff1523ce54cbba7744 Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 04:29:53 +1000 Subject: [PATCH 11/12] refactoring and updating docs --- CHANGELOG.md | 10 + CLAUDE.md | 11 +- NOTICE | 2 +- README.md | 13 +- fatxlib/src/iso/god/convert.rs | 988 +++++++++++++++---------------- fatxlib/src/iso/god/hash_list.rs | 1 + 6 files changed, 507 insertions(+), 518 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1386d..803b255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to `xtafkit` will be documented in this file. +## [Unreleased] + +### ISO / disc-image support +- Added `xtafkit extract` for streaming Xbox / Xbox 360 XISO contents to a local directory, with `$SystemUpdate` skipped by default. +- Added `xtafkit god` for XISO → Games-on-Demand conversion. Default trim is now `compact`; `preserve-layout` and `none` stay available for debugging and compatibility. +- Introduced a shared `fatxlib::iso` namespace for image reading, manifest planning, compact repacking, and GoD conversion. +- Reworked compact GoD conversion to stream a virtual dense XDVDFS layout instead of staging a temporary ISO on disk. +- Centralized ISO filtering and planning so extract, compact trim, and dry-run reporting share the same manifest. +- Removed the old public `fatxlib::xiso` and `fatxlib::iso2god` entry points in favor of `fatxlib::iso::{image,manifest,compact,god}`. + ## [1.1.0] - 2026-05-16 First release under the `xtafkit` name. Forked from diff --git a/CLAUDE.md b/CLAUDE.md index 1c3bc9b..69cb635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Rust toolkit for reading and writing FATX/XTAF file systems on Xbox/Xbox 360 for ## Architecture - **Cargo workspace** with two crates: - `fatxlib` — Library crate. FATX/XTAF volume implementation, types, partition detection, platform I/O. Also: bundled title catalog (Xbox 360 + Original Xbox), STFS header parser, profile (Account) blob decryption, slot-aware display formatting. - - `xtafkit` (root) — Single binary (`xtafkit`). Five subcommands via clap (`browse`, `ls`, `scan`, `mkimage`, `resolve`); no-args entry point launches the TUI via guided picker. Ratatui-based TUI is the primary UX. Test image generator (`mkimage`) is the only non-TUI write path that ships. + - `xtafkit` (root) — Single binary (`xtafkit`). Seven subcommands via clap (`browse`, `ls`, `scan`, `mkimage`, `resolve`, `extract`, `god`); no-args entry point launches the TUI via guided picker. Ratatui-based TUI is the primary UX. `extract` and `god` handle XISO work; `mkimage` is the only non-TUI write path that targets FATX/XTAF itself. ## Key Technical Details @@ -84,10 +84,15 @@ cargo run -p fatxlib --example check_profile -- /path/to/profile-file - Commit and push at each milestone (working feature, major fix, etc.) ### XISO / disc-image support -- `fatxlib::iso::image` 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. +- `fatxlib::iso` owns ISO-domain work: + - `image` 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. + - `manifest` builds the shared ISO file manifest, skips `$SystemUpdate` by default, and feeds both extract and compact planning. + - `compact` builds a dense virtual XDVDFS image for hard-trim GoD conversion. + - `god` owns GoD packaging. +- `xtafkit extract` streams XISO files to disk; `xtafkit god` converts XISO to GoD and defaults to `compact` trim. - 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 -- `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 +- Further split `fatxlib::iso::god::convert` into a pure conversion core plus transport-specific sinks if the current host-FS / FATX split starts accumulating more policy diff --git a/NOTICE b/NOTICE index 5277d63..05539b2 100644 --- a/NOTICE +++ b/NOTICE @@ -19,7 +19,7 @@ License, Version 2.0. See LICENSE for the full license text. Third-party code vendored under Apache-2.0-compatible licenses: -iso2god (in fatxlib/src/iso2god/) +iso2god (in fatxlib/src/iso/god/) --------------------------------- Vendored from QAston/iso2god-rs (xdvdfx branch), itself a fork of iliazeus/iso2god-rs. Both are MIT-licensed: diff --git a/README.md b/README.md index ad2e6e5..87f86bd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) -Mac-native TUI workbench for Xbox 360 (XTAF) and Original Xbox (FATX) drives. Plug a console drive into your Mac over USB, then browse, transfer files, resolve game titles, and decode profile gamertags from a polished terminal UI. +Mac-native TUI workbench for Xbox 360 (XTAF) and Original Xbox (FATX) drives. Plug a console drive into your Mac over USB, then browse, transfer files, resolve game titles, decode profile gamertags, and work with XISO disc images from a polished terminal UI plus a small CLI surface. ## Highlights @@ -10,6 +10,7 @@ Mac-native TUI workbench for Xbox 360 (XTAF) and Original Xbox (FATX) drives. Pl - **Title resolution** — ~5,500 Xbox 360 and Original Xbox games compiled in, with on-demand STFS header parsing for anything the catalog misses - **Profile gamertag extraction** — decrypts the embedded Account file (ARC4 + HMAC-SHA1) to label profile XUIDs - **Per-file resolution** for Arcade / XNA / Marketplace / Installer folders, with one-keystroke bulk scan +- **XISO extraction and GoD conversion** — stream XISO contents to a local directory or build Games-on-Demand packages for Xbox 360 backward-compatible titles - **Sort toggle** — by resolved name or by raw ID (display format flips to match) - **Persistent caches** under `~/.config/xtafkit/` — plain text, human-editable - macOS-native I/O (`F_NOCACHE`, `F_RDAHEAD`, device-optimal alignment) @@ -59,9 +60,15 @@ xtafkit ls [PATH] [-l] list files (text in TTY, JSON when xtafkit scan [--deep] detect FATX/XTAF partitions xtafkit mkimage [--size 1G] [--populate] [--format fatx|xtaf] xtafkit resolve STFS-based title / file resolution +xtafkit extract [--keep-systemupdate] [--dry-run] +xtafkit god [--trim compact|preserve-layout|none] [--dry-run] [--game-title TITLE] ``` -Six subcommands total — file operations (download/upload/mkdir/rm/rename/copy/info/cleanup) live inside the TUI. +Seven subcommands total — file operations (download/upload/mkdir/rm/rename/copy/info/cleanup) live inside the TUI. + +## XISO Tools + +`xtafkit extract` streams every file from an XISO to a local directory and skips `$SystemUpdate` by default. `xtafkit god` converts an XISO into a Games-on-Demand package; the default trim mode is `compact`, which repacks XDVDFS densely before GoD packaging. Pass `--trim preserve-layout` to keep mastered holes, or `--trim none` to use the full data partition. ## TUI @@ -142,7 +149,7 @@ Tests use file-backed FATX/XTAF images generated by `xtafkit mkimage`. No hardwa ## Origin -`xtafkit` started as a fork of [joshuareisbord/fatx-rs](https://github.com/joshuareisbord/fatx-rs), which provided the FATX/XTAF filesystem core. The project has since diverged — title catalog with merged Xbox 360 + Original Xbox sources, on-demand STFS resolution, profile gamertag decryption, slot-aware folder display, TUI-first workflow, and a deliberate scope reduction (the NFS Finder-mount server was removed in favor of doing all file operations inside the TUI). Credit to the original author for the filesystem foundation. +`xtafkit` started as a fork of [joshuareisbord/fatx-rs](https://github.com/joshuareisbord/fatx-rs), which provided the FATX/XTAF filesystem core. The project has since diverged — title catalog with merged Xbox 360 + Original Xbox sources, on-demand STFS resolution, profile gamertag decryption, slot-aware folder display, TUI-first workflow, XISO extraction, and Games-on-Demand conversion. Credit to the original author for the filesystem foundation. ## License diff --git a/fatxlib/src/iso/god/convert.rs b/fatxlib/src/iso/god/convert.rs index 660657c..28ac7db 100644 --- a/fatxlib/src/iso/god/convert.rs +++ b/fatxlib/src/iso/god/convert.rs @@ -12,7 +12,7 @@ use std::fs::{self, File}; use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::{FatxError, Result}; use crate::executable::TitleInfo; @@ -85,303 +85,323 @@ pub struct ConvertReport { pub data_size: u64, } -/// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. -/// -/// Writes: -/// - `///.data/Data0000..DataN` -/// - `///` (CON header) -/// -/// Returns a [`ConvertReport`] describing what was produced (or what *would* -/// have been, when `opts.dry_run` is set). -pub fn convert_iso<'a>( - source_iso: &Path, - dest_dir: &Path, - opts: &'a mut ConvertOptions<'a>, -) -> Result { - if matches!(opts.trim, TrimMode::Compact) { - let compact = build_compact_source(source_iso, opts.should_abort)?; - let block_count = compact.data_size().div_ceil(god::BLOCK_SIZE); - let part_count = block_count.div_ceil(god::BLOCKS_PER_PART); - let report = ConvertReport { - title_id: compact.exe_info().title_id, - media_id: compact.exe_info().media_id, - content_type: compact.content_type(), - part_count, - block_count, - data_size: compact.data_size(), - }; - if opts.dry_run { - return Ok(report); - } +trait ReadSeek: Read + Seek {} - let file_layout = FileLayout::new(dest_dir, compact.exe_info(), compact.content_type()); - ensure_empty_dir(&file_layout.data_dir_path())?; +impl ReadSeek for T {} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", 0, part_count); - } +struct PreparedSource { + report: ConvertReport, + exe_info: crate::executable::TitleExecutionInfo, + content_type: ContentType, + reader: ReaderSource, +} - for part_index in 0..part_count { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other("convert_iso: cancelled".to_string())); - } - let part_path = file_layout.part_file_path(part_index); - let part_file = File::options() - .write(true) - .create(true) - .truncate(true) - .open(&part_path) - .map_err(FatxError::Io)?; - let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); - let remaining_bytes = part_payload_bytes(compact.data_size(), part_index); - let iso_data_volume = compact.open_reader(source_iso)?; +enum ReaderSource { + Raw { + source_iso: PathBuf, + root_offset: u64, + }, + Compact { + source_iso: PathBuf, + compact: crate::iso::compact::CompactSource, + }, +} - god::write_part(iso_data_volume, part_index, remaining_bytes, part_file)?; +impl PreparedSource { + fn open_reader(&self) -> Result> { + self.reader.open_reader() + } +} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", part_index + 1, part_count); +impl ReaderSource { + fn open_reader(&self) -> Result> { + match self { + Self::Raw { + source_iso, + root_offset, + } => { + let mut iso = File::open(source_iso).map_err(FatxError::Io)?; + iso.seek(SeekFrom::Start(*root_offset)) + .map_err(FatxError::Io)?; + Ok(Box::new(iso)) } + Self::Compact { + source_iso, + compact, + } => Ok(Box::new(compact.open_reader(source_iso)?)), } + } +} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", 0, part_count); - } +trait GodSink { + fn begin(&mut self, source: &PreparedSource) -> Result<()>; + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + opts: &mut ConvertOptions<'a>, + ) -> Result<()>; + fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result; + fn write_master_hash( + &mut self, + source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()>; + fn last_part_size(&self, source: &PreparedSource) -> Result; + fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()>; + fn flush_after_parts(&mut self) -> Result<()> { + Ok(()) + } + fn flush_after_mht(&mut self) -> Result<()> { + Ok(()) + } + fn flush_after_header(&mut self) -> Result<()> { + Ok(()) + } +} - let mut mht = read_part_mht(&file_layout, part_count - 1)?; - for prev_part_index in (0..part_count - 1).rev() { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other("convert_iso: cancelled".to_string())); - } - let mut prev_mht = read_part_mht(&file_layout, prev_part_index)?; - prev_mht.add_hash(&mht.digest()); - write_part_mht(&file_layout, prev_part_index, &prev_mht)?; - mht = prev_mht; +struct HostFsSink<'a> { + dest_dir: &'a Path, +} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", part_count - prev_part_index, part_count); - } - } +impl<'a> HostFsSink<'a> { + fn data_dir_path(&self, source: &PreparedSource) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).data_dir_path() + } - let last_part_size = fs::metadata(file_layout.part_file_path(part_count - 1)) - .map_err(FatxError::Io)? - .len(); + fn part_file_path(&self, source: &PreparedSource, part_index: u64) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type) + .part_file_path(part_index) + } - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 0, 1); - } + fn con_header_file_path(&self, source: &PreparedSource) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).con_header_file_path() + } +} - let mut con_header = ConHeaderBuilder::new() - .with_execution_info(compact.exe_info()) - .with_block_counts(block_count as u32, 0) - .with_data_parts_info( - part_count as u32, - last_part_size + (part_count - 1) * god::BLOCK_SIZE * 0xa290, - ) - .with_content_type(compact.content_type()) - .with_mht_hash(&mht.digest()); - - if let Some(game_title) = opts.game_title { - con_header = con_header.with_game_title(game_title); - } +impl GodSink for HostFsSink<'_> { + fn begin(&mut self, source: &PreparedSource) -> Result<()> { + ensure_empty_dir(&self.data_dir_path(source)) + } - let con_header = con_header.finalize(); - let mut con_header_file = File::options() + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + _opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + let part_path = self.part_file_path(source, part_index); + let part_file = File::options() .write(true) .create(true) .truncate(true) - .open(file_layout.con_header_file_path()) - .map_err(FatxError::Io)?; - con_header_file - .write_all(&con_header) + .open(&part_path) .map_err(FatxError::Io)?; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 1, 1); - } - - return Ok(report); + let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); + let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); + let iso_data_volume = source.open_reader()?; + god::write_part(iso_data_volume, part_index, remaining_bytes, part_file) } - let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; - - let img = File::open(source_iso).map_err(FatxError::Io)?; - let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); - let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; - - let volume = xdvdfs::read::read_volume(&mut xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; - - let title_info = TitleInfo::from_image(&mut xiso, volume)?; - let exe_info = title_info.execution_info; - let content_type = title_info.content_type; - - // Pull the partition offset out from the wrapper; the per-part - // readers use it as their `seek` target. - let root_offset = { - xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; - xiso.get_mut().stream_position().map_err(FatxError::Io)? - }; - - let data_size = match opts.trim { - TrimMode::PreserveLayout => volume - .root_table - .file_tree(&mut xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? - .iter() - .map(|dirent| { - if dirent.1.node.dirent.data.is_empty() { - return 0; - } - let offset = dirent - .1 - .node - .dirent - .data - .offset::(0) - .unwrap_or(0); - offset + dirent.1.node.dirent.data.size() as u64 - }) - .max() - .unwrap_or(0), - TrimMode::None => source_iso_file_meta.len() - root_offset, - TrimMode::Compact => unreachable!("compact handled before metadata pass"), - }; - - let block_count = data_size.div_ceil(god::BLOCK_SIZE); - let part_count = block_count.div_ceil(god::BLOCKS_PER_PART); - - let report = ConvertReport { - title_id: exe_info.title_id, - media_id: exe_info.media_id, - content_type, - part_count, - block_count, - data_size, - }; - - if opts.dry_run { - return Ok(report); + fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result { + let part_path = self.part_file_path(source, part_index); + read_part_mht(&part_path) } - let file_layout = FileLayout::new(dest_dir, &exe_info, content_type); - - ensure_empty_dir(&file_layout.data_dir_path())?; - - // ---- Write the Data parts (sequential) ------------------------------ + fn write_master_hash( + &mut self, + source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()> { + let part_path = self.part_file_path(source, part_index); + write_part_mht(&part_path, mht) + } - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", 0, part_count); + fn last_part_size(&self, source: &PreparedSource) -> Result { + fs::metadata(self.part_file_path(source, source.report.part_count - 1)) + .map_err(FatxError::Io) + .map(|meta| meta.len()) } - for part_index in 0..part_count { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other("convert_iso: cancelled".to_string())); - } - let part_path = file_layout.part_file_path(part_index); - let part_file = File::options() + fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()> { + let mut con_header_file = File::options() .write(true) .create(true) .truncate(true) - .open(&part_path) + .open(self.con_header_file_path(source)) .map_err(FatxError::Io)?; - // Wrap the part output in a 1 MiB BufWriter so the interleaved - // 4 KiB hash-list writes and the larger subpart writes don't - // each turn into separate syscalls. The subpart writes themselves - // bypass the buffer (they're larger than the free space), but - // the hash writes ride on top of them for free. - let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); - - // Fresh source reader per part so we can `seek` from a known - // starting point (root_offset). Deliberately UNbuffered — the - // inner hot loop in `god::write_part` reads exactly SUBPART_SIZE - // (~832 KiB) per pass into a pre-allocated buffer; an interposing - // BufReader at that read size just adds an extra memcpy through - // its internal buffer with no syscall-batching benefit. - let mut iso_data_volume = File::open(source_iso).map_err(FatxError::Io)?; - iso_data_volume - .seek(SeekFrom::Start(root_offset)) - .map_err(FatxError::Io)?; - let remaining_bytes = part_payload_bytes(data_size, part_index); + con_header_file.write_all(&con_bytes).map_err(FatxError::Io) + } +} - god::write_part(iso_data_volume, part_index, remaining_bytes, part_file)?; +struct FatxSink<'a, T: Read + Seek + Write> { + vol: &'a mut FatxVolume, + dest_dir: &'a str, + data_dir: Option, + con_header_path: Option, + part_buf: Vec, + master_lists: Vec, + last_part_size: u64, +} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", part_index + 1, part_count); +impl<'a, T: Read + Seek + Write> FatxSink<'a, T> { + fn new(vol: &'a mut FatxVolume, dest_dir: &'a str) -> Self { + Self { + vol, + dest_dir, + data_dir: None, + con_header_path: None, + part_buf: vec![0u8; MAX_PART_BYTES], + master_lists: Vec::new(), + last_part_size: 0, } } - // ---- MHT hash chain (last part → first; in-place update) ------------ + fn data_dir(&self) -> Result<&str> { + self.data_dir + .as_deref() + .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) + } - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", 0, part_count); + fn con_header_path(&self) -> Result<&str> { + self.con_header_path + .as_deref() + .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) } - let mut mht = read_part_mht(&file_layout, part_count - 1)?; - for prev_part_index in (0..part_count - 1).rev() { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other("convert_iso: cancelled".to_string())); - } - let mut prev_mht = read_part_mht(&file_layout, prev_part_index)?; - prev_mht.add_hash(&mht.digest()); - write_part_mht(&file_layout, prev_part_index, &prev_mht)?; - mht = prev_mht; + fn part_path(&self, part_index: u64) -> Result { + Ok(format!("{}/Data{:04}", self.data_dir()?, part_index)) + } +} - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", part_count - prev_part_index, part_count); - } +impl GodSink for FatxSink<'_, T> { + fn begin(&mut self, source: &PreparedSource) -> Result<()> { + let title_id_str = format!("{:08X}", source.exe_info.title_id); + let content_type_str = format!("{:08X}", source.content_type as u32); + let media_id_str = match source.content_type { + ContentType::GamesOnDemand => format!("{:08X}", source.exe_info.media_id), + ContentType::XboxOriginal => format!("{:08X}", source.exe_info.title_id), + }; + let dest_root = self.dest_dir.trim_end_matches('/'); + let title_dir = format!("{}/{}", dest_root, title_id_str); + let content_dir = format!("{}/{}", title_dir, content_type_str); + let con_header_path = format!("{}/{}", content_dir, media_id_str); + let data_dir = format!("{}/{}.data", content_dir, media_id_str); + + ensure_fatx_dir(self.vol, &title_dir)?; + ensure_fatx_dir(self.vol, &content_dir)?; + ensure_fatx_dir(self.vol, &data_dir)?; + self.data_dir = Some(data_dir); + self.con_header_path = Some(con_header_path); + self.master_lists.clear(); + self.master_lists.reserve(source.report.part_count as usize); + self.last_part_size = 0; + Ok(()) } - let last_part_size = fs::metadata(file_layout.part_file_path(part_count - 1)) - .map_err(FatxError::Io)? - .len(); + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); + let mut iso = source.open_reader()?; + let (len, master) = + fill_part_buf(&mut iso, part_index, remaining_bytes, &mut self.part_buf)?; + let part_path = self.part_path(part_index)?; + let reader = Cursor::new(&self.part_buf[..len]); - // ---- CON header (final step) ---------------------------------------- + let mut outer = opts.progress.take(); + let part_idx_now = part_index; + let part_count_now = source.report.part_count; + { + let mut inner = |bytes: u64, total: u64| { + if let Some(cb) = outer.as_deref_mut() { + let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); + cb(&stage, bytes, total); + } + }; + self.vol + .create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; + } + opts.progress = outer; - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 0, 1); + self.master_lists.push(master); + self.last_part_size = len as u64; + Ok(()) } - let mut con_header = ConHeaderBuilder::new() - .with_execution_info(&exe_info) - .with_block_counts(block_count as u32, 0) - .with_data_parts_info( - part_count as u32, - last_part_size + (part_count - 1) * god::BLOCK_SIZE * 0xa290, - ) - .with_content_type(content_type) - .with_mht_hash(&mht.digest()); + fn read_master_hash(&mut self, _source: &PreparedSource, part_index: u64) -> Result { + self.master_lists + .get(part_index as usize) + .cloned() + .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index))) + } - if let Some(game_title) = opts.game_title { - con_header = con_header.with_game_title(game_title); + fn write_master_hash( + &mut self, + _source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()> { + let slot = self + .master_lists + .get_mut(part_index as usize) + .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index)))?; + *slot = mht.clone(); + let part_path = self.part_path(part_index)?; + overwrite_part_master(self.vol, &part_path, mht.bytes()) } - let con_header = con_header.finalize(); + fn last_part_size(&self, _source: &PreparedSource) -> Result { + Ok(self.last_part_size) + } - let mut con_header_file = File::options() - .write(true) - .create(true) - .truncate(true) - .open(file_layout.con_header_file_path()) - .map_err(FatxError::Io)?; + fn write_con_header(&mut self, _source: &PreparedSource, con_bytes: Vec) -> Result<()> { + let con_len = con_bytes.len() as u64; + let path = self.con_header_path()?.to_string(); + self.vol + .create_file_from_reader(&path, con_len, Cursor::new(con_bytes), None) + } - con_header_file - .write_all(&con_header) - .map_err(FatxError::Io)?; + fn flush_after_parts(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) + } - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 1, 1); + fn flush_after_mht(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) } - Ok(report) + fn flush_after_header(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) + } +} + +/// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. +/// +/// Writes: +/// - `///.data/Data0000..DataN` +/// - `///` (CON header) +/// +/// Returns a [`ConvertReport`] describing what was produced (or what *would* +/// have been, when `opts.dry_run` is set). +pub fn convert_iso<'a>( + source_iso: &Path, + dest_dir: &Path, + opts: &'a mut ConvertOptions<'a>, +) -> Result { + let source = prepare_source(source_iso, opts)?; + if opts.dry_run { + return Ok(source.report); + } + let mut sink = HostFsSink { dest_dir }; + run_conversion(&source, &mut sink, opts, "convert_iso") } // --- internal helpers -------------------------------------------------- @@ -394,20 +414,18 @@ fn ensure_empty_dir(path: &Path) -> Result<()> { Ok(()) } -fn read_part_mht(file_layout: &FileLayout, part_index: u64) -> Result { - let part_file = file_layout.part_file_path(part_index); +fn read_part_mht(path: &Path) -> Result { let mut part_file = File::options() .read(true) - .open(part_file) + .open(path) .map_err(FatxError::Io)?; HashList::read(&mut part_file) } -fn write_part_mht(file_layout: &FileLayout, part_index: u64, mht: &HashList) -> Result<()> { - let part_file = file_layout.part_file_path(part_index); +fn write_part_mht(path: &Path, mht: &HashList) -> Result<()> { let mut part_file = File::options() .write(true) - .open(part_file) + .open(path) .map_err(FatxError::Io)?; mht.write(&mut part_file)?; Ok(()) @@ -454,162 +472,53 @@ pub fn convert_iso_to_fatx<'a, T>( where T: Read + Seek + Write, { + let source = prepare_source(source_iso, opts)?; + if opts.dry_run { + return Ok(source.report); + } + if source.report.part_count == 0 { + return Err(FatxError::Other( + "convert_iso_to_fatx: source has no data to convert".to_string(), + )); + } + let mut sink = FatxSink::new(vol, dest_dir); + run_conversion(&source, &mut sink, opts, "convert_iso_to_fatx") +} + +fn prepare_source(source_iso: &Path, opts: &ConvertOptions<'_>) -> Result { if matches!(opts.trim, TrimMode::Compact) { let compact = build_compact_source(source_iso, opts.should_abort)?; - let block_count = compact.data_size().div_ceil(BLOCK_SIZE); - let part_count = block_count.div_ceil(BLOCKS_PER_PART); - let report = ConvertReport { - title_id: compact.exe_info().title_id, - media_id: compact.exe_info().media_id, + let report = build_report( + compact.exe_info().title_id, + compact.exe_info().media_id, + compact.content_type(), + compact.data_size(), + ); + return Ok(PreparedSource { + exe_info: compact.exe_info().clone(), content_type: compact.content_type(), - part_count, - block_count, - data_size: compact.data_size(), - }; - if opts.dry_run { - return Ok(report); - } - if part_count == 0 { - return Err(FatxError::Other( - "convert_iso_to_fatx: source has no data to convert".to_string(), - )); - } - - let title_id_str = format!("{:08X}", compact.exe_info().title_id); - let content_type_str = format!("{:08X}", compact.content_type() as u32); - let media_id_str = match compact.content_type() { - ContentType::GamesOnDemand => format!("{:08X}", compact.exe_info().media_id), - ContentType::XboxOriginal => format!("{:08X}", compact.exe_info().title_id), - }; - let dest_root = dest_dir.trim_end_matches('/'); - let title_dir = format!("{}/{}", dest_root, title_id_str); - let content_dir = format!("{}/{}", title_dir, content_type_str); - let con_header_path = format!("{}/{}", content_dir, media_id_str); - let data_dir = format!("{}/{}.data", content_dir, media_id_str); - - ensure_fatx_dir(vol, &title_dir)?; - ensure_fatx_dir(vol, &content_dir)?; - ensure_fatx_dir(vol, &data_dir)?; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", 0, part_count); - } - - let mut part_buf = vec![0u8; MAX_PART_BYTES]; - let mut master_lists: Vec = Vec::with_capacity(part_count as usize); - let mut last_part_size: u64 = 0; - - for part_index in 0..part_count { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other( - "convert_iso_to_fatx: cancelled".to_string(), - )); - } - - let remaining_bytes = part_payload_bytes(compact.data_size(), part_index); - let mut iso = compact.open_reader(source_iso)?; - let (len, master) = - fill_part_buf(&mut iso, part_index, remaining_bytes, &mut part_buf)?; - let part_path = format!("{}/Data{:04}", data_dir, part_index); - let reader = Cursor::new(&part_buf[..len]); - - let mut outer = opts.progress.take(); - let part_idx_now = part_index; - let part_count_now = part_count; - { - let mut inner = |bytes: u64, total: u64| { - if let Some(cb) = outer.as_deref_mut() { - let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); - cb(&stage, bytes, total); - } - }; - vol.create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; - } - opts.progress = outer; - - master_lists.push(master); - last_part_size = len as u64; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", part_index + 1, part_count); - } - } - let _ = vol.flush(); - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", 0, part_count); - } - for i in (0..(part_count as usize).saturating_sub(1)).rev() { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other( - "convert_iso_to_fatx: cancelled".to_string(), - )); - } - let next_digest = master_lists[i + 1].digest(); - master_lists[i].add_hash(&next_digest); - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", (part_count as u64) - 1 - (i as u64), part_count); - } - } - - for (i, master) in master_lists.iter().enumerate() { - let part_path = format!("{}/Data{:04}", data_dir, i); - overwrite_part_master(vol, &part_path, master.bytes())?; - } - let _ = vol.flush(); - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 0, 1); - } - - let mut con_header = ConHeaderBuilder::new() - .with_execution_info(compact.exe_info()) - .with_block_counts(block_count as u32, 0) - .with_data_parts_info( - part_count as u32, - last_part_size + (part_count - 1) * BLOCK_SIZE * 0xa290, - ) - .with_content_type(compact.content_type()) - .with_mht_hash(&master_lists[0].digest()); - if let Some(title) = opts.game_title { - con_header = con_header.with_game_title(title); - } - let con_bytes = con_header.finalize(); - let con_len = con_bytes.len() as u64; - vol.create_file_from_reader(&con_header_path, con_len, Cursor::new(con_bytes), None)?; - let _ = vol.flush(); - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 1, 1); - } - - return Ok(report); + report, + reader: ReaderSource::Compact { + source_iso: source_iso.to_path_buf(), + compact, + }, + }); } - // --- Metadata pass (mirrors convert_iso) -------------------------------- let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; - let img = File::open(source_iso).map_err(FatxError::Io)?; let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; - let volume = xdvdfs::read::read_volume(&mut xiso) .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; - let title_info = TitleInfo::from_image(&mut xiso, volume)?; let exe_info = title_info.execution_info; let content_type = title_info.content_type; - let root_offset = { xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; xiso.get_mut().stream_position().map_err(FatxError::Io)? }; - let data_size = match opts.trim { TrimMode::PreserveLayout => volume .root_table @@ -620,179 +529,142 @@ where if dirent.1.node.dirent.data.is_empty() { return 0; } - let off = dirent + let offset = dirent .1 .node .dirent .data .offset::(0) .unwrap_or(0); - off + dirent.1.node.dirent.data.size() as u64 + offset + dirent.1.node.dirent.data.size() as u64 }) .max() .unwrap_or(0), TrimMode::None => source_iso_file_meta.len() - root_offset, TrimMode::Compact => unreachable!("compact handled before metadata pass"), }; + let report = build_report( + exe_info.title_id, + exe_info.media_id, + content_type, + data_size, + ); + Ok(PreparedSource { + exe_info, + content_type, + report, + reader: ReaderSource::Raw { + source_iso: source_iso.to_path_buf(), + root_offset, + }, + }) +} +fn build_report( + title_id: u32, + media_id: u32, + content_type: ContentType, + data_size: u64, +) -> ConvertReport { let block_count = data_size.div_ceil(BLOCK_SIZE); let part_count = block_count.div_ceil(BLOCKS_PER_PART); - - let report = ConvertReport { - title_id: exe_info.title_id, - media_id: exe_info.media_id, + ConvertReport { + title_id, + media_id, content_type, part_count, block_count, data_size, - }; - - if opts.dry_run { - return Ok(report); } - if part_count == 0 { - return Err(FatxError::Other( - "convert_iso_to_fatx: source has no data to convert".to_string(), - )); +} + +fn build_con_header( + source: &PreparedSource, + mht_digest: &[u8; 20], + game_title: Option<&str>, + last_part_size: u64, +) -> Vec { + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&source.exe_info) + .with_block_counts(source.report.block_count as u32, 0) + .with_data_parts_info( + source.report.part_count as u32, + last_part_size + (source.report.part_count - 1) * BLOCK_SIZE * 0xa290, + ) + .with_content_type(source.content_type) + .with_mht_hash(mht_digest); + if let Some(title) = game_title { + con_header = con_header.with_game_title(title); } + con_header.finalize() +} - // --- Compose FATX paths ------------------------------------------------- - let title_id_str = format!("{:08X}", exe_info.title_id); - let content_type_str = format!("{:08X}", content_type as u32); - let media_id_str = match content_type { - ContentType::GamesOnDemand => format!("{:08X}", exe_info.media_id), - ContentType::XboxOriginal => format!("{:08X}", exe_info.title_id), - }; - let dest_root = dest_dir.trim_end_matches('/'); - let title_dir = format!("{}/{}", dest_root, title_id_str); - let content_dir = format!("{}/{}", title_dir, content_type_str); - let con_header_path = format!("{}/{}", content_dir, media_id_str); - let data_dir = format!("{}/{}.data", content_dir, media_id_str); +fn run_conversion<'a, S: GodSink>( + source: &PreparedSource, + sink: &mut S, + opts: &mut ConvertOptions<'a>, + cancel_ctx: &str, +) -> Result { + if source.report.part_count == 0 { + return Err(FatxError::Other(format!( + "{cancel_ctx}: source has no data to convert" + ))); + } - ensure_fatx_dir(vol, &title_dir)?; - ensure_fatx_dir(vol, &content_dir)?; - ensure_fatx_dir(vol, &data_dir)?; + sink.begin(source)?; - // --- Write Data parts straight into FATX ------------------------------- if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", 0, part_count); + cb("parts", 0, source.report.part_count); } - - let mut part_buf = vec![0u8; MAX_PART_BYTES]; - let mut master_lists: Vec = Vec::with_capacity(part_count as usize); - let mut last_part_size: u64 = 0; - - for part_index in 0..part_count { + for part_index in 0..source.report.part_count { if let Some(abort) = opts.should_abort && abort() { - return Err(FatxError::Other( - "convert_iso_to_fatx: cancelled".to_string(), - )); - } - - // Fresh source reader per part — matches `convert_iso`'s pattern. - // Unbuffered: each subpart read pulls exactly SUBPART_SIZE bytes - // straight into part_buf, no interposing BufReader copy. - let mut iso = File::open(source_iso).map_err(FatxError::Io)?; - iso.seek(SeekFrom::Start(root_offset)) - .map_err(FatxError::Io)?; - - let remaining_bytes = part_payload_bytes(data_size, part_index); - let (len, master) = fill_part_buf(&mut iso, part_index, remaining_bytes, &mut part_buf)?; - let part_path = format!("{}/Data{:04}", data_dir, part_index); - let reader = Cursor::new(&part_buf[..len]); - - // Forward per-cluster byte progress from `create_file_from_reader` - // up to the caller — each part takes seconds on a slow USB drive, - // and the caller (e.g. the TUI) handles rate-limiting / throughput - // computation. We temporarily move `opts.progress` into a local so - // the inner closure can borrow it exclusively, then restore it - // after the write. - let mut outer = opts.progress.take(); - let part_idx_now = part_index; - let part_count_now = part_count; - { - let mut inner = |bytes: u64, total: u64| { - if let Some(cb) = outer.as_deref_mut() { - // Encode "part X/Y" into the stage label so the caller - // can render bytes / throughput / etc as it sees fit. - let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); - cb(&stage, bytes, total); - } - }; - vol.create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; + return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); } - opts.progress = outer; - - master_lists.push(master); - last_part_size = len as u64; - - // No vol.flush() here. Each flush forces a positional FAT write - // through the slow USB stack and costs hundreds of milliseconds - // per call — and a mid-conversion crash leaves an invalid GoD - // package either way, so partial flushes don't buy meaningful - // recoverability. We flush once at the end of the parts loop, - // again after the MHT patches, and once more after the CON - // header. + sink.write_part(source, part_index, opts)?; if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", part_index + 1, part_count); + cb("parts", part_index + 1, source.report.part_count); } } - let _ = vol.flush(); + sink.flush_after_parts()?; - // --- MHT chain pass (in memory, then patch each part's first cluster) -- if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", 0, part_count); + cb("mht", 0, source.report.part_count); } - for i in (0..(part_count as usize).saturating_sub(1)).rev() { + let mut mht = sink.read_master_hash(source, source.report.part_count - 1)?; + for prev_part_index in (0..source.report.part_count - 1).rev() { if let Some(abort) = opts.should_abort && abort() { - return Err(FatxError::Other( - "convert_iso_to_fatx: cancelled".to_string(), - )); + return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); } - let next_digest = master_lists[i + 1].digest(); - master_lists[i].add_hash(&next_digest); + let mut prev_mht = sink.read_master_hash(source, prev_part_index)?; + prev_mht.add_hash(&mht.digest()); + sink.write_master_hash(source, prev_part_index, &prev_mht)?; + mht = prev_mht; if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", (part_count as u64) - 1 - (i as u64), part_count); + cb( + "mht", + source.report.part_count - prev_part_index, + source.report.part_count, + ); } } + sink.flush_after_mht()?; - for (i, master) in master_lists.iter().enumerate() { - let part_path = format!("{}/Data{:04}", data_dir, i); - overwrite_part_master(vol, &part_path, master.bytes())?; - } - let _ = vol.flush(); - - // --- CON header -------------------------------------------------------- if let Some(cb) = opts.progress.as_deref_mut() { cb("header", 0, 1); } - - let mut con_header = ConHeaderBuilder::new() - .with_execution_info(&exe_info) - .with_block_counts(block_count as u32, 0) - .with_data_parts_info( - part_count as u32, - last_part_size + (part_count - 1) * BLOCK_SIZE * 0xa290, - ) - .with_content_type(content_type) - .with_mht_hash(&master_lists[0].digest()); - if let Some(title) = opts.game_title { - con_header = con_header.with_game_title(title); - } - let con_bytes = con_header.finalize(); - let con_len = con_bytes.len() as u64; - vol.create_file_from_reader(&con_header_path, con_len, Cursor::new(con_bytes), None)?; - let _ = vol.flush(); - + let last_part_size = sink.last_part_size(source)?; + let con_header = build_con_header(source, &mht.digest(), opts.game_title, last_part_size); + sink.write_con_header(source, con_header)?; + sink.flush_after_header()?; if let Some(cb) = opts.progress.as_deref_mut() { cb("header", 1, 1); } - Ok(report) + Ok(source.report) } /// Build one Data part directly in `out`. Returns the actual number of @@ -897,3 +769,97 @@ where Err(e) => Err(e), } } + +#[cfg(test)] +mod tests { + use super::*; + + struct NoopSink; + + impl GodSink for NoopSink { + fn begin(&mut self, _source: &PreparedSource) -> Result<()> { + unreachable!("zero-part source should fail before sink begin") + } + + fn write_part<'a>( + &mut self, + _source: &PreparedSource, + _part_index: u64, + _opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + unreachable!("zero-part source should fail before part writes") + } + + fn read_master_hash( + &mut self, + _source: &PreparedSource, + _part_index: u64, + ) -> Result { + unreachable!("zero-part source should fail before hash reads") + } + + fn write_master_hash( + &mut self, + _source: &PreparedSource, + _part_index: u64, + _mht: &HashList, + ) -> Result<()> { + unreachable!("zero-part source should fail before hash writes") + } + + fn last_part_size(&self, _source: &PreparedSource) -> Result { + unreachable!("zero-part source should fail before header build") + } + + fn write_con_header( + &mut self, + _source: &PreparedSource, + _con_bytes: Vec, + ) -> Result<()> { + unreachable!("zero-part source should fail before header write") + } + } + + #[test] + fn run_conversion_rejects_zero_part_sources() { + let source = PreparedSource { + report: ConvertReport { + title_id: 0, + media_id: 0, + content_type: ContentType::GamesOnDemand, + part_count: 0, + block_count: 0, + data_size: 0, + }, + exe_info: crate::executable::TitleExecutionInfo { + media_id: 0, + version: 0, + base_version: 0, + title_id: 0, + platform: 0, + executable_type: 0, + disc_number: 0, + disc_count: 0, + }, + content_type: ContentType::GamesOnDemand, + reader: ReaderSource::Raw { + source_iso: PathBuf::from("/tmp/zero-part.iso"), + root_offset: 0, + }, + }; + let mut sink = NoopSink; + let mut opts = ConvertOptions { + trim: TrimMode::Compact, + game_title: None, + dry_run: false, + progress: None, + should_abort: None, + }; + + let err = run_conversion(&source, &mut sink, &mut opts, "convert_iso"); + assert!( + matches!(err, Err(FatxError::Other(msg)) if msg.contains("source has no data")), + "zero-part source should be rejected before any sink work" + ); + } +} diff --git a/fatxlib/src/iso/god/hash_list.rs b/fatxlib/src/iso/god/hash_list.rs index 11b5a04..b94de04 100644 --- a/fatxlib/src/iso/god/hash_list.rs +++ b/fatxlib/src/iso/god/hash_list.rs @@ -4,6 +4,7 @@ use crate::error::{FatxError, Result}; use super::sha1_digest; +#[derive(Clone)] pub struct HashList { buffer: [u8; 4096], len: usize, From 05731e64f16e3544b2feb080c090b943058fbe0e Mon Sep 17 00:00:00 2001 From: rdmrocha Date: Sun, 17 May 2026 12:55:23 +1000 Subject: [PATCH 12/12] more refactoring and getting ready for release --- CHANGELOG.md | 26 +- README.md | 22 +- fatxlib/src/iso/god/convert.rs | 803 +------------------------------ fatxlib/src/iso/god/core.rs | 132 +++++ fatxlib/src/iso/god/mod.rs | 11 +- fatxlib/src/iso/god/prepare.rs | 156 ++++++ fatxlib/src/iso/god/sink_fatx.rs | 245 ++++++++++ fatxlib/src/iso/god/sink_host.rs | 113 +++++ 8 files changed, 715 insertions(+), 793 deletions(-) create mode 100644 fatxlib/src/iso/god/core.rs create mode 100644 fatxlib/src/iso/god/prepare.rs create mode 100644 fatxlib/src/iso/god/sink_fatx.rs create mode 100644 fatxlib/src/iso/god/sink_host.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 803b255..0e2a3f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,32 @@ All notable changes to `xtafkit` will be documented in this file. ## [Unreleased] ### ISO / disc-image support -- Added `xtafkit extract` for streaming Xbox / Xbox 360 XISO contents to a local directory, with `$SystemUpdate` skipped by default. -- Added `xtafkit god` for XISO → Games-on-Demand conversion. Default trim is now `compact`; `preserve-layout` and `none` stay available for debugging and compatibility. +- Added `xtafkit extract` for streaming Xbox / Xbox 360 XISO contents to a local directory, with `$SystemUpdate` skipped by default. Supports `--keep-systemupdate` to override and `--dry-run` to preview file list + byte totals without writing. +- Added `xtafkit god` for XISO → Games-on-Demand conversion. Default trim is `compact`; `preserve-layout` and `none` stay available for debugging and compatibility. Game-title slot in the CON header auto-fills from the bundled catalog; pass `--game-title TITLE` to override. +- TUI upload (`u`) now sniffs every local file and, on XISO detection, prompts **e(X)tract / (G)oD / (R)aw / Esc**. Default flips by cwd context: inside `/Content//` defaults to GoD (BC playback target); everywhere else defaults to Extract (alt-dashboard target). +- Extract destination folder name is resolved from the catalog when the title is known — `disc1.iso` with TitleID `4D5307E6` extracts as `Halo 3/` rather than `disc1/`. Falls back to the file stem on catalog miss. Names are sanitized for FATX (illegal chars replaced with `-`, runs of whitespace collapsed, truncated to 42 bytes). - Introduced a shared `fatxlib::iso` namespace for image reading, manifest planning, compact repacking, and GoD conversion. -- Reworked compact GoD conversion to stream a virtual dense XDVDFS layout instead of staging a temporary ISO on disk. +- Reworked compact GoD conversion to stream a virtual dense XDVDFS layout instead of staging a temporary ISO on disk — peak local disk usage during conversion is zero. - Centralized ISO filtering and planning so extract, compact trim, and dry-run reporting share the same manifest. - Removed the old public `fatxlib::xiso` and `fatxlib::iso2god` entry points in favor of `fatxlib::iso::{image,manifest,compact,god}`. +- Refactored GoD conversion to share its engine between host-filesystem and FATX-volume targets via an internal `GodSink` trait — one `run_conversion` loop, two sink implementations. + +### Performance +- Hot-path SHA-1 in GoD conversion routes through `openssl::sha::sha1` by default (ARMv8 SHA on Apple Silicon, SHA-NI on x86). Gated by the default-on `openssl-hash` cargo feature; disable to fall back to RustCrypto's `sha1` crate with zero system OpenSSL dependency. +- Fixed a double-I/O bug in `write_part`: the upstream implementation read each subpart, hashed it, then `seek_relative`d back and re-read it via `io::copy` to write the part file. Now writes from the buffer it already has, halving I/O on the hot path (~33 % wall-time reduction on large ISOs). +- 1 MiB `BufReader` on the source ISO during the metadata pre-pass cuts syscall tax on multi-GiB inputs. +- Streaming variant of GoD conversion to FATX (`convert_iso_to_fatx`) builds each part in a reused ~163 MiB buffer and streams straight into the volume — no local staging. + +### TUI / quality of life +- Mid-conversion `Esc` cancels GoD conversion cleanly (checked between parts and between MHT-chain steps); no partial silent failures. +- Per-part byte-level progress with MiB/s throughput, rate-limited to ~200 ms intervals. +- Upload prompt no longer prefills with the last-used path — always starts blank. +- TUI extract worker skips `$SystemUpdate` and surfaces the skip count + bytes in the completion message. + +### Library API additions +- `fatxlib::iso::image::XisoImage::title_info()` parses the embedded `Default.xex` / `default.xbe` and returns the title's execution info. Used by catalog name resolution and by the GoD conversion pipeline. +- `fatxlib::executable` (top-level module) holds `TitleInfo` / `TitleExecutionInfo` and the XEX/XBE parsers — shared between `iso::image` and `iso::god`. +- `fatxlib::volume::FatxVolume::create_file_from_reader` streams a file into FATX cluster-by-cluster from any `Read` source, capping working-set at one cluster regardless of total file size. ## [1.1.0] - 2026-05-16 diff --git a/README.md b/README.md index 87f86bd..186d483 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ cargo build --release Produces a single binary: `target/release/xtafkit`. +The default build links against the system OpenSSL for hardware-accelerated SHA-1 during GoD conversion. On macOS install via Homebrew (`brew install openssl@3`); on Debian/Ubuntu install `libssl-dev`. To skip the OpenSSL dependency entirely and fall back to portable Rust SHA-1, build with `cargo build --release --no-default-features`. + ## Quick start ```bash @@ -90,7 +92,7 @@ sudo xtafkit browse /dev/rdisk4 --partition "360 Data" | `R` | Resolve title or bulk-scan files (slot-aware) | | `s` | Toggle sort: by name ⇄ by ID (flips bracket order) | | `m` | Create directory | -| `d` / `u` | Download / upload | +| `d` / `u` | Download / upload (XISO uploads prompt for e**(X)**tract / **(G)**oD / **(R)**aw — see below) | | `D` / `r` | Delete / rename | | `i` | Volume info | | `c` | Clean up macOS metadata | @@ -98,6 +100,24 @@ sudo xtafkit browse /dev/rdisk4 --partition "360 Data" Entries that can be resolved show a `?` marker. Resolution results are cached under `~/.config/xtafkit/` and persist across runs. +### Uploading an XISO + +When the file you point at is an Xbox / Xbox 360 disc image (XDVDFS volume detected automatically), the upload prompt becomes: + +``` +Detected XISO 'Halo.iso'. e(X)tract / (G)oD / (R)aw / Esc: +``` + +| Choice | Result | +|---|---| +| **(X)tract** | Walks the XISO and writes each file into `//` on the drive. `$SystemUpdate` is skipped automatically. `` is the catalog-known game title when available, otherwise the local filename stem. Best for alt dashboards (Aurora / FreeStyle / XBMC4XBOX) that launch loose `default.xex` / `default.xbe` directly. | +| **(G)oD** | Streams a Games-on-Demand package into `//00007000/{,.data/}`. Uses the compact trim by default so the output is sized to actual content, not the original mastered layout. Required for stock Xbox 360 backward-compatibility playback. | +| **(R)aw** | Plain byte-for-byte copy of the source ISO file. | + +The default action (the capitalized letter) flips by context: inside `/Content//` the default is **G** (where the dashboard looks for BC packages); everywhere else the default is **X**. + +Press `Esc` to cancel mid-conversion at any time — the worker checks between parts and between hash-tree steps. + ## `xtafkit resolve` Auto-dispatches by what you point at: diff --git a/fatxlib/src/iso/god/convert.rs b/fatxlib/src/iso/god/convert.rs index 28ac7db..638eff5 100644 --- a/fatxlib/src/iso/god/convert.rs +++ b/fatxlib/src/iso/god/convert.rs @@ -1,33 +1,22 @@ //! Public entry point for ISO → Games-on-Demand conversion. //! -//! Walks the source ISO via xdvdfs, computes the GoD file layout, writes -//! each Data part with its embedded hash tree, and finalizes the CON -//! header. See `NOTICE` for the upstream sources this code descends from. +//! The actual work is split across: +//! - `prepare` for source analysis and layout sizing +//! - `core` for the shared conversion loop +//! - `sink_host` / `sink_fatx` for transport-specific outputs //! -//! Single-threaded. The metadata pre-pass uses a 1 MiB `BufReader` to cut -//! syscall tax on the file-tree walk; per-part data reads go straight -//! against the file (a fixed-size subpart read into a pre-allocated -//! buffer makes an interposing reader pure overhead). A multi-threaded -//! mode could land later as an opt-in flag. +//! See `NOTICE` for the upstream sources this code descends from. -use std::fs::{self, File}; -use std::io::{BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; +use std::path::Path; -use crate::error::{FatxError, Result}; -use crate::executable::TitleInfo; -use crate::iso::compact::build_compact_source; +use crate::error::Result; use crate::volume::FatxVolume; -use super::{ - self as god, BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ContentType, FileLayout, HashList, - SUBPART_SIZE, SUBPARTS_PER_PART, -}; - -/// Buffer capacity for the metadata-pass source-ISO reader. 1 MiB — -/// large enough that the default 8 KiB capacity's syscall tax disappears -/// on multi-GiB ISOs, without requiring OS-level read-ahead tuning. -pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; +use super::ContentType; +use super::core::run_conversion; +use super::prepare::prepare_source; +use super::sink_fatx::FatxSink; +use super::sink_host::HostFsSink; /// Progress callback shape: `(stage, current, total)` where `stage` is one /// of `"parts"`, `"mht"`, `"header"`. @@ -55,21 +44,9 @@ pub enum TrimMode { #[derive(Default)] pub struct ConvertOptions<'a> { pub trim: TrimMode, - /// Override the human-readable game title written into the CON header. - /// `None` leaves the slot blank — fatxlib's [`crate::titles`] catalog is - /// not consulted here; callers that want auto-fill should resolve the - /// title ID themselves and pass the result through. pub game_title: Option<&'a str>, - /// When true, read metadata and return the [`ConvertReport`] without - /// touching `dest_dir`. pub dry_run: bool, - /// Optional progress callback. Stages: "scan", "parts", "mht", "header". - /// `current`/`total` are stage-relative. pub progress: Option>, - /// Optional cancellation hook. Checked before each part write and - /// before each MHT-chain step; returning `true` aborts the conversion - /// with a clean error rather than partial silent failure. Mid-part - /// cancellation is not supported. pub should_abort: Option<&'a dyn Fn() -> bool>, } @@ -81,316 +58,10 @@ pub struct ConvertReport { pub content_type: ContentType, pub part_count: u64, pub block_count: u64, - /// Bytes of the source partition packed into the GoD parts (post-trim). pub data_size: u64, } -trait ReadSeek: Read + Seek {} - -impl ReadSeek for T {} - -struct PreparedSource { - report: ConvertReport, - exe_info: crate::executable::TitleExecutionInfo, - content_type: ContentType, - reader: ReaderSource, -} - -enum ReaderSource { - Raw { - source_iso: PathBuf, - root_offset: u64, - }, - Compact { - source_iso: PathBuf, - compact: crate::iso::compact::CompactSource, - }, -} - -impl PreparedSource { - fn open_reader(&self) -> Result> { - self.reader.open_reader() - } -} - -impl ReaderSource { - fn open_reader(&self) -> Result> { - match self { - Self::Raw { - source_iso, - root_offset, - } => { - let mut iso = File::open(source_iso).map_err(FatxError::Io)?; - iso.seek(SeekFrom::Start(*root_offset)) - .map_err(FatxError::Io)?; - Ok(Box::new(iso)) - } - Self::Compact { - source_iso, - compact, - } => Ok(Box::new(compact.open_reader(source_iso)?)), - } - } -} - -trait GodSink { - fn begin(&mut self, source: &PreparedSource) -> Result<()>; - fn write_part<'a>( - &mut self, - source: &PreparedSource, - part_index: u64, - opts: &mut ConvertOptions<'a>, - ) -> Result<()>; - fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result; - fn write_master_hash( - &mut self, - source: &PreparedSource, - part_index: u64, - mht: &HashList, - ) -> Result<()>; - fn last_part_size(&self, source: &PreparedSource) -> Result; - fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()>; - fn flush_after_parts(&mut self) -> Result<()> { - Ok(()) - } - fn flush_after_mht(&mut self) -> Result<()> { - Ok(()) - } - fn flush_after_header(&mut self) -> Result<()> { - Ok(()) - } -} - -struct HostFsSink<'a> { - dest_dir: &'a Path, -} - -impl<'a> HostFsSink<'a> { - fn data_dir_path(&self, source: &PreparedSource) -> std::path::PathBuf { - FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).data_dir_path() - } - - fn part_file_path(&self, source: &PreparedSource, part_index: u64) -> std::path::PathBuf { - FileLayout::new(self.dest_dir, &source.exe_info, source.content_type) - .part_file_path(part_index) - } - - fn con_header_file_path(&self, source: &PreparedSource) -> std::path::PathBuf { - FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).con_header_file_path() - } -} - -impl GodSink for HostFsSink<'_> { - fn begin(&mut self, source: &PreparedSource) -> Result<()> { - ensure_empty_dir(&self.data_dir_path(source)) - } - - fn write_part<'a>( - &mut self, - source: &PreparedSource, - part_index: u64, - _opts: &mut ConvertOptions<'a>, - ) -> Result<()> { - let part_path = self.part_file_path(source, part_index); - let part_file = File::options() - .write(true) - .create(true) - .truncate(true) - .open(&part_path) - .map_err(FatxError::Io)?; - let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); - let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); - let iso_data_volume = source.open_reader()?; - god::write_part(iso_data_volume, part_index, remaining_bytes, part_file) - } - - fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result { - let part_path = self.part_file_path(source, part_index); - read_part_mht(&part_path) - } - - fn write_master_hash( - &mut self, - source: &PreparedSource, - part_index: u64, - mht: &HashList, - ) -> Result<()> { - let part_path = self.part_file_path(source, part_index); - write_part_mht(&part_path, mht) - } - - fn last_part_size(&self, source: &PreparedSource) -> Result { - fs::metadata(self.part_file_path(source, source.report.part_count - 1)) - .map_err(FatxError::Io) - .map(|meta| meta.len()) - } - - fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()> { - let mut con_header_file = File::options() - .write(true) - .create(true) - .truncate(true) - .open(self.con_header_file_path(source)) - .map_err(FatxError::Io)?; - con_header_file.write_all(&con_bytes).map_err(FatxError::Io) - } -} - -struct FatxSink<'a, T: Read + Seek + Write> { - vol: &'a mut FatxVolume, - dest_dir: &'a str, - data_dir: Option, - con_header_path: Option, - part_buf: Vec, - master_lists: Vec, - last_part_size: u64, -} - -impl<'a, T: Read + Seek + Write> FatxSink<'a, T> { - fn new(vol: &'a mut FatxVolume, dest_dir: &'a str) -> Self { - Self { - vol, - dest_dir, - data_dir: None, - con_header_path: None, - part_buf: vec![0u8; MAX_PART_BYTES], - master_lists: Vec::new(), - last_part_size: 0, - } - } - - fn data_dir(&self) -> Result<&str> { - self.data_dir - .as_deref() - .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) - } - - fn con_header_path(&self) -> Result<&str> { - self.con_header_path - .as_deref() - .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) - } - - fn part_path(&self, part_index: u64) -> Result { - Ok(format!("{}/Data{:04}", self.data_dir()?, part_index)) - } -} - -impl GodSink for FatxSink<'_, T> { - fn begin(&mut self, source: &PreparedSource) -> Result<()> { - let title_id_str = format!("{:08X}", source.exe_info.title_id); - let content_type_str = format!("{:08X}", source.content_type as u32); - let media_id_str = match source.content_type { - ContentType::GamesOnDemand => format!("{:08X}", source.exe_info.media_id), - ContentType::XboxOriginal => format!("{:08X}", source.exe_info.title_id), - }; - let dest_root = self.dest_dir.trim_end_matches('/'); - let title_dir = format!("{}/{}", dest_root, title_id_str); - let content_dir = format!("{}/{}", title_dir, content_type_str); - let con_header_path = format!("{}/{}", content_dir, media_id_str); - let data_dir = format!("{}/{}.data", content_dir, media_id_str); - - ensure_fatx_dir(self.vol, &title_dir)?; - ensure_fatx_dir(self.vol, &content_dir)?; - ensure_fatx_dir(self.vol, &data_dir)?; - self.data_dir = Some(data_dir); - self.con_header_path = Some(con_header_path); - self.master_lists.clear(); - self.master_lists.reserve(source.report.part_count as usize); - self.last_part_size = 0; - Ok(()) - } - - fn write_part<'a>( - &mut self, - source: &PreparedSource, - part_index: u64, - opts: &mut ConvertOptions<'a>, - ) -> Result<()> { - let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); - let mut iso = source.open_reader()?; - let (len, master) = - fill_part_buf(&mut iso, part_index, remaining_bytes, &mut self.part_buf)?; - let part_path = self.part_path(part_index)?; - let reader = Cursor::new(&self.part_buf[..len]); - - let mut outer = opts.progress.take(); - let part_idx_now = part_index; - let part_count_now = source.report.part_count; - { - let mut inner = |bytes: u64, total: u64| { - if let Some(cb) = outer.as_deref_mut() { - let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); - cb(&stage, bytes, total); - } - }; - self.vol - .create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; - } - opts.progress = outer; - - self.master_lists.push(master); - self.last_part_size = len as u64; - Ok(()) - } - - fn read_master_hash(&mut self, _source: &PreparedSource, part_index: u64) -> Result { - self.master_lists - .get(part_index as usize) - .cloned() - .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index))) - } - - fn write_master_hash( - &mut self, - _source: &PreparedSource, - part_index: u64, - mht: &HashList, - ) -> Result<()> { - let slot = self - .master_lists - .get_mut(part_index as usize) - .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index)))?; - *slot = mht.clone(); - let part_path = self.part_path(part_index)?; - overwrite_part_master(self.vol, &part_path, mht.bytes()) - } - - fn last_part_size(&self, _source: &PreparedSource) -> Result { - Ok(self.last_part_size) - } - - fn write_con_header(&mut self, _source: &PreparedSource, con_bytes: Vec) -> Result<()> { - let con_len = con_bytes.len() as u64; - let path = self.con_header_path()?.to_string(); - self.vol - .create_file_from_reader(&path, con_len, Cursor::new(con_bytes), None) - } - - fn flush_after_parts(&mut self) -> Result<()> { - let _ = self.vol.flush(); - Ok(()) - } - - fn flush_after_mht(&mut self) -> Result<()> { - let _ = self.vol.flush(); - Ok(()) - } - - fn flush_after_header(&mut self) -> Result<()> { - let _ = self.vol.flush(); - Ok(()) - } -} - /// Convert an Xbox 360 / original-Xbox ISO into a Games-on-Demand package. -/// -/// Writes: -/// - `///.data/Data0000..DataN` -/// - `///` (CON header) -/// -/// Returns a [`ConvertReport`] describing what was produced (or what *would* -/// have been, when `opts.dry_run` is set). pub fn convert_iso<'a>( source_iso: &Path, dest_dir: &Path, @@ -400,69 +71,12 @@ pub fn convert_iso<'a>( if opts.dry_run { return Ok(source.report); } - let mut sink = HostFsSink { dest_dir }; - run_conversion(&source, &mut sink, opts, "convert_iso") -} - -// --- internal helpers -------------------------------------------------- - -fn ensure_empty_dir(path: &Path) -> Result<()> { - if fs::exists(path).map_err(FatxError::Io)? { - fs::remove_dir_all(path).map_err(FatxError::Io)?; - } - fs::create_dir_all(path).map_err(FatxError::Io)?; - Ok(()) -} - -fn read_part_mht(path: &Path) -> Result { - let mut part_file = File::options() - .read(true) - .open(path) - .map_err(FatxError::Io)?; - HashList::read(&mut part_file) -} - -fn write_part_mht(path: &Path, mht: &HashList) -> Result<()> { - let mut part_file = File::options() - .write(true) - .open(path) - .map_err(FatxError::Io)?; - mht.write(&mut part_file)?; - Ok(()) -} - -// =========================================================================== -// Streaming variant: write the GoD package straight into a FatxVolume. -// =========================================================================== -/// Maximum bytes a single Data part file can occupy. Equals `4 KiB -/// master_hash_list + SUBPARTS_PER_PART × (4 KiB sub_hash_list + -/// SUBPART_SIZE)`, which is exactly `BLOCK_SIZE * 0xa290` — the magic -/// constant the CON header uses to describe a full part. -const MAX_PART_BYTES: usize = 4096 + (SUBPARTS_PER_PART as usize) * (4096 + SUBPART_SIZE as usize); - -fn part_payload_bytes(data_size: u64, part_index: u64) -> u64 { - let part_start = part_index - .saturating_mul(BLOCKS_PER_PART) - .saturating_mul(BLOCK_SIZE); - data_size - .saturating_sub(part_start) - .min(BLOCKS_PER_PART * BLOCK_SIZE) + let mut sink = HostFsSink::new(dest_dir); + run_conversion(&source, &mut sink, opts, "convert_iso") } -/// Convert an ISO directly into a Games-on-Demand package rooted at -/// `dest_dir` on a FATX volume — no local staging. -/// -/// Same output as [`convert_iso`] but bypasses the local filesystem -/// entirely: each Data part is built in a reused in-memory buffer -/// (~163 MiB) and streamed into FATX via -/// [`FatxVolume::create_file_from_reader`]. After all parts are written, -/// the MHT chain pass happens in memory and each part's first 4 KiB -/// (the master hash list) is patched on disk with a single -/// read-modify-write at the cluster level. -/// -/// Peak RAM: one part buffer (~163 MiB) plus the per-part master hash -/// list vector (~108 KiB total for a 27-part game). +/// Convert an ISO directly into a Games-on-Demand package rooted at a FATX volume. pub fn convert_iso_to_fatx<'a, T>( source_iso: &Path, vol: &mut FatxVolume, @@ -470,396 +84,13 @@ pub fn convert_iso_to_fatx<'a, T>( opts: &'a mut ConvertOptions<'a>, ) -> Result where - T: Read + Seek + Write, + T: std::io::Read + std::io::Seek + std::io::Write, { let source = prepare_source(source_iso, opts)?; if opts.dry_run { return Ok(source.report); } - if source.report.part_count == 0 { - return Err(FatxError::Other( - "convert_iso_to_fatx: source has no data to convert".to_string(), - )); - } + let mut sink = FatxSink::new(vol, dest_dir); run_conversion(&source, &mut sink, opts, "convert_iso_to_fatx") } - -fn prepare_source(source_iso: &Path, opts: &ConvertOptions<'_>) -> Result { - if matches!(opts.trim, TrimMode::Compact) { - let compact = build_compact_source(source_iso, opts.should_abort)?; - let report = build_report( - compact.exe_info().title_id, - compact.exe_info().media_id, - compact.content_type(), - compact.data_size(), - ); - return Ok(PreparedSource { - exe_info: compact.exe_info().clone(), - content_type: compact.content_type(), - report, - reader: ReaderSource::Compact { - source_iso: source_iso.to_path_buf(), - compact, - }, - }); - } - - let source_iso_file_meta = fs::metadata(source_iso).map_err(FatxError::Io)?; - let img = File::open(source_iso).map_err(FatxError::Io)?; - let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); - let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; - let volume = xdvdfs::read::read_volume(&mut xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; - let title_info = TitleInfo::from_image(&mut xiso, volume)?; - let exe_info = title_info.execution_info; - let content_type = title_info.content_type; - let root_offset = { - xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; - xiso.get_mut().stream_position().map_err(FatxError::Io)? - }; - let data_size = match opts.trim { - TrimMode::PreserveLayout => volume - .root_table - .file_tree(&mut xiso) - .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? - .iter() - .map(|dirent| { - if dirent.1.node.dirent.data.is_empty() { - return 0; - } - let offset = dirent - .1 - .node - .dirent - .data - .offset::(0) - .unwrap_or(0); - offset + dirent.1.node.dirent.data.size() as u64 - }) - .max() - .unwrap_or(0), - TrimMode::None => source_iso_file_meta.len() - root_offset, - TrimMode::Compact => unreachable!("compact handled before metadata pass"), - }; - let report = build_report( - exe_info.title_id, - exe_info.media_id, - content_type, - data_size, - ); - Ok(PreparedSource { - exe_info, - content_type, - report, - reader: ReaderSource::Raw { - source_iso: source_iso.to_path_buf(), - root_offset, - }, - }) -} - -fn build_report( - title_id: u32, - media_id: u32, - content_type: ContentType, - data_size: u64, -) -> ConvertReport { - let block_count = data_size.div_ceil(BLOCK_SIZE); - let part_count = block_count.div_ceil(BLOCKS_PER_PART); - ConvertReport { - title_id, - media_id, - content_type, - part_count, - block_count, - data_size, - } -} - -fn build_con_header( - source: &PreparedSource, - mht_digest: &[u8; 20], - game_title: Option<&str>, - last_part_size: u64, -) -> Vec { - let mut con_header = ConHeaderBuilder::new() - .with_execution_info(&source.exe_info) - .with_block_counts(source.report.block_count as u32, 0) - .with_data_parts_info( - source.report.part_count as u32, - last_part_size + (source.report.part_count - 1) * BLOCK_SIZE * 0xa290, - ) - .with_content_type(source.content_type) - .with_mht_hash(mht_digest); - if let Some(title) = game_title { - con_header = con_header.with_game_title(title); - } - con_header.finalize() -} - -fn run_conversion<'a, S: GodSink>( - source: &PreparedSource, - sink: &mut S, - opts: &mut ConvertOptions<'a>, - cancel_ctx: &str, -) -> Result { - if source.report.part_count == 0 { - return Err(FatxError::Other(format!( - "{cancel_ctx}: source has no data to convert" - ))); - } - - sink.begin(source)?; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", 0, source.report.part_count); - } - for part_index in 0..source.report.part_count { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); - } - sink.write_part(source, part_index, opts)?; - if let Some(cb) = opts.progress.as_deref_mut() { - cb("parts", part_index + 1, source.report.part_count); - } - } - sink.flush_after_parts()?; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("mht", 0, source.report.part_count); - } - let mut mht = sink.read_master_hash(source, source.report.part_count - 1)?; - for prev_part_index in (0..source.report.part_count - 1).rev() { - if let Some(abort) = opts.should_abort - && abort() - { - return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); - } - let mut prev_mht = sink.read_master_hash(source, prev_part_index)?; - prev_mht.add_hash(&mht.digest()); - sink.write_master_hash(source, prev_part_index, &prev_mht)?; - mht = prev_mht; - if let Some(cb) = opts.progress.as_deref_mut() { - cb( - "mht", - source.report.part_count - prev_part_index, - source.report.part_count, - ); - } - } - sink.flush_after_mht()?; - - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 0, 1); - } - let last_part_size = sink.last_part_size(source)?; - let con_header = build_con_header(source, &mht.digest(), opts.game_title, last_part_size); - sink.write_con_header(source, con_header)?; - sink.flush_after_header()?; - if let Some(cb) = opts.progress.as_deref_mut() { - cb("header", 1, 1); - } - - Ok(source.report) -} - -/// Build one Data part directly in `out`. Returns the actual number of -/// bytes used (the last part is usually shorter than [`MAX_PART_BYTES`]) -/// and the master hash list for that part. `out` must be at least -/// [`MAX_PART_BYTES`] long. -fn fill_part_buf( - data_volume: &mut R, - part_index: u64, - remaining_bytes: u64, - out: &mut [u8], -) -> Result<(usize, HashList)> { - data_volume - .seek_relative((part_index * BLOCKS_PER_PART * BLOCK_SIZE) as i64) - .map_err(FatxError::Io)?; - - let mut master = HashList::new(); - - // First 4 KiB reserved for the master hash list — filled in at the end. - let mut cursor = 4096usize; - let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; - let mut bytes_left = remaining_bytes; - - for _ in 0..SUBPARTS_PER_PART { - if bytes_left == 0 { - break; - } - let want = (subpart_buf.len() as u64).min(bytes_left) as usize; - let mut got = 0usize; - while got < want { - let n = data_volume - .read(&mut subpart_buf[got..want]) - .map_err(FatxError::Io)?; - if n == 0 { - break; - } - got += n; - } - if got == 0 { - break; - } - let subpart = &subpart_buf[..got]; - - let mut sub_hash = HashList::new(); - for block in subpart.chunks(BLOCK_SIZE as usize) { - sub_hash.add_block_hash(block); - } - - out[cursor..cursor + 4096].copy_from_slice(sub_hash.bytes()); - cursor += 4096; - out[cursor..cursor + got].copy_from_slice(subpart); - cursor += got; - bytes_left -= got as u64; - - master.add_block_hash(sub_hash.bytes()); - - if got < want { - break; - } - } - - out[0..4096].copy_from_slice(master.bytes()); - Ok((cursor, master)) -} - -/// Read the file's first cluster, overwrite its first 4 KiB with -/// `new_master`, write the cluster back. Used to patch each Data part's -/// master hash list after the MHT chain pass. -fn overwrite_part_master( - vol: &mut FatxVolume, - path: &str, - new_master: &[u8; 4096], -) -> Result<()> -where - T: Read + Seek + Write, -{ - let entry = vol.resolve_path(path)?; - let first_cluster = entry.first_cluster; - let cluster_size = vol.superblock.cluster_size() as usize; - let mut cluster_buf = vec![0u8; cluster_size]; - vol.read_cluster(first_cluster, &mut cluster_buf)?; - cluster_buf[..new_master.len()].copy_from_slice(new_master); - vol.write_cluster(first_cluster, &cluster_buf)?; - Ok(()) -} - -/// Create a directory on the FATX volume if it doesn't already exist. -/// Errors out if the path resolves to a regular file. -fn ensure_fatx_dir(vol: &mut FatxVolume, path: &str) -> Result<()> -where - T: Read + Seek + Write, -{ - match vol.create_directory(path) { - Ok(()) => Ok(()), - Err(FatxError::FileExists(_)) => { - let existing = vol.resolve_path(path)?; - if !existing.is_directory() { - return Err(FatxError::NotADirectory(path.to_string())); - } - Ok(()) - } - Err(e) => Err(e), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct NoopSink; - - impl GodSink for NoopSink { - fn begin(&mut self, _source: &PreparedSource) -> Result<()> { - unreachable!("zero-part source should fail before sink begin") - } - - fn write_part<'a>( - &mut self, - _source: &PreparedSource, - _part_index: u64, - _opts: &mut ConvertOptions<'a>, - ) -> Result<()> { - unreachable!("zero-part source should fail before part writes") - } - - fn read_master_hash( - &mut self, - _source: &PreparedSource, - _part_index: u64, - ) -> Result { - unreachable!("zero-part source should fail before hash reads") - } - - fn write_master_hash( - &mut self, - _source: &PreparedSource, - _part_index: u64, - _mht: &HashList, - ) -> Result<()> { - unreachable!("zero-part source should fail before hash writes") - } - - fn last_part_size(&self, _source: &PreparedSource) -> Result { - unreachable!("zero-part source should fail before header build") - } - - fn write_con_header( - &mut self, - _source: &PreparedSource, - _con_bytes: Vec, - ) -> Result<()> { - unreachable!("zero-part source should fail before header write") - } - } - - #[test] - fn run_conversion_rejects_zero_part_sources() { - let source = PreparedSource { - report: ConvertReport { - title_id: 0, - media_id: 0, - content_type: ContentType::GamesOnDemand, - part_count: 0, - block_count: 0, - data_size: 0, - }, - exe_info: crate::executable::TitleExecutionInfo { - media_id: 0, - version: 0, - base_version: 0, - title_id: 0, - platform: 0, - executable_type: 0, - disc_number: 0, - disc_count: 0, - }, - content_type: ContentType::GamesOnDemand, - reader: ReaderSource::Raw { - source_iso: PathBuf::from("/tmp/zero-part.iso"), - root_offset: 0, - }, - }; - let mut sink = NoopSink; - let mut opts = ConvertOptions { - trim: TrimMode::Compact, - game_title: None, - dry_run: false, - progress: None, - should_abort: None, - }; - - let err = run_conversion(&source, &mut sink, &mut opts, "convert_iso"); - assert!( - matches!(err, Err(FatxError::Other(msg)) if msg.contains("source has no data")), - "zero-part source should be rejected before any sink work" - ); - } -} diff --git a/fatxlib/src/iso/god/core.rs b/fatxlib/src/iso/god/core.rs new file mode 100644 index 0000000..579bbbf --- /dev/null +++ b/fatxlib/src/iso/god/core.rs @@ -0,0 +1,132 @@ +use crate::error::{FatxError, Result}; + +use super::prepare::PreparedSource; +use super::{ + BLOCK_SIZE, BLOCKS_PER_PART, ConHeaderBuilder, ConvertOptions, ConvertReport, HashList, +}; + +pub(crate) trait GodSink { + fn begin(&mut self, source: &PreparedSource) -> Result<()>; + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + opts: &mut ConvertOptions<'a>, + ) -> Result<()>; + fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result; + fn write_master_hash( + &mut self, + source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()>; + fn last_part_size(&self, source: &PreparedSource) -> Result; + fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()>; + fn flush_after_parts(&mut self) -> Result<()> { + Ok(()) + } + fn flush_after_mht(&mut self) -> Result<()> { + Ok(()) + } + fn flush_after_header(&mut self) -> Result<()> { + Ok(()) + } +} + +pub(crate) fn run_conversion<'a, S: GodSink>( + source: &PreparedSource, + sink: &mut S, + opts: &mut ConvertOptions<'a>, + cancel_ctx: &str, +) -> Result { + if source.report.part_count == 0 { + return Err(FatxError::Other(format!( + "{cancel_ctx}: source has no data to convert" + ))); + } + + sink.begin(source)?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", 0, source.report.part_count); + } + for part_index in 0..source.report.part_count { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); + } + sink.write_part(source, part_index, opts)?; + if let Some(cb) = opts.progress.as_deref_mut() { + cb("parts", part_index + 1, source.report.part_count); + } + } + sink.flush_after_parts()?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("mht", 0, source.report.part_count); + } + let mut mht = sink.read_master_hash(source, source.report.part_count - 1)?; + for prev_part_index in (0..source.report.part_count - 1).rev() { + if let Some(abort) = opts.should_abort + && abort() + { + return Err(FatxError::Other(format!("{cancel_ctx}: cancelled"))); + } + let mut prev_mht = sink.read_master_hash(source, prev_part_index)?; + prev_mht.add_hash(&mht.digest()); + sink.write_master_hash(source, prev_part_index, &prev_mht)?; + mht = prev_mht; + if let Some(cb) = opts.progress.as_deref_mut() { + cb( + "mht", + source.report.part_count - prev_part_index, + source.report.part_count, + ); + } + } + sink.flush_after_mht()?; + + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 0, 1); + } + let last_part_size = sink.last_part_size(source)?; + let con_header = build_con_header(source, &mht.digest(), opts.game_title, last_part_size); + sink.write_con_header(source, con_header)?; + sink.flush_after_header()?; + if let Some(cb) = opts.progress.as_deref_mut() { + cb("header", 1, 1); + } + + Ok(source.report) +} + +pub(crate) fn build_con_header( + source: &PreparedSource, + mht_digest: &[u8; 20], + game_title: Option<&str>, + last_part_size: u64, +) -> Vec { + let mut con_header = ConHeaderBuilder::new() + .with_execution_info(&source.exe_info) + .with_block_counts(source.report.block_count as u32, 0) + .with_data_parts_info( + source.report.part_count as u32, + last_part_size + (source.report.part_count - 1) * BLOCK_SIZE * 0xa290, + ) + .with_content_type(source.content_type) + .with_mht_hash(mht_digest); + if let Some(title) = game_title { + con_header = con_header.with_game_title(title); + } + con_header.finalize() +} + +pub(crate) fn part_payload_bytes(data_size: u64, part_index: u64) -> u64 { + let part_start = part_index + .saturating_mul(BLOCKS_PER_PART) + .saturating_mul(BLOCK_SIZE); + data_size + .saturating_sub(part_start) + .min(BLOCKS_PER_PART * BLOCK_SIZE) +} diff --git a/fatxlib/src/iso/god/mod.rs b/fatxlib/src/iso/god/mod.rs index ead9ffa..debc4e3 100644 --- a/fatxlib/src/iso/god/mod.rs +++ b/fatxlib/src/iso/god/mod.rs @@ -19,12 +19,13 @@ use std::io::{Read, Seek, SeekFrom, Write}; use crate::error::{FatxError, Result}; +pub const SOURCE_BUFFER_SIZE: usize = 1 << 20; + mod convert; -pub use convert::{ - ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, TrimMode, convert_iso, convert_iso_to_fatx, -}; +pub use convert::{ConvertOptions, ConvertReport, TrimMode, convert_iso, convert_iso_to_fatx}; mod con_header; +mod core; pub use con_header::*; mod file_layout; @@ -36,6 +37,10 @@ pub use gdf_sector::*; mod hash_list; pub use hash_list::*; +mod prepare; +mod sink_fatx; +mod sink_host; + /// Single hot-path SHA-1 entry point used by [`HashList`] and /// [`ConHeaderBuilder`]. With the `openssl-hash` feature (default on) /// this routes to `openssl::sha::sha1`, which uses ARMv8 SHA on Apple diff --git a/fatxlib/src/iso/god/prepare.rs b/fatxlib/src/iso/god/prepare.rs new file mode 100644 index 0000000..bb185fe --- /dev/null +++ b/fatxlib/src/iso/god/prepare.rs @@ -0,0 +1,156 @@ +use std::fs::File; +use std::io::{BufReader, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +use crate::error::{FatxError, Result}; +use crate::executable::TitleInfo; +use crate::iso::compact::build_compact_source; + +use super::{ + BLOCK_SIZE, BLOCKS_PER_PART, ContentType, ConvertOptions, ConvertReport, SOURCE_BUFFER_SIZE, + TrimMode, +}; + +pub(crate) trait ReadSeek: Read + Seek {} + +impl ReadSeek for T {} + +pub(crate) struct PreparedSource { + pub(crate) report: ConvertReport, + pub(crate) exe_info: crate::executable::TitleExecutionInfo, + pub(crate) content_type: ContentType, + reader: ReaderSource, +} + +enum ReaderSource { + Raw { + source_iso: PathBuf, + root_offset: u64, + }, + Compact { + source_iso: PathBuf, + compact: crate::iso::compact::CompactSource, + }, +} + +impl PreparedSource { + pub(crate) fn open_reader(&self) -> Result> { + self.reader.open_reader() + } +} + +impl ReaderSource { + fn open_reader(&self) -> Result> { + match self { + Self::Raw { + source_iso, + root_offset, + } => { + let mut iso = File::open(source_iso).map_err(FatxError::Io)?; + iso.seek(SeekFrom::Start(*root_offset)) + .map_err(FatxError::Io)?; + Ok(Box::new(iso)) + } + Self::Compact { + source_iso, + compact, + } => Ok(Box::new(compact.open_reader(source_iso)?)), + } + } +} + +pub(crate) fn prepare_source( + source_iso: &Path, + opts: &ConvertOptions<'_>, +) -> Result { + if matches!(opts.trim, TrimMode::Compact) { + let compact = build_compact_source(source_iso, opts.should_abort)?; + let report = build_report( + compact.exe_info().title_id, + compact.exe_info().media_id, + compact.content_type(), + compact.data_size(), + ); + return Ok(PreparedSource { + exe_info: compact.exe_info().clone(), + content_type: compact.content_type(), + report, + reader: ReaderSource::Compact { + source_iso: source_iso.to_path_buf(), + compact, + }, + }); + } + + let source_iso_file_meta = std::fs::metadata(source_iso).map_err(FatxError::Io)?; + let img = File::open(source_iso).map_err(FatxError::Io)?; + let xiso = BufReader::with_capacity(SOURCE_BUFFER_SIZE, img); + let mut xiso = xdvdfs::blockdev::OffsetWrapper::new(xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs offset detect: {e:?}")))?; + let volume = xdvdfs::read::read_volume(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs read_volume: {e:?}")))?; + let title_info = TitleInfo::from_image(&mut xiso, volume)?; + let exe_info = title_info.execution_info; + let content_type = title_info.content_type; + let root_offset = { + xiso.seek(SeekFrom::Start(0)).map_err(FatxError::Io)?; + xiso.get_mut().stream_position().map_err(FatxError::Io)? + }; + let data_size = match opts.trim { + TrimMode::PreserveLayout => volume + .root_table + .file_tree(&mut xiso) + .map_err(|e| FatxError::Other(format!("xdvdfs file_tree: {e:?}")))? + .iter() + .map(|dirent| { + if dirent.1.node.dirent.data.is_empty() { + return 0; + } + let offset = dirent + .1 + .node + .dirent + .data + .offset::(0) + .unwrap_or(0); + offset + dirent.1.node.dirent.data.size() as u64 + }) + .max() + .unwrap_or(0), + TrimMode::None => source_iso_file_meta.len() - root_offset, + TrimMode::Compact => unreachable!("compact handled before metadata pass"), + }; + let report = build_report( + exe_info.title_id, + exe_info.media_id, + content_type, + data_size, + ); + Ok(PreparedSource { + exe_info, + content_type, + report, + reader: ReaderSource::Raw { + source_iso: source_iso.to_path_buf(), + root_offset, + }, + }) +} + +fn build_report( + title_id: u32, + media_id: u32, + content_type: ContentType, + data_size: u64, +) -> ConvertReport { + let block_count = data_size.div_ceil(BLOCK_SIZE); + let part_count = block_count.div_ceil(BLOCKS_PER_PART); + ConvertReport { + title_id, + media_id, + content_type, + part_count, + block_count, + data_size, + } +} diff --git a/fatxlib/src/iso/god/sink_fatx.rs b/fatxlib/src/iso/god/sink_fatx.rs new file mode 100644 index 0000000..1314a66 --- /dev/null +++ b/fatxlib/src/iso/god/sink_fatx.rs @@ -0,0 +1,245 @@ +use std::io::{Cursor, Read, Seek, Write}; + +use crate::error::{FatxError, Result}; +use crate::volume::FatxVolume; + +use super::core::{GodSink, part_payload_bytes}; +use super::prepare::PreparedSource; +use super::{BLOCK_SIZE, ConvertOptions, HashList, SUBPART_SIZE, SUBPARTS_PER_PART}; + +pub(crate) struct FatxSink<'a, T: Read + Seek + Write> { + vol: &'a mut FatxVolume, + dest_dir: &'a str, + data_dir: Option, + con_header_path: Option, + part_buf: Vec, + master_lists: Vec, + last_part_size: u64, +} + +impl<'a, T: Read + Seek + Write> FatxSink<'a, T> { + pub(crate) fn new(vol: &'a mut FatxVolume, dest_dir: &'a str) -> Self { + Self { + vol, + dest_dir, + data_dir: None, + con_header_path: None, + part_buf: vec![0u8; MAX_PART_BYTES], + master_lists: Vec::new(), + last_part_size: 0, + } + } + + fn data_dir(&self) -> Result<&str> { + self.data_dir + .as_deref() + .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) + } + + fn con_header_path(&self) -> Result<&str> { + self.con_header_path + .as_deref() + .ok_or_else(|| FatxError::Other("fatx sink not initialized".to_string())) + } + + fn part_path(&self, part_index: u64) -> Result { + Ok(format!("{}/Data{:04}", self.data_dir()?, part_index)) + } +} + +impl GodSink for FatxSink<'_, T> { + fn begin(&mut self, source: &PreparedSource) -> Result<()> { + let title_id_str = format!("{:08X}", source.exe_info.title_id); + let content_type_str = format!("{:08X}", source.content_type as u32); + let media_id_str = match source.content_type { + super::ContentType::GamesOnDemand => format!("{:08X}", source.exe_info.media_id), + super::ContentType::XboxOriginal => format!("{:08X}", source.exe_info.title_id), + }; + let dest_root = self.dest_dir.trim_end_matches('/'); + let title_dir = format!("{}/{}", dest_root, title_id_str); + let content_dir = format!("{}/{}", title_dir, content_type_str); + let con_header_path = format!("{}/{}", content_dir, media_id_str); + let data_dir = format!("{}/{}.data", content_dir, media_id_str); + + ensure_fatx_dir(self.vol, &title_dir)?; + ensure_fatx_dir(self.vol, &content_dir)?; + ensure_fatx_dir(self.vol, &data_dir)?; + self.data_dir = Some(data_dir); + self.con_header_path = Some(con_header_path); + self.master_lists.clear(); + self.master_lists.reserve(source.report.part_count as usize); + self.last_part_size = 0; + Ok(()) + } + + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); + let mut iso = source.open_reader()?; + let (len, master) = + fill_part_buf(&mut iso, part_index, remaining_bytes, &mut self.part_buf)?; + let part_path = self.part_path(part_index)?; + let reader = Cursor::new(&self.part_buf[..len]); + + let mut outer = opts.progress.take(); + let part_idx_now = part_index; + let part_count_now = source.report.part_count; + { + let mut inner = |bytes: u64, total: u64| { + if let Some(cb) = outer.as_deref_mut() { + let stage = format!("part {}/{}", part_idx_now + 1, part_count_now); + cb(&stage, bytes, total); + } + }; + self.vol + .create_file_from_reader(&part_path, len as u64, reader, Some(&mut inner))?; + } + opts.progress = outer; + + self.master_lists.push(master); + self.last_part_size = len as u64; + Ok(()) + } + + fn read_master_hash(&mut self, _source: &PreparedSource, part_index: u64) -> Result { + self.master_lists + .get(part_index as usize) + .cloned() + .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index))) + } + + fn write_master_hash( + &mut self, + _source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()> { + let slot = self + .master_lists + .get_mut(part_index as usize) + .ok_or_else(|| FatxError::Other(format!("missing FATX part {}", part_index)))?; + *slot = mht.clone(); + let part_path = self.part_path(part_index)?; + overwrite_part_master(self.vol, &part_path, mht.bytes()) + } + + fn last_part_size(&self, _source: &PreparedSource) -> Result { + Ok(self.last_part_size) + } + + fn write_con_header(&mut self, _source: &PreparedSource, con_bytes: Vec) -> Result<()> { + let con_len = con_bytes.len() as u64; + let path = self.con_header_path()?.to_string(); + self.vol + .create_file_from_reader(&path, con_len, Cursor::new(con_bytes), None) + } + + fn flush_after_parts(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) + } + + fn flush_after_mht(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) + } + + fn flush_after_header(&mut self) -> Result<()> { + let _ = self.vol.flush(); + Ok(()) + } +} + +const MAX_PART_BYTES: usize = 4096 + (SUBPARTS_PER_PART as usize) * (4096 + SUBPART_SIZE as usize); + +fn fill_part_buf( + data_volume: &mut R, + part_index: u64, + remaining_bytes: u64, + out: &mut [u8], +) -> Result<(usize, HashList)> { + data_volume + .seek_relative((part_index * super::BLOCKS_PER_PART * BLOCK_SIZE) as i64) + .map_err(FatxError::Io)?; + + let mut master = HashList::new(); + let mut cursor = 4096usize; + let mut subpart_buf = vec![0u8; SUBPART_SIZE as usize]; + let mut bytes_left = remaining_bytes; + + for _ in 0..SUBPARTS_PER_PART { + if bytes_left == 0 { + break; + } + let want = (subpart_buf.len() as u64).min(bytes_left) as usize; + let mut got = 0usize; + while got < want { + let n = data_volume + .read(&mut subpart_buf[got..want]) + .map_err(FatxError::Io)?; + if n == 0 { + break; + } + got += n; + } + if got == 0 { + break; + } + let subpart = &subpart_buf[..got]; + let mut sub_hash = HashList::new(); + for block in subpart.chunks(BLOCK_SIZE as usize) { + sub_hash.add_block_hash(block); + } + out[cursor..cursor + 4096].copy_from_slice(sub_hash.bytes()); + cursor += 4096; + out[cursor..cursor + got].copy_from_slice(subpart); + cursor += got; + bytes_left -= got as u64; + master.add_block_hash(sub_hash.bytes()); + if got < want { + break; + } + } + + out[0..4096].copy_from_slice(master.bytes()); + Ok((cursor, master)) +} + +fn overwrite_part_master( + vol: &mut FatxVolume, + path: &str, + new_master: &[u8; 4096], +) -> Result<()> +where + T: Read + Seek + Write, +{ + let entry = vol.resolve_path(path)?; + let first_cluster = entry.first_cluster; + let cluster_size = vol.superblock.cluster_size() as usize; + let mut cluster_buf = vec![0u8; cluster_size]; + vol.read_cluster(first_cluster, &mut cluster_buf)?; + cluster_buf[..new_master.len()].copy_from_slice(new_master); + vol.write_cluster(first_cluster, &cluster_buf)?; + Ok(()) +} + +fn ensure_fatx_dir(vol: &mut FatxVolume, path: &str) -> Result<()> +where + T: Read + Seek + Write, +{ + match vol.create_directory(path) { + Ok(()) => Ok(()), + Err(FatxError::FileExists(_)) => { + let existing = vol.resolve_path(path)?; + if !existing.is_directory() { + return Err(FatxError::NotADirectory(path.to_string())); + } + Ok(()) + } + Err(e) => Err(e), + } +} diff --git a/fatxlib/src/iso/god/sink_host.rs b/fatxlib/src/iso/god/sink_host.rs new file mode 100644 index 0000000..faf0e47 --- /dev/null +++ b/fatxlib/src/iso/god/sink_host.rs @@ -0,0 +1,113 @@ +use std::fs::{self, File}; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use crate::error::{FatxError, Result}; + +use super::core::{GodSink, part_payload_bytes}; +use super::prepare::PreparedSource; +use super::{ConvertOptions, FileLayout, HashList, SOURCE_BUFFER_SIZE}; + +pub(crate) struct HostFsSink<'a> { + dest_dir: &'a Path, +} + +impl<'a> HostFsSink<'a> { + pub(crate) fn new(dest_dir: &'a Path) -> Self { + Self { dest_dir } + } + + fn data_dir_path(&self, source: &PreparedSource) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).data_dir_path() + } + + fn part_file_path(&self, source: &PreparedSource, part_index: u64) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type) + .part_file_path(part_index) + } + + fn con_header_file_path(&self, source: &PreparedSource) -> std::path::PathBuf { + FileLayout::new(self.dest_dir, &source.exe_info, source.content_type).con_header_file_path() + } +} + +impl GodSink for HostFsSink<'_> { + fn begin(&mut self, source: &PreparedSource) -> Result<()> { + ensure_empty_dir(&self.data_dir_path(source)) + } + + fn write_part<'a>( + &mut self, + source: &PreparedSource, + part_index: u64, + _opts: &mut ConvertOptions<'a>, + ) -> Result<()> { + let part_path = self.part_file_path(source, part_index); + let part_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(&part_path) + .map_err(FatxError::Io)?; + let part_file = BufWriter::with_capacity(SOURCE_BUFFER_SIZE, part_file); + let remaining_bytes = part_payload_bytes(source.report.data_size, part_index); + let iso_data_volume = source.open_reader()?; + super::write_part(iso_data_volume, part_index, remaining_bytes, part_file) + } + + fn read_master_hash(&mut self, source: &PreparedSource, part_index: u64) -> Result { + let part_path = self.part_file_path(source, part_index); + read_part_mht(&part_path) + } + + fn write_master_hash( + &mut self, + source: &PreparedSource, + part_index: u64, + mht: &HashList, + ) -> Result<()> { + let part_path = self.part_file_path(source, part_index); + write_part_mht(&part_path, mht) + } + + fn last_part_size(&self, source: &PreparedSource) -> Result { + fs::metadata(self.part_file_path(source, source.report.part_count - 1)) + .map_err(FatxError::Io) + .map(|meta| meta.len()) + } + + fn write_con_header(&mut self, source: &PreparedSource, con_bytes: Vec) -> Result<()> { + let mut con_header_file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(self.con_header_file_path(source)) + .map_err(FatxError::Io)?; + con_header_file.write_all(&con_bytes).map_err(FatxError::Io) + } +} + +fn ensure_empty_dir(path: &Path) -> Result<()> { + if fs::exists(path).map_err(FatxError::Io)? { + fs::remove_dir_all(path).map_err(FatxError::Io)?; + } + fs::create_dir_all(path).map_err(FatxError::Io)?; + Ok(()) +} + +fn read_part_mht(path: &Path) -> Result { + let mut part_file = File::options() + .read(true) + .open(path) + .map_err(FatxError::Io)?; + HashList::read(&mut part_file) +} + +fn write_part_mht(path: &Path, mht: &HashList) -> Result<()> { + let mut part_file = File::options() + .write(true) + .open(path) + .map_err(FatxError::Io)?; + mht.write(&mut part_file)?; + Ok(()) +}