//! `.datN` companion-file reader.
//!
//! Opens a `.dat0` / `.dat1` / … file, reads the generic 1024-byte
//! [`crate::header::SqPackHeader`] at offset 0, and extracts individual file
//! payloads from byte offsets supplied by the index (typically via
//! [`crate::index::IndexEntry::byte_offset`]).
//!
//! Wire layout
//! -----------
//!
//! `.datN` files begin with the same generic [`SqPackHeader`] that opens every
//! `SqPack` file, distinguished only by the `file_type` discriminant: `1` for
//! `.datN`, `2` for `.index`/`.index2`. The header is padded out to its
//! declared `size` (always 1024 in shipping retail files); the remainder of
//! the file is a sparse collection of per-file payloads.
//!
//! Each payload starts with a [`FileInfo`] at the offset advertised by the
//! index. For a standard file the layout is:
//!
//! ```text
//!   off     size                       field
//!   0       24                         FileInfo (size, kind, raw_size, _u[2], block_count)
//!   24      8 * block_count            BlockInfo[] (offset, comp_size, uncomp_size)
//!   info.size + bi.offset              BlockHeader (16) + block payload bytes
//! ```
//!
//! [`BlockHeader::dat_block_type`] is the upstream Lumina sentinel: `32000`
//! means the block payload is stored uncompressed; any other value means raw
//! DEFLATE — no zlib wrapper, no gzip framing. The non-sentinel value is the
//! encoder's uncompressed-size hint (the retail toolchain caps a single
//! compressed payload at 16000 bytes, hence the field is sometimes documented
//! as "16000" in third-party notes) and is not authoritative for validation —
//! only "== 32000" vs "!= 32000" matters to the reader.
//!
//! Scope
//! -----
//!
//! Standard, Model, and Texture files are all extracted end-to-end.
//! Model payloads carry a 208-byte extended header (per-LOD block layout
//! across stack / runtime / vertex / edge-geometry / index sections);
//! Texture payloads carry a `LodBlock[]` mip table plus a per-block u16
//! size table and an optional mip-header preamble. The decoded byte view
//! returned for both kinds is the concatenation of the per-block
//! decompressed payloads in declared section order — sufficient for
//! content hashing but distinct from a fully reconstructed `.mdl` /
//! `.tex` file, which would require additional layout reconstruction the
//! crate does not attempt. [`DatFile::read_file`] / [`DatFile::read_file_typed`]
//! decode all three kinds; only [`FileKind::Unknown`] is rejected with
//! [`Error::UnsupportedFileKind`].
//!
//! Why no dedicated `SqPackDataHeader`
//! -----------------------------------
//!
//! Earlier drafts of this module speculated a second 1024-byte "data header"
//! sat between the generic [`SqPackHeader`] and the first payload (mirroring
//! the way `.index` files chain into an [`crate::header::SqPackIndexHeader`]). The
//! authoritative Lumina C# reader has no such struct — its
//! `Lumina.Data.Structs` namespace ships [`SqPackHeader`] and
//! [`crate::header::SqPackIndexHeader`] and nothing dat-specific — and `XIVLauncher.Core`'s
//! patcher view of `.dat` files (`SqpackDatFile.cs`) treats anything past the
//! generic header as opaque payload bytes. We follow Lumina here: the only
//! global structure in a `.dat` is the 1024-byte [`SqPackHeader`].

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

use binrw::BinRead;
use flate2::read::DeflateDecoder;
use tracing::{debug, debug_span, info, info_span, trace};

use crate::tracing_schema::span_names;

use crate::error::{Error, Result};
use crate::header::{SqPackFileType, SqPackHeader};
use crate::vfs::{StdFs, Vfs, VfsRead};

/// Fixed on-disk size of a [`BlockHeader`].
pub const BLOCK_HEADER_SIZE: u32 = 16;

/// On-disk size of a single [`BlockInfo`] entry (Standard-file block table).
///
/// Matches Lumina's `DatStdFileBlockInfos`: a `u32` offset followed by two
/// `u16` sizes — no padding.
pub const BLOCK_INFO_SIZE: u32 = 8;

/// Sentinel value carried in [`BlockHeader::dat_block_type`] when a block's
/// payload is stored uncompressed. Any other value indicates a raw-DEFLATE
/// payload; the non-sentinel value itself is the encoder's uncompressed-size
/// hint (retail caps a single compressed payload at 16000 bytes) and is not
/// used by the reader for validation — only equality vs this sentinel matters.
pub const BLOCK_TYPE_UNCOMPRESSED: u32 = 32000;

/// Wire-format payload header that opens every file stored inside a `.datN`.
///
/// The shape matches Lumina's `SqPackFileInfo`:
///
/// ```text
///   off  size  field
///   0    4     `size`           — bytes of this struct + the trailing block table
///   4    4     `type`           — `FileKind` discriminant (1=Empty, 2=Standard, 3=Model, 4=Texture)
///   8    4     `raw_file_size`  — uncompressed total size of the contained file
///   12   8     `_unknown`       — two reserved u32s; held for layout-correct parsing only
///   20   4     `number_of_blocks`
/// ```
///
/// The trailing block table (variable-length, [`BlockInfo`] entries for
/// standard files) starts immediately after this struct and runs for
/// `number_of_blocks * 8` bytes; together with `FileInfo` itself it occupies
/// the first `size` bytes of the payload region. Block payloads begin at
/// `payload_start + size`.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub struct FileInfo {
    /// Combined size of this `FileInfo` plus the trailing block table.
    pub(crate) size: u32,
    /// Raw [`FileKind`] discriminant. Decode via [`FileInfo::kind`].
    pub(crate) kind_raw: u32,
    /// Uncompressed size of the contained file in bytes.
    pub(crate) raw_file_size: u32,
    /// Two reserved `u32` fields — preserved on the wire for layout.
    #[allow(dead_code)]
    pub(crate) unknown: [u32; 2],
    /// Number of [`BlockInfo`] entries in the trailing block table.
    pub(crate) number_of_blocks: u32,
}

impl FileInfo {
    /// Decode the raw `kind_raw` discriminant into a typed [`FileKind`].
    #[must_use]
    pub fn kind(&self) -> FileKind {
        FileKind::from_raw(self.kind_raw)
    }

    /// Combined byte size of this `FileInfo` header plus the trailing
    /// block table. Block payloads begin at `payload_start + header_size()`.
    #[must_use]
    pub fn header_size(&self) -> u32 {
        self.size
    }

    /// Uncompressed size of the contained file in bytes.
    #[must_use]
    pub fn raw_file_size(&self) -> u32 {
        self.raw_file_size
    }

    /// Number of [`BlockInfo`] entries in the trailing block table.
    #[must_use]
    pub fn block_count(&self) -> u32 {
        self.number_of_blocks
    }
}

/// One row of a Standard file's block table.
///
/// Each row tells the reader where a single compressed (or sentinel-flagged
/// uncompressed) block lives, relative to the start of the block-data region
/// (`payload_start + FileInfo::size`).
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub struct BlockInfo {
    /// Byte offset of the block, measured from `payload_start + FileInfo::size`.
    pub offset: u32,
    /// Compressed byte length of the block on disk (excludes the 16-byte
    /// [`BlockHeader`] that prefixes the block payload).
    pub compressed_size: u16,
    /// Uncompressed byte length of the block's payload — what `read_file`
    /// must reconstruct for this row.
    pub uncompressed_size: u16,
}

/// 16-byte header that prefixes every block payload on disk.
///
/// Layout matches Lumina's `DatBlockHeader`:
///
/// ```text
///   off  size  field
///   0    4     size            — header size, always 16
///   4    4     _unknown
///   8    4     dat_block_type  — 32000 = uncompressed, otherwise raw DEFLATE
///   12   4     block_data_size — uncompressed payload length
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub struct BlockHeader {
    /// Size of this header in bytes. Always `16` in shipping retail files —
    /// any other value is rejected as [`Error::BlockHeaderSizeMismatch`].
    pub size: u32,
    /// Reserved `u32` — preserved on the wire for layout.
    #[allow(dead_code)]
    pub unknown1: u32,
    /// Compression discriminant. `32000` ([`BLOCK_TYPE_UNCOMPRESSED`]) means
    /// the block payload is stored uncompressed. Any other value indicates raw
    /// DEFLATE — no zlib or gzip framing. The non-sentinel value is the
    /// encoder's uncompressed-size hint (the retail toolchain caps a single
    /// compressed payload at 16000 bytes) and is not authoritative for the
    /// reader.
    pub dat_block_type: u32,
    /// Uncompressed payload size in bytes — the value `read_file` validates
    /// the decoded output against.
    pub block_data_size: u32,
}

/// Discriminant from [`FileInfo::kind_raw`] identifying a `.dat` payload's
/// internal layout.
///
/// `#[non_exhaustive]`: surprise wire values are preserved via
/// [`FileKind::Unknown`] rather than rejected at parse time so a forward-rolled
/// game version doesn't bring `DatFile::open` down.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileKind {
    /// Wire value `1`. An empty/placeholder file — `raw_file_size = 0`,
    /// `number_of_blocks = 0`. Returned as an empty [`Vec<u8>`] by
    /// [`DatFile::read_file`].
    Empty,
    /// Wire value `2`. A normal opaque file split into one or more DEFLATE
    /// (or uncompressed-sentinel) blocks. Decoded end-to-end.
    Standard,
    /// Wire value `3`. Multi-LOD model file. Decoded via the
    /// `ModelHeader`-driven walker into a concatenation of per-block
    /// decompressed bytes across the 11 declared sections.
    Model,
    /// Wire value `4`. Multi-mipmap texture file. Decoded via the
    /// `LodBlock` table into the mip-header preamble followed by the
    /// concatenated per-mip decompressed block bytes.
    Texture,
    /// Any other discriminant, preserved verbatim so the caller can inspect
    /// the raw value via [`FileKind::as_raw`].
    Unknown(u32),
}

impl FileKind {
    /// Decode a raw wire discriminant into a typed variant.
    #[must_use]
    pub fn from_raw(raw: u32) -> Self {
        match raw {
            1 => FileKind::Empty,
            2 => FileKind::Standard,
            3 => FileKind::Model,
            4 => FileKind::Texture,
            other => FileKind::Unknown(other),
        }
    }

    /// Raw `u32` value as it appears on the wire.
    #[must_use]
    pub fn as_raw(self) -> u32 {
        match self {
            FileKind::Empty => 1,
            FileKind::Standard => 2,
            FileKind::Model => 3,
            FileKind::Texture => 4,
            FileKind::Unknown(v) => v,
        }
    }
}

/// Metadata captured from the on-disk Model extended header (`ModelBlock`
/// in Lumina) that's needed to synthesise the 68-byte
/// [`crate::mdl::ModelFileHeader`] prefix on top of [`ModelPayload::bytes`].
///
/// Every field maps 1:1 to a slot in [`crate::mdl::ModelFileHeader`].
/// Three fields ([`stack_size`](Self::stack_size),
/// [`runtime_size`](Self::runtime_size), and the per-LOD offset / size
/// arrays) are computed during decoding rather than read directly off
/// the wire — Lumina's `SqPackStream.ReadModelFile` synthesises them
/// the same way (it tracks `ms.Position` between sections), because the
/// dat-layer header's `*_size` fields are advisory and may not match
/// the true decompressed extent of each section.
///
/// Constructed inside [`DatFile::read_file_typed`]; surfaced as the
/// `metadata` field on [`ModelPayload`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelMetadata {
    /// MDL format version (opaque pass-through; written verbatim into
    /// [`crate::mdl::ModelFileHeader::version`]).
    pub version: u32,
    /// Decompressed byte size of the stack section
    /// ([`crate::mdl::ModelFileHeader::stack_size`]).
    pub stack_size: u32,
    /// Decompressed byte size of the runtime section
    /// ([`crate::mdl::ModelFileHeader::runtime_size`]).
    pub runtime_size: u32,
    /// Per-mesh vertex-declaration count
    /// ([`crate::mdl::ModelFileHeader::vertex_declaration_count`]).
    pub vertex_declaration_count: u16,
    /// Material name-offset count
    /// ([`crate::mdl::ModelFileHeader::material_count`]).
    pub material_count: u16,
    /// Per-LOD byte offset (from the start of the synthesised `.mdl`
    /// file) of the LOD's vertex buffer. Lumina dedupes: LOD `i >= 1`
    /// is set to `0` when its computed offset equals LOD `i - 1`'s
    /// offset (i.e. the LOD shares the previous LOD's vertex buffer).
    pub vertex_offset: [u32; 3],
    /// Per-LOD byte offset of the LOD's index buffer; same dedupe rule
    /// as [`vertex_offset`](Self::vertex_offset).
    pub index_offset: [u32; 3],
    /// Per-LOD decompressed byte size of the LOD's vertex buffer.
    /// Zero for unpopulated LODs.
    pub vertex_buffer_size: [u32; 3],
    /// Per-LOD decompressed byte size of the LOD's index buffer.
    pub index_buffer_size: [u32; 3],
    /// Populated LOD count (1..=3) — opaque pass-through from
    /// `ModelBlock.NumLods`.
    pub lod_count: u8,
    /// Streaming-index-buffer flag (opaque pass-through from
    /// `ModelBlock.IndexBufferStreamingEnabled`).
    ///
    /// Treat as `!= 0`-check; Lumina stores as `bool` over a single
    /// byte but the wire format does not guarantee `0` or `1`.
    pub enable_index_buffer_streaming: u8,
    /// Edge-geometry flag (opaque pass-through from
    /// `ModelBlock.EdgeGeometryEnabled`).
    ///
    /// Treat as `!= 0`-check; Lumina stores as `bool` over a single
    /// byte but the wire format does not guarantee `0` or `1`.
    pub enable_edge_geometry: u8,
}

/// Decoded Model-kind payload.
///
/// Carries the parsed [`FileInfo`] header (for `raw_file_size`,
/// `number_of_blocks`, and the original wire discriminant), the
/// [`ModelMetadata`] captured from the dat-layer extended header, and
/// the concatenated decompressed bytes of every block listed across the
/// model file's section table (stack, runtime, three vertex-buffer LODs,
/// three edge-geometry-vertex-buffer LODs, three index-buffer LODs).
/// Section order on disk is preserved in `bytes`.
///
/// The byte view exposed by [`bytes`](Self::bytes) is **not** a fully
/// reconstructed `.mdl` file — it lacks the 68-byte
/// [`crate::mdl::ModelFileHeader`] prefix the high-level
/// [`crate::mdl::Mdl::parse`] expects at offset 0. The concatenation is,
/// however, deterministic for a given on-disk payload and therefore
/// suitable for content hashing in the verifier — that is the role
/// [`bytes`](Self::bytes) plays today, and its byte layout is the
/// canonical hash input. For a Lumina-canonical `.mdl` byte view,
/// callers chain through [`lumina_bytes`](Self::lumina_bytes), which
/// synthesises the prefix from [`metadata`](Self::metadata) and prepends
/// it to [`bytes`](Self::bytes).
///
/// Constructed by [`DatFile::read_file_typed`] and surfaced via
/// [`SqPackPayload::Model`].
///
/// `#[non_exhaustive]`: future additions land as additive fields without
/// a major version bump.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelPayload {
    /// Parsed [`FileInfo`] at the model file's byte offset. The
    /// `kind_raw == 3` invariant is enforced by [`DatFile::read_file_typed`];
    /// callers can rely on [`FileKind::Model`] here.
    pub file_info: FileInfo,
    /// Captured metadata sufficient to synthesise the 68-byte
    /// [`crate::mdl::ModelFileHeader`] prefix on top of [`bytes`](Self::bytes).
    pub metadata: ModelMetadata,
    /// Concatenation of every block's decompressed payload in declared
    /// section order. Length equals the sum of per-block
    /// [`BlockHeader::block_data_size`] values across the section table.
    pub bytes: Vec<u8>,
}

impl ModelPayload {
    /// Borrowed view over [`ModelPayload::bytes`].
    ///
    /// Identical to reading the `bytes` field directly; provided as a
    /// method so call sites read uniformly with
    /// [`SqPackPayload::bytes`]. The contents are the dat-layer section
    /// concatenation (stack, runtime, then per-LOD vertex / edge /
    /// index) — *not* a Lumina-canonical `.mdl` file. The verifier
    /// hashes this byte view, so its layout is part of the persisted
    /// manifest contract: do not bridge to Lumina-canonical here. Use
    /// [`lumina_bytes`](Self::lumina_bytes) for that.
    #[must_use]
    pub fn bytes(&self) -> &[u8] {
        &self.bytes
    }

    /// Synthesise a Lumina-canonical `.mdl` byte view: a freshly
    /// allocated `Vec<u8>` with the 68-byte
    /// [`crate::mdl::ModelFileHeader`] prefix in front of
    /// [`bytes`](Self::bytes).
    ///
    /// The prefix is built from [`metadata`](Self::metadata) following
    /// the same field order Lumina's `SqPackStream.ReadModelFile`
    /// writes. The resulting buffer is the canonical input to
    /// [`crate::mdl::Mdl::parse`]:
    ///
    /// ```text
    ///   let payload = sq.read_file_typed("path/to/model.mdl")?;
    ///   let SqPackPayload::Model(model) = payload else { unreachable!() };
    ///   let bytes = model.lumina_bytes();
    ///   let mdl = sqpack_rs::mdl::Mdl::parse(&bytes)?;
    /// ```
    ///
    /// Allocates `68 + self.bytes.len()` bytes; the returned `Vec`
    /// owns its storage. Cheap in absolute terms (one `Vec` allocation
    /// plus a single `extend_from_slice` over the body) but not a
    /// zero-cost projection — call once per parse rather than per
    /// accessor.
    #[must_use]
    pub fn lumina_bytes(&self) -> Vec<u8> {
        let mut out = Vec::with_capacity(crate::mdl::MODEL_FILE_HEADER_SIZE + self.bytes.len());
        out.extend_from_slice(&self.metadata.version.to_le_bytes());
        out.extend_from_slice(&self.metadata.stack_size.to_le_bytes());
        out.extend_from_slice(&self.metadata.runtime_size.to_le_bytes());
        out.extend_from_slice(&self.metadata.vertex_declaration_count.to_le_bytes());
        out.extend_from_slice(&self.metadata.material_count.to_le_bytes());
        for v in &self.metadata.vertex_offset {
            out.extend_from_slice(&v.to_le_bytes());
        }
        for v in &self.metadata.index_offset {
            out.extend_from_slice(&v.to_le_bytes());
        }
        for v in &self.metadata.vertex_buffer_size {
            out.extend_from_slice(&v.to_le_bytes());
        }
        for v in &self.metadata.index_buffer_size {
            out.extend_from_slice(&v.to_le_bytes());
        }
        out.push(self.metadata.lod_count);
        out.push(self.metadata.enable_index_buffer_streaming);
        out.push(self.metadata.enable_edge_geometry);
        out.push(0); // padding
        debug_assert_eq!(out.len(), crate::mdl::MODEL_FILE_HEADER_SIZE);
        out.extend_from_slice(&self.bytes);
        out
    }
}

/// Decoded Texture-kind payload.
///
/// Carries the parsed [`FileInfo`] plus the texture file's decoded
/// bytes: the optional mip-header preamble (verbatim, the bytes Lumina
/// emits from the start of the block-data region up to
/// `LodBlock::compressed_offset` of the first mip) followed by the
/// concatenated decompressed payloads of every block across every
/// mip-level's `LodBlock` entry.
///
/// As with [`ModelPayload`], the byte view is faithful to the on-disk
/// content but is not a fully reconstructed `.tex` file. It is
/// deterministic per on-disk payload, suitable for content hashing.
///
/// Constructed by [`DatFile::read_file_typed`] and surfaced via
/// [`SqPackPayload::Texture`].
///
/// `#[non_exhaustive]`: future additions land additively.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TexturePayload {
    /// Parsed [`FileInfo`] at the texture file's byte offset. The
    /// `kind_raw == 4` invariant is enforced by [`DatFile::read_file_typed`];
    /// callers can rely on [`FileKind::Texture`] here.
    pub file_info: FileInfo,
    /// Mip-header preamble bytes followed by the concatenation of every
    /// block's decompressed payload across every mip.
    pub bytes: Vec<u8>,
}

impl TexturePayload {
    /// Borrowed view over [`TexturePayload::bytes`].
    #[must_use]
    pub fn bytes(&self) -> &[u8] {
        &self.bytes
    }
}

