Skip to main content

wows_data_mgr/
lib.rs

1//! Test helper API for accessing downloaded World of Warships game data.
2//!
3//! Use these functions in integration tests to get VFS access to game builds.
4//! Tests should skip gracefully when game data is unavailable.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use wows_data_mgr::{available_builds, vfs_for_build};
10//!
11//! #[test]
12//! fn test_game_params_load() {
13//!     let builds = available_builds();
14//!     if builds.is_empty() {
15//!         eprintln!("Skipping: no game data available");
16//!         return;
17//!     }
18//!     for build in builds {
19//!         let vfs = vfs_for_build(build).unwrap();
20//!         // test with vfs...
21//!     }
22//! }
23//! ```
24
25/// The `tracing` target every log statement in this crate is emitted under.
26///
27/// Consumers that filter by target (the toolkit's log file writes through an
28/// allowlist) reference this instead of a hand-copied literal, so renaming the
29/// crate cannot silently drop its logs on the floor.
30pub const LOG_TARGET: &str = module_path!();
31
32pub mod builds;
33pub mod cas;
34pub mod cas_vfs;
35#[cfg(feature = "constants")]
36pub mod constants;
37#[cfg(feature = "download")]
38pub mod download_repo;
39pub mod dump;
40pub mod manifest;
41pub mod registry;
42
43use std::path::Path;
44use std::path::PathBuf;
45
46use wowsunpack::game_data;
47use wowsunpack::vfs::VfsPath;
48use wowsunpack::vfs::impls::physical::PhysicalFS;
49
50/// Returns the path to the game_data/ directory.
51///
52/// Checks `WOWS_GAME_DATA` env var first, then walks up from the current
53/// directory to find the workspace root (identified by `game_versions.toml`).
54pub fn game_data_dir() -> Option<PathBuf> {
55    if let Ok(dir) = std::env::var("WOWS_GAME_DATA") {
56        let path = PathBuf::from(dir);
57        if path.exists() {
58            return Some(path);
59        }
60    }
61
62    // Walk up from current dir to find repo root
63    let mut dir = std::env::current_dir().ok()?;
64    loop {
65        if dir.join("game_versions.toml").exists() {
66            let data_dir = dir.join("game_data");
67            return Some(data_dir);
68        }
69        if !dir.pop() {
70            return None;
71        }
72    }
73}
74
75/// Returns sorted list of locally available build numbers.
76///
77/// Reads the local registry to find both downloaded builds
78/// (in `game_data/builds/<build>/`) and registered overrides.
79pub fn available_builds() -> Vec<u32> {
80    let Some(data_dir) = game_data_dir() else {
81        return Vec::new();
82    };
83    let reg = registry::load_registry(&data_dir.join("versions.toml"));
84    // A registry entry is a claim, not proof: a build whose directory was moved
85    // or renamed is not available to read, and callers treat this list as data
86    // they can open.
87    reg.available_builds().into_iter().filter(|build| reg.game_dir_for_build(*build, &data_dir).is_some()).collect()
88}
89
90/// Returns the game root path for a specific build.
91///
92/// For registered overrides, returns the override path.
93/// For downloaded builds, returns `game_data/builds/<build>/`.
94pub fn game_dir_for_build(build: u32) -> Option<PathBuf> {
95    let data_dir = game_data_dir()?;
96    let reg = registry::load_registry(&data_dir.join("versions.toml"));
97    reg.game_dir_for_build(build, &data_dir)
98}
99
100/// A dump directory resolved for reading, whichever on-disk layout it uses.
101///
102/// A CAS-format dump keeps its bytes in the shared `common/` store and has no
103/// `vfs/` tree at all, so reading one through `PhysicalFS` finds nothing. A
104/// directory with no readable `metadata.toml` (a hand-extracted dump, or one
105/// produced before the content-addressed layout) is served from `vfs/`.
106pub struct Dump {
107    dump_dir: PathBuf,
108    cas: Option<cas_vfs::BuildCas>,
109}
110
111impl Dump {
112    /// Resolve `dump_dir`, parsing its manifest once when it has one.
113    pub fn open(dump_dir: &Path) -> Self {
114        Self { dump_dir: dump_dir.to_path_buf(), cas: cas_vfs::BuildCas::open(dump_dir) }
115    }
116
117    /// A VFS over the dump's game files.
118    pub fn vfs(&self) -> VfsPath {
119        match &self.cas {
120            Some(cas) => cas.vfs(),
121            None => VfsPath::new(PhysicalFS::new(self.dump_dir.join("vfs"))),
122        }
123    }
124
125    /// Path to a derived artifact such as `game_params.rkyv`, or `None` when
126    /// this dump does not carry one.
127    pub fn derived_path(&self, rel: &str) -> Option<PathBuf> {
128        match &self.cas {
129            Some(cas) => cas.derived_path(rel),
130            None => {
131                let path = self.dump_dir.join(rel);
132                path.exists().then_some(path)
133            }
134        }
135    }
136
137    /// Whether this dump has game files to read. Callers that skip when data is
138    /// unavailable test this rather than probing for `vfs/`, which a CAS-format
139    /// dump legitimately lacks.
140    pub fn has_game_files(&self) -> bool {
141        match &self.cas {
142            Some(cas) => cas.metadata().has_file_hashes() || self.dump_dir.join("vfs").is_dir(),
143            None => self.dump_dir.join("vfs").is_dir(),
144        }
145    }
146}
147
148/// Resolves a build number to a readable [`Dump`].
149pub fn dump_for_build(build: u32) -> Option<Dump> {
150    Some(Dump::open(&game_dir_for_build(build)?))
151}
152
153/// Constructs a VFS for a specific build.
154///
155/// Most registered builds are dumps, which are read through [`Dump`]. A build
156/// that resolves to a real game installation has no dump layout to read, and
157/// falls back to [`wowsunpack::game_data::build_game_vfs`] over its packages.
158pub fn vfs_for_build(build: u32) -> Option<VfsPath> {
159    let game_dir = game_dir_for_build(build)?;
160    let dump = Dump::open(&game_dir);
161    if dump.has_game_files() {
162        return Some(dump.vfs());
163    }
164    game_data::build_game_vfs(&game_dir).ok()
165}
166
167/// Returns the latest available build number and its VFS.
168pub fn latest_build() -> Option<(u32, VfsPath)> {
169    let builds = available_builds();
170    let build = *builds.last()?;
171    let vfs = vfs_for_build(build)?;
172    Some((build, vfs))
173}
174
175#[cfg(test)]
176mod dump_tests {
177    use std::io::Read;
178
179    use super::*;
180    use crate::builds::BuildMetadata;
181
182    /// A CAS-format dump: bytes live in the sibling `common/` store and there
183    /// is no `vfs/` tree.
184    fn cas_dump(base: &Path, files: &[(&str, &[u8])], derived: &[(&str, &[u8])]) -> PathBuf {
185        let cas_root = base.join("common");
186        let mut meta = BuildMetadata { version: "1.2.3".into(), build: 100, ..Default::default() };
187        for (rel, bytes) in files {
188            meta.files.insert((*rel).to_string(), cas::store(&cas_root, bytes).unwrap());
189        }
190        for (rel, bytes) in derived {
191            meta.derived.insert((*rel).to_string(), cas::store(&cas_root, bytes).unwrap());
192        }
193        let dump_dir = base.join("1.2.3_100");
194        std::fs::create_dir_all(&dump_dir).unwrap();
195        meta.save(&dump_dir.join("metadata.toml")).unwrap();
196        dump_dir
197    }
198
199    /// A pre-CAS dump: a real `vfs/` tree and no manifest.
200    fn legacy_dump(base: &Path, files: &[(&str, &[u8])]) -> PathBuf {
201        let dump_dir = base.join("legacy");
202        for (rel, bytes) in files {
203            let path = dump_dir.join("vfs").join(rel);
204            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
205            std::fs::write(path, bytes).unwrap();
206        }
207        dump_dir
208    }
209
210    fn read(vfs: &VfsPath, rel: &str) -> Vec<u8> {
211        let mut data = Vec::new();
212        vfs.join(rel).unwrap().open_file().unwrap().read_to_end(&mut data).unwrap();
213        data
214    }
215
216    #[test]
217    fn cas_dump_reads_game_files_without_a_vfs_tree() {
218        let base = tempfile::tempdir().unwrap();
219        let dump_dir = cas_dump(base.path(), &[("content/GameParams.data", b"params")], &[]);
220
221        let dump = Dump::open(&dump_dir);
222
223        assert!(!dump_dir.join("vfs").exists(), "a CAS dump has no vfs tree to read");
224        assert!(dump.has_game_files());
225        assert_eq!(read(&dump.vfs(), "content/GameParams.data"), b"params");
226    }
227
228    #[test]
229    fn cas_dump_resolves_derived_artifacts_into_the_store() {
230        let base = tempfile::tempdir().unwrap();
231        let dump_dir = cas_dump(base.path(), &[], &[("game_params.rkyv", b"rkyv bytes")]);
232
233        let dump = Dump::open(&dump_dir);
234        let path = dump.derived_path("game_params.rkyv").expect("derived artifact");
235
236        assert_eq!(std::fs::read(path).unwrap(), b"rkyv bytes");
237        assert!(dump.derived_path("absent.rkyv").is_none());
238    }
239
240    #[test]
241    fn legacy_dump_still_reads_from_its_vfs_tree() {
242        let base = tempfile::tempdir().unwrap();
243        let dump_dir = legacy_dump(base.path(), &[("content/GameParams.data", b"params")]);
244        std::fs::write(dump_dir.join("game_params.rkyv"), b"rkyv bytes").unwrap();
245
246        let dump = Dump::open(&dump_dir);
247
248        assert!(dump.has_game_files());
249        assert_eq!(read(&dump.vfs(), "content/GameParams.data"), b"params");
250        assert_eq!(std::fs::read(dump.derived_path("game_params.rkyv").unwrap()).unwrap(), b"rkyv bytes");
251    }
252
253    #[test]
254    fn empty_directory_has_no_game_files() {
255        let base = tempfile::tempdir().unwrap();
256        let dump_dir = base.path().join("nothing");
257        std::fs::create_dir_all(&dump_dir).unwrap();
258
259        let dump = Dump::open(&dump_dir);
260
261        assert!(!dump.has_game_files());
262        assert!(dump.derived_path("game_params.rkyv").is_none());
263    }
264}
265
266#[cfg(test)]
267mod log_target_tests {
268    /// `tracing` targets default to the emitting module's path, and a target
269    /// filter matches on the leading segments, so every module in this crate is
270    /// covered by the crate-level target. Renaming the crate changes both sides
271    /// together, which is why consumers must not hard-code the string.
272    #[test]
273    fn every_module_in_this_crate_logs_under_the_crate_target() {
274        assert_eq!(super::LOG_TARGET, "wows_data_mgr");
275        assert!(module_path!().starts_with(super::LOG_TARGET), "{}", module_path!());
276    }
277}