Skip to main content

wows_data_mgr/
builds.rs

1//! Master builds index (`builds.toml`) and per-build metadata.
2//!
3//! The builds index lives at `{dump_base}/builds.toml` and tracks all dumped
4//! game versions. Per-build metadata lives in `{build_dir}/metadata.toml` and
5//! includes file hashes for content-addressed storage management.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13// -- Master builds index (builds.toml) --
14
15/// Top-level index of all dumped builds.
16#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct BuildsIndex {
18    #[serde(default)]
19    pub builds: Vec<BuildEntry>,
20}
21
22/// A single dumped build entry.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BuildEntry {
25    pub version: String,
26    pub build: u32,
27    pub dir: String,
28    pub dumped_at: String,
29}
30
31impl BuildsIndex {
32    /// Load from disk. Returns an empty index if the file doesn't exist.
33    pub fn load(path: &Path) -> Self {
34        std::fs::read_to_string(path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default()
35    }
36
37    /// Save to disk. Uses write-to-temp-then-rename for atomicity.
38    pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
39        use rootcause::prelude::*;
40        let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize builds.toml")?;
41        if let Some(parent) = path.parent() {
42            std::fs::create_dir_all(parent)
43                .attach_with(|| format!("Failed to create directory {}", parent.display()))?;
44        }
45        let tmp = path.with_extension("toml.tmp");
46        std::fs::write(&tmp, &contents).attach_with(|| format!("Failed to write {}", tmp.display()))?;
47        std::fs::rename(&tmp, path)
48            .attach_with(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
49        Ok(())
50    }
51
52    /// Add or update an entry. If a build with the same number exists, it's replaced.
53    pub fn upsert(&mut self, entry: BuildEntry) {
54        if let Some(existing) = self.builds.iter_mut().find(|e| e.build == entry.build) {
55            *existing = entry;
56        } else {
57            self.builds.push(entry);
58        }
59        self.builds.sort_by_key(|e| e.build);
60    }
61
62    /// Remove a build entry. Returns the removed entry if found.
63    pub fn remove_build(&mut self, build: u32) -> Option<BuildEntry> {
64        let idx = self.builds.iter().position(|e| e.build == build)?;
65        Some(self.builds.remove(idx))
66    }
67
68    /// Find an entry by exact build number.
69    pub fn find_by_build(&self, build: u32) -> Option<&BuildEntry> {
70        self.builds.iter().find(|e| e.build == build)
71    }
72
73    /// Find all entries matching a version prefix.
74    /// e.g. "15.2.0" matches all builds with that version, regardless of build number.
75    pub fn find_by_version(&self, version_query: &str) -> Vec<&BuildEntry> {
76        self.builds.iter().filter(|e| crate::manifest::version_matches(&e.version, version_query)).collect()
77    }
78
79    /// Resolve a build number to a dump entry.
80    ///
81    /// 1. Try exact build match
82    /// 2. If no exact match and `target_version` is provided, find builds with
83    ///    the same `major.minor.patch` and pick the closest build number
84    ///
85    /// Returns `(entry, is_exact_match)`.
86    pub fn resolve_build(&self, target_build: u32, target_version: Option<&str>) -> Option<(&BuildEntry, bool)> {
87        // Exact match
88        if let Some(entry) = self.find_by_build(target_build) {
89            return Some((entry, true));
90        }
91
92        // Version-based fallback
93        if let Some(version) = target_version {
94            let candidates = self.find_by_version(version);
95            if !candidates.is_empty() {
96                let closest =
97                    candidates.iter().min_by_key(|e| (e.build as i64 - target_build as i64).unsigned_abs()).unwrap();
98                return Some((closest, false));
99            }
100        }
101
102        None
103    }
104}
105
106// -- Per-build metadata (metadata.toml) --
107
108/// Enhanced per-build metadata with file hashes for CAS management.
109#[derive(Debug, Default, Serialize, Deserialize)]
110pub struct BuildMetadata {
111    pub version: String,
112    pub build: u32,
113    /// VFS file path -> CAS hash. Only present in new-format dumps.
114    #[serde(default)]
115    pub files: BTreeMap<String, String>,
116    /// Build-relative path -> CAS hash for derived artifacts (the rkyv game
117    /// params blob and the compressed copies fetched by web clients). Kept
118    /// separate from `files`, which tracks the extracted `vfs/` tree.
119    #[serde(default)]
120    pub derived: BTreeMap<String, String>,
121}
122
123impl BuildMetadata {
124    /// Load from disk. Returns None if the file doesn't exist or can't be parsed.
125    pub fn load(path: &Path) -> Option<Self> {
126        let contents = std::fs::read_to_string(path).ok()?;
127        toml::from_str(&contents).ok()
128    }
129
130    /// Save to disk.
131    pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
132        use rootcause::prelude::*;
133        let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize metadata.toml")?;
134        std::fs::write(path, &contents).attach_with(|| format!("Failed to write {}", path.display()))?;
135        Ok(())
136    }
137
138    /// Whether this metadata has CAS file hashes (new format).
139    pub fn has_file_hashes(&self) -> bool {
140        !self.files.is_empty()
141    }
142
143    /// Collect all unique CAS hashes referenced by this build, across both the
144    /// extracted `vfs/` tree and the derived artifacts.
145    pub fn referenced_hashes(&self) -> std::collections::HashSet<String> {
146        self.files.values().chain(self.derived.values()).cloned().collect()
147    }
148}
149
150/// Maximum number of file paths named in a [`CorruptObject`] message. One
151/// object can back hundreds of files; the full list belongs in the log, not in
152/// a string that ends up in a toast.
153const NAMED_FILES: usize = 3;
154
155/// Maximum number of characters those paths may occupy between them.
156///
157/// A cap on how many paths are named is not a cap on how long the message gets:
158/// measured over `15.6.0_12830008` (4074 paths) a path is 49 characters at the
159/// median, 88 at the 99th percentile and 101 at the longest, so "three paths"
160/// is anywhere from 150 to 300 characters of message. Paths are named whole or
161/// not at all -- half a path identifies nothing -- and the count in the tail
162/// accounts for every one left out.
163const NAMED_FILES_BUDGET: usize = 160;
164
165/// The length a [`CorruptObject`] message stays within, whatever it is handed.
166///
167/// This holds for any file list, and for a build number up to [`u32::MAX`], a
168/// version string up to 20 characters and the 20-character digests this crate
169/// produces. The toast that shows it renders the enclosing report rather than
170/// this string alone, so what the user sees also carries the report's tree
171/// glyphs and its location attachment on top of this.
172pub const MAX_MESSAGE_CHARS: usize = 400;
173
174/// What separates named paths in the message.
175const FILE_SEPARATOR: &str = ", ";
176
177/// A content object whose bytes do not hash to the name it is stored under,
178/// attributed to the build that references it and the files it backs.
179///
180/// A hash is 20 hex characters and says nothing on its own. Carrying the build,
181/// the version and the referencing paths is what makes the failure reportable.
182///
183/// Its [`Display`](std::fmt::Display) is what reaches a toast, and renders
184/// within [`MAX_MESSAGE_CHARS`] however many paths the object backs.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CorruptObject {
187    pub build: u32,
188    pub version: String,
189    /// The name the object is stored under, which is the hash its bytes must
190    /// reproduce.
191    pub hash: String,
192    /// What the bytes actually hashed to.
193    pub actual: String,
194    /// Every path in the build that reads through this object. Goes to the log
195    /// in full; [`Display`](std::fmt::Display) names at most [`NAMED_FILES`] of
196    /// them and counts the rest.
197    pub files: Vec<String>,
198}
199
200impl CorruptObject {
201    /// Attribute a hash mismatch to a build by reverse-mapping the hash through
202    /// the build's metadata, which names every path the object backs.
203    pub fn attribute(entry: &BuildEntry, metadata: &BuildMetadata, hash: &str, actual: &str) -> Self {
204        let files: Vec<String> = metadata
205            .files
206            .iter()
207            .chain(metadata.derived.iter())
208            .filter(|(_, referenced)| referenced.as_str() == hash)
209            .map(|(path, _)| path.clone())
210            .collect();
211        Self {
212            build: entry.build,
213            version: entry.version.clone(),
214            hash: hash.to_string(),
215            actual: actual.to_string(),
216            files,
217        }
218    }
219
220    /// The full list of referencing paths, for the log.
221    pub fn all_files(&self) -> String {
222        self.files.join(FILE_SEPARATOR)
223    }
224
225    /// The paths the message names, and how many it leaves out. Whole paths
226    /// only, within both the count cap and the character budget.
227    fn named_files(&self) -> (Vec<&str>, usize) {
228        let mut named: Vec<&str> = Vec::new();
229        let mut used = 0;
230        for file in self.files.iter().take(NAMED_FILES) {
231            let separator = if named.is_empty() { 0 } else { FILE_SEPARATOR.len() };
232            if used + separator + file.len() > NAMED_FILES_BUDGET {
233                break;
234            }
235            used += separator + file.len();
236            named.push(file.as_str());
237        }
238        let rest = self.files.len() - named.len();
239        (named, rest)
240    }
241}
242
243impl std::fmt::Display for CorruptObject {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        write!(
246            f,
247            "build {} ({}) has a corrupt content object: {} hashed to {}. ",
248            self.build, self.version, self.hash, self.actual
249        )?;
250        match self.named_files() {
251            (named, 0) if named.is_empty() => write!(f, "No file in the build's metadata references it. ")?,
252            (named, rest) if named.is_empty() => {
253                write!(f, "It backs {rest} file(s) whose paths are too long to name here. ")?
254            }
255            (named, 0) => write!(f, "It backs {}. ", named.join(FILE_SEPARATOR))?,
256            (named, rest) => write!(f, "It backs {} and {rest} more. ", named.join(FILE_SEPARATOR))?,
257        }
258        write!(f, "Retrying will not help: the data is corrupt at rest, and the build needs re-publishing.")
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn builds_index_round_trip() {
268        let dir = tempfile::tempdir().unwrap();
269        let path = dir.path().join("builds.toml");
270
271        let mut index = BuildsIndex::default();
272        index.upsert(BuildEntry {
273            version: "15.1.0".into(),
274            build: 11965230,
275            dir: "15.1.0_11965230".into(),
276            dumped_at: "2025-06-15T10:00:00Z".into(),
277        });
278        index.upsert(BuildEntry {
279            version: "15.2.0".into(),
280            build: 12100000,
281            dir: "15.2.0_12100000".into(),
282            dumped_at: "2025-07-01T14:00:00Z".into(),
283        });
284
285        index.save(&path).unwrap();
286        let loaded = BuildsIndex::load(&path);
287        assert_eq!(loaded.builds.len(), 2);
288        assert_eq!(loaded.builds[0].build, 11965230);
289    }
290
291    #[test]
292    fn resolve_exact_match() {
293        let mut index = BuildsIndex::default();
294        index.upsert(BuildEntry {
295            version: "15.2.0".into(),
296            build: 12100000,
297            dir: "15.2.0_12100000".into(),
298            dumped_at: String::new(),
299        });
300
301        let (entry, exact) = index.resolve_build(12100000, None).unwrap();
302        assert!(exact);
303        assert_eq!(entry.build, 12100000);
304    }
305
306    #[test]
307    fn resolve_version_fallback() {
308        let mut index = BuildsIndex::default();
309        index.upsert(BuildEntry {
310            version: "15.2.0".into(),
311            build: 12100000,
312            dir: "15.2.0_12100000".into(),
313            dumped_at: String::new(),
314        });
315
316        // Different build but same version (e.g. CN server)
317        let (entry, exact) = index.resolve_build(12100500, Some("15.2.0")).unwrap();
318        assert!(!exact);
319        assert_eq!(entry.build, 12100000);
320    }
321
322    #[test]
323    fn resolve_no_match() {
324        let index = BuildsIndex::default();
325        assert!(index.resolve_build(99999, Some("99.0.0")).is_none());
326    }
327
328    #[test]
329    fn metadata_round_trip() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("metadata.toml");
332
333        let mut meta = BuildMetadata {
334            version: "15.2.0".into(),
335            build: 12100000,
336            files: BTreeMap::new(),
337            derived: BTreeMap::new(),
338        };
339        meta.files.insert("gui/test.png".into(), "abcdef1234567890abcd".into());
340
341        meta.save(&path).unwrap();
342        let loaded = BuildMetadata::load(&path).unwrap();
343        assert_eq!(loaded.files.len(), 1);
344        assert!(loaded.has_file_hashes());
345    }
346
347    fn corrupt(files: &[&str]) -> CorruptObject {
348        CorruptObject {
349            build: 12506899,
350            version: "15.4.0".into(),
351            hash: "a24a46f62dc08fd95fc7".into(),
352            actual: "674dcbf6a9204c9fe942".into(),
353            files: files.iter().map(|f| f.to_string()).collect(),
354        }
355    }
356
357    /// A path of exactly `len` characters, unique per `index` and shaped like a
358    /// real one. Paths in `15.6.0_12830008` measure 49 at the median, 71 at p90,
359    /// 88 at p99 and 101 at the longest.
360    fn path_of(len: usize, index: usize) -> String {
361        let prefix = "res/content/gameplay/common/spaces/";
362        let tail = format!("/{index:05}.dds");
363        let filler = len.saturating_sub(prefix.len() + tail.len());
364        format!("{prefix}{}{tail}", "s".repeat(filler))
365    }
366
367    /// The fixture is p99-realistic (88 characters), because a fixture of short
368    /// synthetic paths makes a length assertion certify nothing: 28-character
369    /// paths render at 304 characters where the real archive's render at 433.
370    #[test]
371    fn a_corrupt_object_message_names_the_build_and_caps_the_file_list() {
372        let files: Vec<String> = (0..9).map(|i| path_of(88, i)).collect();
373        let err = corrupt(&files.iter().map(String::as_str).collect::<Vec<_>>());
374
375        let rendered = err.to_string();
376        assert_eq!(
377            rendered,
378            format!(
379                "build 12506899 (15.4.0) has a corrupt content object: a24a46f62dc08fd95fc7 hashed to \
380                 674dcbf6a9204c9fe942. It backs {} and 8 more. Retrying will not help: the data is corrupt at \
381                 rest, and the build needs re-publishing.",
382                files[0]
383            )
384        );
385        assert!(!rendered.contains("00001.dds"), "a second path of this length does not fit: {rendered}");
386        assert!(rendered.len() <= MAX_MESSAGE_CHARS, "got {}", rendered.len());
387    }
388
389    /// The bound is on the rendered string, not on how many paths went into it.
390    /// Every combination of path length and count the real archive can produce,
391    /// plus lengths well past anything it holds, and the widest build number
392    /// and version the fields can carry.
393    #[test]
394    fn a_corrupt_object_message_stays_within_its_length_bound() {
395        let mut worst = String::new();
396        for path_len in [28, 49, 71, 88, 101, 250, 4_000] {
397            for count in [1, 2, 3, 4, 9, 4_074] {
398                let files: Vec<String> = (0..count).map(|i| path_of(path_len, i)).collect();
399                let err = CorruptObject {
400                    build: u32::MAX,
401                    version: "15.6.0-preview-build".into(),
402                    hash: "a24a46f62dc08fd95fc7".into(),
403                    actual: "674dcbf6a9204c9fe942".into(),
404                    files,
405                };
406
407                let rendered = err.to_string();
408                if rendered.len() > worst.len() {
409                    worst = rendered;
410                }
411            }
412        }
413
414        assert!(worst.len() <= MAX_MESSAGE_CHARS, "the longest rendered at {}: {worst}", worst.len());
415    }
416
417    /// A truncated path identifies nothing, so a path that does not fit is
418    /// dropped rather than cut, and the count still accounts for it.
419    #[test]
420    fn a_path_too_long_for_the_budget_is_dropped_whole_and_still_counted() {
421        let long = path_of(4_000, 0);
422        let err = corrupt(&[long.as_str(), "res/b.xml"]);
423
424        let rendered = err.to_string();
425        assert!(rendered.contains("It backs 2 file(s) whose paths are too long to name here."), "{rendered}");
426        assert!(!rendered.contains("res/content/gameplay"), "no partial path may appear: {rendered}");
427    }
428
429    /// An implementation that always appends the tail says "and 0 more".
430    #[test]
431    fn three_or_fewer_files_are_all_named_with_no_more_suffix() {
432        let rendered = corrupt(&["content/GameParams.data", "gui/ribbons.png"]).to_string();
433
434        assert_eq!(
435            rendered,
436            "build 12506899 (15.4.0) has a corrupt content object: a24a46f62dc08fd95fc7 hashed to \
437             674dcbf6a9204c9fe942. It backs content/GameParams.data, gui/ribbons.png. Retrying will not help: \
438             the data is corrupt at rest, and the build needs re-publishing."
439        );
440        assert!(!rendered.contains("more"), "no truncation tail belongs here: {rendered}");
441    }
442
443    /// The log gets what the message drops.
444    #[test]
445    fn the_full_file_list_survives_for_the_log() {
446        let files: Vec<String> = (0..9).map(|i| format!("res/spaces/s{i}/space.settings")).collect();
447        let err = corrupt(&files.iter().map(String::as_str).collect::<Vec<_>>());
448
449        assert_eq!(err.all_files(), files.join(", "));
450        assert!(err.all_files().contains("res/spaces/s8/space.settings"));
451    }
452
453    /// The reverse lookup spans both the extracted tree and derived artifacts,
454    /// and names only the paths that actually read through the bad object.
455    #[test]
456    fn attribution_names_every_path_backed_by_the_hash() {
457        let entry = BuildEntry {
458            version: "15.4.0".into(),
459            build: 12506899,
460            dir: "15.4.0_12506899".into(),
461            dumped_at: String::new(),
462        };
463        let mut metadata = BuildMetadata { version: "15.4.0".into(), build: 12506899, ..Default::default() };
464        metadata.files.insert("res/a.xml".into(), "a24a46f62dc08fd95fc7".into());
465        metadata.files.insert("res/b.xml".into(), "a24a46f62dc08fd95fc7".into());
466        metadata.files.insert("res/other.xml".into(), "11111111111111111111".into());
467        metadata.derived.insert("GameParams.rkyv".into(), "a24a46f62dc08fd95fc7".into());
468
469        let err = CorruptObject::attribute(&entry, &metadata, "a24a46f62dc08fd95fc7", "674dcbf6a9204c9fe942");
470
471        assert_eq!(err.files, vec!["res/a.xml", "res/b.xml", "GameParams.rkyv"]);
472        assert_eq!(err.build, 12506899);
473        assert_eq!(err.version, "15.4.0");
474    }
475
476    /// A hash no path claims must not render an empty file list.
477    #[test]
478    fn an_unreferenced_hash_still_renders_a_sensible_message() {
479        let rendered = corrupt(&[]).to_string();
480
481        assert!(rendered.contains("No file in the build's metadata references it"), "{rendered}");
482        assert!(!rendered.contains("It backs"), "{rendered}");
483    }
484
485    #[test]
486    fn old_format_metadata_loads() {
487        let dir = tempfile::tempdir().unwrap();
488        let path = dir.path().join("metadata.toml");
489        std::fs::write(&path, "version = \"15.1.0\"\nbuild = 11965230\n").unwrap();
490
491        let loaded = BuildMetadata::load(&path).unwrap();
492        assert_eq!(loaded.version, "15.1.0");
493        assert!(!loaded.has_file_hashes());
494    }
495}