neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
// WAD parser. Runs once at load; indices into `self.data` are
// header-validated before use. See policy in src/render/mod.rs.
#![allow(clippy::indexing_slicing)]

use alloc::vec::Vec;
use core::fmt;

/// Parsed WAD file. Borrows the raw byte data.
pub struct Wad<'a> {
    data: &'a [u8],
    lumps: Vec<LumpInfo>,
}

struct LumpInfo {
    name: [u8; 8],
    offset: usize,
    size: usize,
}

/// WAD file type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WadKind {
    /// Internal WAD (full game data)
    Iwad,
    /// Patch WAD (mod/addon data)
    Pwad,
}

/// Error during WAD parsing.
#[derive(Debug)]
pub enum WadError {
    TooSmall,
    BadMagic,
    DirectoryOutOfBounds,
    LumpOutOfBounds { index: usize },
}

impl fmt::Display for WadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::TooSmall => write!(f, "WAD data too small for header"),
            Self::BadMagic => write!(f, "invalid WAD magic (expected IWAD or PWAD)"),
            Self::DirectoryOutOfBounds => write!(f, "WAD directory offset out of bounds"),
            Self::LumpOutOfBounds { index } => write!(f, "lump {index} data out of bounds"),
        }
    }
}

impl core::error::Error for WadError {}

fn read_i32_le(data: &[u8], offset: usize) -> i32 {
    // Caller guarantees offset+4 <= data.len() (every call site checks
    // upstream when slicing). On under-length slices, return 0 rather
    // than panicking — matches "tolerate corrupt WAD" behavior.
    let Some(slice) = data.get(offset..offset + 4) else { return 0 };
    let mut bytes = [0u8; 4];
    bytes.copy_from_slice(slice);
    i32::from_le_bytes(bytes)
}

/// Normalize a lump name for comparison: uppercase, strip trailing nulls.
fn normalize_name(raw: &[u8; 8]) -> [u8; 8] {
    let mut out = [0u8; 8];
    for (i, &b) in raw.iter().enumerate() {
        if b == 0 {
            break;
        }
        out[i] = b.to_ascii_uppercase();
    }
    out
}

/// Convert a string to a padded 8-byte name for lookup.
fn str_to_name(s: &str) -> [u8; 8] {
    let mut name = [0u8; 8];
    for (i, &b) in s.as_bytes().iter().take(8).enumerate() {
        name[i] = b.to_ascii_uppercase();
    }
    name
}

impl<'a> Wad<'a> {
    /// Parse a WAD from raw bytes.
    pub fn parse(data: &'a [u8]) -> Result<Self, WadError> {
        if data.len() < 12 {
            return Err(WadError::TooSmall);
        }

        // Validate magic
        let magic = &data[0..4];
        if magic != b"IWAD" && magic != b"PWAD" {
            return Err(WadError::BadMagic);
        }

        let num_lumps = read_i32_le(data, 4) as usize;
        let dir_offset = read_i32_le(data, 8) as usize;

        // Validate directory fits
        let dir_end = dir_offset
            .checked_add(num_lumps.checked_mul(16).ok_or(WadError::DirectoryOutOfBounds)?)
            .ok_or(WadError::DirectoryOutOfBounds)?;
        if dir_end > data.len() {
            return Err(WadError::DirectoryOutOfBounds);
        }

        let mut lumps = Vec::with_capacity(num_lumps);
        for i in 0..num_lumps {
            let entry = dir_offset + i * 16;
            let offset = read_i32_le(data, entry) as usize;
            let size = read_i32_le(data, entry + 4) as usize;

            let mut name = [0u8; 8];
            name.copy_from_slice(&data[entry + 8..entry + 16]);

            // Validate lump data fits (zero-size lumps are marker lumps, always valid)
            if size > 0 && offset.checked_add(size).is_none_or(|end| end > data.len()) {
                return Err(WadError::LumpOutOfBounds { index: i });
            }

            lumps.push(LumpInfo { name, offset, size });
        }

        Ok(Self { data, lumps })
    }

