neurodoom 0.6.7

Deterministic no_std Doom engine with semantic and depth perception buffers for AI
Documentation
//! Demo replay — plays the shareware Doom DEMO1 / DEMO2 / DEMO3 lumps
//! back through the engine, cycling between them when one ends.
//!
//! Run: cargo run --release --example demo
//!
//! Each demo header specifies which map to load and a recorded stream of
//! tic-commands; we feed those commands to `tick_single` and render each
//! frame. Since the shareware demos are all single-player at skill 2,
//! this is a closed-loop determinism check — if the engine matches
//! Doom's physics, the player will retrace the original session.
//!
//! NOTE: physics / combat / PRNG usage have drifted from id's original
//! here and there, so playback typically desyncs well before the
//! recorded end (commonly: the player walks into something and dies).
//! We detect that (player health <= 0 or natural exit) and move on to
//! the next demo rather than hammering through dead-player input.
//!
//! Controls:
//!   Space / N  — jump to the next demo lump
//!   Tab        — cycle view (normal -> semantic -> depth)
//!   Esc        — quit

use minifb::{Key, Window, WindowOptions};

use neurodoom::demo::Demo;
use neurodoom::engine::ClassicEngine;
use neurodoom::math::{SCREENHEIGHT, SCREENWIDTH};
use neurodoom::render::SemanticClass;
use neurodoom::wad::Wad;
use neurodoom::world::{LevelExit, PeerId};

#[path = "common.rs"]
mod common;

const SCALE: usize = 3;

/// Demo lumps to cycle through, in order.
const DEMO_LUMPS: &[&str] = &["DEMO1", "DEMO2", "DEMO3"];

fn main() {
    let (wad_data, wad_path) = common::load_wad()
        .unwrap_or_else(|e| panic!("failed to read WAD at {wad_path:?}: {e}",
            wad_path = std::env::var_os("DOOM_WAD").unwrap_or_else(|| common::DEFAULT_WAD_PATH.into())));
    eprintln!("Loaded WAD {wad_path:?}");

    let mut window = Window::new(
        "neurodoom demo replay — Space: next, Tab: view, Esc: quit",
        SCREENWIDTH * SCALE,
        SCREENHEIGHT * SCALE,
        WindowOptions::default(),
    )
    .expect("failed to create window");
    window.set_target_fps(35);

    let mut fb = vec![0u32; SCREENWIDTH * SCREENHEIGHT];
    let mut view_mode: u8 = 0;
    let mut lump_idx = 0;

    // Outer loop: each iteration loads one demo lump and plays it.
    'outer: while window.is_open() && !window.is_key_down(Key::Escape) {
        let lump_name = DEMO_LUMPS[lump_idx % DEMO_LUMPS.len()];
        let (demo, mut engine) = match load_demo(&wad_data, lump_name) {
            Ok(pair) => pair,
            Err(e) => {
                eprintln!("skipping {lump_name}: {e}");
                lump_idx += 1;
                continue;
            }
        };
        eprintln!(
            "Playing {lump_name}: E{ep}M{map}, skill {s}, {n} tics ({sec:.1}s)",
            ep = demo.episode,
            map = demo.map,
            s = demo.skill,
            n = demo.len(),
            sec = demo.len() as f32 / 35.0
        );

        let reason = play_demo(&mut engine, &demo, &mut window, &mut fb, &mut view_mode);
        match reason {
            StopReason::Quit => break 'outer,
            StopReason::Skipped => eprintln!("  -> skipped by user"),
            StopReason::PlayerDied { tic } => {
                eprintln!("  -> desynced (player died on tic {tic}/{total})", total = demo.len());
            }
            StopReason::LevelComplete { tic } => {
                eprintln!("  -> reached exit on tic {tic}/{total}", total = demo.len());
            }
            StopReason::Finished => eprintln!("  -> finished"),
        }
        // Wait for Space / N / Tab / Esc to be released before looping so the
        // press event doesn't cascade into the next demo.
        drain_skip_keys(&mut window);
        lump_idx += 1;
    }
}

