Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,39 @@

All notable changes to `xtafkit` will be documented in this file.

## [1.3.0] - 2026-05-17
## [1.3.0] - 2026-05-18

Adds read-extraction for Xbox 360 STFS container packages — Arcade (XBLA), XBLIG, Title Updates, Marketplace DLC. New library surface under `fatxlib::stfs::extract` and a new `xtafkit extract-stfs` CLI subcommand. TUI integration for STFS is intentionally deferred to a later release.
Adds read-extraction for Xbox 360 STFS container packages — Arcade (XBLA), XBLIG, Title Updates, Marketplace DLC. Covers both the CLI (`xtafkit extract-stfs`) and the TUI upload-sniff path (`u` on a local STFS file → `e(X)tract / (R)aw / Esc`). New library surface under `fatxlib::stfs::extract` with a `StfsSink` trait abstraction shared between host-filesystem and FATX-volume destinations.

### STFS extraction
### STFS extraction (CLI)
- Added `xtafkit extract-stfs <PACKAGE>` for streaming Xbox 360 STFS containers (CON / LIVE / PIRS) to a local directory. `--to <DIR>` overrides the destination; `--dry-run` lists the entries with sizes and totals; `--json` emits machine-readable output for both dry-run and post-extract modes.
- Default destination (no `--to`) is `./<DisplayName>/` taken straight from the STFS header — no catalog lookup, no `[TitleID]` suffix. The per-package `display_name` is consistently more specific than the title-id catalog mapping (notably for XBLIG, where every indie game shares the same system title-id and the catalog can only return a generic bucket name).
- Read-only / type-1 STFS only. Type-0 (read-write, used by save games / on-drive system files / CON packages) surfaces an explicit `"STFS type 0 (read-write) not supported yet"` error rather than producing wrong output.
- Block-index → byte-offset translator handles all interleaved hash levels: L0 every `0xAA` blocks, L1 every `0x70E4`, L2 every `0x4AF768`. Inline boundary tests pin offsets at `0xA9`, `0xAA`, `0x70E3`, `0x70E4` against literal hex values and assert strict monotonicity across boundaries.
- File chain follower covers both the consecutive fast path (no hash-block reads) and the fragmented case (next-block pointer threaded through the L0 hash block at offset `(N % 0xAA) * 24 + 0x15`). Walk is capped at `used_blocks` iterations to reject malformed cyclic chains, mirroring the existing FAT cycle rejection in `volume.rs`.
- Defensive parent-chain resolution in `extract_to_host`: rejects cyclic `parent_index` references and out-of-range parent pointers; refuses to overwrite existing output files.
- Defensive parent-chain resolution: rejects cyclic `parent_index` references and out-of-range parent pointers; refuses to overwrite existing output files.

### STFS extraction (TUI)
- The upload prompt (`u`) now sniffs every local file. On a CON / LIVE / PIRS package that contains a depth-0 `default.xex`, the prompt becomes `Detected STFS '<filename>' (Arcade). e(X)tract / (R)aw / Esc:`. Default action is `(X)tract`.
- `(X)tract` streams the package's inner files directly into a new subfolder under the current FATX directory, named from the STFS `display_name` (sanitised for FATX). No intermediate host-FS staging.
- Gating rule: the (X) option only appears when the package has a depth-0 `default.xex`. Title Updates (which contain `default.xexp` only) and streaming-DLC Marketplace packages (no executable) bypass the sniff and fall through to plain raw upload — they aren't useful as loose files on the drive.
- The CLI `extract-stfs` remains unrestricted; the TUI gating is a UX guard rail, not a library-level restriction.
- Mid-extraction `Esc` cancels cleanly. The library function honours an `Option<&AtomicBool>` cancel flag checked at the top of every entry iteration.

