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, ApplyObserver};
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. The `Unknown` case deliberately stores the
146/// raw `platform_id` and returns `Ok(())` rather than failing eagerly — a
147/// patch that only touches non-SqPack chunks (e.g. `ADIR`, `DELD`, or
148/// `SqpkFile` operations resolved via `generic_path`) can still apply.
149/// `SqPack` `.dat`/`.index` path resolution refuses to guess and returns
150/// [`crate::ZiPatchError::UnsupportedPlatform`] on the first lookup.
151#[allow(clippy::unnecessary_wraps)] // sibling dispatch arms all return Result<()>
152fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> Result<()> {
153    ctx.platform = match cmd.platform_id {
154        0 => Platform::Win32,
155        1 => Platform::Ps3,
156        2 => Platform::Ps4,
157        id => {
158            warn!(
159                platform_id = id,
160                "unknown platform_id in TargetInfo; stored as Unknown"
161            );
162            Platform::Unknown(id)
163        }
164    };
165    debug!(platform = ?ctx.platform, "target info");
166    Ok(())
167}
168
169/// Apply a [`SqpkAddData`] chunk.
170///
171/// Seeks to `block_offset` in the resolved `.dat` file and writes the chunk's
172/// inline `data` payload, then appends `block_delete_number` zero bytes
173/// immediately after it. The file is opened via the handle cache, so
174/// repeated writes to the same file re-use a single open handle.
175///
176/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
177///
178/// # Errors
179///
180/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
181///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
182/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
183fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> Result<()> {
184    let tf = &cmd.target_file;
185    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
186    trace!(path = %path.display(), offset = cmd.block_offset, delete_zeros = cmd.block_delete_number, "add data");
187    let file = ctx.open_cached(path)?;
188    file.seek(SeekFrom::Start(cmd.block_offset))?;
189    file.write_all(&cmd.data)?;
190    write_zeros(file, cmd.block_delete_number)?;
191    Ok(())
192}
193
194/// Apply a [`SqpkDeleteData`] chunk.
195///
196/// Writes an empty-block header at `block_offset` covering `block_count`
197/// 128-byte blocks. The operation logically "frees" the range in the `SqPack`
198/// data file so the game's archive reader treats those blocks as available
199/// space.
200///
201/// The target path is resolved from `(main_id, sub_id, file_id, platform)`.
202///
203/// # Errors
204///
205/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
206///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
207/// - [`crate::ZiPatchError::Io`] — file open or write failure (e.g.
208///   `block_count` is zero, or a seek or write error).
209fn apply_delete_data(cmd: &SqpkDeleteData, ctx: &mut ApplyContext) -> Result<()> {
210    let tf = &cmd.target_file;
211    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
212    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "delete data");
213    let file = ctx.open_cached(path)?;
214    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
215    Ok(())
216}
217
218/// Apply a [`SqpkExpandData`] chunk.
219///
220/// Behaves identically to `apply_delete_data`: writes an empty-block header
221/// at `block_offset` for `block_count` blocks. The semantic difference is
222/// in the patch's intent — `ExpandData` extends the file into previously
223/// unallocated space, while `DeleteData` clears existing content — but the
224/// on-disk operation (writing empty-block markers) is the same.
225///
226/// # Errors
227///
228/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
229///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
230/// - [`crate::ZiPatchError::Io`] — file open or write failure.
231fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> Result<()> {
232    let tf = &cmd.target_file;
233    let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
234    trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "expand data");
235    let file = ctx.open_cached(path)?;
236    write_empty_block(file, cmd.block_offset, cmd.block_count)?;
237    Ok(())
238}
239
240/// Apply a [`SqpkHeader`] chunk.
241///
242/// Writes exactly 1024 bytes of header data into a `.dat` or `.index` `SqPack`
243/// file at one of two fixed offsets determined by `header_kind`:
244///
245/// | [`TargetHeaderKind`] | File offset |
246/// |--------------------|------------|
247/// | `Version` | `0` (version header slot) |
248/// | `Index` or `Data` | `1024` (secondary header slot) |
249///
250/// The target file is determined by `SqpkHeaderTarget`:
251///
252/// - `Dat(f)` → `.dat` path resolved from `(f.main_id, f.sub_id, f.file_id)`
253/// - `Index(f)` → `.index` path resolved from `(f.main_id, f.sub_id, f.file_id)`
254///
255/// The file handle is obtained from the cache.
256///
257/// # Errors
258///
259/// - [`crate::ZiPatchError::UnsupportedPlatform`] — the context's platform is
260///   [`Platform::Unknown`]; path resolution refuses to guess a layout.
261/// - [`crate::ZiPatchError::Io`] — file open, seek, or write failure.
262fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> Result<()> {
263    let path = match &cmd.target {
264        SqpkHeaderTarget::Dat(f) => dat_path(ctx, f.main_id, f.sub_id, f.file_id)?,
265        SqpkHeaderTarget::Index(f) => index_path(ctx, f.main_id, f.sub_id, f.file_id)?,
266    };
267    let offset: u64 = match cmd.header_kind {
268        TargetHeaderKind::Version => 0,
269        _ => 1024,
270    };
271    trace!(path = %path.display(), offset, kind = ?cmd.header_kind, "apply header");
272    let file = ctx.open_cached(path)?;
273    file.seek(SeekFrom::Start(offset))?;
274    file.write_all(&cmd.header_data)?;
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::io::Cursor;
282    use std::path::Path;
283
284    // --- write_empty_block ---
285
286    #[test]
287    fn write_empty_block_writes_correct_header_and_zeroed_body() {
288        // block_number=2: zeroes 2*128=256 bytes, then writes the 5-field LE
289        // header at offset 0.
290        let mut cur = Cursor::new(Vec::<u8>::new());
291        write_empty_block(&mut cur, 0, 2).unwrap();
292        let buf = cur.into_inner();
293
294        assert_eq!(
295            buf.len(),
296            256,
297            "block_number=2 must produce exactly 256 zeroed bytes"
298        );
299        // Bytes beyond the 20-byte header must all be zero.
300        assert!(
301            buf[20..].iter().all(|&b| b == 0),
302            "bytes after the header must remain zeroed"
303        );
304        // Header field layout (all LE u32).
305        assert_eq!(
306            &buf[0..4],
307            &128u32.to_le_bytes(),
308            "field 0: block-size marker must be 128"
309        );
310        assert_eq!(&buf[4..8], &0u32.to_le_bytes(), "field 1: must be 0");
311        assert_eq!(&buf[8..12], &0u32.to_le_bytes(), "field 2: must be 0");
312        assert_eq!(
313            &buf[12..16],
314            &1u32.to_le_bytes(),
315            "field 3: block_number.wrapping_sub(1) must be 1"
316        );
317        assert_eq!(&buf[16..20], &0u32.to_le_bytes(), "field 4: must be 0");
318    }
319
320    #[test]
321    fn write_empty_block_rejects_zero_block_number() {
322        let mut cur = Cursor::new(Vec::<u8>::new());
323        let err = write_empty_block(&mut cur, 0, 0).expect_err("block_number=0 must be rejected");
324        assert_eq!(
325            err.kind(),
326            std::io::ErrorKind::InvalidInput,
327            "zero block_number must produce InvalidInput error kind"
328        );
329    }
330
331    #[test]
332    fn write_empty_block_at_nonzero_offset_seeks_correctly() {
333        // offset=128: should write 128 zero bytes at position 128, then write
334        // the header at position 128.  The first 128 bytes must remain untouched
335        // (i.e. whatever was there before).
336        let initial = vec![0xABu8; 256];
337        let mut cur = Cursor::new(initial);
338        write_empty_block(&mut cur, 128, 1).unwrap();
339        let buf = cur.into_inner();
340
341        // First 128 bytes untouched.
342        assert!(
343            buf[..128].iter().all(|&b| b == 0xAB),
344            "bytes before offset must be untouched"
345        );
346        // Bytes from offset 128 to 148 are the header; rest zeroed.
347        assert_eq!(
348            &buf[128..132],
349            &128u32.to_le_bytes(),
350            "header marker at offset 128"
351        );
352    }
353
354    // --- keep_in_remove_all ---
355
356    #[test]
357    fn keep_in_remove_all_var_extension_always_kept() {
358        assert!(
359            keep_in_remove_all(Path::new("path/to/something.var")),
360            ".var files must be kept"
361        );
362        // .var at root.
363        assert!(keep_in_remove_all(Path::new("ffxiv.var")));
364    }
365
366    #[test]
367    fn keep_in_remove_all_bk2_00000_through_00003_kept() {
368        for name in &["00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2"] {
369            assert!(keep_in_remove_all(Path::new(name)), "{name} must be kept");
370        }
371    }
372
373    #[test]
374    fn keep_in_remove_all_bk2_00004_and_beyond_deleted() {
375        for name in &["00004.bk2", "00005.bk2", "00099.bk2"] {
376            assert!(
377                !keep_in_remove_all(Path::new(name)),
378                "{name} must NOT be kept"
379            );
380        }
381    }
382
383    #[test]
384    fn keep_in_remove_all_sqpack_dat_and_index_deleted() {
385        assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
386        assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
387    }
388
389    #[test]
390    fn keep_in_remove_all_prefixed_bk2_not_kept() {
391        // The match is exact on the base name — a file like prefix00000.bk2
392        // must NOT be kept.
393        assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
394    }
395
396    #[test]
397    fn keep_in_remove_all_path_without_filename_not_kept() {
398        // A path component with no file_name (e.g. "/") exercises the
399        // `let Some(name) = … else { return false; }` arm (line 102).
400        assert!(
401            !keep_in_remove_all(Path::new("/")),
402            "root path with no filename must return false, not panic"
403        );
404    }
405
406    // --- SqpkCommand dispatch: Index and PatchInfo are no-ops ---
407
408    #[test]
409    fn sqpk_command_index_apply_is_noop() {
410        use crate::chunk::SqpackFile;
411        use crate::chunk::sqpk::{IndexCommand, SqpkIndex};
412
413        let index_cmd = SqpkIndex {
414            command: IndexCommand::Add,
415            is_synonym: false,
416            target_file: SqpackFile {
417                main_id: 0,
418                sub_id: 0,
419                file_id: 0,
420            },
421            file_hash: 0,
422            block_offset: 0,
423            block_number: 0,
424        };
425        let cmd = SqpkCommand::Index(index_cmd);
426        let tmp = tempfile::tempdir().unwrap();
427        let mut ctx = ApplyContext::new(tmp.path());
428        // Must return Ok(()) without touching the filesystem.
429        cmd.apply(&mut ctx).unwrap();
430    }
431
432    #[test]
433    fn sqpk_command_patch_info_apply_is_noop() {
434        use crate::chunk::sqpk::SqpkPatchInfo;
435
436        let patch_info = SqpkPatchInfo {
437            status: 0,
438            version: 0,
439            install_size: 0,
440        };
441        let cmd = SqpkCommand::PatchInfo(patch_info);
442        let tmp = tempfile::tempdir().unwrap();
443        let mut ctx = ApplyContext::new(tmp.path());
444        cmd.apply(&mut ctx).unwrap();
445    }
446
447    // --- apply_file AddFile: parent directory creation ---
448
449    #[test]
450    fn add_file_creates_parent_directories_automatically() {
451        // Exercises the `fs::create_dir_all(parent)?` branch (line 403):
452        // the path "deep/nested/file.dat" requires two directory levels that
453        // do not yet exist in the temp dir.
454        use crate::apply::Apply;
455        use crate::chunk::sqpk::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
456
457        let file_cmd = SqpkFile {
458            operation: SqpkFileOperation::AddFile,
459            file_offset: 0,
460            file_size: 4,
461            expansion_id: 0,
462            path: "deep/nested/file.dat".to_owned(),
463            blocks: vec![SqpkCompressedBlock::new(false, 4, b"data".to_vec())],
464            block_source_offsets: vec![0],
465        };
466        let cmd = SqpkCommand::File(Box::new(file_cmd));
467
468        let tmp = tempfile::tempdir().unwrap();
469        let mut ctx = ApplyContext::new(tmp.path());
470        cmd.apply(&mut ctx).unwrap();
471        // `cmd.apply` writes through `BufWriter`; explicit flush is required
472        // before reading the on-disk state because `apply_to`'s end-of-stream
473        // auto-flush is not in play when callers drive `Apply::apply` directly.
474        ctx.flush().unwrap();
475
476        let target = tmp.path().join("deep").join("nested").join("file.dat");
477        assert!(
478            target.is_file(),
479            "AddFile must create parent directories and write the file"
480        );
481        assert_eq!(
482            std::fs::read(&target).unwrap(),
483            b"data",
484            "file contents must match the block payload"
485        );
486    }
487
488    // --- apply_file AddFile: negative file_offset rejected ---
489
490    #[test]
491    fn add_file_negative_offset_returns_negative_file_offset_error() {
492        // Exercises the `u64::try_from(cmd.file_offset).map_err(|_| NegativeFileOffset)?`
493        // arm. The wire format stores file_offset as u64 but is cast to i64 after
494        // parsing, so a value with the high bit set arrives here as a negative i64.
495        let file_cmd = SqpkFile {
496            operation: SqpkFileOperation::AddFile,
497            file_offset: -1,
498            file_size: 0,
499            expansion_id: 0,
500            path: "neg_offset.dat".to_owned(),
501            blocks: vec![],
502            block_source_offsets: vec![],
503        };
504        let cmd = SqpkCommand::File(Box::new(file_cmd));
505
506        let tmp = tempfile::tempdir().unwrap();
507        let mut ctx = ApplyContext::new(tmp.path());
508        let err = cmd.apply(&mut ctx).unwrap_err();
509        match err {
510            ZiPatchError::NegativeFileOffset(n) => assert_eq!(
511                n, -1,
512                "error must carry the original negative offset for diagnostics"
513            ),
514            other => panic!("expected NegativeFileOffset(-1), got {other:?}"),
515        }
516    }
517
518    // --- apply_file DeleteFile: ignore_missing branches ---
519
520    fn delete_file_cmd(path: &str) -> SqpkCommand {
521        SqpkCommand::File(Box::new(SqpkFile {
522            operation: SqpkFileOperation::DeleteFile,
523            file_offset: 0,
524            file_size: 0,
525            expansion_id: 0,
526            path: path.to_owned(),
527            blocks: vec![],
528            block_source_offsets: vec![],
529        }))
530    }
531
532    #[test]
533    fn delete_file_removes_existing_file() {
534        let tmp = tempfile::tempdir().unwrap();
535        let target = tmp.path().join("victim.dat");
536        std::fs::write(&target, b"bye").unwrap();
537        assert!(target.is_file(), "pre-condition: file must exist");
538
539        let cmd = delete_file_cmd("victim.dat");
540        let mut ctx = ApplyContext::new(tmp.path());
541        cmd.apply(&mut ctx)
542            .expect("delete on an existing file must succeed");
543
544        assert!(!target.exists(), "file must be removed after DeleteFile");
545    }
546
547    #[test]
548    fn delete_file_missing_with_ignore_missing_returns_ok() {
549        // Exercises the `Err(NotFound) && ctx.ignore_missing` arm in apply_file's
550        // DeleteFile branch — warn-and-continue rather than propagating the error.
551        let tmp = tempfile::tempdir().unwrap();
552        let target = tmp.path().join("ghost.dat");
553        assert!(!target.exists(), "pre-condition: file must not exist");
554
555        let cmd = delete_file_cmd("ghost.dat");
556        let mut ctx = ApplyContext::new(tmp.path()).with_ignore_missing(true);
557        cmd.apply(&mut ctx)
558            .expect("missing file must be silently ignored when ignore_missing=true");
559    }
560
561    #[test]
562    fn delete_file_missing_without_ignore_missing_returns_not_found() {
563        // Companion to the above: with the flag off (default), the NotFound error
564        // must propagate as ZiPatchError::Io.
565        let tmp = tempfile::tempdir().unwrap();
566        let cmd = delete_file_cmd("ghost.dat");
567        let mut ctx = ApplyContext::new(tmp.path());
568
569        let err = cmd.apply(&mut ctx).unwrap_err();
570        match err {
571            ZiPatchError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {}
572            other => panic!("expected Io(NotFound), got {other:?}"),
573        }
574    }
575}
576
577/// Apply a [`SqpkFile`] chunk.
578///
579/// Dispatches on [`SqpkFileOperation`]:
580///
581/// ## `AddFile`
582///
583/// Writes compressed-or-raw block payloads into the target file at
584/// `file_offset`. The target path is resolved by joining the game install root
585/// with `cmd.path` (a relative path). Parent directories are created with
586/// `create_dir_all` if they do not exist.
587///
588/// If `file_offset == 0`, the file is truncated to zero before writing (the
589/// operation replaces the file entirely). If `file_offset > 0`, only the
590/// covered byte range is overwritten.
591///
592/// Each block in `cmd.blocks` is decompressed (or passed through verbatim if
593/// uncompressed) into the file handle in sequence. The file handle is kept
594/// open in the cache for subsequent chunks targeting the same path.
595///
596/// ## Errors (`AddFile`)
597///
598/// - [`crate::ZiPatchError::Io`] — file open, `set_len`, seek, or write
599///   failure.
600/// - [`crate::ZiPatchError::NegativeFileOffset`] — `cmd.file_offset` is
601///   negative and cannot be converted to a `u64` seek position.
602/// - [`crate::ZiPatchError::Decompress`] — a DEFLATE block could not be
603///   decompressed.
604///
605/// ## `RemoveAll`
606///
607/// Deletes all files in `sqpack/<expansion>` and `movie/<expansion>` that are
608/// not in the keep-list (`.var` files and `00000`–`00003.bk2`). Before
609/// iterating the directories, **all** cached file handles are flushed to avoid
610/// open-handle conflicts on Windows.
611///
612/// Directories that do not exist are silently skipped.
613///
614/// ## Errors (`RemoveAll`)
615///
616/// - [`crate::ZiPatchError::Io`] — directory read or file deletion failure.
617///
618/// ## `DeleteFile`
619///
620/// Removes a single file at the path resolved from `cmd.path`. The cached
621/// handle for that path is evicted before the deletion (required on Windows;
622/// harmless on Linux).
623///
624/// If the file does not exist and [`ApplyContext::ignore_missing`] is `true`,
625/// the error is demoted to a `warn!` log and `Ok(())` is returned.
626///
627/// ## Errors (`DeleteFile`)
628///
629/// - [`crate::ZiPatchError::Io`] — deletion failed for a reason other than
630///   `NotFound`, or `NotFound` with `ignore_missing = false`.
631///
632/// ## `MakeDirTree`
633///
634/// Creates the directory tree at the path resolved from `cmd.path`,
635/// equivalent to `fs::create_dir_all`. Idempotent.
636///
637/// ## Errors (`MakeDirTree`)
638///
639/// - [`crate::ZiPatchError::Io`] — directory creation failed.
640fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
641    match cmd.operation {
642        SqpkFileOperation::AddFile => {
643            let path = generic_path(ctx, &cmd.path);
644            trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
645            if let Some(parent) = path.parent() {
646                fs::create_dir_all(parent)?;
647            }
648            let writer = ctx.open_cached(path.clone())?;
649            if cmd.file_offset == 0 {
650                // `set_len` is on the raw `File`, not on `BufWriter`. Flush
651                // any pending buffered writes destined for the pre-truncate
652                // offsets before reaching through to the underlying handle —
653                // otherwise the in-memory buffer would be silently dropped on
654                // the next seek and a write error would never surface.
655                writer.flush()?;
656                writer.get_mut().set_len(0)?;
657            }
658            let offset = u64::try_from(cmd.file_offset)
659                .map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
660            writer.seek(SeekFrom::Start(offset))?;
661            // Split-borrow the observer separately from the file-handle cache so
662            // we can poll cancellation between blocks while still holding the
663            // cached `&mut BufWriter<File>`. The entry is guaranteed to be in
664            // the cache because the `open_cached` call immediately above just
665            // inserted (or refreshed) it, and no cache-mutating call sits
666            // between them.
667            let observer: &mut dyn ApplyObserver = &mut *ctx.observer;
668            let writer = ctx
669                .file_cache
670                .get_mut(&path)
671                .expect("open_cached above inserted this path");
672            for block in &cmd.blocks {
673                if observer.should_cancel() {
674                    debug!(path = %path.display(), "add file: cancelled mid-blocks");
675                    return Err(ZiPatchError::Cancelled);
676                }
677                block.decompress_into(writer)?;
678            }
679            Ok(())
680        }
681        SqpkFileOperation::RemoveAll => {
682            // Flush all cached handles before bulk-deleting files — buffered
683            // writes against any of the about-to-be-removed paths must reach
684            // disk first, or be surfaced as an error rather than silently
685            // dropped when the file is unlinked.
686            ctx.clear_file_cache()?;
687            let folder = expansion_folder_id(cmd.expansion_id);
688            debug!(folder = %folder, "remove all");
689            for top in &["sqpack", "movie"] {
690                let dir = ctx.game_path.join(top).join(&folder);
691                if !dir.exists() {
692                    continue;
693                }
694                for entry in fs::read_dir(&dir)? {
695                    let path = entry?.path();
696                    if path.is_file() && !keep_in_remove_all(&path) {
697                        fs::remove_file(&path)?;
698                    }
699                }
700            }
701            Ok(())
702        }
703        SqpkFileOperation::DeleteFile => {
704            let path = generic_path(ctx, &cmd.path);
705            // Flush and drop the cached handle before the OS delete so the
706            // fd is closed first (required on Windows; harmless on Linux),
707            // and any buffered writes against the to-be-deleted path either
708            // land on disk or surface as an error.
709            ctx.evict_cached(&path)?;
710            match fs::remove_file(&path) {
711                Ok(()) => {
712                    trace!(path = %path.display(), "delete file");
713                    Ok(())
714                }
715                Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
716                    warn!(path = %path.display(), "delete file: not found, ignored");
717                    Ok(())
718                }
719                Err(e) => Err(e.into()),
720            }
721        }
722        SqpkFileOperation::MakeDirTree => {
723            let path = generic_path(ctx, &cmd.path);
724            debug!(path = %path.display(), "make dir tree");
725            fs::create_dir_all(path)?;
726            Ok(())
727        }
728    }
729}