zipatch_rs/lib.rs
1//! Parser and applier for FFXIV `ZiPatch` (`.patch`) binary files.
2//!
3//! `zipatch-rs` decodes the binary patch format that Square Enix ships for
4//! Final Fantasy XIV and writes the decoded changes to a local game installation.
5//! The library never touches the network — it operates entirely on byte streams
6//! you supply.
7//!
8//! # Architecture
9//!
10//! The crate is split into three layers that share types but are otherwise
11//! independent:
12//!
13//! ## Layer 1 — I/O primitives (`reader`)
14//!
15//! `reader::ReadExt` is a crate-internal extension trait that adds typed
16//! big- and little-endian reads on top of [`std::io::Read`]. It is not part
17//! of the public API; the parsing layer uses it exclusively.
18//!
19//! ## Layer 2 — Parsing ([`chunk`])
20//!
21//! [`ZiPatchReader`] is an [`Iterator`] over [`Chunk`] values. Construct it
22//! from any [`std::io::Read`] source (a [`std::fs::File`], a
23//! [`std::io::Cursor<Vec<u8>>`], a network stream, …). It validates the
24//! 12-byte file magic on construction, then yields one [`Chunk`] per
25//! [`Iterator::next`] call until it sees the `EOF_` terminator or hits an
26//! error.
27//!
28//! Nothing in the parsing layer allocates file handles, stats paths, or
29//! performs I/O against the install tree. Parse-only users can consume
30//! [`ZiPatchReader`] without ever importing [`apply`].
31//!
32//! ## Layer 3 — Applying ([`apply`])
33//!
34//! The [`Apply`] trait bridges parsing and application: every [`Chunk`]
35//! variant implements it, and each implementation writes the patch change to
36//! disk via an [`ApplyContext`]. [`ApplyContext`] holds the install root, the
37//! target [`Platform`], behavioural flags, and an internal file-handle cache
38//! that avoids re-opening the same `.dat` file for every chunk.
39//!
40//! # Quick start
41//!
42//! The most common usage: open a patch file, build a context, apply every
43//! chunk in stream order.
44//!
45//! ```no_run
46//! use std::fs::File;
47//! use zipatch_rs::{ApplyContext, ZiPatchReader};
48//!
49//! let patch_file = File::open("H2017.07.11.0000.0000a.patch").unwrap();
50//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
51//!
52//! ZiPatchReader::new(patch_file)
53//! .unwrap()
54//! .apply_to(&mut ctx)
55//! .unwrap();
56//! ```
57//!
58//! # Inspecting a patch without applying it
59//!
60//! Iterate the reader directly to inspect chunks without touching the
61//! filesystem:
62//!
63//! ```no_run
64//! use zipatch_rs::{Chunk, ZiPatchReader};
65//! use std::fs::File;
66//!
67//! let reader = ZiPatchReader::new(File::open("patch.patch").unwrap()).unwrap();
68//! for chunk in reader {
69//! match chunk.unwrap() {
70//! Chunk::FileHeader(h) => println!("patch version: {:?}", h),
71//! Chunk::AddDirectory(d) => println!("mkdir {}", d.name),
72//! Chunk::Sqpk(cmd) => println!("sqpk: {cmd:?}"),
73//! _ => {}
74//! }
75//! }
76//! ```
77//!
78//! # In-memory doctest
79//!
80//! The following example builds a minimal well-formed patch in memory — magic
81//! header, one `ADIR` chunk (which creates a directory), and an `EOF_`
82//! terminator — then applies it to a temporary directory. This mirrors the
83//! technique used in the crate's own unit tests.
84//!
85//! ```rust
86//! use std::io::Cursor;
87//! use zipatch_rs::{ApplyContext, Chunk, ZiPatchReader};
88//!
89//! // ZiPatch file magic: \x91ZIPATCH\r\n\x1a\n
90//! const MAGIC: [u8; 12] = [
91//! 0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48,
92//! 0x0D, 0x0A, 0x1A, 0x0A,
93//! ];
94//!
95//! /// Wrap `tag + body` into a length-prefixed, CRC32-verified chunk frame.
96//! fn make_chunk(tag: [u8; 4], body: &[u8]) -> Vec<u8> {
97//! // CRC is computed over tag ++ body (NOT including the leading body_len).
98//! let mut crc_input = Vec::new();
99//! crc_input.extend_from_slice(&tag);
100//! crc_input.extend_from_slice(body);
101//! let crc = crc32fast::hash(&crc_input);
102//!
103//! let mut out = Vec::new();
104//! out.extend_from_slice(&(body.len() as u32).to_be_bytes()); // body_len: u32 BE
105//! out.extend_from_slice(&tag); // tag: 4 bytes
106//! out.extend_from_slice(body); // body: body_len bytes
107//! out.extend_from_slice(&crc.to_be_bytes()); // crc32: u32 BE
108//! out
109//! }
110//!
111//! // ADIR body: big-endian u32 name length followed by the name bytes.
112//! let mut adir_body = Vec::new();
113//! adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
114//! adir_body.extend_from_slice(b"created"); // name
115//!
116//! // Assemble the full patch stream.
117//! let mut patch = Vec::new();
118//! patch.extend_from_slice(&MAGIC);
119//! patch.extend_from_slice(&make_chunk(*b"ADIR", &adir_body));
120//! patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));
121//!
122//! // Apply to a temporary directory.
123//! let tmp = tempfile::tempdir().unwrap();
124//! let mut ctx = ApplyContext::new(tmp.path());
125//! ZiPatchReader::new(Cursor::new(patch))
126//! .unwrap()
127//! .apply_to(&mut ctx)
128//! .unwrap();
129//!
130//! assert!(tmp.path().join("created").is_dir());
131//! ```
132//!
133//! # Error handling
134//!
135//! Every fallible operation returns [`Result<T>`], which is an alias for
136//! `std::result::Result<T, `[`ZiPatchError`]`>`. Parse errors and apply
137//! errors share the same type so callers need only one error arm.
138//!
139//! # Tracing
140//!
141//! The library emits structured [`tracing`] events at `trace!`, `debug!`, and
142//! `warn!` levels. No subscriber is configured here — configure output in your
143//! application binary (or in `gaveloc`'s launcher binary).
144//!
145//! [`tracing`]: https://docs.rs/tracing
146
147#![deny(missing_docs)]
148
149/// Filesystem application of parsed chunks ([`Apply`], [`ApplyContext`]).
150pub mod apply;
151/// Wire-format chunk types and the [`ZiPatchReader`] iterator.
152pub mod chunk;
153/// Error type returned by parsing and applying ([`ZiPatchError`]).
154pub mod error;
155pub(crate) mod reader;
156
157pub use apply::{Apply, ApplyContext};
158pub use chunk::{Chunk, ZiPatchReader};
159pub use error::ZiPatchError;
160
161/// Crate-wide `Result` alias parameterised over [`ZiPatchError`].
162pub type Result<T> = std::result::Result<T, ZiPatchError>;
163
164impl<R: std::io::Read> chunk::ZiPatchReader<R> {
165 /// Iterate every chunk in the patch stream and apply each one to `ctx`.
166 ///
167 /// This is the primary high-level entry point for applying a patch. It
168 /// drives the [`ZiPatchReader`] iterator to completion, calling
169 /// [`Apply::apply`] on each yielded [`Chunk`] in stream order.
170 ///
171 /// Chunks **must** be applied in order — the `ZiPatch` format is a
172 /// sequential log and later chunks may depend on filesystem state produced
173 /// by earlier ones (e.g. a directory created by an `ADIR` chunk that a
174 /// subsequent `SQPK AddFile` writes into).
175 ///
176 /// # Errors
177 ///
178 /// Stops at the first parse or apply error and returns it immediately.
179 /// Any filesystem changes already applied by earlier chunks are **not**
180 /// rolled back — the format does not provide transactional semantics.
181 ///
182 /// Possible error variants:
183 /// - [`ZiPatchError::Io`] — underlying I/O failure (read or write).
184 /// - [`ZiPatchError::InvalidMagic`] — caught at construction, not here.
185 /// - [`ZiPatchError::UnknownChunkTag`] — an unrecognised 4-byte tag was
186 /// encountered.
187 /// - [`ZiPatchError::ChecksumMismatch`] — a chunk's CRC32 did not match.
188 /// - [`ZiPatchError::TruncatedPatch`] — the stream ended before `EOF_`.
189 /// - [`ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk carried a
190 /// negative offset.
191 /// - [`ZiPatchError::Decompress`] — a compressed block could not be
192 /// inflated.
193 ///
194 /// # Example
195 ///
196 /// ```no_run
197 /// use std::fs::File;
198 /// use zipatch_rs::{ApplyContext, ZiPatchReader};
199 ///
200 /// let mut ctx = ApplyContext::new("/opt/ffxiv/game");
201 /// ZiPatchReader::new(File::open("update.patch").unwrap())
202 /// .unwrap()
203 /// .apply_to(&mut ctx)
204 /// .unwrap();
205 /// ```
206 pub fn apply_to(self, ctx: &mut apply::ApplyContext) -> Result<()> {
207 use apply::Apply;
208 for chunk in self {
209 chunk?.apply(ctx)?;
210 }
211 Ok(())
212 }
213}
214
215/// Target platform for `SqPack` file path resolution.
216///
217/// FFXIV's `SqPack` archive files live in platform-specific subdirectories
218/// under the game install root. For example, a data file for the Windows
219/// client lives at `sqpack/ffxiv/000000.win32.dat0`, while the PS4 equivalent
220/// is `sqpack/ffxiv/000000.ps4.dat0`. The [`Platform`] value stored in an
221/// [`ApplyContext`] selects which suffix is used when resolving chunk targets
222/// to filesystem paths.
223///
224/// # Default
225///
226/// An [`ApplyContext`] defaults to [`Platform::Win32`]. Override this at
227/// construction time with [`ApplyContext::with_platform`].
228///
229/// # Runtime override via `SqpkTargetInfo`
230///
231/// In practice, real FFXIV patch files begin with an `SQPK T` chunk
232/// ([`chunk::SqpkTargetInfo`]) that declares the target platform. When
233/// [`Apply::apply`] is called on that chunk (see `src/apply/sqpk.rs`,
234/// `apply_target_info`), it overwrites [`ApplyContext::platform`] with the
235/// decoded [`Platform`] value. This means the default is only relevant for
236/// synthetic patches or when you know the target in advance and want to assert
237/// it before the stream starts.
238///
239/// # Forward compatibility
240///
241/// The enum is `#[non_exhaustive]`. The [`Platform::Unknown`] variant
242/// preserves unrecognised platform IDs so that newer patch files do not fail
243/// parsing when a new platform is introduced. Path resolution falls back to
244/// the `win32` layout for unknown variants.
245///
246/// # Display
247///
248/// Implements [`std::fmt::Display`]: `"Win32"`, `"PS3"`, `"PS4"`, or
249/// `"Unknown(N)"` where `N` is the raw platform ID.
250///
251/// # Example
252///
253/// ```rust
254/// use zipatch_rs::{ApplyContext, Platform};
255///
256/// let ctx = ApplyContext::new("/opt/ffxiv/game")
257/// .with_platform(Platform::Win32);
258///
259/// assert_eq!(ctx.platform(), Platform::Win32);
260/// assert_eq!(format!("{}", Platform::Unknown(99)), "Unknown(99)");
261/// ```
262///
263/// [`chunk::SqpkTargetInfo`]: crate::chunk::SqpkTargetInfo
264#[non_exhaustive]
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum Platform {
267 /// Windows / PC client (`win32` path suffix).
268 ///
269 /// This is the platform used by all current PC releases of FFXIV and is
270 /// the default for [`ApplyContext`].
271 Win32,
272 /// `PlayStation` 3 client (`ps3` path suffix).
273 ///
274 /// PS3 support was discontinued after FFXIV: A Realm Reborn. Patches
275 /// targeting this platform are no longer issued by Square Enix, but the
276 /// variant is retained for completeness.
277 Ps3,
278 /// `PlayStation` 4 client (`ps4` path suffix).
279 ///
280 /// Active platform alongside Windows. PS4 patches share the same chunk
281 /// structure as Windows patches but target different file paths.
282 Ps4,
283 /// Unrecognised platform ID preserved from a `SqpkTargetInfo` chunk.
284 ///
285 /// When `apply_target_info` in `src/apply/sqpk.rs` encounters a
286 /// `platform_id` it does not recognise, it stores the raw `u16` value
287 /// here and emits a `warn!` tracing event. Path resolution then falls
288 /// back to the `win32` layout so that the apply operation can proceed
289 /// rather than hard-failing on an unknown platform.
290 Unknown(u16),
291}
292
293impl std::fmt::Display for Platform {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 match self {
296 Platform::Win32 => f.write_str("Win32"),
297 Platform::Ps3 => f.write_str("PS3"),
298 Platform::Ps4 => f.write_str("PS4"),
299 Platform::Unknown(id) => write!(f, "Unknown({id})"),
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::io::Cursor;
308
309 const MAGIC: [u8; 12] = [
310 0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
311 ];
312
313 fn make_chunk(tag: [u8; 4], body: &[u8]) -> Vec<u8> {
314 let mut crc_input = Vec::with_capacity(4 + body.len());
315 crc_input.extend_from_slice(&tag);
316 crc_input.extend_from_slice(body);
317 let crc = crc32fast::hash(&crc_input);
318
319 let mut out = Vec::with_capacity(4 + 4 + body.len() + 4);
320 out.extend_from_slice(&(body.len() as u32).to_be_bytes());
321 out.extend_from_slice(&tag);
322 out.extend_from_slice(body);
323 out.extend_from_slice(&crc.to_be_bytes());
324 out
325 }
326
327 #[test]
328 fn platform_display_all_variants() {
329 assert_eq!(format!("{}", Platform::Win32), "Win32");
330 assert_eq!(format!("{}", Platform::Ps3), "PS3");
331 assert_eq!(format!("{}", Platform::Ps4), "PS4");
332 assert_eq!(format!("{}", Platform::Unknown(42)), "Unknown(42)");
333 }
334
335 #[test]
336 fn apply_to_runs_every_chunk_to_eof() {
337 // Build: MAGIC + ADIR("created") + EOF_
338 let mut adir_body = Vec::new();
339 adir_body.extend_from_slice(&7u32.to_be_bytes()); // name_len
340 adir_body.extend_from_slice(b"created"); // name
341
342 let mut patch = Vec::new();
343 patch.extend_from_slice(&MAGIC);
344 patch.extend_from_slice(&make_chunk(*b"ADIR", &adir_body));
345 patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));
346
347 let tmp = tempfile::tempdir().unwrap();
348 let mut ctx = ApplyContext::new(tmp.path());
349 let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
350 reader.apply_to(&mut ctx).unwrap();
351
352 assert!(tmp.path().join("created").is_dir());
353 }
354
355 #[test]
356 fn apply_to_propagates_parse_error() {
357 // Build: MAGIC + ZZZZ (unknown tag) — apply_to must surface the parse error.
358 let mut patch = Vec::new();
359 patch.extend_from_slice(&MAGIC);
360 patch.extend_from_slice(&make_chunk(*b"ZZZZ", &[]));
361
362 let tmp = tempfile::tempdir().unwrap();
363 let mut ctx = ApplyContext::new(tmp.path());
364 let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
365 let err = reader.apply_to(&mut ctx).unwrap_err();
366 assert!(matches!(err, ZiPatchError::UnknownChunkTag(_)));
367 }
368
369 #[test]
370 fn apply_to_propagates_apply_error() {
371 // DELD on a missing dir without ignore_missing returns a filesystem error
372 // — exercises the `apply(ctx)?` error-propagation path.
373 let mut deld_body = Vec::new();
374 deld_body.extend_from_slice(&14u32.to_be_bytes()); // name_len
375 deld_body.extend_from_slice(b"does_not_exist"); // name
376
377 let mut patch = Vec::new();
378 patch.extend_from_slice(&MAGIC);
379 patch.extend_from_slice(&make_chunk(*b"DELD", &deld_body));
380 patch.extend_from_slice(&make_chunk(*b"EOF_", &[]));
381
382 let tmp = tempfile::tempdir().unwrap();
383 let mut ctx = ApplyContext::new(tmp.path());
384 let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
385 assert!(reader.apply_to(&mut ctx).is_err());
386 }
387}