zipatch-rs 1.0.0

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 used by older patch files.
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct FileHeaderV2 {
    /// 4-byte patch type tag (e.g. `b"D000"`).
    pub patch_type: [u8; 4],
    /// Number of entry files declared in the patch.
    pub entry_files: u32,
}

/// `FHDR` v3 body: full patch metadata for modern patch files.
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct FileHeaderV3 {
    /// 4-byte patch type tag (e.g. `b"D000"`).
    pub patch_type: [u8; 4],
    /// Number of entry files declared in the patch.
    pub entry_files: u32,
    /// Count of `ADIR` chunks in the patch.
    pub add_directories: u32,
    /// Count of `DELD` chunks in the patch.
    pub delete_directories: u32,
    /// Total bytes that will be removed by SQPK delete commands.
    // Wire: lo_u32_be then hi_u32_be, combined as lo | (hi << 32)
    #[br(parse_with = read_split_u64)]
    pub delete_data_size: u64,
    /// Minor version number.
    pub minor_version: u32,
    /// Repository identifier the patch targets.
    pub repository_name: u32,
    /// Total command count across all SQPK sub-commands.
    pub commands: u32,
    /// Count of SQPK `A` (add data) commands.
    pub sqpk_add_commands: u32,
    /// Count of SQPK `D` (delete data) commands.
    pub sqpk_delete_commands: u32,
    /// Count of SQPK `E` (expand data) commands.
    pub sqpk_expand_commands: u32,
    /// Count of SQPK `H` (header) commands.
    pub sqpk_header_commands: u32,
    /// Count of SQPK `F` (file) commands.
    pub sqpk_file_commands: u32,
}

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.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileHeader {
    /// Version 2 header body.
    V2(FileHeaderV2),
    /// Version 3 header body.
    V3(FileHeaderV3),
}

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

    /// Returns the 4-byte patch type tag (e.g. `b"D000"`).
    #[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);
    // C# ReadUInt32() is LE; version is bits 16-23 of that 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");
    }
}