Skip to main content

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