/// On-disk extended header for a Model-kind payload (`FileKind::Model`).
///
/// Lumina calls this struct `ModelBlock`. It overlays the standard
/// [`FileInfo`] for its first 24 bytes (the `size`, `kind_raw`,
/// `raw_file_size` fields plus a 4-byte gap that is the model file's
/// `number_of_blocks` followed by 4 bytes that are
/// `used_number_of_blocks`); the layout is **not** identical to
/// `FileInfo`, so the parser reads the full struct in one shot rather
/// than trying to share the first-24 prefix with [`FileInfo`].
///
/// Field semantics are taken from the canonical Lumina C# definition
/// (`src/Lumina/Data/Structs/SqPackFileInfo.cs::ModelBlock`). The
/// section ordering on disk is: stack, runtime, vertex×3,
/// edge-geometry-vertex×3, index×3 (11 sections total). Per-section
/// `block_index` is the index of its first block in the global per-block
/// u16 compressed-size array that follows the struct; `block_num` is the
/// section's block count.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub(crate) struct ModelHeader {
    /// Combined size of this `ModelHeader` + per-block compressed-size
    /// table (the model-file analogue of [`FileInfo::size`]).
    pub size: u32,
    /// Raw [`FileKind`] discriminant — `3` for a well-formed Model file.
    pub kind_raw: u32,
    /// Uncompressed total file size as Lumina would reconstruct it
    /// (preamble + section bytes). Treated as advisory; the decoder
    /// validates per-block bytes against each [`BlockHeader::block_data_size`].
    pub raw_file_size: u32,
    /// Total declared block count across every section.
    pub number_of_blocks: u32,
    /// Actually-used block count — Lumina prefers this for its iteration
    /// loop. We use it identically: the per-block u16 size table that
    /// follows has `used_number_of_blocks` entries (modulo padding to
    /// the declared `number_of_blocks`).
    pub used_number_of_blocks: u32,
    /// MDL format version (opaque pass-through, not interpreted here).
    pub version: u32,
    /// Uncompressed stack-section byte count (advisory).
    pub stack_size: u32,
    /// Uncompressed runtime-section byte count (advisory).
    pub runtime_size: u32,
    /// Per-LOD uncompressed vertex-buffer byte counts (advisory).
    pub vertex_buffer_size: [u32; 3],
    /// Per-LOD uncompressed edge-geometry-vertex-buffer byte counts.
    pub edge_geometry_vertex_buffer_size: [u32; 3],
    /// Per-LOD uncompressed index-buffer byte counts.
    pub index_buffer_size: [u32; 3],
    /// Compressed-on-disk byte count of the stack section.
    pub compressed_stack_memory_size: u32,
    /// Compressed-on-disk byte count of the runtime section.
    pub compressed_runtime_memory_size: u32,
    /// Per-LOD compressed vertex-buffer byte counts.
    pub compressed_vertex_buffer_size: [u32; 3],
    /// Per-LOD compressed edge-geometry-vertex-buffer byte counts.
    pub compressed_edge_geometry_vertex_buffer_size: [u32; 3],
    /// Per-LOD compressed index-buffer byte counts.
    pub compressed_index_buffer_size: [u32; 3],
    /// Byte offset of the stack section relative to the block-data base
    /// (`payload_start + size`).
    pub stack_offset: u32,
    /// Byte offset of the runtime section.
    pub runtime_offset: u32,
    /// Per-LOD byte offsets of the vertex-buffer sections.
    pub vertex_buffer_offset: [u32; 3],
    /// Per-LOD byte offsets of the edge-geometry-vertex-buffer sections.
    pub edge_geometry_vertex_buffer_offset: [u32; 3],
    /// Per-LOD byte offsets of the index-buffer sections.
    pub index_buffer_offset: [u32; 3],
    /// First-block index of the stack section in the per-block u16
    /// compressed-size table.
    pub stack_block_index: u16,
    /// First-block index of the runtime section.
    pub runtime_block_index: u16,
    /// Per-LOD first-block indices of the vertex-buffer sections.
    pub vertex_buffer_block_index: [u16; 3],
    /// Per-LOD first-block indices of the edge-geometry-vertex-buffer
    /// sections.
    pub edge_geometry_vertex_buffer_block_index: [u16; 3],
    /// Per-LOD first-block indices of the index-buffer sections.
    pub index_buffer_block_index: [u16; 3],
    /// Block count of the stack section.
    pub stack_block_num: u16,
    /// Block count of the runtime section.
    pub runtime_block_num: u16,
    /// Per-LOD block counts of the vertex-buffer sections.
    pub vertex_buffer_block_num: [u16; 3],
    /// Per-LOD block counts of the edge-geometry-vertex-buffer sections.
    pub edge_geometry_vertex_buffer_block_num: [u16; 3],
    /// Per-LOD block counts of the index-buffer sections.
    pub index_buffer_block_num: [u16; 3],
    /// MDL `vertex_declaration_num` — passed through, not interpreted.
    pub vertex_declaration_num: u16,
    /// MDL `material_num` — passed through, not interpreted.
    pub material_num: u16,
    /// Number of populated LODs (0..=3). The decoder still walks the
    /// per-LOD arrays for empty LODs (their block counts are zero) so
    /// this field is informational.
    pub num_lods: u8,
    /// Whether the MDL uses streaming index buffers — passed through.
    pub index_buffer_streaming_enabled: u8,
    /// Whether the MDL uses edge geometry — passed through.
    pub edge_geometry_enabled: u8,
    /// Trailing pad byte; preserved for layout-correct parsing.
    #[allow(dead_code)]
    pub padding: u8,
}

/// On-disk size of a [`ModelHeader`] in bytes — 208 bytes; the model
/// file's `FileInfo::size` advertises this struct plus the trailing u16
/// compressed-size table together as a single "header region".
pub(crate) const MODEL_HEADER_SIZE: u32 = 208;

/// On-disk per-mip block-table row for a Texture-kind payload.
///
/// Lumina calls this struct `LodBlock`. The texture file's
/// [`FileInfo::number_of_blocks`] tells the parser how many `LodBlock`
/// rows follow the [`FileInfo`] (typically 1, 3, or — for cube-map
/// textures observed by Lumina — 18). Each row points at a contiguous
/// run of blocks within the block-data region; per-block compressed
/// sizes live in a separate u16 array that follows the `LodBlock`
/// table.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, BinRead)]
#[br(little)]
pub(crate) struct LodBlock {
    /// Byte offset of this mip's first block relative to the block-data
    /// base (`payload_start + size`). For the first row, the value also
    /// implicitly carries the length of the leading mip-header preamble
    /// (every byte from the block-data base up to `compressed_offset` is
    /// emitted as preamble before the block payloads).
    pub compressed_offset: u32,
    /// Compressed-on-disk byte count of this mip's block region.
    pub compressed_size: u32,
    /// Uncompressed byte count of this mip — advisory; per-block bytes
    /// are validated against each [`BlockHeader::block_data_size`].
    pub decompressed_size: u32,
    /// Offset of this mip's content in the reconstructed `.tex` body —
    /// passed through, not interpreted by the decoder.
    pub block_offset: u32,
    /// Number of blocks owned by this mip in the per-block u16
    /// compressed-size table.
    pub block_count: u32,
}

/// On-disk size of a [`LodBlock`] entry — 20 bytes.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) const LOD_BLOCK_SIZE: u32 = 20;

/// Tagged-return variant of [`DatFile::read_file`] that lets the caller branch
/// on [`FileKind`] at compile time.
///
/// Standard files are decoded end-to-end (same pipeline as
/// [`DatFile::read_file`]) and yielded as [`SqPackPayload::Standard`].
/// Model and Texture files are decoded into their typed payload
/// wrappers ([`ModelPayload`] / [`TexturePayload`]), each carrying the
/// parsed [`FileInfo`] alongside the concatenated decompressed bytes —
/// the same byte view [`SqPackPayload::bytes`] returns for the
/// [`SqPackPayload::Standard`] variant. See [`ModelPayload`] and
/// [`TexturePayload`] for the precise byte-layout contract.
///
/// Empty files surface as [`SqPackPayload::Standard`] with an empty `Vec` —
/// the kind discriminant is folded into the standard payload variant because
/// "no bytes to decode" and "decoded to zero bytes" are indistinguishable
/// from the caller's perspective.
///
/// `#[non_exhaustive]`: additional payload variants (e.g. a future merged
/// model+texture archive) land as additive variants in a SemVer-minor bump.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SqPackPayload {
    /// A fully-decoded Standard-kind (or Empty) payload.
    Standard(Vec<u8>),
    /// A fully-decoded Model-kind payload.
    Model(ModelPayload),
    /// A fully-decoded Texture-kind payload.
    Texture(TexturePayload),
}

impl SqPackPayload {
    /// The [`FileKind`] of the payload, derived from the variant.
    ///
    /// `Standard` payloads always report [`FileKind::Standard`] even when the
    /// underlying `FileInfo` was [`FileKind::Empty`] — the empty-vs-decoded
    /// distinction is not visible to the consumer.
    #[must_use]
    pub fn kind(&self) -> FileKind {
        match self {
            SqPackPayload::Standard(_) => FileKind::Standard,
            SqPackPayload::Model(_) => FileKind::Model,
            SqPackPayload::Texture(_) => FileKind::Texture,
        }
    }

    /// Decoded payload bytes for every variant.
    ///
    /// Standard payloads return their decompressed-block concatenation
    /// (the same bytes [`DatFile::read_file`] returns as `Vec<u8>`).
    /// Model and Texture payloads return the concatenated decompressed
    /// bytes documented on [`ModelPayload::bytes`] / [`TexturePayload::bytes`]
    /// — a deterministic byte view suitable for content hashing.
    #[must_use]
    pub fn bytes(&self) -> &[u8] {
        match self {
            SqPackPayload::Standard(bytes) => bytes,
            SqPackPayload::Model(m) => &m.bytes,
            SqPackPayload::Texture(t) => &t.bytes,
        }
    }
}

/// Sealed marker trait combining [`Read`] and [`Seek`] so [`DatFileStream`]
/// can hold a trait-object reference uniformly across the production
/// `BufReader<File>` backing and the in-memory `Cursor` backing used by the
/// unit-test suite.
///
/// Auto-implemented for every `Read + Seek` type. Not part of the public
/// API.
pub(crate) trait ReadSeek: Read + Seek {}
impl<T: Read + Seek + ?Sized> ReadSeek for T {}

/// An opened `.datN` companion file.
///
/// Holds the buffered reader, the parsed [`SqPackHeader`], and the originating
/// path (when opened via [`DatFile::open`]). The central operation is
/// [`DatFile::read_file`], which extracts a single file payload at an offset
/// supplied by the index.
///
/// Implements `Send` but not `Sync`. [`DatFile::read_file`] takes `&mut self`
/// because it seeks and reads from a single buffered reader, so concurrent
/// extraction requires either one `DatFile` per thread or external
/// synchronisation (e.g. wrapping in a `Mutex`).
///
/// When the `block_cache` Cargo feature is enabled, a [`DatFile`] may carry
/// an optional [`CacheContext`] populated via
/// [`DatFile::with_cache_context`]. The context is consulted inside
/// [`read_one_block_at`] before the inflate step — a hit copies the
/// decompressed payload from the cache; a miss does the full
/// seek + header + inflate and inserts the result. Without a context the
/// read path is byte-identical to the no-feature build.
pub struct DatFile {
    header: Option<SqPackHeader>,
    reader: BufReader<Box<dyn VfsRead>>,
    source_path: Option<PathBuf>,
    /// Optional block-cache context. Populated by
    /// [`crate::SqPack::open_dat_for`] when the parent `SqPack` has a
    /// [`BlockCache`](crate::BlockCache) attached, [`None`] otherwise.
    /// The field exists unconditionally so the call sites inside
    /// [`read_one_block_at`] don't need a `#[cfg]` fence on every
    /// branch — when the `block_cache` feature is off the
    /// [`CacheContext`] type is a zero-sized stub (`Option<ZST> = 1
    /// byte` + padding) and the field is always [`None`].
    cache_ctx: Option<CacheContext>,
}

impl std::fmt::Debug for DatFile {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DatFile")
            .field("header", &self.header)
            .field("source_path", &self.source_path)
            .field("cache_ctx", &self.cache_ctx.as_ref().map(|_| "..."))
            .finish_non_exhaustive()
    }
}

/// Block-cache identity context carried by a [`DatFile`] when the
/// `block_cache` feature is enabled and the parent
/// [`SqPack`](crate::SqPack) has a cache attached.
///
/// Records the `(install_id, triple, dat_id)` triple needed to
/// construct a [`BlockKey`](crate::block_cache::BlockKey) for every block read off
/// this [`DatFile`], plus the [`Arc<BlockCache>`](crate::BlockCache)
/// the cache lookups consult. `install_id` is the opaque per-install
/// identifier produced by [`SqPack::with_block_cache`](crate::SqPack::with_block_cache)
/// at attach time; threading it through the context ensures every
/// cache probe a [`DatFile`] makes is scoped to the originating
/// install's keyspace (the C1 fix — a shared `Arc<BlockCache>`
/// fronting two installs no longer collides on `(triple, dat_id,
/// byte_offset)` alone). Cloneable so the context can be re-attached
/// to a sibling [`DatFile`] (e.g. inside a streaming reader that owns
/// its own `DatFile` separately from the originating `SqPack`).
#[derive(Debug, Clone)]
#[cfg(feature = "block-cache")]
pub(crate) struct CacheContext {
    install_id: u64,
    triple: crate::route::ArchiveTriple,
    dat_id: u8,
    cache: std::sync::Arc<crate::block_cache::BlockCache>,
}

#[cfg(feature = "block-cache")]
impl CacheContext {
    /// Construct a [`CacheContext`].
    ///
    /// Crate-internal because the only legitimate construction sites
    /// are [`crate::SqPack::open_dat_for`] (which propagates the
    /// parent's cache to a fresh [`DatFile`]) and
    /// [`DatFile::with_cache_context`] (which exposes the wiring to
    /// callers that constructed a [`DatFile`] directly).
    pub(crate) fn new(
        install_id: u64,
        triple: crate::route::ArchiveTriple,
        dat_id: u8,
        cache: std::sync::Arc<crate::block_cache::BlockCache>,
    ) -> Self {
        Self {
            install_id,
            triple,
            dat_id,
            cache,
        }
    }
}

/// Zero-sized stub of [`CacheContext`] used when the `block_cache`
/// feature is off. Lets [`DatFile`]'s `cache_ctx` field exist
/// unconditionally and lets the per-block read primitives take an
/// `Option<&CacheContext>` parameter without `#[cfg]`-gating every
/// call site; the field stays `None` in every code path on the
/// no-feature build and the cache-probe `if let` branch inside
/// [`read_one_block_at`] is fully `#[cfg]`-gated out.
///
/// `pub(crate)` so the `reader_and_cache` accessor's
/// `Option<&CacheContext>` return type can be named at the call sites
/// in `src/sqpack.rs` without a feature-gated re-export dance.
#[cfg(not(feature = "block-cache"))]
#[derive(Debug, Clone)]
pub(crate) struct CacheContext;

