Skip to main content

wows_core/
version.rs

1//! Game version: a `major.minor.patch` triple plus an optional `build` number.
2
3use std::num::NonZeroU32;
4
5#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, PartialOrd, Ord, Hash)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))]
8pub struct Version {
9    pub major: u32,
10    pub minor: u32,
11    pub patch: u32,
12    /// The monotonic build number, if known. `None` for a base version that
13    /// only carries `major.minor.patch`.
14    pub build: Option<NonZeroU32>,
15}
16
17impl Version {
18    pub fn from_client_exe(version: &str) -> Version {
19        let parts: Vec<_> = version.split(",").collect();
20        assert!(parts.len() == 4);
21        Version {
22            major: parts[0].trim().parse::<u32>().unwrap(),
23            minor: parts[1].trim().parse::<u32>().unwrap(),
24            patch: parts[2].trim().parse::<u32>().unwrap(),
25            build: NonZeroU32::new(parts[3].trim().parse::<u32>().unwrap()),
26        }
27    }
28
29    /// Fallible variant of [`Version::from_client_exe`] that returns `None`
30    /// instead of panicking when the version string is malformed (e.g. read
31    /// from a corrupt or truncated replay).
32    pub fn try_from_client_exe(version: &str) -> Option<Version> {
33        let parts: Vec<_> = version.split(',').collect();
34        if parts.len() != 4 {
35            return None;
36        }
37        Some(Version {
38            major: parts[0].trim().parse::<u32>().ok()?,
39            minor: parts[1].trim().parse::<u32>().ok()?,
40            patch: parts[2].trim().parse::<u32>().ok()?,
41            build: NonZeroU32::new(parts[3].trim().parse::<u32>().ok()?),
42        })
43    }
44
45    /// The numeric build, if known.
46    pub fn build_number(&self) -> Option<u32> {
47        self.build.map(NonZeroU32::get)
48    }
49
50    /// Extract the game version from the `Account.def` entity definition XML.
51    ///
52    /// The file contains a node like `<curVersion_15_1_0_11965230></curVersion_15_1_0_11965230>`
53    /// whose tag name encodes the version as `curVersion_{major}_{minor}_{patch}_{build}`.
54    ///
55    /// Older versions use different formats:
56    /// - `curVersion_release_{major}_{minor}_{patch}_{build}` (~v11-v12)
57    /// - `curVersion_Release_{major}_{minor}_{patch}_{subpatch}_{build}` (v0.x era)
58    ///
59    /// The friendly version is normalized to match the replay header
60    /// (`clientVersionFromExe`), which is the source of truth: for the 0.7.8-0.11.7
61    /// era WG's `Account.def` wrote the version with the leading `0.` stripped and
62    /// shifted (`release_11_0_0` for 0.11.0, `release_7_11_0` for 0.7.11), while the
63    /// exe and replays report `0.X.Y`. See [`Self::friendly_from_account_def_parts`].
64    #[cfg(feature = "parsing")]
65    pub fn from_account_def(xml: &str) -> Option<Version> {
66        let doc = roxmltree::Document::parse(xml).ok()?;
67        for node in doc.descendants() {
68            if let Some(rest) = node.tag_name().name().strip_prefix("curVersion_") {
69                // Strip optional "Release_" or "release_" prefix (used in older versions)
70                let rest = rest.strip_prefix("Release_").or_else(|| rest.strip_prefix("release_")).unwrap_or(rest);
71                let parts: Vec<&str> = rest.split('_').collect();
72                // Modern: major_minor_patch_build. Legacy: major_minor_patch_subpatch_build.
73                let build_idx = match parts.len() {
74                    4 => 3,
75                    5 => 4,
76                    _ => continue,
77                };
78                let (major, minor, patch) = Self::friendly_from_account_def_parts(
79                    parts[0].parse().ok()?,
80                    parts[1].parse().ok()?,
81                    parts[2].parse().ok()?,
82                );
83                return Some(Version { major, minor, patch, build: NonZeroU32::new(parts[build_idx].parse().ok()?) });
84            }
85        }
86        None
87    }
88
89    /// Map an `Account.def` `curVersion` triple to the friendly version the replay
90    /// header reports. A `major` of 1..=11 never existed as a real WoWS version
91    /// (the game was `0.x` until 12.0.0), so it is unambiguously the stripped form
92    /// WG wrote for the 0.7.8-0.11.7 builds; the friendly version is `0.{major}.{minor}`
93    /// (the stripped patch is a hotfix marker the friendly version does not carry).
94    /// `major` of 0 (older `0.x`) and >= 12 (the post-rename scheme) are already friendly.
95    #[cfg(feature = "parsing")]
96    fn friendly_from_account_def_parts(major: u32, minor: u32, patch: u32) -> (u32, u32, u32) {
97        if (1..=11).contains(&major) { (0, major, minor) } else { (major, minor, patch) }
98    }
99
100    pub fn to_path(&self) -> String {
101        format!("{}.{}.{}", self.major, self.minor, self.patch)
102    }
103
104    /// A base version `(major, minor, patch)` with no build component (`build`
105    /// is `None`). Useful for keying version-gated tables, where entries take
106    /// effect at a friendly version regardless of build. Compare against a full
107    /// version with [`Self::is_at_least`], which ignores the build field.
108    pub const fn base(major: u32, minor: u32, patch: u32) -> Version {
109        Version { major, minor, patch, build: None }
110    }
111
112    /// Whether the base `major.minor.patch` versions are equal, ignoring the build
113    /// entirely. Unlike [`Self::matches`], two *different* concrete builds of the same
114    /// base version compare equal here. Pairs with [`Self::base`]; use it to test
115    /// friendly-version equality when the builds may legitimately differ.
116    pub fn base_eq(&self, other: &Version) -> bool {
117        (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
118    }
119
120    /// Whether this version matches `other` for relaxed version-gating, where the build
121    /// is an optional refinement of the friendly `major.minor.patch`. The friendly parts
122    /// must be equal; the build narrows the match only when BOTH sides specify one:
123    /// `15.4.0` build N matches `15.4.0` with no build (and vice versa), but two different
124    /// concrete builds of the same friendly version do not match. Not transitive - use `==`
125    /// for strict equality.
126    pub fn matches(&self, other: &Version) -> bool {
127        self.base_eq(other)
128            && match (self.build, other.build) {
129                (Some(a), Some(b)) => a == b,
130                _ => true,
131            }
132    }
133
134    pub fn is_at_least(&self, other: &Version) -> bool {
135        if self.major > other.major {
136            true
137        } else if self.major < other.major {
138            false
139        } else if self.minor > other.minor {
140            true
141        } else if self.minor < other.minor {
142            false
143        } else {
144            self.patch >= other.patch
145        }
146    }
147}
148
149#[cfg(test)]
150mod test {
151    use super::*;
152
153    fn assert_older_newer(older: Version, newer: Version) {
154        assert!(newer.is_at_least(&older));
155        assert!(newer.is_at_least(&newer));
156        assert!(!older.is_at_least(&newer));
157    }
158
159    #[test]
160    fn different_patch() {
161        let older = Version::from_client_exe("0,10,9,0");
162        let newer = Version::from_client_exe("0,10,10,0");
163        assert_older_newer(older, newer);
164    }
165
166    #[test]
167    fn different_minor() {
168        let older = Version::from_client_exe("0,10,9,0");
169        let newer = Version::from_client_exe("0,11,0,0");
170        assert_older_newer(older, newer);
171    }
172
173    #[test]
174    fn different_major() {
175        let older = Version::from_client_exe("0,11,5,0");
176        let newer = Version::from_client_exe("1,0,0,0");
177        assert_older_newer(older, newer);
178    }
179
180    /// The fallible parse is what the replay-opening path uses, because opening
181    /// runs on the UI thread and a malformed or truncated header must not take
182    /// the app down with it.
183    #[test]
184    fn try_from_client_exe_rejects_a_malformed_version_instead_of_panicking() {
185        assert_eq!(Version::try_from_client_exe(""), None, "an empty header has no parts");
186        assert_eq!(Version::try_from_client_exe("0,10,9"), None, "three parts is not four");
187        assert_eq!(Version::try_from_client_exe("0,10,9,0,1"), None, "five parts is not four");
188        assert_eq!(Version::try_from_client_exe("0,ten,9,0"), None, "a non-numeric part is not a version");
189        assert_eq!(Version::try_from_client_exe("0,-1,9,0"), None, "a negative part is not a version");
190    }
191
192    /// A `0` build field is what the pre-0.10 era records. It parses, and the
193    /// version it produces carries no build rather than a build of zero.
194    #[test]
195    fn try_from_client_exe_reads_a_zero_build_as_no_build() {
196        let version = Version::try_from_client_exe("0,9,4,0").expect("a well-formed header parses");
197        assert_eq!((version.major, version.minor, version.patch), (0, 9, 4));
198        assert_eq!(version.build_number(), None);
199    }
200
201    #[test]
202    fn try_from_client_exe_agrees_with_the_panicking_form_on_well_formed_input() {
203        for raw in ["0,9,4,0", "0,11,4,5624555", "15,4,0,11965230"] {
204            assert_eq!(Version::try_from_client_exe(raw), Some(Version::from_client_exe(raw)), "input {raw}");
205        }
206    }
207
208    #[test]
209    fn matches_build_optional_refinement() {
210        let no_build = Version::base(15, 4, 0);
211        let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
212        let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
213
214        // Same friendly + one side has no build -> matches (both directions).
215        assert!(no_build.matches(&build_a));
216        assert!(build_a.matches(&no_build));
217
218        // Same friendly + both builds equal -> matches.
219        assert!(build_a.matches(&build_a));
220
221        // Same friendly + both builds differ -> does NOT match.
222        assert!(!build_a.matches(&build_b));
223        assert!(!build_b.matches(&build_a));
224
225        // Different friendly -> does NOT match regardless of build.
226        let other_friendly = Version { major: 15, minor: 3, patch: 0, build: NonZeroU32::new(100) };
227        assert!(!build_a.matches(&other_friendly));
228        assert!(!other_friendly.matches(&build_a));
229        assert!(!no_build.matches(&Version::base(15, 3, 0)));
230    }
231
232    #[test]
233    fn base_eq_ignores_build() {
234        let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
235        let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
236        // Same friendly, different builds -> base_eq true (where matches would be false).
237        assert!(build_a.base_eq(&build_b));
238        assert!(!build_a.matches(&build_b));
239        // Same friendly, one build None -> still equal.
240        assert!(build_a.base_eq(&Version::base(15, 4, 0)));
241        // Different friendly -> not equal.
242        assert!(!build_a.base_eq(&Version::base(15, 3, 0)));
243    }
244
245    #[cfg(feature = "parsing")]
246    #[test]
247    fn from_account_def_parses_version() {
248        let xml = r#"<root><Properties><curVersion_15_1_0_11965230></curVersion_15_1_0_11965230></Properties></root>"#;
249        let v = Version::from_account_def(xml).unwrap();
250        assert_eq!(v.major, 15);
251        assert_eq!(v.minor, 1);
252        assert_eq!(v.patch, 0);
253        assert_eq!(v.build, NonZeroU32::new(11965230));
254    }
255
256    #[cfg(feature = "parsing")]
257    #[test]
258    fn from_account_def_release_prefix_normalizes_stripped_version() {
259        // WG's Account.def wrote `release_11_4_0` for friendly version 0.11.4
260        // (build 5624555); the exe and replay clientVersionFromExe both report
261        // `0,11,4`, so the detector must normalize to match.
262        let xml = r#"<root><Properties><curVersion_release_11_4_0_5624555></curVersion_release_11_4_0_5624555></Properties></root>"#;
263        let v = Version::from_account_def(xml).unwrap();
264        assert_eq!((v.major, v.minor, v.patch), (0, 11, 4));
265        assert_eq!(v.build, NonZeroU32::new(5624555));
266    }
267
268    #[cfg(feature = "parsing")]
269    #[test]
270    fn from_account_def_stripped_versions_match_replay_header() {
271        // Each case is (Account.def stripped triple, friendly version the replay reports).
272        for (tag, build, want) in [
273            ("curVersion_release_11_0_0_5045210", 5045210u32, (0, 11, 0)),
274            ("curVersion_release_7_11_0_1167524", 1167524, (0, 7, 11)),
275            ("curVersion_release_10_0_0_3343484", 3343484, (0, 10, 0)),
276        ] {
277            let xml = format!("<root><{tag}></{tag}></root>");
278            let v = Version::from_account_def(&xml).unwrap();
279            assert_eq!((v.major, v.minor, v.patch), want, "tag {tag}");
280            assert_eq!(v.build, NonZeroU32::new(build), "tag {tag}");
281        }
282    }
283
284    #[cfg(feature = "parsing")]
285    #[test]
286    fn from_account_def_friendly_versions_untouched() {
287        // major 0 (older 0.x, here the 5-part `0_11_8_0` format) and major >= 12
288        // (post-rename scheme) are already friendly and must not be shifted.
289        let xml = r#"<root><curVersion_0_11_8_0_6223574></curVersion_0_11_8_0_6223574></root>"#;
290        let v = Version::from_account_def(xml).unwrap();
291        assert_eq!((v.major, v.minor, v.patch), (0, 11, 8));
292        let xml = r#"<root><curVersion_12_0_0_6775398></curVersion_12_0_0_6775398></root>"#;
293        let v = Version::from_account_def(xml).unwrap();
294        assert_eq!((v.major, v.minor, v.patch), (12, 0, 0));
295    }
296
297    #[cfg(feature = "parsing")]
298    #[test]
299    fn from_account_def_legacy_5part_version() {
300        let xml = r#"<root><Properties><curVersion_Release_0_6_13_0_296659></curVersion_Release_0_6_13_0_296659></Properties></root>"#;
301        let v = Version::from_account_def(xml).unwrap();
302        assert_eq!(v.major, 0);
303        assert_eq!(v.minor, 6);
304        assert_eq!(v.patch, 13);
305        assert_eq!(v.build, NonZeroU32::new(296659));
306    }
307
308    #[cfg(feature = "parsing")]
309    #[test]
310    fn from_account_def_returns_none_on_missing() {
311        let xml = r#"<root><Properties><someOtherNode/></Properties></root>"#;
312        assert!(Version::from_account_def(xml).is_none());
313    }
314}