### Library API additions
- New `fatxlib::stfs` submodules: `volume_descriptor`, `block_translator`, `file_entry`, `extract`. Existing header parsing (`StfsHeader`, `parse_header`, `MIN_HEADER_BYTES`) moved into `fatxlib::stfs::header` and re-exported at the namespace root — no breaking changes for existing callers.
- `fatxlib::stfs::StfsPackage::{open, header, volume, entries, read_block_chain, read_file}` — read API for STFS packages. `read_file<W: Write>` streams through a writer; no full-file buffering even for multi-hundred-MiB packages.
- `fatxlib::stfs::extract::extract_to_host(&mut StfsPackage, &Path, Option<ProgressFn>) -> Result<ExtractReport>` — top-level walk + write, with progress callback shape matching the existing XISO extract (`(rel_path, file_size, total_bytes_so_far)`).
- `fatxlib::stfs::extract::ExtractReport` — `{ files, directories, bytes }` returned on success.
- `fatxlib::stfs::StfsPackage::{open, header, volume, entries, read_block_chain, read_file, has_default_xex}` — read API for STFS packages. `read_file<W: Write>` streams through a writer; no full-file buffering even for multi-hundred-MiB packages. `has_default_xex` is the depth-0 root-only gating check used by the TUI sniff.
- `fatxlib::stfs::extract::extract_to_host(&mut StfsPackage, &Path, Option<ProgressFn>) -> Result<ExtractReport>` — top-level walk + write to a host filesystem, with progress callback shape matching the existing XISO extract (`(rel_path, file_size, total_bytes_so_far)`).
- `fatxlib::stfs::extract::extract_to_fatx(&mut StfsPackage, &mut FatxVolume, &str, Option<ProgressFn>, Option<&AtomicBool>) -> Result<ExtractReport>` — top-level walk + write directly into a FATX volume. Used by the TUI worker; available to library consumers.
- `fatxlib::stfs::extract::ExtractReport` — `{ files, directories, bytes }` returned on success. Unified semantics: `directories` counts STFS-package directory entries only (does not count an implicitly-created destination root).

### Internal refactor
- `fatxlib/src/stfs/extract/` split from a single file into a directory module with four parts, mirroring the existing `fatxlib/src/iso/god/` pattern: `core.rs` (the `StfsSink` trait + shared `run_extract` engine), `sink_host.rs` (`HostSink` impl + `extract_to_host` wrapper), `sink_fatx.rs` (`FatxSink` impl + `extract_to_fatx` wrapper + the streaming `StfsFileReader` adapter), and `mod.rs` (`StfsPackage` + its tests). Eliminates the ~95% duplication that existed between the two `extract_to_*` functions and gives any future extract destination a single trait to implement.

### Testing / maintenance
- 27 inline synthetic tests across the four new STFS submodules: volume-descriptor parsing (type-0/1 detection, wrong-size rejection, truncation), type-1 block translator (boundary indices at every hash level plus monotonicity), file entry parsing (consecutive flag, directory flag, parent-index, non-ASCII tolerance), and end-to-end synthetic package extraction with a nested directory tree.
- v2 TUI extract-gating rule documented in the design spec: the future TUI sniff prompt will offer `(X)tract` only when the package contains a `default.xex` file (the only reliable signal that loose extraction produces something useful for alt-dashboards). Fallback: gate on `content_type == 0x000D0000`. The CLI `extract-stfs` is unrestricted.
- 32 inline synthetic tests across the STFS submodules: volume-descriptor parsing (type-0/1 detection, wrong-size rejection, truncation), type-1 block translator (boundary indices at every hash level plus monotonicity), file entry parsing (consecutive flag, directory flag, parent-index, non-ASCII tolerance), `has_default_xex` (positive, case-insensitive, subfolder-only rejection, TU `default.xexp` rejection), and end-to-end synthetic package extraction with a nested directory tree.
- New `fatxlib/tests/stfs_extract_to_fatx.rs` integration test exercises a round-trip through `extract_to_fatx` against a real FATX volume, including the cancel-flag path.
- TUI sniff helper (`stfs_extract_target`) covered by three new unit tests in `src/tui.rs`: non-STFS file rejected, STFS without `default.xex` rejected, STFS with `default.xex` returns the catalog-derived destination name.

## [1.2.1] - 2026-05-17

Expand Down
150 changes: 150 additions & 0 deletions fatxlib/src/stfs/extract/core.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! `StfsSink` trait + `run_extract` engine.

use std::io::{Read, Seek};
use std::path::{Path, PathBuf};

use crate::error::{FatxError, Result};
use crate::stfs::file_entry::StfsEntry;

use super::{ExtractReport, ProgressFn, StfsPackage};

// ── StfsSink ────────────────────────────────────────────────────────────────

