diff --git a/Cargo.lock b/Cargo.lock index a550474..97a4788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,6 +222,8 @@ name = "darkomen" version = "0.1.1" dependencies = [ "bitflags 2.5.0", + "encoding_rs", + "encoding_rs_io", "glam", "image", "indexmap", @@ -245,6 +247,24 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index e23d278..3f45e9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ license = "MIT OR Apache-2.0" [dependencies] bitflags = { version = "2.3", features = ["serde"] } +encoding_rs = "0.8.34" +encoding_rs_io = "0.1.7" glam = { version = "0.27.0", default-features = false, features = ["serde"] } image = "0.25.1" indexmap = { version = "2.2.6", features = ["serde"] } diff --git a/src/army/decoder.rs b/src/army/decoder.rs new file mode 100644 index 0000000..78514d6 --- /dev/null +++ b/src/army/decoder.rs @@ -0,0 +1,277 @@ +use super::*; +use encoding_rs::WINDOWS_1252; +use encoding_rs_io::DecodeReaderBytesBuilder; +use std::{ + fmt, + io::{Error as IoError, Read, Seek, SeekFrom}, + mem::size_of, +}; + +#[derive(Debug)] +pub enum DecodeError { + IoError(IoError), + InvalidFormat(u32), + InvalidString, +} + +impl std::error::Error for DecodeError {} + +impl From for DecodeError { + fn from(error: IoError) -> Self { + DecodeError::IoError(error) + } +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::IoError(e) => write!(f, "IO error: {}", e), + DecodeError::InvalidFormat(format) => write!(f, "invalid format: {}", format), + DecodeError::InvalidString => write!(f, "invalid string"), + } + } +} + +pub(crate) const FORMAT: u32 = 0x0000029e; +pub(crate) const HEADER_SIZE: usize = 192; +const SAVE_HEADER_SIZE: usize = 504; +pub(crate) const REGIMENT_BLOCK_SIZE: usize = 188; + +pub(crate) struct Header { + _format: u32, + regiment_count: u32, + /// The size of each regiment block in bytes. + /// + /// This is always 188 despite being encoded in the header. + _regiment_block_size: u32, + race: u8, + unknown1: [u8; 3], // purpose of bytes at index 13, 14, 15 is unknown + unknown2: [u8; 34], // purpose of bytes at index 16-50 is unknown + small_banner_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + small_banner_path_remainder: Vec, + small_banner_disabled_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + small_banner_disabled_path_remainder: Vec, + large_banner_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + large_banner_path_remainder: Vec, + gold_from_treasures: u16, + gold_in_coffers: u16, + magic_items: [u8; 40], + unknown3: [u8; 2], // purpose of bytes at index 190 and 191 is unknown +} + +pub struct Decoder +where + R: Read + Seek, +{ + reader: R, +} + +impl Decoder { + pub fn new(reader: R) -> Self { + Decoder { reader } + } + + pub fn decode(&mut self) -> Result { + let start_pos = self.maybe_read_save_file()?; + + let header = self.read_header(start_pos)?; + + let regiments = self.read_regiments(&header)?; + + Ok(Army { + race: header.race, + unknown1: header.unknown1.to_vec(), + unknown2: header.unknown2.to_vec(), + regiments, + small_banner_path: header.small_banner_path, + small_banner_path_remainder: header.small_banner_path_remainder, + small_banner_disabled_path: header.small_banner_disabled_path, + small_banner_disabled_path_remainder: header.small_banner_disabled_path_remainder, + large_banner_path: header.large_banner_path, + large_banner_path_remainder: header.large_banner_path_remainder, + gold_from_treasures: header.gold_from_treasures, + gold_in_coffers: header.gold_in_coffers, + magic_items: header.magic_items.to_vec(), + unknown3: header.unknown3.to_vec(), + }) + } + + fn maybe_read_save_file(&mut self) -> Result { + let mut buf = [0; size_of::()]; + self.reader.read_exact(&mut buf)?; + + let mut start_pos = 0; + + let format = u32::from_le_bytes(buf[0..size_of::()].try_into().unwrap()); + if format != FORMAT { + // TODO: Skipped over reading save header. + start_pos = SAVE_HEADER_SIZE as u64; + } + Ok(start_pos) + } + + fn read_header(&mut self, start_pos: u64) -> Result { + self.reader.seek(SeekFrom::Start(start_pos))?; + + let mut buf = [0; HEADER_SIZE]; + self.reader.read_exact(&mut buf)?; + + let small_banner_path_buf = &buf[50..82]; + let (small_banner_path_buf, small_banner_path_remainder) = small_banner_path_buf + .iter() + .enumerate() + .find(|(_, &b)| b == 0) + .map(|(i, _)| small_banner_path_buf.split_at(i + 1)) + .unwrap_or((small_banner_path_buf, &[])); + + let small_banner_disabled_path_buf = &buf[82..114]; + let (small_banner_disabled_path_buf, small_banner_disabled_path_remainder) = + small_banner_disabled_path_buf + .iter() + .enumerate() + .find(|(_, &b)| b == 0) + .map(|(i, _)| small_banner_disabled_path_buf.split_at(i + 1)) + .unwrap_or((small_banner_disabled_path_buf, &[])); + + let large_banner_path_buf = &buf[114..146]; + let (large_banner_path_buf, large_banner_path_remainder) = large_banner_path_buf + .iter() + .enumerate() + .find(|(_, &b)| b == 0) + .map(|(i, _)| large_banner_path_buf.split_at(i + 1)) + .unwrap_or((large_banner_path_buf, &[])); + + Ok(Header { + _format: u32::from_le_bytes(buf[0..4].try_into().unwrap()), + regiment_count: u32::from_le_bytes(buf[4..8].try_into().unwrap()), + _regiment_block_size: u32::from_le_bytes(buf[8..12].try_into().unwrap()), + race: buf[12], + unknown1: buf[13..16].try_into().unwrap(), + unknown2: buf[16..50].try_into().unwrap(), + small_banner_path: self.read_string(small_banner_path_buf)?, + small_banner_path_remainder: small_banner_path_remainder.to_vec(), + small_banner_disabled_path: self.read_string(small_banner_disabled_path_buf)?, + small_banner_disabled_path_remainder: small_banner_disabled_path_remainder.to_vec(), + large_banner_path: self.read_string(large_banner_path_buf)?, + large_banner_path_remainder: large_banner_path_remainder.to_vec(), + gold_from_treasures: u16::from_le_bytes(buf[146..148].try_into().unwrap()), + gold_in_coffers: u16::from_le_bytes(buf[148..150].try_into().unwrap()), + magic_items: buf[150..190].try_into().unwrap(), + unknown3: buf[190..192].try_into().unwrap(), + }) + } + + fn read_regiments(&mut self, header: &Header) -> Result, DecodeError> { + let mut regiments = Vec::with_capacity(header.regiment_count as usize); + + for _ in 0..header.regiment_count { + regiments.push(self.read_regiment()?); + } + + Ok(regiments) + } + + fn read_regiment(&mut self) -> Result { + let mut buf = vec![0; REGIMENT_BLOCK_SIZE]; + self.reader.read_exact(&mut buf)?; + + Ok(Regiment { + status: buf[0..2].try_into().unwrap(), + unknown1: buf[2..4].try_into().unwrap(), + id: u16::from_le_bytes(buf[4..6].try_into().unwrap()), + unknown2: buf[6..8].try_into().unwrap(), + wizard_type: buf[8], + max_armor: buf[9], + cost: u16::from_le_bytes(buf[10..12].try_into().unwrap()), + banner_index: u16::from_le_bytes(buf[12..14].try_into().unwrap()), + unknown3: buf[14..16].try_into().unwrap(), + regiment_attributes: buf[16..20].try_into().unwrap(), + sprite_index: u16::from_le_bytes(buf[20..22].try_into().unwrap()), + name: self.read_string(&buf[22..54])?, + name_id: u16::from_le_bytes(buf[54..56].try_into().unwrap()), + alignment: buf[56], + max_troops: buf[57], + alive_troops: buf[58], + ranks: buf[59], + unknown4: buf[60..64].try_into().unwrap(), + troop_attributes: TroopAttributes { + movement: buf[64], + weapon_skill: buf[65], + ballistic_skill: buf[66], + strength: buf[67], + toughness: buf[68], + wounds: buf[69], + initiative: buf[70], + attacks: buf[71], + leadership: buf[72], + }, + mount: buf[73], + armor: buf[74], + weapon: buf[75], + typ: buf[76], + point_value: buf[77], + missile_weapon: buf[78], + unknown5: buf[79], + unknown6: buf[80..84].try_into().unwrap(), + leader: Leader { + sprite_index: u16::from_le_bytes(buf[84..86].try_into().unwrap()), + name: self.read_string(&buf[86..118])?, + name_remainder: buf[118..127].to_vec(), + attributes: TroopAttributes { + movement: buf[127], + weapon_skill: buf[128], + ballistic_skill: buf[129], + strength: buf[130], + toughness: buf[131], + wounds: buf[132], + initiative: buf[133], + attacks: buf[134], + leadership: buf[135], + }, + mount: buf[136], + armor: buf[137], + weapon: buf[138], + unit_type: buf[139], + point_value: buf[140], + missile_weapon: buf[141], + unknown1: buf[142..146].try_into().unwrap(), + head_id: u16::from_le_bytes(buf[146..148].try_into().unwrap()), + x: buf[148..152].try_into().unwrap(), + y: buf[152..156].try_into().unwrap(), + }, + experience: u16::from_le_bytes(buf[156..158].try_into().unwrap()), + duplicate_id: buf[158], + min_armor: buf[159], + magic_book: u16::from_le_bytes(buf[160..162].try_into().unwrap()), + magic_items: [ + u16::from_le_bytes(buf[162..164].try_into().unwrap()), + u16::from_le_bytes(buf[164..166].try_into().unwrap()), + u16::from_le_bytes(buf[166..168].try_into().unwrap()), + ], + unknown7: buf[168..180].try_into().unwrap(), + purchased_armor: buf[180], + max_purchasable_armor: buf[181], + repurchased_troops: buf[182], + max_purchasable_troops: buf[183], + book_profile: buf[184..188].try_into().unwrap(), + }) + } + + fn read_string(&mut self, buf: &[u8]) -> Result { + let nul_pos = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let mut decoder = DecodeReaderBytesBuilder::new() + .encoding(Some(WINDOWS_1252)) + .build(&buf[..nul_pos]); + let mut dest = String::new(); + + decoder.read_to_string(&mut dest)?; + + Ok(dest) + } +} diff --git a/src/army/encoder.rs b/src/army/encoder.rs new file mode 100644 index 0000000..3e30dfe --- /dev/null +++ b/src/army/encoder.rs @@ -0,0 +1,196 @@ +use super::*; +use decoder::{FORMAT, REGIMENT_BLOCK_SIZE}; +use encoding_rs::WINDOWS_1252; +use std::{ + ffi::CString, + io::{BufWriter, Write}, +}; + +#[derive(Debug)] +pub enum EncodeError { + IoError(std::io::Error), + InvalidString, + StringTooLong, +} + +impl std::error::Error for EncodeError {} + +impl From for EncodeError { + fn from(err: std::io::Error) -> Self { + EncodeError::IoError(err) + } +} + +impl std::fmt::Display for EncodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EncodeError::IoError(e) => write!(f, "IO error: {}", e), + EncodeError::InvalidString => write!(f, "invalid string"), + EncodeError::StringTooLong => write!(f, "string too long"), + } + } +} + +#[derive(Debug)] +pub struct Encoder { + writer: BufWriter, +} + +impl Encoder { + pub fn new(writer: W) -> Self { + Encoder { + writer: BufWriter::new(writer), + } + } + + pub fn encode(&mut self, army: &Army) -> Result<(), EncodeError> { + self.write_header(army)?; + self.write_regiments(army)?; + Ok(()) + } + + fn write_header(&mut self, army: &Army) -> Result<(), EncodeError> { + // TODO: Ignoring save file header. + + self.writer.write_all(&FORMAT.to_le_bytes())?; + self.writer + .write_all(&(army.regiments.len() as u32).to_le_bytes())?; + self.writer + .write_all(&(REGIMENT_BLOCK_SIZE as u32).to_le_bytes())?; + self.writer.write_all(&[army.race])?; + self.writer.write_all(&army.unknown1)?; + self.writer.write_all(&army.unknown2)?; + self.write_string(&army.small_banner_path)?; + self.writer.write_all(&army.small_banner_path_remainder)?; + self.write_string(&army.small_banner_disabled_path)?; + self.writer + .write_all(&army.small_banner_disabled_path_remainder)?; + self.write_string(&army.large_banner_path)?; + self.writer.write_all(&army.large_banner_path_remainder)?; + self.writer + .write_all(&army.gold_from_treasures.to_le_bytes())?; + self.writer.write_all(&army.gold_in_coffers.to_le_bytes())?; + self.writer.write_all(&army.magic_items)?; + self.writer.write_all(&army.unknown3)?; + + self.writer.flush()?; + + Ok(()) + } + + fn write_regiments(&mut self, army: &Army) -> Result<(), EncodeError> { + for regiment in &army.regiments { + self.write_regiment(regiment)?; + } + + Ok(()) + } + + fn write_regiment(&mut self, r: &Regiment) -> Result<(), EncodeError> { + self.writer.write_all(&r.status)?; + self.writer.write_all(&r.unknown1)?; + self.writer.write_all(&r.id.to_le_bytes())?; + self.writer.write_all(&r.unknown2)?; + self.writer.write_all(&[r.wizard_type])?; + self.writer.write_all(&[r.max_armor])?; + self.writer.write_all(&r.cost.to_le_bytes())?; + self.writer.write_all(&r.banner_index.to_le_bytes())?; + self.writer.write_all(&r.unknown3)?; + self.writer.write_all(&r.regiment_attributes)?; + self.writer.write_all(&r.sprite_index.to_le_bytes())?; + self.write_string_with_limit(&r.name, 32)?; + self.writer.write_all(&r.name_id.to_le_bytes())?; + self.writer.write_all(&[r.alignment])?; + self.writer.write_all(&[r.max_troops])?; + self.writer.write_all(&[r.alive_troops])?; + self.writer.write_all(&[r.ranks])?; + self.writer.write_all(&r.unknown4)?; + self.writer.write_all(&[r.troop_attributes.movement])?; + self.writer.write_all(&[r.troop_attributes.weapon_skill])?; + self.writer + .write_all(&[r.troop_attributes.ballistic_skill])?; + self.writer.write_all(&[r.troop_attributes.strength])?; + self.writer.write_all(&[r.troop_attributes.toughness])?; + self.writer.write_all(&[r.troop_attributes.wounds])?; + self.writer.write_all(&[r.troop_attributes.initiative])?; + self.writer.write_all(&[r.troop_attributes.attacks])?; + self.writer.write_all(&[r.troop_attributes.leadership])?; + self.writer.write_all(&[r.mount])?; + self.writer.write_all(&[r.armor])?; + self.writer.write_all(&[r.weapon])?; + self.writer.write_all(&[r.typ])?; + self.writer.write_all(&[r.point_value])?; + self.writer.write_all(&[r.missile_weapon])?; + self.writer.write_all(&[r.unknown5])?; + self.writer.write_all(&r.unknown6)?; + self.writer + .write_all(&r.leader.sprite_index.to_le_bytes())?; + self.write_string_with_limit(&r.leader.name, 32)?; + self.writer.write_all(&r.leader.name_remainder)?; + self.writer.write_all(&[r.leader.attributes.movement])?; + self.writer.write_all(&[r.leader.attributes.weapon_skill])?; + self.writer + .write_all(&[r.leader.attributes.ballistic_skill])?; + self.writer.write_all(&[r.leader.attributes.strength])?; + self.writer.write_all(&[r.leader.attributes.toughness])?; + self.writer.write_all(&[r.leader.attributes.wounds])?; + self.writer.write_all(&[r.leader.attributes.initiative])?; + self.writer.write_all(&[r.leader.attributes.attacks])?; + self.writer.write_all(&[r.leader.attributes.leadership])?; + self.writer.write_all(&[r.leader.mount])?; + self.writer.write_all(&[r.leader.armor])?; + self.writer.write_all(&[r.leader.weapon])?; + self.writer.write_all(&[r.leader.unit_type])?; + self.writer.write_all(&[r.leader.point_value])?; + self.writer.write_all(&[r.leader.missile_weapon])?; + self.writer.write_all(&r.leader.unknown1)?; + self.writer.write_all(&r.leader.head_id.to_le_bytes())?; + self.writer.write_all(&r.leader.x)?; + self.writer.write_all(&r.leader.y)?; + self.writer.write_all(&r.experience.to_le_bytes())?; + self.writer.write_all(&[r.duplicate_id])?; + self.writer.write_all(&[r.min_armor])?; + self.writer.write_all(&r.magic_book.to_le_bytes())?; + self.writer.write_all(&r.magic_items[0].to_le_bytes())?; + self.writer.write_all(&r.magic_items[1].to_le_bytes())?; + self.writer.write_all(&r.magic_items[2].to_le_bytes())?; + self.writer.write_all(&r.unknown7)?; + self.writer.write_all(&[r.purchased_armor])?; + self.writer.write_all(&[r.max_purchasable_armor])?; + self.writer.write_all(&[r.repurchased_troops])?; + self.writer.write_all(&[r.max_purchasable_troops])?; + self.writer.write_all(&r.book_profile)?; + + Ok(()) + } + + fn write_string(&mut self, s: &str) -> Result<(), EncodeError> { + let (windows_1252_bytes, _, _) = WINDOWS_1252.encode(s); + + let c_string = CString::new(windows_1252_bytes).map_err(|_| EncodeError::InvalidString)?; + let bytes = c_string.as_bytes_with_nul(); + + self.writer.write_all(bytes)?; + + Ok(()) + } + + fn write_string_with_limit(&mut self, s: &str, limit: usize) -> Result<(), EncodeError> { + let (windows_1252_bytes, _, _) = WINDOWS_1252.encode(s); + + let c_string = CString::new(windows_1252_bytes).map_err(|_| EncodeError::InvalidString)?; + let bytes = c_string.as_bytes_with_nul(); + + if bytes.len() > limit { + return Err(EncodeError::StringTooLong); + } + + self.writer.write_all(bytes)?; + + let padding_size = limit - bytes.len(); + let padding = vec![0; padding_size]; + self.writer.write_all(&padding)?; + + Ok(()) + } +} diff --git a/src/army/mod.rs b/src/army/mod.rs new file mode 100644 index 0000000..362414c --- /dev/null +++ b/src/army/mod.rs @@ -0,0 +1,332 @@ +mod decoder; +mod encoder; + +use serde::Serialize; + +pub use decoder::{DecodeError, Decoder}; +pub use encoder::{EncodeError, Encoder}; + +#[derive(Debug, Clone, Serialize)] +pub struct Army { + pub race: u8, + unknown1: Vec, + unknown2: Vec, + pub regiments: Vec, + pub small_banner_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + small_banner_path_remainder: Vec, + pub small_banner_disabled_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + small_banner_disabled_path_remainder: Vec, + pub large_banner_path: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + large_banner_path_remainder: Vec, + pub gold_from_treasures: u16, + pub gold_in_coffers: u16, + pub magic_items: Vec, + unknown3: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Regiment { + status: [u8; 2], + id: u16, + + /// The name of the regiment, e.g. "Grudgebringer Cavalry", "Zombies #1", + /// "Imperial Steam Tank". + name: String, + + name_id: u16, + + /// The regiment's alignment to good or evil. + /// + /// - 0x00 (decimal 0) is good. + /// - 0x40 (decimal 64) is neutral. + /// - 0x80 (decimal 128) is evil. + alignment: u8, + /// A bitfield for the regiment's type and race. + /// + /// The lower 3 bits determine the race. The higher 5 bits determine the + /// regiment's type. + typ: u8, + /// The index into the list of sprite file names found in ENGREL.EXE for the + /// regiment's banner. + banner_index: u16, + /// The index into the list of sprite file names found in ENGREL.EXE for the + /// regiment's troop sprite. + sprite_index: u16, + /// The maximum number of troops allowed in this regiment. + max_troops: u8, + /// The number of troops currently alive in this regiment. + alive_troops: u8, + + ranks: u8, + regiment_attributes: [u8; 4], + troop_attributes: TroopAttributes, + mount: u8, + armor: u8, + weapon: u8, + point_value: u8, + missile_weapon: u8, + + /// The regiment's leader. + leader: Leader, + /// A number that represents the regiment's total experience. + /// + /// It is a number between 0 and 6000. If experience is <1000 then the + /// regiment has a threat level of 1. If experience >=1000 and <3000 then + /// the regiment has a threat level of 2. If experience >= 3000 and <6000 + /// then the regiment has a threat level of 3. If experience >= 6000 then + /// the regiment has a threat level of 4. + experience: u16, + /// The regiment's minimum or base level of armor. + /// + /// This is displayed as the gold shields in the troop roster. + min_armor: u8, + /// The regiment's maximum level of armor. + max_armor: u8, + /// The magic book that is equipped to the regiment. A magic book is one of + /// the magic items. + /// + /// This is an index into the list of magic items. In the original game, the + /// value is either 22, 23, 24, 25 or 65535. + /// + /// A value of 22 means the Bright Book is equipped. A value of 23 means the + /// Ice Book is equipped. A value of 65535 means the regiment does not have + /// a magic book slot—only magic users can equip magic books. + magic_book: u16, + /// A list of magic items that are equipped to the regiment. + /// + /// Each magic item is an index into the list of magic items. A value of 1 + /// means the Grudgebringer Sword is equipped in that slot. A value of 65535 + /// means the regiment does not have anything equipped in that slot. + magic_items: [u16; 3], + + cost: u16, + + wizard_type: u8, + + duplicate_id: u8, + purchased_armor: u8, + max_purchasable_armor: u8, + repurchased_troops: u8, + max_purchasable_troops: u8, + book_profile: [u8; 4], + + unknown1: [u8; 2], + unknown2: [u8; 2], + unknown3: [u8; 2], + unknown4: [u8; 4], + unknown5: u8, + unknown6: [u8; 4], + unknown7: [u8; 12], +} + +#[derive(Debug, Clone, Serialize)] +pub struct TroopAttributes { + movement: u8, + weapon_skill: u8, + ballistic_skill: u8, + strength: u8, + toughness: u8, + wounds: u8, + initiative: u8, + attacks: u8, + leadership: u8, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Leader { + /// The name of the leader. + name: String, + /// There are some bytes after the null-terminated string. Not sure what + /// they are for. + name_remainder: Vec, + /// The index into the list of sprite file names found in ENGREL.EXE for the + /// leader's sprite. + sprite_index: u16, + + attributes: TroopAttributes, + mount: u8, + armor: u8, + weapon: u8, + unit_type: u8, + point_value: u8, + missile_weapon: u8, + unknown1: [u8; 4], + /// The leader's 3D head ID. + head_id: u16, + x: [u8; 4], + y: [u8; 4], +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::{ + ffi::{OsStr, OsString}, + fs::File, + path::{Path, PathBuf}, + }; + + fn roundtrip_test(original_bytes: &[u8], army: &Army) { + let mut encoded_bytes = Vec::new(); + Encoder::new(&mut encoded_bytes).encode(army).unwrap(); + + let original_bytes = original_bytes + .chunks(16) + .map(|chunk| { + chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + let encoded_bytes = encoded_bytes + .chunks(16) + .map(|chunk| { + chunk + .iter() + .map(|b| format!("{:02X}", b)) + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + assert_eq!(original_bytes, encoded_bytes); + } + + #[test] + fn test_decode_plyr_alg() { + let d: PathBuf = [ + std::env::var("DARKOMEN_PATH").unwrap().as_str(), + "DARKOMEN", + "GAMEDATA", + "1PARM", + "PLYR_ALG.ARM", + ] + .iter() + .collect(); + + let original_bytes = std::fs::read(d.clone()).unwrap(); + + let file = File::open(d).unwrap(); + let a = Decoder::new(file).decode().unwrap(); + + roundtrip_test(&original_bytes, &a); + } + + #[test] + fn test_decode_b101mrc() { + let d: PathBuf = [ + std::env::var("DARKOMEN_PATH").unwrap().as_str(), + "DARKOMEN", + "GAMEDATA", + "1PBAT", + "B1_01", + "B101MRC.ARM", + ] + .iter() + .collect(); + + let original_bytes = std::fs::read(d.clone()).unwrap(); + + let file = File::open(d).unwrap(); + let a = Decoder::new(file).decode().unwrap(); + + assert_eq!(a.small_banner_path, "[BOOKS]\\hshield.spr"); + assert_eq!(a.small_banner_disabled_path, "[BOOKS]\\hgban.spr"); + assert_eq!(a.large_banner_path, "[BOOKS]\\hlban.spr"); + assert_eq!(a.regiments.len(), 4); + assert_eq!(a.regiments[0].name, "Grudgebringer Cavalry"); + assert_eq!(a.regiments[0].leader.name, "Morgan Bernhardt"); + assert_eq!(a.regiments[0].mount, 1); + assert_eq!(a.regiments[1].name, "Grudgebringer Infantry"); + assert_eq!(a.regiments[2].name, "Grudgebringer Crossbows"); + assert_eq!(a.regiments[3].name, "Grudgebringer Cannon"); + + roundtrip_test(&original_bytes, &a); + } + + #[test] + fn test_decode_all() { + let d: PathBuf = [ + std::env::var("DARKOMEN_PATH").unwrap().as_str(), + "DARKOMEN", + "GAMEDATA", + ] + .iter() + .collect(); + + let root_output_dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "decoded", "armies"] + .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()); + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + visit_dirs(&path, cb); + } else { + cb(&path); + } + } + } + + visit_dirs(&d, &mut |path| { + if let Some(ext) = path.extension() { + if ext.to_string_lossy().to_uppercase() == "ARM" + || ext.to_string_lossy().to_uppercase() == "AUD" + || ext.to_string_lossy().to_uppercase() == "ARE" + { + println!("Decoding {:?}", path.file_name().unwrap()); + + let original_bytes = std::fs::read(path).unwrap(); + + let file = File::open(path).unwrap(); + let army = Decoder::new(file).decode().unwrap(); + + roundtrip_test(&original_bytes, &army); + + let parent_dir = path + .components() + .collect::>() + .iter() + .rev() + .skip(1) // skip the file name + .take_while(|c| c.as_os_str() != "DARKOMEN") + .collect::>() + .iter() + .rev() + .collect::(); + let output_dir = root_output_dir.join(parent_dir); + std::fs::create_dir_all(&output_dir).unwrap(); + + let output_path = append_ext("ron", output_dir.join(path.file_name().unwrap())); + let mut output_file = File::create(output_path).unwrap(); + ron::ser::to_writer_pretty(&mut output_file, &army, Default::default()) + .unwrap(); + } + } + }); + } + + fn append_ext(ext: impl AsRef, path: PathBuf) -> PathBuf { + let mut os_string: OsString = path.into(); + os_string.push("."); + os_string.push(ext.as_ref()); + os_string.into() + } +} diff --git a/src/lib.rs b/src/lib.rs index 5f8543e..e74ecc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod army; pub mod battle; pub mod graphics; pub mod light;