zipatch_rs/chunk/sqpk/mod.rs
1//! SQPK chunk sub-commands and their dispatcher.
2//!
3//! A `ZiPatch` file is made up of top-level chunks; the `SQPK` chunk type is the
4//! workhorse — it covers the vast majority of the data in any patch file.
5//! Inside each `SQPK` chunk sits a one-byte sub-command tag that selects the
6//! specific archive operation to perform.
7//!
8//! # Sub-command overview
9//!
10//! | Tag | Type | Purpose |
11//! |-----|------|---------|
12//! | `A` | [`SqpkAddData`] | Write a data payload into a `.dat` file at a block offset |
13//! | `D` | [`SqpkDeleteData`] | Overwrite a block range with empty-block markers |
14//! | `E` | [`SqpkExpandData`] | Grow a `.dat` file by adding empty-block markers |
15//! | `H` | [`SqpkHeader`] | Write a 1024-byte `SqPack` header |
16//! | `T` | [`SqpkTargetInfo`] | Declare the target platform and region |
17//! | `F` | [`SqpkFile`] | File-level operation (add, delete, directory creation) |
18//! | `I` | [`SqpkIndex`] | Index entry metadata (not applied directly to disk) |
19//! | `X` | [`SqpkPatchInfo`] | Patch install metadata (not applied directly to disk) |
20//!
21//! Unknown sub-command bytes are surfaced as [`ParseError::UnknownSqpkCommand`].
22//!
23//! # Wire format
24//!
25//! The SQPK chunk body begins with a 4-byte big-endian `i32` (`inner_size`) that
26//! must equal the total body length, followed immediately by the 1-byte sub-command
27//! tag. The remaining bytes starting at offset 5 form the sub-command body and are
28//! forwarded to the per-command parser.
29//!
30//! ```text
31//! ┌────────────────────────────────────────────────────┐
32//! │ inner_size : i32 BE (== body.len()) │ bytes 0–3
33//! │ sub-command : u8 ('A','D','E','H','T','F',…) │ byte 4
34//! │ sub-cmd body : [u8] (varies by sub-command) │ bytes 5–…
35//! └────────────────────────────────────────────────────┘
36//! ```
37//!
38//! # Block-offset units
39//!
40//! Unless otherwise noted, offsets in SQPK sub-commands are stored as raw `u32`
41//! values that must be multiplied by 128 (`<< 7`) to obtain byte offsets. The
42//! shift is applied automatically during parsing by the `#[br(map = ...)]`
43//! attributes on each struct field; by the time a parsed struct reaches calling
44//! code, all `block_offset` fields are already in bytes.
45
46pub(crate) mod add_data;
47pub(crate) mod delete_data;
48pub(crate) mod expand_data;
49pub(crate) mod file;
50pub(crate) mod header;
51pub(crate) mod index;
52pub(crate) mod target_info;
53
54pub use add_data::SqpkAddData;
55pub use delete_data::SqpkDeleteData;
56pub use expand_data::SqpkExpandData;
57pub use file::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
58pub use header::{SqpkHeader, SqpkHeaderTarget, TargetFileKind, TargetHeaderKind};
59pub use index::{IndexCommand, SqpkIndex, SqpkPatchInfo};
60pub use target_info::SqpkTargetInfo;
61
62use crate::{ParseError, ParseResult as Result};
63use binrw::BinRead;
64
65/// Identifier of a `SqPack` file targeted by a SQPK command.
66///
67/// `SqPack` files live under
68/// `<game_root>/sqpack/<expansion>/<main_id:02x><sub_id:04x>.<platform>.<kind>`.
69/// The three fields together uniquely address one file on disk; see the
70/// `apply::path` module for how they are combined into filesystem paths.
71#[derive(BinRead, Debug, Clone, PartialEq, Eq, Hash)]
72#[br(big)]
73pub struct SqpackFileId {
74 /// Category/repository identifier — the first two hex digits of the filename
75 /// stem (e.g. `04` in `040100.win32.dat0`).
76 ///
77 /// Encoded as a big-endian `u16` (2 bytes) in the wire format.
78 pub main_id: u16,
79 /// Sub-category identifier — the next four hex digits of the filename stem
80 /// (e.g. `0100` in `040100.win32.dat0`).
81 ///
82 /// Encoded as a big-endian `u16` (2 bytes). The **high byte** (`sub_id >> 8`)
83 /// selects the expansion folder: `0` → `ffxiv`, `1` → `ex1`, `2` → `ex2`, etc.
84 pub sub_id: u16,
85 /// File index within the category, used to derive the numeric suffix:
86 ///
87 /// - For `.dat` files: appended directly as the decimal `N` in `.datN`.
88 /// - For `.index` files: `0` produces no suffix (`.index`); `1` or higher
89 /// appends the value directly (`.index1`, `.index2`, …).
90 ///
91 /// Encoded as a big-endian `u32` (4 bytes).
92 pub file_id: u32,
93}
94
95/// Sub-command of a `SQPK` chunk; the variant is selected by the command byte.
96///
97/// Each variant wraps the parsed body of its corresponding sub-command.
98/// `AddData` and `File` are heap-allocated to keep the enum from inflating the
99/// stack when used in iterators — `SqpkAddData` can carry a large inline data
100/// `Vec`, and `SqpkFile` carries both a path and a `Vec` of compressed blocks.
101///
102/// Two variants — `Index` and `PatchInfo` — carry metadata consumed by the
103/// indexed `ZiPatch` reader (not yet implemented) and have no direct filesystem
104/// effect; their [`SqpkCommand::apply`] arms return `Ok(())` immediately.
105///
106/// Unknown sub-command bytes are never silently ignored: they surface as
107/// [`ParseError::UnknownSqpkCommand`] during parsing.
108#[derive(Debug)]
109pub enum SqpkCommand {
110 /// SQPK `A` — write a data payload into a `.dat` file at a block offset,
111 /// then zero a trailing range of blocks. See [`SqpkAddData`].
112 AddData(Box<SqpkAddData>),
113 /// SQPK `D` — overwrite a contiguous block range with empty-block markers,
114 /// logically freeing those blocks in the `SqPack` archive. See [`SqpkDeleteData`].
115 DeleteData(SqpkDeleteData),
116 /// SQPK `E` — extend a `.dat` file into previously unallocated space by
117 /// writing empty-block markers. See [`SqpkExpandData`].
118 ExpandData(SqpkExpandData),
119 /// SQPK `H` — write a 1024-byte `SqPack` header into a `.dat` or `.index`
120 /// file at offset 0 (version header) or 1024 (secondary header). See [`SqpkHeader`].
121 Header(SqpkHeader),
122 /// SQPK `T` — declares the target platform and region for all subsequent
123 /// path-resolution operations in this patch. See [`SqpkTargetInfo`].
124 TargetInfo(SqpkTargetInfo),
125 /// SQPK `F` — file-level operation: add a file from inline block payloads,
126 /// delete a file, remove all files in an expansion folder, or create a
127 /// directory tree. See [`SqpkFile`] and [`SqpkFileOperation`].
128 File(Box<SqpkFile>),
129 /// SQPK `I` — add or remove a single `SqPack` index entry. Carries the
130 /// index hash and block location for use by the indexed `ZiPatch` reader;
131 /// has no direct apply effect. See [`SqpkIndex`].
132 Index(SqpkIndex),
133 /// SQPK `X` — patch install info: status, version, and declared post-patch
134 /// install size. Metadata only; not applied to the filesystem. See [`SqpkPatchInfo`].
135 PatchInfo(SqpkPatchInfo),
136}
137
138/// Parse a SQPK chunk body into a [`SqpkCommand`] variant.
139///
140/// `body` must be the raw bytes of the entire SQPK chunk body (everything after
141/// the outer chunk header that the [`crate::chunk`] layer strips). The first
142/// 4 bytes are a big-endian `i32` (`inner_size`) that is validated against
143/// `body.len()`; byte 4 is the sub-command tag; bytes 5 onward are forwarded to
144/// the per-command parser.
145///
146/// # Errors
147///
148/// - [`ParseError::InvalidField`] — the `inner_size` field does not equal
149/// `body.len()`.
150/// - [`ParseError::UnknownSqpkCommand`] — the sub-command byte is not one of
151/// the recognised tags (`A`, `D`, `E`, `H`, `T`, `F`, `I`, `X`).
152/// - Any [`ParseError`] returned by the per-command parser (e.g.
153/// [`ParseError::Decode`] for a truncated sub-command body, or
154/// [`ParseError::UnknownFileOperation`] for an unrecognised `F` operation byte).
155pub(crate) fn parse_sqpk(body: &[u8]) -> Result<SqpkCommand> {
156 if body.len() < 5 {
157 return Err(ParseError::Io {
158 source: std::io::Error::new(
159 std::io::ErrorKind::UnexpectedEof,
160 "SQPK body shorter than 5 bytes",
161 ),
162 });
163 }
164 let inner_size = i32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
165 if inner_size != body.len() {
166 return Err(ParseError::InvalidField {
167 context: "SQPK inner size mismatch",
168 });
169 }
170 let command = body[4];
171 let cmd_body = &body[5..];
172
173 match command {
174 b'T' => Ok(SqpkCommand::TargetInfo(target_info::parse(cmd_body)?)),
175 b'I' => Ok(SqpkCommand::Index(index::parse_index(cmd_body)?)),
176 b'X' => Ok(SqpkCommand::PatchInfo(index::parse_patch_info(cmd_body)?)),
177 b'A' => Ok(SqpkCommand::AddData(Box::new(add_data::parse(cmd_body)?))),
178 b'D' => Ok(SqpkCommand::DeleteData(delete_data::parse(cmd_body)?)),
179 b'E' => Ok(SqpkCommand::ExpandData(expand_data::parse(cmd_body)?)),
180 b'H' => Ok(SqpkCommand::Header(header::parse(cmd_body)?)),
181 b'F' => Ok(SqpkCommand::File(Box::new(file::parse(cmd_body)?))),
182 _ => Err(ParseError::UnknownSqpkCommand(command)),
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::{SqpkCommand, parse_sqpk};
189
190 fn make_sqpk_body(command: u8, cmd_body: &[u8]) -> Vec<u8> {
191 let total = 5 + cmd_body.len();
192 let mut out = Vec::with_capacity(total);
193 out.extend_from_slice(&(total as i32).to_be_bytes());
194 out.push(command);
195 out.extend_from_slice(cmd_body);
196 out
197 }
198
199 #[test]
200 fn parses_target_info() {
201 let mut cmd_body = Vec::new();
202 cmd_body.extend_from_slice(&[0u8; 3]); // reserved
203 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // platform Win32
204 cmd_body.extend_from_slice(&(-1i16).to_be_bytes()); // region Global
205 cmd_body.extend_from_slice(&0i16.to_be_bytes()); // not debug
206 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // version
207 cmd_body.extend_from_slice(&1234u64.to_le_bytes()); // deleted_data_size
208 cmd_body.extend_from_slice(&5678u64.to_le_bytes()); // seek_count
209
210 let body = make_sqpk_body(b'T', &cmd_body);
211 match parse_sqpk(&body).unwrap() {
212 SqpkCommand::TargetInfo(t) => {
213 assert_eq!(t.platform_id, 0);
214 assert_eq!(t.region, -1);
215 assert!(!t.is_debug);
216 assert_eq!(t.deleted_data_size, 1234);
217 assert_eq!(t.seek_count, 5678);
218 }
219 other => panic!("expected SqpkCommand::TargetInfo, got {other:?}"),
220 }
221 }
222
223 #[test]
224 fn rejects_inner_size_mismatch() {
225 let mut body = Vec::new();
226 body.extend_from_slice(&999i32.to_be_bytes()); // wrong inner_size
227 body.push(b'T');
228 assert!(parse_sqpk(&body).is_err());
229 }
230
231 #[test]
232 fn rejects_unknown_command() {
233 let body = make_sqpk_body(b'Z', &[]);
234 assert!(parse_sqpk(&body).is_err());
235 }
236
237 fn index_cmd_body() -> Vec<u8> {
238 let mut v = Vec::new();
239 v.push(b'A'); // IndexCommand::Add
240 v.push(0u8); // is_synonym = false
241 v.push(0u8); // alignment
242 v.extend_from_slice(&0u16.to_be_bytes()); // main_id
243 v.extend_from_slice(&0u16.to_be_bytes()); // sub_id
244 v.extend_from_slice(&0u32.to_be_bytes()); // file_id
245 v.extend_from_slice(&0u64.to_be_bytes()); // file_hash
246 v.extend_from_slice(&0u32.to_be_bytes()); // block_offset
247 v.extend_from_slice(&0u32.to_be_bytes()); // block_number
248 v
249 }
250
251 #[test]
252 fn parses_index_command() {
253 let body = make_sqpk_body(b'I', &index_cmd_body());
254 assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::Index(_)));
255 }
256
257 #[test]
258 fn parses_patch_info_command() {
259 let mut cmd_body = Vec::new();
260 cmd_body.push(0u8); // status
261 cmd_body.push(0u8); // version
262 cmd_body.push(0u8); // alignment
263 cmd_body.extend_from_slice(&0u64.to_be_bytes()); // install_size
264 let body = make_sqpk_body(b'X', &cmd_body);
265 assert!(matches!(
266 parse_sqpk(&body).unwrap(),
267 SqpkCommand::PatchInfo(_)
268 ));
269 }
270
271 #[test]
272 fn index_command_truncated_body_returns_error() {
273 // Empty `I` body — index::parse_index must error, exercising the `?` arm.
274 let body = make_sqpk_body(b'I', &[]);
275 assert!(parse_sqpk(&body).is_err());
276 }
277
278 #[test]
279 fn patch_info_command_truncated_body_returns_error() {
280 // Empty `X` body — index::parse_patch_info must error, exercising the `?` arm.
281 let body = make_sqpk_body(b'X', &[]);
282 assert!(parse_sqpk(&body).is_err());
283 }
284
285 #[test]
286 fn parses_add_data_command() {
287 let mut cmd_body = Vec::new();
288 cmd_body.extend_from_slice(&[0u8; 3]); // pad
289 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
290 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
291 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
292 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
293 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // data_bytes_raw = 0 → no data
294 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_delete_number_raw
295 let body = make_sqpk_body(b'A', &cmd_body);
296 assert!(matches!(
297 parse_sqpk(&body).unwrap(),
298 SqpkCommand::AddData(_)
299 ));
300 }
301
302 #[test]
303 fn parses_delete_data_command() {
304 let mut cmd_body = Vec::new();
305 cmd_body.extend_from_slice(&[0u8; 3]); // pad
306 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
307 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
308 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
309 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
310 cmd_body.extend_from_slice(&1u32.to_be_bytes()); // block_count
311 cmd_body.extend_from_slice(&[0u8; 4]); // reserved
312 let body = make_sqpk_body(b'D', &cmd_body);
313 assert!(matches!(
314 parse_sqpk(&body).unwrap(),
315 SqpkCommand::DeleteData(_)
316 ));
317 }
318
319 #[test]
320 fn parses_expand_data_command() {
321 let mut cmd_body = Vec::new();
322 cmd_body.extend_from_slice(&[0u8; 3]); // pad
323 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
324 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
325 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
326 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // block_offset_raw
327 cmd_body.extend_from_slice(&1u32.to_be_bytes()); // block_count
328 cmd_body.extend_from_slice(&[0u8; 4]); // reserved
329 let body = make_sqpk_body(b'E', &cmd_body);
330 assert!(matches!(
331 parse_sqpk(&body).unwrap(),
332 SqpkCommand::ExpandData(_)
333 ));
334 }
335
336 #[test]
337 fn parses_header_command() {
338 let mut cmd_body = Vec::new();
339 cmd_body.push(b'D'); // file_kind = Dat
340 cmd_body.push(b'V'); // header_kind = Version
341 cmd_body.push(0u8); // alignment
342 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // main_id
343 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // sub_id
344 cmd_body.extend_from_slice(&0u32.to_be_bytes()); // file_id
345 cmd_body.extend_from_slice(&[0u8; 1024]); // header_data
346 let body = make_sqpk_body(b'H', &cmd_body);
347 assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::Header(_)));
348 }
349
350 #[test]
351 fn parses_file_command() {
352 let mut cmd_body = Vec::new();
353 cmd_body.push(b'A'); // operation = AddFile
354 cmd_body.extend_from_slice(&[0u8; 2]); // alignment
355 cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_offset
356 cmd_body.extend_from_slice(&0u64.to_be_bytes()); // file_size
357 cmd_body.extend_from_slice(&1u32.to_be_bytes()); // path_len = 1
358 cmd_body.extend_from_slice(&0u16.to_be_bytes()); // expansion_id
359 cmd_body.extend_from_slice(&[0u8; 2]); // padding
360 cmd_body.push(b'\0'); // path = ""
361 let body = make_sqpk_body(b'F', &cmd_body);
362 assert!(matches!(parse_sqpk(&body).unwrap(), SqpkCommand::File(_)));
363 }
364}