zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
use binrw::BinRead;
use std::io::Cursor;

use super::SqpackFile;

// SQPK 'A' body layout (all big-endian):
//   3 pad + 2 main_id + 2 sub_id + 4 file_id          (SqpackFile, padded)
//   + 4 block_offset_raw + 4 data_bytes_raw + 4 block_delete_number_raw
//   = 23 bytes before the inline data payload
const SQPK_ADDDATA_HEADER_SIZE: u64 = 23;

/// SQPK `A` command body: write an inline data payload into a `.dat` file,
/// then zero a trailing region.
///
/// The `A` command is the primary mechanism for patching game data. It carries
/// its payload inline in the patch file: the bytes at `data[0..data_bytes]` are
/// written to the resolved `.dat` file starting at `block_offset`, and then
/// `block_delete_number` additional bytes are zeroed out immediately after the
/// payload. This zeroing step logically marks the trailing range of blocks as
/// deleted / available.
///
/// ## Wire format (all big-endian)
///
/// ```text
/// ┌────────────────────────────────────────────────────────────────────────┐
/// │ <padding>              : [u8; 3]  (reserved, always zero)              │  bytes 0–2
/// │ main_id                : u16 BE                                        │  bytes 3–4
/// │ sub_id                 : u16 BE                                        │  bytes 5–6
/// │ file_id                : u32 BE                                        │  bytes 7–10
/// │ block_offset_raw       : u32 BE  multiply by 128 to get byte offset    │  bytes 11–14
/// │ data_bytes_raw         : u32 BE  multiply by 128 to get payload size   │  bytes 15–18
/// │ block_delete_number_raw: u32 BE  multiply by 128 to get zero length    │  bytes 19–22
/// │ data                   : [u8; data_bytes]  inline payload              │  bytes 23–…
/// └────────────────────────────────────────────────────────────────────────┘
/// ```
///
/// All three raw `u32` size/offset fields are in **128-byte `SqPack` block units**
/// and are multiplied by 128 (`<< 7`) during parsing; the decoded fields
/// (`block_offset`, `data_bytes`, `block_delete_number`) are already in bytes.
///
/// ## Reference
///
/// See `SqpkAddData.cs` in the `XIVLauncher` reference implementation.
///
/// # Errors
///
/// Parsing returns [`crate::ZiPatchError::BinrwError`] if the body is too
/// short to contain the fixed header or the declared `data_bytes` payload.
#[derive(BinRead, Debug, Clone, PartialEq, Eq)]
#[br(big)]
pub struct SqpkAddData {
    /// `SqPack` file to write into.
    ///
    /// Preceded by 3 bytes of alignment padding in the wire format.
    #[br(pad_before = 3)]
    pub target_file: SqpackFile,
    /// Byte offset within the target `.dat` file at which to begin writing.
    ///
    /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
    /// The raw value is in 128-byte `SqPack` block units.
    #[br(map = |raw: u32| (raw as u64) << 7)]
    pub block_offset: u64,
    /// Length in bytes of the inline [`data`](SqpkAddData::data) payload.
    ///
    /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
    /// Used to determine how many bytes to read into `data`.
    #[br(map = |raw: u32| (raw as u64) << 7)]
    pub data_bytes: u64,
    /// Number of bytes to zero immediately after writing `data`.
    ///
    /// Decoded from a raw big-endian `u32` by multiplying by 128 (`raw << 7`).
    /// If non-zero, the apply layer writes this many zero bytes starting at
    /// `block_offset + data_bytes`, logically marking those blocks as freed.
    #[br(map = |raw: u32| (raw as u64) << 7)]
    pub block_delete_number: u64,
    /// Inline data payload of exactly `data_bytes` bytes.
    ///
    /// Written verbatim to the target `.dat` file at `block_offset`. The
    /// content is raw `SqPack` block data — compressed game assets, index
    /// tables, etc. — as the game engine expects them.
    #[br(count = data_bytes)]
    pub data: Vec<u8>,
}

impl SqpkAddData {
    /// Byte offset of the [`data`](SqpkAddData::data) field within the SQPK
    /// command body slice (i.e. the byte slice starting after the SQPK
    /// `inner_size` + sub-command tag).
    ///
    /// This constant (23) is the size of the fixed header preceding the inline
    /// payload: 3 bytes padding + 8 bytes `SqpackFile` + 4 + 4 + 4 bytes for
    /// the three raw size/offset `u32`s.
    ///
    /// Adding this constant to the chunk's absolute position in the patch file
    /// gives the patch-file offset where `data` begins — the value needed for
    /// `IndexedZiPatch` random-access reads that skip decompressing the full
    /// patch stream.
    pub const DATA_SOURCE_OFFSET: u64 = SQPK_ADDDATA_HEADER_SIZE;
}

pub(crate) fn parse(body: &[u8]) -> crate::Result<SqpkAddData> {
    Ok(SqpkAddData::read_be(&mut Cursor::new(body))?)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_add_data() {
        let mut body = Vec::new();
        body.extend_from_slice(&[0u8; 3]); // alignment
        body.extend_from_slice(&1u16.to_be_bytes()); // main_id
        body.extend_from_slice(&2u16.to_be_bytes()); // sub_id
        body.extend_from_slice(&3u32.to_be_bytes()); // file_id
        body.extend_from_slice(&1u32.to_be_bytes()); // block_offset raw → 1 << 7 = 128
        body.extend_from_slice(&1u32.to_be_bytes()); // data_bytes raw → 1 << 7 = 128
        body.extend_from_slice(&0u32.to_be_bytes()); // block_delete_number raw → 0
        body.extend_from_slice(&[0xABu8; 128]); // data blob

        let cmd = parse(&body).unwrap();
        assert_eq!(cmd.target_file.main_id, 1);
        assert_eq!(cmd.target_file.sub_id, 2);
        assert_eq!(cmd.target_file.file_id, 3);
        assert_eq!(cmd.block_offset, 128);
        assert_eq!(cmd.data_bytes, 128);
        assert_eq!(cmd.block_delete_number, 0);
        assert_eq!(cmd.data.len(), 128);
        assert!(cmd.data.iter().all(|&b| b == 0xAB));
        assert_eq!(SqpkAddData::DATA_SOURCE_OFFSET, 23);
    }
}