use std::path::Path;
use std::process::ExitCode;
use crate::chunk::{SqpackFileId, SqpkCommand};
use crate::{Chunk, open_patch};
pub fn run(path: &Path, sqpk_only: bool) -> ExitCode {
let mut reader = match open_patch(path) {
Ok(r) => r,
Err(e) => {
eprintln!("failed to open {}: {e}", path.display());
return ExitCode::FAILURE;
}
};
let mut index: u64 = 0;
let mut sqpk_count: u64 = 0;
let mut prev_bytes: u64 = reader.bytes_read();
loop {
let rec = match reader.next_chunk() {
Ok(Some(rec)) => rec,
Ok(None) => break,
Err(e) => {
eprintln!("parse error at chunk #{}: {e}", index + 1);
return ExitCode::FAILURE;
}
};
index += 1;
let body_size = rec.bytes_read.saturating_sub(prev_bytes).saturating_sub(12);
prev_bytes = rec.bytes_read;
let tag = rec.tag.to_string();
let is_sqpk = matches!(rec.chunk, Chunk::Sqpk(_));
if sqpk_only && !is_sqpk {
continue;
}
if is_sqpk {
sqpk_count += 1;
}
let desc = describe_chunk(&rec.chunk);
let offset = rec.body_offset;
println!("#{index:>6} {offset:>10} {tag} {body_size:>10} {desc}");
}
eprintln!(
"{index} chunks, {sqpk_count} SQPK, {} bytes consumed",
reader.bytes_read()
);
ExitCode::SUCCESS
}
#[cfg(test)]
fn tag_to_string(tag: [u8; 4]) -> String {
crate::newtypes::ChunkTag::new(tag).to_string()
}
fn describe_chunk(chunk: &Chunk) -> String {
use crate::chunk::FileHeader;
match chunk {
Chunk::FileHeader(h) => {
let pt = String::from_utf8_lossy(h.patch_type());
match h {
FileHeader::V2(v) => {
format!("FHDR v2 patch_type={pt} entry_files={}", v.entry_files)
}
FileHeader::V3(v) => format!(
"FHDR v3 patch_type={pt} entry_files={} commands={} add={} del={} exp={} hdr={} file={} repo={:#010x}",
v.entry_files,
v.commands,
v.sqpk_add_commands,
v.sqpk_delete_commands,
v.sqpk_expand_commands,
v.sqpk_header_commands,
v.sqpk_file_commands,
v.repository_name,
),
}
}
Chunk::ApplyOption(a) => {
format!("APLY kind={:?} value={}", a.kind, a.value)
}
Chunk::ApplyFreeSpace(f) => {
format!("APFS unknown_a={} unknown_b={}", f.unknown_a, f.unknown_b)
}
Chunk::AddDirectory(d) => format!("ADIR name={:?}", d.name),
Chunk::DeleteDirectory(d) => format!("DELD name={:?}", d.name),
Chunk::Sqpk(cmd) => describe_sqpk(cmd),
Chunk::EndOfFile => "EOF_".to_owned(),
}
}
fn describe_sqpk(cmd: &SqpkCommand) -> String {
match cmd {
SqpkCommand::TargetInfo(t) => format!(
"T TargetInfo platform_id={} region={} version={} debug={}",
t.platform_id, t.region, t.version, t.is_debug
),
SqpkCommand::PatchInfo(p) => format!(
"X PatchInfo status={} version={} install_size={}",
p.status, p.version, p.install_size
),
SqpkCommand::Header(h) => format!(
"H Header file_kind={:?} header_kind={:?} data_len={}",
h.file_kind,
h.header_kind,
h.header_data.len()
),
SqpkCommand::AddData(a) => format!(
"A AddData {} offset={} data={}B zero_after={}B",
fmt_target(&a.target_file),
a.block_offset,
a.data_bytes,
a.block_delete_number,
),
SqpkCommand::DeleteData(d) => format!(
"D DeleteData {} offset={} blocks={}",
fmt_target(&d.target_file),
d.block_offset,
d.block_count,
),
SqpkCommand::ExpandData(e) => format!(
"E ExpandData {} offset={} blocks={}",
fmt_target(&e.target_file),
e.block_offset,
e.block_count,
),
SqpkCommand::File(f) => format!(
"F File op={:?} exp={} size={} blocks={} path={:?}",
f.operation,
f.expansion_id,
f.file_size,
f.blocks.len(),
f.path,
),
SqpkCommand::Index(i) => format!(
"I Index cmd={:?} {} hash={:016x} synonym={}",
i.command,
fmt_target(&i.target_file),
i.file_hash,
i.is_synonym,
),
}
}
fn fmt_target(f: &SqpackFileId) -> String {
format!(
"target=main={:02x} sub={:04x} file_id={}",
f.main_id, f.sub_id, f.file_id
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::SqpackFileId;
use crate::test_utils::wire::{
AddDirectory, ApplyFreeSpace, ApplyOption, ApplyOptionKind, DeleteDirectory, FileHeader,
FileHeaderV2, FileHeaderV3, IndexCommand, SqpkAddData, SqpkDeleteData, SqpkExpandData,
SqpkFile, SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex, SqpkPatchInfo,
SqpkTargetInfo, TargetFileKind, TargetHeaderKind,
};
fn sqpack_file(main_id: u16, sub_id: u16, file_id: u32) -> SqpackFileId {
SqpackFileId {
main_id,
sub_id,
file_id,
}
}
#[test]
fn tag_to_string_renders_printable_ascii() {
assert_eq!(tag_to_string(*b"SQPK"), "SQPK");
assert_eq!(tag_to_string(*b"EOF_"), "EOF_");
assert_eq!(tag_to_string([0, b'A', b'B', b'C']), "_ABC");
assert_eq!(tag_to_string([0xff, b'A', b'B', b'C']), ".ABC");
}
#[test]
fn tag_to_string_all_spaces() {
assert_eq!(tag_to_string([b' '; 4]), " ");
}
#[test]
fn tag_to_string_mixed_graphic_and_control() {
assert_eq!(tag_to_string([b'A', 0x01, b'B', 0x00]), "A.B_");
}
#[test]
fn describe_chunk_handles_add_directory() {
let chunk = Chunk::AddDirectory(AddDirectory {
name: "movie/ffxiv".to_owned(),
});
let s = describe_chunk(&chunk);
assert!(s.starts_with("ADIR"));
assert!(s.contains("movie/ffxiv"));
}
#[test]
fn describe_chunk_handles_eof() {
assert_eq!(describe_chunk(&Chunk::EndOfFile), "EOF_");
}
#[test]
fn describe_chunk_file_header_v2() {
let chunk = Chunk::FileHeader(FileHeader::V2(FileHeaderV2 {
patch_type: *b"D000",
entry_files: 7,
}));
let s = describe_chunk(&chunk);
assert!(s.starts_with("FHDR v2"));
assert!(s.contains("D000"));
assert!(s.contains("entry_files=7"));
}
#[test]
fn describe_chunk_file_header_v3() {
let chunk = Chunk::FileHeader(FileHeader::V3(FileHeaderV3 {
patch_type: *b"H000",
entry_files: 3,
add_directories: 0,
delete_directories: 0,
delete_data_size: 0,
minor_version: 0,
repository_name: 0x0102_0304,
commands: 100,
sqpk_add_commands: 10,
sqpk_delete_commands: 20,
sqpk_expand_commands: 30,
sqpk_header_commands: 40,
sqpk_file_commands: 50,
}));
let s = describe_chunk(&chunk);
assert!(s.starts_with("FHDR v3"));
assert!(s.contains("H000"));
assert!(s.contains("entry_files=3"));
assert!(s.contains("commands=100"));
assert!(s.contains("repo=0x01020304"));
}
#[test]
fn describe_chunk_apply_option() {
let chunk = Chunk::ApplyOption(ApplyOption {
kind: ApplyOptionKind::IgnoreMissing,
value: false,
});
let s = describe_chunk(&chunk);
assert!(s.starts_with("APLY"));
assert!(s.contains("IgnoreMissing"));
}
#[test]
fn describe_chunk_apply_free_space() {
let chunk = Chunk::ApplyFreeSpace(ApplyFreeSpace {
unknown_a: 42,
unknown_b: -1,
});
let s = describe_chunk(&chunk);
assert!(s.starts_with("APFS"));
assert!(s.contains("unknown_a=42"));
assert!(s.contains("unknown_b=-1"));
}
#[test]
fn describe_chunk_delete_directory() {
let chunk = Chunk::DeleteDirectory(DeleteDirectory {
name: "sqpack/ex1".to_owned(),
});
let s = describe_chunk(&chunk);
assert!(s.starts_with("DELD"));
assert!(s.contains("sqpack/ex1"));
}
#[test]
fn describe_chunk_sqpk_dispatches_to_describe_sqpk() {
let target_info = SqpkTargetInfo {
platform_id: 0,
region: -1,
is_debug: false,
version: 0,
deleted_data_size: 0,
seek_count: 0,
};
let chunk = Chunk::Sqpk(SqpkCommand::TargetInfo(target_info));
let s = describe_chunk(&chunk);
assert!(s.starts_with("T TargetInfo"));
}
#[test]
fn describe_sqpk_target_info() {
let cmd = SqpkCommand::TargetInfo(SqpkTargetInfo {
platform_id: 0,
region: -1,
is_debug: false,
version: 2,
deleted_data_size: 0,
seek_count: 0,
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("T TargetInfo"));
assert!(s.contains("platform_id=0"));
assert!(s.contains("region=-1"));
assert!(s.contains("version=2"));
}
#[test]
fn describe_sqpk_patch_info() {
let cmd = SqpkCommand::PatchInfo(SqpkPatchInfo {
status: 1,
version: 0,
install_size: 999_999,
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("X PatchInfo"));
assert!(s.contains("status=1"));
assert!(s.contains("install_size=999999"));
}
#[test]
fn describe_sqpk_header() {
let cmd = SqpkCommand::Header(SqpkHeader {
file_kind: TargetFileKind::Dat,
header_kind: TargetHeaderKind::Version,
target: SqpkHeaderTarget::Dat(sqpack_file(4, 0x0100, 0)),
header_data: vec![0u8; 1024],
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("H Header"));
assert!(s.contains("file_kind=Dat"));
assert!(s.contains("header_kind=Version"));
assert!(s.contains("data_len=1024"));
}
#[test]
fn describe_sqpk_add_data() {
let cmd = SqpkCommand::AddData(Box::new(SqpkAddData {
target_file: sqpack_file(4, 0x0100, 0),
block_offset: 128,
data_bytes: 256,
block_delete_number: 0,
data: vec![0u8; 256],
}));
let s = describe_sqpk(&cmd);
assert!(s.starts_with("A AddData"));
assert!(s.contains("offset=128"));
assert!(s.contains("data=256B"));
assert!(s.contains("main=04"));
assert!(s.contains("sub=0100"));
}
#[test]
fn describe_sqpk_delete_data() {
let cmd = SqpkCommand::DeleteData(SqpkDeleteData {
target_file: sqpack_file(0, 0, 1),
block_offset: 512,
block_count: 8,
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("D DeleteData"));
assert!(s.contains("offset=512"));
assert!(s.contains("blocks=8"));
}
#[test]
fn describe_sqpk_expand_data() {
let cmd = SqpkCommand::ExpandData(SqpkExpandData {
target_file: sqpack_file(0, 0, 0),
block_offset: 1024,
block_count: 4,
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("E ExpandData"));
assert!(s.contains("offset=1024"));
assert!(s.contains("blocks=4"));
}
#[test]
fn describe_sqpk_file() {
let cmd = SqpkCommand::File(Box::new(SqpkFile {
operation: SqpkFileOperation::AddFile,
file_offset: 0,
file_size: 4096,
expansion_id: 1,
path: "sqpack/ex1/0a0000.win32.index".to_owned(),
block_source_offsets: vec![],
blocks: vec![],
}));
let s = describe_sqpk(&cmd);
assert!(s.starts_with("F File"));
assert!(s.contains("op=AddFile"));
assert!(s.contains("size=4096"));
assert!(s.contains("sqpack/ex1/0a0000.win32.index"));
}
#[test]
fn describe_sqpk_index() {
let cmd = SqpkCommand::Index(SqpkIndex {
command: IndexCommand::Add,
is_synonym: false,
target_file: sqpack_file(4, 0x0100, 0),
file_hash: 0xdead_beef_cafe_babe,
block_offset: 0,
block_number: 0,
});
let s = describe_sqpk(&cmd);
assert!(s.starts_with("I Index"));
assert!(s.contains("cmd=Add"));
assert!(s.contains("deadbeefcafebabe"));
assert!(s.contains("synonym=false"));
}
#[test]
fn fmt_target_formats_fields() {
let f = sqpack_file(0x04, 0x0100, 2);
let s = fmt_target(&f);
assert!(s.contains("main=04"));
assert!(s.contains("sub=0100"));
assert!(s.contains("file_id=2"));
}
}