1
Fork 0
mirror of https://github.com/redstrate/Physis.git synced 2025-04-21 20:27:46 +00:00
physis/src/patch.rs
Joshua Goins 8e6b0dd6b4 Fix issue where patches could panic because file didn't exist
Some of the recent boot updates now include patch files that try to
ask us to remove files that were already deleted. Now the patch process
is smarter and will quietly warn that said operation failed but will
continue to chug along.
2024-03-23 11:56:55 -04:00

643 lines
18 KiB
Rust
Executable file

// SPDX-FileCopyrightText: 2023 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-or-later
use core::cmp::min;
use std::fs;
use std::fs::{File, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use binrw::binread;
use binrw::BinRead;
use binrw::binrw;
use tracing::{debug, warn};
use crate::common::Region;
use crate::sqpack::read_data_block_patch;
#[binread]
#[derive(Debug)]
#[br(little)]
struct PatchHeader {
#[br(temp)]
#[br(count = 7)]
#[br(pad_before = 1)]
#[br(pad_after = 4)]
#[br(assert(magic == b"ZIPATCH"))]
magic: Vec<u8>,
}
#[derive(BinRead, Debug)]
#[allow(dead_code)]
#[br(little)]
struct PatchChunk {
#[br(big)]
size: u32,
chunk_type: ChunkType,
#[br(if(chunk_type != ChunkType::EndOfFile))]
crc32: u32,
}
#[derive(BinRead, PartialEq, Debug)]
enum ChunkType {
#[br(magic = b"FHDR")]
FileHeader(
#[br(pad_before = 2)]
#[br(pad_after = 1)]
FileHeaderChunk,
),
#[br(magic = b"APLY")]
ApplyOption(ApplyOptionChunk),
#[br(magic = b"ADIR")]
AddDirectory(DirectoryChunk),
#[br(magic = b"DELD")]
DeleteDirectory(DirectoryChunk),
#[br(magic = b"SQPK")]
Sqpk(SqpkChunk),
#[br(magic = b"EOF_")]
EndOfFile,
}
#[derive(BinRead, PartialEq, Debug)]
enum FileHeaderChunk {
#[br(magic = 2u8)]
Version2(FileHeaderChunk2),
#[br(magic = 3u8)]
Version3(FileHeaderChunk3),
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct FileHeaderChunk2 {
#[br(count = 4)]
#[br(map = | x: Vec < u8 > | String::from_utf8(x).unwrap())]
name: String,
#[br(pad_before = 8)]
depot_hash: u32,
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct FileHeaderChunk3 {
#[br(count = 4)]
#[br(map = | x: Vec < u8 > | String::from_utf8(x).unwrap())]
name: String,
entry_files: u32,
add_directories: u32,
delete_directories: u32,
delete_data_size: u32,
delete_data_size_2: u32,
minor_version: u32,
repository_name: u32,
commands: u32,
sqpk_add_commands: u32,
sqpk_delete_commands: u32,
sqpk_expand_commands: u32,
sqpk_header_commands: u32,
#[br(pad_after = 0xB8)]
sqpk_file_commands: u32,
}
#[binread]
#[br(repr = u32)]
#[br(big)]
#[derive(PartialEq, Debug)]
enum ApplyOption {
IgnoreMissing = 1,
IgnoreOldMismatch = 2,
}
#[binrw::binread]
#[derive(PartialEq, Debug)]
struct ApplyOptionChunk {
#[br(pad_after = 4)]
option: ApplyOption,
#[br(big)]
value: u32,
}
#[binrw::binread]
#[derive(PartialEq, Debug)]
struct DirectoryChunk {
#[br(temp)]
path_length: u32,
#[br(count = path_length)]
#[br(map = | x: Vec < u8 > | String::from_utf8(x).unwrap())]
name: String,
}
#[binread]
#[derive(PartialEq, Debug)]
enum SqpkOperation {
#[br(magic = b'A')]
AddData(SqpkAddData),
#[br(magic = b'D')]
DeleteData(SqpkDeleteData),
#[br(magic = b'E')]
ExpandData(SqpkDeleteData),
#[br(magic = b'F')]
FileOperation(SqpkFileOperationData),
#[br(magic = b'H')]
HeaderUpdate(SqpkHeaderUpdateData),
#[br(magic = b'X')]
PatchInfo(SqpkPatchInfo),
#[br(magic = b'T')]
TargetInfo(SqpkTargetInfo),
#[br(magic = b'I')]
Index(SqpkIndex)
}
#[derive(BinRead, PartialEq, Debug)]
struct SqpkPatchInfo {
status: u8,
#[br(pad_after = 1)]
version: u8,
#[br(big)]
install_size: u64,
}
#[binread]
#[derive(PartialEq, Debug)]
enum SqpkFileOperation {
#[br(magic = b'A')]
AddFile,
#[br(magic = b'R')]
RemoveAll,
#[br(magic = b'D')]
DeleteFile,
#[br(magic = b'M')]
MakeDirTree,
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkAddData {
#[br(pad_before = 3)]
main_id: u16,
sub_id: u16,
file_id: u32,
#[br(map = | x : u32 | x << 7 )]
block_offset: u32,
#[br(map = | x : u32 | x << 7 )]
block_number: u32,
#[br(map = | x : u32 | x << 7 )]
block_delete_number: u32,
#[br(count = block_number)]
block_data: Vec<u8>,
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkDeleteData {
#[br(pad_before = 3)]
main_id: u16,
sub_id: u16,
file_id: u32,
#[br(map = | x : u32 | x << 7 )]
block_offset: u32,
#[br(pad_after = 4)]
block_number: u32,
}
#[binread]
#[derive(PartialEq, Debug)]
enum TargetFileKind {
#[br(magic = b'D')]
Dat,
#[br(magic = b'I')]
Index,
}
#[binread]
#[derive(PartialEq, Debug)]
enum TargetHeaderKind {
#[br(magic = b'V')]
Version,
#[br(magic = b'I')]
Index,
#[br(magic = b'D')]
Data,
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkHeaderUpdateData {
file_kind: TargetFileKind,
header_kind: TargetHeaderKind,
#[br(pad_before = 1)]
main_id: u16,
sub_id: u16,
file_id: u32,
#[br(count = 1024)]
header_data: Vec<u8>,
}
#[binread]
#[derive(PartialEq, Debug)]
#[br(big)]
struct SqpkFileOperationData {
#[br(pad_after = 2)]
operation: SqpkFileOperation,
offset: u64,
file_size: u64,
#[br(temp)]
path_length: u32,
#[br(pad_after = 2)]
expansion_id: u16,
#[br(count = path_length)]
#[br(map = | x: Vec < u8 > | String::from_utf8(x[..x.len() - 1].to_vec()).unwrap())]
path: String,
}
#[binrw]
#[brw(repr = u16)]
#[derive(Debug, PartialEq)]
enum PlatformId {
Windows,
PS3,
PS4,
}
fn get_platform_string(id: &PlatformId) -> &'static str {
match &id {
PlatformId::Windows => "win32",
PlatformId::PS3 => "ps3.d",
PlatformId::PS4 => "ps4.d",
}
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkTargetInfo {
#[br(pad_before = 3)]
platform: PlatformId,
region: Region,
#[br(map = | x : u16 | x == 1)]
is_debug: bool,
version: u16,
#[br(little)]
deleted_data_size: u64,
#[br(little)]
#[br(pad_after = 96)]
seek_count: u64,
}
#[binread]
#[derive(PartialEq, Debug)]
enum SqpkIndexCommand {
#[br(magic = b'A')]
Add,
#[br(magic = b'D')]
Delete,
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkIndex {
command: SqpkIndexCommand,
#[br(map = | x : u8 | x == 1)]
is_synonym: bool,
#[br(pad_before = 1)]
file_hash: u64,
block_offset: u32,
#[br(pad_after = 8)] // data?
block_number: u32
}
#[derive(BinRead, PartialEq, Debug)]
#[br(big)]
struct SqpkChunk {
size: u32,
operation: SqpkOperation,
}
const WIPE_BUFFER: [u8; 1 << 16] = [0; 1 << 16];
fn wipe(mut file: &File, length: u32) -> Result<(), PatchError> {
let mut length: usize = length as usize;
while length > 0 {
let num_bytes = min(WIPE_BUFFER.len(), length);
file.write_all(&WIPE_BUFFER[0..num_bytes])?;
length -= num_bytes;
}
Ok(())
}
fn wipe_from_offset(mut file: &File, length: u32, offset: u32) -> Result<(), PatchError> {
file.seek(SeekFrom::Start(offset as u64))?;
wipe(file, length)
}
fn write_empty_file_block_at(
mut file: &File,
offset: u32,
block_number: u32,
) -> Result<(), PatchError> {
wipe_from_offset(file, block_number << 7, offset)?;
file.seek(SeekFrom::Start(offset as u64))?;
let block_size: i32 = 1 << 7;
file.write_all(block_size.to_le_bytes().as_slice())?;
let unknown: i32 = 0;
file.write_all(unknown.to_le_bytes().as_slice())?;
let file_size: i32 = 0;
file.write_all(file_size.to_le_bytes().as_slice())?;
let num_blocks: i32 = (block_number - 1).try_into().unwrap();
file.write_all(num_blocks.to_le_bytes().as_slice())?;
let used_blocks: i32 = 0;
file.write_all(used_blocks.to_le_bytes().as_slice())?;
Ok(())
}
fn get_expansion_folder_sub(sub_id: u16) -> String {
let expansion_id = sub_id >> 8;
get_expansion_folder(expansion_id)
}
fn get_expansion_folder(id: u16) -> String {
match id {
0 => "ffxiv".to_string(),
n => format!("ex{}", n),
}
}
#[derive(Debug)]
/// Errors emitted in the patching process
pub enum PatchError {
/// Failed to read parts of the file
InvalidPatchFile,
/// Failed to parse the patch format
ParseError,
}
impl From<std::io::Error> for PatchError {
// TODO: implement specific PatchErrors for stuff like out of storage space. invalidpatchfile is a bad name for this
fn from(_: std::io::Error) -> Self {
PatchError::InvalidPatchFile
}
}
impl From<binrw::Error> for PatchError {
fn from(_: binrw::Error) -> Self {
PatchError::ParseError
}
}
/// Applies a boot or a game patch to the specified _data_dir_.
pub fn apply_patch(data_dir: &str, patch_path: &str) -> Result<(), PatchError> {
let mut file = File::open(patch_path)?;
PatchHeader::read(&mut file)?;
let mut target_info: Option<SqpkTargetInfo> = None;
let get_dat_path =
|target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
let filename = format!(
"{:02x}{:04x}.{}.dat{}",
main_id,
sub_id,
get_platform_string(&target_info.platform),
file_id
);
let path: PathBuf = [
data_dir,
"sqpack",
&get_expansion_folder_sub(sub_id),
&filename,
]
.iter()
.collect();
path.to_str().unwrap().to_string()
};
let get_index_path =
|target_info: &SqpkTargetInfo, main_id: u16, sub_id: u16, file_id: u32| -> String {
let mut filename = format!(
"{:02x}{:04x}.{}.index",
main_id,
sub_id,
get_platform_string(&target_info.platform)
);
// index files have no special ending if it's file_id == 0
if file_id != 0 {
filename += &*format!("{}", file_id);
}
let path: PathBuf = [
data_dir,
"sqpack",
&get_expansion_folder_sub(sub_id),
&filename,
]
.iter()
.collect();
path.to_str().unwrap().to_string()
};
loop {
let chunk = PatchChunk::read(&mut file)?;
match chunk.chunk_type {
ChunkType::Sqpk(pchunk) => {
match pchunk.operation {
SqpkOperation::AddData(add) => {
let filename = get_dat_path(
target_info.as_ref().unwrap(),
add.main_id,
add.sub_id,
add.file_id,
);
let (left, _) = filename.rsplit_once('/').unwrap();
fs::create_dir_all(left)?;
let mut new_file =
OpenOptions::new().write(true).create(true).open(filename)?;
new_file.seek(SeekFrom::Start(add.block_offset as u64))?;
new_file.write_all(&add.block_data)?;
wipe(&new_file, add.block_delete_number)?;
}
SqpkOperation::DeleteData(delete) => {
let filename = get_dat_path(
target_info.as_ref().unwrap(),
delete.main_id,
delete.sub_id,
delete.file_id,
);
let new_file =
OpenOptions::new().write(true).create(true).open(filename)?;
write_empty_file_block_at(
&new_file,
delete.block_offset,
delete.block_number,
)?;
}
SqpkOperation::ExpandData(expand) => {
let filename = get_dat_path(
target_info.as_ref().unwrap(),
expand.main_id,
expand.sub_id,
expand.file_id,
);
let (left, _) = filename.rsplit_once('/').unwrap();
fs::create_dir_all(left)?;
let new_file =
OpenOptions::new().write(true).create(true).open(filename)?;
write_empty_file_block_at(
&new_file,
expand.block_offset,
expand.block_number,
)?;
}
SqpkOperation::HeaderUpdate(header) => {
let file_path = match header.file_kind {
TargetFileKind::Dat => get_dat_path(
target_info.as_ref().unwrap(),
header.main_id,
header.sub_id,
header.file_id,
),
TargetFileKind::Index => get_index_path(
target_info.as_ref().unwrap(),
header.main_id,
header.sub_id,
header.file_id,
),
};
let (left, _) = file_path.rsplit_once('/').ok_or(PatchError::ParseError)?;
fs::create_dir_all(left)?;
let mut new_file = OpenOptions::new()
.write(true)
.create(true)
.open(file_path)?;
if header.header_kind != TargetHeaderKind::Version {
new_file.seek(SeekFrom::Start(1024))?;
}
new_file.write_all(&header.header_data)?;
}
SqpkOperation::FileOperation(fop) => {
let file_path = format!("{}/{}", data_dir, fop.path);
let (parent_directory, _) = file_path.rsplit_once('/').unwrap();
match fop.operation {
SqpkFileOperation::AddFile => {
fs::create_dir_all(parent_directory)?;
// reverse reading crc32
file.seek(SeekFrom::Current(-4))?;
let mut data: Vec<u8> = Vec::with_capacity(fop.file_size as usize);
while data.len() < fop.file_size as usize {
data.append(&mut read_data_block_patch(&mut file).unwrap());
}
// re-apply crc32
file.seek(SeekFrom::Current(4))?;
// now apply the file!
let new_file = OpenOptions::new()
.write(true)
.create(true)
.open(&file_path);
if let Ok(mut file) = new_file {
if fop.offset == 0 {
file.set_len(0)?;
}
file.seek(SeekFrom::Start(fop.offset))?;
file.write_all(&data)?;
} else {
warn!("{file_path} does not exist, skipping.");
}
}
SqpkFileOperation::DeleteFile => {
if fs::remove_file(file_path.as_str()).is_err() {
warn!("Failed to remove {file_path}");
}
}
SqpkFileOperation::RemoveAll => {
let path: PathBuf =
[data_dir, "sqpack", &get_expansion_folder(fop.expansion_id)]
.iter()
.collect();
if fs::read_dir(&path).is_ok() {
fs::remove_dir_all(&path)?;
}
}
SqpkFileOperation::MakeDirTree => {
fs::create_dir_all(parent_directory)?;
}
}
}
SqpkOperation::PatchInfo(_) => {
// Currently, there's nothing we need from PatchInfo. Intentional NOP.
}
SqpkOperation::TargetInfo(new_target_info) => {
target_info = Some(new_target_info);
}
SqpkOperation::Index(_) => {
// Currently, there's nothing we need from Index command. Intentional NOP.
}
}
}
ChunkType::FileHeader(_) => {
// Currently there's nothing very useful in the FileHeader, so it's an intentional NOP.
}
ChunkType::ApplyOption(_) => {
// Currently, IgnoreMissing and IgnoreOldMismatch is not used in XIVQuickLauncher either. This stays as an intentional NOP.
}
ChunkType::AddDirectory(_) => {
debug!("PATCH: NOP AddDirectory");
}
ChunkType::DeleteDirectory(_) => {
debug!("PATCH: NOP DeleteDirectory");
}
ChunkType::EndOfFile => {
return Ok(());
}
}
}
}