zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
use binrw::{BinRead, BinResult, Endian};
use std::io::Cursor;
use tracing::debug;

/// `FHDR` v2 body: minimal patch metadata from older patch files.
///
/// Version 2 headers appear in early FFXIV patch files. They carry only the
/// patch type tag and an entry-file count; the extended statistics present in
/// v3 are absent.
///
/// After the two fields listed here, the wire format includes 8 trailing zero
/// bytes that the parser intentionally ignores.
///
/// See `lib/FFXIVQuickLauncher/.../Chunk/FileHeaderChunk.cs` (the `ReadChunk`
/// method, version ≠ 3 branch).
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct FileHeaderV2 {
    /// 4-byte ASCII patch type tag, e.g. `b"D000"` for a game-data patch or
    /// `b"H000"` for a boot/header patch.
    pub patch_type: [u8; 4],
    /// Number of `SqPack` entry files referenced by this patch.
    ///
    /// This is an informational counter; the patcher does not use it to
    /// pre-allocate or validate anything at apply time.
    pub entry_files: u32,
}

/// `FHDR` v3 body: full patch metadata for modern FFXIV patch files.
///
/// All XIVARR+ game-data patches use version 3. The additional fields provide
/// a complete statistical summary of the patch content that patch management
/// tooling can use without scanning every chunk. The counts are informational
/// and are not verified during apply.
///
/// After the fields listed here, the wire format includes 0xB8 bytes of
/// trailing unknown data that the parser intentionally ignores (the reference
/// calls them "probably irrelevant").
///
/// See `lib/FFXIVQuickLauncher/.../Chunk/FileHeaderChunk.cs` (the `ReadChunk`
/// method, version == 3 branch).
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct FileHeaderV3 {
    /// 4-byte ASCII patch type tag, e.g. `b"D000"` for a game-data patch or
    /// `b"H000"` for a boot/header patch.
    pub patch_type: [u8; 4],
    /// Number of `SqPack` entry files referenced by this patch.
    pub entry_files: u32,
    /// Number of `ADIR` (add directory) chunks in the patch stream.
    pub add_directories: u32,
    /// Number of `DELD` (delete directory) chunks in the patch stream.
    pub delete_directories: u32,
    /// Total bytes that will be zeroed or removed by SQPK `D` (delete data)
    /// commands.
    ///
    /// Stored on the wire as two consecutive `u32 BE` values in
    /// low-word / high-word order (i.e. `lo | (hi << 32)`). This matches the
    /// C# comment "Split in 2 DWORD; Low, High". The `read_split_u64` parser
    /// reconstructs the full 64-bit value.
    #[br(parse_with = read_split_u64)]
    pub delete_data_size: u64,
    /// Minor format version of the patch file.
    pub minor_version: u32,
    /// Opaque identifier for the `SqPack` repository this patch targets.
    ///
    /// In practice this corresponds to the game repository (e.g. `ffxiv` vs.
    /// expansion repositories), but the crate treats it as an opaque `u32`.
    pub repository_name: u32,
    /// Total count of all SQPK sub-commands across the patch stream.
    pub commands: u32,
    /// Count of SQPK `A` (add data) sub-commands.
    pub sqpk_add_commands: u32,
    /// Count of SQPK `D` (delete data) sub-commands.
    pub sqpk_delete_commands: u32,
    /// Count of SQPK `E` (expand data) sub-commands.
    pub sqpk_expand_commands: u32,
    /// Count of SQPK `H` (header write) sub-commands.
    pub sqpk_header_commands: u32,
    /// Count of SQPK `F` (file operation) sub-commands.
    pub sqpk_file_commands: u32,
}

/// Read two consecutive `u32 BE` values and combine them as `lo | (hi << 32)`.
///
/// Used for the `delete_data_size` field in [`FileHeaderV3`], which the wire
/// format stores split across two 32-bit words (low word first, high word
/// second) rather than as a single `u64`.
fn read_split_u64<R: std::io::Read + std::io::Seek>(
    reader: &mut R,
    endian: Endian,
    (): (),
) -> BinResult<u64> {
    let lo = <u32 as BinRead>::read_options(reader, endian, ())? as u64;
    let hi = <u32 as BinRead>::read_options(reader, endian, ())? as u64;
    Ok(lo | (hi << 32))
}

