# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.4.0] - 2026-05-24

### Added

- Optional `path-list` Cargo feature exposing a new `PathList` type
  plus `SqPack::with_path_list(Arc<PathList>)` (consuming) and
  `SqPack::{set,unset}_path_list` (in-place) for attaching an
  embedded path dictionary to a read pipeline, alongside
  `SqPack::lookup_path_v1(hash) -> Option<&str>` /
  `SqPack::lookup_path_v2(hash) -> Option<&str>` for reverse hash →
  path queries and `SqPack::known_paths() -> KnownPathsIter<'_>`
  for "iterate every embedded path that exists in this install"
  cross-referenced against the live `.index` / `.index2` files.
  `SqPack` only stores hashes of paths, not the original strings;
  the path list is the dictionary that lets consumers recover the
  strings. **Zero new runtime dependencies** — the lookup tables
  are built from `std::collections::HashMap` and the embedded seed
  list is a compile-time `&[&str]`. `PathList::with_seed_list()`
  preloads a tiny curated default (a few dozen well-known paths:
  `exd/root.exl` plus representative sheet headers, the built-in
  font family) sufficient to exercise the reverse-lookup contract
  on any retail install; consumers wanting full coverage feed a
  community-maintained dump (e.g. ResLogger2's CC0-licensed dump
  at https://rl2.perchbird.dev/) through
  `PathList::with_paths(impl IntoIterator<Item = impl AsRef<str>>)`
  at their own build time. The crate **never** fetches a path list
  at runtime or build time, and there is no `build.rs`. Paths are
  stored as `Arc<str>` so the insertion-order `paths` vec and the
  two reverse-lookup hash maps (v1 64-bit composite, v2 32-bit
  full-path) all share one allocation per entry — refcount on a
  1k-entry list is exactly 3 per string. First-wins dedup on
  CRC32 collisions, with a `debug!` event per collision. Default-off
  to match the crate's minimal-deps posture (the empty default
  `[dependencies]` block, `default-features = false` on `sha2`, the
  per-backend `flate2` features, and the `parallel` / `serde` /
  `mmap` / `block-cache` features that already follow this pattern).
  Public surface: `PathList` (`new` / `with_seed_list` /
  `with_paths` / `from_paths` / `extend_from_paths` / `len` /
  `is_empty` / `paths` / `arc_paths` / `lookup_v1` / `lookup_v2` /
  `contains`), `ExtendOutcome` (the `#[non_exhaustive]` struct
  returned by `extend_from_paths` carrying `added` / `total` /
  `dropped_duplicate` / `dropped_collision_v1` /
  `dropped_collision_v2`), `SEED_PATHS`, plus the four `SqPack`
  methods above and the `KnownPathsIter` iterator type yielding
  `Arc<str>` (with an upper-bounded `size_hint`). See
  `src/path_list.rs` for the design rationale, the normalisation
  rules (lowercase ASCII, `\` → `/` — same as the rest of the
  hash pipeline), and the first-wins collision contract. The
  `path_list.extend_from_paths` completion event fires at
  `debug!` (not `info!`), consistent with the other opt-in
  features' setup-time tracing posture (`block-cache`, `mmap`,
  `parallel`); the four counters are also reachable via the
  `ExtendOutcome` return value without standing up a tracing
  subscriber.

- Optional `block-cache` Cargo feature pulling in `lru` as an
  optional dependency and exposing a new `BlockCache` type that
  sits between the `.dat` reader and `flate2`, copying
  already-decompressed bytes out of a shared `Arc<Vec<u8>>` on a
  hit. Default capacity 256 entries; keyed on `(install_id,
  ArchiveTriple, dat_id, byte_offset)` — the `install_id` (an
  opaque `u64` hashed from the canonical install root) separates
  the keyspaces of distinct installs sharing one `Arc<BlockCache>`.
  Attach via `SqPack::with_block_cache(Arc<BlockCache>)` (consuming)
  or `SqPack::{set,unset,take}_block_cache` (in-place). Composes
  with `parallel` (one cache fronts every rayon worker) and `mmap`
  (orthogonal — `mmap` eliminates I/O, `block-cache` eliminates
  inflate). Bench `lru_block.rs` shows ~47x speedup on a 64-repeat
  shared-block workload and zero overhead on cache-miss-only
  workloads. Public surface: `BlockCache` (`new` / `with_capacity`
  / `clear` / `len` / `is_empty` / `capacity` / `evict_dat` /
  `stats`), `BlockKey`, `CacheStats`,
  `BLOCK_CACHE_DEFAULT_CAPACITY`, `install_id_from_path`. See
  `src/block_cache.rs` for the design rationale, the invalidation
  contract (callers must `clear()` or `evict_dat()` after any
  external `.dat` mutation), and the concurrency posture.

- Optional `mmap` Cargo feature pulling in `memmap2` as an optional
  dependency and exposing a new `MmapFs` read-only `Vfs`
  implementation as a sibling of `StdFs` / `InMemoryFs`. `MmapFs`
  caches one `Arc<Mmap>` per canonical path behind a
  `RwLock<HashMap<_, _>>` (two-level keying — raw input path and
  canonical path — so steady-state hits skip `realpath(3)`) and
  serves every `Vfs::open_read` from a fresh `Read + Seek` cursor
  over the shared mapping. Benchmarked against `StdFs` on a
  500-entry archive fixture: random-read workloads see roughly an
  11x speedup (60 ms -> 5 ms), full-archive verifier scans roughly
  2x (10 ms -> 4 ms). Composes with the `parallel` feature: an
  `MmapFs`-backed `SqPack` shares one mapping per `.dat` across
  every rayon worker (vs one `File` per worker for `StdFs`).
  Public surface: `MmapFs::new()` / `MmapFs::with_capacity(n)`
  constructors, plus `evict(path)` / `clear()` / `len()` /
  `is_empty()` for cache control. Read-only by design — `MmapFs`
  does **not** implement `VfsWrite`; mix with `StdFs` writes via
  separate handles when both are needed. One debug-level tracing
  event (`mmap.map`) fires per first-touch mapping; see the
  catalog in `src/lib.rs` for the field set. **Safety**: the
  module rustdoc on `mmap_fs` documents the aliasing contract
  (no concurrent writes to mapped files); see also the per-
  feature note in `Cargo.toml` for the `unsafe_code = "deny"`
  scope (one `#[allow]` call site, gated behind the `mmap`
  feature).

