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    pub fn is_at_least(&self, other: &Version) -> bool {
113        if self.major > other.major {
114            true
115        } else if self.major < other.major {
116            false
117        } else if self.minor > other.minor {
118            true
119        } else if self.minor < other.minor {
120            false
121        } else {
122            self.patch >= other.patch
123        }
124    }
125}
126
127#[cfg(test)]
128mod test {
129    use super::*;
130
131    fn assert_older_newer(older: Version, newer: Version) {
132        assert!(newer.is_at_least(&older));
133        assert!(newer.is_at_least(&newer));
134        assert!(!older.is_at_least(&newer));
135    }
136
137    #[test]
138    fn different_patch() {
139        let older = Version::from_client_exe("0,10,9,0");
140        let newer = Version::from_client_exe("0,10,10,0");
141        assert_older_newer(older, newer);
142    }
143
144    #[test]
145    fn different_minor() {
146        let older = Version::from_client_exe("0,10,9,0");
147        let newer = Version::from_client_exe("0,11,0,0");
148        assert_older_newer(older, newer);
149    }
150
151    #[test]
152    fn different_major() {
153        let older = Version::from_client_exe("0,11,5,0");
154        let newer = Version::from_client_exe("1,0,0,0");
155        assert_older_newer(older, newer);
156    }
157
158    #[cfg(feature = "parsing")]
159    #[test]
160    fn from_account_def_parses_version() {
161        let xml = r#"<root><Properties><curVersion_15_1_0_11965230></curVersion_15_1_0_11965230></Properties></root>"#;
162        let v = Version::from_account_def(xml).unwrap();
163        assert_eq!(v.major, 15);
164        assert_eq!(v.minor, 1);
165        assert_eq!(v.patch, 0);
166        assert_eq!(v.build, NonZeroU32::new(11965230));
167    }
168
169    #[cfg(feature = "parsing")]
170    #[test]
171    fn from_account_def_release_prefix_normalizes_stripped_version() {
172        // WG's Account.def wrote `release_11_4_0` for friendly version 0.11.4
173        // (build 5624555); the exe and replay clientVersionFromExe both report
174        // `0,11,4`, so the detector must normalize to match.
175        let xml = r#"<root><Properties><curVersion_release_11_4_0_5624555></curVersion_release_11_4_0_5624555></Properties></root>"#;
176        let v = Version::from_account_def(xml).unwrap();
177        assert_eq!((v.major, v.minor, v.patch), (0, 11, 4));
178        assert_eq!(v.build, NonZeroU32::new(5624555));
179    }
180
181    #[cfg(feature = "parsing")]
182    #[test]
183    fn from_account_def_stripped_versions_match_replay_header() {
184        // Each case is (Account.def stripped triple, friendly version the replay reports).
185        for (tag, build, want) in [
186            ("curVersion_release_11_0_0_5045210", 5045210u32, (0, 11, 0)),
187            ("curVersion_release_7_11_0_1167524", 1167524, (0, 7, 11)),
188            ("curVersion_release_10_0_0_3343484", 3343484, (0, 10, 0)),
189        ] {
190            let xml = format!("<root><{tag}></{tag}></root>");
191            let v = Version::from_account_def(&xml).unwrap();
192            assert_eq!((v.major, v.minor, v.patch), want, "tag {tag}");
193            assert_eq!(v.build, NonZeroU32::new(build), "tag {tag}");
194        }
195    }
196
197    #[cfg(feature = "parsing")]
198    #[test]
199    fn from_account_def_friendly_versions_untouched() {
200        // major 0 (older 0.x, here the 5-part `0_11_8_0` format) and major >= 12
201        // (post-rename scheme) are already friendly and must not be shifted.
202        let xml = r#"<root><curVersion_0_11_8_0_6223574></curVersion_0_11_8_0_6223574></root>"#;
203        let v = Version::from_account_def(xml).unwrap();
204        assert_eq!((v.major, v.minor, v.patch), (0, 11, 8));
205        let xml = r#"<root><curVersion_12_0_0_6775398></curVersion_12_0_0_6775398></root>"#;
206        let v = Version::from_account_def(xml).unwrap();
207        assert_eq!((v.major, v.minor, v.patch), (12, 0, 0));
208    }
209
210    #[cfg(feature = "parsing")]
211    #[test]
212    fn from_account_def_legacy_5part_version() {
213        let xml = r#"<root><Properties><curVersion_Release_0_6_13_0_296659></curVersion_Release_0_6_13_0_296659></Properties></root>"#;
214        let v = Version::from_account_def(xml).unwrap();
215        assert_eq!(v.major, 0);
216        assert_eq!(v.minor, 6);
217        assert_eq!(v.patch, 13);
218        assert_eq!(v.build, NonZeroU32::new(296659));
219    }
220
221    #[cfg(feature = "parsing")]
222    #[test]
223    fn from_account_def_returns_none_on_missing() {
224        let xml = r#"<root><Properties><someOtherNode/></Properties></root>"#;
225        assert!(Version::from_account_def(xml).is_none());
226    }
227}