Skip to main content

zipatch_rs/chunk/sqpk/
index.rs

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