aboutsummaryrefslogtreecommitdiff
path: root/crates/unitdat/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 15:07:01 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 15:07:01 +0300
commit4ef08d0bf6366b0bc8ccb6357b794937411f74cc (patch)
treed733ac655029b10e466c450be25c63e156f40773 /crates/unitdat/src
parent598137ed132d95a3e3bf9b95e9e27286cc2186ac (diff)
downloadfparkan-4ef08d0bf6366b0bc8ccb6357b794937411f74cc.tar.xz
fparkan-4ef08d0bf6366b0bc8ccb6357b794937411f74cc.zip
feat: add terrain-core, tma, and unitdat crates with parsing functionality
- Introduced `terrain-core` crate for loading and processing terrain mesh data. - Added `tma` crate for parsing mission files, including footer and object records. - Created `unitdat` crate for reading unit data files with validation of structure. - Implemented error handling and tests for all new crates. - Documented object registry format and rendering pipeline in specifications.
Diffstat (limited to 'crates/unitdat/src')
-rw-r--r--crates/unitdat/src/lib.rs180
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");
+ }
+}