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}