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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

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

## [1.3.0] - 2026-05-17

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.

### STFS extraction
- 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.

### 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.

### 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.

## [1.2.1] - 2026-05-17

Maintenance and hardening release on top of 1.2.0. No new features; all changes are bug fixes, internal refactors that reduce drift, and test coverage for areas that previously had none.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "xtafkit"
version = "1.2.1"
version = "1.3.0"
edition = "2024"
description = "Mac-native TUI workbench for Xbox 360 FATX/XTAF drives — browse, transfer, resolve titles, decode profiles"
license = "Apache-2.0"
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
[![Release](https://img.shields.io/github/v/release/rdmrocha/xtafkit?include_prereleases&sort=semver)](https://github.com/rdmrocha/xtafkit/releases)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

[![Buy me a coffee](https://img.shields.io/badge/Buy%20Me%20a%20coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](http://paypal.me/rdmrocha)

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
Expand All @@ -12,6 +14,7 @@ Mac-native TUI workbench for Xbox 360 (XTAF) and Original Xbox (FATX) drives. Pl
- **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
- **STFS extraction** — pull files out of Xbox 360 Arcade (XBLA), XBLIG, Title Update, and Marketplace DLC packages (CON / LIVE / PIRS)
- **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)
Expand Down Expand Up @@ -62,15 +65,34 @@ xtafkit scan <DEVICE> [--deep] detect FATX/XTAF partitions
xtafkit mkimage <PATH> [--size 1G] [--populate] [--format fatx|xtaf]
xtafkit resolve <DEVICE> <PATH> STFS-based title / file resolution
xtafkit extract <ISO> <DEST> [--keep-systemupdate] [--dry-run]
xtafkit extract-stfs <PACKAGE> [--to DIR] [--dry-run]
xtafkit god <ISO> <DEST> [--trim compact|preserve-layout|none] [--dry-run] [--game-title TITLE]
```

Seven subcommands total — file operations (download/upload/mkdir/rm/rename/copy/info/cleanup) live inside the TUI.
Eight 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.

## STFS extraction

`xtafkit extract-stfs <PACKAGE>` reads any Xbox 360 STFS container (CON / LIVE / PIRS) and writes its inner files to a local directory. Works on Arcade (XBLA), XBLIG, Title Updates, and Marketplace DLC.

- Default destination is `./<DisplayName>/` taken from the package's STFS header — no catalog lookup, no `[TitleID]` suffix.
- `--to <DIR>` overrides the destination explicitly.
- `--dry-run` lists every entry with its size and totals, exits without writing.
- `--json` emits machine-readable output for both `--dry-run` and post-extract modes.
- Read-only / type-1 STFS only. Type-0 packages (read-write — save games, on-drive system files) surface a clean error rather than producing wrong output.

```bash
# Inspect what's inside without writing anything
xtafkit extract-stfs ./XBLA_pkg --dry-run

# Extract to a chosen directory
xtafkit extract-stfs ./TU_patch --to ./out/title-update
```

## TUI

```bash
Expand Down Expand Up @@ -168,7 +190,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, XISO extraction, and Games-on-Demand conversion. 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, Games-on-Demand conversion, and STFS container extraction. Credit to the original author for the filesystem foundation.

## License

Expand Down
2 changes: 1 addition & 1 deletion fatxlib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fatxlib"
version = "1.2.1"
version = "1.3.0"
edition = "2024"
description = "A Rust library for reading and writing Xbox FATX file systems"
license = "Apache-2.0"
Expand Down
110 changes: 110 additions & 0 deletions fatxlib/src/stfs/block_translator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! STFS block-index → byte-offset translator (type 1, read-only format).
//!
//! Hash blocks are interleaved between data block groups:
//! - Every 0xAA (170) data blocks → one L0 hash block after the group
//! - Every 0x70E4 (28900) data blocks → one L1 hash block after the group of L0 groups
//! - Every 0x4AF768 data blocks → one L2 hash block (rarely reached)
//!
//! References:
//! - Free60: https://free60.org/System-Software/Formats/STFS/
//! - py360 STFSPackage._getRealBlockNum
//! - Velocity StfsPackage::GetRealAddress

/// First data block byte offset inside an STFS package (header is 0xB000 +
/// the first 0x1000 reserved).
pub const FIRST_DATA_BLOCK_OFFSET: u64 = 0xC000;

/// Block size in bytes.
pub const BLOCK_SIZE: u64 = 0x1000;

/// Data blocks per L0 hash group.
pub const BLOCKS_PER_L0: u32 = 0xAA;

/// Data blocks per L1 hash group (`BLOCKS_PER_L0 * BLOCKS_PER_L0`).
pub const BLOCKS_PER_L1: u32 = 0x70E4;

/// Data blocks per L2 hash group.
pub const BLOCKS_PER_L2: u32 = 0x4AF768;

/// Translate a logical block index into a byte offset.
///
/// Accounts for L0, L1, and L2 hash blocks interleaved between data block
/// groups in the type-1 (read-only / "male pack") layout.
pub fn block_to_byte_offset(block_index: u32) -> u64 {
let mut adjusted = block_index as u64;
if block_index >= BLOCKS_PER_L0 {
adjusted += (block_index / BLOCKS_PER_L0) as u64;
}
if block_index >= BLOCKS_PER_L1 {
adjusted += (block_index / BLOCKS_PER_L1) as u64;
}
if block_index >= BLOCKS_PER_L2 {
adjusted += (block_index / BLOCKS_PER_L2) as u64;
}
FIRST_DATA_BLOCK_OFFSET + adjusted * BLOCK_SIZE
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn block_zero_lands_at_0xc000() {
assert_eq!(block_to_byte_offset(0), 0xC000);
}

#[test]
fn last_block_of_first_l0_group() {
// Block 0xA9 is the 170th block — no hash blocks before it.
// Offset = 0xC000 + 0xA9 * 0x1000 = 0xB5000
assert_eq!(block_to_byte_offset(0xA9), 0xB5000);
}

#[test]
fn first_block_of_second_l0_group_skips_one_l0_hash() {
// Block 0xAA: one L0 hash block sits between it and block 0xA9.
// Offset = 0xC000 + (0xAA + 1) * 0x1000 = 0xB7000
assert_eq!(block_to_byte_offset(0xAA), 0xB7000);
}

#[test]
fn second_block_of_second_l0_group() {
// Block 0xAB: same L0 hash skipped as block 0xAA.
// Offset = 0xC000 + (0xAB + 1) * 0x1000 = 0xB8000
assert_eq!(block_to_byte_offset(0xAB), 0xB8000);
}

#[test]
fn last_block_before_first_l1_hash() {
// Block 0x70E3: 169 complete L0 groups passed (0x70E3 / 0xAA = 0xA9).
// Offset = 0xC000 + (0x70E3 + 0xA9) * 0x1000 = 0xC000 + 0x718C000 = 0x7198000
assert_eq!(block_to_byte_offset(0x70E3), 0x7198000);
}

#[test]
fn first_block_after_l1_hash_skips_l0_and_l1() {
// Block 0x70E4: one L1 hash + one L0 hash inserted since 0x70E3.
// Offset = 0xC000 + (0x70E4 + 0xAA + 1) * 0x1000
// = 0xC000 + 0x718F * 0x1000
// = 0x719B000
assert_eq!(block_to_byte_offset(0x70E4), 0x719B000);
}

#[test]
fn block_translator_is_strictly_monotonic_across_boundaries() {
// Sanity: offsets must strictly increase as block index increases.
let probes: [u32; 9] = [0, 1, 0xA9, 0xAA, 0xAB, 0x70E3, 0x70E4, 0x70E5, 100_000];
let mut last = 0u64;
for n in probes {
let off = block_to_byte_offset(n);
assert!(
off > last,
"non-monotonic at block 0x{:X}: 0x{:X} <= 0x{:X}",
n,
off,
last
);
last = off;
}
}
}
Loading