zipatch_rs/chunk/sqpk/index.rs
1use binrw::{BinRead, BinResult, Endian};
2use std::io::Cursor;
3
4use super::SqpackFile;
5
6/// Sub-command byte of a SQPK `I` (Index) chunk.
7///
8/// Determines whether the index entry described by the containing [`SqpkIndex`]
9/// should be added to or removed from the `SqPack` index file.
10///
11/// Encoded as a single ASCII byte: `b'A'` → `Add`, `b'D'` → `Delete`.
12/// Any other byte is rejected with a [`binrw::Error::Custom`].
13///
14/// See `SqpkIndex.cs` in the `XIVLauncher` reference implementation.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum IndexCommand {
17 /// Add or update an index entry for the described asset.
18 Add,
19 /// Remove the index entry for the described asset.
20 Delete,
21}
22
23fn read_index_command<R: std::io::Read + std::io::Seek>(
24 reader: &mut R,
25 _: Endian,
26 (): (),
27) -> BinResult<IndexCommand> {
28 let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
29 match byte {
30 b'A' => Ok(IndexCommand::Add),
31 b'D' => Ok(IndexCommand::Delete),
32 _ => Err(binrw::Error::Custom {
33 pos: 0,
34 err: Box::new(std::io::Error::new(
35 std::io::ErrorKind::InvalidData,
36 "unknown IndexCommand",
37 )),
38 }),
39 }
40}
41
42/// SQPK `I` command body: add or remove a single `SqPack` index entry.
43///
44/// Index entries map a 64-bit asset path hash to a block location inside a
45/// `.dat` file. The `I` command is used by the indexed `ZiPatch` reader to
46/// maintain the `.index` files without a full re-scan; it has **no direct
47/// apply effect** (the apply arm returns `Ok(())` immediately).
48///
49/// ## Wire format (all big-endian)
50///
51/// ```text
52/// ┌──────────────────────────────────────────────────────────────┐
53/// │ command : u8 b'A' = Add, b'D' = Delete │ byte 0
54/// │ is_synonym : u8 0 = false, nonzero = true │ byte 1
55/// │ <padding> : u8 (reserved) │ byte 2
56/// │ main_id : u16 BE SqPack category ID │ bytes 3–4
57/// │ sub_id : u16 BE SqPack sub-category ID │ bytes 5–6
58/// │ file_id : u32 BE dat/index file index │ bytes 7–10
59/// │ file_hash : u64 BE 64-bit hash of the asset path │ bytes 11–18
60/// │ block_offset : u32 BE block offset within the .dat file │ bytes 19–22
61/// │ block_number : u32 BE index lookup block number │ bytes 23–26
62/// └──────────────────────────────────────────────────────────────┘
63/// ```
64///
65/// ## Reference
66///
67/// See `SqpkIndex.cs` in the `XIVLauncher` reference implementation.
68///
69/// # Errors
70///
71/// Parsing returns [`crate::ZiPatchError::BinrwError`] if:
72/// - `command` is not `b'A'` or `b'D'`.
73/// - The body is too short to contain all required fields.
74#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
75#[br(big)]
76pub struct SqpkIndex {
77 /// Whether to add the entry to the index or delete it.
78 ///
79 /// Parsed from a single ASCII byte: `b'A'` → [`IndexCommand::Add`],
80 /// `b'D'` → [`IndexCommand::Delete`].
81 #[br(parse_with = read_index_command)]
82 pub command: IndexCommand,
83 /// `true` if this entry is a synonym (hash-collision) record in the index.
84 ///
85 /// Synonym entries exist when two asset paths hash to the same value;
86 /// the index stores them in a secondary synonym table rather than the
87 /// primary hash table.
88 ///
89 /// Parsed from a `u8`: `0` → `false`, any nonzero → `true`.
90 #[br(map = |x: u8| x != 0)]
91 pub is_synonym: bool,
92 /// The `SqPack` file whose index is being modified.
93 ///
94 /// Preceded by 1 byte of alignment padding in the wire format.
95 #[br(pad_before = 1)]
96 pub target_file: SqpackFile,
97 /// 64-bit hash of the indexed asset path.
98 ///
99 /// The hash algorithm is `SqPack`'s internal path hash (a combination of
100 /// folder hash and filename hash). Encoded as a big-endian `u64`.
101 pub file_hash: u64,
102 /// Block offset of the asset data within the target `.dat` file.
103 ///
104 /// Encoded as a big-endian `u32`. Unlike the offsets in `AddData` /
105 /// `ExpandData` / `DeleteData`, this value is stored and used directly
106 /// by the index reader without the `<< 7` shift.
107 pub block_offset: u32,
108 /// Block number for the index lookup table entry.
109 ///
110 /// Encoded as a big-endian `u32`.
111 pub block_number: u32,
112}
113
114/// SQPK `X` command body: patch install metadata.
115///
116/// Carries informational fields about the patch as a whole — install status,
117/// format version, and the declared post-patch total install size. Like `I`,
118/// this command has **no direct apply effect** and the apply arm returns
119/// `Ok(())` immediately.
120///
121/// ## Wire format (all big-endian)
122///
123/// ```text
124/// ┌────────────────────────────────────────────────────────┐
125/// │ status : u8 install status code │ byte 0
126/// │ version : u8 patch info structure version │ byte 1
127/// │ <padding> : u8 (reserved) │ byte 2
128/// │ install_size : u64 BE declared total size after patch │ bytes 3–10
129/// └────────────────────────────────────────────────────────┘
130/// ```
131///
132/// ## Reference
133///
134/// See `SqpkPatchInfo.cs` in the `XIVLauncher` reference implementation.
135///
136/// # Errors
137///
138/// Parsing returns [`crate::ZiPatchError::BinrwError`] if the body is too
139/// short to contain all required fields.
140#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
141#[br(big)]
142pub struct SqpkPatchInfo {
143 /// Install status code for this patch. The exact semantics are SE-internal;
144 /// no known values are documented in the `XIVLauncher` reference.
145 pub status: u8,
146 /// Version of the `SqpkPatchInfo` wire structure.
147 pub version: u8,
148 /// Declared total install size (in bytes) of the game after this patch is
149 /// applied. Informational only; not validated during apply.
150 ///
151 /// Preceded by 1 byte of alignment padding. Encoded as a big-endian `u64`.
152 #[br(pad_before = 1)]
153 pub install_size: u64,
154}
155
156pub(crate) fn parse_index(body: &[u8]) -> crate::Result<SqpkIndex> {
157 Ok(SqpkIndex::read_be(&mut Cursor::new(body))?)
158}
159
160pub(crate) fn parse_patch_info(body: &[u8]) -> crate::Result<SqpkPatchInfo> {
161 Ok(SqpkPatchInfo::read_be(&mut Cursor::new(body))?)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn parses_sqpk_index_add() {
170 let mut body = Vec::new();
171 body.push(b'A'); // command = Add
172 body.push(1u8); // is_synonym = true
173 body.push(0u8); // alignment
174 body.extend_from_slice(&0x0102u16.to_be_bytes()); // main_id
175 body.extend_from_slice(&0x0304u16.to_be_bytes()); // sub_id
176 body.extend_from_slice(&0u32.to_be_bytes()); // file_id
177 body.extend_from_slice(&0x0807_0605_0403_0201_u64.to_be_bytes()); // file_hash (would differ in LE)
178 body.extend_from_slice(&5u32.to_be_bytes()); // block_offset
179 body.extend_from_slice(&10u32.to_be_bytes()); // block_number
180
181 let idx = parse_index(&body).unwrap();
182 assert!(matches!(idx.command, IndexCommand::Add));
183 assert!(idx.is_synonym);
184 assert_eq!(idx.target_file.main_id, 0x0102);
185 assert_eq!(idx.file_hash, 0x0807_0605_0403_0201);
186 assert_eq!(idx.block_offset, 5);
187 assert_eq!(idx.block_number, 10);
188 }
189
190 #[test]
191 fn rejects_unknown_index_command() {
192 let mut body = Vec::new();
193 body.push(b'Z'); // invalid
194 body.extend_from_slice(&[0u8; 20]);
195 assert!(parse_index(&body).is_err());
196 }
197
198 #[test]
199 fn parses_sqpk_patch_info() {
200 let mut body = Vec::new();
201 body.push(3u8); // status
202 body.push(1u8); // version
203 body.push(0u8); // alignment
204 body.extend_from_slice(&0x0102_0304_0506_0708_u64.to_be_bytes()); // install_size (BE, not LE)
205
206 let info = parse_patch_info(&body).unwrap();
207 assert_eq!(info.status, 3);
208 assert_eq!(info.version, 1);
209 assert_eq!(info.install_size, 0x0102_0304_0506_0708);
210 }
211}