impl DatFile {
    /// Open and validate a `.dat0` file from `path` using the default
    /// [`StdFs`] backend.
    ///
    /// Thin wrapper around [`DatFile::open_in`]; see that doc for the
    /// `Vfs`-aware variant. Parses the generic [`SqPackHeader`] at offset 0
    /// and asserts its `file_type` is [`SqPackFileType::Dat`]. The remainder
    /// of the file is not touched until [`DatFile::read_file`] is invoked.
    ///
    /// For companion `.datN` files where `N > 0`, use [`DatFile::open_at`]
    /// or [`DatFile::open_at_in`] — those tolerate the missing/absent `SqPack`
    /// header that the patcher writes for `.dat1+` companion files.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        DatFile::open_in(path, &StdFs::new())
    }

    /// Open and validate a `.dat0` file from `path` via an explicit [`Vfs`].
    ///
    /// The [`Vfs`] is consulted once (to open the read handle). The opened
    /// [`DatFile`] retains the handle for subsequent [`DatFile::read_file`]
    /// / [`DatFile::read_file_streaming`] calls — those reuse the handle's
    /// buffered reader and do not re-consult the [`Vfs`].
    ///
    /// [`DatFile::source_path`] returns the supplied `path` regardless of
    /// which backend resolved it. When a non-[`StdFs`] backend is in use,
    /// the returned `Path` is not guaranteed to point at a real
    /// `std::fs`-accessible file.
    ///
    /// For `.datN` files where `N > 0`, use [`DatFile::open_at_in`].
    pub fn open_in(path: impl AsRef<Path>, vfs: &dyn Vfs) -> Result<Self> {
        DatFile::open_at_in(path, vfs, 0)
    }

    /// Open a `.dat{dat_id}` companion file from `path` using the default
    /// [`StdFs`] backend.
    ///
    /// Thin wrapper around [`DatFile::open_at_in`].
    pub fn open_at(path: impl AsRef<Path>, dat_id: u8) -> Result<Self> {
        DatFile::open_at_in(path, &StdFs::new(), dat_id)
    }

    /// Open a `.dat{dat_id}` companion file from `path` via an explicit
    /// [`Vfs`].
    ///
    /// When `dat_id` is `0`, behaves identically to [`DatFile::open_in`]:
    /// the 1024-byte [`SqPackHeader`] at offset 0 is parsed and validated,
    /// rejecting the file with [`Error::InvalidMagic`] /
    /// [`Error::UnexpectedFileType`] on mismatch.
    ///
    /// When `dat_id > 0`, the `SqPack` header is optional: the patcher writes
    /// `.dat1+` companion files as raw payload-only containers, so an
    /// unrecognisable magic at offset 0 is tolerated. [`DatFile::header`]
    /// returns `None` in that case and the per-entry byte-offset guards
    /// fall back to a floor of zero. A well-formed `SqPack` header at offset
    /// 0 of a `.dat1+` is still parsed and exposed; only a missing or
    /// invalid magic is what triggers the lenient path.
    pub fn open_at_in(path: impl AsRef<Path>, vfs: &dyn Vfs, dat_id: u8) -> Result<Self> {
        let path = path.as_ref();
        let span = info_span!(span_names::OPEN_DAT, path = %path.display(), dat_id);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        let handle = vfs.open_read(path).map_err(|e| Error::io_at(path, e))?;
        let mut reader = BufReader::new(handle);

        let header = {
            let _span = debug_span!(span_names::SQPACK_HEADER).entered();
            match SqPackHeader::parse(&mut reader, SqPackFileType::Dat) {
                Ok(h) => Some(h),
                Err(Error::InvalidMagic) if dat_id > 0 => {
                    debug!("dat companion file lacks SqPack header; treating as headerless");
                    reader.seek(SeekFrom::Start(0))?;
                    None
                }
                Err(e) => return Err(e),
            }
        };
        if let Some(ref h) = header {
            debug!(
                platform_id = h.platform_id,
                version = h.version,
                size = h.size,
                "parsed SqPack header"
            );
        }

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            size = header.as_ref().map(|h| h.size),
            version = header.as_ref().map(|h| h.version),
            elapsed_ms,
            "opened SqPack dat"
        );

        Ok(DatFile {
            header,
            reader,
            source_path: Some(path.to_path_buf()),
            cache_ctx: None,
        })
    }

    /// Attach a [`BlockCache`](crate::BlockCache) context to this
    /// [`DatFile`]. Subsequent block-reads consult the cache before
    /// running the seek + header + inflate pipeline; misses populate
    /// the cache with the decompressed payload.
    ///
    /// The cache is shared via [`Arc`](std::sync::Arc): one
    /// [`BlockCache`](crate::BlockCache) can be attached to multiple
    /// [`DatFile`] handles concurrently, which is how the parallel
    /// verifier serves all rayon workers from a single cache. See
    /// [`crate::block_cache`] for the design rationale (key shape,
    /// concurrency posture, capacity semantics).
    ///
    /// `install_id` is the opaque per-install identifier produced by
    /// [`SqPack::with_block_cache`](crate::SqPack::with_block_cache)
    /// (or by calling
    /// [`block_cache::install_id_from_path`](crate::block_cache)
    /// directly on the install root); `triple` and `dat_id` together
    /// identify the on-disk `.dat{dat_id}` companion this handle reads
    /// from. The three components plus the per-block `byte_offset`
    /// form the [`BlockKey`](crate::block_cache::BlockKey); the caller is
    /// responsible for supplying values that match the underlying file
    /// (mismatched values produce cache pollution, not unsafety).
    ///
    /// Returns `self` so the call composes with the `open_*`
    /// constructors:
    ///
    /// ```no_run
    /// # #[cfg(feature = "block-cache")]
    /// # fn run() -> sqpack_rs::Result<()> {
    /// use std::sync::Arc;
    /// use sqpack_rs::route::ArchiveTriple;
    /// use sqpack_rs::{BlockCache, DatFile};
    ///
    /// let cache = Arc::new(BlockCache::new());
    /// let triple = ArchiveTriple::from_game_path("exd/root.exl").expect("category");
    /// let install_id = 0xdead_beef; // typically from SqPack::with_block_cache
    /// let dat = DatFile::open("/path/to/000000.win32.dat0")?
    ///     .with_cache_context(install_id, triple, 0, Arc::clone(&cache));
    /// # let _ = dat;
    /// # Ok(())
    /// # }
    /// ```
    #[cfg(feature = "block-cache")]
    #[cfg_attr(docsrs, doc(cfg(feature = "block-cache")))]
    #[must_use]
    pub fn with_cache_context(
        mut self,
        install_id: u64,
        triple: crate::route::ArchiveTriple,
        dat_id: u8,
        cache: std::sync::Arc<crate::block_cache::BlockCache>,
    ) -> Self {
        self.cache_ctx = Some(CacheContext::new(install_id, triple, dat_id, cache));
        self
    }

    /// Parse and validate the [`SqPackHeader`] of an in-memory `.dat` byte
    /// stream. Used by the test suite and any caller that already holds the
    /// file's bytes (e.g. a mocked reader). The reader is wrapped into a
    /// [`File`]-backed [`BufReader`] via [`DatFile::open`] in the common case;
    /// here the caller is responsible for keeping the underlying source alive
    /// for the lifetime of the returned `DatFile`.
    ///
    /// Internally used only by tests; not part of the public API. The hot
    /// path is [`DatFile::open`].
    #[cfg(test)]
    fn from_buffer(buf: Vec<u8>) -> Result<DatFileBuf> {
        let mut cur = std::io::Cursor::new(buf);
        let header = SqPackHeader::parse(&mut cur, SqPackFileType::Dat)?;
        Ok(DatFileBuf {
            header: Some(header),
            cursor: cur,
        })
    }

    /// Test-only constructor mirroring [`DatFile::open_at_in`]: tolerates a
    /// missing/invalid `SqPack` header when `dat_id > 0`. Used by the
    /// multi-dat regression tests.
    #[cfg(test)]
    fn from_buffer_at(buf: Vec<u8>, dat_id: u8) -> Result<DatFileBuf> {
        let mut cur = std::io::Cursor::new(buf);
        let header = match SqPackHeader::parse(&mut cur, SqPackFileType::Dat) {
            Ok(h) => Some(h),
            Err(Error::InvalidMagic) if dat_id > 0 => {
                cur.set_position(0);
                None
            }
            Err(e) => return Err(e),
        };
        Ok(DatFileBuf {
            header,
            cursor: cur,
        })
    }

    /// Path the `.dat` was opened from, if it was opened via [`DatFile::open`].
    #[must_use]
    pub fn source_path(&self) -> Option<&Path> {
        self.source_path.as_deref()
    }

    /// `pub(crate)` accessor used by [`crate::sqpack::SqPackFileStream`] to
    /// drive its own block-by-block read loop against this dat file's
    /// reader. Not exposed publicly — the public surface is the
    /// [`DatFile::read_file_streaming`] / [`crate::SqPack::read_file_streaming`]
    /// pair, which encapsulates the state machine.
    pub(crate) fn reader_mut(&mut self) -> &mut BufReader<Box<dyn VfsRead>> {
        &mut self.reader
    }

    /// `pub(crate)` accessor that returns mutable access to both the
    /// reader and the cache context in a single call so callers can
    /// borrow them simultaneously without fighting the borrow checker
    /// over `&mut self.reader` + `&self.cache_ctx`. Used by
    /// [`crate::sqpack::SqPackFileStream`] to thread the cache through
    /// its own per-block read loop, mirroring what [`DatFileStream`]
    /// does via its owned [`CacheContext`] clone.
    pub(crate) fn reader_and_cache(
        &mut self,
    ) -> (&mut BufReader<Box<dyn VfsRead>>, Option<&CacheContext>) {
        (&mut self.reader, self.cache_ctx.as_ref())
    }

    /// Generic [`SqPackHeader`] this `.dat` was parsed from, if one was
    /// present.
    ///
    /// Returns `Some` for `.dat0` (where the `SqPack` header is always
    /// validated on open) and for `.dat1+` files that carry a well-formed
    /// `SqPack` header at offset 0. Returns `None` for `.dat1+` companion
    /// files written by the patcher as raw payload-only containers — those
    /// have no usable 1024-byte header to expose.
    #[must_use]
    pub fn header(&self) -> Option<&SqPackHeader> {
        self.header.as_ref()
    }

    /// Read a single file payload at `byte_offset` (typically supplied by
    /// [`crate::index::IndexEntry::byte_offset`]).
    ///
    /// Parses the [`FileInfo`] header at `byte_offset`, then dispatches on
    /// [`FileKind`]: Standard files read every [`BlockInfo`] row and
    /// concatenate the decompressed blocks into a `Vec<u8>` whose final
    /// length equals [`FileInfo::raw_file_size`]; Model and Texture files
    /// walk their extended-header section / mip tables and return the
    /// per-block decompressed-bytes concatenation in declared order (the
    /// same byte view exposed on [`ModelPayload::bytes`] /
    /// [`TexturePayload::bytes`]). Empty files return an empty `Vec`;
    /// [`FileKind::Unknown`] is rejected.
    ///
    /// # Errors
    ///
    /// - [`Error::InvalidHeaderSize`] if `byte_offset` lands inside the
    ///   1024-byte [`SqPackHeader`] region (re-parsing the magic as a
    ///   [`FileInfo`] would produce nonsensical results).
    /// - [`Error::UnsupportedFileKind`] if the payload is an unknown
    ///   variant. Model and Texture are decoded; Empty returns an empty
    ///   `Vec`.
    /// - [`Error::BlockHeaderSizeMismatch`] if a block-header `size` is not `16`.
    /// - [`Error::DecompressedSizeMismatch`] if a single block's decoded
    ///   length, or the cumulative file size, disagrees with the on-disk hint.
    /// - [`Error::InvalidBlockLayout`] if a block's `offset` or
    ///   `compressed_size` violates a structural invariant (non-monotonic,
    ///   zero-sized, OOB).
    /// - [`Error::Decode`] for any structural parse failure, with `context`
    ///   set to one of `"file_info"`, `"block_info"`, `"block_header"`,
    ///   `"model_header"`, `"texture_lod_block"`, or
    ///   `"texture_block_size"`.
    /// - [`Error::Io`] for I/O failures bubbled up from the underlying reader.
    pub fn read_file(&mut self, byte_offset: u64) -> Result<Vec<u8>> {
        let mut out = Vec::new();
        self.read_file_into(byte_offset, &mut out)?;
        Ok(out)
    }

    /// Read a single file payload at `byte_offset` into a caller-supplied
    /// buffer, allowing the allocation to be reused across calls. `out` is
    /// cleared (its capacity is retained) before the payload is written.
    ///
    /// This is the primitive [`DatFile::read_file`] delegates to — callers in
    /// a hot loop should reuse a single `Vec<u8>` via this method and avoid
    /// re-allocating per file.
    ///
    /// # Errors
    ///
    /// Same as [`DatFile::read_file`].
    pub fn read_file_into(&mut self, byte_offset: u64, out: &mut Vec<u8>) -> Result<()> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }

        let span = info_span!(span_names::DAT_READ_FILE, byte_offset);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        out.clear();
        let cache_ctx = self.cache_ctx.as_ref();
        read_file_inner(&mut self.reader, byte_offset, started_at, out, cache_ctx)
    }

    /// Read the [`FileInfo`] header at `byte_offset` without decoding the body.
    ///
    /// Useful for callers that want to decide whether to materialise a payload
    /// based on its size or kind — a launcher's integrity scanner can use this
    /// to skip over Model/Texture payloads without paying for the abandoned
    /// block-table read.
    ///
    /// # Errors
    ///
    /// - [`Error::InvalidHeaderSize`] if `byte_offset` lands inside the
    ///   1024-byte [`SqPackHeader`] region.
    /// - [`Error::Decode`] with `context = "file_info"` for a structural
    ///   parse failure of the [`FileInfo`] struct.
    /// - [`Error::Io`] for I/O failures from the underlying reader.
    pub fn peek_file_info(&mut self, byte_offset: u64) -> Result<FileInfo> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        self.reader.seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(&mut self.reader).map_err(|e| Error::decode("file_info", &e))?;
        Ok(file_info)
    }

    /// Read just the [`FileKind`] discriminant at `byte_offset`.
    ///
    /// Thin wrapper around [`DatFile::peek_file_info`] that discards the rest
    /// of the parsed [`FileInfo`]. Equivalent to
    /// `dat.peek_file_info(byte_offset).map(|fi| fi.kind())` but
    /// communicates the caller's intent at the type level.
    ///
    /// # Note
    ///
    /// Returns [`FileKind::Empty`] for empty payloads. If you then open a
    /// [`DatFileStream`] for the same offset, the stream's
    /// [`DatFileStream::kind`] will collapse `Empty` into [`FileKind::Standard`]
    /// because the streaming pipeline treats both as zero-or-more standard
    /// blocks. See [`DatFileStream::kind`] for the rationale.
    ///
    /// # Errors
    ///
    /// Same as [`DatFile::peek_file_info`].
    pub fn peek_kind(&mut self, byte_offset: u64) -> Result<FileKind> {
        self.peek_file_info(byte_offset).map(|fi| fi.kind())
    }

    /// Open a streaming reader over the Standard-kind file at `byte_offset`.
    ///
    /// Parses the [`FileInfo`] and the full [`BlockInfo`] table up front (the
    /// same validation [`DatFile::read_file`] performs), then returns a
    /// [`DatFileStream`] that decodes one block per [`Iterator::next`] call
    /// — useful for large files (audio, model archives) where materialising
    /// the entire payload into a single `Vec<u8>` is wasteful.
    ///
    /// Only Standard-kind payloads are streamed. Empty payloads return a
    /// stream that yields zero blocks (the iterator's first `next()` is
    /// `None`). Model and Texture payloads are rejected with
    /// [`Error::UnsupportedFileKind`] from this constructor — once typed
    /// model/texture decoders land they will get their own streaming entry
    /// points.
    ///
    /// The returned stream borrows `&mut self` for its lifetime; further
    /// [`DatFile`] operations are blocked until the stream is dropped.
    ///
    /// # Errors
    ///
    /// - [`Error::InvalidHeaderSize`] / [`Error::Decode`] for header parse
    ///   failures (same as [`DatFile::read_file`]).
    /// - [`Error::UnsupportedFileKind`] for Model / Texture / Unknown kinds.
    /// - [`Error::InvalidBlockLayout`] for a structurally invalid block table.
    /// - [`Error::Io`] for an I/O failure while seeking or reading.
    ///
    /// Per-block decode failures surface from [`DatFileStream::next`], not
    /// from this constructor.
    pub fn read_file_streaming(&mut self, byte_offset: u64) -> Result<DatFileStream<'_>> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        let span = debug_span!(span_names::DAT_READ_FILE_STREAMING, byte_offset);
        let _enter = span.enter();

        self.reader.seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(&mut self.reader).map_err(|e| Error::decode("file_info", &e))?;
        let kind = file_info.kind();

        match kind {
            FileKind::Standard | FileKind::Empty => {}
            other => return Err(Error::UnsupportedFileKind { kind: other }),
        }

        let blocks = read_and_validate_block_table(&mut self.reader, &file_info)?;
        let block_data_base = byte_offset + u64::from(file_info.size);
        let block_count = blocks.len();
        let raw_file_size = file_info.raw_file_size;
        info!(
            byte_offset,
            kind = ?kind,
            block_count,
            raw_file_size,
            "read_file_streaming opened"
        );
        let cache_ctx = self.cache_ctx.clone();
        Ok(DatFileStream {
            reader: &mut self.reader,
            file_info,
            blocks,
            block_data_base,
            cursor: 0,
            cache_ctx,
        })
    }

    /// Tagged-return variant of [`DatFile::read_file`].
    ///
    /// Parses the [`FileInfo`] at `byte_offset` and dispatches on
    /// [`FileKind`]: Standard / Empty are decoded end-to-end and yielded as
    /// [`SqPackPayload::Standard`]; Model and Texture are decoded into
    /// [`SqPackPayload::Model`] / [`SqPackPayload::Texture`], each
    /// carrying the parsed [`FileInfo`] alongside the decompressed-block
    /// concatenation. Unknown [`FileKind`] variants are rejected with
    /// [`Error::UnsupportedFileKind`].
    ///
    /// # Errors
    ///
    /// - [`Error::UnsupportedFileKind`] only when the kind is
    ///   [`FileKind::Unknown`].
    /// - Every other error variant [`DatFile::read_file`] can produce.
    pub fn read_file_typed(&mut self, byte_offset: u64) -> Result<SqPackPayload> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        let span = info_span!(span_names::DAT_READ_FILE_TYPED, byte_offset);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();

        self.reader.seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(&mut self.reader).map_err(|e| Error::decode("file_info", &e))?;
        let kind = file_info.kind();
        debug!(
            kind = ?kind,
            raw_file_size = file_info.raw_file_size,
            number_of_blocks = file_info.number_of_blocks,
            info_size = file_info.size,
            "parsed file_info",
        );

        let raw_file_size = file_info.raw_file_size;
        let cache_ctx = self.cache_ctx.as_ref();
        let payload = match kind {
            FileKind::Empty => {
                // Empty payloads decode to an empty Vec under the Standard
                // variant — see SqPackPayload docs for the rationale.
                SqPackPayload::Standard(Vec::new())
            }
            FileKind::Standard => {
                let mut out = Vec::new();
                read_standard_blocks(
                    &mut self.reader,
                    byte_offset,
                    &file_info,
                    &mut out,
                    cache_ctx,
                )?;
                SqPackPayload::Standard(out)
            }
            FileKind::Model => {
                let mut out = Vec::new();
                let metadata =
                    read_model_blocks(&mut self.reader, byte_offset, &mut out, cache_ctx)?;
                SqPackPayload::Model(ModelPayload {
                    file_info,
                    metadata,
                    bytes: out,
                })
            }
            FileKind::Texture => {
                let mut out = Vec::new();
                read_texture_blocks(
                    &mut self.reader,
                    byte_offset,
                    &file_info,
                    &mut out,
                    cache_ctx,
                )?;
                SqPackPayload::Texture(TexturePayload {
                    file_info,
                    bytes: out,
                })
            }
            FileKind::Unknown(_) => return Err(Error::UnsupportedFileKind { kind }),
        };

        #[allow(clippy::cast_possible_truncation)]
        let elapsed_ms = started_at.elapsed().as_millis() as u64;
        info!(
            byte_offset,
            kind = ?kind,
            raw_file_size,
            elapsed_ms,
            "read_file_typed"
        );
        Ok(payload)
    }
}

/// Test-only stand-in for a [`DatFile`] backed by an in-memory cursor instead
/// of a [`File`]. Lets the unit-test suite exercise [`read_file_inner`]
/// without touching disk.
#[cfg(test)]
#[derive(Debug)]
struct DatFileBuf {
    header: Option<SqPackHeader>,
    cursor: std::io::Cursor<Vec<u8>>,
}

#[cfg(test)]
impl DatFileBuf {
    fn read_file(&mut self, byte_offset: u64) -> Result<Vec<u8>> {
        let mut out = Vec::new();
        self.read_file_into(byte_offset, &mut out)?;
        Ok(out)
    }

    fn read_file_into(&mut self, byte_offset: u64, out: &mut Vec<u8>) -> Result<()> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        let span = info_span!(span_names::DAT_READ_FILE, byte_offset);
        let _enter = span.enter();
        let started_at = std::time::Instant::now();
        out.clear();
        // Test-only stand-in: no cache wiring path exists for `DatFileBuf`
        // (it's an in-memory cursor used only by the unit-test suite). Pass
        // `None` so the cache fast-path compiles out.
        read_file_inner(&mut self.cursor, byte_offset, started_at, out, None)
    }

    fn peek_file_info(&mut self, byte_offset: u64) -> Result<FileInfo> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        self.cursor.seek(SeekFrom::Start(byte_offset))?;
        FileInfo::read(&mut self.cursor).map_err(|e| Error::decode("file_info", &e))
    }

    fn peek_kind(&mut self, byte_offset: u64) -> Result<FileKind> {
        self.peek_file_info(byte_offset).map(|fi| fi.kind())
    }

    fn read_file_typed(&mut self, byte_offset: u64) -> Result<SqPackPayload> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        self.cursor.seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(&mut self.cursor).map_err(|e| Error::decode("file_info", &e))?;
        let kind = file_info.kind();
        match kind {
            FileKind::Empty => Ok(SqPackPayload::Standard(Vec::new())),
            FileKind::Standard => {
                let mut out = Vec::new();
                read_standard_blocks(&mut self.cursor, byte_offset, &file_info, &mut out, None)?;
                Ok(SqPackPayload::Standard(out))
            }
            FileKind::Model => {
                let mut out = Vec::new();
                let metadata = read_model_blocks(&mut self.cursor, byte_offset, &mut out, None)?;
                Ok(SqPackPayload::Model(ModelPayload {
                    file_info,
                    metadata,
                    bytes: out,
                }))
            }
            FileKind::Texture => {
                let mut out = Vec::new();
                read_texture_blocks(&mut self.cursor, byte_offset, &file_info, &mut out, None)?;
                Ok(SqPackPayload::Texture(TexturePayload {
                    file_info,
                    bytes: out,
                }))
            }
            FileKind::Unknown(_) => Err(Error::UnsupportedFileKind { kind }),
        }
    }

    fn read_file_streaming(&mut self, byte_offset: u64) -> Result<DatFileStream<'_>> {
        let header_size = self.header.as_ref().map_or(0, |h| u64::from(h.size));
        if byte_offset < header_size {
            #[allow(clippy::cast_possible_truncation)]
            return Err(Error::InvalidHeaderSize {
                field: "byte_offset",
                value: byte_offset.min(u64::from(u32::MAX)) as u32,
                minimum: self.header.as_ref().map_or(0, |h| h.size),
            });
        }
        self.cursor.seek(SeekFrom::Start(byte_offset))?;
        let file_info =
            FileInfo::read(&mut self.cursor).map_err(|e| Error::decode("file_info", &e))?;
        let kind = file_info.kind();
        match kind {
            FileKind::Standard | FileKind::Empty => {}
            other => return Err(Error::UnsupportedFileKind { kind: other }),
        }
        let blocks = read_and_validate_block_table(&mut self.cursor, &file_info)?;
        let block_data_base = byte_offset + u64::from(file_info.size);
        Ok(DatFileStream {
            reader: &mut self.cursor,
            file_info,
            blocks,
            block_data_base,
            cursor: 0,
            cache_ctx: None,
        })
    }

    fn header(&self) -> Option<&SqPackHeader> {
        self.header.as_ref()
    }
}

fn read_file_inner<R: Read + Seek>(
    reader: &mut R,
    byte_offset: u64,
    started_at: std::time::Instant,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    reader.seek(SeekFrom::Start(byte_offset))?;

    let file_info = FileInfo::read(reader).map_err(|e| Error::decode("file_info", &e))?;
    let kind = file_info.kind();
    debug!(
        kind = ?kind,
        raw_file_size = file_info.raw_file_size,
        number_of_blocks = file_info.number_of_blocks,
        info_size = file_info.size,
        "parsed file_info"
    );

    match kind {
        FileKind::Empty => {
            // Empty payloads have no blocks and no body bytes; `out` is
            // already cleared by the caller.
        }
        FileKind::Standard => {
            read_standard_blocks(reader, byte_offset, &file_info, out, cache_ctx)?;
        }
        FileKind::Model => {
            // Untyped path: discard metadata; only the byte view is surfaced.
            let _ = read_model_blocks(reader, byte_offset, out, cache_ctx)?;
        }
        FileKind::Texture => {
            read_texture_blocks(reader, byte_offset, &file_info, out, cache_ctx)?;
        }
        FileKind::Unknown(_) => {
            return Err(Error::UnsupportedFileKind { kind });
        }
    }

    #[allow(clippy::cast_possible_truncation)]
    let elapsed_ms = started_at.elapsed().as_millis() as u64;
    info!(
        byte_offset,
        kind = ?file_info.kind(),
        raw_file_size = file_info.raw_file_size,
        block_count = file_info.number_of_blocks,
        elapsed_ms,
        "read_file"
    );

    Ok(())
}

/// Decode the block table + block payloads of a Standard-kind file at
/// `byte_offset`. `file_info` is the already-parsed payload header; the
/// reader is positioned immediately after it (so the block-info table is
/// the next thing on the wire).
fn read_standard_blocks<R: Read + Seek>(
    reader: &mut R,
    byte_offset: u64,
    file_info: &FileInfo,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    let blocks = read_and_validate_block_table(reader, file_info)?;
    let block_count = blocks.len();

    let block_data_base = byte_offset + u64::from(file_info.size);
    debug!(block_data_base, block_count, "entering block data section");

    out.reserve(file_info.raw_file_size as usize);
    for bi in &blocks {
        read_one_block(reader, block_data_base, *bi, out, cache_ctx)?;
    }

    if out.len() != file_info.raw_file_size as usize {
        return Err(Error::DecompressedSizeMismatch {
            context: "file",
            expected: file_info.raw_file_size,
            #[allow(clippy::cast_possible_truncation)]
            found: out.len() as u32,
        });
    }
    Ok(())
}

/// Read and validate the Standard-file [`BlockInfo`] table.
///
/// Shared between [`read_standard_blocks`] (eager decode) and
/// [`DatFile::read_file_streaming`] (lazy per-block decode). The reader must
/// be positioned immediately after the [`FileInfo`] header on entry; on
/// return it sits past the block table, at `byte_offset + file_info.size`
/// (the start of the block-data section).
///
/// Structural validation: every block's `offset` must monotonically
/// non-decrease relative to the previous block's all-in end, and
/// `offset + compressed_size` must not overflow a `u64`.
pub(crate) fn read_and_validate_block_table<R: Read + Seek>(
    reader: &mut R,
    file_info: &FileInfo,
) -> Result<Vec<BlockInfo>> {
    let block_count = file_info.number_of_blocks as usize;
    let mut blocks = Vec::with_capacity(block_count);
    for _ in 0..block_count {
        let bi = BlockInfo::read(reader).map_err(|e| Error::decode("block_info", &e))?;
        blocks.push(bi);
    }

    // Structural validation of the block-info table before we trust any of
    // these offsets to drive seeks. Offsets are measured from the block-data
    // base (`byte_offset + file_info.size`); `bi.compressed_size` is the
    // all-in on-disk size of the block region starting at `bi.offset`,
    // including the 16-byte [`BlockHeader`] prefix. Blocks must not regress
    // relative to the previous block's end.
    //
    // NOTE: `compressed_size` includes the [`BLOCK_HEADER_SIZE`]-byte header
    // in its count. The on-disk layout is dense: adjacent blocks pack with
    // no gap, so block[N+1].offset == block[N].offset + block[N].compressed_size.
    // A separate `BLOCK_HEADER_SIZE` must NOT be added here; `read_one_block`
    // seeks to `block_data_base + bi.offset` and reads the header from there.
    let mut prev_end: u64 = 0;
    for (idx, bi) in blocks.iter().enumerate() {
        let bi_offset = u64::from(bi.offset);
        let bi_size = u64::from(bi.compressed_size);
        if bi_offset < prev_end {
            return Err(Error::InvalidBlockLayout {
                context: "block_info.offset",
                message: format!(
                    "block {idx} offset {bi_offset} is less than previous block end {prev_end}"
                ),
            });
        }
        let end = bi_offset
            .checked_add(bi_size)
            .ok_or_else(|| Error::InvalidBlockLayout {
                context: "block_info.offset",
                message: format!("block {idx} offset {bi_offset} + size {bi_size} overflows u64"),
            })?;
        prev_end = end;
    }
    Ok(blocks)
}

/// Read a single block (header + payload) and append the uncompressed bytes
/// to `out`. Validates the [`BlockHeader`]'s `size` field and the produced
/// byte count against the [`BlockInfo`] row's `uncompressed_size`.
pub(crate) fn read_one_block<R: Read + Seek>(
    reader: &mut R,
    block_data_base: u64,
    bi: BlockInfo,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    let block_abs_offset = block_data_base + u64::from(bi.offset);
    read_one_block_at(
        reader,
        block_abs_offset,
        u32::from(bi.compressed_size),
        out,
        cache_ctx,
    )
}

/// Hard cap on `BlockHeader::block_data_size` (the declared
/// uncompressed byte count of a single block).
///
/// The retail toolchain emits blocks of at most ~16 KiB; this cap is
/// two orders of magnitude looser so a future-game change to larger
/// blocks doesn't suddenly fail to parse, while still bounding
/// adversarial input (`u32::MAX` `block_data_size`, zip-bomb DEFLATE
/// streams) below an OOM threshold. Applied before the
/// compressed-vs-uncompressed branch inside [`read_one_block_at`] so
/// both paths benefit; the tighter per-branch `compressed_size` check
/// remains as a format-correctness assertion for the
/// uncompressed-sentinel path. Adjust if a real future block ever
/// legitimately exceeds 1 MiB.
const MAX_BLOCK_DATA_SIZE: u32 = 1 << 20; // 1 MiB

