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}