Skip to main content

wows_core/
version.rs

1//! Game version: a `major.minor.patch` triple plus a monotonic `build` number.
2
3#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, PartialOrd, Ord, Hash)]
4#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
5#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))]
6pub struct Version {
7    pub major: u32,
8    pub minor: u32,
9    pub patch: u32,
10    pub build: u32,
11}
12
13impl Version {
14    pub fn from_client_exe(version: &str) -> Version {
15        let parts: Vec<_> = version.split(",").collect();
16        assert!(parts.len() == 4);
17        Version {
18            major: parts[0].trim().parse::<u32>().unwrap(),
19            minor: parts[1].trim().parse::<u32>().unwrap(),
20            patch: parts[2].trim().parse::<u32>().unwrap(),
21            build: parts[3].trim().parse::<u32>().unwrap(),
22        }
23    }
24
25    /// Fallible variant of [`Version::from_client_exe`] that returns `None`
26    /// instead of panicking when the version string is malformed (e.g. read
27    /// from a corrupt or truncated replay).
28    pub fn try_from_client_exe(version: &str) -> Option<Version> {
29        let parts: Vec<_> = version.split(',').collect();
30        if parts.len() != 4 {
31            return None;
32        }
33        Some(Version {
34            major: parts[0].trim().parse::<u32>().ok()?,
35            minor: parts[1].trim().parse::<u32>().ok()?,
36            patch: parts[2].trim().parse::<u32>().ok()?,
37            build: parts[3].trim().parse::<u32>().ok()?,
38        })
39    }
40
41    /// Extract the game version from the `Account.def` entity definition XML.
42    ///
43    /// The file contains a node like `<curVersion_15_1_0_11965230></curVersion_15_1_0_11965230>`
44    /// whose tag name encodes the version as `curVersion_{major}_{minor}_{patch}_{build}`.
45    ///
46    /// Older versions use different formats:
47    /// - `curVersion_release_{major}_{minor}_{patch}_{build}` (~v11-v12)
48    /// - `curVersion_Release_{major}_{minor}_{patch}_{subpatch}_{build}` (v0.x era)
49    #[cfg(feature = "parsing")]
50    pub fn from_account_def(xml: &str) -> Option<Version> {
51        let doc = roxmltree::Document::parse(xml).ok()?;
52        for node in doc.descendants() {
53            if let Some(rest) = node.tag_name().name().strip_prefix("curVersion_") {
54                // Strip optional "Release_" or "release_" prefix (used in older versions)
55                let rest = rest.strip_prefix("Release_").or_else(|| rest.strip_prefix("release_")).unwrap_or(rest);
56                let parts: Vec<&str> = rest.split('_').collect();
57                match parts.len() {
58                    // Modern: major_minor_patch_build
59                    4 => {
60                        return Some(Version {
61                            major: parts[0].parse().ok()?,
62                            minor: parts[1].parse().ok()?,
63                            patch: parts[2].parse().ok()?,
64                            build: parts[3].parse().ok()?,
65                        });
66                    }
67                    // Legacy: major_minor_patch_subpatch_build (subpatch folded into patch)
68                    5 => {
69                        return Some(Version {
70                            major: parts[0].parse().ok()?,
71                            minor: parts[1].parse().ok()?,
72                            patch: parts[2].parse().ok()?,
73                            build: parts[4].parse().ok()?,
74                        });
75                    }
76                    _ => {}
77                }
78            }
79        }
80        None
81    }
82
83    pub fn to_path(&self) -> String {
84        format!("{}.{}.{}", self.major, self.minor, self.patch)
85    }
86
87    /// A base version `(major, minor, patch)` with no build component. Useful for
88    /// keying version-gated tables, where entries take effect at a friendly
89    /// version regardless of build. Compare against a full version with
90    /// [`Self::is_at_least`], which ignores the build field.
91    pub const fn base(major: u32, minor: u32, patch: u32) -> Version {
92        Version { major, minor, patch, build: 0 }
93    }
94
95    pub fn is_at_least(&self, other: &Version) -> bool {
96        if self.major > other.major {
97            true
98        } else if self.major < other.major {
99            false
100        } else if self.minor > other.minor {
101            true
102        } else if self.minor < other.minor {
103            false
104        } else {
105            self.patch >= other.patch
106        }
107    }
108}
109
110#[cfg(test)]
111mod test {
112    use super::*;
113
114    fn assert_older_newer(older: Version, newer: Version) {
115        assert!(newer.is_at_least(&older));
116        assert!(newer.is_at_least(&newer));
117        assert!(!older.is_at_least(&newer));
118    }
119
120    #[test]
121    fn different_patch() {
122        let older = Version::from_client_exe("0,10,9,0");
123        let newer = Version::from_client_exe("0,10,10,0");
124        assert_older_newer(older, newer);
125    }
126
127    #[test]
128    fn different_minor() {
129        let older = Version::from_client_exe("0,10,9,0");
130        let newer = Version::from_client_exe("0,11,0,0");
131        assert_older_newer(older, newer);
132    }
133
134    #[test]
135    fn different_major() {
136        let older = Version::from_client_exe("0,11,5,0");
137        let newer = Version::from_client_exe("1,0,0,0");
138        assert_older_newer(older, newer);
139    }
140
141    #[cfg(feature = "parsing")]
142    #[test]
143    fn from_account_def_parses_version() {
144        let xml = r#"<root><Properties><curVersion_15_1_0_11965230></curVersion_15_1_0_11965230></Properties></root>"#;
145        let v = Version::from_account_def(xml).unwrap();
146        assert_eq!(v.major, 15);
147        assert_eq!(v.minor, 1);
148        assert_eq!(v.patch, 0);
149        assert_eq!(v.build, 11965230);
150    }
151
152    #[cfg(feature = "parsing")]
153    #[test]
154    fn from_account_def_with_release_prefix() {
155        let xml = r#"<root><Properties><curVersion_release_11_4_0_5624555></curVersion_release_11_4_0_5624555></Properties></root>"#;
156        let v = Version::from_account_def(xml).unwrap();
157        assert_eq!(v.major, 11);
158        assert_eq!(v.minor, 4);
159        assert_eq!(v.patch, 0);
160        assert_eq!(v.build, 5624555);
161    }
162
163    #[cfg(feature = "parsing")]
164    #[test]
165    fn from_account_def_legacy_5part_version() {
166        let xml = r#"<root><Properties><curVersion_Release_0_6_13_0_296659></curVersion_Release_0_6_13_0_296659></Properties></root>"#;
167        let v = Version::from_account_def(xml).unwrap();
168        assert_eq!(v.major, 0);
169        assert_eq!(v.minor, 6);
170        assert_eq!(v.patch, 13);
171        assert_eq!(v.build, 296659);
172    }
173
174    #[cfg(feature = "parsing")]
175    #[test]
176    fn from_account_def_returns_none_on_missing() {
177        let xml = r#"<root><Properties><someOtherNode/></Properties></root>"#;
178        assert!(Version::from_account_def(xml).is_none());
179    }
180}