/// Read a single block (header + payload) at an absolute file offset.
///
/// Generalised primitive used by every block-decoding path. `compressed_size`
/// is the all-in on-disk footprint of the block (16-byte [`BlockHeader`] +
/// compressed/uncompressed payload). Callers that have a [`BlockInfo`] row
/// pass `bi.compressed_size`; the Model/Texture decoders supply it from
/// their per-block u16 size tables.
///
/// `cache_ctx` is the optional block-cache identity context. When
/// supplied, the function probes the [`BlockCache`](crate::BlockCache)
/// at `(triple, dat_id, block_abs_offset)` before performing any I/O;
/// a hit copies the cached payload bytes into `out` and returns
/// immediately. A miss runs the full seek + header + inflate pipeline
/// and inserts the decompressed bytes into the cache on success.
/// Errors during inflate are **not** cached — only successful decodes
/// populate the cache.
pub(crate) fn read_one_block_at<R: Read + Seek>(
    reader: &mut R,
    block_abs_offset: u64,
    compressed_size: u32,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    // Cache probe — fast path. A hit copies the decompressed bytes
    // into `out` and skips the whole seek + header + inflate sequence.
    // Wired only under the `block_cache` feature so the no-feature
    // build compiles the cache check out entirely.
    //
    // Trade-off: the cached bytes are trusted verbatim — the per-block
    // `produced != expected_uncompressed` check that runs below for a
    // miss is **not** re-run on a hit. The check is a format-correctness
    // assertion (BlockHeader.block_data_size vs the byte count we
    // actually decoded) and was validated when the entry was inserted;
    // re-running it would require carrying `expected_uncompressed` in
    // the [`BlockKey`](crate::block_cache::BlockKey) (changing the key
    // shape) or storing it alongside the payload (changing the value
    // shape). Both are larger surgeries than the audit warranted.
    #[cfg(feature = "block-cache")]
    if let Some(ctx) = cache_ctx {
        let key = crate::block_cache::BlockKey::new(
            ctx.install_id,
            ctx.triple,
            ctx.dat_id,
            block_abs_offset,
        );
        if let Some(cached) = ctx.cache.get(key) {
            out.extend_from_slice(&cached);
            return Ok(());
        }
    }
    // The `cache_ctx` parameter is unused when the feature is off;
    // the binding is consumed by the inner `cfg`'d block above only
    // when the feature is on. Drop the unused-variable warning on
    // the no-feature build with an explicit `_`.
    #[cfg(not(feature = "block-cache"))]
    let _ = cache_ctx;

    reader.seek(SeekFrom::Start(block_abs_offset))?;

    let bh = BlockHeader::read(reader).map_err(|e| Error::decode("block_header", &e))?;
    if bh.size != BLOCK_HEADER_SIZE {
        return Err(Error::BlockHeaderSizeMismatch {
            expected: BLOCK_HEADER_SIZE,
            found: bh.size,
        });
    }

    // The authoritative uncompressed-size source is the BlockHeader's
    // `block_data_size` field — Lumina's `SqPackStream.ReadFileBlock`
    // validates against this rather than the `BlockInfo` row, and real
    // shipping files have been observed where `bi.uncompressed_size` carries
    // a sentinel or padded value distinct from the actual decompressed length.
    // Bound the field against `MAX_BLOCK_DATA_SIZE` here so both the
    // compressed and uncompressed-sentinel branches inherit the OOM guard.
    if bh.block_data_size > MAX_BLOCK_DATA_SIZE {
        return Err(Error::DecompressedSizeMismatch {
            context: "block",
            expected: MAX_BLOCK_DATA_SIZE,
            found: bh.block_data_size,
        });
    }
    let expected_uncompressed = bh.block_data_size;
    let decompressed = bh.dat_block_type != BLOCK_TYPE_UNCOMPRESSED;
    trace!(
        block_abs_offset,
        compressed_size, expected_uncompressed, decompressed, "reading block"
    );

    let prev_len = out.len();
    if decompressed {
        // Raw DEFLATE payload. `compressed_size` is the all-in on-disk
        // footprint of this block (header + payload). The actual compressed
        // payload byte count is `compressed_size - BLOCK_HEADER_SIZE`;
        // the header has already been read above.
        if compressed_size <= BLOCK_HEADER_SIZE {
            return Err(Error::InvalidBlockLayout {
                context: "block_info.compressed_size",
                message: format!(
                    "compressed block at offset {block_abs_offset} declared zero compressed_size",
                ),
            });
        }
        let payload_size = (compressed_size - BLOCK_HEADER_SIZE) as usize;
        let mut compressed = vec![0u8; payload_size];
        reader.read_exact(&mut compressed)?;
        let mut decoder = DeflateDecoder::new(&compressed[..]);
        // Cap the decoder's *output* (not just the initial allocation) so a
        // zip-bomb DEFLATE stream that produces more bytes than declared
        // cannot grow `tmp` past `expected + 1`. The `+1` lets the decoder
        // overproduce by one byte before `take()` returns; the post-decode
        // `produced != expected_uncompressed` check below then surfaces the
        // overproduction as `DecompressedSizeMismatch`. Without this cap a
        // malicious payload could OOM the parser even though the initial
        // `Vec::with_capacity` is bounded by `MAX_BLOCK_DATA_SIZE`.
        let expected = expected_uncompressed as usize;
        let mut tmp = Vec::with_capacity(expected);
        decoder
            .by_ref()
            .take((expected as u64) + 1)
            .read_to_end(&mut tmp)?;
        out.extend_from_slice(&tmp);
    } else {
        // Uncompressed sentinel: payload bytes follow the BlockHeader
        // directly; their length is `bh.block_data_size`. The on-disk
        // footprint of this block is `compressed_size` bytes (header +
        // payload), so the declared payload length must not exceed
        // `compressed_size - BLOCK_HEADER_SIZE`. An adversarial value
        // (e.g. 0xFFFF_FFFF) would otherwise drive `Vec::resize` to OOM
        // before any per-byte read could fail on EOF.
        let expected_payload = compressed_size.saturating_sub(BLOCK_HEADER_SIZE);
        if bh.block_data_size > expected_payload {
            return Err(Error::DecompressedSizeMismatch {
                context: "block",
                expected: expected_payload,
                found: bh.block_data_size,
            });
        }
        let n = bh.block_data_size as usize;
        out.resize(prev_len + n, 0);
        reader.read_exact(&mut out[prev_len..])?;
    }

    #[allow(clippy::cast_possible_truncation)]
    let produced = (out.len() - prev_len) as u32;
    if produced != expected_uncompressed {
        return Err(Error::DecompressedSizeMismatch {
            context: "block",
            expected: expected_uncompressed,
            found: produced,
        });
    }

    // Cache insertion — runs only on the success path so a mid-decode
    // error (size mismatch, OOM-style overproduction, structurally
    // invalid block) never pollutes the cache with a partial payload.
    // The slice borrowed off `out[prev_len..]` is copied into a fresh
    // `Vec<u8>` so the cached `Arc` doesn't alias the caller's
    // output buffer — the caller is free to reuse / extend / clear
    // `out` after the call without affecting the cached entry.
    #[cfg(feature = "block-cache")]
    if let Some(ctx) = cache_ctx {
        let key = crate::block_cache::BlockKey::new(
            ctx.install_id,
            ctx.triple,
            ctx.dat_id,
            block_abs_offset,
        );
        let cached: std::sync::Arc<Vec<u8>> = std::sync::Arc::new(out[prev_len..].to_vec());
        // Discard the previous value (if any) — the duplicate-inflate
        // race documented in [`crate::block_cache`] means the second
        // insert wins; the first call's `Arc<Vec<u8>>` drops to
        // refcount zero when its temporary owner returns.
        let _ = ctx.cache.put(key, cached);
    }

    Ok(())
}

/// Upper bound on the per-block compressed-size table allocated for a
/// Model-kind payload. The 11 declared sections (stack, runtime, three per
/// LOD × three LODs) each carry a `u16` block count; the sum can in
/// principle reach `11 * u16::MAX`, but real shipping models stay below a
/// few hundred blocks. The cap rejects adversarial inputs that would drive
/// `Vec::with_capacity` toward a multi-GiB allocation before the per-block
/// read loop ever fails on EOF.
const MAX_MODEL_BLOCKS: usize = 1024;

/// Upper bound on the per-mip `LodBlock` row count for a Texture-kind
/// payload. Lumina's observed maximum is 18 (cube-map textures); the cap
/// here is two orders of magnitude looser so any future format variant
/// still parses while still bounding the `Vec::with_capacity` allocation.
const MAX_TEXTURE_LOD_BLOCKS: usize = 1024;

/// Upper bound on the mip-header preamble size (in bytes) emitted before
/// the first mip's blocks. Real `.tex` files use an ~80-byte `TexHeader`;
/// the cap here is 1 MiB so any future format variant still parses while
/// bounding the `Vec::resize` allocation against an adversarial
/// `LodBlock::compressed_offset`.
const MAX_TEXTURE_PREAMBLE_SIZE: u32 = 1 << 20;

/// Upper bound on the aggregate per-mip `block_count` sum that drives the
/// `Vec::with_capacity` for the per-block u16 compressed-size table.
/// Sized to match [`MAX_MODEL_BLOCKS`] — Lumina's observed maximum for
/// real shipping textures is ~900 blocks (18 cube-map LODs × ~50
/// blocks/mip), so the 1024 cap leaves a tight headroom while bounding
/// an adversarial `LodBlock.block_count` (a u32 — `0xFFFFFFFF` would
/// otherwise drive `Vec::with_capacity` to ~8 GiB).
const MAX_TEXTURE_TOTAL_BLOCKS: u64 = 1024;

/// Decode every block of a Model-kind payload at `byte_offset`, appending
/// the concatenated decompressed bytes to `out` in declared section order
/// (stack, runtime, then per-LOD vertex / edge-geometry-vertex / index).
///
/// The reader is positioned at `byte_offset` on entry (any position is
/// acceptable — the function seeks to the file start before parsing the
/// extended header).
///
/// The Model file's on-disk layout is:
///
/// ```text
///   off                            size                    field
///   0                              208                     ModelHeader
///   208                            2 * used_number_of_blocks  per-block compressed-size table (u16[])
///   header.size                    -                       block-data base (section offsets are relative to this)
/// ```
///
/// Each section seeks to `block_data_base + section_offset`, reads its
/// `block_num` blocks consecutively, and advances by the corresponding
/// entry of the per-block u16 size table.
///
/// On success, returns a [`ModelMetadata`] populated from the dat-layer
/// extended header plus the per-section decompressed sizes the walker
/// observes. The caller surfaces this on [`ModelPayload::metadata`] so
/// downstream consumers can synthesise the 68-byte
/// [`crate::mdl::ModelFileHeader`] prefix via
/// [`ModelPayload::lumina_bytes`].
fn read_model_blocks<R: Read + Seek>(
    reader: &mut R,
    byte_offset: u64,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<ModelMetadata> {
    let _span = debug_span!(span_names::READ_MODEL).entered();

    reader.seek(SeekFrom::Start(byte_offset))?;
    let header = ModelHeader::read(reader).map_err(|e| Error::decode("model_header", &e))?;
    if header.size < MODEL_HEADER_SIZE {
        return Err(Error::InvalidHeaderSize {
            field: "model_header.size",
            value: header.size,
            minimum: MODEL_HEADER_SIZE,
        });
    }

    // Why: the per-section sum is authoritative for sizing the compressed-size
    // table — Lumina reads exactly that many u16 entries and never consults
    // `used_number_of_blocks`. Real game data has `used_number_of_blocks` >
    // sum (observed 37 vs 6, 65 vs 6 in retail bgcommon Models); the extra
    // u16 slots are unused padding accounted for by `header.size` and are
    // skipped when we seek to `block_data_base`.
    let total_blocks_sum: u32 = u32::from(header.stack_block_num)
        + u32::from(header.runtime_block_num)
        + header
            .vertex_buffer_block_num
            .iter()
            .map(|&n| u32::from(n))
            .sum::<u32>()
        + header
            .edge_geometry_vertex_buffer_block_num
            .iter()
            .map(|&n| u32::from(n))
            .sum::<u32>()
        + header
            .index_buffer_block_num
            .iter()
            .map(|&n| u32::from(n))
            .sum::<u32>();
    let total_blocks = total_blocks_sum as usize;
    if total_blocks > MAX_MODEL_BLOCKS {
        return Err(Error::InvalidBlockLayout {
            context: "model.block_count",
            message: format!("block count {total_blocks} exceeds maximum {MAX_MODEL_BLOCKS}"),
        });
    }
    trace!(
        used_number_of_blocks = header.used_number_of_blocks,
        total_blocks_sum, "model header parsed",
    );

    // Per-block compressed-size table sits immediately after the
    // ModelHeader struct. Reader is already at that position.
    let mut compressed_sizes = vec![0u16; total_blocks];
    for slot in &mut compressed_sizes {
        let mut buf = [0u8; 2];
        reader.read_exact(&mut buf)?;
        *slot = u16::from_le_bytes(buf);
    }

    let block_data_base = byte_offset
        .checked_add(u64::from(header.size))
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "model.block_data_base",
            message: format!(
                "byte_offset {byte_offset} + header.size {} overflows u64",
                header.size
            ),
        })?;

    // Walk the 11 sections in declared order: stack, runtime, then
    // (vertex, edge_geometry_vertex, index) per LOD. Capture per-section
    // *decompressed* byte counts so we can synthesise the file-header
    // prefix's `stack_size` / `runtime_size` / per-LOD offset / size
    // arrays the way Lumina's SqPackStream.ReadModelFile does (tracking
    // `ms.Position` between sections).
    let sections = model_section_table(&header);
    let mut current_block: usize = 0;
    let mut section_sizes = [0u32; 11];
    for (i, &(section_offset, block_num)) in sections.iter().enumerate() {
        let before = out.len();
        walk_model_section(
            reader,
            out,
            &compressed_sizes,
            &mut current_block,
            block_data_base,
            section_offset,
            block_num,
            cache_ctx,
        )?;
        let written = out.len() - before;
        // The per-section sum across the 11 walked sections is the same
        // as `out.len()` after the walk. Each section's contribution
        // fits in `u32` because `MAX_MODEL_BLOCKS * MAX_BLOCK_BYTES` is
        // bounded below `u32::MAX`; cast is checked.
        section_sizes[i] = u32::try_from(written).map_err(|_| Error::InvalidBlockLayout {
            context: "model.section_size",
            message: format!("section {i} decompressed size {written} exceeds u32::MAX"),
        })?;
    }

    Ok(build_model_metadata(&header, &section_sizes))
}

/// Build the [`ModelMetadata`] surfaced on [`ModelPayload`] from the
/// on-disk extended header + observed per-section decompressed sizes.
///
/// `section_sizes` follows the same 11-section declared order as
/// [`model_section_table`]: `[stack, runtime, v0, e0, i0, v1, e1, i1,
/// v2, e2, i2]`. The per-LOD `vertex_offset[i]` / `index_offset[i]`
/// values are reconstructed file-relative byte offsets (measured from
/// the start of the synthesised `.mdl` file — i.e. they include the
/// 68-byte prefix), with Lumina's dedupe rule applied: a LOD whose
/// computed offset equals the previous LOD's offset surfaces as `0`
/// (the empty-vertex-buffer / shared-with-previous-LOD sentinel).
fn build_model_metadata(header: &ModelHeader, section_sizes: &[u32; 11]) -> ModelMetadata {
    // Section index map (matches model_section_table order):
    //   [0]  stack
    //   [1]  runtime
    //   [2]  v0   [3]  e0   [4]  i0
    //   [5]  v1   [6]  e1   [7]  i1
    //   [8]  v2   [9]  e2   [10] i2
    // The 11-section invariant is enforced by the `[u32; 11]` array type;
    // the assert pins it explicitly for the magic indexing below.
    assert!(
        section_sizes.len() == 11,
        "model section table is fixed at 11 entries (stack + runtime + (v, e, i) * 3)"
    );
    let stack_size = section_sizes[0];
    let runtime_size = section_sizes[1];
    // Per-LOD vertex / index buffer sizes: each LOD contributes a
    // (v, e, i) triple starting at section index 2, so v[L] is at
    // 2 + L*3, e[L] at 2 + L*3 + 1, and i[L] at 2 + L*3 + 2.
    let vertex_buffer_size = [section_sizes[2], section_sizes[5], section_sizes[8]];
    let index_buffer_size = [section_sizes[4], section_sizes[7], section_sizes[10]];

    // Lumina synthesis: `ms.Seek( 0x44, SeekOrigin.Begin )` then writes
    // stack ++ runtime ++ (v ++ e ++ i)*3. The vertex / index data
    // offsets it records are `ms.Position` at the start of each section
    // (i.e. include the 68-byte prefix). We replicate that by walking
    // the cursor file-relative.
    let prefix_len = u32::try_from(crate::mdl::MODEL_FILE_HEADER_SIZE).expect("0x44 fits u32");
    let mut cursor = prefix_len + stack_size + runtime_size;
    let mut vertex_offset = [0u32; 3];
    let mut index_offset = [0u32; 3];
    for lod in 0..3 {
        let v_size = vertex_buffer_size[lod];
        // Edge-geometry-vertex buffer for this LOD — sits between the
        // vertex (`2 + lod * 3`) and index (`2 + lod * 3 + 2`) sections.
        let e_size = section_sizes[2 + lod * 3 + 1];
        let i_size = index_buffer_size[lod];

        // Lumina dedupes: only record the offset when this LOD's vertex
        // buffer doesn't share a position with the previous LOD's
        // buffer. `lod == 0` is always recorded.
        let current_vertex_offset = cursor;
        if header.vertex_buffer_block_num[lod] != 0
            && (lod == 0 || current_vertex_offset != vertex_offset[lod - 1])
        {
            vertex_offset[lod] = current_vertex_offset;
        }
        cursor += v_size;
        cursor += e_size;

        let current_index_offset = cursor;
        if header.index_buffer_block_num[lod] != 0
            && (lod == 0 || current_index_offset != index_offset[lod - 1])
        {
            index_offset[lod] = current_index_offset;
        }
        cursor += i_size;
    }

    ModelMetadata {
        version: header.version,
        stack_size,
        runtime_size,
        vertex_declaration_count: header.vertex_declaration_num,
        material_count: header.material_num,
        vertex_offset,
        index_offset,
        vertex_buffer_size,
        index_buffer_size,
        lod_count: header.num_lods,
        enable_index_buffer_streaming: header.index_buffer_streaming_enabled,
        enable_edge_geometry: header.edge_geometry_enabled,
    }
}

/// Build the 11-entry `(section_offset, block_num)` table for a parsed
/// `ModelHeader`. Declared order: stack, runtime, then
/// `(vertex, edge_geometry_vertex, index)` per LOD.
fn model_section_table(header: &ModelHeader) -> [(u32, u16); 11] {
    [
        (header.stack_offset, header.stack_block_num),
        (header.runtime_offset, header.runtime_block_num),
        (
            header.vertex_buffer_offset[0],
            header.vertex_buffer_block_num[0],
        ),
        (
            header.edge_geometry_vertex_buffer_offset[0],
            header.edge_geometry_vertex_buffer_block_num[0],
        ),
        (
            header.index_buffer_offset[0],
            header.index_buffer_block_num[0],
        ),
        (
            header.vertex_buffer_offset[1],
            header.vertex_buffer_block_num[1],
        ),
        (
            header.edge_geometry_vertex_buffer_offset[1],
            header.edge_geometry_vertex_buffer_block_num[1],
        ),
        (
            header.index_buffer_offset[1],
            header.index_buffer_block_num[1],
        ),
        (
            header.vertex_buffer_offset[2],
            header.vertex_buffer_block_num[2],
        ),
        (
            header.edge_geometry_vertex_buffer_offset[2],
            header.edge_geometry_vertex_buffer_block_num[2],
        ),
        (
            header.index_buffer_offset[2],
            header.index_buffer_block_num[2],
        ),
    ]
}

/// Walk one Model section: read `block_num` blocks starting at
/// `block_data_base + section_offset`, advancing the running cursor by each
/// block's u16-table compressed size. Caller threads `current_block`
/// through across sections so the size-table index keeps incrementing.
#[allow(clippy::too_many_arguments)]
fn walk_model_section<R: Read + Seek>(
    reader: &mut R,
    out: &mut Vec<u8>,
    compressed_sizes: &[u16],
    current_block: &mut usize,
    block_data_base: u64,
    section_offset: u32,
    block_num: u16,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    if block_num == 0 {
        return Ok(());
    }
    let mut cursor = block_data_base
        .checked_add(u64::from(section_offset))
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "model.section_offset",
            message: format!(
                "block_data_base {block_data_base} + section_offset {section_offset} overflows u64",
            ),
        })?;
    for j in 0..usize::from(block_num) {
        let cs =
            *compressed_sizes
                .get(*current_block)
                .ok_or_else(|| Error::InvalidBlockLayout {
                    context: "model.block_size_table",
                    message: format!(
                        "block index {} out of range (table len {})",
                        *current_block,
                        compressed_sizes.len()
                    ),
                })?;
        trace!(
            block_index = *current_block,
            section_block_index = j,
            section_offset,
            cursor,
            compressed_size = cs,
            "model block",
        );
        read_one_block_at(reader, cursor, u32::from(cs), out, cache_ctx)?;
        cursor = cursor
            .checked_add(u64::from(cs))
            .ok_or_else(|| Error::InvalidBlockLayout {
                context: "model.block_cursor",
                message: format!(
                    "block cursor overflow at section_offset {section_offset}, block {j}"
                ),
            })?;
        *current_block += 1;
    }
    Ok(())
}

