diff options
Diffstat (limited to 'crates/unitdat/src/lib.rs')
| -rw-r--r-- | crates/unitdat/src/lib.rs | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/crates/unitdat/src/lib.rs b/crates/unitdat/src/lib.rs new file mode 100644 index 0000000..6414e66 --- /dev/null +++ b/crates/unitdat/src/lib.rs @@ -0,0 +1,180 @@ +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<T> = core::result::Result<T, Error>; + +#[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<std::io::Error> 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<Path>) -> Result<UnitDat> { + let bytes = fs::read(path.as_ref())?; + parse_bytes(&bytes) +} + +pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> { + 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<u32> { + 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<PathBuf> { + 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"); + } +} |
