diff --git a/insim/src/insim/ver.rs b/insim/src/insim/ver.rs index 7033900..f76fe61 100644 --- a/insim/src/insim/ver.rs +++ b/insim/src/insim/ver.rs @@ -1,10 +1,51 @@ +use std::str::FromStr; + +use bytes::BufMut; use insim_core::{ - binrw::{self, binrw}, + binrw::{self, binrw, BinRead, BinWrite}, + game_version::GameVersion, string::{binrw_parse_codepage_string, binrw_write_codepage_string}, }; use crate::identifiers::RequestId; +#[binrw::parser(reader, endian)] +fn parse_game_version() -> binrw::BinResult { + let pos = reader.stream_position()?; + <[u8; 8]>::read_options(reader, endian, ()).and_then(|bytes| { + std::str::from_utf8(&bytes) + .map_err(|err| binrw::Error::Custom { + pos, + err: Box::new(err), + }) + .map(|s| { + GameVersion::from_str(s.trim_end_matches('\0')).map_err(|err| { + binrw::Error::Custom { + pos, + err: Box::new(err), + } + }) + }) + })? +} + +#[binrw::writer(writer, endian)] +fn write_game_version(input: &GameVersion) -> binrw::BinResult<()> { + let mut ver = input.to_string().as_bytes().to_vec(); + if ver.len() > 8 { + ver.truncate(8); + } else { + let remaining = 8 - ver.len(); + if remaining > 0 { + ver.put_bytes(0, remaining); + } + } + + ver.write_options(writer, endian, ())?; + + Ok(()) +} + #[binrw] #[derive(Debug, Clone, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] @@ -18,9 +59,9 @@ pub struct Ver { pub reqi: RequestId, /// LFS version, e.g. 0.3G - #[br(parse_with = binrw_parse_codepage_string::<8, _>)] - #[bw(write_with = binrw_write_codepage_string::<8, _>)] - pub version: String, + #[br(parse_with = parse_game_version)] + #[bw(write_with = write_game_version)] + pub version: GameVersion, /// Product: DEMO / S1 / S2 / S3 #[br(parse_with = binrw_parse_codepage_string::<6, _>)] @@ -31,3 +72,32 @@ pub struct Ver { #[brw(pad_after = 1)] pub insimver: u8, } + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn test_version() { + let data = vec![ + 0, // reqi + 0, // padding + 48, 46, 55, 65, 0, 0, 0, 0, // game version + 68, 69, 77, 79, 0, 0, // product + 9, // insim ver + 0, // padding + ]; + + let parsed = Ver::read_le(&mut Cursor::new(&data)).unwrap(); + assert_eq!(parsed.version, GameVersion::from_str("0.7A").unwrap()); + assert_eq!(parsed.product, "DEMO"); + assert_eq!(parsed.insimver, 9); + + let mut data2 = Cursor::new(Vec::new()); + parsed.write_le(&mut data2).unwrap(); + + assert_eq!(data, data2.into_inner()); + } +} diff --git a/insim_core/Cargo.toml b/insim_core/Cargo.toml index a54ed98..16dfd71 100644 --- a/insim_core/Cargo.toml +++ b/insim_core/Cargo.toml @@ -18,10 +18,11 @@ bench = false doctest = false [dependencies] +binrw = { workspace = true } bytes = { workspace = true } encoding_rs = { workspace = true } +if_chain = { workspace = true } itertools = { workspace = true } -serde = { workspace = true, features = ["derive"], optional = true } once_cell = { workspace = true } -if_chain = { workspace = true } -binrw = { workspace = true } +serde = { workspace = true, features = ["derive"], optional = true } +thiserror = { workspace = true } diff --git a/insim_core/src/game_version.rs b/insim_core/src/game_version.rs new file mode 100644 index 0000000..c8dbf78 --- /dev/null +++ b/insim_core/src/game_version.rs @@ -0,0 +1,265 @@ +//! Tools for parsing, comparing and sorting a game version, based on best effort of known LFS +//! version + +use std::{cmp::Ordering, fmt::Display, str::FromStr}; + +use if_chain::if_chain; +use itertools::Itertools; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +/// Possible errors when parsing a game version +pub enum GameVersionParseError { + /// Could not parse a float + #[error("Could not parse major version: {0}")] + Major(String), + + /// Could not parse minor + #[error("Could not parse minor version: {0}")] + Minor(String), + + /// Could not parse an int + #[error("Could not parse patch: {0}")] + Patch(String), +} + +/// GameVersion +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct GameVersion { + /// Version + // XXX: Why a float? Because as far as I can tell Scawen treats LFS versions like a number, not + // as a version, based on the existence of 0.04k. Version numbers tend not to have leading + // zeros. + pub major: f32, + + /// Patch + pub minor: char, + + /// Patch revision + pub patch: Option, +} + +impl PartialEq for GameVersion { + fn eq(&self, other: &Self) -> bool { + self.major.to_bits() == other.major.to_bits() + && self.minor == other.minor + && self.patch.unwrap_or(0) == other.patch.unwrap_or(0) + } +} + +impl Eq for GameVersion {} + +impl PartialOrd for GameVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for GameVersion { + fn cmp(&self, other: &Self) -> Ordering { + let major = self.major.partial_cmp(&other.major); + let minor = self.minor.partial_cmp(&other.minor); + let patch = self + .patch + .unwrap_or(0) + .partial_cmp(&other.patch.unwrap_or(0)); + + match (major, minor, patch) { + (Some(Ordering::Equal), Some(Ordering::Equal), Some(patch_eq)) => patch_eq, + (Some(Ordering::Equal), Some(Ordering::Greater), _) => Ordering::Greater, + (Some(Ordering::Equal), Some(Ordering::Less), _) => Ordering::Less, + + (Some(non_eq), _, _) => non_eq, + + _ => Ordering::Equal, + } + } +} + +impl Default for GameVersion { + fn default() -> Self { + Self { + major: 0.0, + minor: 'A', + patch: None, + } + } +} + +impl Display for GameVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(patch) = self.patch { + write!(f, "{}{}{}", self.major, self.minor, patch) + } else { + write!(f, "{}{}", self.major, self.minor) + } + } +} + +enum Position { + Major, + Minor, + Patch, +} + +impl FromStr for GameVersion { + type Err = GameVersionParseError; + + fn from_str(text: &str) -> Result { + let mut data = Self::default(); + let mut pos = Position::Major; + let mut iter = text.chars().peekable(); + + while iter.peek().is_some() { + match pos { + Position::Major => { + let major: String = iter + .take_while_ref(|x| x.is_numeric() || *x == '.') + .collect(); + data.major = major + .parse() + .map_err(|e| GameVersionParseError::Major(format!("{}", e)))?; + pos = Position::Minor; + }, + Position::Minor => { + let next = iter.next(); + + if_chain! { + if let Some(patch) = next; + if patch.is_ascii_alphabetic(); + then { + data.minor = patch.to_ascii_uppercase(); + pos = Position::Patch; + + } else { + return Err(GameVersionParseError::Minor( + format!("Expected A-Z character, found {:?}", next) + )); + } + } + }, + Position::Patch => { + let rev: String = iter.by_ref().take_while_ref(|x| x.is_numeric()).collect(); + data.patch = Some( + rev.parse() + .map_err(|e| GameVersionParseError::Patch(format!("{}", e)))?, + ); + }, + } + } + + Ok(data) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_known_versions() { + let version = vec![ + "0.7F", "0.7E15", "0.7E14", "0.7E13", "0.7E12", "0.7E11", "0.7E10", "0.7E9", "0.7E8", + "0.7E7", "0.7E6", "0.7E5", "0.7E4", "0.7E3", "0.7E2", "0.7E", "0.7D64", "0.7D63", + "0.7D62", "0.7D61", "0.7D60", "0.7D59", "0.7D58", "0.7D57", "0.7D56", "0.7D55", + "0.7D54", "0.7D53", "0.7D52", "0.7D51", "0.7D50", "0.7D48", "0.7D47", "0.7D46", + "0.7D45", "0.7D44", "0.7D43", "0.7D42", "0.7D41", "0.7D40", "0.7D39", "0.7D38", + "0.7D37", "0.7D36", "0.7D35", "0.7D34", "0.7D33", "0.7D32", "0.7D31", "0.7D30", + "0.7D29", "0.7D28", "0.7D27", "0.7D26", "0.7D25", "0.7D24", "0.7D21", "0.7D20", + "0.7D19", "0.7D18", "0.7D17", "0.7D16", "0.7D15", "0.7D14", "0.7D13", "0.7D12", + "0.7D11", "0.7D10", "0.7D9", "0.7D8", "0.7D7", "0.7D6", "0.7D5", "0.7D4", "0.7D", + "0.7C6", "0.7C5", "0.7C4", "0.7C3", "0.7C2", "0.7C", "0.7B12", "0.7B11", "0.7B10", + "0.7B8", "0.7B7", "0.7B6", "0.7B5", "0.7B3", "0.7B2", "0.7B", "0.7A13", "0.7A12", + "0.7A11", "0.7A10", "0.7A9", "0.7A7", "0.7A6", "0.7A5", "0.7A3", "0.7A2", "0.7A", + "0.6W60", "0.6W59", "0.6W58", "0.6W57", "0.6W56", "0.6W55", "0.6W54", "0.6W53", + "0.6W52", "0.7F", "0.6W51", "0.6W50", "0.6W49", "0.6W48", "0.6W47", "0.6W46", "0.6W45", + "0.6W43", "0.6V3", "0.6V", "0.6U25", "0.6U24", "0.6U23", "0.6U22", "0.6U21", "0.6U20", + "0.6U19", "0.6U18", "0.6U17", "0.6U16", "0.6U15", "0.6U14", "0.6U13", "0.6U12", + "0.6U11", "0.6U9", "0.6U7", "0.6U6", "0.6U5", "0.7F", "0.6U4", "0.6U3", "0.6U2", + "0.6U", "0.6T7", "0.6T6", "0.6T5", "0.6T4", "0.6T3", "0.6T2", "0.6T", "0.6R22", + "0.6R21", "0.6R20", "0.6R19", "0.6R18", "0.6R17", "0.6R16", "0.6R15", "0.6R14", + "0.6R13", "0.6R12", "0.6R11", "0.6R9", "0.6R8", "0.6R7", "0.6R", "0.6Q14", "0.6Q12", + "0.6Q10", "0.6Q9", "0.6Q3", "0.6Q", "0.6P9", "0.6P8", "0.6P7", "0.6P6", "0.6P5", + "0.6P4", "0.6P3", "0.6P2", "0.6P", "0.6N7", "0.6N6", "0.6N4", "0.6N3", "0.6N2", "0.6N", + "0.6M9", "0.6M8", "0.6M7", "0.6M6", "0.6M5", "0.6M3", "0.6M2", "0.6M", "0.7F", + "0.6K26", "0.6K25", "0.6K24", "0.6K23", "0.6K22", "0.6K21", "0.6K20", "0.6K19", + "0.6K18", "0.6K17", "0.6K16", "0.6K14", "0.6K12", "0.6K11", "0.6K10", "0.6K9", "0.6K8", + "0.6K7", "0.6K6", "0.6K5", "0.6K4", "0.6K3", "0.6K2", "0.6K", "0.6J5", "0.6J4", + "0.6J3", "0.6J2", "0.6J", "0.7F", "0.6H10", "0.6H6", "0.6H5", "0.6H4", "0.6H3", + "0.6H2", "0.6H", "0.6G19", "0.6G18", "0.6G17", "0.6G16", "0.6G14", "0.6G3", "0.6G2", + "0.6G", "0.6F12", "0.6F11", "0.6F10", "0.6F9", "0.6F8", "0.6F7", "0.6F6", "0.6F5", + "0.6F4", "0.6F3", "0.6F2", "0.6F", "0.6E19", "0.6E18", "0.7F", "0.6E17", "0.6E16", + "0.6E15", "0.6E14", "0.6E13", "0.6E12", "0.6E11", "0.6E10", "0.6E8", "0.6E7", "0.6E6", + "0.6E5", "0.6E4", "0.6E", "0.6B16", "0.6B15", "0.6B14", "0.6B13", "0.6B12", "0.6B11", + "0.6B10", "0.6B9", "0.6B8", "0.6B7", "0.6B6", "0.6B5", "0.6B", "0.6A4", "0.6A3", + "0.7F", "0.6A2", "0.6A1", "0.5Z34", "0.5Z33", "0.5Z32", "0.5Z31", "0.5Z30", "0.5Z28", + "0.5Z27", "0.5Z26", "0.5Z25", "0.5Z24", "0.5Z22", "0.5Z20", "0.5Z19", "0.5Z18", + "0.5Z17", "0.5Z16", "0.5Z15", "0.5Z13", "0.5Z10", "0.5Z9", "0.5Z8", "0.5Z7", "0.5Z6", + "0.5Z5", "0.5Z4", "0.5Z3", "0.5Z", "0.5Y32", "0.5Y31", "0.5Y30", "0.5Y24", "0.5Y22", + "0.5Y21", "0.5Y20", "0.5Y19", "0.5Y18", "0.5Y16", "0.5Y15", "0.5Y14", "0.5Y13", + "0.5Y12", "0.5Y11", "0.5Y10", "0.5Y9", "0.5Y8", "0.5Y", "0.5X39", "0.5X38", "0.5X37", + "0.5X36", "0.5X35", "0.5X33", "0.5X32", "0.5X31", "0.5X30", "0.5X10", "0.5X8", "0.5X7", + "0.5X6", "0.5X5", "0.5X4", "0.5X3", "0.5X2", "0.5X", "0.5W48", "0.5W47", "0.5W44", + "0.5W43", "0.5W42", "0.5W41", "0.5W40", "0.5W39", "0.5W38", "0.5W37", "0.5W36", + "0.5W35", "0.5W34", "0.5W33", "0.5W32", "0.5W26", "0.5W25", "0.5W24", "0.5W20", + "0.5W17", "0.5W10", "0.5W9", "0.5W", "0.5V9", "0.5V5", "0.5V3", "0.5V2", "0.5V", + "0.5U35", "0.5U34", "0.5U33", "0.5U32", "0.5U30", "0.5U10", "0.5U9", "0.5U7", "0.5U", + "0.5T7", "0.5T6", "0.5T5", "0.5T4", "0.5T3", "0.5T2", "0.5T", "0.5S", "0.5Q", "0.5P12", + "0.5P5", "0.5P4", "0.5P3", "0.5P2", "0.5P", "0.5L", "0.5K", "0.3H6", "0.3H5", "0.3H4", + "0.3H3", "0.3H2", "0.3H", "0.3G10", "0.3G9", "0.3G8", "0.3G7", "0.3G6", "0.3G5", + "0.3G4", "0.3G3", "0.3G", "0.3F", "0.3E12", "0.3E10", "0.3E8", "0.3E7", "0.3E6", + "0.3E5", "0.3E4", "0.3E", "0.3D", "0.3C", "0.3B", "0.3A", "0.2F", "0.2E5", "0.2E4", + "0.2E1", "0.2D4", "0.2D3", "0.2D2", "0.2D", "0.2C", "0.2B", "0.2A", "0.1W", "0.1T", + "0.1Q", "0.1P", "0.1N", "0.1M", "0.1L", "0.1K", "0.1J", "0.1H3", "0.1H2", "0.1H", + "0.1G3", "0.1G2", "0.1G", "0.1F2", "0.1F", "0.1E", "0.1D", "0.1C", "0.1B", "0.04Q", + "0.04K", + ]; + + for i in version.iter() { + let parsed = GameVersion::from_str(&i).unwrap(); + assert_eq!(i, &parsed.to_string()); + } + } + + #[test] + fn test_ordering() { + assert!(GameVersion::from_str("0.04k").unwrap() < GameVersion::from_str("0.1").unwrap()); + assert!(GameVersion::from_str("0.1").unwrap() < GameVersion::from_str("0.1P").unwrap()); + assert!(GameVersion::from_str("0.3D").unwrap() < GameVersion::from_str("0.3e").unwrap()); + assert!(GameVersion::from_str("0.7F").unwrap() < GameVersion::from_str("0.7F1").unwrap()); + assert!(GameVersion::from_str("0.7F").unwrap() < GameVersion::from_str("0.7F1").unwrap()); + assert!(GameVersion::from_str("0.7F").unwrap() < GameVersion::from_str("0.8").unwrap()); + + assert!(GameVersion::from_str("0.7F").unwrap() == GameVersion::from_str("0.7f").unwrap()); + } + + #[test] + fn test_parse() { + let res = "0.04k".parse::(); + assert!(res.is_ok()); + } + + #[test] + fn test_failure_to_parse_major() { + let res = "a4k".parse::(); + assert!(matches!(res, Err(GameVersionParseError::Major(_)))); + } + + #[test] + fn test_failure_to_parse_minor() { + let res = "0.04-".parse::(); + assert!(matches!(res, Err(GameVersionParseError::Minor(_)))); + } + + #[test] + fn test_failure_to_parse_patch() { + let res = "0.04k-".parse::(); + assert!(matches!(res, Err(GameVersionParseError::Patch(_)))); + } + + #[test] + fn test_normalise_to_uppercase() { + let res = "0.04k".parse::().unwrap(); + assert!(res.minor == 'K'); + } +} diff --git a/insim_core/src/lib.rs b/insim_core/src/lib.rs index 6c55175..4aa4e94 100644 --- a/insim_core/src/lib.rs +++ b/insim_core/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] pub mod duration; +pub mod game_version; pub mod license; pub mod point; pub mod string;