Skip to main content

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}