    /// WAD kind (IWAD or PWAD).
    pub fn kind(&self) -> WadKind {
        if &self.data[0..4] == b"IWAD" {
            WadKind::Iwad
        } else {
            WadKind::Pwad
        }
    }

    /// Number of lumps in this WAD.
    pub fn num_lumps(&self) -> usize {
        self.lumps.len()
    }

    /// Find a lump by name (case-insensitive). Returns the lump index.
    pub fn find_lump(&self, name: &str) -> Option<usize> {
        let target = str_to_name(name);
        self.lumps
            .iter()
            .position(|l| normalize_name(&l.name) == target)
    }

    /// Find a lump by name, searching only at or after `after` index.
    pub fn find_lump_after(&self, name: &str, after: usize) -> Option<usize> {
        let target = str_to_name(name);
        self.lumps[after..]
            .iter()
            .position(|l| normalize_name(&l.name) == target)
            .map(|i| i + after)
    }

    /// Raw bytes of a lump by index.
    pub fn lump_data(&self, index: usize) -> &'a [u8] {
        let info = &self.lumps[index];
        &self.data[info.offset..info.offset + info.size]
    }

    /// Size of a lump in bytes.
    pub fn lump_size(&self, index: usize) -> usize {
        self.lumps[index].size
    }

    /// Name of a lump (raw 8 bytes, null-padded).
    pub fn lump_name(&self, index: usize) -> &[u8; 8] {
        &self.lumps[index].name
    }

    /// Find a lump by name and return its data, or `None`.
    pub fn lump_by_name(&self, name: &str) -> Option<&'a [u8]> {
        self.find_lump(name).map(|i| self.lump_data(i))
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // tests build known-good inputs
mod tests {
    use super::*;

    #[test]
    fn parse_minimal_iwad() {
        // Minimal valid IWAD with 0 lumps
        let mut data = Vec::new();
        data.extend_from_slice(b"IWAD");
        data.extend_from_slice(&0i32.to_le_bytes()); // numlumps
        data.extend_from_slice(&12i32.to_le_bytes()); // infotableofs (right after header)
        let wad = Wad::parse(&data).unwrap();
        assert_eq!(wad.kind(), WadKind::Iwad);
        assert_eq!(wad.num_lumps(), 0);
    }

    #[test]
    fn parse_single_lump() {
        let mut data = Vec::new();
        // Header
        data.extend_from_slice(b"PWAD");
        data.extend_from_slice(&1i32.to_le_bytes()); // 1 lump
        data.extend_from_slice(&16i32.to_le_bytes()); // dir at offset 16

        // Lump data at offset 12
        data.extend_from_slice(b"TEST");

        // Directory entry at offset 16
        data.extend_from_slice(&12i32.to_le_bytes()); // lump offset
        data.extend_from_slice(&4i32.to_le_bytes()); // lump size
        data.extend_from_slice(b"TESTLUMP"); // lump name

        let wad = Wad::parse(&data).unwrap();
        assert_eq!(wad.kind(), WadKind::Pwad);
        assert_eq!(wad.num_lumps(), 1);
        assert_eq!(wad.find_lump("TESTLUMP"), Some(0));
        assert_eq!(wad.find_lump("testlump"), Some(0)); // case insensitive
        assert_eq!(wad.lump_data(0), b"TEST");
        assert_eq!(wad.lump_size(0), 4);
    }

    #[test]
    fn bad_magic() {
        let data = b"XWAD\x00\x00\x00\x00\x0c\x00\x00\x00";
        assert!(matches!(Wad::parse(data), Err(WadError::BadMagic)));
    }

    #[test]
    fn too_small() {
        assert!(matches!(Wad::parse(b"IWA"), Err(WadError::TooSmall)));
    }
}