1use core::cmp::min;
5use std::fs;
6use std::fs::{File, OpenOptions, read, read_dir};
7use std::io::{BufWriter, Cursor, Seek, SeekFrom, Write};
8use std::path::{Path, PathBuf};
9
10use crate::ByteBuffer;
11use binrw::BinRead;
12use binrw::{BinWrite, binrw};
13use tracing::{debug, warn};
14
15use crate::common::{Platform, Region, get_platform_string};
16use crate::common_file_operations::{
17 get_string_len, read_bool_from, read_string, write_bool_as, write_string,
18};
19use crate::sqpack::{read_data_block_patch, write_data_block_patch};
20
21#[binrw]
22#[derive(Debug)]
23#[brw(little)]
24struct PatchHeader {
25 #[br(temp)]
26 #[bw(calc = *b"ZIPATCH")]
27 #[brw(pad_before = 1)]
28 #[brw(pad_after = 4)]
29 #[br(assert(magic == *b"ZIPATCH"))]
30 magic: [u8; 7],
31}
32
33#[binrw]
34#[allow(dead_code)]
35#[brw(little)]
36struct PatchChunk {
37 #[brw(big)]
38 size: u32,
39 chunk_type: ChunkType,
40 #[br(if(chunk_type != ChunkType::EndOfFile))]
41 #[bw(if(*chunk_type != ChunkType::EndOfFile))]
42 crc32: u32,
43}
44
45#[binrw]
46#[derive(PartialEq, Debug)]
47enum ChunkType {
48 #[brw(magic = b"FHDR")]
49 FileHeader(
50 #[brw(pad_before = 2)]
51 #[brw(pad_after = 1)]
52 FileHeaderChunk,
53 ),
54 #[brw(magic = b"APLY")]
55 ApplyOption(ApplyOptionChunk),
56 #[brw(magic = b"ADIR")]
57 AddDirectory(DirectoryChunk),
58 #[brw(magic = b"DELD")]
59 DeleteDirectory(DirectoryChunk),
60 #[brw(magic = b"SQPK")]
61 Sqpk(SqpkChunk),
62 #[brw(magic = b"EOF_")]
63 EndOfFile,
64}
65
66#[binrw]
67#[derive(PartialEq, Debug)]
68enum FileHeaderChunk {
69 #[brw(magic = 2u8)]
70 Version2(FileHeaderChunk2),
71 #[brw(magic = 3u8)]
72 Version3(FileHeaderChunk3),
73}
74
75#[binrw]
76#[derive(PartialEq, Debug)]
77#[brw(big)]
78struct FileHeaderChunk2 {
79 #[br(count = 4)]
80 #[br(map = read_string)]
81 #[bw(map = write_string)]
82 name: String,
83
84 #[brw(pad_before = 8)]
85 depot_hash: u32,
86}
87
88#[binrw]
89#[derive(PartialEq, Debug)]
90#[brw(big)]
91struct FileHeaderChunk3 {
92 #[br(count = 4)]
93 #[br(map = read_string)]
94 #[bw(map = write_string)]
95 name: String,
96
97 entry_files: u32,
98
99 add_directories: u32,
100 delete_directories: u32,
101 delete_data_size: u32,
102 delete_data_size_2: u32,
103 minor_version: u32,
104 repository_name: u32,
105 commands: u32,
106 sqpk_add_commands: u32,
107 sqpk_delete_commands: u32,
108 sqpk_expand_commands: u32,
109 sqpk_header_commands: u32,
110 #[brw(pad_after = 0xB8)]
111 sqpk_file_commands: u32,
112}
113
114#[binrw]
115#[brw(repr = u32)]
116#[brw(big)]
117#[derive(PartialEq, Debug)]
118enum ApplyOption {
119 IgnoreMissing = 1,
120 IgnoreOldMismatch = 2,
121}
122
123#[binrw]
124#[derive(PartialEq, Debug)]
125struct ApplyOptionChunk {
126 #[brw(pad_after = 4)]
127 option: ApplyOption,
128 #[brw(big)]
129 value: u32,
130}
131
132#[binrw]
133#[derive(PartialEq, Debug)]
134struct DirectoryChunk {
135 #[br(temp)]
136 #[bw(calc = get_string_len(name) as u32)]
137 name_length: u32,
138
139 #[br(count = name_length)]
140 #[br(map = read_string)]
141 #[bw(map = write_string)]
142 name: String,
143}
144
145#[binrw]
146#[derive(PartialEq, Debug)]
147enum SqpkOperation {
148 #[brw(magic = b'A')]
149 AddData(SqpkAddData),
150 #[brw(magic = b'D')]
151 DeleteData(SqpkDeleteData),
152 #[brw(magic = b'E')]
153 ExpandData(SqpkDeleteData),
154 #[brw(magic = b'F')]
155 FileOperation(SqpkFileOperationData),
156 #[brw(magic = b'H')]
157 HeaderUpdate(SqpkHeaderUpdateData),
158 #[brw(magic = b'X')]
159 PatchInfo(SqpkPatchInfo),
160 #[brw(magic = b'T')]
161 TargetInfo(SqpkTargetInfo),
162 #[brw(magic = b'I')]
163 Index(SqpkIndex),
164}
165
166#[binrw]
167#[derive(PartialEq, Debug)]
168struct SqpkPatchInfo {
169 status: u8,
170 #[brw(pad_after = 1)]
171 version: u8,
172
173 #[brw(big)]
174 install_size: u64,
175}
176
177#[binrw]
178#[derive(PartialEq, Debug)]
179enum SqpkFileOperation {
180 #[brw(magic = b'A')]
181 AddFile,
182 #[brw(magic = b'R')]
183 RemoveAll,
184 #[brw(magic = b'D')]
185 DeleteFile,
186 #[brw(magic = b'M')]
187 MakeDirTree,
188}
189
190#[binrw]
191#[derive(PartialEq, Debug)]
192#[brw(big)]
193struct SqpkAddData {
194 #[brw(pad_before = 3)]
195 main_id: u16,
196 sub_id: u16,
197 file_id: u32,
198
199 #[br(map = | x : u32 | (x as u64) << 7 )]
200 block_offset: u64,
201 #[br(map = | x : u32 | (x as u64) << 7 )]
202 block_number: u64,
203 #[br(map = | x : u32 | (x as u64) << 7 )]
204 block_delete_number: u64,
205
206 #[br(count = block_number)]
207 block_data: Vec<u8>,
208}
209
210#[binrw]
211#[derive(PartialEq, Debug)]
212#[brw(big)]
213struct SqpkDeleteData {
214 #[brw(pad_before = 3)]
215 main_id: u16,
216 sub_id: u16,
217 file_id: u32,
218
219 #[br(map = | x : u32 | (x as u64) << 7 )]
220 block_offset: u64,
221 #[brw(pad_after = 4)]
222 block_number: u32,
223}
224
225#[binrw]
226#[derive(PartialEq, Debug)]
227enum TargetFileKind {
228 #[brw(magic = b'D')]
229 Dat,
230 #[brw(magic = b'I')]
231 Index,
232}
233
234#[binrw]
235#[derive(PartialEq, Debug)]
236enum TargetHeaderKind {
237 #[brw(magic = b'V')]
238 Version,
239 #[brw(magic = b'I')]
240 Index,
241 #[brw(magic = b'D')]
242 Data,
243}
244
245#[binrw]
246#[derive(PartialEq, Debug)]
247#[brw(big)]
248struct SqpkHeaderUpdateData {
249 file_kind: TargetFileKind,
250 header_kind: TargetHeaderKind,
251
252 #[brw(pad_before = 1)]
253 main_id: u16,
254 sub_id: u16,
255 file_id: u32,
256
257 #[br(count = 1024)]
258 header_data: Vec<u8>,
259}
260
261#[binrw]
262#[derive(PartialEq, Debug)]
263#[brw(big)]
264struct SqpkFileOperationData {
265 #[brw(pad_after = 2)]
266 operation: SqpkFileOperation,
267
268 offset: u64,
269 file_size: u64,
270
271 #[br(temp)]
273 #[bw(calc = get_string_len(path) as u32)]
274 path_length: u32,
275
276 #[brw(pad_after = 2)]
277 expansion_id: u16,
278
279 #[br(count = path_length)]
280 #[br(map = read_string)]
281 #[bw(map = write_string)]
282 path: String,
283}
284
285#[binrw]
286#[derive(PartialEq, Debug)]
287#[brw(big)]
288struct SqpkTargetInfo {
289 #[brw(pad_before = 3)]
290 #[brw(pad_size_to = 2)]
291 platform: Platform, region: Region,
293 #[br(map = read_bool_from::<u16>)]
294 #[bw(map = write_bool_as::<u16>)]
295 is_debug: bool,
296 version: u16,
297 #[brw(little)]
298 deleted_data_size: u64,
299 #[brw(little)]
300 #[brw(pad_after = 96)]
301 seek_count: u64,
302}
303
304#[binrw]
305#[derive(PartialEq, Debug)]
306enum SqpkIndexCommand {
307 #[brw(magic = b'A')]
308 Add,
309 #[brw(magic = b'D')]
310 Delete,
311}
312
313#[binrw]
314#[derive(PartialEq, Debug)]
315#[brw(big)]
316struct SqpkIndex {
317 command: SqpkIndexCommand,
318 #[br(map = read_bool_from::<u8>)]
319 #[bw(map = write_bool_as::<u8>)]
320 is_synonym: bool,
321
322 #[brw(pad_before = 1)]
323 file_hash: u64,
324
325 block_offset: u32,
326 #[brw(pad_after = 8)] block_number: u32,
328}
329
330#[binrw]
331#[derive(PartialEq, Debug)]
332#[brw(big)]
333struct SqpkChunk {
334 size: u32,
335 operation: SqpkOperation,
336}
337
338static WIPE_BUFFER: [u8; 1 << 16] = [0; 1 << 16];
339
340fn wipe(mut file: &File, length: usize) -> Result<(), PatchError> {
341 let mut length: usize = length;
342 while length > 0 {
343 let num_bytes = min(WIPE_BUFFER.len(), length);
344 file.write_all(&WIPE_BUFFER[0..num_bytes])?;
345 length -= num_bytes;
346 }
347
348 Ok(())
349}
350
351fn wipe_from_offset(mut file: &File, length: usize, offset: u64) -> Result<(), PatchError> {
352 file.seek(SeekFrom::Start(offset))?;
353 wipe(file, length)
354}
355
356fn write_empty_file_block_at(
357 mut file: &File,
358 offset: u64,
359 block_number: u64,
360) -> Result<(), PatchError> {
361 wipe_from_offset(file, (block_number << 7) as usize, offset)?;
362
363 file.seek(SeekFrom::Start(offset))?;
364
365 let block_size: i32 = 1 << 7;
366 file.write_all(block_size.to_le_bytes().as_slice())?;
367
368 let unknown: i32 = 0;
369 file.write_all(unknown.to_le_bytes().as_slice())?;
370
371 let file_size: i32 = 0;
372 file.write_all(file_size.to_le_bytes().as_slice())?;
373
374 let num_blocks: i32 = (block_number - 1).try_into().unwrap();
375 file.write_all(num_blocks.to_le_bytes().as_slice())?;
376
377 let used_blocks: i32 = 0;
378 file.write_all(used_blocks.to_le_bytes().as_slice())?;
379
380 Ok(())
381}
382
383fn get_expansion_folder_sub(sub_id: u16) -> String {
384 let expansion_id = sub_id >> 8;
385
386 get_expansion_folder(expansion_id)
387}
388
389fn get_expansion_folder(id: u16) -> String {
390 match id {
391 0 => "ffxiv".to_string(),
392 n => format!("ex{}", n),
393 }
394}
395
396#[derive(Debug)]
397pub enum PatchError {
399 InvalidPatchFile,
401 ParseError,
403}
404
405impl From<std::io::Error> for PatchError {
406 fn from(_: std::io::Error) -> Self {
408 PatchError::InvalidPatchFile
409 }
410}
411
412impl From<binrw::Error> for PatchError {
413 fn from(_: binrw::Error) -> Self {
414 PatchError::ParseError
415 }
416}
417
418fn recurse(path: impl AsRef<Path>) -> Vec<PathBuf> {
419 let Ok(entries) = read_dir(path) else {
420 return vec![];
421 };
422 entries
423 .flatten()
424 .flat_map(|entry| {
425 let Ok(meta) = entry.metadata() else {
426 return vec![];
427 };
428 if meta.is_dir() {
429 return recurse(entry.path());
430 }
431 if meta.is_file() {
432 return vec![entry.path()];
433 }
434 vec![]
435 })
436 .collect()
437}
438
439pub struct ZiPatch;
440
441impl ZiPatch {
442 pub fn apply(data_dir: &str, patch_path: &str) -> Result<(), PatchError> {
444 let mut file = File::open(patch_path)?;
445
446 PatchHeader::read(&mut file)?;
447
448 let mut target_info: Option<SqpkTargetInfo> = None;
449
450 let get_dat_path =
451 |target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
452 let filename = format!(
453 "{:02x}{:04x}.{}.dat{}",
454 main_id,
455 sub_id,
456 get_platform_string(&target_info.platform),
457 file_id
458 );
459 let path: PathBuf = [
460 data_dir,
461 "sqpack",
462 &get_expansion_folder_sub(sub_id),
463 &filename,
464 ]
465 .iter()
466 .collect();
467
468 path.to_str().unwrap().to_string()
469 };
470
471 let get_index_path =
472 |target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
473 let mut filename = format!(
474 "{:02x}{:04x}.{}.index",
475 main_id,
476 sub_id,
477 get_platform_string(&target_info.platform)
478 );
479
480 if file_id != 0 {
482 filename += &*format!("{}", file_id);
483 }
484
485 let path: PathBuf = [
486 data_dir,
487 "sqpack",
488 &get_expansion_folder_sub(sub_id),
489 &filename,
490 ]
491 .iter()
492 .collect();
493
494 path.to_str().unwrap().to_string()
495 };
496
497 loop {
498 let chunk = PatchChunk::read(&mut file)?;
499
500 match chunk.chunk_type {
501 ChunkType::Sqpk(pchunk) => {
502 match pchunk.operation {
503 SqpkOperation::AddData(add) => {
504 let filename = get_dat_path(
505 target_info.as_ref().unwrap(),
506 add.main_id,
507 add.sub_id,
508 add.file_id,
509 );
510
511 let (left, _) = filename.rsplit_once('/').unwrap();
512 fs::create_dir_all(left)?;
513
514 let mut new_file = OpenOptions::new()
515 .write(true)
516 .create(true)
517 .truncate(false)
518 .open(filename)?;
519
520 new_file.seek(SeekFrom::Start(add.block_offset))?;
521
522 new_file.write_all(&add.block_data)?;
523
524 wipe(&new_file, add.block_delete_number as usize)?;
525 }
526 SqpkOperation::DeleteData(delete) => {
527 let filename = get_dat_path(
528 target_info.as_ref().unwrap(),
529 delete.main_id,
530 delete.sub_id,
531 delete.file_id,
532 );
533
534 let new_file = OpenOptions::new()
535 .write(true)
536 .create(true)
537 .truncate(false)
538 .open(filename)?;
539
540 write_empty_file_block_at(
541 &new_file,
542 delete.block_offset,
543 delete.block_number as u64,
544 )?;
545 }
546 SqpkOperation::ExpandData(expand) => {
547 let filename = get_dat_path(
548 target_info.as_ref().unwrap(),
549 expand.main_id,
550 expand.sub_id,
551 expand.file_id,
552 );
553
554 let (left, _) = filename.rsplit_once('/').unwrap();
555 fs::create_dir_all(left)?;
556
557 let new_file = OpenOptions::new()
558 .write(true)
559 .create(true)
560 .truncate(false)
561 .open(filename)?;
562
563 write_empty_file_block_at(
564 &new_file,
565 expand.block_offset,
566 expand.block_number as u64,
567 )?;
568 }
569 SqpkOperation::HeaderUpdate(header) => {
570 let file_path = match header.file_kind {
571 TargetFileKind::Dat => get_dat_path(
572 target_info.as_ref().unwrap(),
573 header.main_id,
574 header.sub_id,
575 header.file_id,
576 ),
577 TargetFileKind::Index => get_index_path(
578 target_info.as_ref().unwrap(),
579 header.main_id,
580 header.sub_id,
581 header.file_id,
582 ),
583 };
584
585 let (left, _) =
586 file_path.rsplit_once('/').ok_or(PatchError::ParseError)?;
587 fs::create_dir_all(left)?;
588
589 let mut new_file = OpenOptions::new()
590 .write(true)
591 .create(true)
592 .truncate(false)
593 .open(file_path)?;
594
595 if header.header_kind != TargetHeaderKind::Version {
596 new_file.seek(SeekFrom::Start(1024))?;
597 }
598
599 new_file.write_all(&header.header_data)?;
600 }
601 SqpkOperation::FileOperation(fop) => {
602 let file_path = format!("{}/{}", data_dir, fop.path);
603 let (parent_directory, _) = file_path.rsplit_once('/').unwrap();
604
605 match fop.operation {
606 SqpkFileOperation::AddFile => {
607 fs::create_dir_all(parent_directory)?;
608
609 file.seek(SeekFrom::Current(-4))?;
611
612 let mut data: Vec<u8> =
613 Vec::with_capacity(fop.file_size as usize);
614
615 while data.len() < fop.file_size as usize {
616 data.append(&mut read_data_block_patch(&mut file).unwrap());
617 }
618
619 file.seek(SeekFrom::Current(4))?;
621
622 let new_file = OpenOptions::new()
624 .write(true)
625 .create(true)
626 .truncate(false)
627 .open(&file_path);
628
629 if let Ok(mut file) = new_file {
630 if fop.offset == 0 {
631 file.set_len(0)?;
632 }
633
634 file.seek(SeekFrom::Start(fop.offset))?;
635 file.write_all(&data)?;
636 } else {
637 warn!("{file_path} does not exist, skipping.");
638 }
639 }
640 SqpkFileOperation::DeleteFile => {
641 if fs::remove_file(file_path.as_str()).is_err() {
642 warn!("Failed to remove {file_path}");
643 }
644 }
645 SqpkFileOperation::RemoveAll => {
646 let path: PathBuf = [
647 data_dir,
648 "sqpack",
649 &get_expansion_folder(fop.expansion_id),
650 ]
651 .iter()
652 .collect();
653
654 if fs::read_dir(&path).is_ok() {
655 fs::remove_dir_all(&path)?;
656 }
657 }
658 SqpkFileOperation::MakeDirTree => {
659 fs::create_dir_all(parent_directory)?;
660 }
661 }
662 }
663 SqpkOperation::PatchInfo(_) => {
664 debug!("PATCH: NOP PatchInfo");
666 }
667 SqpkOperation::TargetInfo(new_target_info) => {
668 target_info = Some(new_target_info);
669 }
670 SqpkOperation::Index(_) => {
671 debug!("PATCH: NOP Index");
673 }
674 }
675 }
676 ChunkType::FileHeader(_) => {
677 debug!("PATCH: NOP FileHeader");
679 }
680 ChunkType::ApplyOption(_) => {
681 debug!("PATCH: NOP ApplyOption");
683 }
684 ChunkType::AddDirectory(_) => {
685 debug!("PATCH: NOP AddDirectory");
686 }
687 ChunkType::DeleteDirectory(_) => {
688 debug!("PATCH: NOP DeleteDirectory");
689 }
690 ChunkType::EndOfFile => {
691 return Ok(());
692 }
693 }
694 }
695 }
696
697 pub fn create(base_directory: &str, new_directory: &str) -> Option<ByteBuffer> {
699 let mut buffer = ByteBuffer::new();
700
701 {
702 let cursor = Cursor::new(&mut buffer);
703 let mut writer = BufWriter::new(cursor);
704
705 let header = PatchHeader {};
706 header.write(&mut writer).ok()?;
707
708 let base_files = crate::patch::recurse(base_directory);
709 let new_files = crate::patch::recurse(new_directory);
710
711 let added_files: Vec<&PathBuf> = new_files
713 .iter()
714 .filter(|item| {
715 let metadata = fs::metadata(item).unwrap();
716 !base_files.contains(item) && metadata.len() > 0 })
718 .collect();
719
720 let removed_files: Vec<&PathBuf> = base_files
722 .iter()
723 .filter(|item| !new_files.contains(item))
724 .collect();
725
726 for file in added_files {
728 let file_data = read(file.to_str().unwrap()).unwrap();
729 let relative_path = file
730 .strip_prefix(new_directory)
731 .unwrap()
732 .to_str()
733 .unwrap()
734 .to_string();
735
736 let add_file_chunk = PatchChunk {
737 size: 0,
738 chunk_type: ChunkType::Sqpk(SqpkChunk {
739 size: 0,
740 operation: SqpkOperation::FileOperation(SqpkFileOperationData {
741 operation: SqpkFileOperation::AddFile,
742 offset: 0,
743 file_size: file_data.len() as u64,
744 expansion_id: 0,
745 path: relative_path,
746 }),
747 }),
748 crc32: 0,
749 };
750
751 add_file_chunk.write(&mut writer).ok()?;
752
753 writer.seek(SeekFrom::Current(-4)).ok()?;
755
756 write_data_block_patch(&mut writer, file_data);
758
759 writer.seek(SeekFrom::Current(4)).ok()?;
761 }
762
763 for file in removed_files {
765 let relative_path = file
766 .strip_prefix(base_directory)
767 .unwrap()
768 .to_str()
769 .unwrap()
770 .to_string();
771
772 let remove_file_chunk = PatchChunk {
773 size: 0,
774 chunk_type: ChunkType::Sqpk(SqpkChunk {
775 size: 0,
776 operation: SqpkOperation::FileOperation(SqpkFileOperationData {
777 operation: SqpkFileOperation::DeleteFile,
778 offset: 0,
779 file_size: 0,
780 expansion_id: 0,
781 path: relative_path,
782 }),
783 }),
784 crc32: 0,
785 };
786
787 remove_file_chunk.write(&mut writer).ok()?;
788 }
789
790 let eof_chunk = PatchChunk {
791 size: 0,
792 chunk_type: ChunkType::EndOfFile,
793 crc32: 0,
794 };
795 eof_chunk.write(&mut writer).ok()?;
796 }
797
798 Some(buffer)
799 }
800}
801
802#[cfg(test)]
804mod tests {
805 use std::fs::{read, write};
806 use std::path::PathBuf;
807
808 use super::*;
809
810 fn prepare_data_dir() -> String {
812 let mut dir = std::env::temp_dir();
813 dir.push("physis-patch-tests");
814 if dir.exists() {
815 fs::remove_dir_all(&dir);
816 }
817
818 fs::create_dir_all(&dir);
819
820 dir.to_str().unwrap().to_string()
821 }
822
823 #[test]
824 fn test_invalid() {
825 let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
826 d.push("resources/tests");
827 d.push("random");
828
829 let data_dir = prepare_data_dir();
830
831 write(data_dir.clone() + "/test.patch", read(d).unwrap()).unwrap();
832
833 let Err(PatchError::ParseError) =
835 ZiPatch::apply(&data_dir.clone(), &(data_dir + "/test.patch"))
836 else {
837 panic!("Expecting a parse error!");
838 };
839 }
840
841 #[test]
842 fn test_add_file_op() {
843 let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
844 d.push("resources/tests");
845 d.push("random");
846
847 let data_dir = prepare_data_dir();
848
849 let mut resources_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
850 resources_dir.push("resources/tests");
851
852 let patch = ZiPatch::create(&data_dir, resources_dir.to_str().unwrap()).unwrap();
854
855 write(data_dir.clone() + "/test.patch", &patch).unwrap();
856
857 ZiPatch::apply(&data_dir.clone(), &(data_dir.clone() + "/test.patch")).unwrap();
858
859 fs::remove_file(data_dir.clone() + "/test.patch").unwrap();
860
861 let old_files = recurse(&resources_dir);
862 let new_files = recurse(&data_dir);
863
864 let mut old_relative_files: Vec<&Path> = old_files
865 .iter()
866 .filter(|item| {
867 let metadata = fs::metadata(item).unwrap();
868 metadata.len() > 0 })
870 .map(|x| x.strip_prefix(&resources_dir).unwrap())
871 .collect();
872 let mut new_relative_files: Vec<&Path> = new_files
873 .iter()
874 .map(|x| x.strip_prefix(&data_dir).unwrap())
875 .collect();
876
877 assert_eq!(old_relative_files.sort(), new_relative_files.sort());
878 }
879}