Skip to main content

zipatch_rs/apply/
sqpk.rs

1//! [`Apply`] implementations for [`SqpkCommand`] variants.
2//!
3//! This module is the consumer side of the SQPK dispatcher. Each function
4//! applies one SQPK sub-command to the filesystem via an [`ApplyContext`].
5//!
6//! # Dispatch
7//!
8//! [`SqpkCommand`]'s [`Apply`] impl matches on the variant and calls the
9//! corresponding private function. Two variants — `Index` and `PatchInfo` —
10//! carry metadata that is used by the indexed `ZiPatch` reader (not yet
11//! implemented) and have no direct filesystem effect; their apply arms return
12//! `Ok(())` immediately.
13//!
14//! # File-handle caching
15//!
16//! All write operations (`AddData`, `DeleteData`, `ExpandData`, `Header`, and
17//! the `AddFile` arm of `File`) call an internal `open_cached` method rather
18//! than opening a new handle directly. See [`crate::apply`] for the cache
19//! semantics. The `RemoveAll` and `DeleteFile` arms flush or evict cached
20//! handles before touching the filesystem.
21//!
22//! # Block-offset scaling
23//!
24//! Block offsets in `AddData`, `DeleteData`, and `ExpandData` are stored in
25//! the wire format as a `u32` scaled by 128 (`<< 7`). The shift is applied
26//! during parsing; by the time an apply function sees a chunk, `block_offset`
27//! is already in bytes.
28
29use crate::Platform;
30use crate::apply::path::{dat_path, expansion_folder_id, generic_path, index_path};
31use crate::apply::{Apply, ApplyContext};
32use crate::chunk::sqpk::SqpkCommand;
33use crate::chunk::sqpk::add_data::SqpkAddData;
34use crate::chunk::sqpk::delete_data::SqpkDeleteData;
35use crate::chunk::sqpk::expand_data::SqpkExpandData;
36use crate::chunk::sqpk::file::{SqpkFile, SqpkFileOperation};
37use crate::chunk::sqpk::header::{SqpkHeader, SqpkHeaderTarget, TargetHeaderKind};
38use crate::chunk::sqpk::target_info::SqpkTargetInfo;
39use crate::{Result, ZiPatchError};
40use std::fs;
41use std::io::{Read, Seek, SeekFrom, Write};
42use std::path::Path;
43use tracing::{debug, trace, warn};
44
45/// Write `len` zero bytes to `w` using `std::io::copy`.
46fn write_zeros(w: &mut impl Write, len: u64) -> std::io::Result<()> {
47    std::io::copy(&mut std::io::repeat(0).take(len), w)?;
48    Ok(())
49}
50
51/// Write a `SqPack` empty-block header at `offset` and zero the full block range.
52///
53/// The block range is `block_number * 128` bytes starting at `offset`. After
54/// zeroing, a 5-field little-endian header is written at `offset`:
55///
56/// | Offset | Size | Value |
57/// |--------|------|-------|
58/// | 0 | 4 | `128` — block-size marker |
59/// | 4 | 4 | `0` |
60/// | 8 | 4 | `0` |
61/// | 12 | 4 | `block_number - 1` — "next free" count |
62/// | 16 | 4 | `0` |
63///
64/// `block_number` must be non-zero; the function returns
65/// [`std::io::ErrorKind::InvalidInput`] otherwise, because a zero-block range
66/// has no meaningful on-disk representation and would silently skip the seek.
67fn write_empty_block(
68    f: &mut (impl Write + Seek),
69    offset: u64,
70    block_number: u32,
71) -> std::io::Result<()> {
72    if block_number == 0 {
73        return Err(std::io::Error::new(
74            std::io::ErrorKind::InvalidInput,
75            "block_number must be non-zero",
76        ));
77    }
78    f.seek(SeekFrom::Start(offset))?;
79    write_zeros(f, (block_number as u64) << 7)?;
80    f.seek(SeekFrom::Start(offset))?;
81    f.write_all(&128u32.to_le_bytes())?;
82    f.write_all(&0u32.to_le_bytes())?;
83    f.write_all(&0u32.to_le_bytes())?;
84    f.write_all(&block_number.wrapping_sub(1).to_le_bytes())?;
85    f.write_all(&0u32.to_le_bytes())?;
86    Ok(())
87}
88
89/// Decide whether a path should be preserved by `RemoveAll`.
90///
91/// `RemoveAll` deletes all files in an expansion folder's `sqpack/` and
92/// `movie/` subdirectories, but preserves:
93///
94/// - Any file whose name ends in `.var` (version markers used by the patcher).
95/// - The four introductory movie files `00000.bk2` through `00003.bk2`.
96///   Files named `00004.bk2` and beyond are deleted.
97///
98/// The `.bk2` match is exact on the base name — a file like
99/// `prefix00000.bk2` is **not** kept.
100fn keep_in_remove_all(path: &Path) -> bool {
101    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
102        return false;
103    };
104    #[allow(clippy::case_sensitive_file_extension_comparisons)]
105    let is_var = name.ends_with(".var");
106    is_var || matches!(name, "00000.bk2" | "00001.bk2" | "00002.bk2" | "00003.bk2")
107}
108
109/// Dispatch an [`SqpkCommand`] to its specific apply function.
110///
111/// - [`SqpkCommand::TargetInfo`] — updates [`ApplyContext::platform`] in place.
112/// - [`SqpkCommand::Index`] and [`SqpkCommand::PatchInfo`] — metadata only;
113///   returns `Ok(())` without touching the filesystem.
114/// - All other variants — delegate to the write/delete functions below.
115impl Apply for SqpkCommand {
116    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
117        match self {
118            SqpkCommand::TargetInfo(c) => apply_target_info(c, ctx),
119            SqpkCommand::Index(_) | SqpkCommand::PatchInfo(_) => Ok(()),
120            SqpkCommand::AddData(c) => apply_add_data(c, ctx),
121            SqpkCommand::DeleteData(c) => apply_delete_data(c, ctx),
122            SqpkCommand::ExpandData(c) => apply_expand_data(c, ctx),
123            SqpkCommand::Header(c) => apply_header(c, ctx),
124            SqpkCommand::File(c) => apply_file(c, ctx),
125        }
126    }
127}
128
129/// Apply a [`SqpkTargetInfo`] chunk.
130///
131/// Overwrites [`ApplyContext::platform`] with the platform declared by the
132/// chunk. All subsequent path resolution — for `AddData`, `DeleteData`, etc.
133/// — uses the updated platform. Real FFXIV patches start with a `TargetInfo`
134/// chunk, so this is typically the first mutation applied to the context.
135///
136/// Platform ID mapping:
137///
138/// | `platform_id` | [`Platform`] |
139/// |---------------|-------------|
140/// | `0` | [`Platform::Win32`] |
141/// | `1` | [`Platform::Ps3`] |
142/// | `2` | [`Platform::Ps4`] |
143/// | anything else | [`Platform::Unknown`] + `warn!` |
144///
145/// No filesystem I/O is performed.
146#[allow(clippy::unnecessary_wraps)] // sibling dispatch arms all return Result<()>
147fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> Result<()> {
148    ctx.platform = match cmd.platform_id {
149        0 => Platform::Win32,
150        1 => Platform::Ps3,
151        2 => Platform::Ps4,
152        id => {
153            warn!(
154                platform_id = id,
155                "unknown platform_id in TargetInfo; stored as Unknown"
156            );
157            Platform::Unknown(id)
158        }
159    };
160    debug!(platform = ?ctx.platform, "target info");
161    Ok(())
162}
163
164/// Apply a [`SqpkAddData`] chunk.
165///
166/// Seeks to `block_offset` in the resolved `.dat` file and writes the chunk's
167/// inline `data` payload, then appends `block_delete_number` zero bytes
168/// immediately after it. The file is opened via the handle cache, so
169/// repeated writes to the same file re-use a single open handle.
170///
171/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
172///
173/// # Errors
174///
175/// Returns [`crate::ZiPatchError::Io`] if the file cannot be opened, the seek
176/// fails, or either write fails.
177fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> Result<()> {
178    let tf = &cmd.target_file;
179    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
180    trace!(path = %path.display(), offset = cmd.block_offset, delete_zeros = cmd.block_delete_number, "add data");
181    let file = ctx.open_cached(path)?;
182    file.seek(SeekFrom::Start(cmd.block_offset))?;
183    file.write_all(&cmd.data)?;
184    write_zeros(file, cmd.block_delete_number)?;
185    Ok(())
186}
187
188/// Apply a [`SqpkDeleteData`] chunk.
189///
190/// Writes an empty-block header at `block_offset` covering `block_count`
191/// 128-byte blocks. The operation logically "frees" the range in the `SqPack`
192/// data file so the game's archive reader treats those blocks as available
193/// space.
194///
195/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
196///
197/// # Errors
198///
199/// Returns [`crate::ZiPatchError::Io`] if the file cannot be opened, or if
200/// the write fails (e.g. `block_count` is zero, or a seek or write error).
201fn apply_delete_data(cmd: &SqpkDeleteData, ctx: &mut ApplyContext) -> Result<()> {
202    let tf = &cmd.target_file;
203    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
204    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "delete data");
205    let file = ctx.open_cached(path)?;
206    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
207    Ok(())
208}
209
210/// Apply a [`SqpkExpandData`] chunk.
211///
212/// Behaves identically to `apply_delete_data`: writes an empty-block header
213/// at `block_offset` for `block_count` blocks. The semantic difference is
214/// in the patch's intent — `ExpandData` extends the file into previously
215/// unallocated space, while `DeleteData` clears existing content — but the
216/// on-disk operation (writing empty-block markers) is the same.
217///
218/// # Errors
219///
220/// Returns [`crate::ZiPatchError::Io`] if the file cannot be opened or if
221/// the write fails.
222fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> Result<()> {
223    let tf = &cmd.target_file;
224    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id);
225    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "expand data");
226    let file = ctx.open_cached(path)?;
227    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
228    Ok(())
229}
230
231/// Apply a [`SqpkHeader`] chunk.
232///
233/// Writes exactly 1024 bytes of header data into a `.dat` or `.index` `SqPack`
234/// file at one of two fixed offsets determined by `header_kind`:
235///
236/// | [`TargetHeaderKind`] | File offset |
237/// |--------------------|------------|
238/// | `Version` | `0` (version header slot) |
239/// | `Index` or `Data` | `1024` (secondary header slot) |
240///
241/// The target file is determined by `SqpkHeaderTarget`:
242///
243/// - `Dat(f)` → `.dat` path resolved from `(f.main_id, f.sub_id, f.file_id)`
244/// - `Index(f)` → `.index` path resolved from `(f.main_id, f.sub_id, f.file_id)`
245///
246/// The file handle is obtained from the cache.
247///
248/// # Errors
249///
250/// Returns [`crate::ZiPatchError::Io`] if the file cannot be opened, the
251/// seek fails, or the write fails.
252fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> Result<()> {
253    let path = match &cmd.target {
254        SqpkHeaderTarget::Dat(f) => dat_path(ctx, f.main_id, f.sub_id, f.file_id),
255        SqpkHeaderTarget::Index(f) => index_path(ctx, f.main_id, f.sub_id, f.file_id),
256    };
257    let offset: u64 = match cmd.header_kind {
258        TargetHeaderKind::Version => 0,
259        _ => 1024,
260    };
261    trace!(path = %path.display(), offset, kind = ?cmd.header_kind, "apply header");
262    let file = ctx.open_cached(path)?;
263    file.seek(SeekFrom::Start(offset))?;
264    file.write_all(&cmd.header_data)?;
265    Ok(())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use std::io::Cursor;
272    use std::path::Path;
273
274    // --- write_empty_block header structure ---
275    // Tests the private helper directly; cannot be tested through public API.
276
277    #[test]
278    fn write_empty_block_header_structure() {
279        let mut cur = Cursor::new(Vec::<u8>::new());
280        write_empty_block(&mut cur, 0, 2).unwrap();
281        let buf = cur.into_inner();
282
283        assert_eq!(buf.len(), 256); // block_number << 7 = 256 bytes zeroed
284        assert!(buf[20..].iter().all(|&b| b == 0));
285
286        assert_eq!(&buf[0..4], &128u32.to_le_bytes()); // block size marker
287        assert_eq!(&buf[4..8], &0u32.to_le_bytes());
288        assert_eq!(&buf[8..12], &0u32.to_le_bytes());
289        assert_eq!(&buf[12..16], &1u32.to_le_bytes()); // block_number.wrapping_sub(1) = 1
290        assert_eq!(&buf[16..20], &0u32.to_le_bytes());
291    }
292
293    #[test]
294    fn write_empty_block_rejects_zero_block_number() {
295        let mut cur = Cursor::new(Vec::<u8>::new());
296        let err = write_empty_block(&mut cur, 0, 0).expect_err("must reject block_number=0");
297        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
298    }
299
300    // --- keep_in_remove_all filter ---
301    // Tests the private helper directly.
302
303    #[test]
304    fn keep_in_remove_all_var_kept() {
305        assert!(keep_in_remove_all(Path::new("path/to/something.var")));
306    }
307
308    #[test]
309    fn keep_in_remove_all_bk2_kept() {
310        assert!(keep_in_remove_all(Path::new("00000.bk2")));
311        assert!(keep_in_remove_all(Path::new("00001.bk2")));
312        assert!(keep_in_remove_all(Path::new("00002.bk2")));
313        assert!(keep_in_remove_all(Path::new("00003.bk2")));
314    }
315
316    #[test]
317    fn keep_in_remove_all_bk2_04_deleted() {
318        assert!(!keep_in_remove_all(Path::new("00004.bk2")));
319    }
320
321    #[test]
322    fn keep_in_remove_all_dat_deleted() {
323        assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
324        assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
325    }
326
327    #[test]
328    fn keep_in_remove_all_prefixed_bk2_not_kept() {
329        assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
330    }
331}
332
333/// Apply a [`SqpkFile`] chunk.
334///
335/// Dispatches on [`SqpkFileOperation`]:
336///
337/// ## `AddFile`
338///
339/// Writes compressed-or-raw block payloads into the target file at
340/// `file_offset`. The target path is resolved by joining the game install root
341/// with `cmd.path` (a relative path). Parent directories are created with
342/// `create_dir_all` if they do not exist.
343///
344/// If `file_offset == 0`, the file is truncated to zero before writing (the
345/// operation replaces the file entirely). If `file_offset > 0`, only the
346/// covered byte range is overwritten.
347///
348/// Each block in `cmd.blocks` is decompressed (or passed through verbatim if
349/// uncompressed) into the file handle in sequence. The file handle is kept
350/// open in the cache for subsequent chunks targeting the same path.
351///
352/// ## Errors (`AddFile`)
353///
354/// - [`crate::ZiPatchError::Io`] — file open, `set_len`, seek, or write
355///   failure.
356/// - [`crate::ZiPatchError::NegativeFileOffset`] — `cmd.file_offset` is
357///   negative and cannot be converted to a `u64` seek position.
358/// - [`crate::ZiPatchError::Decompress`] — a DEFLATE block could not be
359///   decompressed.
360///
361/// ## `RemoveAll`
362///
363/// Deletes all files in `sqpack/<expansion>` and `movie/<expansion>` that are
364/// not in the keep-list (`.var` files and `00000`–`00003.bk2`). Before
365/// iterating the directories, **all** cached file handles are flushed to avoid
366/// open-handle conflicts on Windows.
367///
368/// Directories that do not exist are silently skipped.
369///
370/// ## Errors (`RemoveAll`)
371///
372/// - [`crate::ZiPatchError::Io`] — directory read or file deletion failure.
373///
374/// ## `DeleteFile`
375///
376/// Removes a single file at the path resolved from `cmd.path`. The cached
377/// handle for that path is evicted before the deletion (required on Windows;
378/// harmless on Linux).
379///
380/// If the file does not exist and [`ApplyContext::ignore_missing`] is `true`,
381/// the error is demoted to a `warn!` log and `Ok(())` is returned.
382///
383/// ## Errors (`DeleteFile`)
384///
385/// - [`crate::ZiPatchError::Io`] — deletion failed for a reason other than
386///   `NotFound`, or `NotFound` with `ignore_missing = false`.
387///
388/// ## `MakeDirTree`
389///
390/// Creates the directory tree at the path resolved from `cmd.path`,
391/// equivalent to `fs::create_dir_all`. Idempotent.
392///
393/// ## Errors (`MakeDirTree`)
394///
395/// - [`crate::ZiPatchError::Io`] — directory creation failed.
396fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
397    match cmd.operation {
398        SqpkFileOperation::AddFile => {
399            let path = generic_path(ctx, &cmd.path);
400            trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
401            if let Some(parent) = path.parent() {
402                fs::create_dir_all(parent)?;
403            }
404            let file = ctx.open_cached(path)?;
405            if cmd.file_offset == 0 {
406                file.set_len(0)?;
407            }
408            let offset = u64::try_from(cmd.file_offset)
409                .map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
410            file.seek(SeekFrom::Start(offset))?;
411            for block in &cmd.blocks {
412                block.decompress_into(file)?;
413            }
414            Ok(())
415        }
416        SqpkFileOperation::RemoveAll => {
417            // Flush all cached handles before bulk-deleting files.
418            ctx.clear_file_cache();
419            let folder = expansion_folder_id(cmd.expansion_id);
420            debug!(folder = %folder, "remove all");
421            for top in &["sqpack", "movie"] {
422                let dir = ctx.game_path.join(top).join(&folder);
423                if !dir.exists() {
424                    continue;
425                }
426                for entry in fs::read_dir(&dir)? {
427                    let path = entry?.path();
428                    if path.is_file() && !keep_in_remove_all(&path) {
429                        fs::remove_file(&path)?;
430                    }
431                }
432            }
433            Ok(())
434        }
435        SqpkFileOperation::DeleteFile => {
436            let path = generic_path(ctx, &cmd.path);
437            // Drop the cached handle before the OS delete so the fd is closed first
438            // (required on Windows; harmless on Linux).
439            ctx.evict_cached(&path);
440            match fs::remove_file(&path) {
441                Ok(()) => {
442                    trace!(path = %path.display(), "delete file");
443                    Ok(())
444                }
445                Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
446                    warn!(path = %path.display(), "delete file: not found, ignored");
447                    Ok(())
448                }
449                Err(e) => Err(e.into()),
450            }
451        }
452        SqpkFileOperation::MakeDirTree => {
453            let path = generic_path(ctx, &cmd.path);
454            debug!(path = %path.display(), "make dir tree");
455            fs::create_dir_all(path)?;
456            Ok(())
457        }
458    }
459}