use crate::Platform;
use crate::apply::path::{dat_path, expansion_folder_id, generic_path, index_path};
use crate::apply::{Apply, ApplyContext, ApplyObserver};
use crate::chunk::sqpk::SqpkCommand;
use crate::chunk::sqpk::add_data::SqpkAddData;
use crate::chunk::sqpk::delete_data::SqpkDeleteData;
use crate::chunk::sqpk::expand_data::SqpkExpandData;
use crate::chunk::sqpk::file::{SqpkFile, SqpkFileOperation};
use crate::chunk::sqpk::header::{SqpkHeader, SqpkHeaderTarget, TargetHeaderKind};
use crate::chunk::sqpk::target_info::SqpkTargetInfo;
use crate::{Result, ZiPatchError};
use std::fs;
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
use tracing::{debug, trace, warn};
fn write_zeros(w: &mut impl Write, len: u64) -> std::io::Result<()> {
static BUF: [u8; 64 * 1024] = [0; 64 * 1024];
let mut remaining = len;
while remaining > 0 {
let n = remaining.min(BUF.len() as u64) as usize;
w.write_all(&BUF[..n])?;
remaining -= n as u64;
}
Ok(())
}
fn write_empty_block(
f: &mut (impl Write + Seek),
offset: u64,
block_number: u32,
) -> std::io::Result<()> {
if block_number == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"block_number must be non-zero",
));
}
f.seek(SeekFrom::Start(offset))?;
write_zeros(f, (block_number as u64) << 7)?;
f.seek(SeekFrom::Start(offset))?;
f.write_all(&128u32.to_le_bytes())?;
f.write_all(&0u32.to_le_bytes())?;
f.write_all(&0u32.to_le_bytes())?;
f.write_all(&block_number.wrapping_sub(1).to_le_bytes())?;
f.write_all(&0u32.to_le_bytes())?;
Ok(())
}
fn keep_in_remove_all(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
#[allow(clippy::case_sensitive_file_extension_comparisons)]
let is_var = name.ends_with(".var");
is_var || matches!(name, "00000.bk2" | "00001.bk2" | "00002.bk2" | "00003.bk2")
}
impl Apply for SqpkCommand {
fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
match self {
SqpkCommand::TargetInfo(c) => apply_target_info(c, ctx),
SqpkCommand::Index(_) | SqpkCommand::PatchInfo(_) => Ok(()),
SqpkCommand::AddData(c) => apply_add_data(c, ctx),
SqpkCommand::DeleteData(c) => apply_delete_data(c, ctx),
SqpkCommand::ExpandData(c) => apply_expand_data(c, ctx),
SqpkCommand::Header(c) => apply_header(c, ctx),
SqpkCommand::File(c) => apply_file(c, ctx),
}
}
}
#[allow(clippy::unnecessary_wraps)] fn apply_target_info(cmd: &SqpkTargetInfo, ctx: &mut ApplyContext) -> Result<()> {
let new_platform = match cmd.platform_id {
0 => Platform::Win32,
1 => Platform::Ps3,
2 => Platform::Ps4,
id => {
warn!(
platform_id = id,
"unknown platform_id in TargetInfo; stored as Unknown"
);
Platform::Unknown(id)
}
};
if ctx.platform != new_platform {
ctx.invalidate_path_cache();
}
ctx.platform = new_platform;
debug!(platform = ?ctx.platform, "target info");
Ok(())
}
fn apply_add_data(cmd: &SqpkAddData, ctx: &mut ApplyContext) -> Result<()> {
let tf = &cmd.target_file;
let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
trace!(path = %path.display(), offset = cmd.block_offset, delete_zeros = cmd.block_delete_number, "add data");
let file = ctx.open_cached(path)?;
file.seek(SeekFrom::Start(cmd.block_offset))?;
file.write_all(&cmd.data)?;
write_zeros(file, cmd.block_delete_number)?;
Ok(())
}
fn apply_delete_data(cmd: &SqpkDeleteData, ctx: &mut ApplyContext) -> Result<()> {
let tf = &cmd.target_file;
let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "delete data");
let file = ctx.open_cached(path)?;
write_empty_block(file, cmd.block_offset, cmd.block_count)?;
Ok(())
}
fn apply_expand_data(cmd: &SqpkExpandData, ctx: &mut ApplyContext) -> Result<()> {
let tf = &cmd.target_file;
let path = dat_path(ctx, tf.main_id, tf.sub_id, tf.file_id)?;
trace!(path = %path.display(), offset = cmd.block_offset, block_count = cmd.block_count, "expand data");
let file = ctx.open_cached(path)?;
write_empty_block(file, cmd.block_offset, cmd.block_count)?;
Ok(())
}
fn apply_header(cmd: &SqpkHeader, ctx: &mut ApplyContext) -> Result<()> {
let path = match &cmd.target {
SqpkHeaderTarget::Dat(f) => dat_path(ctx, f.main_id, f.sub_id, f.file_id)?,
SqpkHeaderTarget::Index(f) => index_path(ctx, f.main_id, f.sub_id, f.file_id)?,
};
let offset: u64 = match cmd.header_kind {
TargetHeaderKind::Version => 0,
_ => 1024,
};
trace!(path = %path.display(), offset, kind = ?cmd.header_kind, "apply header");
let file = ctx.open_cached(path)?;
file.seek(SeekFrom::Start(offset))?;
file.write_all(&cmd.header_data)?;
Ok(())
}
fn apply_file(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
match cmd.operation {
SqpkFileOperation::AddFile => {
let path = generic_path(ctx, &cmd.path);
trace!(path = %path.display(), file_offset = cmd.file_offset, blocks = cmd.blocks.len(), "add file");
if let Some(parent) = path.parent() {
ctx.ensure_dir_all(parent)?;
}
let writer = ctx.open_cached(path.clone())?;
if cmd.file_offset == 0 {
writer.flush()?;
writer.get_mut().set_len(0)?;
}
let offset = u64::try_from(cmd.file_offset)
.map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
writer.seek(SeekFrom::Start(offset))?;
let observer: &mut dyn ApplyObserver = &mut *ctx.observer;
let decompressor = &mut ctx.decompressor;
let writer = ctx
.file_cache
.get_mut(&path)
.expect("open_cached above inserted this path");
for block in &cmd.blocks {
if observer.should_cancel() {
debug!(path = %path.display(), "add file: cancelled mid-blocks");
return Err(ZiPatchError::Cancelled);
}
block.decompress_into_with(decompressor, writer)?;
}
Ok(())
}
SqpkFileOperation::RemoveAll => {
ctx.clear_file_cache()?;
let folder = expansion_folder_id(cmd.expansion_id);
debug!(folder = %folder, "remove all");
for top in &["sqpack", "movie"] {
let dir = ctx.game_path.join(top).join(&folder);
if !dir.exists() {
continue;
}
for entry in fs::read_dir(&dir)? {
let path = entry?.path();
if path.is_file() && !keep_in_remove_all(&path) {
fs::remove_file(&path)?;
}
}
}
Ok(())
}
SqpkFileOperation::DeleteFile => {
let path = generic_path(ctx, &cmd.path);
ctx.evict_cached(&path)?;
match fs::remove_file(&path) {
Ok(()) => {
trace!(path = %path.display(), "delete file");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
warn!(path = %path.display(), "delete file: not found, ignored");
Ok(())
}
Err(e) => Err(e.into()),
}
}
SqpkFileOperation::MakeDirTree => {
let path = generic_path(ctx, &cmd.path);
debug!(path = %path.display(), "make dir tree");
ctx.ensure_dir_all(&path)?;
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use std::path::Path;
#[test]
fn write_empty_block_writes_correct_header_and_zeroed_body() {
let mut cur = Cursor::new(Vec::<u8>::new());
write_empty_block(&mut cur, 0, 2).unwrap();
let buf = cur.into_inner();
assert_eq!(
buf.len(),
256,
"block_number=2 must produce exactly 256 zeroed bytes"
);
assert!(
buf[20..].iter().all(|&b| b == 0),
"bytes after the header must remain zeroed"
);
assert_eq!(
&buf[0..4],
&128u32.to_le_bytes(),
"field 0: block-size marker must be 128"
);
assert_eq!(&buf[4..8], &0u32.to_le_bytes(), "field 1: must be 0");
assert_eq!(&buf[8..12], &0u32.to_le_bytes(), "field 2: must be 0");
assert_eq!(
&buf[12..16],
&1u32.to_le_bytes(),
"field 3: block_number.wrapping_sub(1) must be 1"
);
assert_eq!(&buf[16..20], &0u32.to_le_bytes(), "field 4: must be 0");
}
#[test]
fn write_empty_block_rejects_zero_block_number() {
let mut cur = Cursor::new(Vec::<u8>::new());
let err = write_empty_block(&mut cur, 0, 0).expect_err("block_number=0 must be rejected");
assert_eq!(
err.kind(),
std::io::ErrorKind::InvalidInput,
"zero block_number must produce InvalidInput error kind"
);
}
#[test]
fn write_empty_block_at_nonzero_offset_seeks_correctly() {
let initial = vec![0xABu8; 256];
let mut cur = Cursor::new(initial);
write_empty_block(&mut cur, 128, 1).unwrap();
let buf = cur.into_inner();
assert!(
buf[..128].iter().all(|&b| b == 0xAB),
"bytes before offset must be untouched"
);
assert_eq!(
&buf[128..132],
&128u32.to_le_bytes(),
"header marker at offset 128"
);
}
#[test]
fn keep_in_remove_all_var_extension_always_kept() {
assert!(
keep_in_remove_all(Path::new("path/to/something.var")),
".var files must be kept"
);
assert!(keep_in_remove_all(Path::new("ffxiv.var")));
}
#[test]
fn keep_in_remove_all_bk2_00000_through_00003_kept() {
for name in &["00000.bk2", "00001.bk2", "00002.bk2", "00003.bk2"] {
assert!(keep_in_remove_all(Path::new(name)), "{name} must be kept");
}
}
#[test]
fn keep_in_remove_all_bk2_00004_and_beyond_deleted() {
for name in &["00004.bk2", "00005.bk2", "00099.bk2"] {
assert!(
!keep_in_remove_all(Path::new(name)),
"{name} must NOT be kept"
);
}
}
#[test]
fn keep_in_remove_all_sqpack_dat_and_index_deleted() {
assert!(!keep_in_remove_all(Path::new("040100.win32.dat0")));
assert!(!keep_in_remove_all(Path::new("040100.win32.index")));
}
#[test]
fn keep_in_remove_all_prefixed_bk2_not_kept() {
assert!(!keep_in_remove_all(Path::new("prefix00000.bk2")));
}
#[test]
fn keep_in_remove_all_path_without_filename_not_kept() {
assert!(
!keep_in_remove_all(Path::new("/")),
"root path with no filename must return false, not panic"
);
}
#[test]
fn sqpk_command_index_apply_is_noop() {
use crate::chunk::SqpackFile;
use crate::chunk::sqpk::{IndexCommand, SqpkIndex};
let index_cmd = SqpkIndex {
command: IndexCommand::Add,
is_synonym: false,
target_file: SqpackFile {
main_id: 0,
sub_id: 0,
file_id: 0,
},
file_hash: 0,
block_offset: 0,
block_number: 0,
};
let cmd = SqpkCommand::Index(index_cmd);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
cmd.apply(&mut ctx).unwrap();
}
#[test]
fn sqpk_command_patch_info_apply_is_noop() {
use crate::chunk::sqpk::SqpkPatchInfo;
let patch_info = SqpkPatchInfo {
status: 0,
version: 0,
install_size: 0,
};
let cmd = SqpkCommand::PatchInfo(patch_info);
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
cmd.apply(&mut ctx).unwrap();
}
#[test]
fn add_file_creates_parent_directories_automatically() {
use crate::apply::Apply;
use crate::chunk::sqpk::{SqpkCompressedBlock, SqpkFile, SqpkFileOperation};
let file_cmd = SqpkFile {
operation: SqpkFileOperation::AddFile,
file_offset: 0,
file_size: 4,
expansion_id: 0,
path: "deep/nested/file.dat".to_owned(),
blocks: vec![SqpkCompressedBlock::new(false, 4, b"data".to_vec())],
block_source_offsets: vec![0],
};
let cmd = SqpkCommand::File(Box::new(file_cmd));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
cmd.apply(&mut ctx).unwrap();
ctx.flush().unwrap();
let target = tmp.path().join("deep").join("nested").join("file.dat");
assert!(
target.is_file(),
"AddFile must create parent directories and write the file"
);
assert_eq!(
std::fs::read(&target).unwrap(),
b"data",
"file contents must match the block payload"
);
}
#[test]
fn add_file_negative_offset_returns_negative_file_offset_error() {
let file_cmd = SqpkFile {
operation: SqpkFileOperation::AddFile,
file_offset: -1,
file_size: 0,
expansion_id: 0,
path: "neg_offset.dat".to_owned(),
blocks: vec![],
block_source_offsets: vec![],
};
let cmd = SqpkCommand::File(Box::new(file_cmd));
let tmp = tempfile::tempdir().unwrap();
let mut ctx = ApplyContext::new(tmp.path());
let err = cmd.apply(&mut ctx).unwrap_err();
match err {
ZiPatchError::NegativeFileOffset(n) => assert_eq!(
n, -1,
"error must carry the original negative offset for diagnostics"
),
other => panic!("expected NegativeFileOffset(-1), got {other:?}"),
}
}
fn delete_file_cmd(path: &str) -> SqpkCommand {
SqpkCommand::File(Box::new(SqpkFile {
operation: SqpkFileOperation::DeleteFile,
file_offset: 0,
file_size: 0,
expansion_id: 0,
path: path.to_owned(),
blocks: vec![],
block_source_offsets: vec![],
}))
}
#[test]
fn delete_file_removes_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("victim.dat");
std::fs::write(&target, b"bye").unwrap();
assert!(target.is_file(), "pre-condition: file must exist");
let cmd = delete_file_cmd("victim.dat");
let mut ctx = ApplyContext::new(tmp.path());
cmd.apply(&mut ctx)
.expect("delete on an existing file must succeed");
assert!(!target.exists(), "file must be removed after DeleteFile");
}
#[test]
fn delete_file_missing_with_ignore_missing_returns_ok() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("ghost.dat");
assert!(!target.exists(), "pre-condition: file must not exist");
let cmd = delete_file_cmd("ghost.dat");
let mut ctx = ApplyContext::new(tmp.path()).with_ignore_missing(true);
cmd.apply(&mut ctx)
.expect("missing file must be silently ignored when ignore_missing=true");
}
#[test]
fn delete_file_missing_without_ignore_missing_returns_not_found() {
let tmp = tempfile::tempdir().unwrap();
let cmd = delete_file_cmd("ghost.dat");
let mut ctx = ApplyContext::new(tmp.path());
let err = cmd.apply(&mut ctx).unwrap_err();
match err {
ZiPatchError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => {}
other => panic!("expected Io(NotFound), got {other:?}"),
}
}
}