/// Destination abstraction used by [`run_extract`].
///
/// Implementors handle the sink-specific work (host filesystem vs FATX volume)
/// while the engine drives the common traversal loop.
pub(crate) trait StfsSink {
/// Create a directory at `rel` relative to the destination root.
/// Treats "already exists as directory" as success.
fn ensure_dir(&mut self, rel: &Path) -> Result<()>;

/// Return an error if a file already exists at `rel`.
///
/// Default: `Ok(())` — FATX's `create_file_from_reader` enforces this
/// internally so the FATX sink can rely on the default.
fn refuse_if_file_exists(&mut self, _rel: &Path) -> Result<()> {
Ok(())
}

/// Stream `size` bytes from `reader` into the destination at `rel`.
fn write_file(&mut self, rel: &Path, size: u64, reader: &mut dyn Read) -> Result<()>;

/// Called once per entry before any work, enabling early cancellation.
///
/// Default: `Ok(())` — the host sink has no cancel signal.
fn check_cancelled(&self) -> Result<()> {
Ok(())
}
}

// ── engine ──────────────────────────────────────────────────────────────────

/// Walk every entry in `package` and stream it through `sink`.
///
/// `directories` in the returned report counts only STFS package directory
/// entries — NOT the destination root created by the sink itself.
pub(crate) fn run_extract<R, S>(
package: &mut StfsPackage<R>,
sink: &mut S,
progress: Option<ProgressFn<'_>>,
) -> Result<ExtractReport>
where
R: Read + Seek,
S: StfsSink,
{
let entries = package.entries()?;
let paths = build_relative_paths(&entries)?;

let mut files = 0usize;
let mut directories = 0usize;
let mut bytes = 0u64;

for (idx, entry) in entries.iter().enumerate() {
sink.check_cancelled()?;
let rel = &paths[idx];

if entry.is_directory {
sink.ensure_dir(rel)?;
directories += 1;
continue;
}

// Ensure the file's parent directory exists before writing.
if let Some(parent) = rel.parent()
&& !parent.as_os_str().is_empty()
{
sink.ensure_dir(parent)?;
}

sink.refuse_if_file_exists(rel)?;

if let Some(cb) = progress {
cb(&rel.to_string_lossy(), entry.size, bytes);
}

let mut reader = super::sink_fatx::StfsFileReader::new(package, entry)?;
sink.write_file(rel, entry.size, &mut reader)?;
files += 1;
bytes += entry.size;
}

Ok(ExtractReport {
files,
directories,
bytes,
})
}

// ── build_relative_paths ────────────────────────────────────────────────────

/// Build a `Vec<PathBuf>` parallel to `entries`, resolving `parent_index`
/// chains. Orphan entries (out-of-range parent) get a `<orphan-N>` prefix.
pub(crate) fn build_relative_paths(entries: &[StfsEntry]) -> Result<Vec<PathBuf>> {
let mut out: Vec<Option<PathBuf>> = vec![None; entries.len()];

fn resolve(
idx: usize,
entries: &[StfsEntry],
cache: &mut Vec<Option<PathBuf>>,
guard: &mut Vec<bool>,
) -> Result<PathBuf> {
if let Some(p) = &cache[idx] {
return Ok(p.clone());
}
if guard[idx] {
return Err(FatxError::Other(format!(
"STFS entry parent chain cycles at index {}",
idx,
)));
}
guard[idx] = true;
let entry = &entries[idx];
let path = if entry.parent_index == -1 {
PathBuf::from(&entry.name)
} else {
let parent_idx = entry.parent_index as usize;
if parent_idx >= entries.len() {
return Err(FatxError::Other(format!(
"STFS entry {} references out-of-range parent {}",
idx, entry.parent_index,
)));
}
let parent_path = resolve(parent_idx, entries, cache, guard)?;
parent_path.join(&entry.name)
};
cache[idx] = Some(path.clone());
Ok(path)
}

let mut guard = vec![false; entries.len()];
for i in 0..entries.len() {
resolve(i, entries, &mut out, &mut guard)?;
// Reset guard for the next traversal — the recursive helper marks
// visited nodes but does not reset them between top-level calls.
for slot in guard.iter_mut() {
*slot = false;
}
}
Ok(out.into_iter().map(|p| p.unwrap()).collect())
}
Loading