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