/// Why `play_demo` returned.
enum StopReason {
    /// Ran out of recorded ticcmds.
    Finished,
    /// Window closed or Esc pressed.
    Quit,
    /// User pressed Space / N.
    Skipped,
    /// Player health reached 0 (our simulation diverged from Doom's).
    PlayerDied { tic: usize },
    /// Player triggered an exit line.
    LevelComplete { tic: usize },
}

/// Play one demo through to completion (or early stop). Keeps the input /
/// render / tick loop focused in one place.
fn play_demo(
    engine: &mut ClassicEngine,
    demo: &Demo,
    window: &mut Window,
    fb: &mut [u32],
    view_mode: &mut u8,
) -> StopReason {
    for (tic, &action) in demo.actions().iter().enumerate() {
        if !window.is_open() || window.is_key_down(Key::Escape) {
            return StopReason::Quit;
        }
        if window.is_key_pressed(Key::Space, minifb::KeyRepeat::No)
            || window.is_key_pressed(Key::N, minifb::KeyRepeat::No)
        {
            return StopReason::Skipped;
        }
        if window.is_key_pressed(Key::Tab, minifb::KeyRepeat::No) {
            *view_mode = (*view_mode + 1) % 3;
        }

        engine.tick_single(PeerId(0), action);
        paint_frame(engine, *view_mode, fb);
        window
            .update_with_buffer(fb, SCREENWIDTH, SCREENHEIGHT)
            .unwrap();

        // Stop playback if the recording has diverged from our physics.
        if engine.level_exit() != LevelExit::None {
            return StopReason::LevelComplete { tic };
        }
        if let Some(eid) = engine.world().controlled_by(PeerId(0))
            && let Some(e) = engine.world().get(eid)
            && e.health <= 0
        {
            return StopReason::PlayerDied { tic };
        }
    }
    StopReason::Finished
}

/// Pump `window.update()` until Space / N / Tab aren't held. Prevents a
/// single key press from being re-observed on the next demo's first
/// iteration (which would cascade-skip through every lump instantly).
fn drain_skip_keys(window: &mut Window) {
    while window.is_open()
        && !window.is_key_down(Key::Escape)
        && (window.is_key_down(Key::Space)
            || window.is_key_down(Key::N)
            || window.is_key_down(Key::Tab))
    {
        window.update();
        std::thread::sleep(std::time::Duration::from_millis(16));
    }
}

fn load_demo(wad_data: &[u8], lump_name: &str) -> Result<(Demo, ClassicEngine), Box<dyn std::error::Error>> {
    let wad = Wad::parse(wad_data)?;
    let lump_idx = wad
        .find_lump(lump_name)
        .ok_or_else(|| format!("lump {lump_name} not found"))?;
    let demo = Demo::parse(wad.lump_data(lump_idx))?;
    let map = format!("E{}M{}", demo.episode, demo.map);
    let engine = ClassicEngine::new(wad_data, &map)?;
    Ok((demo, engine))
}

fn paint_frame(engine: &ClassicEngine, view_mode: u8, fb: &mut [u32]) {
    match view_mode {
        1 => {
            for (i, &class) in engine.semantic_buffer().iter().enumerate() {
                fb[i] = SemanticClass::from_u8(class).to_rgb();
            }
        }
        2 => {
            for (i, &d) in engine.depth_buffer().iter().enumerate() {
                fb[i] = common::depth_to_rgb(d);
            }
        }
        _ => {
            let rgba = engine.framebuffer();
            for (i, pixel) in fb.iter_mut().enumerate() {
                let base = i * 4;
                let r = rgba[base] as u32;
                let g = rgba[base + 1] as u32;
                let b = rgba[base + 2] as u32;
                *pixel = (r << 16) | (g << 8) | b;
            }
        }
    }
}