zipatch-rs 1.1.0

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`] → [`crate::ZiPatchError::UnsupportedPlatform`]
//!   (a silent fallback would risk writing platform-specific data to the wrong
//!   file — see [`platform_str`] for the full rationale)
//!
//! ## `.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, Result, ZiPatchError};
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.
///
/// The parsing layer tolerates unrecognised `platform_id` values in
/// [`SqpkTargetInfo`](crate::chunk::SqpkTargetInfo) by storing them as
/// [`Platform::Unknown`] rather than failing, so the iterator can still yield
/// non-SqPack chunks (e.g. `ADIR`, `DELD`, generic `SqpkFile` ops) from a
/// patch authored for a future platform. Path resolution, however, refuses to
/// guess: silently substituting the `win32` layout for an unknown platform
/// would write platform-specific data to the wrong `.dat`/`.index` file and
/// corrupt the on-disk install. This function therefore returns
/// [`ZiPatchError::UnsupportedPlatform`] carrying the raw `platform_id` so
/// callers can surface it to users.
///
/// # Errors
///
/// Returns [`ZiPatchError::UnsupportedPlatform`] when `platform` is
/// [`Platform::Unknown`].
fn platform_str(platform: Platform) -> Result<&'static str> {
    match platform {
        Platform::Win32 => Ok("win32"),
        Platform::Ps3 => Ok("ps3"),
        Platform::Ps4 => Ok("ps4"),
        Platform::Unknown(id) => Err(ZiPatchError::UnsupportedPlatform(id)),
    }
}

/// 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`).
///
/// # Errors
///
/// Returns [`ZiPatchError::UnsupportedPlatform`] when [`ApplyContext::platform`]
/// is [`Platform::Unknown`]. The parser preserves unrecognised `platform_id`
/// values rather than failing the iterator, so this is the first point at
/// which an unknown platform aborts an apply that would otherwise misroute a
/// `.dat` write to the wrong on-disk layout.
pub(crate) fn dat_path(
    ctx: &ApplyContext,
    main_id: u16,
    sub_id: u16,
    file_id: u32,
) -> Result<PathBuf> {
    let platform = platform_str(ctx.platform)?;
    Ok(ctx
        .game_path
        .join("sqpack")
        .join(expansion_folder(sub_id))
        .join(format!("{main_id:02x}{sub_id:04x}.{platform}.dat{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.
///
/// # Errors
///
/// Returns [`ZiPatchError::UnsupportedPlatform`] when [`ApplyContext::platform`]
/// is [`Platform::Unknown`]. The parser preserves unrecognised `platform_id`
/// values rather than failing the iterator, so this is the first point at
/// which an unknown platform aborts an apply that would otherwise misroute an
/// `.index` write to the wrong on-disk layout.
pub(crate) fn index_path(
    ctx: &ApplyContext,
    main_id: u16,
    sub_id: u16,
    file_id: u32,
) -> Result<PathBuf> {
    let platform = platform_str(ctx.platform)?;
    let suffix = if file_id == 0 {
        String::new()
    } else {
        file_id.to_string()
    };
    Ok(ctx
        .game_path
        .join("sqpack")
        .join(expansion_folder(sub_id))
        .join(format!(
            "{main_id:02x}{sub_id:04x}.{platform}.index{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).unwrap();
        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).unwrap();
        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).unwrap();
        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).unwrap();
        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).unwrap();
        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).unwrap();
        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).unwrap(),
            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).unwrap(),
            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).unwrap();
        assert_eq!(result, PathBuf::from("/game/sqpack/ex2/080200.win32.dat0"));
    }

    // --- Unknown-platform refusal ---

    #[test]
    fn dat_path_returns_unsupported_platform_for_unknown() {
        // Confirms the fix: an unknown platform_id no longer silently routes
        // to a win32 path. The error carries the raw u16 so callers can
        // surface it to users.
        let mut c = ApplyContext::new("/game");
        c.platform = Platform::Unknown(99);
        let err = dat_path(&c, 0x00, 0x0000, 0)
            .expect_err("unknown platform must abort dat_path resolution");
        match err {
            ZiPatchError::UnsupportedPlatform(id) => assert_eq!(
                id, 99,
                "error must carry the raw platform_id for diagnostics"
            ),
            other => panic!("expected UnsupportedPlatform(99), got {other:?}"),
        }
    }

    #[test]
    fn index_path_returns_unsupported_platform_for_unknown() {
        let mut c = ApplyContext::new("/game");
        c.platform = Platform::Unknown(7);
        let err = index_path(&c, 0x00, 0x0000, 1)
            .expect_err("unknown platform must abort index_path resolution");
        match err {
            ZiPatchError::UnsupportedPlatform(id) => assert_eq!(id, 7),
            other => panic!("expected UnsupportedPlatform(7), got {other:?}"),
        }
    }
}