use encoding_rs::WINDOWS_1251; use std::fmt; use std::fs; use std::path::Path; const MIN_SIZE: usize = 0x48; const MAGIC: u32 = 0x0000_F0F1; pub type Result = core::result::Result; #[derive(Debug)] pub enum Error { Io(std::io::Error), TooSmall { got: usize }, InvalidMagic { got: u32 }, MissingArchiveName, MissingModelKey, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(err) => write!(f, "{err}"), Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"), Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"), Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"), Self::MissingModelKey => write!(f, "unit .dat has empty model key"), } } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(err) => Some(err), _ => None, } } } impl From for Error { fn from(value: std::io::Error) -> Self { Self::Io(value) } } #[derive(Clone, Debug)] pub struct UnitDat { pub magic: u32, pub flags: u32, pub archive_name: String, pub model_key: String, } pub fn parse_path(path: impl AsRef) -> Result { let bytes = fs::read(path.as_ref())?; parse_bytes(&bytes) } pub fn parse_bytes(bytes: &[u8]) -> Result { if bytes.len() < MIN_SIZE { return Err(Error::TooSmall { got: bytes.len() }); } let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?; if magic != MAGIC { return Err(Error::InvalidMagic { got: magic }); } let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?; let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]); if archive_name.is_empty() { return Err(Error::MissingArchiveName); } let model_key = decode_c_string_fixed(&bytes[0x28..0x48]); if model_key.is_empty() { return Err(Error::MissingModelKey); } Ok(UnitDat { magic, flags, archive_name, model_key, }) } fn read_u32(bytes: &[u8], offset: usize) -> Option { let end = offset.checked_add(4)?; let chunk = bytes.get(offset..end)?; Some(u32::from_le_bytes(chunk.try_into().ok()?)) } fn decode_c_string_fixed(bytes: &[u8]) -> String { let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len()); let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]); decoded.trim().to_string() } #[cfg(test)] mod tests { use super::*; use common::collect_files_recursive; use std::path::{Path, PathBuf}; fn game_root() -> Option { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("testdata") .join("Parkan - Iron Strategy"); root.is_dir().then_some(root) } #[test] fn parses_known_dat_files() { let Some(root) = game_root() else { eprintln!("skipping: game root missing"); return; }; let samples = [ root.join("UNITS/UNITS/HERO/tut1_p.dat"), root.join("UNITS/UNITS/BATTLE/l_targ.dat"), root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"), ]; for path in samples { if !path.is_file() { eprintln!("skipping missing sample {}", path.display()); continue; } let dat = parse_path(&path) .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); assert_eq!(dat.magic, MAGIC); assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb")); assert!(dat.model_key.contains('_')); } } #[test] fn parses_retail_dat_corpus() { let Some(root) = game_root() else { eprintln!("skipping: game root missing"); return; }; let units_root = root.join("UNITS"); let mut files = Vec::new(); collect_files_recursive(&units_root, &mut files); files.sort(); let mut parsed = 0usize; for path in files { if !path .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")) { continue; } let dat = parse_path(&path) .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); assert!( !dat.archive_name.is_empty(), "{} empty archive", path.display() ); assert!( !dat.model_key.is_empty(), "{} empty model key", path.display() ); parsed += 1; } assert!(parsed > 0, "no .dat files parsed"); } }