darkomen 0.5.0

Warhammer: Dark Omen library and CLI in Rust
Documentation
mod decoder;
mod encoder;

#[cfg(feature = "bevy_reflect")]
use bevy_reflect::prelude::*;
use bitflags::bitflags;
use glam::{I8Vec3, U8Vec2};
use serde::{Deserialize, Serialize};

pub use decoder::{DecodeError, Decoder};
pub use encoder::{EncodeError, Encoder};

bitflags! {
    #[repr(transparent)]
    #[derive(Clone, Copy, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
    #[cfg_attr(feature = "debug", derive(Debug))]
    #[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(opaque), reflect(Default, Deserialize, Hash, PartialEq, Serialize))]
    #[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
    pub struct HeadFlags: u8 {
        const NONE = 0;
        /// The character has no mouth model, e.g., because they wear a helmet
        /// (i.e., no "BITS" texture).
        const NO_MOUTH = 1 << 0;
        /// Hide accessory slot 0 in meet (non-battle) scenes. Slots are
        /// 0-indexed.
        const HIDE_ACCESSORY_0_IN_MEET = 1 << 1;
        /// Hide accessory slot 1 in meet (non-battle) scenes. Slots are
        /// 0-indexed.
        const HIDE_ACCESSORY_1_IN_MEET = 1 << 2;
        /// Hide head accessory in meet (non-battle) scenes.
        const HIDE_HEAD_ACCESSORY_IN_MEET = 1 << 3;
        /// The character does not have an injured variant (i.e., no "HEADI" or
        /// "BITSI" textures).
        const NO_INJURED_VARIANT = 1 << 4;
        /// The character does not have a death variant (i.e., no "DEATH"
        /// texture).
        const NO_DEATH_VARIANT = 1 << 5;
        const UNKNOWN_HEAD_FLAG_6 = 1 << 6;
        const UNKNOWN_HEAD_FLAG_7 = 1 << 7;
    }
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
    feature = "bevy_reflect",
    derive(Reflect),
    reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct HeadsDatabase {
    pub entries: Vec<HeadEntry>,
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
    feature = "bevy_reflect",
    derive(Reflect),
    reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct HeadEntry {
    /// 2-character ASCII identifier for the head (e.g., "KZ", "MB", "GS"). Used
    /// to load textures like `{name}_HEAD.BMP`, `{name}_BODY.BMP`.
    pub name: String,
    /// Flags.
    pub flags: HeadFlags,
    /// Animation sequences ID for battles. References which .SEQ file to use
    /// (0-63 maps to `{id}.SEQ`).
    pub battle_sequences_id: u8,
    /// Animation sequences ID for meets. References which .SEQ file to use
    /// (0-63 maps to `{id}.SEQ`).
    pub meet_sequences_id: u8,
    pub mouth: Option<Mouth>,
    pub eyes: Option<Eyes>,
    /// Body model.
    body: ModelSlot,
    /// Head model.
    head: ModelSlot,
    /// Animation keyframes ID for battles. References which .KEY file to use
    /// (0-63 maps to `{id}.KEY`).
    pub battle_keyframes_id: u8,
    /// Animation keyframes ID for meets. References which .KEY file to use
    /// (0-63 maps to `{id}.KEY`).
    pub meet_keyframes_id: u8,
    /// Neck model.
    neck: ModelSlot,
    /// Equipment/accessory models (e.g., sword, staff, shield). 2 slots
    /// available.
    accessories: [ModelSlot; 2],
    /// Head accessory model (e.g., horns on helmet, laurel wreath on helmet).
    head_accessory: ModelSlot,
}

impl HeadEntry {
    pub fn body(&self) -> Option<ModelSlot> {
        if self.body.model_id == 0 {
            None
        } else {
            Some(self.body.clone())
        }
    }

    pub fn head(&self) -> Option<ModelSlot> {
        if self.head.model_id == 0 {
            None
        } else {
            Some(self.head.clone())
        }
    }

    pub fn head_accessory(&self) -> Option<ModelSlot> {
        if self.head_accessory.model_id == 0 {
            None
        } else {
            Some(self.head_accessory.clone())
        }
    }

    pub fn neck(&self) -> Option<ModelSlot> {
        if self.neck.model_id == 0 {
            None
        } else {
            Some(self.neck.clone())
        }
    }

    pub fn accessories(&self) -> [Option<ModelSlot>; 2] {
        [
            if self.accessories[0].model_id == 0 {
                None
            } else {
                Some(self.accessories[0].clone())
            },
            if self.accessories[1].model_id == 0 {
                None
            } else {
                Some(self.accessories[1].clone())
            },
        ]
    }
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
    feature = "bevy_reflect",
    derive(Reflect),
    reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct ModelSlot {
    /// Model ID (1-63). 0 means no model in this slot.
    pub model_id: u8,
    /// Translation offset [x, y, z] in integer format. Multiply by 0.05 to
    /// get world coordinates.
    pub translation: I8Vec3,
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
    feature = "bevy_reflect",
    derive(Reflect),
    reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Mouth {
    /// The size [width, height] of the mouth. Combine with the position to
    /// determine the mouth rectangle in the head texture.
    pub size: U8Vec2,
    /// The top left position [x, y] to position the mouth in the head texture.
    pub position: U8Vec2,
}

#[derive(Clone, Default, Deserialize, Serialize)]
#[cfg_attr(feature = "debug", derive(Debug))]
#[cfg_attr(
    feature = "bevy_reflect",
    derive(Reflect),
    reflect(Default, Deserialize, Serialize)
)]
#[cfg_attr(all(feature = "bevy_reflect", feature = "debug"), reflect(Debug))]
pub struct Eyes {
    /// The size [width, height] of the eyes. Combine with the position to
    /// determine the eyes rectangle in the head texture.
    pub size: U8Vec2,
    /// The top left position [x, y] to position the eyes in the head texture.
    pub position: U8Vec2,
}

#[cfg(test)]
mod tests {
    use std::{
        ffi::{OsStr, OsString},
        fs::File,
        path::{Path, PathBuf},
    };

    use pretty_assertions::assert_eq;

    use super::*;

    fn roundtrip_test(original_bytes: &[u8], heads: &HeadsDatabase) {
        let mut encoded_bytes = Vec::new();
        Encoder::new(&mut encoded_bytes).encode(heads).unwrap();

        let original_bytes = original_bytes
            .chunks(16)
            .map(|chunk| {
                chunk
                    .iter()
                    .map(|b| format!("{b:02X}"))
                    .collect::<Vec<_>>()
                    .join(" ")
            })
            .collect::<Vec<_>>()
            .join("\n");

        let encoded_bytes = encoded_bytes
            .chunks(16)
            .map(|chunk| {
                chunk
                    .iter()
                    .map(|b| format!("{b:02X}"))
                    .collect::<Vec<_>>()
                    .join(" ")
            })
            .collect::<Vec<_>>()
            .join("\n");

        assert_eq!(original_bytes, encoded_bytes);
    }

    #[test]
    fn test_decode_heads_db() {
        let d: PathBuf = [
            std::env::var("DARKOMEN_PATH").unwrap().as_str(),
            "DARKOMEN",
            "GRAPHICS",
            "PORTRAIT",
            "SCRIPT",
            "HEADS.DB",
        ]
        .iter()
        .collect();

        let original_bytes = std::fs::read(d.clone()).unwrap();
        let file = File::open(d).unwrap();
        let heads = Decoder::new(file).decode().unwrap();

        assert_eq!(heads.entries.len(), 63);
        assert_eq!(heads.entries.first().unwrap().name, "MB");
        assert_eq!(
            heads.entries.first().unwrap().flags,
            HeadFlags::HIDE_ACCESSORY_0_IN_MEET | HeadFlags::HIDE_ACCESSORY_1_IN_MEET
        );
        assert_eq!(heads.entries.first().unwrap().body.model_id, 2);
        assert_eq!(heads.entries.first().unwrap().head.model_id, 13);

        roundtrip_test(&original_bytes, &heads);
    }

    #[test]
    fn test_encode_too_many_entries() {
        let heads = HeadsDatabase {
            entries: vec![HeadEntry::default(); 256],
        };

        let mut encoded_bytes = Vec::new();
        let result = Encoder::new(&mut encoded_bytes).encode(&heads);

        assert!(result.is_err());
        match result {
            Err(EncodeError::TooManyEntries) => (),
            _ => panic!("Expected TooManyEntries error"),
        }
    }

    #[test]
    fn test_decode_all() {
        let d: PathBuf = [
            std::env::var("DARKOMEN_PATH").unwrap().as_str(),
            "DARKOMEN",
            "GRAPHICS",
            "PORTRAIT",
        ]
        .iter()
        .collect();

        let root_output_dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "decoded", "portrait", "heads"]
            .iter()
            .collect();

        std::fs::create_dir_all(&root_output_dir).unwrap();

        fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&Path)) {
            println!("Reading dir {:?}", dir.display());

            let mut paths = std::fs::read_dir(dir)
                .unwrap()
                .map(|res| res.map(|e| e.path()))
                .collect::<Result<Vec<_>, std::io::Error>>()
                .unwrap();

            paths.sort();

            for path in paths {
                if path.is_dir() {
                    visit_dirs(&path, cb);
                } else {
                    cb(&path);
                }
            }
        }

        visit_dirs(&d, &mut |path| {
            let Some(ext) = path.extension() else {
                return;
            };
            if ext.to_string_lossy().to_uppercase() != "DB" {
                return;
            }
            // Just decode HEADS.DB because BACKUP.DB doesn't start with the
            // head count and HEADSBU.DB seems corrupted so we can't decode them
            // properly.
            if path.file_stem().unwrap().to_string_lossy() != "HEADS" {
                return;
            }

            println!("Decoding {:?}", path.file_name().unwrap());

            let original_bytes = std::fs::read(path).unwrap();

            let file = File::open(path).unwrap();
            let heads = Decoder::new(file).decode().unwrap();

            roundtrip_test(&original_bytes, &heads);

            let parent_dir = path
                .components()
                .collect::<Vec<_>>()
                .iter()
                .rev()
                .skip(1) // skip the file name
                .take_while(|c| c.as_os_str() != "DARKOMEN")
                .collect::<Vec<_>>()
                .iter()
                .rev()
                .collect::<PathBuf>();
            let output_dir = root_output_dir.join(parent_dir);
            std::fs::create_dir_all(&output_dir).unwrap();

            // Write the complete database.
            let output_path = append_ext("ron", output_dir.join(path.file_name().unwrap()));
            let mut buffer = String::new();
            ron::ser::to_writer_pretty(&mut buffer, &heads, Default::default()).unwrap();
            std::fs::write(output_path, buffer).unwrap();

            // Write individual head entries.
            let db_name = path.file_stem().unwrap().to_string_lossy();
            let individual_dir = output_dir.join(db_name.as_ref());
            std::fs::create_dir_all(&individual_dir).unwrap();

            for (index, entry) in heads.entries.iter().enumerate() {
                let individual_path =
                    individual_dir.join(format!("{:02}_{}.ron", index, entry.name));
                let mut buffer = String::new();
                ron::ser::to_writer_pretty(&mut buffer, entry, Default::default()).unwrap();
                std::fs::write(individual_path, buffer).unwrap();
            }
        });
    }

    fn append_ext(ext: impl AsRef<OsStr>, path: PathBuf) -> PathBuf {
        let mut os_string: OsString = path.into();
        os_string.push(".");
        os_string.push(ext.as_ref());
        os_string.into()
    }
}