neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
//! Parser for classic Doom demo (`LMP`) lumps.
//!
//! Doom stores recorded play sessions as `DEMO1` / `DEMO2` / `DEMO3`
//! lumps inside the IWAD (plus any `.lmp` file saved by the player).
//! Each lump contains a fixed 13-byte header followed by a stream of
//! 4-byte tic-commands (one per participating player per tic), ending
//! in a single `0x80` terminator.
//!
//! This parser targets the Doom 1.9 single-player format (version byte
//! `0x6d`, 4-byte ticcmds). Later variants (Final Doom 1.10+, Heretic
//! / Hexen, demo packs with 5-byte ticcmds) are rejected with
//! `DemoError::UnsupportedVersion`.
//!
//! ```no_run
//! use neurodoom::{ClassicEngine, PeerId};
//! use neurodoom::demo::Demo;
//!
//! # fn demo() -> Result<(), Box<dyn core::error::Error>> {
//! let wad_bytes = std::fs::read("doom1.wad")?;
//! let wad = neurodoom::wad::Wad::parse(&wad_bytes)?;
//! let idx = wad.find_lump("DEMO1").ok_or("no DEMO1")?;
//! let demo = Demo::parse(wad.lump_data(idx))?;
//!
//! let map = format!("E{}M{}", demo.episode, demo.map);
//! let mut engine = ClassicEngine::new(&wad_bytes, &map)?;
//!
//! for &action in demo.actions() {
//!     engine.tick_single(PeerId(0), action);
//! }
//! # Ok(())
//! # }
//! ```

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

use crate::rules::PlayerAction;
use crate::types::{Button, Buttons};

/// Version byte for Doom 1.9 demos (the shareware / registered IWADs).
const DOOM_1_9: u8 = 109;

/// Single byte that marks end-of-tic-stream.
const DEMO_TERMINATOR: u8 = 0x80;

/// Doom tic-command button bits.
const BT_ATTACK: u8 = 1 << 0;
const BT_USE: u8 = 1 << 1;
const BT_CHANGE: u8 = 1 << 2;
const BT_WEAPONMASK: u8 = 0b0011_1000; // bits 3-5
const BT_WEAPONSHIFT: u32 = 3;

/// A parsed Doom demo lump: header metadata plus a decoded tic-command
/// stream in the engine's `PlayerAction` representation.
#[derive(Clone, Debug)]
pub struct Demo {
    pub skill: u8,
    pub episode: u8,
    pub map: u8,
    pub deathmatch: bool,
    pub respawn: bool,
    pub fast: bool,
    pub no_monsters: bool,
    pub console_player: u8,
    pub players_in_game: [bool; 4],
    /// One entry per tic the demo recorded (for the console player).
    actions: Vec<PlayerAction>,
}

impl Demo {
    /// Parse a raw LMP byte blob (header + ticcmds + terminator).
    pub fn parse(bytes: &[u8]) -> Result<Self, DemoError> {
        // Header: 13 bytes. Destructure via fixed-size array ref so the
        // compiler proves every field access is in-bounds.
        let Some(header) = bytes.first_chunk::<13>() else {
            return Err(DemoError::TooShort);
        };
        let [version, skill, episode, map, dm, resp, fast, nomonst,
             console_player, p1, p2, p3, p4] = *header;
        if version != DOOM_1_9 {
            return Err(DemoError::UnsupportedVersion(version));
        }
        let deathmatch = dm != 0;
        let respawn = resp != 0;
        let fast = fast != 0;
        let no_monsters = nomonst != 0;
        let players_in_game = [p1 != 0, p2 != 0, p3 != 0, p4 != 0];
        let num_players = players_in_game.iter().filter(|p| **p).count();
        if num_players == 0 {
            return Err(DemoError::NoPlayers);
        }
        if let Some(&present) = players_in_game.get(console_player as usize) {
            if !present {
                return Err(DemoError::ConsolePlayerAbsent);
            }
        } else {
            return Err(DemoError::ConsolePlayerAbsent);
        }

        // Body: 4 bytes per player per tic, terminated by 0x80.
        // Header consumed 13 bytes; remainder is the tic stream.
        let body = bytes.get(13..).unwrap_or_default();
        let stride = 4 * num_players;
        let console_offset = 4 * console_player as usize;
        let mut actions = Vec::new();
        let mut i = 0;
        loop {
            let Some(&first) = body.get(i) else {
                break; // no terminator — tolerate, treat as end of stream
            };
            if first == DEMO_TERMINATOR {
                break;
            }
            let tic_start = i + console_offset;
            let Some(t) = body.get(tic_start..tic_start + 4) else {
                return Err(DemoError::TruncatedTicStream);
            };
            actions.push(decode_ticcmd(t));
            i += stride;
        }

        Ok(Self {
            skill,
            episode,
            map,
            deathmatch,
            respawn,
            fast,
            no_monsters,
            console_player,
            players_in_game,
            actions,
        })
    }

