oxipkx 1.0.0

Zero-dependency parser for id Tech 3/4 PK3/PK4 files (Quake III, Doom 3).
Documentation
//! Doom 3 (id Tech 4) PK4 extras: the pak checksum, the `addon.conf` /
//! `binary.conf` special-entry flags, and the Doom 3 BFG `.resources`
//! container (a separate big-endian flat format, not a ZIP).

use crate::md4::block_checksum;
use crate::Archive;

impl Archive {
    /// pak **checksum**: block checksum over the CRC-32 of every nonzero entry
    /// (in central-directory order), stored little-endian. identifies the pak
    /// for pure-server validation, download matching, and add-on dependency
    /// resolution. equivalent to the Quake III content checksum.
    pub fn pak_checksum(&self) -> u32 {
        let feed: Vec<u8> =
            self.content_crc_feed().iter().flat_map(|crc| crc.to_le_bytes()).collect();
        block_checksum(&feed)
    }

    /// true if the pak carries an `addon.conf`, which marks it as an add-on
    /// that is not searched unless explicitly activated or pulled in as a
    /// dependency.
    pub fn is_addon(&self) -> bool {
        self.contains("addon.conf")
    }

    /// true if the pak carries a `binary.conf`, which marks it as containing a
    /// platform-native game binary.
    pub fn is_binary_pak(&self) -> bool {
        self.contains("binary.conf")
    }
}

/// magic at the start of a Doom 3 BFG `.resources` container.
const RESOURCES_MAGIC: u32 = 0xd000_000d;

/// one file recorded in a `.resources` container's table.
#[derive(Debug, Clone)]
pub struct ResourceEntry {
    /// normalized virtual path (lowercase, forward slashes)
    pub name: String,
    /// absolute offset of the blob within the container file
    pub offset: u32,
    /// blob length in bytes
    pub length: u32,
}

/// errors from parsing a `.resources` container.
#[derive(Debug)]
pub enum ResourcesError {
    /// file was too small to hold the header or table
    TooShort,
    /// header magic did not match `0xD000000D`
    BadMagic(u32),
    /// the table offset/length pointed outside the file
    BadTable,
}

impl std::fmt::Display for ResourcesError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ResourcesError::TooShort => write!(formatter, "resources file too short"),
            ResourcesError::BadMagic(magic) => write!(formatter, "bad resources magic {magic:#010x}"),
            ResourcesError::BadTable => write!(formatter, "resources table out of range"),
        }
    }
}

impl std::error::Error for ResourcesError {}

/// read a big-endian u32 at `offset` (the container's `ReadBig` convention).
fn read_be_u32(data: &[u8], offset: usize) -> Option<u32> {
    data.get(offset..offset + 4).map(|slice| u32::from_be_bytes(slice.try_into().unwrap()))
}

/// a parsed Doom 3 BFG `.resources` container: raw bytes plus its file table.
/// this is a flat, big-endian, uncompressed container, unrelated to the ZIP
/// layout of PK4 files. blobs are served by absolute seek+read.
pub struct ResourcesContainer {
    data: Vec<u8>,
    entries: Vec<ResourceEntry>,
}

impl ResourcesContainer {
    /// parse a `.resources` container from its raw bytes.
    pub fn from_bytes(data: Vec<u8>) -> Result<Self, ResourcesError> {
        if data.len() < 12 {
            return Err(ResourcesError::TooShort);
        }
        let magic = read_be_u32(&data, 0).unwrap();
        if magic != RESOURCES_MAGIC {
            return Err(ResourcesError::BadMagic(magic));
        }
        let table_offset = read_be_u32(&data, 4).ok_or(ResourcesError::BadTable)? as usize;
        let table_length = read_be_u32(&data, 8).ok_or(ResourcesError::BadTable)? as usize;
        if table_offset + table_length > data.len() {
            return Err(ResourcesError::BadTable);
        }

        let mut cursor = table_offset;
        let count = read_be_u32(&data, cursor).ok_or(ResourcesError::BadTable)? as usize;
        cursor += 4;

        let mut entries = Vec::with_capacity(count);
        for _ in 0..count {
            // length-prefixed name, then absolute offset and length
            let name_length = read_be_u32(&data, cursor).ok_or(ResourcesError::BadTable)? as usize;
            cursor += 4;
            let name_bytes =
                data.get(cursor..cursor + name_length).ok_or(ResourcesError::BadTable)?;
            let name = crate::normalize_name(&name_bytes.iter().map(|&b| b as char).collect::<String>());
            cursor += name_length;
            let offset = read_be_u32(&data, cursor).ok_or(ResourcesError::BadTable)?;
            cursor += 4;
            let length = read_be_u32(&data, cursor).ok_or(ResourcesError::BadTable)?;
            cursor += 4;
            entries.push(ResourceEntry { name, offset, length });
        }

        Ok(Self { data, entries })
    }

    /// number of files in the container.
    pub fn entry_count(&self) -> usize {
        self.entries.len()
    }

    /// the table entry at `index`.
    pub fn entry(&self, index: usize) -> Option<&ResourceEntry> {
        self.entries.get(index)
    }

    /// iterate the file table.
    pub fn entries(&self) -> impl Iterator<Item = &ResourceEntry> {
        self.entries.iter()
    }

    /// find a file index by path, case-insensitively and slash-insensitively.
    pub fn find(&self, name: &str) -> Option<usize> {
        let query = crate::normalize_name(name);
        self.entries.iter().position(|entry| entry.name == query)
    }

    /// raw bytes of the blob at `index` (no decompression: the container does
    /// not compress; any compression is applied per-asset upstream).
    pub fn read(&self, index: usize) -> Option<&[u8]> {
        let entry = self.entries.get(index)?;
        let start = entry.offset as usize;
        self.data.get(start..start + entry.length as usize)
    }

    /// raw bytes of the blob named `name`.
    pub fn read_by_name(&self, name: &str) -> Option<&[u8]> {
        self.read(self.find(name)?)
    }
}