zipatch-rs 1.0.2

Parser for FFXIV ZiPatch patch files
Documentation
//! `SqPack` on-disk path resolution.
//!
//! `SqPack` files live under
//! `<game_root>/sqpack/<expansion>/<category><sub>.<platform>.<kind>[<n>]`.
//! This module reconstructs those paths from the numeric identifiers carried
//! in SQPK chunk headers.
//!
//! # Path structure
//!
//! Every `SqPack` file path has four variable components:
//!
//! | Component | Source | Example |
//! |-----------|--------|---------|
//! | Expansion folder | high byte of `sub_id` | `ffxiv`, `ex1`, `ex2` |
//! | Filename prefix | `main_id` (2 hex digits) + `sub_id` (4 hex digits) | `040100` |
//! | Platform suffix | [`crate::Platform`] from [`crate::apply::ApplyContext`] | `win32`, `ps3`, `ps4` |
//! | Kind + index | `.dat0`, `.index`, `.index1` | |
//!
//! ## Expansion folder
//!
//! Derived from bits `[15:8]` of `sub_id` (i.e. `sub_id >> 8`):
//!
//! - `0` → `ffxiv` (base game data)
//! - `n > 0` → `ex<n>` (expansion packs: `ex1`, `ex2`, …)
//!
//! ## Platform suffix
//!
//! Derived from [`crate::Platform`] stored in [`crate::apply::ApplyContext`]:
//!
//! - [`crate::Platform::Win32`] → `win32`
//! - [`crate::Platform::Ps3`] → `ps3`
//! - [`crate::Platform::Ps4`] → `ps4`
//! - [`crate::Platform::Unknown`] → `win32` (fallback; prevents misrouting to
//!   a nonexistent layout while still allowing the apply to proceed)
//!
//! ## `.dat` files
//!
//! `<game_root>/sqpack/<expansion>/<main_id:02x><sub_id:04x>.<platform>.dat<file_id>`
//!
//! Example: `main_id=0x04`, `sub_id=0x0100`, `file_id=2`, `platform=Win32`
//! → `sqpack/ex1/040100.win32.dat2`
//!
//! ## `.index` files
//!
//! `<game_root>/sqpack/<expansion>/<main_id:02x><sub_id:04x>.<platform>.index[<file_id>]`
//!
//! `file_id=0` produces no suffix (`.index`); `file_id=1` produces `.index1`,
//! `file_id=2` produces `.index2`, and so on. This matches the on-disk layout
//! where the primary index has no numeric suffix and alternate indexes are
//! numbered from 1.
//!
//! ## Generic files
//!
//! Non-`SqPack` files (written by [`crate::chunk::sqpk::SqpkFile`]'s
//! `AddFile` and `DeleteFile` operations) are resolved as a simple join:
//! `<game_root>/<relative_path>`.

use super::ApplyContext;
use crate::Platform;
use std::path::PathBuf;

/// Map an expansion ID to its on-disk folder name.
///
/// - `0` → `"ffxiv"` (base game)
/// - `n > 0` → `"ex<n>"` (e.g. `1` → `"ex1"`, `2` → `"ex2"`)
///
/// This is the public form that accepts a pre-extracted expansion ID (i.e. the
/// high byte of `sub_id`). The `expansion_folder` private function calls this
/// after extracting the ID from a raw `sub_id` via `sub_id >> 8`.
pub(crate) fn expansion_folder_id(id: u16) -> String {
    if id == 0 {
        "ffxiv".to_string()
    } else {
        format!("ex{id}")
    }
}

/// Extract the expansion ID from a raw `sub_id` and return its folder name.
///
/// The expansion ID is the high byte of `sub_id` (`sub_id >> 8`).
fn expansion_folder(sub_id: u16) -> String {
    expansion_folder_id(sub_id >> 8)
}

/// Map a [`Platform`] to its path-component string.
///
/// [`Platform::Unknown`] falls back to `"win32"` rather than failing: an
/// unrecognised platform ID in a `TargetInfo` chunk is preserved as
/// `Unknown(id)` to avoid a hard error during parsing, but path resolution
/// still needs a concrete string. The `win32` fallback is the best-effort
/// choice because the Windows client layout is the most common.
fn platform_str(platform: Platform) -> &'static str {
    // Unknown platforms fall back to win32 paths — best effort so partial
    // applies do not silently misroute to a different folder layout.
    match platform {
        Platform::Win32 | Platform::Unknown(_) => "win32",
        Platform::Ps3 => "ps3",
        Platform::Ps4 => "ps4",
    }
}