/// Decode every block of a Texture-kind payload at `byte_offset`, writing
/// the optional mip-header preamble followed by the concatenated
/// decompressed bytes of every per-mip block into `out`.
///
/// The Texture file's on-disk layout is:
///
/// ```text
///   off                                   size                     field
///   0                                     24                       FileInfo
///   24                                    20 * file_info.number_of_blocks   LodBlock[] mip table
///   24 + 20 * number_of_blocks            2 * sum(lb.block_count)  per-block u16 compressed-size table
///   file_info.size                        -                        block-data base
///   block_data_base                       lod0.compressed_offset   optional mip-header preamble
///   block_data_base + lod[i].compressed_offset                     start of mip[i] block payloads
/// ```
#[allow(clippy::too_many_lines)]
fn read_texture_blocks<R: Read + Seek>(
    reader: &mut R,
    byte_offset: u64,
    file_info: &FileInfo,
    out: &mut Vec<u8>,
    cache_ctx: Option<&CacheContext>,
) -> Result<()> {
    let _span = debug_span!(span_names::READ_TEXTURE).entered();

    // Reader is positioned immediately after FileInfo on entry — same
    // contract as `read_standard_blocks`.
    let lod_count = file_info.number_of_blocks as usize;
    if lod_count > MAX_TEXTURE_LOD_BLOCKS {
        return Err(Error::InvalidBlockLayout {
            context: "texture.lod_count",
            message: format!("LodBlock count {lod_count} exceeds maximum {MAX_TEXTURE_LOD_BLOCKS}"),
        });
    }
    let mut lods = Vec::with_capacity(lod_count);
    for _ in 0..lod_count {
        let lb = LodBlock::read(reader).map_err(|e| Error::decode("texture_lod_block", &e))?;
        lods.push(lb);
    }

    // After the LodBlock table comes the per-block u16 compressed-size
    // table. Total entry count = sum of LodBlock.block_count across all
    // mips. The table lives inside the FileInfo::size header region.
    let total_blocks: u64 = lods.iter().map(|lb| u64::from(lb.block_count)).sum();
    if total_blocks > MAX_TEXTURE_TOTAL_BLOCKS {
        return Err(Error::InvalidBlockLayout {
            context: "texture.block_count",
            message: format!(
                "aggregate block_count {total_blocks} exceeds maximum {MAX_TEXTURE_TOTAL_BLOCKS}"
            ),
        });
    }
    let total_blocks_usize =
        usize::try_from(total_blocks).map_err(|_| Error::InvalidBlockLayout {
            context: "texture.block_count",
            message: format!("aggregate block_count {total_blocks} does not fit in usize"),
        })?;
    let mut compressed_sizes = vec![0u16; total_blocks_usize];
    for slot in &mut compressed_sizes {
        let mut buf = [0u8; 2];
        reader.read_exact(&mut buf)?;
        *slot = u16::from_le_bytes(buf);
    }

    let block_data_base = byte_offset
        .checked_add(u64::from(file_info.size))
        .ok_or_else(|| Error::InvalidBlockLayout {
            context: "texture.block_data_base",
            message: format!(
                "byte_offset {byte_offset} + file_info.size {} overflows u64",
                file_info.size
            ),
        })?;

    // Optional mip-header preamble: every byte from the block-data base
    // up to the first mip's `compressed_offset` is emitted verbatim
    // (Lumina copies these bytes through without decompression — they
    // hold the `.tex` mip-table struct).
    if let Some(first) = lods.first() {
        let preamble_size = first.compressed_offset;
        if preamble_size > MAX_TEXTURE_PREAMBLE_SIZE {
            return Err(Error::InvalidBlockLayout {
                context: "texture.preamble_size",
                message: format!(
                    "preamble size {preamble_size} exceeds maximum {MAX_TEXTURE_PREAMBLE_SIZE}"
                ),
            });
        }
        if preamble_size > 0 {
            trace!(preamble_size, "texture mip-header preamble",);
            reader.seek(SeekFrom::Start(block_data_base))?;
            let prev_len = out.len();
            let n = preamble_size as usize;
            out.resize(prev_len + n, 0);
            reader.read_exact(&mut out[prev_len..])?;
        }
    }

    // Walk each mip's run of blocks. Within a mip, blocks pack tightly:
    // the next block's offset is the running cursor + the u16 size table
    // entry. Across mips, restart the cursor at the mip's
    // `compressed_offset`.
    let mut current_block: usize = 0;
    for (mip_idx, lb) in lods.iter().enumerate() {
        let mut cursor = block_data_base
            .checked_add(u64::from(lb.compressed_offset))
            .ok_or_else(|| Error::InvalidBlockLayout {
                context: "texture.compressed_offset",
                message: format!(
                    "block_data_base {block_data_base} + lod[{mip_idx}].compressed_offset {} overflows u64",
                    lb.compressed_offset
                ),
            })?;
        trace!(
            mip_idx,
            mip_block_count = lb.block_count,
            compressed_offset = lb.compressed_offset,
            "texture mip",
        );
        for j in 0..lb.block_count {
            let cs =
                *compressed_sizes
                    .get(current_block)
                    .ok_or_else(|| Error::InvalidBlockLayout {
                        context: "texture.block_size_table",
                        message: format!(
                            "block index {current_block} out of range (table len {})",
                            compressed_sizes.len()
                        ),
                    })?;
            trace!(
                block_index = current_block,
                mip_block_index = j,
                cursor,
                compressed_size = cs,
                "texture block",
            );
            read_one_block_at(reader, cursor, u32::from(cs), out, cache_ctx)?;
            cursor =
                cursor
                    .checked_add(u64::from(cs))
                    .ok_or_else(|| Error::InvalidBlockLayout {
                        context: "texture.block_cursor",
                        message: format!("block cursor overflow at mip {mip_idx}, block {j}"),
                    })?;
            current_block += 1;
        }
    }

    Ok(())
}

/// One decoded block yielded by [`DatFileStream`].
///
/// Wraps the decompressed payload bytes plus the block's index in the file's
/// block table. The same `Vec<u8>` is returned for both compressed
/// (raw DEFLATE) and sentinel-uncompressed blocks — the distinction matters
/// for on-disk-byte accounting but not for the consumer of the decoded
/// stream.
///
/// `#[non_exhaustive]`: a future addition may surface per-block timing or
/// on-disk compressed-size metrics.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockChunk {
    /// Zero-based index of this block in the file's block table.
    pub index: u32,
    /// Decompressed (or uncompressed-sentinel) payload bytes.
    pub bytes: Vec<u8>,
}

/// Lazy block iterator returned by [`DatFile::read_file_streaming`].
///
/// Each [`Iterator::next`] call seeks to the next block in the file's block
/// table, reads the 16-byte [`BlockHeader`], decodes the block payload
/// (raw DEFLATE or stored uncompressed), and yields a [`BlockChunk`]. The
/// stream borrows the underlying reader mutably for its lifetime so
/// subsequent [`DatFile`] operations are blocked until the stream drops.
///
/// Only Standard-kind (and degenerate Empty-kind, which yields zero blocks)
/// payloads are supported — Model and Texture kinds are rejected by the
/// constructor with [`Error::UnsupportedFileKind`].
///
/// Block ordering: identical to the on-disk block-info table, which always
/// scans block 0 first. Per-block decoded length is validated against the
/// authoritative [`BlockHeader::block_data_size`]; the final cumulative
/// length is **not** cross-checked against [`FileInfo::raw_file_size`]
/// because the consumer chooses when to stop iterating. A caller that wants
/// the same "sum-of-blocks matches `raw_file_size`" guarantee
/// [`DatFile::read_file`] enforces should accumulate the chunk lengths and
/// compare against [`DatFileStream::raw_file_size`] after draining the
/// iterator.
pub struct DatFileStream<'a> {
    reader: &'a mut dyn ReadSeek,
    file_info: FileInfo,
    blocks: Vec<BlockInfo>,
    block_data_base: u64,
    cursor: usize,
    /// Cloned block-cache context from the originating [`DatFile`].
    /// The stream owns its own clone (cheap — the context is an
    /// `Arc<BlockCache>` plus a few small integers) so the borrow
    /// of `&'a mut dyn ReadSeek` doesn't fight the borrow of the
    /// parent [`DatFile`]'s `cache_ctx` field. [`None`] when the
    /// `block_cache` feature is off or the parent has no cache
    /// attached.
    cache_ctx: Option<CacheContext>,
}

impl DatFileStream<'_> {
    /// [`FileKind`] of the payload being streamed — always
    /// [`FileKind::Standard`] (Empty is treated as a zero-block Standard).
    #[must_use]
    pub fn kind(&self) -> FileKind {
        // For Empty-kind payloads, FileInfo::kind() would report Empty; we
        // expose Standard here because the streaming iterator's contract is
        // "Standard-shaped, possibly zero blocks". The kind discriminant the
        // consumer asked about — "is this a Standard-block stream?" — is
        // affirmatively yes in both cases.
        match self.file_info.kind() {
            FileKind::Empty => FileKind::Standard,
            other => other,
        }
    }

    /// Total uncompressed file size in bytes, as declared by the parsed
    /// [`FileInfo`].
    #[must_use]
    pub fn raw_file_size(&self) -> u32 {
        self.file_info.raw_file_size
    }

    /// Number of blocks in the file's block table — the total iteration
    /// count.
    #[must_use]
    pub fn block_count(&self) -> u32 {
        self.file_info.number_of_blocks
    }

    /// Parsed [`FileInfo`] for the file being streamed.
    #[must_use]
    pub fn file_info(&self) -> &FileInfo {
        &self.file_info
    }

    /// Read the next block's payload into a caller-supplied buffer.
    ///
    /// Equivalent to [`Iterator::next`] but writes into the supplied buffer
    /// (which is cleared on entry) instead of allocating a fresh `Vec`.
    /// Returns `Some(Ok(()))` after appending the block's bytes,
    /// `Some(Err(_))` on a decode failure, and `None` when the iteration
    /// exhausts.
    ///
    /// Buffer-reuse hot loop:
    ///
    /// ```ignore
    /// let mut scratch = Vec::with_capacity(64 * 1024);
    /// while let Some(res) = stream.read_block_into(&mut scratch) {
    ///     res?;
    ///     // process scratch.as_slice() — capacity is retained across iterations
    /// }
    /// ```
    pub fn read_block_into(&mut self, buf: &mut Vec<u8>) -> Option<Result<()>> {
        let bi = *self.blocks.get(self.cursor)?;
        self.cursor += 1;
        buf.clear();
        Some(read_one_block(
            &mut self.reader,
            self.block_data_base,
            bi,
            buf,
            self.cache_ctx.as_ref(),
        ))
    }
}

impl Iterator for DatFileStream<'_> {
    type Item = Result<BlockChunk>;

    fn next(&mut self) -> Option<Self::Item> {
        // cursor is bounded by blocks.len() which came from a u32 on disk
        // (FileInfo::number_of_blocks), so the cast back to u32 cannot truncate.
        #[allow(clippy::cast_possible_truncation)]
        let idx = self.cursor as u32;
        let bi = *self.blocks.get(self.cursor)?;
        self.cursor += 1;
        let mut bytes = Vec::with_capacity(usize::from(bi.uncompressed_size));
        let result = read_one_block(
            &mut self.reader,
            self.block_data_base,
            bi,
            &mut bytes,
            self.cache_ctx.as_ref(),
        );
        Some(result.map(|()| BlockChunk { index: idx, bytes }))
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        let remaining = self.blocks.len().saturating_sub(self.cursor);
        (remaining, Some(remaining))
    }
}

impl ExactSizeIterator for DatFileStream<'_> {}

impl std::fmt::Debug for DatFileStream<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DatFileStream")
            .field("file_info", &self.file_info)
            .field("block_count", &self.blocks.len())
            .field("block_data_base", &self.block_data_base)
            .field("cursor", &self.cursor)
            .finish_non_exhaustive()
    }
}

#[cfg(test)]
#[allow(clippy::doc_markdown)]
mod tests {
    use super::*;
    use crate::header::SQPACK_HEADER_SIZE;
    use crate::header::tests::make_sqpack_header;
    use flate2::Compression;
    use flate2::write::DeflateEncoder;
    use std::io::Write;

    // ----- helpers -----

    /// Build a minimal valid `.dat0`-shaped buffer: 1024-byte SqPack header
    /// (file_type=1) followed by `body`. Returns the full buffer; the body
    /// starts at offset 1024.
    fn make_dat_buf(body: &[u8]) -> Vec<u8> {
        let mut buf = make_sqpack_header(1); // 1 = Dat wire value
        buf.extend_from_slice(body);
        buf
    }

    /// Encode a FileInfo struct (24 bytes — six u32s).
    fn make_file_info(
        size: u32,
        kind_raw: u32,
        raw_file_size: u32,
        number_of_blocks: u32,
    ) -> Vec<u8> {
        let mut v = Vec::with_capacity(24);
        v.extend_from_slice(&size.to_le_bytes());
        v.extend_from_slice(&kind_raw.to_le_bytes());
        v.extend_from_slice(&raw_file_size.to_le_bytes());
        v.extend_from_slice(&0u32.to_le_bytes()); // unknown[0]
        v.extend_from_slice(&0u32.to_le_bytes()); // unknown[1]
        v.extend_from_slice(&number_of_blocks.to_le_bytes());
        v
    }

    fn make_block_info(offset: u32, compressed_size: u16, uncompressed_size: u16) -> Vec<u8> {
        let mut v = Vec::with_capacity(BLOCK_INFO_SIZE as usize);
        v.extend_from_slice(&offset.to_le_bytes());
        v.extend_from_slice(&compressed_size.to_le_bytes());
        v.extend_from_slice(&uncompressed_size.to_le_bytes());
        v
    }

    fn make_block_header(dat_block_type: u32, block_data_size: u32) -> Vec<u8> {
        let mut v = Vec::with_capacity(16);
        v.extend_from_slice(&16u32.to_le_bytes()); // size
        v.extend_from_slice(&0u32.to_le_bytes()); // _unknown
        v.extend_from_slice(&dat_block_type.to_le_bytes());
        v.extend_from_slice(&block_data_size.to_le_bytes());
        v
    }

    fn deflate_compress(input: &[u8]) -> Vec<u8> {
        let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
        enc.write_all(input).unwrap();
        enc.finish().unwrap()
    }

