zipatch_rs/chunk/sqpk/header.rs
1use binrw::{BinRead, BinResult, Endian};
2use std::io::Cursor;
3
4use super::SqpackFile;
5
6/// Which `SqPack` file kind a [`SqpkHeader`] targets.
7///
8/// Encoded as a single ASCII byte in the wire format:
9/// `b'D'` → [`Dat`](TargetFileKind::Dat), `b'I'` → [`Index`](TargetFileKind::Index).
10/// Any other byte is rejected with a [`binrw::Error::Custom`].
11///
12/// See `SqpkHeader.cs` in the `XIVLauncher` reference implementation.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TargetFileKind {
15 /// Target is a `.datN` data file.
16 Dat,
17 /// Target is a `.indexN` index file.
18 Index,
19}
20
21/// Which header slot a [`SqpkHeader`] writes into.
22///
23/// `SqPack` files contain two 1024-byte header regions at fixed offsets:
24///
25/// | Variant | File offset | Description |
26/// |---------|------------|-------------|
27/// | [`Version`](TargetHeaderKind::Version) | 0 | Version/magic header |
28/// | [`Index`](TargetHeaderKind::Index) | 1024 | Index structure header |
29/// | [`Data`](TargetHeaderKind::Data) | 1024 | Data structure header |
30///
31/// Both `Index` and `Data` map to file offset 1024; the distinction is semantic
32/// (which file type they accompany) but the write offset is the same for both.
33///
34/// Encoded as a single ASCII byte: `b'V'` → `Version`, `b'I'` → `Index`,
35/// `b'D'` → `Data`. Any other byte is rejected with a [`binrw::Error::Custom`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TargetHeaderKind {
38 /// Version header, written at file offset `0`.
39 Version,
40 /// Index header, written at file offset `1024`.
41 Index,
42 /// Data header, written at file offset `1024`.
43 Data,
44}
45
46/// Resolved file target for a [`SqpkHeader`], tagged by [`TargetFileKind`].
47///
48/// Wraps the [`SqpackFile`] identifier so that the apply layer can resolve the
49/// correct on-disk path without carrying a separate `TargetFileKind` value.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum SqpkHeaderTarget {
52 /// The header is destined for a `.datN` file.
53 Dat(SqpackFile),
54 /// The header is destined for a `.indexN` file.
55 Index(SqpackFile),
56}
57
58fn read_file_kind<R: std::io::Read + std::io::Seek>(
59 reader: &mut R,
60 _: Endian,
61 (): (),
62) -> BinResult<TargetFileKind> {
63 let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
64 match byte {
65 b'D' => Ok(TargetFileKind::Dat),
66 b'I' => Ok(TargetFileKind::Index),
67 _ => Err(binrw::Error::Custom {
68 pos: 0,
69 err: Box::new(std::io::Error::new(
70 std::io::ErrorKind::InvalidData,
71 "unknown SqpkHeader file kind",
72 )),
73 }),
74 }
75}
76
77fn read_header_kind<R: std::io::Read + std::io::Seek>(
78 reader: &mut R,
79 _: Endian,
80 (): (),
81) -> BinResult<TargetHeaderKind> {
82 let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
83 match byte {
84 b'V' => Ok(TargetHeaderKind::Version),
85 b'I' => Ok(TargetHeaderKind::Index),
86 b'D' => Ok(TargetHeaderKind::Data),
87 _ => Err(binrw::Error::Custom {
88 pos: 0,
89 err: Box::new(std::io::Error::new(
90 std::io::ErrorKind::InvalidData,
91 "unknown SqpkHeader header kind",
92 )),
93 }),
94 }
95}
96
97fn read_header_target<R: std::io::Read + std::io::Seek>(
98 reader: &mut R,
99 endian: Endian,
100 (file_kind,): (&TargetFileKind,),
101) -> BinResult<SqpkHeaderTarget> {
102 let f = SqpackFile::read_options(reader, endian, ())?;
103 match file_kind {
104 TargetFileKind::Dat => Ok(SqpkHeaderTarget::Dat(f)),
105 TargetFileKind::Index => Ok(SqpkHeaderTarget::Index(f)),
106 }
107}
108
109/// SQPK `H` command body: write a 1024-byte `SqPack` header into a target file.
110///
111/// Every `SqPack` file (both `.dat` and `.index`) begins with one or two
112/// 1024-byte header blocks at fixed offsets. The `H` command replaces one of
113/// these headers atomically as part of a patch.
114///
115/// ## Wire format (all big-endian unless noted)
116///
117/// ```text
118/// ┌──────────────────────────────────────────────────────────────┐
119/// │ file_kind : u8 b'D' = Dat, b'I' = Index │ byte 0
120/// │ header_kind : u8 b'V' = Version, b'I' = Index, b'D' = Data │ byte 1
121/// │ <padding> : u8 (reserved, always 0) │ byte 2
122/// │ main_id : u16 BE SqPack category ID │ bytes 3–4
123/// │ sub_id : u16 BE SqPack sub-category ID │ bytes 5–6
124/// │ file_id : u32 BE dat/index file index │ bytes 7–10
125/// │ header_data : [u8; 1024] raw header bytes │ bytes 11–1034
126/// └──────────────────────────────────────────────────────────────┘
127/// ```
128///
129/// ## Apply behaviour
130///
131/// - [`TargetHeaderKind::Version`] → write `header_data` at file offset **0**.
132/// - [`TargetHeaderKind::Index`] or [`TargetHeaderKind::Data`] → write at
133/// file offset **1024**.
134///
135/// The target file is opened via the apply context's handle cache; the write
136/// does not truncate or resize the file.
137///
138/// ## Reference
139///
140/// See `SqpkHeader.cs` in the `XIVLauncher` reference implementation.
141///
142/// # Errors
143///
144/// Parsing returns [`crate::ZiPatchError::BinrwError`] if:
145/// - `file_kind` is not `b'D'` or `b'I'`.
146/// - `header_kind` is not `b'V'`, `b'I'`, or `b'D'`.
147/// - The body is too short to contain a full 1024-byte `header_data`.
148#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
149#[br(big)]
150pub struct SqpkHeader {
151 /// Whether the operation targets a `.dat` or `.index` file.
152 ///
153 /// Parsed from a single ASCII byte: `b'D'` → `Dat`, `b'I'` → `Index`.
154 #[br(parse_with = read_file_kind)]
155 pub file_kind: TargetFileKind,
156 /// Which of the two header slots to overwrite.
157 ///
158 /// Parsed from a single ASCII byte: `b'V'` → `Version` (offset 0),
159 /// `b'I'` → `Index` (offset 1024), `b'D'` → `Data` (offset 1024).
160 #[br(parse_with = read_header_kind)]
161 pub header_kind: TargetHeaderKind,
162 /// The target `SqPack` file, tagged by [`file_kind`](SqpkHeader::file_kind)
163 /// so the apply layer can resolve the correct path without carrying a
164 /// separate kind value.
165 ///
166 /// Preceded by 1 byte of alignment padding in the wire format.
167 #[br(pad_before = 1, parse_with = read_header_target, args(&file_kind))]
168 pub target: SqpkHeaderTarget,
169 /// The 1024-byte block to write into the target file's header slot.
170 ///
171 /// The content follows the `SqPack` header structure defined by Square
172 /// Enix; this crate treats it as an opaque byte array and writes it
173 /// verbatim.
174 #[br(count = 1024)]
175 pub header_data: Vec<u8>,
176}
177
178pub(crate) fn parse(body: &[u8]) -> crate::Result<SqpkHeader> {
179 Ok(SqpkHeader::read_be(&mut Cursor::new(body))?)
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn parses_header_dat_version() {
188 let mut body = Vec::new();
189 body.push(b'D'); // file_kind = Dat
190 body.push(b'V'); // header_kind = Version
191 body.push(0u8); // alignment
192 body.extend_from_slice(&10u16.to_be_bytes()); // main_id
193 body.extend_from_slice(&20u16.to_be_bytes()); // sub_id
194 body.extend_from_slice(&0u32.to_be_bytes()); // file_id
195 body.extend_from_slice(&[0xCCu8; 1024]); // header_data
196
197 let cmd = parse(&body).unwrap();
198 assert!(matches!(cmd.file_kind, TargetFileKind::Dat));
199 assert!(matches!(cmd.header_kind, TargetHeaderKind::Version));
200 match cmd.target {
201 SqpkHeaderTarget::Dat(f) => {
202 assert_eq!(f.main_id, 10);
203 assert_eq!(f.sub_id, 20);
204 }
205 other @ SqpkHeaderTarget::Index(_) => {
206 panic!("expected SqpkHeaderTarget::Dat, got {other:?}")
207 }
208 }
209 assert_eq!(cmd.header_data.len(), 1024);
210 }
211
212 #[test]
213 fn rejects_unknown_file_kind() {
214 let mut body = Vec::new();
215 body.push(b'Z'); // invalid
216 body.push(b'V');
217 body.push(0u8);
218 body.extend_from_slice(&[0u8; 8 + 1024]);
219 assert!(parse(&body).is_err());
220 }
221
222 #[test]
223 fn rejects_unknown_header_kind() {
224 let mut body = Vec::new();
225 body.push(b'D');
226 body.push(b'Z'); // invalid header_kind
227 body.push(0u8);
228 body.extend_from_slice(&[0u8; 8 + 1024]);
229 assert!(parse(&body).is_err());
230 }
231
232 #[test]
233 fn parses_header_index_file() {
234 let mut body = Vec::new();
235 body.push(b'I'); // file_kind = Index
236 body.push(b'I'); // header_kind = Index
237 body.push(0u8);
238 body.extend_from_slice(&7u16.to_be_bytes()); // main_id
239 body.extend_from_slice(&8u16.to_be_bytes()); // sub_id
240 body.extend_from_slice(&0u32.to_be_bytes()); // file_id
241 body.extend_from_slice(&[0xBBu8; 1024]);
242
243 let cmd = parse(&body).unwrap();
244 assert!(matches!(cmd.file_kind, TargetFileKind::Index));
245 assert!(matches!(cmd.header_kind, TargetHeaderKind::Index));
246 match cmd.target {
247 SqpkHeaderTarget::Index(f) => {
248 assert_eq!(f.main_id, 7);
249 assert_eq!(f.sub_id, 8);
250 }
251 other @ SqpkHeaderTarget::Dat(_) => {
252 panic!("expected SqpkHeaderTarget::Index, got {other:?}")
253 }
254 }
255 assert_eq!(cmd.header_data.len(), 1024);
256 }
257
258 #[test]
259 fn header_data_truncated() {
260 let mut body = Vec::new();
261 body.push(b'D');
262 body.push(b'V');
263 body.push(0u8);
264 body.extend_from_slice(&[0u8; 8]);
265 body.extend_from_slice(&[0u8; 512]); // only 512, need 1024
266 assert!(parse(&body).is_err());
267 }
268}