/// Resolve the path of a `SqPack` `.dat` file.
///
/// Produces:
/// `<game_root>/sqpack/<expansion>/<main_id:02x><sub_id:04x>.<platform>.dat<file_id>`
///
/// # Arguments
///
/// - `ctx` — provides the install root and current platform.
/// - `main_id` — the category/repository identifier (2 lower hex digits of
///   the filename prefix).
/// - `sub_id` — the sub-category identifier (4 hex digits). Its high byte
///   selects the expansion folder.
/// - `file_id` — the dat file index, appended as a decimal suffix after
///   `.dat` with no separator (e.g. `dat0`, `dat2`).
#[must_use]
pub(crate) fn dat_path(ctx: &ApplyContext, main_id: u16, sub_id: u16, file_id: u32) -> PathBuf {
    ctx.game_path
        .join("sqpack")
        .join(expansion_folder(sub_id))
        .join(format!(
            "{:02x}{:04x}.{}.dat{}",
            main_id,
            sub_id,
            platform_str(ctx.platform),
            file_id
        ))
}

/// Resolve the path of a `SqPack` `.index` file.
///
/// Produces:
/// `<game_root>/sqpack/<expansion>/<main_id:02x><sub_id:04x>.<platform>.index[<file_id>]`
///
/// `file_id=0` appends no numeric suffix (primary index); `file_id > 0`
/// appends the decimal value directly, yielding `.index1`, `.index2`, etc.
///
/// # Arguments
///
/// - `ctx` — provides the install root and current platform.
/// - `main_id` — the category/repository identifier.
/// - `sub_id` — the sub-category identifier; high byte selects the expansion.
/// - `file_id` — `0` for the primary index, `1` or higher for alternate indexes.
#[must_use]
pub(crate) fn index_path(ctx: &ApplyContext, main_id: u16, sub_id: u16, file_id: u32) -> PathBuf {
    let suffix = if file_id == 0 {
        String::new()
    } else {
        file_id.to_string()
    };
    ctx.game_path
        .join("sqpack")
        .join(expansion_folder(sub_id))
        .join(format!(
            "{:02x}{:04x}.{}.index{}",
            main_id,
            sub_id,
            platform_str(ctx.platform),
            suffix
        ))
}

/// Resolve a generic (non-`SqPack`) path relative to the game install root.
///
/// Simply joins `ctx.game_path` with `relative_path`. Used by
/// [`crate::chunk::sqpk::SqpkFile`] operations that target files outside the
/// `sqpack/` subtree (e.g. launcher executables or movie files written by
/// `AddFile`/`DeleteFile`).
///
/// No path components are validated or normalised; the caller is responsible
/// for ensuring `relative_path` does not escape the install root.
#[must_use]
pub(crate) fn generic_path(ctx: &ApplyContext, relative_path: &str) -> PathBuf {
    ctx.game_path.join(relative_path)
}

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

    fn ctx(game: &str) -> ApplyContext {
        ApplyContext::new(game)
    }

    #[test]
    fn dat_expansion_0() {
        let result = dat_path(&ctx("/game"), 0x00, 0x0000, 0);
        assert_eq!(
            result,
            PathBuf::from("/game/sqpack/ffxiv/000000.win32.dat0")
        );
    }

    #[test]
    fn dat_expansion_1() {
        let result = dat_path(&ctx("/game"), 0x04, 0x0100, 2);
        assert_eq!(result, PathBuf::from("/game/sqpack/ex1/040100.win32.dat2"));
    }

    #[test]
    fn index_expansion_0_file_id_0() {
        let result = index_path(&ctx("/game"), 0x00, 0x0000, 0);
        assert_eq!(
            result,
            PathBuf::from("/game/sqpack/ffxiv/000000.win32.index")
        );
    }

    #[test]
    fn index_expansion_0_file_id_1() {
        let result = index_path(&ctx("/game"), 0x00, 0x0000, 1);
        assert_eq!(
            result,
            PathBuf::from("/game/sqpack/ffxiv/000000.win32.index1")
        );
    }

    #[test]
    fn index_expansion_1_file_id_0() {
        let result = index_path(&ctx("/game"), 0x04, 0x0100, 0);
        assert_eq!(result, PathBuf::from("/game/sqpack/ex1/040100.win32.index"));
    }

    #[test]
    fn index_expansion_1_file_id_1() {
        let result = index_path(&ctx("/game"), 0x04, 0x0100, 1);
        assert_eq!(
            result,
            PathBuf::from("/game/sqpack/ex1/040100.win32.index1")
        );
    }

    #[test]
    fn dat_ps3_platform() {
        let mut ctx = ApplyContext::new("/game");
        ctx.platform = Platform::Ps3;
        assert_eq!(
            dat_path(&ctx, 0x00, 0x0000, 0),
            PathBuf::from("/game/sqpack/ffxiv/000000.ps3.dat0")
        );
    }

    #[test]
    fn dat_ps4_platform() {
        let mut ctx = ApplyContext::new("/game");
        ctx.platform = Platform::Ps4;
        assert_eq!(
            dat_path(&ctx, 0x00, 0x0000, 0),
            PathBuf::from("/game/sqpack/ffxiv/000000.ps4.dat0")
        );
    }

    #[test]
    fn dat_expansion_2() {
        // sub_id >> 8 == 2 → "ex2"
        let result = dat_path(&ctx("/game"), 0x08, 0x0200, 0);
        assert_eq!(result, PathBuf::from("/game/sqpack/ex2/080200.win32.dat0"));
    }
}