Skip to main content

zipatch_rs/chunk/
fhdr.rs

1use binrw::{BinRead, BinResult, Endian};
2use std::io::Cursor;
3use tracing::trace;
4
5/// `FHDR` v2 body: minimal patch metadata from older patch files.
6///
7/// Version 2 headers appear in early FFXIV patch files. They carry only the
8/// patch type tag and an entry-file count; the extended statistics present in
9/// v3 are absent.
10///
11/// After the two fields listed here, the wire format includes 8 trailing zero
12/// bytes that the parser intentionally ignores.
13#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
14#[br(big)]
15pub struct FileHeaderV2 {
16    /// 4-byte ASCII patch type tag, e.g. `b"D000"` for a game-data patch or
17    /// `b"H000"` for a boot/header patch.
18    pub patch_type: [u8; 4],
19    /// Number of `SqPack` entry files referenced by this patch.
20    ///
21    /// This is an informational counter; the patcher does not use it to
22    /// pre-allocate or validate anything at apply time.
23    pub entry_files: u32,
24}
25
26/// `FHDR` v3 body: full patch metadata for modern FFXIV patch files.
27///
28/// All XIVARR+ game-data patches use version 3. The additional fields provide
29/// a complete statistical summary of the patch content that patch management
30/// tooling can use without scanning every chunk. The counts are informational
31/// and are not verified during apply.
32///
33/// After the fields listed here, the wire format includes 0xB8 bytes of
34/// trailing unknown data that the parser intentionally ignores.
35#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
36#[br(big)]
37pub struct FileHeaderV3 {
38    /// 4-byte ASCII patch type tag, e.g. `b"D000"` for a game-data patch or
39    /// `b"H000"` for a boot/header patch.
40    pub patch_type: [u8; 4],
41    /// Number of `SqPack` entry files referenced by this patch.
42    pub entry_files: u32,
43    /// Number of `ADIR` (add directory) chunks in the patch stream.
44    pub add_directories: u32,
45    /// Number of `DELD` (delete directory) chunks in the patch stream.
46    pub delete_directories: u32,
47    /// Total bytes that will be zeroed or removed by SQPK `D` (delete data)
48    /// commands.
49    ///
50    /// Stored on the wire as two consecutive `u32 BE` values in
51    /// low-word / high-word order (i.e. `lo | (hi << 32)`). This matches the
52    /// C# comment "Split in 2 DWORD; Low, High". The `read_split_u64` parser
53    /// reconstructs the full 64-bit value.
54    #[br(parse_with = read_split_u64)]
55    pub delete_data_size: u64,
56    /// Minor format version of the patch file.
57    pub minor_version: u32,
58    /// Opaque identifier for the `SqPack` repository this patch targets.
59    ///
60    /// In practice this corresponds to the game repository (e.g. `ffxiv` vs.
61    /// expansion repositories), but the crate treats it as an opaque `u32`.
62    pub repository_name: u32,
63    /// Total count of all SQPK sub-commands across the patch stream.
64    pub commands: u32,
65    /// Count of SQPK `A` (add data) sub-commands.
66    pub sqpk_add_commands: u32,
67    /// Count of SQPK `D` (delete data) sub-commands.
68    pub sqpk_delete_commands: u32,
69    /// Count of SQPK `E` (expand data) sub-commands.
70    pub sqpk_expand_commands: u32,
71    /// Count of SQPK `H` (header write) sub-commands.
72    pub sqpk_header_commands: u32,
73    /// Count of SQPK `F` (file operation) sub-commands.
74    pub sqpk_file_commands: u32,
75}
76
77/// Read two consecutive `u32 BE` values and combine them as `lo | (hi << 32)`.
78///
79/// Used for the `delete_data_size` field in [`FileHeaderV3`], which the wire
80/// format stores split across two 32-bit words (low word first, high word
81/// second) rather than as a single `u64`.
82fn read_split_u64<R: std::io::Read + std::io::Seek>(
83    reader: &mut R,
84    endian: Endian,
85    (): (),
86) -> BinResult<u64> {
87    let lo = <u32 as BinRead>::read_options(reader, endian, ())? as u64;
88    let hi = <u32 as BinRead>::read_options(reader, endian, ())? as u64;
89    Ok(lo | (hi << 32))
90}
91
92/// `FHDR` chunk: patch file header with a version-specific body.
93///
94/// This is always the first chunk in a well-formed `ZiPatch` file, immediately
95/// following the 12-byte file magic. The version byte selects whether a v2 or
96/// v3 body follows.
97///
98/// # Wire format
99///
100/// ```text
101/// [version_word: i32 LE]   -- version = bits 16..23 of this word
102/// [body: version-specific] -- FileHeaderV2 or FileHeaderV3 (big-endian)
103/// [trailing: 0x08 or 0xB8 bytes of zeros/unknowns, ignored]
104/// ```
105///
106/// The version word is the only little-endian field in the chunk. The rest of
107/// the header is big-endian, matching the rest of the format.
108///
109/// # Errors
110///
111/// Parsing fails with [`crate::ParseError::Decode`] if the body is too
112/// short to contain the full version-specific fields.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum FileHeader {
115    /// Version 2 header — older patches with minimal metadata.
116    V2(FileHeaderV2),
117    /// Version 3 header — modern patches with full statistical summary.
118    V3(FileHeaderV3),
119}
120
121impl FileHeader {
122    /// Returns the format version number: `2` for [`FileHeader::V2`], `3` for
123    /// [`FileHeader::V3`].
124    #[must_use]
125    pub fn version(&self) -> u8 {
126        match self {
127            FileHeader::V2(_) => 2,
128            FileHeader::V3(_) => 3,
129        }
130    }
131
132    /// Returns the 4-byte ASCII patch type tag (e.g. `b"D000"` or `b"H000"`).
133    ///
134    /// Delegates to the inner [`FileHeaderV2::patch_type`] or
135    /// [`FileHeaderV3::patch_type`] field without copying.
136    #[must_use]
137    pub fn patch_type(&self) -> &[u8; 4] {
138        match self {
139            FileHeader::V2(h) => &h.patch_type,
140            FileHeader::V3(h) => &h.patch_type,
141        }
142    }
143}
144
145pub(crate) fn parse(body: &[u8]) -> crate::ParseResult<FileHeader> {
146    let mut c = Cursor::new(body);
147    // The version word is little-endian (matching C# BinaryReader.ReadUInt32());
148    // version is extracted from bits 16..23 of that 32-bit word.
149    let version_word = <i32 as BinRead>::read_le(&mut c)?;
150    let version = (version_word as u32 >> 16) as u8;
151    if version == 3 {
152        let v3 = FileHeaderV3::read_be(&mut c)?;
153        // 0xB8 bytes of trailing unknowns — ignored, cursor is bounded by body slice
154        trace!(version = 3, entry_files = v3.entry_files, "file header");
155        Ok(FileHeader::V3(v3))
156    } else {
157        let v2 = FileHeaderV2::read_be(&mut c)?;
158        // 0x08 bytes of trailing zeros — ignored
159        trace!(version = 2, entry_files = v2.entry_files, "file header");
160        Ok(FileHeader::V2(v2))
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn v2() -> FileHeader {
169        FileHeader::V2(FileHeaderV2 {
170            patch_type: *b"D000",
171            entry_files: 1,
172        })
173    }
174
175    fn v3() -> FileHeader {
176        FileHeader::V3(FileHeaderV3 {
177            patch_type: *b"H000",
178            entry_files: 5,
179            add_directories: 0,
180            delete_directories: 0,
181            delete_data_size: 0,
182            minor_version: 0,
183            repository_name: 0,
184            commands: 0,
185            sqpk_add_commands: 0,
186            sqpk_delete_commands: 0,
187            sqpk_expand_commands: 0,
188            sqpk_header_commands: 0,
189            sqpk_file_commands: 0,
190        })
191    }
192
193    #[test]
194    fn version_returns_2_for_v2() {
195        assert_eq!(v2().version(), 2);
196    }
197
198    #[test]
199    fn version_returns_3_for_v3() {
200        assert_eq!(v3().version(), 3);
201    }
202
203    #[test]
204    fn patch_type_returns_v2_tag() {
205        assert_eq!(v2().patch_type(), b"D000");
206    }
207
208    #[test]
209    fn patch_type_returns_v3_tag() {
210        assert_eq!(v3().patch_type(), b"H000");
211    }
212}