/// `FHDR` chunk: patch file header with a version-specific body.
///
/// This is always the first chunk in a well-formed `ZiPatch` file, immediately
/// following the 12-byte file magic. The version byte selects whether a v2 or
/// v3 body follows.
///
/// # Wire format
///
/// ```text
/// [version_word: i32 LE]   -- version = bits 16..23 of this word
/// [body: version-specific] -- FileHeaderV2 or FileHeaderV3 (big-endian)
/// [trailing: 0x08 or 0xB8 bytes of zeros/unknowns, ignored]
/// ```
///
/// The version word is the only little-endian field in the chunk. The rest of
/// the header is big-endian, matching the rest of the format.
///
/// # Errors
///
/// Parsing fails with [`crate::ZiPatchError::BinrwError`] if the body is too
/// short to contain the full version-specific fields.
///
/// See `lib/FFXIVQuickLauncher/.../Chunk/FileHeaderChunk.cs`.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileHeader {
    /// Version 2 header — older patches with minimal metadata.
    V2(FileHeaderV2),
    /// Version 3 header — modern patches with full statistical summary.
    V3(FileHeaderV3),
}

impl FileHeader {
    /// Returns the format version number: `2` for [`FileHeader::V2`], `3` for
    /// [`FileHeader::V3`].
    #[must_use]
    pub fn version(&self) -> u8 {
        match self {
            FileHeader::V2(_) => 2,
            FileHeader::V3(_) => 3,
        }
    }

    /// Returns the 4-byte ASCII patch type tag (e.g. `b"D000"` or `b"H000"`).
    ///
    /// Delegates to the inner [`FileHeaderV2::patch_type`] or
    /// [`FileHeaderV3::patch_type`] field without copying.
    #[must_use]
    pub fn patch_type(&self) -> &[u8; 4] {
        match self {
            FileHeader::V2(h) => &h.patch_type,
            FileHeader::V3(h) => &h.patch_type,
        }
    }
}

pub(crate) fn parse(body: &[u8]) -> crate::Result<FileHeader> {
    let mut c = Cursor::new(body);
    // The version word is little-endian (matching C# BinaryReader.ReadUInt32());
    // version is extracted from bits 16..23 of that 32-bit word.
    let version_word = <i32 as BinRead>::read_le(&mut c)?;
    let version = (version_word as u32 >> 16) as u8;
    if version == 3 {
        let v3 = FileHeaderV3::read_be(&mut c)?;
        // 0xB8 bytes of trailing unknowns — ignored, cursor is bounded by body slice
        debug!(version = 3, entry_files = v3.entry_files, "file header");
        Ok(FileHeader::V3(v3))
    } else {
        let v2 = FileHeaderV2::read_be(&mut c)?;
        // 0x08 bytes of trailing zeros — ignored
        debug!(version = 2, entry_files = v2.entry_files, "file header");
        Ok(FileHeader::V2(v2))
    }
}

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

    fn v2() -> FileHeader {
        FileHeader::V2(FileHeaderV2 {
            patch_type: *b"D000",
            entry_files: 1,
        })
    }

    fn v3() -> FileHeader {
        FileHeader::V3(FileHeaderV3 {
            patch_type: *b"H000",
            entry_files: 5,
            add_directories: 0,
            delete_directories: 0,
            delete_data_size: 0,
            minor_version: 0,
            repository_name: 0,
            commands: 0,
            sqpk_add_commands: 0,
            sqpk_delete_commands: 0,
            sqpk_expand_commands: 0,
            sqpk_header_commands: 0,
            sqpk_file_commands: 0,
        })
    }

    #[test]
    fn version_returns_2_for_v2() {
        assert_eq!(v2().version(), 2);
    }

    #[test]
    fn version_returns_3_for_v3() {
        assert_eq!(v3().version(), 3);
    }

    #[test]
    fn patch_type_returns_v2_tag() {
        assert_eq!(v2().patch_type(), b"D000");
    }

    #[test]
    fn patch_type_returns_v3_tag() {
        assert_eq!(v3().patch_type(), b"H000");
    }
}