zipatch_rs/chunk/sqpk/add_data.rs
1use binrw::BinRead;
2use std::io::Cursor;
3
4use super::SqpackFile;
5
6// SQPK 'A' body layout (all big-endian):
7// 3 pad + 2 main_id + 2 sub_id + 4 file_id (SqpackFile, padded)
8// + 4 block_offset_raw + 4 data_bytes_raw + 4 block_delete_number_raw
9// = 23 bytes before the inline data payload
10const SQPK_ADDDATA_HEADER_SIZE: u64 = 23;
11
12/// SQPK `A` command body: write an inline data payload into a `.dat` file,
13/// then zero a trailing region.
14///
15/// The `A` command is the primary mechanism for patching game data. It carries
16/// its payload inline in the patch file: the bytes at `data[0..data_bytes]` are
17/// written to the resolved `.dat` file starting at `block_offset`, and then
18/// `block_delete_number` additional bytes are zeroed out immediately after the
19/// payload. This zeroing step logically marks the trailing range of blocks as
20/// deleted / available.
21///
22/// ## Wire format (all big-endian)
23///
24/// ```text
25/// ┌────────────────────────────────────────────────────────────────────────┐
26/// │ <padding> : [u8; 3] (reserved, always zero) │ bytes 0–2
27/// │ main_id : u16 BE │ bytes 3–4
28/// │ sub_id : u16 BE │ bytes 5–6
29/// │ file_id : u32 BE │ bytes 7–10
30/// │ block_offset_raw : u32 BE multiply by 128 to get byte offset │ bytes 11–14
31/// │ data_bytes_raw : u32 BE multiply by 128 to get payload size │ bytes 15–18
32/// │ block_delete_number_raw: u32 BE multiply by 128 to get zero length │ bytes 19–22
33/// │ data : [u8; data_bytes] inline payload │ bytes 23–…
34/// └────────────────────────────────────────────────────────────────────────┘
35/// ```
36///
37/// All three raw `u32` size/offset fields are in **128-byte `SqPack` block units**
38/// and are multiplied by 128 (`<< 7`) during parsing; the decoded fields
39/// (`block_offset`, `data_bytes`, `block_delete_number`) are already in bytes.
40///
41/// ## Reference
42///
43/// See `SqpkAddData.cs` in the `XIVLauncher` reference implementation.
44///
45/// # Errors
46///
47/// Parsing returns [`crate::ZiPatchError::BinrwError`] if the body is too
48/// short to contain the fixed header or the declared `data_bytes` payload.
49#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
50#[br(big)]
51pub struct SqpkAddData {
52 /// `SqPack` file to write into.
53 ///
54 /// Preceded by 3 bytes of alignment padding in the wire format.
55 #[br(pad_before = 3)]
56 pub target_file: SqpackFile,
57 /// Byte offset within the target `.dat` file at which to begin writing.
58 ///
59 /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
60 /// The raw value is in 128-byte `SqPack` block units.
61 #[br(map = |raw: u32| (raw as u64) << 7)]
62 pub block_offset: u64,
63 /// Length in bytes of the inline [`data`](SqpkAddData::data) payload.
64 ///
65 /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
66 /// Used to determine how many bytes to read into `data`.
67 #[br(map = |raw: u32| (raw as u64) << 7)]
68 pub data_bytes: u64,
69 /// Number of bytes to zero immediately after writing `data`.
70 ///
71 /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
72 /// If non-zero, the apply layer writes this many zero bytes starting at
73 /// `block_offset + data_bytes`, logically marking those blocks as freed.
74 #[br(map = |raw: u32| (raw as u64) << 7)]
75 pub block_delete_number: u64,
76 /// Inline data payload of exactly `data_bytes` bytes.
77 ///
78 /// Written verbatim to the target `.dat` file at `block_offset`. The
79 /// content is raw `SqPack` block data — compressed game assets, index
80 /// tables, etc. — as the game engine expects them.
81 #[br(count = data_bytes)]
82 pub data: Vec<u8>,
83}
84
85impl SqpkAddData {
86 /// Byte offset of the [`data`](SqpkAddData::data) field within the SQPK
87 /// command body slice (i.e. the byte slice starting after the SQPK
88 /// `inner_size` + sub-command tag).
89 ///
90 /// This constant (23) is the size of the fixed header preceding the inline
91 /// payload: 3 bytes padding + 8 bytes `SqpackFile` + 4 + 4 + 4 bytes for
92 /// the three raw size/offset `u32`s.
93 ///
94 /// Adding this constant to the chunk's absolute position in the patch file
95 /// gives the patch-file offset where `data` begins — the value needed for
96 /// `IndexedZiPatch` random-access reads that skip decompressing the full
97 /// patch stream.
98 pub const DATA_SOURCE_OFFSET: u64 = SQPK_ADDDATA_HEADER_SIZE;
99}
100
101pub(crate) fn parse(body: &[u8]) -> crate::Result<SqpkAddData> {
102 Ok(SqpkAddData::read_be(&mut Cursor::new(body))?)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn parses_add_data() {
111 let mut body = Vec::new();
112 body.extend_from_slice(&[0u8; 3]); // alignment
113 body.extend_from_slice(&1u16.to_be_bytes()); // main_id
114 body.extend_from_slice(&2u16.to_be_bytes()); // sub_id
115 body.extend_from_slice(&3u32.to_be_bytes()); // file_id
116 body.extend_from_slice(&1u32.to_be_bytes()); // block_offset raw → 1 << 7 = 128
117 body.extend_from_slice(&1u32.to_be_bytes()); // data_bytes raw → 1 << 7 = 128
118 body.extend_from_slice(&0u32.to_be_bytes()); // block_delete_number raw → 0
119 body.extend_from_slice(&[0xABu8; 128]); // data blob
120
121 let cmd = parse(&body).unwrap();
122 assert_eq!(cmd.target_file.main_id, 1);
123 assert_eq!(cmd.target_file.sub_id, 2);
124 assert_eq!(cmd.target_file.file_id, 3);
125 assert_eq!(cmd.block_offset, 128);
126 assert_eq!(cmd.data_bytes, 128);
127 assert_eq!(cmd.block_delete_number, 0);
128 assert_eq!(cmd.data.len(), 128);
129 assert!(cmd.data.iter().all(|&b| b == 0xAB));
130 assert_eq!(SqpkAddData::DATA_SOURCE_OFFSET, 23);
131 }
132}