zipatch_rs/chunk/sqpk/header.rs
1use binrw::{BinRead, BinResult, Endian};
2use std::io::Cursor;
3
4use super::SqpackFileId;
5
6/// Which `SqPack` file kind a [`SqpkHeader`] targets.
7///
8/// Encoded as a single ASCII byte in the wire format:
9/// `b'D'` → [`Dat`](TargetFileKind::Dat), `b'I'` → [`Index`](TargetFileKind::Index).
10/// Any other byte is rejected with a [`binrw::Error::Custom`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum TargetFileKind {
13 /// Target is a `.datN` data file.
14 Dat,
15 /// Target is a `.indexN` index file.
16 Index,
17}
18
19/// Which header slot a [`SqpkHeader`] writes into.
20///
21/// `SqPack` files contain two 1024-byte header regions at fixed offsets:
22///
23/// | Variant | File offset | Description |
24/// |---------|------------|-------------|
25/// | [`Version`](TargetHeaderKind::Version) | 0 | Version/magic header |
26/// | [`Index`](TargetHeaderKind::Index) | 1024 | Index structure header |
27/// | [`Data`](TargetHeaderKind::Data) | 1024 | Data structure header |
28///
29/// Both `Index` and `Data` map to file offset 1024; the distinction is semantic
30/// (which file type they accompany) but the write offset is the same for both.
31///
32/// Encoded as a single ASCII byte: `b'V'` → `Version`, `b'I'` → `Index`,
33/// `b'D'` → `Data`. Any other byte is rejected with a [`binrw::Error::Custom`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TargetHeaderKind {
36 /// Version header, written at file offset `0`.
37 Version,
38 /// Index header, written at file offset `1024`.
39 Index,
40 /// Data header, written at file offset `1024`.
41 Data,
42}
43
44/// Resolved file target for a [`SqpkHeader`], tagged by [`TargetFileKind`].
45///
46/// Wraps the [`SqpackFileId`] identifier so that the apply layer can resolve the
47/// correct on-disk path without carrying a separate `TargetFileKind` value.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum SqpkHeaderTarget {
50 /// The header is destined for a `.datN` file.
51 Dat(SqpackFileId),
52 /// The header is destined for a `.indexN` file.
53 Index(SqpackFileId),
54}
55
56fn read_file_kind<R: std::io::Read + std::io::Seek>(
57 reader: &mut R,
58 _: Endian,
59 (): (),
60) -> BinResult<TargetFileKind> {
61 let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
62 match byte {
63 b'D' => Ok(TargetFileKind::Dat),
64 b'I' => Ok(TargetFileKind::Index),
65 _ => Err(binrw::Error::Custom {
66 pos: 0,
67 err: Box::new(std::io::Error::new(
68 std::io::ErrorKind::InvalidData,
69 "unknown SqpkHeader file kind",
70 )),
71 }),
72 }
73}
74
75fn read_header_kind<R: std::io::Read + std::io::Seek>(
76 reader: &mut R,
77 _: Endian,
78 (): (),
79) -> BinResult<TargetHeaderKind> {
80 let byte = <u8 as BinRead>::read_options(reader, Endian::Big, ())?;
81 match byte {
82 b'V' => Ok(TargetHeaderKind::Version),
83 b'I' => Ok(TargetHeaderKind::Index),
84 b'D' => Ok(TargetHeaderKind::Data),
85 _ => Err(binrw::Error::Custom {
86 pos: 0,
87 err: Box::new(std::io::Error::new(
88 std::io::ErrorKind::InvalidData,
89 "unknown SqpkHeader header kind",
90 )),
91 }),
92 }
93}
94
95fn read_header_target<R: std::io::Read + std::io::Seek>(
96 reader: &mut R,
97 endian: Endian,
98 (file_kind,): (&TargetFileKind,),
99) -> BinResult<SqpkHeaderTarget> {
100 let f = SqpackFileId::read_options(reader, endian, ())?;
101 match file_kind {
102 TargetFileKind::Dat => Ok(SqpkHeaderTarget::Dat(f)),
103 TargetFileKind::Index => Ok(SqpkHeaderTarget::Index(f)),
104 }
105}
106
107/// SQPK `H` command body: write a 1024-byte `SqPack` header into a target file.
108///
109/// Every `SqPack` file (both `.dat` and `.index`) begins with one or two
110/// 1024-byte header blocks at fixed offsets. The `H` command replaces one of
111/// these headers atomically as part of a patch.
112///
113/// ## Wire format (all big-endian unless noted)
114///
115/// ```text
116/// ┌──────────────────────────────────────────────────────────────┐
117/// │ file_kind : u8 b'D' = Dat, b'I' = Index │ byte 0
118/// │ header_kind : u8 b'V' = Version, b'I' = Index, b'D' = Data │ byte 1
119/// │ <padding> : u8 (reserved, always 0) │ byte 2
120/// │ main_id : u16 BE SqPack category ID │ bytes 3–4
121/// │ sub_id : u16 BE SqPack sub-category ID │ bytes 5–6
122/// │ file_id : u32 BE dat/index file index │ bytes 7–10
123/// │ header_data : [u8; 1024] raw header bytes │ bytes 11–1034
124/// └──────────────────────────────────────────────────────────────┘
125/// ```
126///
127/// ## Apply behaviour
128///
129/// - [`TargetHeaderKind::Version`] → write `header_data` at file offset **0**.
130/// - [`TargetHeaderKind::Index`] or [`TargetHeaderKind::Data`] → write at
131/// file offset **1024**.
132///
133/// The target file is opened via the apply context's handle cache; the write
134/// does not truncate or resize the file.
135///
136/// ## Reference
137///
138/// # Errors
139///
140/// Parsing returns [`crate::ParseError::Decode`] if:
141/// - `file_kind` is not `b'D'` or `b'I'`.
142/// - `header_kind` is not `b'V'`, `b'I'`, or `b'D'`.
143/// - The body is too short to contain a full 1024-byte `header_data`.
144#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
145#[br(big)]
146pub struct SqpkHeader {
147 /// Whether the operation targets a `.dat` or `.index` file.
148 ///
149 /// Parsed from a single ASCII byte: `b'D'` → `Dat`, `b'I'` → `Index`.
150 #[br(parse_with = read_file_kind)]
151 pub file_kind: TargetFileKind,
152 /// Which of the two header slots to overwrite.
153 ///
154 /// Parsed from a single ASCII byte: `b'V'` → `Version` (offset 0),
155 /// `b'I'` → `Index` (offset 1024), `b'D'` → `Data` (offset 1024).
156 #[br(parse_with = read_header_kind)]
157 pub header_kind: TargetHeaderKind,
158 /// The target `SqPack` file, tagged by [`file_kind`](SqpkHeader::file_kind)
159 /// so the apply layer can resolve the correct path without carrying a
160 /// separate kind value.
161 ///
162 /// Preceded by 1 byte of alignment padding in the wire format.
163 #[br(pad_before = 1, parse_with = read_header_target, args(&file_kind))]
164 pub target: SqpkHeaderTarget,
165 /// The 1024-byte block to write into the target file's header slot.
166 ///
167 /// The content follows the `SqPack` header structure defined by Square
168 /// Enix; this crate treats it as an opaque byte array and writes it
169 /// verbatim.
170 #[br(count = 1024)]
171 pub header_data: Vec<u8>,
172}
173
174pub(crate) fn parse(body: &[u8]) -> crate::ParseResult<SqpkHeader> {
175 Ok(SqpkHeader::read_be(&mut Cursor::new(body))?)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn parses_header_dat_version() {
184 let mut body = Vec::new();
185 body.push(b'D'); // file_kind = Dat
186 body.push(b'V'); // header_kind = Version
187 body.push(0u8); // alignment
188 body.extend_from_slice(&10u16.to_be_bytes()); // main_id
189 body.extend_from_slice(&20u16.to_be_bytes()); // sub_id
190 body.extend_from_slice(&0u32.to_be_bytes()); // file_id
191 body.extend_from_slice(&[0xCCu8; 1024]); // header_data
192
193 let cmd = parse(&body).unwrap();
194 assert!(matches!(cmd.file_kind, TargetFileKind::Dat));
195 assert!(matches!(cmd.header_kind, TargetHeaderKind::Version));
196 match cmd.target {
197 SqpkHeaderTarget::Dat(f) => {
198 assert_eq!(f.main_id, 10);
199 assert_eq!(f.sub_id, 20);
200 }
201 other @ SqpkHeaderTarget::Index(_) => {
202 panic!("expected SqpkHeaderTarget::Dat, got {other:?}")
203 }
204 }
205 assert_eq!(cmd.header_data.len(), 1024);
206 }
207
208 #[test]
209 fn rejects_unknown_file_kind() {
210 let mut body = Vec::new();
211 body.push(b'Z'); // invalid
212 body.push(b'V');
213 body.push(0u8);
214 body.extend_from_slice(&[0u8; 8 + 1024]);
215 assert!(parse(&body).is_err());
216 }
217
218 #[test]
219 fn rejects_unknown_header_kind() {
220 let mut body = Vec::new();
221 body.push(b'D');
222 body.push(b'Z'); // invalid header_kind
223 body.push(0u8);
224 body.extend_from_slice(&[0u8; 8 + 1024]);
225 assert!(parse(&body).is_err());
226 }
227
228 #[test]
229 fn parses_header_index_file() {
230 let mut body = Vec::new();
231 body.push(b'I'); // file_kind = Index
232 body.push(b'I'); // header_kind = Index
233 body.push(0u8);
234 body.extend_from_slice(&7u16.to_be_bytes()); // main_id
235 body.extend_from_slice(&8u16.to_be_bytes()); // sub_id
236 body.extend_from_slice(&0u32.to_be_bytes()); // file_id
237 body.extend_from_slice(&[0xBBu8; 1024]);
238
239 let cmd = parse(&body).unwrap();
240 assert!(matches!(cmd.file_kind, TargetFileKind::Index));
241 assert!(matches!(cmd.header_kind, TargetHeaderKind::Index));
242 match cmd.target {
243 SqpkHeaderTarget::Index(f) => {
244 assert_eq!(f.main_id, 7);
245 assert_eq!(f.sub_id, 8);
246 }
247 other @ SqpkHeaderTarget::Dat(_) => {
248 panic!("expected SqpkHeaderTarget::Index, got {other:?}")
249 }
250 }
251 assert_eq!(cmd.header_data.len(), 1024);
252 }
253
254 #[test]
255 fn header_data_truncated() {
256 let mut body = Vec::new();
257 body.push(b'D');
258 body.push(b'V');
259 body.push(0u8);
260 body.extend_from_slice(&[0u8; 8]);
261 body.extend_from_slice(&[0u8; 512]); // only 512, need 1024
262 assert!(parse(&body).is_err());
263 }
264}