    /// Decoded action stream for the console player, one entry per tic.
    #[inline]
    pub fn actions(&self) -> &[PlayerAction] {
        &self.actions
    }

    /// Number of recorded tics (one tic = ~1/35 sec).
    #[inline]
    pub fn len(&self) -> usize {
        self.actions.len()
    }

    #[inline]
    pub fn is_empty(&self) -> bool {
        self.actions.is_empty()
    }
}

fn decode_ticcmd(bytes: &[u8]) -> PlayerAction {
    // Caller guarantees len >= 4. Destructure for bounds-safety.
    let &[fwd, side, angle, raw_buttons, ..] = bytes else {
        return PlayerAction::default();
    };
    let forward = fwd as i8;
    let side = side as i8;
    let raw_angle = angle as i8;

    let mut buttons = Buttons::empty();
    if raw_buttons & BT_ATTACK != 0 {
        buttons |= Button::Attack;
    }
    if raw_buttons & BT_USE != 0 {
        buttons |= Button::Use;
    }
    let weapon_select = if raw_buttons & BT_CHANGE != 0 {
        (((raw_buttons & BT_WEAPONMASK) >> BT_WEAPONSHIFT) + 1).min(8)
    } else {
        0
    };

    PlayerAction {
        forward_move: forward,
        side_move: side,
        // Doom stores a high-byte delta; shift back to a full i16 BAM delta.
        angle_turn: (raw_angle as i16) << 8,
        buttons,
        weapon_select,
    }
}

#[derive(Debug)]
pub enum DemoError {
    TooShort,
    UnsupportedVersion(u8),
    NoPlayers,
    ConsolePlayerAbsent,
    TruncatedTicStream,
}

impl fmt::Display for DemoError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::TooShort => write!(f, "demo lump shorter than 13-byte header"),
            Self::UnsupportedVersion(v) => {
                write!(f, "unsupported demo version 0x{v:02x} (expected 0x6d / Doom 1.9)")
            }
            Self::NoPlayers => write!(f, "demo header marks no players in game"),
            Self::ConsolePlayerAbsent => write!(f, "console_player index is not marked in-game"),
            Self::TruncatedTicStream => write!(f, "demo body ended mid-ticcmd (no 0x80 terminator)"),
        }
    }
}

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

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

    fn hdr(episode: u8, map: u8) -> [u8; 13] {
        [
            DOOM_1_9, 2, episode, map, 0, 0, 0, 0, 0, 1, 0, 0, 0,
        ]
    }

    #[test]
    fn parses_empty_stream() {
        let mut bytes = hdr(1, 5).to_vec();
        bytes.push(DEMO_TERMINATOR);
        let d = Demo::parse(&bytes).expect("parse");
        assert_eq!(d.episode, 1);
        assert_eq!(d.map, 5);
        assert!(d.is_empty());
    }

    #[test]
    fn decodes_one_ticcmd() {
        let mut bytes = hdr(1, 1).to_vec();
        // forwardmove=10, sidemove=-5, angleturn=0x10 (<<8 = 0x1000),
        // buttons = attack | use | change-to-weapon-3 (pistol slot+2)
        bytes.extend_from_slice(&[10, (-5i8) as u8, 0x10, 0b0001_0111]);
        bytes.push(DEMO_TERMINATOR);
        let d = Demo::parse(&bytes).expect("parse");
        assert_eq!(d.len(), 1);
        let a = d.actions()[0];
        assert_eq!(a.forward_move, 10);
        assert_eq!(a.side_move, -5);
        assert_eq!(a.angle_turn, 0x1000);
        assert!(a.buttons.contains(Button::Attack));
        assert!(a.buttons.contains(Button::Use));
        assert_eq!(a.weapon_select, 3); // weapon index 2 + 1
    }

    #[test]
    fn rejects_unknown_version() {
        let mut bytes = hdr(1, 1).to_vec();
        bytes[0] = 0x6e;
        bytes.push(DEMO_TERMINATOR);
        assert!(matches!(
            Demo::parse(&bytes),
            Err(DemoError::UnsupportedVersion(0x6e))
        ));
    }

    #[test]
    fn rejects_too_short() {
        assert!(matches!(Demo::parse(&[0; 5]), Err(DemoError::TooShort)));
    }
}