Skip to main content

wows_data_mgr/
dump.rs

1use std::collections::BTreeMap;
2use std::io::Read;
3use std::path::Path;
4use std::sync::Arc;
5
6use indicatif::ProgressBar;
7use indicatif::ProgressStyle;
8use rootcause::prelude::*;
9use wowsunpack::game_data;
10use wowsunpack::game_params::cache;
11use wowsunpack::game_params::provider::GameMetadataProvider;
12use wowsunpack::game_params::types::GameParamProvider;
13use wowsunpack::game_params::types::Param;
14use wowsunpack::vfs::VfsFileType;
15use wowsunpack::vfs::VfsPath;
16
17use crate::builds::BuildEntry;
18use crate::builds::BuildMetadata;
19use crate::builds::BuildsIndex;
20use crate::builds::CorruptObject;
21use crate::cas;
22
23/// VFS directories dumped in their entirety. `gui/battle_hud` is dumped whole
24/// (rather than enumerating subdirectories) so newly-added HUD icons are always
25/// captured without code changes.
26const VFS_DIRS: &[&str] = &[
27    "gui/fla/minimap",
28    "gui/battle_hud",
29    "gui/consumables",
30    "gui/powerups/drops",
31    "gui/fonts",
32    "gui/data/constants",
33    "gui/ships_silhouettes",
34    "gui/ribbons",
35    "gui/achievements",
36    "gui/nation_flags",
37    "gui/crew_commander/skills",
38    "gui/modernization_icons",
39    "gui/signal_flags",
40    "scripts/entity_defs",
41];
42
43/// Directories whose absence (zero files extracted) makes the dump unusable.
44/// Kept deliberately small: most icon directories are version-dependent (added
45/// in later game versions), so their absence is tolerated with a warning.
46const REQUIRED_NONEMPTY_DIRS: &[&str] = &["scripts/entity_defs"];
47
48/// Individual VFS files required beyond the directory dumps. A dump missing any
49/// of these can't parse replays, so their absence is fatal.
50const REQUIRED_VFS_FILES: &[&str] = &["content/GameParams.data", "scripts/entities.xml"];
51
52/// Files to extract per map from `spaces/<map>/`.
53const MAP_FILES_SPACES: &[&str] = &["minimap.png", "minimap_water.png", "space.settings"];
54
55/// Files to extract per map from `content/gameplay/<map>/`.
56const MAP_FILES_GAMEPLAY: &[&str] = &["space.settings"];
57
58/// Glob patterns covering every VFS path the dump extracts.
59///
60/// Feed these to `wowsunpack pkgs` to resolve the minimal set of `.pkg` files
61/// to download for a build — letting callers fetch all idx (small) first, then
62/// only the packages actually required, instead of the full multi-GiB depots.
63pub fn required_path_globs() -> Vec<String> {
64    let mut globs = Vec::new();
65    for dir in VFS_DIRS {
66        // `wowsunpack pkgs` matches with the glob crate's default options, where
67        // `*` spans `/`, so `{dir}/*` matches every file under the tree.
68        globs.push(format!("{dir}/*"));
69    }
70    for file in REQUIRED_VFS_FILES {
71        globs.push((*file).to_string());
72    }
73    for name in MAP_FILES_SPACES {
74        globs.push(format!("spaces/*/{name}"));
75    }
76    for name in MAP_FILES_GAMEPLAY {
77        globs.push(format!("content/gameplay/*/{name}"));
78    }
79    globs
80}
81
82/// Returns the dump directory path for a given version and build.
83pub fn dump_dir(output_base: &Path, version_str: &str, build: u32) -> std::path::PathBuf {
84    output_base.join(format!("{version_str}_{build}"))
85}
86
87/// Check if a valid dump exists for the given version and build.
88pub fn dump_exists(output_base: &Path, version_str: &str, build: u32) -> bool {
89    dump_dir(output_base, version_str, build).join("metadata.toml").exists()
90}
91
92/// Dump game data with content-addressed deduplication.
93///
94/// VFS files are stored in `{output_base}/common/` by hash, with symlinks
95/// in the build's `vfs/` directory. Non-VFS files (game_params.rkyv, translations)
96/// are stored directly in the build directory.
97///
98/// When `progress` is `Some`, a CLI progress bar is updated during extraction.
99/// When `allow_existing` is true and a complete dump already exists, returns immediately.
100pub fn dump_renderer_data(
101    game_dir: &Path,
102    build: u32,
103    version_str: &str,
104    output_base: &Path,
105    progress: Option<&ProgressBar>,
106    allow_existing: bool,
107) -> Result<(), Report> {
108    let output_dir = dump_dir(output_base, version_str, build);
109    let vfs_dir = output_dir.join("vfs");
110    let cas_root = cas::cas_root(output_base);
111
112    if output_dir.join("metadata.toml").exists() {
113        if allow_existing {
114            return Ok(());
115        }
116        bail!("Output directory already exists: {}", output_dir.display());
117    }
118
119    // Clean up partial dumps
120    if output_dir.exists() {
121        std::fs::remove_dir_all(&output_dir)
122            .attach_with(|| format!("Failed to clean up partial dump at {}", output_dir.display()))?;
123    }
124
125    let vfs = game_data::build_game_vfs_for_build(game_dir, build).attach_with(|| "Failed to build game VFS")?;
126
127    // Extract VFS files through CAS
128    let mut file_hashes: BTreeMap<String, String> = BTreeMap::new();
129
130    let mut dir_counts: BTreeMap<&str, usize> = BTreeMap::new();
131    for dir in VFS_DIRS {
132        let count = extract_vfs_dir_cas(&vfs, dir, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
133        dir_counts.insert(dir, count);
134    }
135    let mut missing_files = Vec::new();
136    for file in REQUIRED_VFS_FILES {
137        if !extract_vfs_file_cas(&vfs, file, &vfs_dir, &cas_root, &mut file_hashes)? {
138            missing_files.push(*file);
139        }
140        if let Some(pb) = progress {
141            pb.inc(1);
142        }
143    }
144    let map_count =
145        extract_map_files_cas(&vfs, "spaces", MAP_FILES_SPACES, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
146    extract_map_files_cas(
147        &vfs,
148        "content/gameplay",
149        MAP_FILES_GAMEPLAY,
150        &vfs_dir,
151        &cas_root,
152        &mut file_hashes,
153        progress,
154    )?;
155
156    if let Some(pb) = progress {
157        pb.finish_and_clear();
158    }
159
160    // Fail loudly on an incomplete dump rather than silently shipping one that
161    // renders blank maps or can't parse replays. CAS objects already written
162    // are shared and harmless; only the (unregistered) build dir is discarded.
163    let mut problems = Vec::new();
164    if !missing_files.is_empty() {
165        problems.push(format!("missing required file(s): {}", missing_files.join(", ")));
166    }
167    for dir in REQUIRED_NONEMPTY_DIRS {
168        if dir_counts.get(dir).copied().unwrap_or(0) == 0 {
169            problems.push(format!("required directory '{dir}' extracted no files"));
170        }
171    }
172    if map_count == 0 {
173        problems.push(
174            "no map data extracted (spaces/*/minimap.png); the content depot's spaces packages are likely missing"
175                .to_string(),
176        );
177    }
178    if !problems.is_empty() {
179        let _ = std::fs::remove_dir_all(&output_dir);
180        bail!("Incomplete dump for build {build} ({version_str}): {}", problems.join("; "));
181    }
182    for dir in VFS_DIRS {
183        if dir_counts.get(dir).copied().unwrap_or(0) == 0 {
184            tracing::warn!("dump for build {build}: directory '{dir}' extracted no files");
185        }
186    }
187
188    std::fs::create_dir_all(&output_dir)
189        .attach_with(|| format!("Failed to create output directory {}", output_dir.display()))?;
190
191    dump_all_translations(game_dir, build, &output_dir)?;
192
193    // Fetch and store versioned constants (non-fatal)
194    #[cfg(feature = "constants")]
195    match crate::constants::ConstantsFetcher::new() {
196        Ok(fetcher) => {
197            write_constants_for_build(&output_dir, build, Some(version_str), &fetcher);
198        }
199        Err(e) => {
200            tracing::warn!("Could not initialize constants fetcher for build {build}: {e:?}");
201        }
202    }
203
204    // Write enhanced metadata with file hashes. The derived artifacts (rkyv
205    // blob, compressed copies) are generated and content-addressed by the same
206    // step the refresh-derived command uses, so dumps and refreshes agree.
207    let mut metadata =
208        BuildMetadata { version: version_str.to_string(), build, files: file_hashes, derived: BTreeMap::new() };
209    refresh_build_derived(&output_dir, &cas_root, &mut metadata)?;
210    metadata.save(&output_dir.join("metadata.toml"))?;
211
212    // Update master builds index
213    let builds_path = output_base.join("builds.toml");
214    let mut index = BuildsIndex::load(&builds_path);
215    index.upsert(BuildEntry {
216        version: version_str.to_string(),
217        build,
218        dir: format!("{version_str}_{build}"),
219        dumped_at: jiff::Zoned::now().to_string(),
220    });
221    index.save(&builds_path)?;
222
223    Ok(())
224}
225
226/// Add the assets an existing build is missing without re-extracting the data it
227/// already has. Extracts maps (and, with `with_gui`, the `gui/` asset dirs) from
228/// `game_dir` into the build's `vfs/`, then regenerates derived artifacts (the
229/// rkyv game-params blob, with the current parser) from the build's existing
230/// `GameParams.data`.
231///
232/// Unlike [`dump_renderer_data`], this never reads `content/GameParams.data`,
233/// `scripts/`, or other already-present data from the game install, so the caller
234/// only needs the `gui` and `spaces_*` packages on disk -- not the multi-gigabyte
235/// `basecontent` package whose `GameParams.data` the build already holds.
236///
237/// The build must already exist in `builds.toml`. Returns the number of maps
238/// extracted; errors if no maps were found.
239pub fn complete_build(game_dir: &Path, build: u32, output_base: &Path, with_gui: bool) -> Result<usize, Report> {
240    let index = BuildsIndex::load(&output_base.join("builds.toml"));
241    let entry = index
242        .find_by_build(build)
243        .ok_or_else(|| report!("build {build} is not in builds.toml; dump it normally first"))?
244        .clone();
245    let output_dir = output_base.join(&entry.dir);
246    let vfs_dir = output_dir.join("vfs");
247    let cas_root = cas::cas_root(output_base);
248    let meta_path = output_dir.join("metadata.toml");
249    let mut metadata =
250        BuildMetadata::load(&meta_path).ok_or_else(|| report!("{} has no readable metadata.toml", entry.dir))?;
251
252    let vfs = game_data::build_game_vfs_for_build(game_dir, build).attach_with(|| "Failed to build game VFS")?;
253
254    if with_gui {
255        // Only the `gui/` dirs live in the gui package; re-extracting other
256        // VFS_DIRS (e.g. scripts/entity_defs) would need packages we deliberately
257        // skip, and that data is already present in the build.
258        for dir in VFS_DIRS.iter().filter(|d| d.starts_with("gui")) {
259            extract_vfs_dir_cas(&vfs, dir, &vfs_dir, &cas_root, &mut metadata.files, None)?;
260        }
261    }
262
263    let map_count =
264        extract_map_files_cas(&vfs, "spaces", MAP_FILES_SPACES, &vfs_dir, &cas_root, &mut metadata.files, None)?;
265    extract_map_files_cas(
266        &vfs,
267        "content/gameplay",
268        MAP_FILES_GAMEPLAY,
269        &vfs_dir,
270        &cas_root,
271        &mut metadata.files,
272        None,
273    )?;
274    if map_count == 0 {
275        bail!("no maps extracted for build {build}; refusing to record an incomplete build");
276    }
277
278    // Regenerate derived artifacts from the build's existing GameParams.data,
279    // picking up the current parser. Needs no package downloads.
280    refresh_build_derived(&output_dir, &cas_root, &mut metadata)?;
281    metadata.save(&meta_path)?;
282    Ok(map_count)
283}
284
285/// Create a configured progress bar for CLI use.
286pub fn create_progress_bar(game_dir: &Path) -> Option<ProgressBar> {
287    let vfs = game_data::build_game_vfs(game_dir).ok()?;
288    let mut total_files = 0u64;
289    for dir in VFS_DIRS {
290        total_files += count_vfs_dir_files(&vfs, dir);
291    }
292    total_files += REQUIRED_VFS_FILES.len() as u64;
293    total_files += count_map_files(&vfs, "spaces", MAP_FILES_SPACES);
294    total_files += count_map_files(&vfs, "content/gameplay", MAP_FILES_GAMEPLAY);
295
296    let pb = ProgressBar::new(total_files);
297    pb.set_style(
298        ProgressStyle::default_bar()
299            .template("{msg} [{bar:40}] {pos}/{len}")
300            .expect("valid template")
301            .progress_chars("=> "),
302    );
303    pb.set_message("Extracting VFS");
304    Some(pb)
305}
306
307/// Remove a dumped build, cleaning up orphaned CAS objects.
308pub fn remove_build(output_base: &Path, target_build: u32) -> Result<(), Report> {
309    let builds_path = output_base.join("builds.toml");
310    let mut index = BuildsIndex::load(&builds_path);
311    let entry = index
312        .find_by_build(target_build)
313        .ok_or_else(|| report!("Build {target_build} not found in builds.toml"))?
314        .clone();
315
316    let target_dir = output_base.join(&entry.dir);
317    let target_meta = BuildMetadata::load(&target_dir.join("metadata.toml"));
318
319    // Collect hashes still in use by other builds (vfs tree + derived data)
320    let mut live_hashes = std::collections::HashSet::new();
321    for other in &index.builds {
322        if other.build == target_build {
323            continue;
324        }
325        if let Some(meta) = BuildMetadata::load(&output_base.join(&other.dir).join("metadata.toml")) {
326            live_hashes.extend(meta.referenced_hashes());
327        }
328    }
329
330    // Delete orphaned CAS objects
331    if let Some(meta) = target_meta {
332        let cas_root = cas::cas_root(output_base);
333        for hash in meta.referenced_hashes() {
334            if !live_hashes.contains(&hash) {
335                let path = cas::cas_path(&cas_root, &hash);
336                let _ = std::fs::remove_file(&path);
337            }
338        }
339        // Clean up empty fanout directories
340        let _ = cas::gc(&cas_root, &live_hashes);
341    }
342
343    // Remove build directory
344    if target_dir.exists() {
345        std::fs::remove_dir_all(&target_dir)
346            .attach_with(|| format!("Failed to remove build directory {}", target_dir.display()))?;
347    }
348
349    // Update builds index
350    index.remove_build(target_build);
351    index.save(&builds_path)?;
352
353    Ok(())
354}
355
356// -- Local sync (offline mirror of download_repo) --
357
358/// Which builds to copy when syncing from a local source dump base.
359pub enum SyncSelector {
360    /// Every build listed in the source `builds.toml`.
361    All,
362    /// Only the highest build number in the source.
363    Latest,
364    /// A single exact build number.
365    Build(u32),
366    /// All builds matching a `major.minor.patch` version string.
367    Version(String),
368}
369
370/// Outcome of copying one build during a sync.
371pub struct SyncedBuild {
372    pub build: u32,
373    pub version: String,
374    /// Whether content was copied (`false` means it was already present and skipped).
375    pub copied: bool,
376}
377
378/// Copy builds from a local source dump base into `output_base`, deduplicating
379/// against content already present in the destination CAS.
380///
381/// This is the offline analog of `download_repo::download_build`: it reconstructs
382/// each selected build's directory (vfs/derived symlinks, constants, metadata)
383/// from the source's content-addressed store with no network access, copying only
384/// the content objects the destination is missing. Useful for promoting a
385/// freshly-dumped build into the toolkit's data cache without publishing it.
386pub fn sync_from_local(
387    source_base: &Path,
388    output_base: &Path,
389    selector: &SyncSelector,
390    force: bool,
391) -> Result<Vec<SyncedBuild>, Report> {
392    if source_base == output_base {
393        bail!("source and destination are the same directory");
394    }
395    let source_index = BuildsIndex::load(&source_base.join("builds.toml"));
396    if source_index.builds.is_empty() {
397        bail!("no builds.toml entries found in source {}", source_base.display());
398    }
399
400    let entries: Vec<BuildEntry> = match selector {
401        SyncSelector::All => source_index.builds.clone(),
402        SyncSelector::Latest => {
403            let latest =
404                source_index.builds.iter().max_by_key(|e| e.build).ok_or_else(|| report!("source has no builds"))?;
405            vec![latest.clone()]
406        }
407        SyncSelector::Build(b) => {
408            let entry = source_index
409                .find_by_build(*b)
410                .ok_or_else(|| report!("build {b} not found in source {}", source_base.display()))?;
411            vec![entry.clone()]
412        }
413        SyncSelector::Version(v) => {
414            let matches: Vec<BuildEntry> = source_index.find_by_version(v).into_iter().cloned().collect();
415            if matches.is_empty() {
416                bail!("no builds matching version '{v}' in source {}", source_base.display());
417            }
418            matches
419        }
420    };
421
422    let mut synced = Vec::new();
423    for entry in &entries {
424        let copied = copy_build_from_local(source_base, output_base, entry, force)?;
425        synced.push(SyncedBuild { build: entry.build, version: entry.version.clone(), copied });
426    }
427    Ok(synced)
428}
429
430/// Copy one build's data from `source_base` into `output_base`. Returns `true`
431/// when content was copied, `false` when an existing complete copy was reused.
432fn copy_build_from_local(
433    source_base: &Path,
434    output_base: &Path,
435    entry: &BuildEntry,
436    force: bool,
437) -> Result<bool, Report> {
438    let src_cas = cas::cas_root(source_base);
439    let dst_cas = cas::cas_root(output_base);
440    let output_dir = output_base.join(&entry.dir);
441
442    // A complete copy already on disk only needs registering, unless forced.
443    if !force && output_dir.join("metadata.toml").exists() {
444        register_build(output_base, entry)?;
445        return Ok(false);
446    }
447
448    let src_dir = source_base.join(&entry.dir);
449    let meta_path = src_dir.join("metadata.toml");
450    let metadata = BuildMetadata::load(&meta_path)
451        .ok_or_else(|| report!("source build {} has no readable metadata.toml", entry.dir))?;
452
453    // Copy every referenced content object the destination doesn't already have,
454    // verifying each against its hash. This happens before touching the existing
455    // build directory so a failed copy (e.g. an inconsistent source) never
456    // destroys a good local copy. Objects land in the shared store; nothing is
457    // wired into the build until every object is present.
458    for hash in metadata.referenced_hashes() {
459        if cas::object_exists(&dst_cas, &hash) {
460            continue;
461        }
462        let src_obj = cas::cas_path(&src_cas, &hash);
463        let data =
464            std::fs::read(&src_obj).attach_with(|| format!("source content object {} missing", src_obj.display()))?;
465        let actual = cas::hash_bytes(&data);
466        if actual != hash {
467            bail!("source content object {hash} hashed to {actual}");
468        }
469        cas::store(&dst_cas, &data)?;
470    }
471
472    // All content is present; now clear any partial/stale directory and rebuild.
473    if output_dir.exists() {
474        std::fs::remove_dir_all(&output_dir)
475            .attach_with(|| format!("failed to clear destination build dir {}", output_dir.display()))?;
476    }
477
478    // Recreate the extracted vfs tree and derived artifacts as symlinks into the
479    // destination CAS.
480    let vfs_dir = output_dir.join("vfs");
481    for (rel, hash) in &metadata.files {
482        cas::link_file(&dst_cas, hash, &vfs_dir.join(rel))?;
483    }
484    for (rel, hash) in &metadata.derived {
485        cas::link_file(&dst_cas, hash, &output_dir.join(rel))?;
486    }
487
488    // Versioned constants, when present alongside the source build.
489    let src_constants = src_dir.join("constants.json");
490    if src_constants.exists() {
491        let bytes =
492            std::fs::read(&src_constants).attach_with(|| format!("failed to read {}", src_constants.display()))?;
493        let dest = output_dir.join("constants.json");
494        std::fs::create_dir_all(dest.parent().unwrap())?;
495        std::fs::write(&dest, &bytes).attach_with(|| format!("failed to write {}", dest.display()))?;
496    }
497
498    metadata.save(&output_dir.join("metadata.toml"))?;
499    register_build(output_base, entry)?;
500    Ok(true)
501}
502
503/// Add or update the build's entry in the destination `builds.toml`.
504fn register_build(output_base: &Path, entry: &BuildEntry) -> Result<(), Report> {
505    let builds_path = output_base.join("builds.toml");
506    let mut index = BuildsIndex::load(&builds_path);
507    index.upsert(entry.clone());
508    index.save(&builds_path)
509}
510
511// -- Translation dumping --
512
513fn dump_all_translations(game_dir: &Path, build: u32, output_dir: &Path) -> Result<(), Report> {
514    let texts_dir = game_dir.join("bin").join(build.to_string()).join("res/texts");
515    if !texts_dir.exists() {
516        tracing::warn!("Translations directory not found: {}", texts_dir.display());
517        return Ok(());
518    }
519    for entry in std::fs::read_dir(&texts_dir)
520        .attach_with(|| format!("Failed to read translations directory {}", texts_dir.display()))?
521        .flatten()
522    {
523        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
524            continue;
525        }
526        let lang = entry.file_name();
527        let mo_src = entry.path().join("LC_MESSAGES/global.mo");
528        if mo_src.exists() {
529            let mo_dest = output_dir.join("translations").join(&lang).join("LC_MESSAGES/global.mo");
530            std::fs::create_dir_all(mo_dest.parent().unwrap())?;
531            std::fs::copy(&mo_src, &mo_dest)?;
532        }
533    }
534    Ok(())
535}
536
537// -- CAS-aware extraction helpers --
538
539/// Read a VFS file into a buffer, store in CAS, and create a link in the build's vfs dir.
540fn store_and_link(
541    data: &[u8],
542    rel_path: &str,
543    vfs_dir: &Path,
544    cas_root: &Path,
545    file_hashes: &mut BTreeMap<String, String>,
546) -> Result<(), Report> {
547    let hash = cas::store(cas_root, data)?;
548    let link_path = vfs_dir.join(rel_path.trim_start_matches('/'));
549    cas::link_file(cas_root, &hash, &link_path)?;
550    file_hashes.insert(rel_path.trim_start_matches('/').to_string(), hash);
551    Ok(())
552}
553
554// -- Derived artifact generation (shared by dump and refresh-derived) --
555
556/// Convert `vfs_dir/content/GameParams.data` into a rkyv-encoded `Vec<Param>`
557/// using the current `wowsunpack` schema. Returns `None` when the source file
558/// is missing or the conversion fails (panic from a layout-incompatible older
559/// pickle, or serialization error). Diagnostics are logged via stderr.
560fn derive_game_params_rkyv(vfs_dir: &Path) -> Option<Vec<u8>> {
561    if !vfs_dir.join("content/GameParams.data").exists() {
562        return None;
563    }
564    let vfs = VfsPath::new(wowsunpack::vfs::PhysicalFS::new(vfs_dir));
565    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
566        let gmp = GameMetadataProvider::from_vfs(&vfs)?;
567        let params: Vec<Param> = gmp.params().iter().map(|p| Arc::unwrap_or_clone(Arc::clone(p))).collect();
568        cache::encode(&params).map_err(|e| report!("Failed to serialize: {e}"))
569    }));
570    match result {
571        Ok(Ok(bytes)) => Some(bytes),
572        Ok(Err(e)) => {
573            eprintln!("WARN: GameParams re-derivation failed for {}: {e:?}", vfs_dir.display());
574            None
575        }
576        Err(_) => {
577            eprintln!("WARN: GameParams re-derivation panicked for {} (incompatible pickle format)", vfs_dir.display(),);
578            None
579        }
580    }
581}
582
583/// Store `data` in the CAS and point `link_path` at it, replacing any file or
584/// symlink already there. Returns the content hash.
585fn store_and_relink(data: &[u8], link_path: &Path, cas_root: &Path) -> Result<String, Report> {
586    let hash = cas::store(cas_root, data)?;
587    let _ = std::fs::remove_file(link_path);
588    cas::link_file(cas_root, &hash, link_path)?;
589    Ok(hash)
590}
591
592/// Generate and content-address a build's derived artifacts: the rkyv game
593/// params blob, its zstd copy, and the English translation catalog's zstd copy.
594/// The rkyv blob is derived from `vfs/content/GameParams.data` against the
595/// current `wowsunpack::game_params::types` schema; the on-disk rkyv is only
596/// consulted as a fallback when the extracted vfs is missing or conversion
597/// fails. Each artifact is stored in the CAS, linked back into `build_dir`,
598/// and recorded in `metadata.derived`. Idempotent.
599pub fn refresh_build_derived(build_dir: &Path, cas_root: &Path, metadata: &mut BuildMetadata) -> Result<(), Report> {
600    metadata.derived.clear();
601
602    let rkyv_path = build_dir.join("game_params.rkyv");
603    let rkyv_bytes = derive_game_params_rkyv(&build_dir.join("vfs"));
604    let rkyv_bytes = match rkyv_bytes {
605        Some(b) => Some(b),
606        None if rkyv_path.exists() => {
607            Some(std::fs::read(&rkyv_path).attach_with(|| format!("Failed to read {}", rkyv_path.display()))?)
608        }
609        None => None,
610    };
611    if let Some(rkyv_bytes) = rkyv_bytes {
612        let hash = store_and_relink(&rkyv_bytes, &rkyv_path, cas_root)?;
613        metadata.derived.insert("game_params.rkyv".to_string(), hash);
614
615        let compressed =
616            ruzstd::encoding::compress_to_vec(rkyv_bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
617        let zst_path = build_dir.join("game_params.rkyv.zst");
618        let hash = store_and_relink(&compressed, &zst_path, cas_root)?;
619        metadata.derived.insert("game_params.rkyv.zst".to_string(), hash);
620    }
621
622    // Content-address the per-locale translation catalogs (raw .mo files copied
623    // from the game install). They are identical across many builds, so this
624    // deduplicates them into the shared store like every other asset.
625    let translations_dir = build_dir.join("translations");
626    if translations_dir.exists() {
627        for lang_entry in std::fs::read_dir(&translations_dir)
628            .attach_with(|| format!("Failed to read {}", translations_dir.display()))?
629            .flatten()
630        {
631            if !lang_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
632                continue;
633            }
634            let lang = lang_entry.file_name().to_string_lossy().into_owned();
635            let mo_rel = format!("translations/{lang}/LC_MESSAGES/global.mo");
636            let mo_path = build_dir.join(&mo_rel);
637            if !mo_path.exists() {
638                continue;
639            }
640            let bytes = std::fs::read(&mo_path).attach_with(|| format!("Failed to read {}", mo_path.display()))?;
641            let hash = store_and_relink(&bytes, &mo_path, cas_root)?;
642            metadata.derived.insert(mo_rel, hash);
643        }
644    }
645
646    // The web client fetches only the English catalog, zstd-compressed.
647    let mo_rel = "translations/en/LC_MESSAGES/global.mo";
648    let mo_path = build_dir.join(mo_rel);
649    if mo_path.exists() {
650        let mo_bytes = std::fs::read(&mo_path).attach_with(|| format!("Failed to read {}", mo_path.display()))?;
651        let compressed =
652            ruzstd::encoding::compress_to_vec(mo_bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
653        let zst_path = build_dir.join(format!("{mo_rel}.zst"));
654        let hash = store_and_relink(&compressed, &zst_path, cas_root)?;
655        metadata.derived.insert(format!("{mo_rel}.zst"), hash);
656    }
657
658    Ok(())
659}
660
661/// Regenerate derived artifacts for every dumped build (or one build when
662/// `only_build` is given), then garbage-collect CAS objects no longer
663/// referenced by any build.
664pub fn refresh_derived(output_base: &Path, only_build: Option<u32>) -> Result<(), Report> {
665    let index = BuildsIndex::load(&output_base.join("builds.toml"));
666    let cas_root = cas::cas_root(output_base);
667
668    let targets: Vec<&BuildEntry> = match only_build {
669        Some(b) => index.builds.iter().filter(|e| e.build == b).collect(),
670        None => index.builds.iter().collect(),
671    };
672    if targets.is_empty() {
673        bail!("No matching builds found in {}", output_base.join("builds.toml").display());
674    }
675
676    // Build a single fetcher up front so the GitHub listing only runs once
677    // even when backfilling constants for many builds. `None` here just skips
678    // the constants step rather than failing the whole refresh.
679    #[cfg(feature = "constants")]
680    let constants_fetcher = match crate::constants::ConstantsFetcher::new() {
681        Ok(f) => Some(f),
682        Err(e) => {
683            eprintln!("WARN: Could not initialize constants fetcher: {e:?}");
684            None
685        }
686    };
687
688    for entry in &targets {
689        let build_dir = output_base.join(&entry.dir);
690        let meta_path = build_dir.join("metadata.toml");
691        let mut metadata = BuildMetadata::load(&meta_path).unwrap_or(BuildMetadata {
692            version: entry.version.clone(),
693            build: entry.build,
694            ..Default::default()
695        });
696
697        #[cfg(feature = "constants")]
698        let constants_added = if let Some(fetcher) = constants_fetcher.as_ref() {
699            update_constants_if_missing(&build_dir, entry.build, Some(entry.version.as_str()), fetcher)
700        } else {
701            false
702        };
703
704        match refresh_build_derived(&build_dir, &cas_root, &mut metadata) {
705            Ok(()) => {
706                metadata.save(&meta_path)?;
707                #[cfg(feature = "constants")]
708                let constants_note = if constants_added { " + constants" } else { "" };
709                #[cfg(not(feature = "constants"))]
710                let constants_note = "";
711                println!("  {} - {} derived artifact(s){}", entry.dir, metadata.derived.len(), constants_note);
712            }
713            Err(e) => eprintln!("WARN: {} - failed to refresh derived data: {e:?}", entry.dir),
714        }
715    }
716
717    println!("Refreshed {} build(s).", targets.len());
718    Ok(())
719}
720
721/// Write `constants.json` into `build_dir` if upstream has constants for this
722/// build. Logs a warning and leaves the build alone when nothing is published
723/// (e.g. very old builds the wows-constants repo doesn't cover).
724#[cfg(feature = "constants")]
725fn write_constants_for_build(
726    build_dir: &Path,
727    build: u32,
728    version: Option<&str>,
729    fetcher: &crate::constants::ConstantsFetcher,
730) -> bool {
731    let Some((data, actual_build)) = fetcher.fetch(build, version) else {
732        tracing::warn!("No upstream constants available for build {build}");
733        return false;
734    };
735    let bytes = match serde_json::to_vec_pretty(&data) {
736        Ok(b) => b,
737        Err(e) => {
738            tracing::warn!("Failed to serialize constants for build {build}: {e}");
739            return false;
740        }
741    };
742    if let Err(e) = std::fs::write(build_dir.join("constants.json"), &bytes) {
743        tracing::warn!("Failed to write constants.json for build {build}: {e}");
744        return false;
745    }
746    if actual_build != build {
747        tracing::info!("Stored constants from build {actual_build} (fallback for {build})");
748    }
749    true
750}
751
752/// Fetch and write `constants.json` only when the build doesn't already have
753/// one. Returns `true` if a new file was written. Constants for already-shipped
754/// builds don't change upstream, so leaving existing files alone keeps repeat
755/// refreshes idempotent and fast.
756#[cfg(feature = "constants")]
757fn update_constants_if_missing(
758    build_dir: &Path,
759    build: u32,
760    version: Option<&str>,
761    fetcher: &crate::constants::ConstantsFetcher,
762) -> bool {
763    if build_dir.join("constants.json").exists() {
764        return false;
765    }
766    write_constants_for_build(build_dir, build, version, fetcher)
767}
768
769/// Remove content-addressed objects no longer referenced by any build. An
770/// object is live if it appears in some build's metadata (the extracted vfs
771/// tree or the derived artifacts). Aborts without deleting anything if any
772/// build's metadata cannot be read, so in-use objects are never removed.
773pub fn gc_cas(output_base: &Path) -> Result<(), Report> {
774    let index = BuildsIndex::load(&output_base.join("builds.toml"));
775    let cas_root = cas::cas_root(output_base);
776
777    let mut live = std::collections::HashSet::new();
778    for entry in &index.builds {
779        let meta_path = output_base.join(&entry.dir).join("metadata.toml");
780        let meta = BuildMetadata::load(&meta_path)
781            .ok_or_else(|| report!("{} has no readable metadata.toml; aborting GC", entry.dir))?;
782        live.extend(meta.referenced_hashes());
783    }
784
785    let removed = cas::gc(&cas_root, &live)?;
786    println!("GC removed {removed} orphaned CAS object(s); {} still referenced.", live.len());
787    Ok(())
788}
789
790/// Consistency report for one build in a dump base.
791pub struct BuildVerification {
792    pub dir: String,
793    pub build: u32,
794    pub version: String,
795    /// Total unique content hashes the build references.
796    pub referenced: usize,
797    /// Referenced hashes with no object in the shared store.
798    pub missing_objects: Vec<String>,
799    /// Referenced objects present in the store whose bytes hash to something
800    /// else. Only populated when hash checking is requested.
801    pub corrupt_objects: Vec<CorruptObject>,
802    /// VFS-relative paths whose symlink target does not resolve to a file.
803    pub broken_links: Vec<String>,
804    /// True when `metadata.toml` was missing or unparseable.
805    pub metadata_unreadable: bool,
806}
807
808impl BuildVerification {
809    pub fn is_ok(&self) -> bool {
810        !self.metadata_unreadable
811            && self.missing_objects.is_empty()
812            && self.corrupt_objects.is_empty()
813            && self.broken_links.is_empty()
814    }
815}
816
817/// Verdict for one content object in the shared store.
818#[derive(Debug, Clone, PartialEq, Eq)]
819pub enum ObjectAudit {
820    /// The object is present and its bytes hash to its name.
821    Intact,
822    /// Nothing is stored under this hash, or what is there cannot be read.
823    Absent,
824    /// The object is present but its bytes hash to something else.
825    Corrupt { actual: String },
826}
827
828/// Audit every content hash referenced by any of `builds`, invoking `audit` at
829/// most once per distinct hash.
830///
831/// The store is shared across builds, so one object is typically referenced by
832/// dozens of them. Auditing per build would re-read the same gigabytes once per
833/// referencing build.
834fn audit_objects(
835    builds: &[&BuildMetadata],
836    mut audit: impl FnMut(&str) -> ObjectAudit,
837) -> BTreeMap<String, ObjectAudit> {
838    let mut verdicts: BTreeMap<String, ObjectAudit> = BTreeMap::new();
839    for meta in builds {
840        for hash in meta.referenced_hashes() {
841            if verdicts.contains_key(&hash) {
842                continue;
843            }
844            let verdict = audit(&hash);
845            verdicts.insert(hash, verdict);
846        }
847    }
848    verdicts
849}
850
851/// Read one content object and compare its bytes against the name it is stored
852/// under.
853fn audit_object_on_disk(cas_root: &Path, hash: &str) -> ObjectAudit {
854    let path = cas::cas_path(cas_root, hash);
855    match cas::hash_file(&path) {
856        Ok(actual) if actual == hash => ObjectAudit::Intact,
857        Ok(actual) => ObjectAudit::Corrupt { actual },
858        Err(e) if e.kind() == std::io::ErrorKind::NotFound => ObjectAudit::Absent,
859        Err(e) => {
860            tracing::warn!("could not read content object {}: {e}", path.display());
861            ObjectAudit::Absent
862        }
863    }
864}
865
866/// Verify that every build in `builds.toml` is internally consistent: its
867/// `metadata.toml` parses, every referenced content object exists in the shared
868/// store, and (when `check_links` is set) every reconstructed symlink resolves
869/// to a readable file. Returns a report per build; the caller decides how to act.
870///
871/// With `check_hashes` set, every referenced object is read and re-hashed rather
872/// than merely checked for presence, which is the only way to catch an object
873/// whose name is right and whose bytes are not. Each distinct object is read
874/// once across the whole dump base.
875pub fn verify_builds(
876    output_base: &Path,
877    check_links: bool,
878    check_hashes: bool,
879) -> Result<Vec<BuildVerification>, Report> {
880    let cas_root = cas::cas_root(output_base);
881    verify_builds_audited(output_base, check_links, check_hashes, |hash| audit_object_on_disk(&cas_root, hash))
882}
883
884/// [`verify_builds`] with the object audit supplied, so a test can count how
885/// many times each object is read. Production has exactly one auditor; the
886/// parameter exists because "each object is read once" is otherwise
887/// unobservable from outside, and a `verify_builds` that audited per build
888/// would pass every other test in this module.
889fn verify_builds_audited(
890    output_base: &Path,
891    check_links: bool,
892    check_hashes: bool,
893    audit: impl FnMut(&str) -> ObjectAudit,
894) -> Result<Vec<BuildVerification>, Report> {
895    let index = BuildsIndex::load(&output_base.join("builds.toml"));
896    let cas_root = cas::cas_root(output_base);
897
898    let loaded: Vec<(&BuildEntry, Option<BuildMetadata>)> = index
899        .builds
900        .iter()
901        .map(|entry| (entry, BuildMetadata::load(&output_base.join(&entry.dir).join("metadata.toml"))))
902        .collect();
903
904    let audits = if check_hashes {
905        let readable: Vec<&BuildMetadata> = loaded.iter().filter_map(|(_, meta)| meta.as_ref()).collect();
906        audit_objects(&readable, audit)
907    } else {
908        BTreeMap::new()
909    };
910
911    let mut reports = Vec::new();
912    for (entry, meta) in loaded {
913        let build_dir = output_base.join(&entry.dir);
914        let Some(meta) = meta else {
915            reports.push(BuildVerification {
916                dir: entry.dir.clone(),
917                build: entry.build,
918                version: entry.version.clone(),
919                referenced: 0,
920                missing_objects: Vec::new(),
921                corrupt_objects: Vec::new(),
922                broken_links: Vec::new(),
923                metadata_unreadable: true,
924            });
925            continue;
926        };
927
928        let referenced = meta.referenced_hashes();
929        let mut missing_objects = Vec::new();
930        let mut corrupt_objects = Vec::new();
931        for hash in &referenced {
932            match audits.get(hash) {
933                Some(ObjectAudit::Intact) => {}
934                Some(ObjectAudit::Absent) => missing_objects.push(hash.clone()),
935                Some(ObjectAudit::Corrupt { actual }) => {
936                    corrupt_objects.push(CorruptObject::attribute(entry, &meta, hash, actual));
937                }
938                // Without hash checking, presence is all that can be known.
939                None => {
940                    if !cas::object_exists(&cas_root, hash) {
941                        missing_objects.push(hash.clone());
942                    }
943                }
944            }
945        }
946        missing_objects.sort();
947        corrupt_objects.sort_by(|a, b| a.hash.cmp(&b.hash));
948
949        let mut broken_links = Vec::new();
950        if check_links {
951            for (rel, _) in meta.files.iter().chain(meta.derived.iter()) {
952                let path = build_dir.join("vfs").join(rel);
953                // Derived artifacts live at the build root, not under vfs/.
954                let candidate = if path.exists() { path } else { build_dir.join(rel) };
955                if !candidate.exists() {
956                    broken_links.push(rel.clone());
957                }
958            }
959            broken_links.sort();
960        }
961
962        reports.push(BuildVerification {
963            dir: entry.dir.clone(),
964            build: entry.build,
965            version: entry.version.clone(),
966            referenced: referenced.len(),
967            missing_objects,
968            corrupt_objects,
969            broken_links,
970            metadata_unreadable: false,
971        });
972    }
973    Ok(reports)
974}
975
976/// Remove content-addressed objects no longer referenced by any build present
977/// on disk. Scans every directory under `output_base` that contains a
978/// `metadata.toml`, so it stays correct even when `builds.toml` is out of sync
979/// (e.g. a build directory was deleted manually without GC). Aborts without
980/// removing anything if any metadata file cannot be read, so in-use objects are
981/// never deleted. Returns the number of objects removed.
982pub fn gc_unreferenced(output_base: &Path) -> Result<usize, Report> {
983    let cas_root = cas::cas_root(output_base);
984    if !cas_root.exists() {
985        return Ok(0);
986    }
987
988    let mut live = std::collections::HashSet::new();
989    for entry in
990        std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
991    {
992        let meta_path = entry.path().join("metadata.toml");
993        if !meta_path.exists() {
994            continue;
995        }
996        match BuildMetadata::load(&meta_path) {
997            Some(meta) => live.extend(meta.referenced_hashes()),
998            None => bail!("unreadable metadata at {}; aborting GC", meta_path.display()),
999        }
1000    }
1001
1002    cas::gc(&cas_root, &live)
1003}
1004
1005/// Migrate a dump base from the legacy `vfs_common/` CAS directory to `common/`,
1006/// rewriting every build's symlinks to point at the new store. Handles both a
1007/// clean rename (when `common/` doesn't exist yet) and a merge (when a redump
1008/// has already created `common/` while old builds still reference `vfs_common/`).
1009/// No-op when the legacy directory is absent. Returns whether a migration ran.
1010pub fn migrate_cas_dir_name(output_base: &Path) -> Result<bool, Report> {
1011    let legacy = output_base.join(cas::LEGACY_CAS_DIR);
1012    let current = cas::cas_root(output_base);
1013    if !legacy.exists() {
1014        return Ok(false);
1015    }
1016
1017    if !current.exists() {
1018        // Fast path: nothing in the new store yet, so move it wholesale.
1019        std::fs::rename(&legacy, &current)
1020            .attach_with(|| format!("failed to rename {} to {}", legacy.display(), current.display()))?;
1021    } else {
1022        // Both stores exist: fold legacy objects into `common/`. Objects are
1023        // content-addressed, so a name collision means identical bytes — keep
1024        // the existing copy and drop the duplicate.
1025        merge_cas_objects(&legacy, &current)?;
1026    }
1027
1028    // Relative symlinks under each build may still name the old store, so
1029    // re-create every build's links against `common/`.
1030    relink_all_builds(output_base, &current)?;
1031
1032    // Drop whatever remains of the legacy tree (emptied by the merge, or already
1033    // gone after the rename).
1034    if legacy.exists() {
1035        std::fs::remove_dir_all(&legacy)
1036            .attach_with(|| format!("failed to remove emptied legacy store {}", legacy.display()))?;
1037    }
1038
1039    Ok(true)
1040}
1041
1042/// Move every object from a legacy CAS tree into `dest`, deduplicating by hash.
1043/// A collision (same fanout/name) is identical content, so the source copy is
1044/// simply removed. Empties the source fanout directories as it goes.
1045fn merge_cas_objects(legacy: &Path, dest: &Path) -> Result<(), Report> {
1046    for fanout in std::fs::read_dir(legacy).attach_with(|| format!("Failed to read {}", legacy.display()))?.flatten() {
1047        if !fanout.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1048            continue;
1049        }
1050        let dest_fanout = dest.join(fanout.file_name());
1051        for obj in std::fs::read_dir(fanout.path())?.flatten() {
1052            if !obj.file_type().map(|t| t.is_file()).unwrap_or(false) {
1053                continue;
1054            }
1055            let dest_path = dest_fanout.join(obj.file_name());
1056            if dest_path.exists() {
1057                std::fs::remove_file(obj.path())?;
1058                continue;
1059            }
1060            std::fs::create_dir_all(&dest_fanout)?;
1061            // rename works within a volume; fall back to copy+delete across volumes.
1062            if std::fs::rename(obj.path(), &dest_path).is_err() {
1063                std::fs::copy(obj.path(), &dest_path)?;
1064                std::fs::remove_file(obj.path())?;
1065            }
1066        }
1067    }
1068    Ok(())
1069}
1070
1071/// Re-create every build's `vfs/` and derived symlinks against `cas_root`,
1072/// using the hashes recorded in each `metadata.toml`. Idempotent.
1073fn relink_all_builds(output_base: &Path, cas_root: &Path) -> Result<(), Report> {
1074    for entry in
1075        std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
1076    {
1077        let build_dir = entry.path();
1078        let Some(meta) = BuildMetadata::load(&build_dir.join("metadata.toml")) else {
1079            continue;
1080        };
1081        let vfs_dir = build_dir.join("vfs");
1082        let relink = |rel: &str, hash: &str, base: &Path| {
1083            let link = base.join(rel);
1084            let _ = std::fs::remove_file(&link);
1085            if let Err(e) = cas::link_file(cas_root, hash, &link) {
1086                tracing::warn!("failed to relink {}: {e}", link.display());
1087            }
1088        };
1089        for (rel, hash) in &meta.files {
1090            relink(rel, hash, &vfs_dir);
1091        }
1092        for (rel, hash) in &meta.derived {
1093            relink(rel, hash, &build_dir);
1094        }
1095    }
1096    Ok(())
1097}
1098
1099/// Migrate any pre-CAS dumps in `output_base` into content-addressed storage.
1100///
1101/// Older dumps stored the extracted `vfs/` tree as plain files with no entries
1102/// in `metadata.files`. This rehashes those files into `common/`, replaces
1103/// them with symlinks, records the hashes, and regenerates derived artifacts so
1104/// the dump deduplicates against every other build. Returns the number of
1105/// builds migrated. Builds already in CAS format are left untouched.
1106pub fn migrate_to_cas(output_base: &Path) -> Result<usize, Report> {
1107    let cas_root = cas::cas_root(output_base);
1108    let mut migrated = 0;
1109    for entry in
1110        std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
1111    {
1112        let build_dir = entry.path();
1113        let meta_path = build_dir.join("metadata.toml");
1114        let Some(mut metadata) = BuildMetadata::load(&meta_path) else {
1115            continue;
1116        };
1117        if metadata.has_file_hashes() || !build_dir.join("vfs").exists() {
1118            continue;
1119        }
1120        match migrate_build_to_cas(&build_dir, &cas_root, &mut metadata) {
1121            Ok(()) => {
1122                metadata.save(&meta_path)?;
1123                migrated += 1;
1124            }
1125            Err(e) => tracing::warn!("failed to migrate {} to CAS: {e}", build_dir.display()),
1126        }
1127    }
1128    Ok(migrated)
1129}
1130
1131/// Rehash a single pre-CAS build's `vfs/` tree into the CAS, replacing each
1132/// plain file with a symlink and recording its hash in `metadata.files`.
1133fn migrate_build_to_cas(build_dir: &Path, cas_root: &Path, metadata: &mut BuildMetadata) -> Result<(), Report> {
1134    let vfs_dir = build_dir.join("vfs");
1135    let mut stack = vec![vfs_dir.clone()];
1136    while let Some(dir) = stack.pop() {
1137        for entry in std::fs::read_dir(&dir).attach_with(|| format!("Failed to read {}", dir.display()))?.flatten() {
1138            let path = entry.path();
1139            let file_type = entry.file_type().attach_with(|| format!("Failed to stat {}", path.display()))?;
1140            if file_type.is_dir() {
1141                stack.push(path);
1142                continue;
1143            }
1144            // Symlinks are already-migrated CAS references; leave them alone.
1145            if file_type.is_symlink() {
1146                continue;
1147            }
1148            let rel =
1149                path.strip_prefix(&vfs_dir).expect("walked path is under vfs_dir").to_string_lossy().replace('\\', "/");
1150            let data = std::fs::read(&path).attach_with(|| format!("Failed to read {}", path.display()))?;
1151            let hash = cas::store(cas_root, &data)?;
1152            std::fs::remove_file(&path).attach_with(|| format!("Failed to remove {}", path.display()))?;
1153            cas::link_file(cas_root, &hash, &path)?;
1154            metadata.files.insert(rel, hash);
1155        }
1156    }
1157    refresh_build_derived(build_dir, cas_root, metadata)
1158}
1159
1160fn extract_vfs_dir_cas(
1161    vfs: &VfsPath,
1162    vfs_path: &str,
1163    vfs_dir: &Path,
1164    cas_root: &Path,
1165    file_hashes: &mut BTreeMap<String, String>,
1166    progress: Option<&ProgressBar>,
1167) -> Result<usize, Report> {
1168    let dir = match vfs.join(vfs_path) {
1169        Ok(d) => d,
1170        Err(_) => return Ok(0),
1171    };
1172    let walker = match dir.walk_dir() {
1173        Ok(w) => w,
1174        Err(_) => return Ok(0),
1175    };
1176
1177    let mut count = 0;
1178    for entry in walker.flatten() {
1179        let metadata = match entry.metadata() {
1180            Ok(m) => m,
1181            Err(_) => continue,
1182        };
1183        if metadata.file_type != VfsFileType::File {
1184            continue;
1185        }
1186        let rel = entry.as_str();
1187        let mut buf = Vec::new();
1188        match entry.open_file() {
1189            Ok(mut f) => f.read_to_end(&mut buf)?,
1190            Err(e) => {
1191                tracing::warn!("Failed to open VFS file {rel}: {e}");
1192                continue;
1193            }
1194        };
1195        store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
1196        count += 1;
1197        if let Some(pb) = progress {
1198            pb.inc(1);
1199        }
1200    }
1201    Ok(count)
1202}
1203
1204/// Extract a single VFS file. Returns `true` if it was found and stored.
1205fn extract_vfs_file_cas(
1206    vfs: &VfsPath,
1207    vfs_path: &str,
1208    vfs_dir: &Path,
1209    cas_root: &Path,
1210    file_hashes: &mut BTreeMap<String, String>,
1211) -> Result<bool, Report> {
1212    let file = match vfs.join(vfs_path) {
1213        Ok(f) => f,
1214        Err(_) => {
1215            tracing::warn!("VFS path not found (skipping): {vfs_path}");
1216            return Ok(false);
1217        }
1218    };
1219    let mut buf = Vec::new();
1220    match file.open_file() {
1221        Ok(mut f) => f.read_to_end(&mut buf)?,
1222        Err(_) => {
1223            tracing::warn!("Could not open VFS file (skipping): {vfs_path}");
1224            return Ok(false);
1225        }
1226    };
1227    store_and_link(&buf, vfs_path, vfs_dir, cas_root, file_hashes)?;
1228    Ok(true)
1229}
1230
1231/// Extract the named files from each subdirectory of `parent_dir`. Returns the
1232/// number of files extracted.
1233fn extract_map_files_cas(
1234    vfs: &VfsPath,
1235    parent_dir: &str,
1236    filenames: &[&str],
1237    vfs_dir: &Path,
1238    cas_root: &Path,
1239    file_hashes: &mut BTreeMap<String, String>,
1240    progress: Option<&ProgressBar>,
1241) -> Result<usize, Report> {
1242    let parent = match vfs.join(parent_dir) {
1243        Ok(d) => d,
1244        Err(_) => return Ok(0),
1245    };
1246    let entries = match parent.read_dir() {
1247        Ok(e) => e,
1248        Err(_) => return Ok(0),
1249    };
1250
1251    let mut count = 0;
1252    for entry in entries {
1253        if !entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
1254            continue;
1255        }
1256        for filename in filenames {
1257            let file_path = match entry.join(filename) {
1258                Ok(f) => f,
1259                Err(_) => continue,
1260            };
1261            if !file_path.exists().unwrap_or(false) {
1262                continue;
1263            }
1264            let rel = file_path.as_str();
1265            let mut buf = Vec::new();
1266            match file_path.open_file() {
1267                Ok(mut f) => f.read_to_end(&mut buf)?,
1268                Err(e) => {
1269                    tracing::warn!("Failed to open VFS file {rel}: {e}");
1270                    continue;
1271                }
1272            };
1273            store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
1274            count += 1;
1275            if let Some(pb) = progress {
1276                pb.inc(1);
1277            }
1278        }
1279    }
1280    Ok(count)
1281}
1282
1283// -- Counting helpers (for progress bar) --
1284
1285fn count_vfs_dir_files(vfs: &VfsPath, dir: &str) -> u64 {
1286    let mut count = 0;
1287    if let Ok(vfs_dir_path) = vfs.join(dir)
1288        && let Ok(walker) = vfs_dir_path.walk_dir()
1289    {
1290        for entry in walker.flatten() {
1291            if entry.metadata().map(|m| m.file_type == VfsFileType::File).unwrap_or(false) {
1292                count += 1;
1293            }
1294        }
1295    }
1296    count
1297}
1298
1299fn count_map_files(vfs: &VfsPath, parent_dir: &str, filenames: &[&str]) -> u64 {
1300    let mut count = 0;
1301    if let Ok(parent) = vfs.join(parent_dir)
1302        && let Ok(entries) = parent.read_dir()
1303    {
1304        for entry in entries {
1305            if entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
1306                for filename in filenames {
1307                    if entry.join(filename).is_ok_and(|f: VfsPath| f.exists().unwrap_or(false)) {
1308                        count += 1;
1309                    }
1310                }
1311            }
1312        }
1313    }
1314    count
1315}
1316
1317#[cfg(test)]
1318mod maintenance_tests {
1319    use super::*;
1320
1321    fn write_build_metadata(build_dir: &Path, version: &str, build: u32, files: &[(&str, &str)]) {
1322        std::fs::create_dir_all(build_dir).unwrap();
1323        let mut meta = BuildMetadata { version: version.to_string(), build, ..Default::default() };
1324        for (rel, hash) in files {
1325            meta.files.insert((*rel).to_string(), (*hash).to_string());
1326        }
1327        meta.save(&build_dir.join("metadata.toml")).unwrap();
1328    }
1329
1330    fn register(base: &Path, version: &str, build: u32) {
1331        let path = base.join("builds.toml");
1332        let mut index = BuildsIndex::load(&path);
1333        index.upsert(BuildEntry {
1334            version: version.to_string(),
1335            build,
1336            dir: format!("{version}_{build}"),
1337            dumped_at: String::new(),
1338        });
1339        index.save(&path).unwrap();
1340    }
1341
1342    /// An object rewritten in place keeps its name, so existence checking sees
1343    /// nothing wrong. This is exactly what `core.autocrlf` did to the published
1344    /// archive: same path, different bytes, different length.
1345    #[test]
1346    fn hash_checking_finds_an_object_whose_bytes_were_changed() {
1347        let dir = tempfile::tempdir().unwrap();
1348        let base = dir.path();
1349        let cas_root = base.join("common");
1350
1351        let intact = cas::store(&cas_root, b"an object nobody touched").unwrap();
1352        let tampered = cas::store(&cas_root, b"<xml>\r\n  <node />\r\n</xml>\r\n").unwrap();
1353        write_build_metadata(
1354            &base.join("15.4.0_12506899"),
1355            "15.4.0",
1356            12506899,
1357            &[("res/a.xml", &intact), ("res/b.xml", &tampered), ("res/c.xml", &tampered)],
1358        );
1359        register(base, "15.4.0", 12506899);
1360
1361        let rewritten: &[u8] = b"<xml>\n  <node />\n</xml>\n";
1362        std::fs::write(cas::cas_path(&cas_root, &tampered), rewritten).unwrap();
1363
1364        let unchecked = verify_builds(base, false, false).unwrap();
1365        assert!(unchecked[0].is_ok(), "existence checking cannot see rewritten bytes");
1366
1367        let checked = verify_builds(base, false, true).unwrap();
1368        let report = &checked[0];
1369        assert!(!report.is_ok(), "hash checking must reject the rewritten object");
1370        assert!(report.missing_objects.is_empty(), "the object is present, just wrong");
1371        assert_eq!(report.corrupt_objects.len(), 1, "only the rewritten object is corrupt");
1372
1373        let corrupt = &report.corrupt_objects[0];
1374        assert_eq!(corrupt.hash, tampered);
1375        assert_eq!(corrupt.actual, cas::hash_bytes(rewritten));
1376        assert_eq!(corrupt.build, 12506899);
1377        assert_eq!(corrupt.version, "15.4.0");
1378        assert_eq!(corrupt.files, vec!["res/b.xml", "res/c.xml"]);
1379    }
1380
1381    /// The single-pass design is the whole reason auditing 12 GB across 127
1382    /// builds is feasible: a per-build audit re-reads every shared object once
1383    /// per referencing build.
1384    #[test]
1385    fn an_object_is_hashed_once_even_when_several_builds_reference_it() {
1386        let mut first = BuildMetadata { version: "15.3.0".into(), build: 12400000, ..Default::default() };
1387        first.files.insert("res/a.xml".into(), "shared".into());
1388        first.files.insert("res/only_in_first.xml".into(), "unique".into());
1389        let mut second = BuildMetadata { version: "15.4.0".into(), build: 12506899, ..Default::default() };
1390        second.files.insert("res/b.xml".into(), "shared".into());
1391        second.derived.insert("GameParams.rkyv".into(), "shared".into());
1392
1393        let mut reads: Vec<String> = Vec::new();
1394        let verdicts = audit_objects(&[&first, &second], |hash| {
1395            reads.push(hash.to_string());
1396            ObjectAudit::Intact
1397        });
1398
1399        assert_eq!(reads.iter().filter(|h| h.as_str() == "shared").count(), 1, "read {reads:?}");
1400        assert_eq!(reads.len(), 2, "read {reads:?}");
1401        assert_eq!(verdicts.len(), 2);
1402    }
1403
1404    /// The test above exercises `audit_objects` directly, which says nothing
1405    /// about how `verify_builds` calls it. A `verify_builds` that called it once
1406    /// per build with a one-element slice would re-read every shared object per
1407    /// referencing build -- 12 GB times 127 builds -- and pass every other test
1408    /// here. This one counts the reads the whole verification makes.
1409    #[test]
1410    fn verifying_a_whole_dump_base_hashes_each_shared_object_once() {
1411        let dir = tempfile::tempdir().unwrap();
1412        let base = dir.path();
1413
1414        for (version, build, own) in [("15.3.0", 12400000u32, "only_in_first"), ("15.4.0", 12506899, "only_in_second")]
1415        {
1416            write_build_metadata(
1417                &base.join(format!("{version}_{build}")),
1418                version,
1419                build,
1420                &[("res/shared.xml", "shared"), ("res/own.xml", own)],
1421            );
1422            register(base, version, build);
1423        }
1424
1425        let mut reads: Vec<String> = Vec::new();
1426        let reports = verify_builds_audited(base, false, true, |hash| {
1427            reads.push(hash.to_string());
1428            ObjectAudit::Intact
1429        })
1430        .unwrap();
1431
1432        assert_eq!(reports.len(), 2);
1433        assert_eq!(reads.iter().filter(|h| h.as_str() == "shared").count(), 1, "read {reads:?}");
1434        assert_eq!(reads.len(), 3, "read {reads:?}");
1435    }
1436
1437    /// Without `check_hashes` no object is read at all, which is what makes
1438    /// reporting a corrupt count on that path a claim about an audit that never
1439    /// ran.
1440    #[test]
1441    fn verifying_without_hash_checking_reads_no_object() {
1442        let dir = tempfile::tempdir().unwrap();
1443        let base = dir.path();
1444        write_build_metadata(&base.join("15.4.0_12506899"), "15.4.0", 12506899, &[("res/a.xml", "shared")]);
1445        register(base, "15.4.0", 12506899);
1446
1447        let mut reads: Vec<String> = Vec::new();
1448        let reports = verify_builds_audited(base, false, false, |hash| {
1449            reads.push(hash.to_string());
1450            ObjectAudit::Intact
1451        })
1452        .unwrap();
1453
1454        assert_eq!(reports.len(), 1);
1455        assert!(reads.is_empty(), "read {reads:?}");
1456        assert!(reports[0].corrupt_objects.is_empty(), "nothing was hashed, so nothing can be called corrupt");
1457    }
1458
1459    /// Reading each object once must not cost per-build attribution: the user
1460    /// needs to know every build a corrupt object breaks.
1461    #[test]
1462    fn a_shared_corrupt_object_is_reported_against_every_build_that_references_it() {
1463        let dir = tempfile::tempdir().unwrap();
1464        let base = dir.path();
1465        let cas_root = base.join("common");
1466
1467        let shared = cas::store(&cas_root, b"shared between two builds\r\n").unwrap();
1468        write_build_metadata(&base.join("15.3.0_12400000"), "15.3.0", 12400000, &[("res/a.xml", &shared)]);
1469        write_build_metadata(&base.join("15.4.0_12506899"), "15.4.0", 12506899, &[("res/b.xml", &shared)]);
1470        register(base, "15.3.0", 12400000);
1471        register(base, "15.4.0", 12506899);
1472
1473        std::fs::write(cas::cas_path(&cas_root, &shared), b"rewritten\n").unwrap();
1474
1475        let reports = verify_builds(base, false, true).unwrap();
1476        assert_eq!(reports.len(), 2);
1477        for report in &reports {
1478            assert_eq!(report.corrupt_objects.len(), 1, "{} missed the corrupt object", report.dir);
1479            assert_eq!(report.corrupt_objects[0].hash, shared);
1480        }
1481        assert_eq!(reports[0].corrupt_objects[0].files, vec!["res/a.xml"]);
1482        assert_eq!(reports[1].corrupt_objects[0].files, vec!["res/b.xml"]);
1483    }
1484
1485    /// A hash with no object at all is missing, not corrupt: re-downloading
1486    /// fixes one and cannot fix the other.
1487    #[test]
1488    fn a_missing_object_is_not_reported_as_corrupt() {
1489        let dir = tempfile::tempdir().unwrap();
1490        let base = dir.path();
1491
1492        write_build_metadata(
1493            &base.join("15.4.0_12506899"),
1494            "15.4.0",
1495            12506899,
1496            &[("res/a.xml", "0123456789abcdef0123")],
1497        );
1498        register(base, "15.4.0", 12506899);
1499
1500        let reports = verify_builds(base, false, true).unwrap();
1501        assert_eq!(reports[0].missing_objects, vec!["0123456789abcdef0123"]);
1502        assert!(reports[0].corrupt_objects.is_empty());
1503    }
1504
1505    #[test]
1506    fn gc_unreferenced_removes_orphans_and_keeps_live() {
1507        let dir = tempfile::tempdir().unwrap();
1508        let base = dir.path();
1509        let cas_root = base.join("common");
1510
1511        let live_hash = cas::store(&cas_root, b"live object").unwrap();
1512        let orphan_hash = cas::store(&cas_root, b"orphan object").unwrap();
1513        write_build_metadata(&base.join("1.0.0_100"), "1.0.0", 100, &[("gui/a.png", &live_hash)]);
1514
1515        let removed = gc_unreferenced(base).unwrap();
1516        assert_eq!(removed, 1);
1517        assert!(cas::object_exists(&cas_root, &live_hash));
1518        assert!(!cas::object_exists(&cas_root, &orphan_hash));
1519    }
1520
1521    #[test]
1522    fn gc_unreferenced_aborts_on_unreadable_metadata() {
1523        let dir = tempfile::tempdir().unwrap();
1524        let base = dir.path();
1525        let cas_root = base.join("common");
1526        let orphan_hash = cas::store(&cas_root, b"orphan object").unwrap();
1527
1528        let build_dir = base.join("1.0.0_100");
1529        std::fs::create_dir_all(&build_dir).unwrap();
1530        std::fs::write(build_dir.join("metadata.toml"), b"this is not valid toml = =").unwrap();
1531
1532        assert!(gc_unreferenced(base).is_err());
1533        // Nothing was removed because GC aborted.
1534        assert!(cas::object_exists(&cas_root, &orphan_hash));
1535    }
1536
1537    #[test]
1538    fn migrate_to_cas_dedups_plain_files() {
1539        let dir = tempfile::tempdir().unwrap();
1540        let base = dir.path();
1541        let build_dir = base.join("1.0.0_100");
1542
1543        // Old-format dump: plain files in vfs/, no file hashes in metadata.
1544        write_build_metadata(&build_dir, "1.0.0", 100, &[]);
1545        let file_path = build_dir.join("vfs/gui/a.png");
1546        std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
1547        std::fs::write(&file_path, b"some asset bytes").unwrap();
1548
1549        let migrated = migrate_to_cas(base).unwrap();
1550        assert_eq!(migrated, 1);
1551
1552        // The plain file is now a symlink whose content still reads back.
1553        assert!(std::fs::symlink_metadata(&file_path).unwrap().file_type().is_symlink());
1554        assert_eq!(std::fs::read(&file_path).unwrap(), b"some asset bytes");
1555
1556        // Metadata now records the hash, and a second pass is a no-op.
1557        let meta = BuildMetadata::load(&build_dir.join("metadata.toml")).unwrap();
1558        assert!(meta.has_file_hashes());
1559        assert!(meta.files.contains_key("gui/a.png"));
1560        assert_eq!(migrate_to_cas(base).unwrap(), 0);
1561    }
1562
1563    #[test]
1564    fn migrate_cas_dir_name_renames_and_relinks() {
1565        let dir = tempfile::tempdir().unwrap();
1566        let base = dir.path();
1567
1568        // Legacy layout: vfs_common/ store + a build whose vfs file symlinks into it.
1569        let legacy = base.join(cas::LEGACY_CAS_DIR);
1570        let hash = cas::store(&legacy, b"icon bytes").unwrap();
1571        let build_dir = base.join("1.0.0_100");
1572        let link = build_dir.join("vfs/gui/x.png");
1573        cas::link_file(&legacy, &hash, &link).unwrap();
1574        write_build_metadata(&build_dir, "1.0.0", 100, &[("gui/x.png", &hash)]);
1575
1576        assert!(migrate_cas_dir_name(base).unwrap());
1577        assert!(base.join(cas::CAS_DIR).exists());
1578        assert!(!base.join(cas::LEGACY_CAS_DIR).exists());
1579        // The symlink now resolves through common/ and still reads back.
1580        assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
1581        assert_eq!(std::fs::read(&link).unwrap(), b"icon bytes");
1582        // Idempotent: nothing to migrate the second time.
1583        assert!(!migrate_cas_dir_name(base).unwrap());
1584    }
1585
1586    #[test]
1587    fn refresh_derived_content_addresses_translations() {
1588        let dir = tempfile::tempdir().unwrap();
1589        let base = dir.path();
1590        let cas_root = base.join(cas::CAS_DIR);
1591        let build_dir = base.join("1.0.0_100");
1592        // A raw per-locale catalog as a plain file (as copied from the game install).
1593        let mo = build_dir.join("translations/ru/LC_MESSAGES/global.mo");
1594        std::fs::create_dir_all(mo.parent().unwrap()).unwrap();
1595        std::fs::write(&mo, b"catalog bytes").unwrap();
1596
1597        let mut meta = BuildMetadata { version: "1.0.0".into(), build: 100, ..Default::default() };
1598        refresh_build_derived(&build_dir, &cas_root, &mut meta).unwrap();
1599
1600        // The catalog is now a symlink into the shared store, recorded in derived.
1601        assert!(std::fs::symlink_metadata(&mo).unwrap().file_type().is_symlink());
1602        assert_eq!(std::fs::read(&mo).unwrap(), b"catalog bytes");
1603        assert!(meta.derived.contains_key("translations/ru/LC_MESSAGES/global.mo"));
1604    }
1605}