- Optional `parallel` Cargo feature pulling in `rayon` as an optional
  dependency and exposing rayon-backed parallel siblings of the four
  hottest sweep entry points: `SqPackVerifier::verify_parallel`,
  `SqPackHashVerifier::verify_parallel`,
  `SqPackHashManifestBuilder::build_parallel`, and
  `SqPack::enumerate_archives_parallel`. Each parallel method
  produces a report that is byte-for-byte identical to its
  sequential sibling for the same input — manifest entries collect
  into a `Vec<_>` (rayon's `.collect()` preserves input order) and
  fold into the report's `BTreeMap` / `Vec<Mismatch>` in the same
  order the sequential driver walks. The parallel methods
  deliberately **skip observer callbacks** to avoid forcing every
  callback through a `Mutex` or replay loop; an installed
  `CancelToken` is still honoured (it is `Arc<AtomicBool>`-backed
  and natively `Send + Sync`). Each per-entry rayon task opens its
  own `DatFile` handle — `DatFile` is `Send` but not `Sync`, so
  workers don't share them; the `Arc<dyn Vfs>` inside `SqPack` is
  freely shareable. Default-off to match the crate's minimal-deps
  posture (the empty default `[dependencies]` block,
  `default-features = false` on `sha2`, the per-backend `flate2`
  features). Enabling pulls in `rayon` only.

- Full-repack writer (`sqpack_rs::write::SqPackRepacker`). Sister of
  the append-only writer that builds a fresh `.dat0` + `.index` /
  `.index2` triple from scratch given a sequence of `(game_path,
  bytes)` inputs. The key contract is **byte-for-byte determinism**:
  `build()` on the same inputs (in any order) on the same
  `(ArchiveTriple, Platform)` produces identical output bytes across
  calls, processes, and machines — the foundation any serious mod
  tooling needs to diff repacked archives, sign archive hashes for
  distribution, or verify round-trip safety. Determinism rests on
  six pinned behaviors: stable input sort (by composite hash key),
  pinned `flate2` default compression level *and* pinned backend
  feature (different flate2 backends — `rust-backend`, `zlib-rs`,
  `zlib-ng` — produce different DEFLATE bytes; see
  `FLATE2_DETERMINISM_NOTE`), no on-disk timestamps, explicit zero
  padding on every reserved byte, recomputed SHA-1 over every data
  region, and empty synonym/empty-block/dir-index sections at
  deterministic offsets. The `SqPackRepacker` is
  separate from `SqPackAppender` because the semantics differ
  fundamentally (rebuild vs mutate); the appender preserves the
  source dat's header padding verbatim while the repacker emits
  canonical zeros, so the two writers produce different dat bytes
  for the same single input when the source has non-canonical
  padding (pinned via the `appender_repacker_one_entry_byte_divergence`
  integration test) and identical bytes when the source is the
  canonical empty stub (pinned via the sibling
  `appender_repacker_byte_identical_on_canonical_empty_source`
  test). The API uses a builder pattern with explicit opt-in for
  each index variant: `SqPackRepacker::new(triple, platform)` plus
  `with_index_v1()` and/or `with_index_v2()`, then `add_file(path,
  bytes)` per input, then either `build()` for the buffer-oriented
  exit (`RepackedArchive { dat, index, index2 }`) or `write_to`
  for the VFS-oriented exit that lands all three files via the
  existing atomic tmpfile-rename helper. Scope is deliberately
  tight: Standard file kind only (Model and Texture deferred); one
  archive per repack call; single `.dat0` (no `.dat1+` fan-out, so
  the 2 GB cap fires as `Error::DatSizeOverflow`); strict on
  structural problems — hash collisions surface as
  `Error::DuplicatePath { game_path, kind }` where `kind` is
  `"v1"` or `"v2"`; zero inputs surface as
  `Error::RepackEmptyArchive`; the 65536-file cap (`REPACK_MAX_FILES`)
  surfaces as `Error::RepackTooManyFiles`; the at-least-one-index
  requirement surfaces as `Error::RepackNoIndexSelected`. Two new
  info-level tracing spans join the `SemVer`-stable catalog:
  `sqpack.repack.build` (per-call, fields `file_count` /
  `dat_bytes` / `index_bytes` / `index2_bytes` / `elapsed_ms`) and
  `sqpack.repack.write` (per-call, fields `file_count` /
  `bytes_written` / `elapsed_ms`). Four new `Error` variants
  surface the repacker's vocabulary alongside the existing append
  family: `DuplicatePath`, `RepackEmptyArchive`,
  `RepackTooManyFiles`, `RepackNoIndexSelected`. Exposed via
  `sqpack_rs::write::{SqPackRepacker, RepackedArchive,
  REPACK_MAX_FILES, FLATE2_DETERMINISM_NOTE}` and the top-level
  re-exports. Cite: same Lumina (`NotAdam/Lumina`) reference as the
  appender for the inverse-of-read-path layout; the determinism
  contract is `sqpack-rs`-specific (Lumina is read-only).
- Append-only writer (`sqpack_rs::write`). The first mutation API in
  an otherwise read-only crate — adds a new Standard-kind file to an
  existing `.dat` and atomically updates the corresponding `.index` /
  `.index2`. `SqPackAppender::open` opens an install, `append(game_path,
  bytes)` stages one file at a time (encoding the bytes as a sequence of
  16-KiB DEFLATE blocks plus the matching `FileInfo` + `BlockInfo` table
  the reader expects, then writing the result at the routed dat's EOF),
  and `commit()` flushes every touched index in a single batched
  tmpfile-and-rename pass. The dat-side append is durable as soon as the
  bytes land at EOF; the rename is the visibility cutover — a mid-write
  crash leaves the dat with trailing dead bytes that no entry references,
  the next launcher run never sees them. Collision rejection is strict:
  a path whose hash already exists in the resolved index surfaces as
  `Error::FileAlreadyExists` without writing any dat bytes. The
  one-shot convenience helper `SqPackAppender::append_one` wraps open +
  append + commit for the single-file case. Scope is deliberately tight
  for this sub-tier: Standard file kind only (Model and Texture surface
  as `Error::UnsupportedAppendKind`); no replace semantics; no fan-out to
  `.dat1+` companion files (`Error::DatSizeOverflow` at the 2 GB cap); no
  synonym-table updates; no compaction. The two new defensive caps
  surface as public constants: `APPEND_MAX_FILE_SIZE` (1 GiB per call),
  `APPEND_BLOCK_SIZE` (16 KiB per block), and `DAT_MAX_SIZE` (2 GB per
  `.dat`). The writer brings a sibling `VfsWrite` trait alongside the
  existing read-only `Vfs`; both shipped impls (`StdFs`, `InMemoryFs`)
  implement both, so the test-isolation story (parse hand-built fixtures
  with no disk I/O) extends to write tests. Two info-level tracing spans
  join the `SemVer`-stable catalog: `sqpack.append` (per-file, fields
  `path` / `bytes_in` / `bytes_written` / `byte_offset` / `block_count` /
  `elapsed_ms`) and `sqpack.append.commit` (per-batch, fields
  `appended` / `dats_touched` / `indexes_touched` / `elapsed_ms`). The
  rebuilt index files carry recomputed SHA-1 hashes over their data
  regions (the live reader does not validate these, but the writer
  produces correct bytes for compatibility with external tooling).
  Five new `Error` variants surface the writer's vocabulary:
  `FileAlreadyExists`, `DatSizeOverflow`, `UnsupportedAppendKind`,
  `AppendFileTooLarge`, `WriteAtomicityFailure`. Exposed via
  `sqpack_rs::write::{SqPackAppender, AppendOutcome, APPEND_BLOCK_SIZE,
  APPEND_MAX_FILE_SIZE, DAT_MAX_SIZE}` and the top-level re-exports
  `sqpack_rs::{SqPackAppender, AppendOutcome, VfsWrite, VfsWriteHandle}`.
  Cite: Lumina (`NotAdam/Lumina`) for the inverse layout of the read
  path; `redstrate/Physis` for cross-validation of the on-disk
  byte-sequence shape.
- EID bind-point decoder (`sqpack_rs::eid`). Parses the FFXIV `.eid`
  bind-point table that pairs a bone name with a per-bone transform
  for every VFX / weapon attachment anchor on a given skeleton —
  the static overlay that VFX emitters and weapon meshes attach to
  during rendering. **Not** an animation lookup table: the actual
  animation index lives in the PAP info block. `Eid::parse` reads
  the 16-byte file header (`b"eid\0"` magic, `version1` / `version2`
  `i16` pair, `i32` entry count, trailing `u32` `unk1`) plus the
  per-entry table. `EidVersion` dispatches on `version1`:
  `0x3132` selects the new V1 layout (64-byte record: 32-byte name,
  `i32` id, `[f32;3]` position, `[f32;3]` rotation in radians,
  `i32` padding); anything else routes to the Old layout (44-byte
  record: `i32` id, position, rotation in degrees, 12-byte name,
  `i32` padding). The rotation triple is exposed verbatim — no
  degrees-to-radians conversion — so consumers can dispatch on
  `Eid::version` before composing the transform against a `.sklb`
  skeleton's bind-pose matrices. Strict on structural problems:
  truncated input, bad `b"eid\0"` magic, negative or
  cap-exceeding counts, and bone-name buffers that are not valid
  UTF-8 all surface as dedicated variants (`InvalidEidLength`,
  `InvalidEidMagic`, `InvalidEidEntryCount`,
  `InvalidUtf8InEidString`). `UnsupportedEidVersion` is reserved
  as a `SemVer`-stable forward-compat slot — the current parser
  matches `VFXEditor`'s binary `version1 == 0x3132` dispatch and
  never produces it. Defensive cap `EID_MAX_ENTRIES = 4096`
  matches the PAP / PHYB / TMB project precedent. One info-level
  tracing span (`eid.parse`) joins the `SemVer`-stable catalog with
  `bytes`, `version`, `header_size`, `entry_count`,
  `entries_offset`, and `elapsed_ms`. Exposed via
  `sqpack_rs::{Eid, EidVersion, EidEntry}` with size / version
  constants under the `sqpack_rs::eid` module. Cite: `VFXEditor`'s
  `VFXEditor/Formats/EidFormat/EidFile.cs` plus the per-version
  `BindPoint/EidBindPointNew.cs` / `BindPoint/EidBindPointOld.cs`
  as schemas-as-facts.
- TMB action-timeline decoder (`sqpack_rs::tmb`). Parses the
  `b"TMLB"`-prefixed action-timeline format that drives FFXIV
  combat / animation timing — when an action starts, which sound
  and VFX triggers fire on which frames, which animation plays,
  where weapons and cameras move, what cancel-windows are open.
  `Tmb::parse` reads the 12-byte TMLB container header (`magic`,
  `file_size`, `chunk_count`) off raw bytes and walks the chunk
  list into a flat `Vec<TmbChunk>` view. The five structural chunk
  tags surface as typed wrappers — `TmdhChunk` (timeline header),
  `TmppChunk` (face-library assignment), `TmalChunk` (actor list),
  `TmacChunk` (actor), `TmtrChunk` (track) — and the five most
  representative `C0xx` entry types surface as typed `TmbEntry`
  variants: `C006` (`Fly Text Settings`), `C010` (`Animation` —
  the most common per-track entry), `C014` (`Weapon Position`),
  `C063` (`Sound` — VFX/sound trigger), `C131` (`Animation
  Cancelled by Movement`). **Forward-compat by `Opaque` variant**:
  unlike `sklb` / `pap` / `phyb`, TMB ships hundreds of `C0xx`
  entry types and SE adds new ones every patch — unknown chunk
  tags surface as `TmbChunk::Opaque` and unknown `C0xx` entry
  tags surface as `TmbEntry::Opaque`, each carrying the raw
  4-byte tag and a zero-copy `&[u8]` borrow of the payload.
  Structural problems (truncated input, bad `TMLB` magic, chunk
  sizes below their own header or past EOF, chunk-count past the
  defensive 65536 cap, opaque-payload size past the defensive
  4 MiB cap, typed-entry size mismatches) all surface as dedicated
  variants (`InvalidTmbLength`, `InvalidTmbMagic`,
  `InvalidTmbChunkLength`, `InvalidTmbChunkCount`,
  `InvalidTmbEntryLength`, `InvalidTmbEntryCount`,
  `InvalidUtf8InTmbString`). Path-offset fields inside typed
  entries (`C010.path_offset`, `C063.path_offset`,
  `TmppChunk.path_offset`) are surfaced as raw `u32` values
  relative to the parent chunk's body start; consumers that want
  to resolve them decode against `Tmb::raw` using the per-chunk
  `TmbChunk::start_offset` helper. One info-level tracing span
  (`tmb.parse`) joins the SemVer-stable catalog with `bytes`,
  `file_size`, `chunk_count`, `known_chunks`, `opaque_chunks`,
  `entries`, `opaque_entries`, and `elapsed_ms`. Exposed via
  `sqpack_rs::{Tmb, TmbChunk, TmbEntry}` with the per-chunk and
  per-entry structs plus constants under the `sqpack_rs::tmb`
  module. Cite: VFXEditor's `VFXEditor/Formats/TmbFormat/*`
  (`TmbFile.cs`, `TmbItem.cs`, `Utils/TmbReader.cs`, the
  per-chunk files under `Root/`, `Actor/`, `Track/`, and the
  per-entry files under `Entries/`) as schemas-as-facts.
- PHYB physics-binding decoder (`sqpack_rs::phyb`). Parses the
  FFXIV-native simulator graph and collision primitives from `.phyb`
  files; **no embedded Havok tagfile** — the simulator is SE's own
  per-bone graph fully described inline. `Phyb::parse` reads the
  leading `u32` version word at offset `0` and dispatches: `0` →
  `PhybVersion::V0` (12-byte degenerate stub, no payload),
  `0x0100_0001` → `PhybVersion::V1` (full layout with `data_type`
  plus collision / simulator section offsets). The `data_type == 0`
  sub-case (VFXEditor-style 16-byte writer stub) is tolerated and
  surfaces as empty `CollisionData` / `SimulatorData`. The typed
  `CollisionData` exposes capsules / ellipsoids / normal planes /
  three-point planes / spheres — each bone-keyed with offset vectors
  and shape-specific scalars. The typed `SimulatorData` exposes one
  or more `Simulator`s, each carrying `SimulatorParams` (gravity,
  wind, iteration counts, `SimulatorFlags`, group id) plus per-record
  arrays for `SimChain` / `SimNode` / `SimConnector` / `SimAttractor`
  / `SimPin` / `SimSpring` / `SimPostAlignment` / `SimulatorCollisionRef`.
  Chain sub-arrays are decoded via the documented
  `simulator_offset + sub_offset + 4` resolver. The Dawntrail-era
  trailing `EPHB` FlatBuffers blob is surfaced as opaque `&[u8]` via
  `Phyb::extended_bytes` for downstream decoding — this crate does
  not parse the FlatBuffers payload. Strict on structural problems —
  truncated input, unsupported version, offsets past EOF, counts
  above the defensive 4096-record-per-section cap, V0 stub slots
  whose reserved bytes are non-zero, per-simulator sub-array
  byte-window overlaps, and string fields that are not valid UTF-8
  all surface as dedicated variants
  (`InvalidPhybLength`, `UnsupportedPhybVersion`, `InvalidPhybOffset`,
  `InvalidPhybCount`, `InvalidPhybStub`,
  `InvalidPhybSubArrayOverlap`, `InvalidUtf8InPhybString`). One info-level tracing span (`phyb.parse`)
  joins the SemVer-stable catalog with `bytes`, `version`,
  `data_type`, `collision_offset`, `simulator_offset`,
  `simulator_count`, per-shape counts, and `elapsed_ms`. Exposed via
  `sqpack_rs::{Phyb, PhybVersion, CollisionData, Simulator, SimulatorData}`
  with the per-primitive structs and constants under the
  `sqpack_rs::phyb` module. Cite: VFXEditor's
  `VFXEditor/Formats/PhybFormat/*` (`PhybFile.cs`, the per-section
  files under `Collision/` and `Simulator/`) and
  `redstrate/Physis`'s `src/phyb.rs` as schemas-as-facts.
- PAP animation-clip wrapper decoder (`sqpack_rs::pap`). Parses the
  FFXIV-specific 26-byte envelope around an embedded Havok 2014
  binary tagfile carrying an `hkaAnimationContainer`, the
  per-animation info-block array, and the trailing TMB chunk footer.
  `Pap::parse` returns a zero-copy `Pap<'a>` view that borrows from
  the input; `Pap::animations` hands back the parsed
  `PapAnimationInfo` records; `Pap::havok_bytes` and
  `Pap::tmb_footer_bytes` surface the embedded Havok bytes
  (`havok_offset..tmb_offset`) and the trailing TMB footer
  (`tmb_offset..EOF`) as opaque `&[u8]` windows for downstream
  decoding by separate `hkx-rs` and (future) `tmb-rs` crates.
  Strict on structural problems — truncated input, wrong magic,
  unknown version, offset out of bounds, offset-ordering violations,
  info-block overrun, and animation counts above the defensive
  4096-record cap surface as `InvalidPapLength`, `InvalidPapMagic`,
  `UnsupportedPapVersion`, `InvalidPapOffset`,
  `InvalidPapOffsetOrdering`, and `InvalidPapAnimationCount`. One
  info-level tracing span (`pap.parse`) joins the SemVer-stable
  catalog. Exposed via `sqpack_rs::{Pap, PapAnimationInfo}` plus
  consts under `sqpack_rs::pap`. Cite: VFXEditor's
  `VFXEditor/Formats/PapFormat/PapFile.cs` + `PapAnimation.cs` and
  `redstrate/Physis`'s `src/pap.rs` as schemas-as-facts.
- SKLB skeleton-archive wrapper decoder (`sqpack_rs::sklb`). Parses
  the FFXIV-specific envelope around an embedded Havok 2014 binary
  tagfile carrying an `hkaSkeleton`: a 4-byte `b"blks"` magic, the
  `version1` / `version2` u16 pair, and the version-specific layout
  body. V1 ("old", `version2 == 0x3131` / `0x3132`) carries
  `layer_offset` and `havok_offset` as `u16`s with a 28-byte fixed
  header; V2 ("new", `version2 == 0x3133`) carries them as `u32`s
  with a 36-byte fixed header plus an opaque `unk` slot before the
  `body_id` / `mapper_body_id*` tail. `Sklb::parse` returns a
  zero-copy `Sklb<'a>` view that borrows from the input bytes;
  `Sklb::havok_bytes()` hands back a zero-copy `&[u8]` window of the
  embedded Havok tagfile bytes (`havok_offset..=EOF`) for downstream
  decoding by a separate `hkx-rs` crate. The `SklbVersion` enum
  dispatches on the `version2` u16 alone — V2 iff
  `version2 == 0x3133`, otherwise V1 — mirroring VFXEditor's
  `SklbFile.cs` (`if( Version2 == 0x3133 ) { … SklbNewData … }`) and
  tolerating the documented `chara/human/c1801/animation/f0003/
  resident/face.pap` quirk where the envelope advertises a quirky
  `version1` half but should still parse via the V1 layout. Strict
  on structural problems — truncated input, wrong magic, and
  `havok_offset` values that lie past the end of the input slice
  surface as dedicated `Error` variants (`InvalidSklbLength`,
  `InvalidSklbMagic`, `InvalidSklbHavokOffset`). One info-level tracing span
  (`sklb.parse`) joins the SemVer-stable catalog with `bytes`,
  `version`, `havok_offset`, `havok_len`, `layer_offset`,
  `header_size`, and `elapsed_ms` completion-event fields. Exposed
  via `sqpack_rs::{Sklb, SklbVersion}` and the `sqpack_rs::sklb`
  module. Wrapper-only by design — decoding the Havok tagfile
  itself (bone tree, reference pose, `hkaSkeleton` traversal) is
  Phase 1 work that lives in a separate `hkx-rs` crate.
- SCD audio-payload decoder (`sqpack_rs::scd::decode`). Two new
  `impl Track<'_>` entry points: `Track::decode_to_pcm16` returns
  interleaved `i16` PCM at the track's `sample_rate`, and
  `Track::ogg_bytes` reconstructs the standalone `.ogg` container
  for OGG Vorbis tracks with the FFXIV XOR de-obfuscation applied.
  Vorbis PCM decoding is deliberately **not** in scope — pulling
  `lewton` (or any Vorbis backend) into this crate would roughly
  double the dep graph for a feature `gaveloc`'s launcher product
  does not need, so consumers feed `ogg_bytes` into the Vorbis
  decoder of their choice. Codec-by-codec status: `MsAdpcm` decodes
  end-to-end in pure Rust (per-channel preamble, block-aligned
  nibble walk, signed-4-bit linear-predictor step with the
  canonical Microsoft adaptation table, mono + stereo); `Empty`
  round-trips to an empty buffer; `OggVorbis` `decode_to_pcm16`
  returns `Error::OggVorbisDecodingUnsupported` with guidance
  pointing at `ogg_bytes`; `Mp3` (PS3-only), `Atrac9` (Sony
  proprietary), and `Unknown(raw)` return dedicated
  `Mp3DecodingUnsupported` / `Atrac9DecodingUnsupported` /
  `UnknownCodecDecodingUnsupported` variants. The OGG XOR
  de-obfuscation ports Lumina's `ScdFile.GetAudio` verbatim for
  both version-2 (single-byte header mask) and version-3 (stateful
  three-way XOR with the 256-byte `OggXorTable`); for `version == 2`
  the routine sniffs for `OggS` magic at offset zero and passes
  the bytes through untouched when found, because modern FFXIV
  (7.x as of 2026-05) ships clean v2 bytes regardless of the
  declared `xor_byte` and Lumina's algorithm corrupts them.
  New `Error` variants: `InvalidMsAdpcmBlock`, `InvalidOggHeader`,
  `OggBytesNotApplicable`, `Mp3DecodingUnsupported`,
  `Atrac9DecodingUnsupported`, `UnknownCodecDecodingUnsupported`,
  `OggVorbisDecodingUnsupported`. One info-level tracing span
  (`scd.decode`) joins the SemVer-stable catalog with the same
  fields-on-create / completion-event shape as `tex.decompress`
  (`format`, `bytes_in`, `samples_out`, `elapsed_ms`).
- SCD audio-container decoder (`sqpack_rs::scd`). Parses the
  Lumina-canonical FFXIV `.scd` byte layout: a 48-byte `BinaryHeader`
  envelope (`b"SEDB"` / `b"SSCF"` magic, endianness byte, declared
  file size, build timestamp), a 32-byte `ScdHeader` chunk directory
  pointing at three offset tables (`sound` / `track` / `audio`), and
  per-track 32-byte `AudioBasicDesc` records carrying an optional
  marker-chunk subregion, a codec-specific extra-data header
  (`AdpcmWaveFormat` for MS-ADPCM, `OggVorbisSeekTableHeader` for OGG
  Vorbis, `Atrac9Header` for ATRAC9, `Ps3Mp3SeekTableHeader` for the
  PS3 STBL MP3 shape), and the raw codec payload bytes themselves.
  `Scd::parse` returns a zero-copy `Scd<'a>` view that borrows from
  the input bytes; `Track::raw_bytes()` hands back a zero-copy `&[u8]`
  window of the payload (for OggVorbis tracks the window includes the
  `ogg_header_size` container-header bytes that precede the body,
  matching Lumina's `GetAudio` reconstruction so a downstream decoder
  can run XOR de-obfuscation without rebuilding the header from the
  sub-info region). The `ScdFormat` enum carries the codec
  discriminant — `Empty (-1)`, `OggVorbis (6)`, `Mp3 (7)`,
  `MsAdpcm (12)`, `Atrac9 (22)` — with `Unknown(i32)` covering every
  other wire value (including `3` and `10`, which vgmstream labels
  but Lumina does not). `ExtraData<'a>` dispatches per codec into
  `None` / `MsAdpcm` / `OggVorbis { header, seek_table }` /
  `Atrac9` / `Mp3 { header, seek_table }` / `Opaque(&[u8])`, with
  `Mp3` produced only when the sub-info starts with the `b"STBL"`
  magic (raw-u32-length MP3 surfaces as `Opaque`). Strict on
  structural problems — truncated input, wrong magic, big-endian
  files, sub-info subregions that overrun the payload, marker chunks
  with negative counts — all surface as dedicated `Error` variants
  (`InvalidScdLength`, `InvalidScdMagic`, `UnsupportedScdEndianness`,
  `InvalidScdTrack`). One info-level tracing span (`scd.parse`) with
  the same SemVer-stable contract as the existing `sqpack.*` / `tex.*`
  / `mdl.*` span family. Exposed via `sqpack_rs::scd::{Scd,
  BinaryHeader, ScdHeader, AudioBasicDesc, AudioFlags, ExtraData,
  ScdFormat, Track, AdpcmWaveFormat, OggVorbisSeekTableHeader,
  Ps3Mp3SeekTableHeader, Atrac9Header, SubInfoMarkerChunk,
  BINARY_HEADER_SIZE, SCD_HEADER_SIZE, AUDIO_BASIC_DESC_SIZE,
  SUB_INFO_MARKER_CHUNK_SIZE, ADPCM_WAVE_FORMAT_SIZE,
  OGG_VORBIS_SEEK_TABLE_HEADER_SIZE, PS3_MP3_SEEK_TABLE_HEADER_SIZE,
  ATRAC9_HEADER_SIZE, SCD_MAGIC_TYPE, SCD_MAGIC_SUBTYPE,
  ENDIAN_TYPE_LITTLE}`. Structural parser only — audio decoding
  (Vorbis XOR de-obfuscation, MS-ADPCM block decode, ATRAC9
  superframe decode) ships in a follow-up.
- MDL model decoder (`sqpack_rs::mdl`). Parses the Lumina-canonical
  FFXIV `.mdl` byte layout: a 68-byte `ModelFileHeader` prefix
  (per-LOD vertex / index buffer offsets and sizes, LOD count, flag
  bytes), the per-mesh `VertexDeclaration` table (fixed-slot 17 ×
  8 bytes terminated by a `stream = 0xFF` sentinel), the string
  table, the 56-byte `ModelHeader` (mesh / submesh / material / bone
  / shape counts plus the two `ModelFlags1` / `ModelFlags2` byte
  sets), the LOD / extra-LOD / mesh / submesh / terrain-shadow
  tables, the material / bone / attribute name-offset tables, the
  optional shape-key (`Shape` / `ShapeMesh` / `ShapeValue`) tables,
  the submesh-bone-map, and the per-LOD bounding boxes. `Mdl::parse`
  returns a zero-copy `Mdl<'a>` view that borrows from the input
  bytes; per-LOD `vertex_buffer(lod) -> &[u8]` /
  `index_buffer(lod) -> &[u8]` accessors hand back zero-copy windows
  into the same slice, and `indices(lod) -> Vec<u16>` decodes the
  little-endian u16 index buffer. String-table offsets resolve
  lazily on `material_name` / `bone_name` / `attribute_name` /
  `shape_name` calls with UTF-8 validation. Strict on structural
  problems — truncated input, missing vertex-decl sentinel,
  oversized counts, malformed `submesh_bone_map` size, vertex /
  index buffer windows past EOF, out-of-range string-table offsets
  all surface as dedicated `Error` variants (`InvalidMdlLength`,
  `InvalidMdlSection`, `MdlLodOutOfRange`, `MdlMaterialOutOfRange`,
  `MdlBoneOutOfRange`, `MdlAttributeOutOfRange`,
  `MdlShapeOutOfRange`, `InvalidMdlStringOffset`,
  `InvalidUtf8InMdlString`). One info-level tracing span
  (`mdl.parse`) with the same SemVer-stable contract as the existing
  `sqpack.*` / `tex.*` span family. Exposed via
  `sqpack_rs::{Mdl, ModelFileHeader, ModelHeader as MdlModelHeader,
  ModelFlags1, ModelFlags2, Lod, ExtraLod, ElementId, Mesh, Submesh,
  TerrainShadowMesh, TerrainShadowSubmesh, VertexDeclaration,
  VertexElement, VertexUsage, BoneTable, Shape, ShapeMesh,
  ShapeValue, BoundingBox as MdlBoundingBox, MODEL_FILE_HEADER_SIZE,
  MODEL_HEADER_SIZE, …}`. Does not decode individual vertex bytes
  (consumers dispatch on `VertexDeclaration` per their target
  rendering API), does not resolve referenced `.mtrl` / `.sklb`
  files, does not apply shape morphs — those are downstream
  concerns. Bridging `ModelPayload::bytes` (SqPack-DAT-layer section
  ordering without the 68-byte file-header prefix) to the
  Lumina-canonical input `Mdl::parse` expects is a documented
  follow-up.
- TEX texture decoder (`sqpack_rs::tex`). Parses the FFXIV `.tex`
  file format: an 80-byte header carrying `TexFlags` (texture-shape
  bits: 1D / 2D / 3D / Cube / 2D-array), `TextureFormat` (14 named
  variants — L8, A8, B4G4R4A4, B5G5R5A1, B8G8R8A8, B8G8R8X8,
  R16G16B16A16F, R32G32B32A32F, BC1–BC5, BC7 — plus `Unknown(u32)`
  for forward compatibility), width / height / depth, a 7-bit
  `mip_count` clamped to the 13-slot surface offset table, and an
  `array_size`. `Tex::parse` returns a zero-copy `Tex<'a>` view that
  borrows from the input bytes; `Tex::surface_bytes(mip, slice)`
  exposes one decompressed surface as a borrowed `&[u8]`, and
  `Tex::iter_surfaces` walks every surface in mip-major /
  slice-minor order with `(width, height, depth)` geometry. The
  helper `decompress_to_rgba8(format, width, height, src)` lifts a
  BC1 / BC2 / BC3 / BC4 / BC5 surface to row-major 8-bit RGBA; BC7
  and `Unknown` are rejected with
  `Error::FormatNotDecompressibleToRgba`. Strict on structural
  problems — truncated input, out-of-bounds mip / slice indices,
  surface windows past the file end, and arithmetic overflow on
  oversized dimensions all surface as dedicated `Error` variants
  (`InvalidTexLength`, `MipOutOfRange`, `SliceOutOfRange`,
  `TexSurfaceOutOfBounds`, `TexSurfaceOverflow`,
  `UnsupportedTextureFormat`, `FormatNotDecompressibleToRgba`).
  The BCn interpolation follows the integer floor-divide rules from
  Lumina's `ColourBlock` / `Alpha` reference. The module emits two
  info-level tracing spans (`tex.parse` and `tex.decompress`) with
  the same SemVer-stable contract as the existing `sqpack.*` span
  family. Exposed via `sqpack_rs::{Tex, TexHeader, TexFlags,
  TextureFormat, SurfaceInfo, SurfaceIter, decompress_to_rgba8,
  MAX_MIP_LEVELS, TEX_HEADER_SIZE}`.
- `ExcelList` reader for the FFXIV Excel master sheet directory
  (`exd/root.exl`). Parses the `EXLT,2` text format into a typed
  `(name, id)` directory with O(1) lookup, distinguishing
  "sheet absent" from "sheet present but unkeyed (`-1`)" via a
  `Option<Option<u32>>` return shape. Exposed as a top-level
  `sqpack_rs::ExcelList` re-export and via the convenience method
  `SqPack::excel_list`. EXH and EXD readers (per-sheet schema and
  row data) ship in follow-up releases.
- `ExcelHeader` reader for the per-sheet `exd/<SheetName>.exh`
  schema. Decodes the big-endian EXH wire format into a typed
  `(columns, pages, languages, row_size, kind)` view: column types
  (scalar / string / float / packed-bool), page table windows, and
  shipped language variants. Strict on unknown discriminants —
  unrecognised column types, language bytes, or `variant` values
  surface as dedicated `Error` variants rather than being tolerated
  silently. Exposed via `sqpack_rs::{ExcelHeader, ColumnDefinition,
  PageDefinition, ColumnDataType, Language, SheetKind}` and the
  convenience method `SqPack::excel_header`. The EXD row reader
  ships in a follow-up release.
- `ExcelData` reader for one EXD page
  (`exd/<SheetName>_<page>_<lang>.exd`). Parses the big-endian EXD
  wire format into a row table keyed by `row_id`, with O(log n)
  lookup, an iterator over all rows, and a zero-copy `Row` / `Field`
  view that decodes columns against the EXH schema on read. Handles
  both `Default` (one row per id) and `SubRows` (variable sub-row
  count per id, addressed via `ExcelData::sub_row` and
  `Row::sub_rows`) sheet layouts. `Field::String` borrows directly
  from the page bytes; numeric and packed-bool fields are decoded on
  read. Strict on structural problems — unsorted indexes, row
  offsets out of bounds, column offsets past the fixed portion,
  string offsets past the trailing area, missing NUL terminators,
  and invalid UTF-8 surface as dedicated `Error` variants. Exposed
  via `sqpack_rs::{ExcelData, Row, Field}` and the convenience
  method `SqPack::excel_data`. Sheet enumeration (a single iterator
  that walks every page transparently) ships in a follow-up release.
- Sheet handle and whole-install enumeration on top of the EXL / EXH
  / EXD primitives. `SqPack::sheet(name)` opens a single sheet
  (parses the EXH eagerly, defers `.exd` reads); `SqPack::sheets()`
  walks every sheet in `exd/root.exl` yielding one `Result<Sheet>`
  per item with per-sheet failures surfaced inline rather than
  short-circuiting the iteration; `SqPack::sheet_names()` is the
  cheap "just give me the catalog" variant that never touches an
  EXH. Each returned `Sheet` holds a borrow into the `SqPack` it was
  opened from. Exposed via `sqpack_rs::{Sheet, SheetIter, SheetNameIter}`.
- Cross-page row iteration via `Sheet::rows(language)` and direct
  row lookup via `Sheet::row(row_id, language)`. The iterator walks
  every page in declaration order, flattens sub-rows on
  `SheetKind::SubRows` sheets (one yielded `OwnedRow` per
  `(row_id, sub_id)` tuple), loads pages lazily one at a time, and
  surfaces per-page load failures as a single `Err` item before
  stopping. `Sheet::row` uses a `Sheet::page_for` page-lookup
  heuristic (linear walk over the page table, contiguous-`row_id`
  assumption) — documented in the rustdoc — then loads the candidate
  page and returns the row; on a SubRows sheet it returns the
  `sub_id == 0` representative. The `OwnedRow` shape carries its
  payload bytes by value plus an `Arc`-shared column schema so
  `Field::String` borrows still resolve after the source `ExcelData`
  is dropped. `OwnedRow::fields()` iterates every column in order.
  Exposed via `sqpack_rs::{OwnedRow, OwnedRowFields, SheetRows}`.
- Fuzz targets for the new Excel-layer parsers: `parser_exl`,
  `parser_exh`, and `parser_exd` join the existing eight targets in
  `fuzz/`. EXL and EXH targets feed arbitrary bytes directly into
  `ExcelList::parse` / `ExcelHeader::parse`; the EXD target pairs
  arbitrary input bytes with a hand-constructed minimal valid EXH
  (Default kind, one `Int32` column, single page, single language)
  so the EXD parser's row-index walk and sub-row decoding paths get
  exercised without simultaneously fuzzing EXH-parsing variability.
  Each target's invariant is "no panic for any input."
- Tracing schema additions for the sheet layer. The `Sheet::row` and
  `Sheet::sub_row` debug-level spans are now promoted from hardcoded
  literals (`"sheet.row"`, `"sheet.sub_row"`) to the namespaced
  `sqpack.sheet.row` / `sqpack.sheet.sub_row` consts in the
  crate-internal `tracing_schema` module, with matching completion
  events `sqpack.sheet.row` / `sqpack.sheet.sub_row` and the
  iterator-setup event `sqpack.sheet.rows opened` from
  `Sheet::rows`. Names are documented in the `# Tracing` span /
  event catalog in `src/lib.rs` and follow the same SemVer-tracked
  stability tier as the existing `sqpack.*` span family.

### Changed

- **Breaking:** Root re-exports trimmed from ~185 to 36 symbols.
  Wire-format constants, sub-types, and internal details move behind
  their module paths (`sqpack_rs::mdl::Mesh`, `sqpack_rs::scd::Track`,
  etc.). Core types stay at root.
- **Breaking:** `SqPackPayload::bytes()` returns `&[u8]` directly (was
  `Option<&[u8]>`).
- **Breaking:** `SqPack` constructor matrix collapsed: `open()` +
  `open_with_vfs()` + `.with_platform()` builder method replaces the
  previous 2×2 `open*` set.
- **Breaking:** `SqPackObserver` trait split into `PathVerifyObserver`
  and `HashScanObserver` (was one trait with 7 methods spanning 2
  workflows).
- **Breaking:** Path-keyed verify types renamed for symmetry with the
  hash-keyed family: `SqPackManifest` → `SqPackPathManifest`,
  `SqPackVerifier` → `SqPackPathVerifier`, `Mismatch` →
  `PathMismatch`, etc. `VerifyEvent` (single-variant enum) flattened —
  `PathVerifyStream` yields `PathMismatch` directly.
- **Breaking:** `block_cache` feature renamed to `block-cache`
  (consistency with `path-list`, `zlib-rs`).
- **Breaking:** `Track::ogg_bytes()` renamed to `to_ogg_bytes()`
  (allocates, old name was misleading).
- **Breaking:** `ExcelData::parse` takes `&[u8]` (was `Vec<u8>`)
  matching every other decoder.
- **Breaking:** `FileInfo` fields are `pub(crate)` with typed accessors
  (`header_size()`, `raw_file_size()`, `block_count()`).
  `IndexEntry.data` / `.padding` fields are `pub(crate)`.
- **Breaking:** Internal decoder sub-modules changed from `pub mod` to
  `mod` (single public path per type). `SynonymUnsupported` dead error
  variant removed.
- `SqPack::read_tex_bytes()` / `read_mdl_bytes()` convenience methods
  eliminate payload-kind dispatch ceremony.
- `Tex::decompress_surface(mip, slice)` combines `surface_bytes` +
  `decompress_to_rgba8` in one call.
- `Field` enum gains typed accessors (`as_u32()`, `as_str()`,
  `to_i64()`, etc.).
- `SqPack::appender()` creates an appender from the handle's own
  root/platform/vfs.
- `SqPack::warm_cache(paths)` pre-warms the block cache.
- `SqPackPathManifestBuilder` gains cancel/observer support (was
  asymmetric with hash builder).
- `PathMismatchKind::ReadError` gains
  `error_kind: Option<io::ErrorKind>`.
- `Display` impls added for `TextureFormat` and `ScdFormat`.
- Rewrote all rustdoc from scratch in zipatch-rs style.
- Documentation-only: clarified the `SheetIter` vs `SheetRows`
  error-recovery contract (continue-past-`Err` vs latch-`done`)
  with a side-by-side note at the `excel` module level and
  cross-links from both iterator types; reworded the
  `exd/root.exl` comma rule to make the "first comma terminates
  the name; everything after must parse as `i32`" contract
  explicit; added a "Falling back to a full sweep" recommendation
  on `Sheet::page_for` for callers whose sheets have row ids the
  contiguous-range heuristic might miss, with cross-links from
  `Sheet::row` and `Sheet::sub_row`; documented the
  EXH reserved-column-type strictness (current `0x08` / `0x0A`
  gaps) as a SemVer-major decision point if a future game patch
  starts using one of those values; and added `Send + Sync`
  thread-safety notes on `Sheet`, `SheetRows`, and `OwnedRow`
  calling out compatibility with `rayon::par_bridge` and
  cross-thread row handoff.

### Fixed

- Three fragile integration tests that failed against current game
  data: `iterates_item_sheet_via_sheet_handle` (Item sheet's first
  page now starts at row_id 0), `sub_rows_sheet_iterates_flattened`
  (original candidates were all `Default` kind — replaced with known
  SubRows sheets), `scanned_pap_files_parse_and_expose_havok_and_tmb_payloads`
  (stub PAPs with 0 animations no longer trip the Havok-magic assertion).

## [0.3.0] - 2026-05-20

### Added

- Model- (`FileKind::Model`) and Texture- (`FileKind::Texture`) kind
  decoders. `DatFile::read_file` and `DatFile::read_file_typed` now
  decode both kinds end-to-end alongside Standard files; the
  `SqPackPayload::Model` / `SqPackPayload::Texture` variants carry the
  decompressed-block byte concatenation (via `ModelPayload::bytes`
  / `TexturePayload::bytes`).
- Model parsing walks the 208-byte `ModelHeader` extended struct
  (`MODEL_HEADER_SIZE`) across its 11 declared sections (stack /
  runtime / vertex×3 / edge-geometry-vertex×3 / index×3); Texture
  parsing walks the per-mip `LodBlock` table and emits the mip-header
  preamble verbatim before each mip's decompressed blocks. Per-block
  size-table allocations are bounded above (1024-block cap for Model,
  1024-row `LodBlock` cap for Texture) and every offset arithmetic
  site uses `checked_add`, surfacing wraparounds as
  `Error::InvalidBlockLayout`.
- The `SqPackVerifier` and `SqPackHashVerifier` flows route Model and
  Texture through the typed-read path so they hash like Standard
  files; only `FileKind::Empty` and `FileKind::Unknown` still surface
  as `MismatchKind::Unverifiable`.
- SemVer-tracked tracing schema. Every span name emitted by the crate
  is now centralised in a crate-internal `tracing_schema` module
  (`pub(crate)` consts) so a rename lands in a single file. The
  `# Tracing` section in `src/lib.rs` documents the public contract:
  span catalog (info-level top-level + debug-level sub-spans),
  event catalog (one info-level completion event per top-level
  operation), and the stable field-name vocabulary. Tier-1 names are
  SemVer-tracked while the crate is `0.x` / `1.x`; `trace!` events and
  field names emitted only at `debug!` / `trace!` levels stay
  best-effort. Mirrors the
  [`zipatch-rs` contract](https://docs.rs/zipatch-rs) byte-for-byte
  where the operations overlap.
- Cancellation primitive in the new `sqpack_rs::cancel` module:
  `CancelToken` is a cloneable `Arc<AtomicBool>` wrapper installed via
  `SqPackVerifier::with_cancel_token`. Flipping the token from any
  clone aborts the next manifest-entry boundary; the eager
  `SqPackVerifier::verify` returns a partial `VerifyReport` with the
  new `cancelled: bool` field set true, and the streaming
  `SqPackVerifier::verify_streaming` stops yielding events on the next
  `next()` call (`VerifyStream::cancelled()` reports the same flag).
  `CancelToken::new()` / `cancel()` / `is_cancelled()` / `reset()`,
  `Clone + Send + Sync`. Verbatim shape lift from
  `zipatch-rs::CancelToken`; partial-report exit pattern replaces
  zipatch's `ApplyError::Cancelled` because the verifier's "collect
  every issue you can find" goal makes a hard-stop error worse than a
  graceful partial-report exit.
- Observer trait in the new `sqpack_rs::observer` module:
  `SqPackObserver` is a `Send`-only trait with four callbacks
  (`on_file_verified` / `on_mismatch` / `on_skipped` / `should_cancel`)
  all defaulted to no-op. Installed via
  `SqPackVerifier::with_observer`; the verifier polls
  `should_cancel` between manifest entries and fires the per-file
  callbacks inside the entry loop. Composes cleanly with
  `CancelToken` — both are polled at the same boundary, either can
  abort. `NoopObserver` is the public-named default (matching the
  zipatch shape) so consumers can name it in
  `Box<dyn SqPackObserver>`-typed fields. Adapted from
  `zipatch-rs::ApplyObserver`; the per-chunk single-entry-point design
  doesn't carry over because verification has three distinct outcome
  paths per entry, each useful to a launcher's UI.
- `VerifyReport::cancelled: bool` field (additive; the struct stays
  `#[non_exhaustive]`). `true` when the scan stopped early because of
  a cancellation request; `false` on natural exhaustion. Companion
  `VerifyStream::cancelled()` accessor on the streaming variant.
  Default value is `false` so existing consumers that construct
  `VerifyReport` through `Default` or the eager `verify` path see no
  behavioural change.
- `SqPackVerifier::verify` and `verify_streaming` now take
  `&mut self` (was `&self`) to route the observer's `&mut` callbacks
  through a single mutable borrow. The streaming variant additionally
  borrows the verifier for the iterator's lifetime via a new `'v`
  lifetime parameter — `VerifyStream<'v, 'a, 'm>`. SemVer-minor while
  the crate is `0.x`; consumers binding the verifier to a `let`
  must change to `let mut`. The eager `verify`'s `&VerifyReport`
  return shape is unchanged. `SqPackVerifier` is now `Send` only
  (lost `Sync`) — a consequence of holding
  `Box<dyn SqPackObserver + 'a>`, whose trait bound is `Send` only.
  The verifier is single-thread-owned during a scan; consumers
  needing cross-thread sharing should construct one per worker
  against a shared `Arc<SqPack>` rather than wrapping a single
  verifier in `Arc<RwLock<_>>`.
- `# Async usage` section in the crate-root rustdoc documenting how to
  drive the synchronous verifier from a tokio (or any async) runtime
  via `tokio::task::spawn_blocking` + `CancelToken` for cancellation +
  an mpsc-wrapped `SqPackObserver` for per-file progress. Mirrors the
  `zipatch-rs` async-bridge section.
- Persisted-hash manifest in the new `sqpack_rs::verify` module:
  `SqPackManifest` is a `BTreeMap<game_path, ManifestEntry>` carrying
  the SHA-256 + `raw_file_size` of every tracked file. The `BTreeMap`
  backing keeps `(de)serialise` order deterministic so two manifests
  built from the same install compare byte-for-byte. Designed to be
  recorded once after a clean install and re-verified on every boot
  by the launcher's repair flow.
- `SqPackManifestBuilder` hashes a set of `game_path`s against a live
  `SqPack` install and produces an `SqPackManifest`. Driven through
  `SqPack::read_file_streaming` + `read_block_into` with a reused
  scratch buffer so peak memory is bounded by one block, not the full
  uncompressed file (which can exceed 100 MiB for audio assets).
  `add(game_path)` returns `ManifestAddOutcome::Added` for hashable
  Standard / Empty files, `Skipped` for Model / Texture / Unknown
  kinds (the multi-LOD / multi-mipmap layouts aren't decoded yet),
  and `Err(Error::PathNotFound)` for paths not in the install — the
  builder is for "I'm recording the install I have", so a missing
  path is treated as a caller error rather than a normal case.
- `SqPackVerifier` walks an `SqPackManifest` against a live install
  and reports discrepancies. `verify(&manifest)` collects every
  mismatch into a `VerifyReport` (`mismatches`, `verified_count`,
  `skipped_count`, plus `by_category()` / `is_clean()` / `total()`
  helpers). Each `Mismatch` carries the `game_path`, resolved
  `Category`, and a `MismatchKind` (`HashMismatch`, `SizeMismatch`,
  `Missing`, `Unverifiable { kind }`, `ReadError { message }`).
  `ReadError` holds a formatted `String` rather than an `Error` so
  `Mismatch` stays `Clone`. Size mismatches short-circuit before
  hashing.
- `SqPackVerifier::verify_streaming(&manifest)` yields a `VerifyStream`
  iterator that emits only `VerifyEvent::Mismatch` events — verified
  entries are accumulated into `verified_count()` / `skipped_count()`
  on the stream so a launcher's progress UI can drive a "X of Y
  verified" indicator without enumerating the (overwhelmingly common)
  clean entries. The `VerifyEvent` enum is `#[non_exhaustive]` so a
  future variant (e.g. periodic progress checkpoints) can land
  SemVer-additively.
- Optional `serde` Cargo feature (default-off). Gates
  `serde::Serialize` / `serde::Deserialize` derives on
  `SqPackManifest` and `ManifestEntry`; the verifier itself works
  without the feature. Default-off so consumers that don't persist
  manifests don't pull `serde` into their dep graph.
- `sha2 = "0.10"` promoted from a dev-dep to a runtime dep — the
  verifier is a first-class feature of the crate, not an opt-in. The
  dep uses `default-features = false` so consumers that already pull
  in `sha2` with their own feature set don't get the defaults
  re-enabled.
- `Category` now derives `Ord` / `PartialOrd` / `Hash` so it can be
  used as a `BTreeMap` / `HashMap` key. Ordering is by raw wire id;
  the derive is the additive change that makes
  `VerifyReport::by_category()` returning `BTreeMap<Category, ...>`
  possible.
- Path enumeration on parsed index handles: `Index::iter()` and
  `Index2::iter()` return borrowed iterators over every entry in
  the v1 / v2 hash table, plus an `IntoIterator` impl on `&Index`
  / `&Index2` so `for entry in &idx` works without an explicit
  `.iter()` call. Order is the underlying on-disk order — stable
  per file, deterministic across calls on the same instance, and
  already-sorted-ascending-by-hash in shipped retail archives.
  The existing `entries()` slice accessor is unchanged. Designed for
  launcher integrity scans that walk every entry, resolve each
  `dat_location`, and stream the matching payload.
- Archive-level enumeration on the `SqPack` handle:
  `SqPack::enumerate_archives()` returns an `ArchiveWalker`
  iterator that sweeps the full `(category, expansion, chunk)`
  cross-product and lazily opens each `.index` / `.index2` archive
  that exists on disk, yielding `Result<EnumeratedArchive>` per
  step. Triples where both v1 and v2 are present emit `V1` then
  `V2` at the same triple; the chunk probe early-breaks per
  `(category, expansion)` pair as soon as a chunk has no archive.
  Designed for whole-install integrity scans without re-walking
  the filesystem; parse errors surface inline so a corrupt archive
  cannot disappear from the scan.
- File-type tagging via `SqPackPayload`:
  `DatFile::peek_kind` / `DatFile::peek_file_info` read only the
  per-payload `FileInfo` header without touching the body, letting
  callers branch on `FileKind` cheaply. `DatFile::read_file_typed`
  / `SqPack::read_file_typed` / `SqPack::peek_kind` add tagged-return
  variants of the existing read APIs: Standard payloads decode
  end-to-end into `SqPackPayload::Standard(Vec<u8>)`; Model and
  Texture payloads surface as `SqPackPayload::Model(ModelPayload)`
  / `SqPackPayload::Texture(TexturePayload)` carrying the parsed
  `FileInfo` (the multi-LOD / multi-mipmap block decoders are not
  yet implemented — typed decoders land in a follow-up). The
  existing `read_file` API still rejects Model / Texture with
  `Error::UnsupportedFileKind`.
- Streaming reads on `DatFile` and `SqPack`:
  `DatFile::read_file_streaming` returns a borrowing `DatFileStream`
  iterator that decodes one block per `next()`; the SqPack-level
  `SqPack::read_file_streaming` returns the self-owning
  `SqPackFileStream` (owns the opened `DatFile` internally so the
  `SqPack` handle is free during iteration). Both expose
  `kind() / raw_file_size() / block_count() / file_info()`
  accessors and a `read_block_into(&mut Vec<u8>)` buffer-reuse
  variant. `BlockChunk` pairs each decoded block's payload with
  its zero-based index in the file's block table. Both iterators
  are `ExactSizeIterator`. Only Standard-kind payloads are
  streamed; Model / Texture surface as `UnsupportedFileKind` from
  the constructor.
- Read-only filesystem abstraction in the new `sqpack_rs::vfs`
  module: the `Vfs` trait (with `open_read`, `exists`, `metadata`,
  `read_dir`), two concrete impls (`StdFs` wrapping `std::fs`
  and `InMemoryFs` storing files in an `Arc<Mutex<BTreeMap>>`),
  and the `VfsRead` / `VfsMetadata` companion types. Mirrors the
  shape of `zipatch-rs::Vfs` but trimmed to the read-only call
  sites. The `SqPack` constructor gains `open_with_vfs` and
  `open_with_vfs_and_platform`; `Index::open_in`, `Index2::open_in`,
  and `DatFile::open_in` add Vfs-aware companions to the existing
  path-only constructors. The `SqPack` handle stores an `Arc<dyn Vfs>`
  internally and plumbs it through every archive open, so a single
  configured backend drives the entire pipeline. Lets tests run
  against an `InMemoryFs` populated with hand-built `.index` /
  `.dat` fixtures without any disk I/O — see
  `tests/vfs_integration.rs`.

### Changed

- `SqPackPayload::bytes()` now returns `Some(_)` for the
  `SqPackPayload::Model` and `SqPackPayload::Texture` variants
  (previously only `SqPackPayload::Standard` returned `Some`).
  Downstream callers that branched on `bytes().is_some()` to decide
  whether a payload was "hashable" will now hash Model and Texture
  bytes through that branch — the intended new behaviour, but a
  behaviour change for any consumer that treated a `None` return as
  "skip this variant". The return type stays `Option<&[u8]>` for
  forward compatibility (see the `# Stability` note in the
  rustdoc).
- v1 synonym-table parsing + resolution. `Index` now parses the
  `.index` file's synonym region into a typed `SynonymEntry` list
  (terminated by the on-disk `conflict_index == u32::MAX` sentinel)
  alongside the main hash table, and exposes `Index::synonyms()`,
  `Index::synonyms_by_hash(hash)`, and `Index::resolve_synonym(hash, path)`
  for direct lookup. `SynonymEntry` carries the 64-bit composite hash,
  the resolved `(data_file_id, byte_offset)` packed `data` word, the
  per-record `conflict_index`, and the stored 240-byte path; accessors
  for each plus `path()` (zero-trimmed `Cow<str>`, allocates only on
  non-UTF-8 input) ship alongside the struct. `SYNONYM_ENTRY_SIZE = 256`
  and `SYNONYM_PATH_LEN = 240` are re-exported.
- v1 synonym resolution at the composition layer: `SqPack::read_file`
  (and every entry point that flows through `resolve`) now consults
  the synonym table when an `IndexEntry::is_synonym()` hit lands.
  Resolution by `(v1_key, game_path)` returns a real `DatLocation`;
  a synonym-flagged entry with no matching synonym record falls
  through to v2 / the next chunk and surfaces as `PathNotFound`
  rather than a parse failure. `Error::SynonymUnsupported` is
  retained on the enum for forward compatibility (the variant
  documentation now describes it as such) but is **not produced**
  under the current parser.
- v1 synonym fan-out in the hash-keyed walker. Both
  `SqPackHashManifestBuilder` and `SqPackHashVerifier` now resolve
  synonym-flagged main-table entries through the v1 synonym table:
  each matching `SynonymEntry` becomes its own enumerated entry under
  a new `IndexHashKey::V1Synonym { triple, hash, conflict_index }`
  variant. The synonym record's stored path is copied into
  `HashManifestEntry::path` as a free annotation — the only case
  where the hash-keyed walker populates the path field. The parent
  main-table entry is treated as a pure redirect and is never
  hashed. `HashVerifyStream` drains synonym fan-outs one record per
  step with a cancellation poll between each, so a single
  synonym-flagged parent with many records can be cancelled cleanly
  mid-fan-out. `IndexHashKey` stays `#[non_exhaustive]`; the new
  variant sorts after `V2` by enum-discriminant ordering.
- Hash-keyed manifest in `sqpack_rs::verify` alongside the existing
  path-keyed `SqPackManifest`. `SqPackHashManifest` is a
  `BTreeMap<IndexHashKey, HashManifestEntry>` keyed on the same hash
  the on-disk `.index` / `.index2` entries carry; v1 keys are the
  composite `folder_hash << 32 | file_hash`, v2 keys are the full-path
  CRC32. Both shapes embed the `ArchiveTriple` so entries from
  different categories / expansions / chunks don't collide. The
  `IndexHashKey` enum is `#[non_exhaustive]` so future index formats
  can land additively. The two manifests coexist: path-keyed for
  readable repair flows over a curated path list, hash-keyed for
  "clean install once, verify forever" baselines that surface drift
  without curation.
- `SqPackHashManifestBuilder` sweeps every archive in the install via
  `SqPack::enumerate_archives` and hashes every Standard-kind entry,
  producing a `SqPackHashManifest`. Same `with_cancel_token` /
  `with_observer` configuration shape as `SqPackVerifier`; per-entry
  skips (Model / Texture / Empty / Unknown kinds, or read failures)
  surface in the returned `HashManifestBuildReport::issues` as
  `HashManifestBuildIssue { key, kind: Unverifiable | ReadError }`
  rather than as `Mismatch` — a "build issue" is distinct from a
  "verify mismatch" by design. Cancellation takes effect at archive-
  entry boundaries; the manifest is always returned even when
  partial.
- `SqPackHashVerifier` walks an install against a `SqPackHashManifest`
  and reports discrepancies as `HashMismatch { key, kind, path }`.
  The `kind` field reuses the existing `MismatchKind` enum verbatim
  (the variants — `HashMismatch`, `SizeMismatch`, `Missing`,
  `Unverifiable`, `ReadError` — apply identically), with one
  hash-keyed-only addition surfaced on the report itself:
  `HashVerifyReport::extras` lists entries present in the live install
  that are not in the manifest, so a post-baseline game update's new
  files surface as informational rather than as repair triggers.
  `is_clean()` requires all four predicates (no mismatches, no skips,
  not cancelled, **and** no extras) — strictly stronger than the
  path-keyed `VerifyReport::is_clean`.
- `SqPackHashVerifier::verify_streaming` returns a `HashVerifyStream`
  iterator yielding `HashVerifyEvent::Mismatch(HashMismatch)` and
  `HashVerifyEvent::Extra(IndexHashKey)` events as the install walk
  progresses. Two-phase iteration: phase 1 walks every archive
  entry-by-entry; phase 2 drains manifest keys the walk never saw and
  emits `MismatchKind::Missing` for each. Clean entries do not emit;
  `verified_count` / `skipped_count` / `cancelled` accumulators expose
  work-done-so-far without re-walking. The eager
  `SqPackHashVerifier::verify` is now a thin driver over the same
  stream, so the two shapes produce byte-identical
  mismatches/extras/counts/observer-callback orderings. The
  reconciliation phase is skipped entirely on cancellation so the
  partial report reflects only work actually done. `HashVerifyEvent`
  is `#[non_exhaustive]` for SemVer-minor variant additions.
- `SqPackObserver` gains four defaulted no-op callbacks for the
  hash-keyed flows: `on_entry_hashed(&IndexHashKey, u32)`,
  `on_entry_mismatch(&HashMismatch)`,
  `on_entry_build_issue(&HashManifestBuildIssue)`, and
  `on_entry_extra(&IndexHashKey)`. The trait stays a single shared
  observer surface — existing impls compile unchanged because every
  new method has a no-op default, and a launcher can route both
  manifest types through one observer implementation. Defaults are
  no-ops; consumers override only the methods they care about.
- `ArchiveTriple` now derives `PartialOrd`, `Ord`, `Hash`. Additive
  derive change made so `IndexHashKey` (which embeds the triple) can
  derive the same traits for use as a `BTreeMap` / `HashMap` key.
- `serde` feature gates on `SqPackHashManifest`, `HashManifestEntry`,
  and `IndexHashKey` — same default-off shape as the path-keyed
  manifest's `serde` gating. Persisted hash-keyed manifests stay
  byte-for-byte deterministic across builds (same `BTreeMap` backing
  + derived `Ord` on the key).
- `SqPack::open_dat_for(triple, dat_id)` is a `pub(crate)` helper that
  opens a `DatFile` for a given `(triple, dat_id)` against the install's
  configured `Vfs` and `Platform`. Used by the hash-keyed walker to
  acquire a `DatFile` from an `IndexEntry::dat_location` without going
  through the path-routing layer. Not part of the public API.
- `examples/verify_install.rs` — docs.rs-visible example
  demonstrating the path-keyed manifest round-trip against a real
  install: build a manifest from a curated list of game paths,
  re-verify against the install (expect clean), synthesize
  corruption by tampering with one entry's expected hash, and
  re-verify to show the structured `MismatchKind::HashMismatch`
  report.
- `examples/scan_install.rs` mirrors the `verify_install` example
  for the hash-keyed flow: sweeps a real install with
  `SqPackHashManifestBuilder`, prints a category breakdown, round-
  trip verifies, tampers with one entry's hash, and re-verifies to
  show the structured mismatch report.
- New tracing schema entries: `hash_manifest.build` and
  `hash_verify.run` (info-level top-level spans) plus matching
  info-level completion events. The `hash_verify.run` completion event
  adds an `extras` counter field to the existing
  `verified` / `skipped` / `mismatches` / `cancelled` vocabulary.

### Fixed

- Texture decoder now bounds two attacker-controlled `Vec`
  allocations that the per-block 1 MiB cap did not cover. (a) The
  mip-header preamble (`LodBlock[0].compressed_offset`) is capped at
  1 MiB — the previous unconditional
  `out.resize(prev_len + compressed_offset, 0)` would have driven
  `Vec::resize` to ~4 GiB on an adversarial
  `compressed_offset = 0xFFFFFFFF` before any per-byte `read_exact`
  could fail on EOF. (b) The aggregate per-mip
  `LodBlock.block_count` sum is capped at 1024 — an adversarial u32
  `block_count = 0xFFFFFFFF` would have driven
  `vec![0u16; total_blocks]` to ~8 GiB on the same path. Both
  surface as `Error::InvalidBlockLayout`
  (`texture.preamble_size` and `texture.block_count`). Real `.tex`
  files use an ~80-byte `TexHeader` and ~900 aggregate blocks
  (cube-map ceiling); the caps leave a tight headroom while
  bounding the worst-case heap reservation.
- Two new fuzz targets — `parser_model_payload` and
  `parser_texture_payload` — exercise the Model `ModelHeader` and
  Texture `LodBlock` / mip-preamble decode paths (`read_model_blocks`
  / `read_texture_blocks`). The previous six targets all hardcoded
  `FileInfo::kind = Standard`, leaving the Tier-3 extended-header
  decoders unreachable by fuzz mutation; the new targets surfaced
  both Texture OOM paths above.
- Model decoder no longer rejects entries whose
  `header.used_number_of_blocks` exceeds the per-section `block_num`
  sum. Real retail Models routinely declare the two unequal (observed
  37 vs 6 and 65 vs 6 in `bgcommon`); Lumina sizes its size-table read
  off the section sum and never consults `used_number_of_blocks`, and
  we now mirror that. The extra u16 size-table slots are accounted for
  by `header.size` and skipped when the walk seeks to the block-data
  base. The cap on per-section sum (1024) still bounds the
  `Vec::with_capacity` for the size table.
- Multi-`.dat` archives no longer trip "invalid SqPack magic" on every
  entry routed to `.dat1+`. The patcher writes companion `.dat1`,
  `.dat2`, ... files as raw payload-only containers — they carry no
  `SqPack` header at offset 0 — but the previous `DatFile::open` /
  `DatFile::open_in` validated the header unconditionally and rejected
  every such file. `SqPack::read_file` / `read_file_typed` /
  `read_file_streaming` / `peek_kind` and the hash-keyed walker now
  route through a `dat_id`-aware open that tolerates a missing header
  on `.dat1+`. New public entry points `DatFile::open_at` /
  `DatFile::open_at_in` expose the `(path, dat_id)` shape directly;
  `DatFile::header()` is now `Option<&SqPackHeader>` (was
  `&SqPackHeader`) — `Some` for `.dat0` and any `.dat1+` that does
  carry a header, `None` for the headerless `.dat1+` case.
- `Index::read` / `Index2::read` now bound the declared `index_data_size`
  against the actual stream length before sizing the entry `Vec` and
  `HashMap`. Surfaced by the new fuzz harness: an adversarial
  `index_data_size = 0x8500_0000` previously drove `Vec::with_capacity`
  to ~2 GiB and OOM'd the parser before any per-entry read could fail
  on EOF. The structural rejection now lands as
  `Error::InvalidHeaderSize { field: "index_header.index_data_size", .. }`,
  symmetric across the v1 and v2 readers.
- `Index2::read` no longer panics when an entry has bit 0 of `data` set.
  The reserved-bit observation previously fired a `debug_assert!`, which
  `cargo-fuzz` (and every `cargo test` debug build) honoured by aborting
  the parse. The harness surfaced the panic immediately. The condition
  is now a `warn!` event so a default-level subscriber sees the
  anomaly and the parse continues — matching the module's documented
  "tolerated in release" contract on every build profile.
- `read_one_block` in `src/dat.rs` now validates the uncompressed-sentinel
  branch's `BlockHeader::block_data_size` against the on-disk footprint
  declared in `BlockInfo::compressed_size` before calling
  `Vec::resize`. Surfaced by the fuzz harness: an adversarial
  `block_data_size = 0xFFFF_FFFF` previously triggered a ~4 GiB
  allocation. The mismatch now surfaces as
  `Error::DecompressedSizeMismatch { context: "block", .. }`.
- `read_one_block` in `src/dat.rs` now bounds
  `BlockHeader::block_data_size` against a 1 MiB hard cap before the
  compressed-vs-uncompressed branch, and the compressed branch caps the
  DEFLATE decoder's *output* via `Read::take` rather than just the
  initial `Vec::with_capacity`. A follow-up fuzz run surfaced the
  parallel OOM in the compressed branch: an adversarial
  `block_data_size = 0xFFFF_FFFF` with `dat_block_type != 32000`
  previously drove `Vec::with_capacity` to ~4 GiB before the decoder
  read a single byte, and a zip-bomb DEFLATE stream could grow the
  buffer unboundedly past any initial-allocation bound. Both vectors
  now surface as `Error::DecompressedSizeMismatch { context: "block", .. }`.


## [0.2.0] - 2026-05-19

### Added

- Criterion bench suite under `benches/`. Primary bench is
  `sqpack.read_file/cold_cache` — end-to-end `SqPack::open` + `read_file`
  per iteration against a synthetic install fixture (1000-entry `.index`
  + a 4-block `~10 KB` `.dat0` Standard payload), measuring the
  realistic-first-call cost. Secondary microbenches: `hash` (CRC32 /
  `hash_index1_key` / `hash_index2` on short and long paths),
  `index.open` (`Index::open` / `Index2::open` for 1000-entry fixtures),
  and `dat.read_file` (single-block vs multi-block Standard payloads
  through the `DatFile::open` + `DatFile::read_file` pipeline; bench file
  is `dat_read_file.rs`). Fixtures are hand-built in
  `benches/common/mod.rs` to avoid promoting `#[cfg(test)]` helpers;
  `codspeed-criterion-compat = "4"` is added as a dev-dep only — no impact on the
  published crate's dep tree. Cold-cache scope: fresh in-process state
  per iter; OS page cache is allowed to be warm — measuring
  cold-disk-with-fsync is out of scope and requires root.
- Continuous benchmarking via CodSpeed
  (`.github/workflows/benchmarks.yml`). Runs the bench suite on a weekly
  cron (Sunday 12:00 UTC), on every published GitHub Release, and on
  manual `workflow_dispatch`. Uses CodSpeed's `simulation` mode
  (Valgrind-backed CPU instruction counting, deterministic ±0.001%) so
  cloud-runner wall-clock noise is not the regression signal. The job
  is informational — never gates a merge — and surfaces results via
  the CodSpeed dashboard and PR comments. No upload token is required;
  the CodSpeed GitHub App handles auth for public repos. The bench
  files use `codspeed-criterion-compat` (a drop-in replacement for
  `criterion`) so the suite still runs identically under local
  `cargo bench`.
- Path hashing module `sqpack_rs::hash`: `crc32` byte-slice primitive plus
  `hash_index1_path` / `hash_index1_key` / `hash_index2` for the v1 and v2
  key shapes. Standard reflected CRC32 polynomial (`0xEDB8_8320`, init
  `0xFFFF_FFFF`) with **no final XOR** — matching Lumina's `Crc32.Get` and
  the actual on-disk `.index` / `.index2` hash keys. Hand-rolled
  table-driven implementation; no new crate dependency. Paths are
  lowercased and `\` → `/`-normalised before hashing.
- Category and physical-path routing in `sqpack_rs::route`: `Category`
  (validated wrapper around the wire-format `u8` id with
  `try_from_name` for the full lowercase-name table) and `ArchiveTriple`
  (`(category, expansion, chunk)` with `from_game_path`, `repo_folder`,
  `file_stem`, `index_path` / `index2_path` / `dat_path`). `repo_folder`
  returns `Option<&'static str>` and yields `None` for `expansion > 15`
  rather than clamping to a wrong-but-existing folder; path-building
  methods return `Option<PathBuf>` and surface `None` for both
  `Platform::Unknown` and out-of-range expansions. Chunk is always `0`
  from `from_game_path`; the composing layer probes chunks starting at 0.
- One-call public API in `sqpack_rs::sqpack`: `SqPack::open` /
  `SqPack::open_with_platform` validate that `<install>/sqpack/` exists
  and `SqPack::read_file(game_path)` hashes, routes, looks up the entry,
  and decodes the payload end-to-end. `open_with_platform` rejects
  `Platform::Unknown` at open time with `Error::UnsupportedPlatform`.
  `read_file` probes chunks starting at 0 and stops as soon as a chunk
  has no `.index` or `.index2` on disk, with a defensive cap at chunk 7
  (`CHUNK_PROBE_CAP`); this replaces the previous hard `0..=2` loop. v0
  contract is deliberately lazy: no archive pre-scanning, no inter-call
  caching; archives are re-opened per call. Eager caching is a follow-up.
- Four new `Error` variants for the composition layer:
  `Error::UnknownCategory { name }`, `Error::PathNotFound { game_path }`,
  `Error::SynonymUnsupported { game_path }` (the v1 synonym table is
  still out of scope this iteration), and
  `Error::UnsupportedPlatform { platform }`.
- Re-exports at the crate root: `SqPack`, `ArchiveTriple`, `Category`.
- `std::fmt::Display` impl for `Platform` rendering `"Win32"`, `"PS3"`,
  `"PS4"`, or `"Unknown(N)"`. Matches `zipatch-rs::Platform`'s `Display`
  output byte-for-byte so cross-crate diagnostics line up; consumed by
  `Error::UnsupportedPlatform`'s rendered message.
- `Platform::from_raw_u16` constructor for callers that already hold the
  platform identifier as a `u16` (e.g. a hypothetical bridge from
  `zipatch-rs`'s `SqpkTargetInfo` chunk, whose `platform_id` field is
  parsed as a `u16`). The existing `Platform::from_raw(u8)` is unchanged
  and remains the canonical entry point for on-disk SqPack bytes.
- Integration test `read_file_matches_lumina_byte_for_byte` in
  `tests/sqpack_real_file.rs` cross-validates `SqPack::read_file` output
  against Lumina 7.5.0 byte-for-byte. Two reference files are pinned by
  SHA256: `exd/root.exl` (238923 bytes) and `exd/item.exh` (1258 bytes).
  Adds `sha2 = "0.10"` as a dev-dependency (test-only). Reference values
  are install-patch-tied; re-capture when the local install advances.
  Supersedes the stale 100 KiB ceiling assertion in `reads_root_exl`,
  which is dropped (the SHA256 check is strictly stronger). Closes #9.

### Changed

- **Breaking:** `Platform` variant set is now `Win32 / Ps3 / Ps4 /
  Unknown(u16)`. The previous `Ps5` and `Lys` named variants are dropped
  and their wire values (`3` and `4`) now surface as `Unknown(3)` /
  `Unknown(4)` via `Platform::from_raw`; the `Unknown` payload widens
  from `u8` to `u16`. The drop matches `zipatch-rs::Platform`'s variant
  set so the two crates stay symmetric — both PS5 and Xbox/Lys variants
  were speculative (carried over from Lumina's superset) and have not
  been observed in any shipping SqPack file. The on-disk
  `SqPackHeader::platform_id` stays a `u8`; only the in-enum payload
  width changes. SemVer: this is a pre-1.0 minor-version bump, shipped
  in the next `release/v0.x.0` PR.
- `SqPackIndexHeader::reserved` (the 656-byte structural-padding field) is
  now `pub(crate)` rather than `pub`. The field has no semantic value and
  was only retained to keep the binrw layout correct; exposing it
  contradicted the project convention that reserved fields be `pub(crate)
  + #[allow(dead_code)]` unless their parent type is `#[non_exhaustive]`
  (which `SqPackIndexHeader` is not). Other `SqPackIndexHeader` fields,
  including the four `*_hash` SHA-1 digests, remain `pub`.
- `DatLocation` moved from `sqpack_rs::index` to `sqpack_rs::header`. The
  re-export at the crate root (`sqpack_rs::DatLocation`) is unchanged, but
  consumers reaching the type via its module-qualified path
  (`sqpack_rs::index::DatLocation`) must update to `sqpack_rs::header::DatLocation`.
  The struct shape is identical; it is shared between v1 and v2 entries and
  the format layer's "no upward imports" invariant means it belongs in
  `header.rs` rather than in either `index` module.
- `flate2` is now an optional dependency selected via a backend feature.
  Three mutually-exclusive features pick the DEFLATE implementation:
  `rust-backend` (default, pure-Rust via `miniz_oxide`), `zlib-rs`
  (pure-Rust port of zlib), and `zlib-ng` (C-based, requires `cmake` + a
  C compiler at build time). A `compile_error!` in `src/lib.rs` rejects
  builds with no backend enabled. Consumers using `default-features = false`
  must now re-enable a backend explicitly.

### Fixed

- `SqPackFileType` wire values were inverted relative to actual FFXIV retail
  files: `Dat` is wire value `1` and `Index` is wire value `2` (the old
  mapping had them swapped). The `Db` variant is removed — no shipped file
  has been observed using value `2` as a database type; that value is the
  actual index discriminant. Unknown wire values (including `0`) are still
  preserved via `SqPackFileType::Unknown`.

### Added

- Initial `.index` (v1) parser: `Index::open` / `Index::read` validate the
  `SqPack` magic and file-type, parse the generic + index-specific headers,
  collect the hash table into a typed `IndexEntry` list, and expose
  constant-time lookup by 64-bit hash or by `(folder_hash, file_hash)`.
- `IndexEntry` decodes the packed `data` field into typed accessors:
  `is_synonym`, `data_file_id`, `byte_offset`, and `dat_location`. Duplicate
  hashes in the main table are rejected as corrupt (the synonym table handles
  legitimate collisions and is out of scope for this release).
- Expanded unit-test coverage: 26 new tests covering `Error::Decode` context
  strings for all three parse sites, `InvalidHeaderSize` boundary arithmetic
  (exact minimum and one-below), `index_data_size = 1` misalignment, all 8
  `data_file_id` values, `raw_data` / `entries()` / `data_file_count()` /
  `source_path()` accessors, zero-entry `is_empty`, `get_by_parts` key
  composition, synonym round-trip through a parsed `Index`, 1536-byte
  non-standard header size, and `SqPackFileType::Unknown` inner raw access.
- Integration tests in `tests/index_real_file.rs` parse up to five small
  `.index` files from a local FFXIV install (skip-on-absent via
  `SQPACK_TEST_DATA_DIR` env var or hard-coded default); validates magic,
  file type, `data_file_id ≤ 7`, `byte_offset < 4 GiB`, synonym/normal
  proportions, and absence of `DuplicateHash` errors in real data.
- `.index2` (v2) parser: `Index2::open` / `Index2::read` validate the same
  generic + index-specific headers as v1 and parse the 8-byte v2 entries
  keyed on a single 32-bit full-path CRC32. `Index2Entry` exposes
  `data_file_id` and `byte_offset` accessors and returns `DatLocation`
  directly from `dat_location()` — unlike v1, v2 has **no synonym table**
  (the full-path CRC32 leaves no path fragment to disambiguate against),
  so `is_synonym` / `Option<DatLocation>` are deliberately absent from the
  v2 API. Bit 0 of the packed `data` field is treated as reserved and
  guarded by a `debug_assert!` during parsing. Constant-time lookup by
  `u32` hash. Duplicate hashes in the main table are rejected as corrupt;
  the shared `Error::DuplicateHash` carries the v2 key widened to `u64`.
  `INDEX2_ENTRY_SIZE = 8` is re-exported alongside `INDEX_ENTRY_SIZE`.
- `IndexEntry` and `Index2Entry` are `#[non_exhaustive]` so additional
  public fields can be added in a SemVer-minor release.
- Integration tests in `tests/index2_real_file.rs` mirror the v1 suite
  against `.index2` files: open, header sanity, per-entry field
  constraints, entry counts, and absence of duplicate-hash errors. All
  `#[ignore]`'d like the v1 suite.
- `.datN` companion-file reader: `DatFile::open` validates the SqPack
  header (asserts `file_type = Dat`) and `DatFile::read_file(byte_offset)`
  extracts a single Standard-kind file payload from the offset supplied
  by `IndexEntry::byte_offset`. Reads the per-payload `FileInfo`,
  iterates the trailing block table (`BlockInfo`), and concatenates each
  block — raw DEFLATE (via the pure-Rust `flate2` backend) for
  `dat_block_type != 32000`, otherwise stored uncompressed. The final
  byte count is validated against `FileInfo::raw_file_size`.
- `FileKind` enum (`Empty`, `Standard`, `Model`, `Texture`, `Unknown`)
  decodes the `FileInfo::kind_raw` discriminant. Empty payloads return
  an empty `Vec<u8>`; Model and Texture payloads are recognised but not
  yet decoded and surface as `Error::UnsupportedFileKind { kind }` so
  the caller can route them to a future specialised reader.
- `Error::UnsupportedFileKind { kind: FileKind }` and
  `Error::BlockSizeMismatch { expected, found }` cover the new failure
  modes — unrecognised payload variants and a block whose header `size`
  or decompressed length disagrees with what the surrounding metadata
  declared.
- `flate2 = { version = "1", default-features = false, features = ["rust_backend"] }`
  added as a dependency for the pure-Rust DEFLATE backend (no system
  zlib, MSRV-clean, `forbid(unsafe_code)`-clean).
- Integration tests in `tests/dat_real_file.rs` mirror the index suites
  against `.dat` files: open the tiny 2 KiB `120000.win32.dat0`, extract
  the first Standard file from the `(000000.win32.index, .dat0)` pair,
  and sweep up to twenty entries from the same `.dat0` to prove the
  pipeline holds across real shipped files. Model/Texture payloads
  encountered during the sweep are tolerated and counted.

[Unreleased]: https://github.com/reh3502/sqpack-rs/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/reh3502/sqpack-rs/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/reh3502/sqpack-rs/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/reh3502/sqpack-rs/releases/tag/v0.2.0