    /// Build a Standard-file payload region at offset 1024 (right after the
    /// SqPack header). `blocks` is `(payload_bytes, compressed)` — when
    /// `compressed` is `true` the payload is DEFLATE-encoded and emitted with
    /// dat_block_type=16000; otherwise it's stored raw with
    /// dat_block_type=32000.
    ///
    /// Returns the full `.dat` buffer and the byte offset of the FileInfo.
    fn make_standard_dat(blocks: &[(&[u8], bool)]) -> (Vec<u8>, u64) {
        let raw_total: u32 = blocks
            .iter()
            .map(|(b, _)| u32::try_from(b.len()).unwrap())
            .sum();
        let number_of_blocks = u32::try_from(blocks.len()).unwrap();

        // FileInfo.size = 24 + 8 * number_of_blocks (info + block table).
        let info_size = 24 + 8 * number_of_blocks;

        // Build the block-data section first so we can compute each block's
        // offset relative to the block-data base.
        let mut block_data = Vec::new();
        let mut block_infos = Vec::new();
        for (payload, compressed) in blocks {
            let offset = u32::try_from(block_data.len()).unwrap();
            let uncompressed_size = u16::try_from(payload.len()).unwrap();
            if *compressed {
                let encoded = deflate_compress(payload);
                // compressed_size is the all-in on-disk footprint of the block,
                // including the 16-byte BlockHeader. This matches the real game
                // file convention where bi.offset[N+1] = bi.offset[N] + bi.compressed_size[N].
                let compressed_size =
                    u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
                block_infos.push(make_block_info(offset, compressed_size, uncompressed_size));
                block_data
                    .extend_from_slice(&make_block_header(16000, u32::from(uncompressed_size)));
                block_data.extend_from_slice(&encoded);
            } else {
                // All-in size: header (16) + payload.
                let compressed_size =
                    u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
                block_infos.push(make_block_info(offset, compressed_size, uncompressed_size));
                block_data.extend_from_slice(&make_block_header(
                    BLOCK_TYPE_UNCOMPRESSED,
                    u32::from(uncompressed_size),
                ));
                block_data.extend_from_slice(payload);
            }
        }

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 2, raw_total, number_of_blocks));
        for bi in &block_infos {
            body.extend_from_slice(bi);
        }
        // Block data section starts at FileInfo offset + info_size.
        // The body so far is exactly info_size bytes long; append the block data.
        debug_assert_eq!(body.len(), info_size as usize);
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        (buf, u64::from(SQPACK_HEADER_SIZE))
    }

    /// Build a minimal Model-kind payload region: one populated section
    /// (`stack`) holding `blocks` payloads in declared order. Every other
    /// section is empty (block_num=0). `blocks` follows the same
    /// `(payload, compressed)` convention as `make_standard_dat`.
    ///
    /// Returns the full `.dat` buffer and the byte offset of the
    /// ModelHeader.
    fn make_model_dat(blocks: &[(&[u8], bool)]) -> (Vec<u8>, u64) {
        let used_number_of_blocks = u32::try_from(blocks.len()).unwrap();
        let header_size = MODEL_HEADER_SIZE + 2 * used_number_of_blocks;

        let mut block_data = Vec::new();
        let mut compressed_sizes: Vec<u16> = Vec::new();
        for (payload, compressed) in blocks {
            let uncompressed_size = u32::try_from(payload.len()).unwrap();
            if *compressed {
                let encoded = deflate_compress(payload);
                let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
                compressed_sizes.push(cs);
                block_data.extend_from_slice(&make_block_header(16000, uncompressed_size));
                block_data.extend_from_slice(&encoded);
            } else {
                let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
                compressed_sizes.push(cs);
                block_data.extend_from_slice(&make_block_header(
                    BLOCK_TYPE_UNCOMPRESSED,
                    uncompressed_size,
                ));
                block_data.extend_from_slice(payload);
            }
        }

        let block_num = u16::try_from(blocks.len()).unwrap();
        let raw_total: u32 = blocks
            .iter()
            .map(|(b, _)| u32::try_from(b.len()).unwrap())
            .sum();

        let mut header = Vec::with_capacity(MODEL_HEADER_SIZE as usize);
        header.extend_from_slice(&header_size.to_le_bytes()); // size
        header.extend_from_slice(&3u32.to_le_bytes()); // type = Model
        header.extend_from_slice(&raw_total.to_le_bytes()); // raw_file_size
        header.extend_from_slice(&used_number_of_blocks.to_le_bytes()); // number_of_blocks
        header.extend_from_slice(&used_number_of_blocks.to_le_bytes()); // used_number_of_blocks
        header.extend_from_slice(&0u32.to_le_bytes()); // version
        header.extend_from_slice(&0u32.to_le_bytes()); // stack_size
        header.extend_from_slice(&0u32.to_le_bytes()); // runtime_size
        // vertex_buffer_size[3] + edge_geometry_vertex_buffer_size[3] + index_buffer_size[3]
        for _ in 0..9 {
            header.extend_from_slice(&0u32.to_le_bytes());
        }
        header.extend_from_slice(&0u32.to_le_bytes()); // compressed_stack_memory_size
        header.extend_from_slice(&0u32.to_le_bytes()); // compressed_runtime_memory_size
        for _ in 0..9 {
            header.extend_from_slice(&0u32.to_le_bytes());
        }
        // Offsets: stack_offset=0 (relative to block_data_base), runtime + all others zero.
        header.extend_from_slice(&0u32.to_le_bytes()); // stack_offset
        header.extend_from_slice(&0u32.to_le_bytes()); // runtime_offset
        for _ in 0..9 {
            header.extend_from_slice(&0u32.to_le_bytes()); // vertex/edge/index offsets per LOD
        }
        // Block indices (u16). All zero — irrelevant since only stack has blocks.
        header.extend_from_slice(&0u16.to_le_bytes()); // stack_block_index
        header.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_index
        for _ in 0..9 {
            header.extend_from_slice(&0u16.to_le_bytes());
        }
        // Block counts. stack_block_num = `block_num`, everything else 0.
        header.extend_from_slice(&block_num.to_le_bytes()); // stack_block_num
        header.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_num
        for _ in 0..9 {
            header.extend_from_slice(&0u16.to_le_bytes());
        }
        header.extend_from_slice(&0u16.to_le_bytes()); // vertex_declaration_num
        header.extend_from_slice(&0u16.to_le_bytes()); // material_num
        header.push(0); // num_lods
        header.push(0); // index_buffer_streaming_enabled
        header.push(0); // edge_geometry_enabled
        header.push(0); // padding
        debug_assert_eq!(header.len(), MODEL_HEADER_SIZE as usize);

        let mut body = header;
        for cs in &compressed_sizes {
            body.extend_from_slice(&cs.to_le_bytes());
        }
        debug_assert_eq!(body.len(), header_size as usize);
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        (buf, u64::from(SQPACK_HEADER_SIZE))
    }

    /// Encode a [`LodBlock`] entry (20 bytes — five u32s).
    fn make_lod_block(
        compressed_offset: u32,
        compressed_size: u32,
        decompressed_size: u32,
        block_offset: u32,
        block_count: u32,
    ) -> Vec<u8> {
        let mut v = Vec::with_capacity(LOD_BLOCK_SIZE as usize);
        v.extend_from_slice(&compressed_offset.to_le_bytes());
        v.extend_from_slice(&compressed_size.to_le_bytes());
        v.extend_from_slice(&decompressed_size.to_le_bytes());
        v.extend_from_slice(&block_offset.to_le_bytes());
        v.extend_from_slice(&block_count.to_le_bytes());
        v
    }

    /// Build a minimal Texture-kind payload region with a single mip
    /// holding `blocks` payloads in order, optionally prefixed by a
    /// `preamble` (verbatim mip-header bytes). `blocks` follows the same
    /// `(payload, compressed)` convention as `make_standard_dat`.
    ///
    /// Returns the full `.dat` buffer and the byte offset of the FileInfo.
    fn make_texture_dat(preamble: &[u8], blocks: &[(&[u8], bool)]) -> (Vec<u8>, u64) {
        let block_count = u32::try_from(blocks.len()).unwrap();
        let info_size = 24 + LOD_BLOCK_SIZE + 2 * block_count;

        let mut block_data = Vec::new();
        block_data.extend_from_slice(preamble);
        let mut compressed_sizes: Vec<u16> = Vec::new();
        let mut decompressed_total: u32 = 0;
        for (payload, compressed) in blocks {
            let uncompressed_size = u32::try_from(payload.len()).unwrap();
            decompressed_total += uncompressed_size;
            if *compressed {
                let encoded = deflate_compress(payload);
                let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
                compressed_sizes.push(cs);
                block_data.extend_from_slice(&make_block_header(16000, uncompressed_size));
                block_data.extend_from_slice(&encoded);
            } else {
                let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
                compressed_sizes.push(cs);
                block_data.extend_from_slice(&make_block_header(
                    BLOCK_TYPE_UNCOMPRESSED,
                    uncompressed_size,
                ));
                block_data.extend_from_slice(payload);
            }
        }
        let mip_compressed_size: u32 = compressed_sizes.iter().map(|&cs| u32::from(cs)).sum();

        let preamble_len = u32::try_from(preamble.len()).unwrap();
        let raw_total = preamble_len + decompressed_total;

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 4, raw_total, 1));
        body.extend_from_slice(&make_lod_block(
            preamble_len,
            mip_compressed_size,
            decompressed_total,
            0,
            block_count,
        ));
        for cs in &compressed_sizes {
            body.extend_from_slice(&cs.to_le_bytes());
        }
        debug_assert_eq!(body.len(), info_size as usize);
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        (buf, u64::from(SQPACK_HEADER_SIZE))
    }

    // ----- header & opening -----

    #[test]
    fn opens_valid_dat_buffer() {
        let buf = make_dat_buf(&[]);
        let dat = DatFile::from_buffer(buf).unwrap();
        let header = dat.header().expect("dat0 fixture has SqPack header");
        assert_eq!(header.size, SQPACK_HEADER_SIZE);
        assert_eq!(header.typed_file_type(), SqPackFileType::Dat);
    }

    #[test]
    fn rejects_bad_magic() {
        let mut buf = make_dat_buf(&[]);
        buf[0] = b'X';
        let err = DatFile::from_buffer(buf).unwrap_err();
        assert!(matches!(err, Error::InvalidMagic));
    }

    #[test]
    fn rejects_wrong_file_type() {
        // file_type = 2 (Index) but caller expects Dat (1).
        let mut buf = make_sqpack_header(2);
        buf.extend_from_slice(&[0u8; 32]);
        let err = DatFile::from_buffer(buf).unwrap_err();
        match err {
            Error::UnexpectedFileType { expected, found } => {
                assert_eq!(expected, 1);
                assert_eq!(found, 2);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_truncated_sqpack_header() {
        let buf = vec![0u8; 4];
        let err = DatFile::from_buffer(buf).unwrap_err();
        assert!(matches!(err, Error::Decode { context, .. } if context == "sqpack_header"));
    }

    #[test]
    fn dat1_tolerates_missing_sqpack_header() {
        // A `.dat1` companion written by the patcher carries no SqPack
        // header at offset 0 — just the raw FileInfo records the index
        // points at. Opening with dat_id > 0 must succeed and surface
        // `header()` as `None`; opening the same bytes with dat_id == 0
        // (the strict path) must still reject them as `InvalidMagic`.
        let bytes_without_header: Vec<u8> = (0u8..32).collect();
        let dat = DatFile::from_buffer_at(bytes_without_header.clone(), 1)
            .expect("dat1 with no SqPack header should open");
        assert!(dat.header().is_none());
        // Strict path rejects identically.
        let err = DatFile::from_buffer_at(bytes_without_header, 0).unwrap_err();
        assert!(matches!(err, Error::InvalidMagic));
    }

    #[test]
    fn dat1_with_well_formed_header_still_parses_it() {
        // A `.dat1` that *does* carry a SqPack header (a legitimate
        // shape — the patcher's `SqpkHeader` chunk can target any dat)
        // must still expose it via `header()`. The lenient open path
        // is a fallback for the headerless case, not a blanket skip.
        let buf = make_dat_buf(&[]);
        let dat = DatFile::from_buffer_at(buf, 1).expect("dat1 with header should open");
        let header = dat.header().expect("well-formed header should round-trip");
        assert_eq!(header.size, SQPACK_HEADER_SIZE);
    }

    // ----- FileKind round-trip -----

    #[test]
    fn file_kind_round_trip() {
        for raw in [1u32, 2, 3, 4] {
            assert_eq!(FileKind::from_raw(raw).as_raw(), raw);
        }
    }

    #[test]
    fn file_kind_unknown_preserves_raw() {
        let k = FileKind::from_raw(99);
        assert!(matches!(k, FileKind::Unknown(99)));
        assert_eq!(k.as_raw(), 99);
    }

    #[test]
    fn file_kind_zero_is_unknown() {
        let k = FileKind::from_raw(0);
        assert!(matches!(k, FileKind::Unknown(0)));
    }

    #[test]
    fn file_info_kind_accessor() {
        let fi = FileInfo {
            size: 24,
            kind_raw: 2,
            raw_file_size: 0,
            unknown: [0, 0],
            number_of_blocks: 0,
        };
        assert_eq!(fi.kind(), FileKind::Standard);
    }

    // ----- Standard-file reads -----

    #[test]
    fn reads_single_uncompressed_block() {
        let payload = b"hello, world! this is one block.".to_vec();
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn reads_single_compressed_block() {
        // Use enough bytes that DEFLATE actually has something to compress.
        let payload: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), true)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn reads_multiple_blocks_mixed_compression() {
        let a: Vec<u8> = vec![0xAA; 200];
        let b: Vec<u8> = (0..500u32).map(|i| (i % 13) as u8).collect();
        let c: Vec<u8> = vec![0xCC; 64];
        let blocks = [
            (a.as_slice(), false),
            (b.as_slice(), true),
            (c.as_slice(), false),
        ];
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        let mut expected = Vec::new();
        expected.extend_from_slice(&a);
        expected.extend_from_slice(&b);
        expected.extend_from_slice(&c);
        assert_eq!(out, expected);
        assert_eq!(out.len(), a.len() + b.len() + c.len());
    }

    #[test]
    fn reads_empty_file_kind_returns_empty_vec() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 0, 0)); // kind = Empty
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert!(out.is_empty());
    }

    // ----- unsupported kinds -----

    #[test]
    fn rejects_unknown_file_kind() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 42, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::UnsupportedFileKind { kind } => assert_eq!(kind, FileKind::Unknown(42)),
            other => panic!("unexpected: {other:?}"),
        }
    }

    // ----- structural failures -----

    #[test]
    fn rejects_block_header_with_wrong_size() {
        // One uncompressed block, then go corrupt its BlockHeader.size field.
        let payload = vec![0u8; 16];
        let (mut buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        // BlockHeader sits at info_off + info_size (= 24 + 8 for one block).
        let bh_off = usize::try_from(info_off + 24 + 8).unwrap();
        // size field is the first u32 of the BlockHeader.
        buf[bh_off..bh_off + 4].copy_from_slice(&15u32.to_le_bytes());
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(info_off).unwrap_err();
        match err {
            Error::BlockHeaderSizeMismatch { expected, found } => {
                assert_eq!(expected, 16);
                assert_eq!(found, 15);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn tolerates_block_info_uncompressed_size_disagreeing_with_block_header() {
        // Per Lumina the BlockHeader.block_data_size is authoritative; the
        // BlockInfo.uncompressed_size field is allowed to disagree (real
        // shipping files have been observed where it carries sentinel/padded
        // values). The reader should ignore `bi.uncompressed_size` and use
        // `bh.block_data_size` to size the output.
        let payload = b"abcdefgh".to_vec(); // 8 bytes
        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 8));
        block_data.extend_from_slice(&payload);

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, 8, 1));
        // BlockInfo with a deliberately-wrong uncompressed_size (1) — must be ignored.
        // compressed_size is the all-in block footprint: 16-byte header + 8-byte payload = 24.
        body.extend_from_slice(&make_block_info(0, 24, 1));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn rejects_decompressed_size_mismatch() {
        // Build a deflate-compressed block whose decoded length doesn't match
        // bi.uncompressed_size. We compress 32 bytes but claim 64.
        let payload: Vec<u8> = (0..32u8).collect();
        let encoded = deflate_compress(&payload);

        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(16000, 64)); // header lies too
        block_data.extend_from_slice(&encoded);

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, 64, 1));
        // compressed_size is all-in (header + payload).
        body.extend_from_slice(&make_block_info(
            0,
            u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap(),
            64,
        ));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(matches!(
            err,
            Error::DecompressedSizeMismatch {
                context: "block",
                expected: 64,
                found: 32
            }
        ));
    }

    #[test]
    fn rejects_raw_file_size_mismatch_at_end() {
        // FileInfo.raw_file_size lies relative to the sum of block uncompressed sizes.
        let payload = vec![0xABu8; 16];
        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 16));
        block_data.extend_from_slice(&payload);

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, 32, 1)); // lies: claims 32
        // compressed_size all-in: 16 (header) + 16 (payload) = 32.
        body.extend_from_slice(&make_block_info(0, 32, 16));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(matches!(
            err,
            Error::DecompressedSizeMismatch {
                context: "file",
                expected: 32,
                found: 16
            }
        ));
    }

    #[test]
    fn decode_error_context_file_info() {
        // FileInfo is 24 bytes; supply only 10 past the header.
        let mut buf = make_sqpack_header(1);
        buf.extend_from_slice(&[0u8; 10]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "file_info"),
            Error::Io { .. } => {}
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn decode_error_context_block_info() {
        // FileInfo claims 2 blocks, but the file is truncated mid-table.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 16, 2, 16, 2));
        // compressed_size all-in for the first block (header + payload = 32).
        body.extend_from_slice(&make_block_info(0, 32, 16));
        // Truncate: leave only 2 bytes of the second 8-byte block-info row.
        body.extend_from_slice(&[0u8; 2]);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "block_info"),
            Error::Io { .. } => {}
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn decode_error_context_block_header() {
        // One block declared, but block_data section is empty.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, 4, 1));
        // compressed_size all-in: 16 + 4 = 20. Validation passes; the subsequent
        // seek to offset 0 finds nothing (block_data region is empty) → Decode error.
        body.extend_from_slice(&make_block_info(0, 20, 4));
        // No block data at all.
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::Decode { context, .. } => assert_eq!(context, "block_header"),
            Error::Io { .. } => {}
            other => panic!("unexpected: {other:?}"),
        }
    }

    // ----- small accessor / size invariants -----

    #[test]
    fn block_header_size_constant_is_16() {
        assert_eq!(BLOCK_HEADER_SIZE, 16);
    }

    #[test]
    fn block_info_size_constant_is_8() {
        assert_eq!(BLOCK_INFO_SIZE, 8);
    }

    #[test]
    fn uncompressed_sentinel_constant_is_32000() {
        assert_eq!(BLOCK_TYPE_UNCOMPRESSED, 32000);
    }

    #[test]
    fn zero_block_standard_file_returns_empty_vec() {
        // Standard with raw_file_size=0 and no blocks — well-formed degenerate case.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 2, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert!(out.is_empty());
    }

    #[test]
    fn rejects_byte_offset_inside_sqpack_header() {
        // byte_offset = 0 would re-parse the SqPack magic as a FileInfo.
        let buf = make_dat_buf(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(0).unwrap_err();
        match err {
            Error::InvalidHeaderSize {
                field,
                value,
                minimum,
            } => {
                assert_eq!(field, "byte_offset");
                assert_eq!(value, 0);
                assert_eq!(minimum, SQPACK_HEADER_SIZE);
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_non_monotonic_block_offsets() {
        // Two blocks where the second's offset overlaps the first.
        let payload_a = b"AAAAAAAA".to_vec(); // 8 bytes
        let payload_b = b"BBBBBBBB".to_vec(); // 8 bytes

        let mut block_data = Vec::new();
        // Block 0 at offset 0: 16-byte header + 8-byte payload = 24 bytes total.
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 8));
        block_data.extend_from_slice(&payload_a);
        // Block 1 at offset 16 (overlaps block 0's payload region).
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 8));
        block_data.extend_from_slice(&payload_b);

        let info_size = 24 + 8 * 2;
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 2, 16, 2));
        // All-in compressed_size for block 0: 16 (header) + 8 (payload) = 24.
        // Block 1 at offset 16 overlaps block 0 (prev_end = 24 > 16).
        body.extend_from_slice(&make_block_info(0, 24, 8));
        body.extend_from_slice(&make_block_info(16, 24, 8)); // overlaps prev (which ends at 24)
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::InvalidBlockLayout { context, .. } => {
                assert_eq!(context, "block_info.offset");
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn rejects_zero_compressed_size_on_compressed_block() {
        // BlockHeader marks the block as DEFLATE (type != 32000) but the
        // BlockInfo row claims compressed_size = 0.
        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(16000, 8));
        // No payload bytes: compressed_size is 0.

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, 8, 1));
        body.extend_from_slice(&make_block_info(0, 0, 8));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        match err {
            Error::InvalidBlockLayout { context, .. } => {
                assert_eq!(context, "block_info.compressed_size");
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn read_file_into_reuses_buffer() {
        let payload = b"reuse me".to_vec();
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let mut out = Vec::with_capacity(256);
        let cap_before = out.capacity();
        // Pre-fill so we can verify the call clears.
        out.extend_from_slice(b"stale data");
        dat.read_file_into(info_off, &mut out).unwrap();
        assert_eq!(out, payload);
        // Capacity should be retained (the read fits in the existing capacity).
        assert!(out.capacity() >= cap_before);
    }

    #[test]
    fn many_blocks_concatenate_in_order() {
        // 10 uncompressed blocks of distinct byte values; verifies ordering.
        let payloads: Vec<Vec<u8>> = (0u8..10).map(|i| vec![i; 32]).collect();
        let blocks: Vec<(&[u8], bool)> = payloads.iter().map(|p| (p.as_slice(), false)).collect();
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        let mut expected = Vec::new();
        for p in &payloads {
            expected.extend_from_slice(p);
        }
        assert_eq!(out, expected);
        assert_eq!(out.len(), 10 * 32);
    }

    // ----- byte_offset past EOF -----

    #[test]
    fn byte_offset_past_eof_returns_io_error() {
        // A minimal dat buffer that ends right after the SqPack header (1024
        // bytes total). Seeking to offset 2048 — well past EOF — must fail with
        // Error::Io (BinRead propagates the seek / unexpected-EOF as an I/O
        // error when attempting to read the FileInfo at that position).
        let buf = make_dat_buf(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(2048).unwrap_err();
        assert!(
            matches!(err, Error::Io { .. } | Error::Decode { .. }),
            "expected Io or Decode, got {err:?}"
        );
    }

    // ----- malformed DEFLATE stream -----

    #[test]
    fn rejects_malformed_deflate_payload() {
        // Build a block that claims to be DEFLATE-compressed (dat_block_type !=
        // 32000) but whose payload bytes are not valid DEFLATE. The decoder
        // should surface this as Error::Io (flate2 propagates corrupt-stream
        // errors as io::Error which our From impl wraps into Error::Io).
        let garbage: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11, 0x22, 0x33];
        // compressed_size is all-in (header + payload).
        let compressed_size = u16::try_from(BLOCK_HEADER_SIZE as usize + garbage.len()).unwrap();
        let uncompressed_hint: u32 = 32;

        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(16000, uncompressed_hint));
        block_data.extend_from_slice(&garbage);

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24 + 8, 2, uncompressed_hint, 1));
        body.extend_from_slice(&make_block_info(0, compressed_size, 32));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(err, Error::Io { .. }),
            "expected Error::Io for corrupt DEFLATE, got {err:?}"
        );
    }

    // ----- FileInfo.size lying about block-table length -----

    #[test]
    fn rejects_file_info_size_smaller_than_block_table() {
        // FileInfo.size = 24 (just the FileInfo struct itself, no block table)
        // but number_of_blocks = 2. The block-data base is computed as
        // `byte_offset + file_info.size`, so with size=24 the "block data"
        // region starts immediately after FileInfo and overlaps where the block
        // table bytes actually sit. The block table parse will read those bytes
        // as BlockInfo entries — they are structurally nonsense and should
        // cause a parse or layout error, or (when the bytes happen to look
        // valid) produce a DecompressedSizeMismatch. This test locks in that
        // the reader does *not* silently succeed.
        let payload_a = vec![0xAAu8; 8];
        let payload_b = vec![0xBBu8; 8];

        // Real block data for two valid uncompressed blocks.
        let mut block_data = Vec::new();
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 8));
        block_data.extend_from_slice(&payload_a);
        block_data.extend_from_slice(&make_block_header(BLOCK_TYPE_UNCOMPRESSED, 8));
        block_data.extend_from_slice(&payload_b);

        // Deliberately wrong: size=24 excludes the 16-byte block table from the header.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 2, 16, 2)); // size=24 is wrong
        // All-in compressed_sizes: 16 (header) + 8 (payload) = 24.
        body.extend_from_slice(&make_block_info(0, 24, 8));
        body.extend_from_slice(&make_block_info(24, 24, 8));
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let result = dat.read_file(u64::from(SQPACK_HEADER_SIZE));
        assert!(
            result.is_err(),
            "expected an error when FileInfo.size excludes the block table, got Ok"
        );
    }

    // ----- Empty kind edge cases -----

    #[test]
    fn empty_kind_with_nonzero_raw_file_size_returns_empty_vec() {
        // Lumina returns an empty byte slice for Empty regardless of what
        // raw_file_size claims; we follow the same convention and treat Empty
        // as "return empty output, no block reading". This test locks in that
        // a non-zero raw_file_size on an Empty payload does NOT trigger a
        // DecompressedSizeMismatch — we simply return an empty Vec.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 42, 0)); // kind=Empty, raw_file_size=42
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert!(
            out.is_empty(),
            "Empty kind should return empty vec regardless of raw_file_size"
        );
    }

    #[test]
    fn empty_kind_with_nonzero_block_count_returns_empty_vec() {
        // Some shipped FFXIV files have been observed with Empty kind but a
        // non-zero number_of_blocks in the wire data. Lumina ignores the block
        // table for Empty payloads; we do the same: the block table is not
        // read, no seek happens, and an empty Vec is returned.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 0, 3)); // kind=Empty, 3 blocks claimed
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert!(
            out.is_empty(),
            "Empty kind should return empty vec regardless of number_of_blocks"
        );
    }

    // ----- u64 overflow in block layout validation -----

    #[test]
    fn rejects_block_offset_plus_size_overflow() {
        // Craft a BlockInfo whose offset is near u32::MAX so that
        // offset + (BLOCK_HEADER_SIZE + compressed_size) overflows u64 when
        // widened. The validation loop uses checked_add and must return
        // Error::InvalidBlockLayout on overflow.
        //
        // We set offset = u32::MAX - 8 and compressed_size = u16::MAX so the
        // u64 sum (u32::MAX - 8) + (16 + 65535) = (u32::MAX - 8) + 65551 which
        // is 4_295_032_839 — well within u64 but astronomically larger than
        // the file itself, which means the prev_end check on a *second* block
        // would fire. To test the overflow guard specifically we need a single
        // block whose arithmetic overflows u64. Achieve that via a custom
        // hand-rolled body that bypasses the make_standard_dat helper.
        //
        // u64 addition: (u32::MAX as u64) + (u16::MAX as u64 + 16) = 4_295_032_830 — no overflow.
        // To force overflow we'd need offset close to u64::MAX — impossible with u32 offsets.
        // What we CAN test is a valid but astronomically large offset that a second block
        // would see as non-monotonic; that is already covered by rejects_non_monotonic_block_offsets.
        // So this test instead verifies the u32::MAX boundary itself: a block at offset
        // u32::MAX - 20 with all-in compressed_size = 20 passes the single-block
        // validation (prev_end = u32::MAX, within u64) but points past the actual file
        // data, which the seek+read surfaces as an Io error.
        let large_offset: u32 = u32::MAX - 20;
        let compressed_size: u16 = 20; // all-in: header(16) + 4-byte payload

        let mut body = Vec::new();
        // number_of_blocks=1, info_size=24+8=32
        body.extend_from_slice(&make_file_info(32, 2, 16, 1));
        body.extend_from_slice(&make_block_info(large_offset, compressed_size, 16));
        // No actual block data needed — the seek will go past the buffer end.

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        // The block seek lands past EOF; the BlockHeader read fails as Io or Decode.
        assert!(
            matches!(err, Error::Io { .. } | Error::Decode { .. }),
            "expected Io or Decode for out-of-file block seek, got {err:?}"
        );
    }

    // ----- Feature 2: peek_kind / peek_file_info -----

    #[test]
    fn peek_kind_returns_standard_for_standard_payload() {
        let payload = b"x".repeat(16);
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let kind = dat.peek_kind(info_off).unwrap();
        assert_eq!(kind, FileKind::Standard);
    }

    #[test]
    fn peek_kind_returns_model_without_decoding_body() {
        // Model-kind FileInfo with no block data — peek_kind must not error
        // (unlike read_file, which rejects Model with UnsupportedFileKind).
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 3, 0, 0)); // kind = Model
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let kind = dat.peek_kind(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert_eq!(kind, FileKind::Model);
    }

    #[test]
    fn peek_kind_returns_texture_without_decoding_body() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 4, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let kind = dat.peek_kind(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert_eq!(kind, FileKind::Texture);
    }

    #[test]
    fn peek_kind_preserves_unknown_kind() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 42, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let kind = dat.peek_kind(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        assert_eq!(kind, FileKind::Unknown(42));
    }

    #[test]
    fn peek_kind_rejects_byte_offset_inside_sqpack_header() {
        let buf = make_dat_buf(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.peek_kind(0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidHeaderSize {
                field: "byte_offset",
                ..
            }
        ));
    }

    #[test]
    fn peek_file_info_returns_full_struct() {
        // Standard FileInfo with non-trivial fields — peek_file_info exposes
        // raw_file_size and number_of_blocks for callers that need them.
        let payload = b"a".repeat(32);
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let fi = dat.peek_file_info(info_off).unwrap();
        assert_eq!(fi.kind(), FileKind::Standard);
        assert_eq!(fi.raw_file_size, 32);
        assert_eq!(fi.number_of_blocks, 1);
        // info_size = 24 (FileInfo) + 8 (one BlockInfo)
        assert_eq!(fi.size, 32);
    }

    // ----- Feature 2: read_file_typed -----

    #[test]
    fn read_file_typed_returns_standard_variant_for_standard_payload() {
        let payload = b"hello, typed read".to_vec();
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        match p {
            SqPackPayload::Standard(bytes) => assert_eq!(bytes, payload),
            other => panic!("expected Standard, got {other:?}"),
        }
    }

    #[test]
    fn read_file_typed_returns_model_variant() {
        // A minimal Model payload with one uncompressed stack block.
        let payload = b"a model block".to_vec();
        let (buf, info_off) = make_model_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        match p {
            SqPackPayload::Model(m) => {
                assert_eq!(m.file_info.kind(), FileKind::Model);
                assert_eq!(m.bytes, payload);
            }
            other => panic!("expected Model, got {other:?}"),
        }
    }

    #[test]
    fn read_file_typed_returns_texture_variant() {
        // A minimal Texture payload with one uncompressed mip block and
        // no preamble.
        let payload = b"a texture mip".to_vec();
        let (buf, info_off) = make_texture_dat(&[], &[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        match p {
            SqPackPayload::Texture(t) => {
                assert_eq!(t.file_info.kind(), FileKind::Texture);
                assert_eq!(t.bytes, payload);
            }
            other => panic!("expected Texture, got {other:?}"),
        }
    }

    #[test]
    fn read_file_typed_empty_kind_returns_standard_empty_vec() {
        // Empty kind folds into SqPackPayload::Standard(Vec::new()) — see
        // SqPackPayload docs for the rationale.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(u64::from(SQPACK_HEADER_SIZE)).unwrap();
        match p {
            SqPackPayload::Standard(bytes) => assert!(bytes.is_empty()),
            other => panic!("expected Standard(empty), got {other:?}"),
        }
    }

    #[test]
    fn read_file_typed_rejects_unknown_kind() {
        // Unknown kind still produces UnsupportedFileKind — only Model and
        // Texture are reclassified into typed variants.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 99, 0, 0));
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat
            .read_file_typed(u64::from(SQPACK_HEADER_SIZE))
            .unwrap_err();
        match err {
            Error::UnsupportedFileKind { kind } => assert_eq!(kind, FileKind::Unknown(99)),
            other => panic!("expected UnsupportedFileKind, got {other:?}"),
        }
    }

    #[test]
    fn read_file_typed_preserves_byte_offset_validation() {
        let buf = make_dat_buf(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file_typed(0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidHeaderSize {
                field: "byte_offset",
                ..
            }
        ));
    }

    /// Zero-initialised [`ModelMetadata`] used by tests that don't care
    /// about prefix-synthesis correctness — only the byte view / kind
    /// routing. The real-data path is exercised separately by
    /// [`lumina_bytes_starts_with_model_file_header_prefix`].
    fn empty_model_metadata() -> ModelMetadata {
        ModelMetadata {
            version: 0,
            stack_size: 0,
            runtime_size: 0,
            vertex_declaration_count: 0,
            material_count: 0,
            vertex_offset: [0; 3],
            index_offset: [0; 3],
            vertex_buffer_size: [0; 3],
            index_buffer_size: [0; 3],
            lod_count: 0,
            enable_index_buffer_streaming: 0,
            enable_edge_geometry: 0,
        }
    }

    #[test]
    fn sqpack_payload_kind_accessor_matches_variant() {
        // SqPackPayload::kind() collapses Empty into Standard — verify the
        // documented behaviour for all three round-trippable variants.
        assert_eq!(SqPackPayload::Standard(vec![]).kind(), FileKind::Standard);
        assert_eq!(
            SqPackPayload::Model(ModelPayload {
                file_info: FileInfo {
                    size: 24,
                    kind_raw: 3,
                    raw_file_size: 0,
                    unknown: [0, 0],
                    number_of_blocks: 0,
                },
                metadata: empty_model_metadata(),
                bytes: Vec::new(),
            })
            .kind(),
            FileKind::Model,
        );
        assert_eq!(
            SqPackPayload::Texture(TexturePayload {
                file_info: FileInfo {
                    size: 24,
                    kind_raw: 4,
                    raw_file_size: 0,
                    unknown: [0, 0],
                    number_of_blocks: 0,
                },
                bytes: Vec::new(),
            })
            .kind(),
            FileKind::Texture,
        );
    }

    // ----- Feature 3: streaming reads -----

    #[test]
    fn streaming_reads_single_uncompressed_block() {
        let payload = b"streamed bytes".to_vec();
        let (buf, info_off) = make_standard_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let mut stream = dat.read_file_streaming(info_off).unwrap();
        assert_eq!(stream.kind(), FileKind::Standard);
        assert_eq!(stream.block_count(), 1);
        assert_eq!(
            stream.raw_file_size(),
            u32::try_from(payload.len()).unwrap()
        );

        let chunks: Vec<_> = (&mut stream).collect();
        assert_eq!(chunks.len(), 1);
        match &chunks[0] {
            Ok(c) => {
                assert_eq!(c.index, 0);
                assert_eq!(c.bytes, payload);
            }
            Err(e) => panic!("unexpected: {e:?}"),
        }
    }

    #[test]
    fn streaming_concatenates_multiple_blocks_in_order() {
        let a: Vec<u8> = vec![0xAA; 200];
        let b: Vec<u8> = (0..500u32).map(|i| (i % 13) as u8).collect();
        let c: Vec<u8> = vec![0xCC; 64];
        let blocks = [
            (a.as_slice(), false),
            (b.as_slice(), true),
            (c.as_slice(), false),
        ];
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let stream = dat.read_file_streaming(info_off).unwrap();
        let expected_len = u32::try_from(a.len() + b.len() + c.len()).unwrap();
        assert_eq!(stream.raw_file_size(), expected_len);
        assert_eq!(stream.block_count(), 3);

        let mut concatenated = Vec::new();
        for (i, chunk) in stream.enumerate() {
            let chunk = chunk.unwrap();
            #[allow(clippy::cast_possible_truncation)]
            let expected_idx = i as u32;
            assert_eq!(chunk.index, expected_idx);
            concatenated.extend_from_slice(&chunk.bytes);
        }
        let mut expected = Vec::new();
        expected.extend_from_slice(&a);
        expected.extend_from_slice(&b);
        expected.extend_from_slice(&c);
        assert_eq!(concatenated, expected);
    }

    #[test]
    fn streaming_empty_kind_yields_zero_blocks() {
        // Empty-kind payloads are allowed by the streaming constructor
        // (treated as zero-block Standard); the iterator drains immediately.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 0, 0)); // Empty
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let stream = dat
            .read_file_streaming(u64::from(SQPACK_HEADER_SIZE))
            .unwrap();
        assert_eq!(stream.kind(), FileKind::Standard);
        assert_eq!(stream.block_count(), 0);
        assert_eq!(stream.count(), 0);
    }

    #[test]
    fn streaming_rejects_model_kind() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 3, 0, 0)); // Model
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat
            .read_file_streaming(u64::from(SQPACK_HEADER_SIZE))
            .unwrap_err();
        assert!(matches!(
            err,
            Error::UnsupportedFileKind {
                kind: FileKind::Model
            }
        ));
    }

    #[test]
    fn streaming_rejects_texture_kind() {
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 4, 0, 0)); // Texture
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat
            .read_file_streaming(u64::from(SQPACK_HEADER_SIZE))
            .unwrap_err();
        assert!(matches!(
            err,
            Error::UnsupportedFileKind {
                kind: FileKind::Texture
            }
        ));
    }

    #[test]
    fn streaming_exact_size_iterator_reports_remaining() {
        let payloads: Vec<Vec<u8>> = (0u8..4).map(|i| vec![i; 16]).collect();
        let blocks: Vec<(&[u8], bool)> = payloads.iter().map(|p| (p.as_slice(), false)).collect();
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let mut stream = dat.read_file_streaming(info_off).unwrap();
        assert_eq!(stream.len(), 4);
        let _ = stream.next().unwrap().unwrap();
        assert_eq!(stream.len(), 3);
        let _ = stream.next().unwrap().unwrap();
        assert_eq!(stream.len(), 2);
        let _ = stream.next().unwrap().unwrap();
        assert_eq!(stream.len(), 1);
        let _ = stream.next().unwrap().unwrap();
        assert_eq!(stream.len(), 0);
        assert!(stream.next().is_none());
    }

    #[test]
    fn streaming_read_block_into_reuses_buffer() {
        // read_block_into writes into the caller's buffer; verify the API
        // works for buffer-reuse hot loops.
        let payloads: Vec<Vec<u8>> = (0u8..3).map(|i| vec![i; 32]).collect();
        let blocks: Vec<(&[u8], bool)> = payloads.iter().map(|p| (p.as_slice(), false)).collect();
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let mut stream = dat.read_file_streaming(info_off).unwrap();

        let mut scratch = Vec::with_capacity(64);
        let cap_before = scratch.capacity();
        let mut concatenated = Vec::new();

        while let Some(res) = stream.read_block_into(&mut scratch) {
            res.unwrap();
            concatenated.extend_from_slice(&scratch);
        }

        let mut expected = Vec::new();
        for p in &payloads {
            expected.extend_from_slice(p);
        }
        assert_eq!(concatenated, expected);
        // The scratch buffer should be reused (capacity not shrunk).
        assert!(scratch.capacity() >= cap_before);
    }

    #[test]
    fn streaming_rejects_byte_offset_inside_sqpack_header() {
        let buf = make_dat_buf(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file_streaming(0).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidHeaderSize {
                field: "byte_offset",
                ..
            }
        ));
    }

    // ----- Gap 2: streaming early-drop + reopen on the same DatFile -----

    #[test]
    fn streaming_early_drop_then_reopen_yields_all_blocks() {
        // Verifies that dropping a DatFileStream mid-iteration releases the
        // borrow so the same DatFileBuf can be re-opened, and that the second
        // stream yields every block in order with cumulative byte count equal
        // to file_info.raw_file_size.
        //
        // Build a 4-block Standard payload so there are enough blocks to
        // consume one and still have blocks remaining when we drop.
        let payloads: Vec<Vec<u8>> = (0u8..4).map(|i| vec![i * 11 + 7; 64]).collect();
        let blocks: Vec<(&[u8], bool)> = payloads.iter().map(|p| (p.as_slice(), false)).collect();
        let (buf, info_off) = make_standard_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();

        // First stream: consume exactly one block, then drop.
        {
            let mut stream = dat.read_file_streaming(info_off).unwrap();
            assert_eq!(stream.block_count(), 4);
            let first = stream.next().unwrap().unwrap();
            assert_eq!(first.index, 0);
            assert_eq!(first.bytes, payloads[0]);
            // Stream drops here — borrow on `dat` released.
        }

        // Second stream on the same DatFileBuf, same offset — must see all 4 blocks.
        let stream = dat.read_file_streaming(info_off).unwrap();
        let expected_raw = u32::try_from(payloads.iter().map(Vec::len).sum::<usize>()).unwrap();
        assert_eq!(stream.raw_file_size(), expected_raw);
        assert_eq!(stream.block_count(), 4);

        let mut total_bytes: u32 = 0;
        let mut expected_all = Vec::new();
        for p in &payloads {
            expected_all.extend_from_slice(p);
        }
        let mut concatenated = Vec::new();
        for (i, chunk) in stream.enumerate() {
            let chunk = chunk.unwrap();
            #[allow(clippy::cast_possible_truncation)]
            let expected_idx = i as u32;
            assert_eq!(chunk.index, expected_idx, "block index must be in order");
            assert_eq!(
                chunk.bytes, payloads[i],
                "block {i} bytes must match original payload"
            );
            total_bytes += u32::try_from(chunk.bytes.len()).unwrap();
            concatenated.extend_from_slice(&chunk.bytes);
        }
        assert_eq!(
            total_bytes, expected_raw,
            "cumulative byte count must match raw_file_size"
        );
        assert_eq!(
            concatenated, expected_all,
            "full stream must reconstruct the payload"
        );
    }

    // ----- Additional gap: peek_kind vs streaming kind asymmetry for Empty -----

    #[test]
    fn peek_kind_returns_empty_but_streaming_kind_returns_standard() {
        // The documented asymmetry: peek_kind reports the wire value (Empty),
        // while DatFileStream::kind collapses Empty into Standard because the
        // streaming pipeline treats both as "zero-or-more standard blocks".
        // Pin both behaviours in the same test so the contrast is explicit.
        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(24, 1, 0, 0)); // kind = Empty
        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let info_off = u64::from(SQPACK_HEADER_SIZE);

        let kind_from_peek = dat.peek_kind(info_off).unwrap();
        assert_eq!(
            kind_from_peek,
            FileKind::Empty,
            "peek_kind must report the raw wire value"
        );

        let stream = dat.read_file_streaming(info_off).unwrap();
        assert_eq!(
            stream.kind(),
            FileKind::Standard,
            "DatFileStream::kind must collapse Empty into Standard"
        );
    }

    // ----- Additional gap: SqPackPayload::bytes() pinning -----

    #[test]
    fn payload_bytes_returns_slice_for_every_decoded_variant() {
        let standard = SqPackPayload::Standard(b"hello".to_vec());
        assert_eq!(standard.bytes(), b"hello".as_slice(),);

        let model = SqPackPayload::Model(ModelPayload {
            file_info: FileInfo {
                size: 24,
                kind_raw: 3,
                raw_file_size: 0,
                unknown: [0, 0],
                number_of_blocks: 0,
            },
            metadata: empty_model_metadata(),
            bytes: b"model bytes".to_vec(),
        });
        assert_eq!(model.bytes(), b"model bytes".as_slice(),);

        let texture = SqPackPayload::Texture(TexturePayload {
            file_info: FileInfo {
                size: 24,
                kind_raw: 4,
                raw_file_size: 0,
                unknown: [0, 0],
                number_of_blocks: 0,
            },
            bytes: b"tex bytes".to_vec(),
        });
        assert_eq!(texture.bytes(), b"tex bytes".as_slice(),);
    }

    // ----- Additional gap: SqPackFileStream::read_block_into buffer-reuse -----

    #[test]
    fn sqpack_file_stream_read_block_into_clears_buffer_per_call() {
        // SqPackFileStream::read_block_into must clear the buffer on each call
        // so reusing the same Vec across iterations doesn't accumulate stale
        // bytes from prior blocks.
        //
        // Use blocks of different sizes so a stale non-clear would be visible:
        // block 0 = 32 bytes, block 1 = 8 bytes. If clear is missing, after
        // block 1 the buffer would contain block_0_bytes ++ block_1_bytes.
        let chunk_a: Vec<u8> = vec![0xAA; 32];
        let chunk_b: Vec<u8> = vec![0xBB; 8];
        let two_blocks = [(chunk_a.as_slice(), false), (chunk_b.as_slice(), false)];
        let (buf, info_off) = make_standard_dat(&two_blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();

        // Pre-fill the scratch buffer with junk to make stale-data bugs obvious.
        let mut scratch = vec![0xFFu8; 64];

        // Open a stream; note DatFileBuf exposes read_file_streaming directly.
        let mut stream = dat.read_file_streaming(info_off).unwrap();

        stream.read_block_into(&mut scratch).unwrap().unwrap();
        assert_eq!(
            scratch, chunk_a,
            "buffer after block 0 must exactly equal block 0"
        );

        stream.read_block_into(&mut scratch).unwrap().unwrap();
        assert_eq!(
            scratch, chunk_b,
            "buffer after block 1 must exactly equal block 1 (no stale bytes from block 0)"
        );

        // Iterator exhausted.
        assert!(stream.read_block_into(&mut scratch).is_none());
    }

    /// Build a multi-mip Texture-kind payload. `mips` is a slice where each
    /// element is `(preamble, &[(payload, compressed)])`. The preamble bytes
    /// of the **first** mip are emitted as the leading mip-header preamble;
    /// subsequent mips have no preamble contribution — their
    /// `compressed_offset` is set to the exact start of their block run
    /// (relative to the block-data base).
    ///
    /// Layout: FileInfo (24) + LodBlock[mip_count] (20 each) + u16 size table
    /// + block-data (preamble from mip 0 + all blocks in mip order).
    type MipSpec<'a> = (&'a [u8], &'a [(&'a [u8], bool)]);

    fn make_texture_dat_multi_mip(mips: &[MipSpec<'_>]) -> (Vec<u8>, u64) {
        let mip_count = u32::try_from(mips.len()).unwrap();
        let total_block_count: u32 = mips
            .iter()
            .map(|(_, blocks)| u32::try_from(blocks.len()).unwrap())
            .sum();
        // info_size = 24 (FileInfo) + 20 * mip_count (LodBlock[]) + 2 * total_block_count (u16[])
        let info_size: u32 = 24 + 20 * mip_count + 2 * total_block_count;

        // Build the block-data section: preamble from mip 0, then all blocks.
        let mut block_data: Vec<u8> = Vec::new();
        let preamble_bytes = mips.first().map_or(&[][..], |(p, _)| p);
        block_data.extend_from_slice(preamble_bytes);

        // Per-mip: compressed_sizes and (offset relative to block_data_base)
        let mut all_compressed_sizes: Vec<u16> = Vec::new();
        let mut lod_blocks: Vec<Vec<u8>> = Vec::new();
        for (mip_idx, (_preamble, blocks)) in mips.iter().enumerate() {
            // compressed_offset for this mip = bytes already in block_data.
            let compressed_offset = u32::try_from(block_data.len()).unwrap();
            let mip_start_in_block_data = block_data.len();
            let mut decompressed_total: u32 = 0;
            for (payload, compressed) in *blocks {
                let uncompressed_size = u32::try_from(payload.len()).unwrap();
                decompressed_total += uncompressed_size;
                if *compressed {
                    let encoded = deflate_compress(payload);
                    let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
                    all_compressed_sizes.push(cs);
                    block_data.extend_from_slice(&make_block_header(16000, uncompressed_size));
                    block_data.extend_from_slice(&encoded);
                } else {
                    let cs = u16::try_from(BLOCK_HEADER_SIZE as usize + payload.len()).unwrap();
                    all_compressed_sizes.push(cs);
                    block_data.extend_from_slice(&make_block_header(
                        BLOCK_TYPE_UNCOMPRESSED,
                        uncompressed_size,
                    ));
                    block_data.extend_from_slice(payload);
                }
            }
            let mip_compressed_size =
                u32::try_from(block_data.len() - mip_start_in_block_data).unwrap();
            let block_count = u32::try_from(blocks.len()).unwrap();
            // block_offset is informational (unused by decoder) — use mip_idx for distinctness.
            let block_offset = u32::try_from(mip_idx).unwrap();
            lod_blocks.push(make_lod_block(
                compressed_offset,
                mip_compressed_size,
                decompressed_total,
                block_offset,
                block_count,
            ));
        }

        let preamble_len = u32::try_from(preamble_bytes.len()).unwrap();
        let raw_total: u32 = preamble_len
            + mips
                .iter()
                .flat_map(|(_, blocks)| *blocks)
                .map(|(p, _)| u32::try_from(p.len()).unwrap())
                .sum::<u32>();

        let mut body = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 4, raw_total, mip_count));
        for lb in &lod_blocks {
            body.extend_from_slice(lb);
        }
        for cs in &all_compressed_sizes {
            body.extend_from_slice(&cs.to_le_bytes());
        }
        debug_assert_eq!(body.len(), info_size as usize);
        body.extend_from_slice(&block_data);

        let buf = make_dat_buf(&body);
        (buf, u64::from(SQPACK_HEADER_SIZE))
    }

    // ----- Model decoder -----

    #[test]
    fn model_decoder_round_trips_single_uncompressed_block() {
        let payload = b"a model stack block, raw on disk".to_vec();
        let (buf, info_off) = make_model_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn model_decoder_round_trips_single_compressed_block() {
        // Enough bytes for DEFLATE to compress.
        let payload: Vec<u8> = (0..1024u32).map(|i| (i % 251) as u8).collect();
        let (buf, info_off) = make_model_dat(&[(payload.as_slice(), true)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn model_decoder_concatenates_multiple_blocks_in_order() {
        let a: Vec<u8> = vec![0xAA; 200];
        let b: Vec<u8> = (0..500u32).map(|i| (i % 13) as u8).collect();
        let c: Vec<u8> = vec![0xCC; 64];
        let blocks = [
            (a.as_slice(), false),
            (b.as_slice(), true),
            (c.as_slice(), false),
        ];
        let (buf, info_off) = make_model_dat(&blocks);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        let mut expected = Vec::new();
        expected.extend_from_slice(&a);
        expected.extend_from_slice(&b);
        expected.extend_from_slice(&c);
        assert_eq!(out, expected);
    }

    #[test]
    fn model_decoder_rejects_truncated_block_size_table() {
        // Build a valid 2-block Model body via the helper, then truncate
        // the trailing per-block u16 size table by chopping the last 2
        // bytes. The reader must surface an Io / Decode error rather
        // than panicking on the missing u16 entry.
        let a = b"first block".to_vec();
        let b = b"second block".to_vec();
        let (mut buf, _info_off) = make_model_dat(&[(a.as_slice(), false), (b.as_slice(), false)]);
        // The size table sits at byte offset 1024 + MODEL_HEADER_SIZE.
        // It carries `used_number_of_blocks` (= 2) u16 entries, then the
        // block-data section follows. We truncate the file so the second
        // u16 entry is absent — but keep enough bytes that the header
        // parses successfully.
        let table_start = 1024 + MODEL_HEADER_SIZE as usize;
        let truncated_len = table_start + 2; // keep only 1 u16 entry
        buf.truncate(truncated_len);

        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(err, Error::Io { .. } | Error::Decode { .. }),
            "expected Io / Decode for truncated model block-size table, got {err:?}"
        );
    }

    #[test]
    fn model_decoder_typed_payload_carries_decoded_bytes() {
        let payload = b"typed model bytes".to_vec();
        let (buf, info_off) = make_model_dat(&[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        match p {
            SqPackPayload::Model(m) => {
                assert_eq!(m.file_info.kind(), FileKind::Model);
                assert_eq!(m.bytes(), payload.as_slice());
            }
            other => panic!("expected Model, got {other:?}"),
        }
    }

    /// `lumina_bytes` must prepend a 68-byte
    /// [`crate::mdl::ModelFileHeader`]-shaped prefix synthesised from
    /// [`ModelMetadata`], with [`ModelPayload::bytes`] appended verbatim.
    ///
    /// Pins the byte layout of the prefix: field offsets and little-endian
    /// encoding must match Lumina's `SqPackStream.ReadModelFile` write
    /// order. A drift in the synthesis would be silent for an MDL parser
    /// that only inspects a subset of fields; pinning the raw bytes makes
    /// any off-by-N regression fail loudly here.
    #[test]
    fn lumina_bytes_starts_with_model_file_header_prefix() {
        use crate::mdl::ModelFileHeader;
        use binrw::BinRead;
        use std::io::Cursor;

        // Hand-built metadata + payload body. Values chosen to be
        // distinct across every field so a bit-shift / off-by-four
        // mistake in the prefix layout cannot collide with a neighbour.
        let metadata = ModelMetadata {
            version: 0x0101_0203,
            stack_size: 0x10,
            runtime_size: 0x20,
            vertex_declaration_count: 0x0A,
            material_count: 0x0B,
            vertex_offset: [0x100, 0x200, 0x300],
            index_offset: [0x110, 0x210, 0x310],
            vertex_buffer_size: [0x40, 0x50, 0x60],
            index_buffer_size: [0x80, 0x90, 0xA0],
            lod_count: 3,
            enable_index_buffer_streaming: 1,
            enable_edge_geometry: 0,
        };
        let body: Vec<u8> = (0..200u32).map(|i| (i & 0xFF) as u8).collect();
        let payload = ModelPayload {
            file_info: FileInfo {
                size: 24,
                kind_raw: 3,
                raw_file_size: 0,
                unknown: [0, 0],
                number_of_blocks: 0,
            },
            metadata,
            bytes: body.clone(),
        };

        let out = payload.lumina_bytes();

        // Prefix is 68 bytes, body is appended verbatim.
        assert_eq!(
            out.len(),
            crate::mdl::MODEL_FILE_HEADER_SIZE + body.len(),
            "lumina_bytes length must equal 0x44 + bytes.len()"
        );
        assert_eq!(
            &out[crate::mdl::MODEL_FILE_HEADER_SIZE..],
            body.as_slice(),
            "tail of lumina_bytes must equal ModelPayload::bytes verbatim"
        );

        // Decode the prefix back as the public ModelFileHeader and check
        // every field round-trips. This exercises both the byte layout
        // (binrw decodes the same bytes Lumina's MdlFile.LoadFile reads)
        // and the field-name mapping.
        let mut cursor = Cursor::new(&out[..crate::mdl::MODEL_FILE_HEADER_SIZE]);
        let hdr = ModelFileHeader::read(&mut cursor).expect("prefix decodes as ModelFileHeader");
        assert_eq!(hdr.version, metadata.version, "version slot");
        assert_eq!(hdr.stack_size, metadata.stack_size, "stack_size slot");
        assert_eq!(hdr.runtime_size, metadata.runtime_size, "runtime_size slot");
        assert_eq!(
            hdr.vertex_declaration_count, metadata.vertex_declaration_count,
            "vertex_declaration_count slot"
        );
        assert_eq!(
            hdr.material_count, metadata.material_count,
            "material_count slot"
        );
        assert_eq!(
            hdr.vertex_offset, metadata.vertex_offset,
            "vertex_offset[3]"
        );
        assert_eq!(hdr.index_offset, metadata.index_offset, "index_offset[3]");
        assert_eq!(
            hdr.vertex_buffer_size, metadata.vertex_buffer_size,
            "vertex_buffer_size[3]"
        );
        assert_eq!(
            hdr.index_buffer_size, metadata.index_buffer_size,
            "index_buffer_size[3]"
        );
        assert_eq!(hdr.lod_count, metadata.lod_count, "lod_count");
        assert_eq!(
            hdr.enable_index_buffer_streaming, metadata.enable_index_buffer_streaming,
            "enable_index_buffer_streaming"
        );
        assert_eq!(
            hdr.enable_edge_geometry, metadata.enable_edge_geometry,
            "enable_edge_geometry"
        );
    }

    /// End-to-end: a [`make_model_dat`] payload whose extended header's
    /// `version` / `num_lods` / `index_buffer_streaming_enabled` /
    /// `edge_geometry_enabled` / `vertex_declaration_num` / `material_num`
    /// fields are non-zero must surface those values on
    /// [`ModelPayload::metadata`].
    ///
    /// This pins the wire-to-struct mapping inside
    /// [`build_model_metadata`]: a future rename of either side that
    /// shuffles slots silently would fail this assertion.
    #[test]
    fn model_payload_metadata_round_trips_through_decoder() {
        use crate::mdl::ModelFileHeader;
        use binrw::BinRead;
        use std::io::Cursor;

        let payload = b"section payload".to_vec();
        let (mut buf, info_off) = make_model_dat(&[(payload.as_slice(), false)]);

        // The ModelHeader struct sits at byte_offset (info_off) in the
        // buffer. Field offsets relative to the start of the struct
        // (from the BinRead-derived layout):
        //   version: 20 (after the 5 u32 framing fields)
        //   vertex_declaration_num: 200 (0xC8)
        //   material_num: 202 (0xCA)
        //   num_lods: 204 (0xCC)
        //   index_buffer_streaming_enabled: 205 (0xCD)
        //   edge_geometry_enabled: 206 (0xCE)
        let hdr_start = usize::try_from(info_off).expect("info_off fits usize on 64-bit");
        let payload_len_u32 = u32::try_from(payload.len()).expect("test payload fits u32");
        buf[hdr_start + 20..hdr_start + 24].copy_from_slice(&0xABCD_1234u32.to_le_bytes());
        buf[hdr_start + 0xC8..hdr_start + 0xCA].copy_from_slice(&0x0007u16.to_le_bytes());
        buf[hdr_start + 0xCA..hdr_start + 0xCC].copy_from_slice(&0x0009u16.to_le_bytes());
        buf[hdr_start + 0xCC] = 2;
        buf[hdr_start + 0xCD] = 1;
        buf[hdr_start + 0xCE] = 1;

        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        let m = match p {
            SqPackPayload::Model(m) => m,
            other => panic!("expected Model, got {other:?}"),
        };

        // make_model_dat puts the single block in the stack section.
        // After decoding, the only non-zero per-section size is stack_size.
        assert_eq!(m.bytes(), payload.as_slice());
        assert_eq!(m.metadata.version, 0xABCD_1234, "version round-trips");
        assert_eq!(m.metadata.stack_size, payload_len_u32);
        assert_eq!(m.metadata.runtime_size, 0);
        assert_eq!(m.metadata.vertex_declaration_count, 7);
        assert_eq!(m.metadata.material_count, 9);
        assert_eq!(m.metadata.lod_count, 2);
        assert_eq!(m.metadata.enable_index_buffer_streaming, 1);
        assert_eq!(m.metadata.enable_edge_geometry, 1);
        assert_eq!(m.metadata.vertex_buffer_size, [0, 0, 0]);
        assert_eq!(m.metadata.index_buffer_size, [0, 0, 0]);
        // No populated vertex / index sections, so dedupe leaves all
        // offsets at 0.
        assert_eq!(m.metadata.vertex_offset, [0, 0, 0]);
        assert_eq!(m.metadata.index_offset, [0, 0, 0]);

        // And the synthesised prefix must round-trip through the MDL
        // file-header decoder.
        let lumina = m.lumina_bytes();
        let hdr = ModelFileHeader::read(&mut Cursor::new(
            &lumina[..crate::mdl::MODEL_FILE_HEADER_SIZE],
        ))
        .expect("prefix decodes");
        assert_eq!(hdr.version, 0xABCD_1234);
        assert_eq!(hdr.stack_size, payload_len_u32);
        assert_eq!(hdr.lod_count, 2);
    }

    // ----- Texture decoder -----

    #[test]
    fn texture_decoder_round_trips_single_uncompressed_block() {
        let payload = b"a single uncompressed mip block".to_vec();
        let (buf, info_off) = make_texture_dat(&[], &[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn texture_decoder_round_trips_with_preamble() {
        // Smallest possible preamble (8 bytes) followed by one mip block.
        let preamble: [u8; 8] = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
        let payload: Vec<u8> = (0..64u8).collect();
        let (buf, info_off) = make_texture_dat(&preamble, &[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        let mut expected = Vec::new();
        expected.extend_from_slice(&preamble);
        expected.extend_from_slice(&payload);
        assert_eq!(out, expected);
    }

    #[test]
    fn texture_decoder_round_trips_single_compressed_block() {
        let payload: Vec<u8> = (0..1024u32).map(|i| (i % 31) as u8).collect();
        let (buf, info_off) = make_texture_dat(&[], &[(payload.as_slice(), true)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, payload);
    }

    #[test]
    fn texture_decoder_rejects_truncated_lod_block_table() {
        // FileInfo declares 2 LOD blocks but the body only contains 1
        // full LodBlock — the second is truncated.
        let mut body = Vec::new();
        let info_size = 24 + LOD_BLOCK_SIZE * 2 + 4; // claimed
        body.extend_from_slice(&make_file_info(info_size, 4, 0, 2)); // 2 LodBlocks
        // First LodBlock (full 20 bytes).
        body.extend_from_slice(&make_lod_block(0, 0, 0, 0, 0));
        // Second LodBlock: only 10 of 20 bytes — truncated.
        body.extend_from_slice(&[0u8; 10]);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(err, Error::Io { .. } | Error::Decode { .. }),
            "expected Io / Decode for truncated LodBlock table, got {err:?}"
        );
    }

    #[test]
    fn texture_decoder_typed_payload_carries_decoded_bytes() {
        let preamble: [u8; 16] = [
            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
            0x0F, 0x10,
        ];
        let payload = b"typed texture bytes".to_vec();
        let (buf, info_off) = make_texture_dat(&preamble, &[(payload.as_slice(), false)]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let p = dat.read_file_typed(info_off).unwrap();
        match p {
            SqPackPayload::Texture(t) => {
                assert_eq!(t.file_info.kind(), FileKind::Texture);
                let mut expected = Vec::new();
                expected.extend_from_slice(&preamble);
                expected.extend_from_slice(&payload);
                assert_eq!(t.bytes(), expected.as_slice());
            }
            other => panic!("expected Texture, got {other:?}"),
        }
    }

    // ----- Gap 1: empty block list (Model) -----

    #[test]
    fn model_decoder_all_zero_block_layout_produces_empty_output() {
        // Every section has block_num == 0; used_number_of_blocks == 0.
        // walk_model_section returns Ok(()) immediately for each, so the
        // decoder must produce zero bytes and not error.
        let (buf, info_off) = make_model_dat(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, Vec::<u8>::new());
    }

    // ----- Gap 2: DEFLATE that decompresses to the wrong size (Model) -----

    #[test]
    fn model_decoder_deflate_wrong_decompressed_size_is_error() {
        // Construct a Model payload whose block declares block_data_size = 64
        // but whose DEFLATE stream expands to only 32 bytes.
        let real_payload: Vec<u8> = (0..32u8).collect();
        let encoded = deflate_compress(&real_payload);

        // Build a 1-block model manually, lying in the BlockHeader.block_data_size.
        let all_in = u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
        let header_size = MODEL_HEADER_SIZE + 2;

        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&header_size.to_le_bytes()); // ModelHeader.size
        body.extend_from_slice(&3u32.to_le_bytes()); // kind = Model
        body.extend_from_slice(&64u32.to_le_bytes()); // raw_file_size (lied)
        body.extend_from_slice(&1u32.to_le_bytes()); // number_of_blocks
        body.extend_from_slice(&1u32.to_le_bytes()); // used_number_of_blocks
        body.extend_from_slice(&0u32.to_le_bytes()); // version
        body.extend_from_slice(&0u32.to_le_bytes()); // stack_size
        body.extend_from_slice(&0u32.to_le_bytes()); // runtime_size
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_stack_memory_size
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_runtime_memory_size
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u32.to_le_bytes()); // stack_offset
        body.extend_from_slice(&0u32.to_le_bytes()); // runtime_offset
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u16.to_le_bytes()); // stack_block_index
        body.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_index
        for _ in 0..9 {
            body.extend_from_slice(&0u16.to_le_bytes());
        }
        body.extend_from_slice(&1u16.to_le_bytes()); // stack_block_num = 1
        body.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_num
        for _ in 0..9 {
            body.extend_from_slice(&0u16.to_le_bytes());
        }
        body.extend_from_slice(&0u16.to_le_bytes()); // vertex_declaration_num
        body.extend_from_slice(&0u16.to_le_bytes()); // material_num
        body.push(0); // num_lods
        body.push(0); // index_buffer_streaming_enabled
        body.push(0); // edge_geometry_enabled
        body.push(0); // padding

        body.extend_from_slice(&all_in.to_le_bytes()); // per-block u16 size table

        // BlockHeader: lie — claim 64 bytes uncompressed, provide 32.
        body.extend_from_slice(&make_block_header(16000, 64));
        body.extend_from_slice(&encoded);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::DecompressedSizeMismatch {
                    context: "block",
                    expected: 64,
                    found: 32
                }
            ),
            "expected DecompressedSizeMismatch(block, 64, 32), got {err:?}"
        );
    }

    // ----- Gap 2: DEFLATE that decompresses to wrong size (Texture) -----

    #[test]
    fn texture_decoder_deflate_wrong_decompressed_size_is_error() {
        // Same pattern as the Model variant but exercised through the
        // Texture decoder path. The block declares block_data_size = 64
        // but DEFLATE only produces 32 bytes.
        let real_payload: Vec<u8> = (0..32u8).collect();
        let encoded = deflate_compress(&real_payload);
        let all_in = u16::try_from(BLOCK_HEADER_SIZE as usize + encoded.len()).unwrap();
        let block_count: u32 = 1;
        let preamble_len: u32 = 0;
        let info_size = 24 + LOD_BLOCK_SIZE + 2 * block_count;

        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 4, 64, 1));
        body.extend_from_slice(&make_lod_block(
            preamble_len,
            u32::from(all_in),
            64,
            0,
            block_count,
        ));
        body.extend_from_slice(&all_in.to_le_bytes());
        // BlockHeader lies: claims 64 bytes uncompressed, provides 32.
        body.extend_from_slice(&make_block_header(16000, 64));
        body.extend_from_slice(&encoded);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::DecompressedSizeMismatch {
                    context: "block",
                    expected: 64,
                    found: 32
                }
            ),
            "expected DecompressedSizeMismatch(block, 64, 32), got {err:?}"
        );
    }

    // ----- Gap 3: multi-mip Texture with mixed compression -----

    #[test]
    fn texture_decoder_multi_mip_concatenates_in_order() {
        // 4 mips; mip 0 has a preamble + 2 uncompressed blocks,
        // mip 1 has 1 compressed block, mip 2 has 3 uncompressed blocks,
        // mip 3 has 1 compressed block. The decoded output must be the
        // preamble followed by all blocks in mip order.
        let preamble: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
        let mip0_a: Vec<u8> = vec![0x11; 32];
        let mip0_b: Vec<u8> = vec![0x22; 48];
        let mip1_a: Vec<u8> = (0..512u16).map(|i| (i % 251) as u8).collect();
        let mip2_a: Vec<u8> = vec![0x33; 16];
        let mip2_b: Vec<u8> = vec![0x44; 16];
        let mip2_c: Vec<u8> = vec![0x55; 16];
        let mip3_a: Vec<u8> = (0..256u16).map(|i| (i % 17) as u8).collect();

        let mips: &[MipSpec<'_>] = &[
            (
                preamble.as_slice(),
                &[(mip0_a.as_slice(), false), (mip0_b.as_slice(), false)],
            ),
            (&[], &[(mip1_a.as_slice(), true)]),
            (
                &[],
                &[
                    (mip2_a.as_slice(), false),
                    (mip2_b.as_slice(), false),
                    (mip2_c.as_slice(), false),
                ],
            ),
            (&[], &[(mip3_a.as_slice(), true)]),
        ];

        let (buf, info_off) = make_texture_dat_multi_mip(mips);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();

        let mut expected = Vec::new();
        expected.extend_from_slice(&preamble);
        expected.extend_from_slice(&mip0_a);
        expected.extend_from_slice(&mip0_b);
        expected.extend_from_slice(&mip1_a);
        expected.extend_from_slice(&mip2_a);
        expected.extend_from_slice(&mip2_b);
        expected.extend_from_slice(&mip2_c);
        expected.extend_from_slice(&mip3_a);
        assert_eq!(out, expected);
    }

    // ----- Gap 4: zero-preamble path on a multi-block mip -----

    #[test]
    fn texture_decoder_zero_preamble_multi_block_mip_starts_at_base() {
        // compressed_offset == 0 for the only mip: the block-data base IS the
        // mip start; no preamble bytes are emitted. Uses two uncompressed blocks
        // to verify the within-mip cursor advances correctly.
        let block_a: Vec<u8> = vec![0xAA; 64];
        let block_b: Vec<u8> = vec![0xBB; 32];
        // preamble is empty — make_texture_dat_multi_mip sets compressed_offset
        // of mip 0 to len(block_data before mip 0), which is zero (no preamble).
        let mips: &[MipSpec<'_>] = &[(
            &[],
            &[(block_a.as_slice(), false), (block_b.as_slice(), false)],
        )];
        let (buf, info_off) = make_texture_dat_multi_mip(mips);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();

        let mut expected = Vec::new();
        expected.extend_from_slice(&block_a);
        expected.extend_from_slice(&block_b);
        assert_eq!(out, expected);
    }

    // ----- used_number_of_blocks > per-section sum is tolerated -----

    #[test]
    fn model_decoder_accepts_used_number_of_blocks_greater_than_sum() {
        // Real retail Models routinely declare `used_number_of_blocks` larger
        // than the per-section block_num sum (observed 37 vs 6, 65 vs 6 in
        // bgcommon). Lumina sizes its size-table read off the sum and never
        // looks at `used_number_of_blocks`; we mirror that.
        let payload = b"one block".to_vec();
        let (mut buf, info_off) = make_model_dat(&[(payload.as_slice(), false)]);

        // make_model_dat sets used_number_of_blocks = 1 (one block in stack).
        // Overwrite it with a much larger value so we know the decoder no
        // longer cross-checks against the section sum. `used_number_of_blocks`
        // is at FileInfo offset 16 (after size, kind, raw_file_size,
        // number_of_blocks — each 4 bytes).
        let ubof = usize::try_from(info_off).unwrap() + 16;
        buf[ubof..ubof + 4].copy_from_slice(&37u32.to_le_bytes());

        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat
            .read_file(info_off)
            .expect("Model with used > sum must decode");
        assert_eq!(out, payload);
    }

    // ----- Cap: MAX_MODEL_BLOCKS -----

    #[test]
    fn model_decoder_rejects_block_count_exceeding_cap() {
        // Construct a ModelHeader whose per-section sum exceeds MAX_MODEL_BLOCKS
        // (1024). The sum must equal used_number_of_blocks to pass the equality
        // check, and must then trip the cap check.
        //
        // Use stack_block_num = 1025 and used_number_of_blocks = 1025.
        let header_size = MODEL_HEADER_SIZE + 2 * 1025; // giant u16 table
        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&header_size.to_le_bytes()); // size
        body.extend_from_slice(&3u32.to_le_bytes()); // kind = Model
        body.extend_from_slice(&0u32.to_le_bytes()); // raw_file_size
        body.extend_from_slice(&1025u32.to_le_bytes()); // number_of_blocks
        body.extend_from_slice(&1025u32.to_le_bytes()); // used_number_of_blocks
        body.extend_from_slice(&0u32.to_le_bytes()); // version
        body.extend_from_slice(&0u32.to_le_bytes()); // stack_size
        body.extend_from_slice(&0u32.to_le_bytes()); // runtime_size
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_stack_memory_size
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_runtime_memory_size
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u32.to_le_bytes()); // stack_offset
        body.extend_from_slice(&0u32.to_le_bytes()); // runtime_offset
        for _ in 0..9 {
            body.extend_from_slice(&0u32.to_le_bytes());
        }
        body.extend_from_slice(&0u16.to_le_bytes()); // stack_block_index
        body.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_index
        for _ in 0..9 {
            body.extend_from_slice(&0u16.to_le_bytes());
        }
        body.extend_from_slice(&1025u16.to_le_bytes()); // stack_block_num = 1025
        body.extend_from_slice(&0u16.to_le_bytes()); // runtime_block_num
        for _ in 0..9 {
            body.extend_from_slice(&0u16.to_le_bytes());
        }
        body.extend_from_slice(&0u16.to_le_bytes()); // vertex_declaration_num
        body.extend_from_slice(&0u16.to_le_bytes()); // material_num
        body.push(0); // num_lods
        body.push(0); // index_buffer_streaming_enabled
        body.push(0); // edge_geometry_enabled
        body.push(0); // padding
        // No actual u16 size table or block data needed — the cap check fires first.

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidBlockLayout {
                    context: "model.block_count",
                    ..
                }
            ),
            "expected InvalidBlockLayout(model.block_count) for block count > 1024, got {err:?}"
        );
    }

    // ----- Cap: MAX_TEXTURE_LOD_BLOCKS -----

    #[test]
    fn texture_decoder_rejects_lod_count_exceeding_cap() {
        // FileInfo declares 1025 LodBlock rows (number_of_blocks = 1025),
        // which exceeds MAX_TEXTURE_LOD_BLOCKS = 1024. The check fires before
        // any LodBlock is read.
        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&make_file_info(24, 4, 0, 1025));
        // No LodBlock data needed — the cap rejects it immediately.

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidBlockLayout {
                    context: "texture.lod_count",
                    ..
                }
            ),
            "expected InvalidBlockLayout(texture.lod_count) for lod_count > 1024, got {err:?}"
        );
    }

    // ----- Cap: MAX_TEXTURE_TOTAL_BLOCKS -----

    #[test]
    fn texture_decoder_rejects_aggregate_block_count_exceeding_cap() {
        // One LodBlock declaring `block_count = MAX_TEXTURE_TOTAL_BLOCKS + 1`
        // must fire `InvalidBlockLayout(texture.block_count)` before the
        // `vec![0u16; total_blocks]` allocation. An adversarial u32
        // `block_count = 0xFFFFFFFF` would otherwise drive that `Vec` to
        // ~8 GiB before the per-entry read could fail on EOF.
        let info_size = 24 + LOD_BLOCK_SIZE;
        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 4, 0, 1));
        let bad_block_count = u32::try_from(MAX_TEXTURE_TOTAL_BLOCKS + 1).unwrap();
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_offset
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_size
        body.extend_from_slice(&0u32.to_le_bytes()); // decompressed_size
        body.extend_from_slice(&0u32.to_le_bytes()); // block_offset
        body.extend_from_slice(&bad_block_count.to_le_bytes()); // block_count

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidBlockLayout {
                    context: "texture.block_count",
                    ..
                }
            ),
            "expected InvalidBlockLayout(texture.block_count), got {err:?}"
        );
    }

    // ----- Cap: MAX_TEXTURE_PREAMBLE_SIZE -----

    #[test]
    fn texture_decoder_rejects_preamble_size_exceeding_cap() {
        // LodBlock[0].compressed_offset = MAX_TEXTURE_PREAMBLE_SIZE + 1 must
        // fire `InvalidBlockLayout(texture.preamble_size)` before the
        // `Vec::resize` that would allocate ~1 MiB+ of preamble.
        let info_size = 24 + LOD_BLOCK_SIZE;
        let mut body: Vec<u8> = Vec::new();
        body.extend_from_slice(&make_file_info(info_size, 4, 0, 1)); // 1 LodBlock
        // One LodBlock with an oversized `compressed_offset`. The other
        // fields can be zero; the cap fires before they're consulted.
        let bad_offset = MAX_TEXTURE_PREAMBLE_SIZE + 1;
        body.extend_from_slice(&bad_offset.to_le_bytes()); // compressed_offset
        body.extend_from_slice(&0u32.to_le_bytes()); // compressed_size
        body.extend_from_slice(&0u32.to_le_bytes()); // decompressed_size
        body.extend_from_slice(&0u32.to_le_bytes()); // block_offset
        body.extend_from_slice(&0u32.to_le_bytes()); // block_count

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidBlockLayout {
                    context: "texture.preamble_size",
                    ..
                }
            ),
            "expected InvalidBlockLayout(texture.preamble_size), got {err:?}"
        );
    }

    // ----- Off-by-one: model_header.size exactly at boundary -----

    #[test]
    fn model_decoder_rejects_header_size_one_less_than_minimum() {
        // ModelHeader.size = MODEL_HEADER_SIZE - 1 must fire InvalidHeaderSize.
        let mut body: Vec<u8> = Vec::new();
        let bad_size = MODEL_HEADER_SIZE - 1;
        body.extend_from_slice(&bad_size.to_le_bytes()); // size (too small)
        body.extend_from_slice(&3u32.to_le_bytes()); // kind = Model
        // Pad out to at least MODEL_HEADER_SIZE bytes so the parse succeeds.
        body.resize(MODEL_HEADER_SIZE as usize, 0);

        let buf = make_dat_buf(&body);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let err = dat.read_file(u64::from(SQPACK_HEADER_SIZE)).unwrap_err();
        assert!(
            matches!(
                err,
                Error::InvalidHeaderSize {
                    field: "model_header.size",
                    value,
                    minimum: 208,
                } if value == bad_size
            ),
            "expected InvalidHeaderSize(model_header.size), got {err:?}"
        );
    }

    #[test]
    fn model_decoder_accepts_header_size_exactly_at_minimum() {
        // ModelHeader.size == MODEL_HEADER_SIZE with zero blocks — must succeed.
        let (buf, info_off) = make_model_dat(&[]);
        let mut dat = DatFile::from_buffer(buf).unwrap();
        let out = dat.read_file(info_off).unwrap();
        assert_eq!(out, Vec::<u8>::new());
    }
}
