use crate::Platform;
use crate::apply::path::{dat_path, expansion_folder_id, generic_path, index_path};
use crate::apply::{Apply, ApplyContext, ApplyMode};
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};
pub(crate) 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(())
}
pub(crate) 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(&empty_block_header(block_number))?;
Ok(())
}
pub(crate) fn empty_block_header(block_number: u32) -> [u8; 20] {
debug_assert!(block_number != 0, "block_number must be non-zero");
let mut h = [0u8; 20];
h[0..4].copy_from_slice(&128u32.to_le_bytes());
h[12..16].copy_from_slice(&block_number.wrapping_sub(1).to_le_bytes());
h
}
pub(crate) 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 => apply_file_add(cmd, ctx),
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)
&& matches!(ctx.mode, ApplyMode::Write)
{
fs::remove_file(&path)?;
}
}
}
Ok(())
}
SqpkFileOperation::DeleteFile => {
let path = generic_path(ctx, &cmd.path);
ctx.evict_cached(&path)?;
if matches!(ctx.mode, ApplyMode::DryRun) {
trace!(path = %path.display(), "delete file: dry-run, suppressed");
return Ok(());
}
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(())
}
}
}
fn apply_file_add(cmd: &SqpkFile, ctx: &mut ApplyContext) -> Result<()> {
apply_file_add_from(cmd, ctx, 0, 0)
}
pub(crate) fn apply_file_add_from(
cmd: &SqpkFile,
ctx: &mut ApplyContext,
start_block: usize,
start_bytes_into_target: u64,
) -> Result<()> {
let path = generic_path(ctx, &cmd.path);
trace!(
path = %path.display(),
file_offset = cmd.file_offset,
blocks = cmd.blocks.len(),
start_block,
start_bytes_into_target,
"add file"
);
if let Some(parent) = path.parent() {
ctx.ensure_dir_all(parent)?;
}
let chunk_index = ctx.current_chunk_index;
let chunk_bytes_read = ctx.current_chunk_bytes_read;
let patch_name = ctx.patch_name.clone();
let patch_size = ctx.patch_size;
let offset = u64::try_from(cmd.file_offset)
.map_err(|_| ZiPatchError::NegativeFileOffset(cmd.file_offset))?;
{
let writer = ctx.open_cached(&path)?;
if cmd.file_offset == 0 && start_block == 0 {
writer.truncate_to_zero()?;
}
writer.seek(SeekFrom::Start(offset + start_bytes_into_target))?;
}
let mut bytes_into_target: u64 = start_bytes_into_target;
for (block_idx, block) in cmd.blocks.iter().enumerate().skip(start_block) {
if ctx.observer.should_cancel() {
debug!(path = %path.display(), "add file: cancelled mid-blocks");
return Err(ZiPatchError::Cancelled);
}
let pre;
let post;
{
let decompressor = &mut ctx.decompressor;
let writer = ctx
.file_cache
.get_mut(&path)
.expect("open_cached above inserted this path");
pre = writer.stream_position()?;
block.decompress_into_with(decompressor, writer)?;
post = writer.stream_position()?;
}
bytes_into_target = bytes_into_target.saturating_add(post.saturating_sub(pre));
let block_idx_u32 = block_idx as u32 + 1;
let checkpoint = crate::apply::Checkpoint::Sequential(crate::apply::SequentialCheckpoint {
schema_version: crate::apply::SequentialCheckpoint::CURRENT_SCHEMA_VERSION,
next_chunk_index: chunk_index,
bytes_read: chunk_bytes_read,
patch_name: patch_name.clone(),
patch_size,
in_flight: Some(crate::apply::InFlightAddFile {
target_path: path.clone(),
file_offset: offset,
block_idx: block_idx_u32,
bytes_into_target,
}),
});
trace!(
path = %path.display(),
block_idx = block_idx_u32,
bytes_into_target,
"add file: per-block checkpoint"
);
ctx.record_checkpoint_mid_block(&checkpoint)?;
}
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:?}"),
}
}
#[test]
fn empty_block_header_layout() {
let h = empty_block_header(8);
assert_eq!(&h[0..4], &128u32.to_le_bytes());
assert_eq!(&h[4..8], &0u32.to_le_bytes());
assert_eq!(&h[8..12], &0u32.to_le_bytes());
assert_eq!(&h[12..16], &7u32.to_le_bytes());
assert_eq!(&h[16..20], &0u32.to_le_bytes());
}
#[test]
fn empty_block_header_matches_write_empty_block_prefix() {
let units = 4u32;
let mut buf = Vec::with_capacity((units as usize) * 128);
write_empty_block(&mut std::io::Cursor::new(&mut buf), 0, units).unwrap();
assert_eq!(&buf[..20], &empty_block_header(units));
}
}