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::provider::GameMetadataProvider;
11use wowsunpack::game_params::types::GameParamProvider;
12use wowsunpack::game_params::types::Param;
13use wowsunpack::vfs::VfsFileType;
14use wowsunpack::vfs::VfsPath;
15
16use crate::builds::BuildEntry;
17use crate::builds::BuildMetadata;
18use crate::builds::BuildsIndex;
19use crate::cas;
20
21const REQUIRED_VFS_DIRS: &[&str] = &[
23 "gui/fla/minimap",
24 "gui/battle_hud/markers_minimap",
25 "gui/battle_hud/icon_frag",
26 "gui/battle_hud/markers/capture_point",
27 "gui/battle_hud/markers/building_icons",
28 "gui/consumables",
29 "gui/powerups/drops",
30 "gui/fonts",
31 "gui/data/constants",
32 "gui/ships_silhouettes",
33 "scripts/entity_defs",
34];
35
36const REQUIRED_VFS_FILES: &[&str] = &["content/GameParams.data", "scripts/entities.xml"];
38
39const MAP_FILES_SPACES: &[&str] = &["minimap.png", "minimap_water.png", "space.settings"];
41
42const MAP_FILES_GAMEPLAY: &[&str] = &["space.settings"];
44
45pub fn dump_dir(output_base: &Path, version_str: &str, build: u32) -> std::path::PathBuf {
47 output_base.join(format!("{version_str}_{build}"))
48}
49
50pub fn dump_exists(output_base: &Path, version_str: &str, build: u32) -> bool {
52 dump_dir(output_base, version_str, build).join("metadata.toml").exists()
53}
54
55pub fn dump_renderer_data(
64 game_dir: &Path,
65 build: u32,
66 version_str: &str,
67 output_base: &Path,
68 progress: Option<&ProgressBar>,
69 allow_existing: bool,
70) -> Result<(), Report> {
71 let output_dir = dump_dir(output_base, version_str, build);
72 let vfs_dir = output_dir.join("vfs");
73 let cas_root = output_base.join("vfs_common");
74
75 if output_dir.join("metadata.toml").exists() {
76 if allow_existing {
77 return Ok(());
78 }
79 bail!("Output directory already exists: {}", output_dir.display());
80 }
81
82 if output_dir.exists() {
84 std::fs::remove_dir_all(&output_dir)
85 .attach_with(|| format!("Failed to clean up partial dump at {}", output_dir.display()))?;
86 }
87
88 let vfs = game_data::build_game_vfs_for_build(game_dir, build).attach_with(|| "Failed to build game VFS")?;
89
90 let mut file_hashes: BTreeMap<String, String> = BTreeMap::new();
92
93 for dir in REQUIRED_VFS_DIRS {
94 extract_vfs_dir_cas(&vfs, dir, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
95 }
96 for file in REQUIRED_VFS_FILES {
97 extract_vfs_file_cas(&vfs, file, &vfs_dir, &cas_root, &mut file_hashes)?;
98 if let Some(pb) = progress {
99 pb.inc(1);
100 }
101 }
102 extract_map_files_cas(&vfs, "spaces", MAP_FILES_SPACES, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
103 extract_map_files_cas(
104 &vfs,
105 "content/gameplay",
106 MAP_FILES_GAMEPLAY,
107 &vfs_dir,
108 &cas_root,
109 &mut file_hashes,
110 progress,
111 )?;
112
113 if let Some(pb) = progress {
114 pb.finish_and_clear();
115 }
116
117 std::fs::create_dir_all(&output_dir)
118 .attach_with(|| format!("Failed to create output directory {}", output_dir.display()))?;
119
120 let vfs_clone = vfs.clone();
123 let game_params_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
124 let gmp = GameMetadataProvider::from_vfs(&vfs_clone)?;
125 let params: Vec<Param> = gmp.params().iter().map(|p| Arc::unwrap_or_clone(Arc::clone(p))).collect();
126 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(¶ms).map_err(|e| report!("Failed to serialize: {e}"))?;
127 Ok::<_, rootcause::Report>(bytes)
128 }));
129 match game_params_result {
130 Ok(Ok(bytes)) => {
131 std::fs::write(output_dir.join("game_params.rkyv"), &bytes)
132 .attach_with(|| "Failed to write game_params.rkyv")?;
133 }
134 Ok(Err(e)) => {
135 eprintln!("WARN: GameParams conversion failed for build {build}: {e:?}");
136 }
137 Err(_) => {
138 eprintln!("WARN: GameParams conversion panicked for build {build} (incompatible format)");
139 }
140 }
141
142 dump_all_translations(game_dir, build, &output_dir)?;
144
145 #[cfg(feature = "constants")]
147 {
148 match crate::constants::fetch_versioned_constants_blocking(build) {
149 Ok((data, actual_build)) => {
150 if let Ok(bytes) = serde_json::to_vec_pretty(&data) {
151 let _ = std::fs::write(output_dir.join("constants.json"), &bytes);
152 if actual_build != build {
153 tracing::info!("Stored constants from build {actual_build} (fallback for {build})");
154 }
155 }
156 }
157 Err(e) => {
158 tracing::warn!("Could not fetch constants for build {build}: {e}");
159 }
160 }
161 }
162
163 let metadata = BuildMetadata { version: version_str.to_string(), build, files: file_hashes };
165 metadata.save(&output_dir.join("metadata.toml"))?;
166
167 let builds_path = output_base.join("builds.toml");
169 let mut index = BuildsIndex::load(&builds_path);
170 index.upsert(BuildEntry {
171 version: version_str.to_string(),
172 build,
173 dir: format!("{version_str}_{build}"),
174 dumped_at: jiff::Zoned::now().to_string(),
175 });
176 index.save(&builds_path)?;
177
178 Ok(())
179}
180
181pub fn create_progress_bar(game_dir: &Path) -> Option<ProgressBar> {
183 let vfs = game_data::build_game_vfs(game_dir).ok()?;
184 let mut total_files = 0u64;
185 for dir in REQUIRED_VFS_DIRS {
186 total_files += count_vfs_dir_files(&vfs, dir);
187 }
188 total_files += REQUIRED_VFS_FILES.len() as u64;
189 total_files += count_map_files(&vfs, "spaces", MAP_FILES_SPACES);
190 total_files += count_map_files(&vfs, "content/gameplay", MAP_FILES_GAMEPLAY);
191
192 let pb = ProgressBar::new(total_files);
193 pb.set_style(
194 ProgressStyle::default_bar()
195 .template("{msg} [{bar:40}] {pos}/{len}")
196 .expect("valid template")
197 .progress_chars("=> "),
198 );
199 pb.set_message("Extracting VFS");
200 Some(pb)
201}
202
203pub fn remove_build(output_base: &Path, target_build: u32) -> Result<(), Report> {
205 let builds_path = output_base.join("builds.toml");
206 let mut index = BuildsIndex::load(&builds_path);
207 let entry = index
208 .find_by_build(target_build)
209 .ok_or_else(|| report!("Build {target_build} not found in builds.toml"))?
210 .clone();
211
212 let target_dir = output_base.join(&entry.dir);
213 let target_meta = BuildMetadata::load(&target_dir.join("metadata.toml"));
214
215 let mut live_hashes = std::collections::HashSet::new();
217 for other in &index.builds {
218 if other.build == target_build {
219 continue;
220 }
221 if let Some(meta) = BuildMetadata::load(&output_base.join(&other.dir).join("metadata.toml")) {
222 live_hashes.extend(meta.files.values().cloned());
223 }
224 }
225
226 if let Some(meta) = target_meta {
228 let cas_root = output_base.join("vfs_common");
229 for hash in meta.files.values() {
230 if !live_hashes.contains(hash) {
231 let path = cas::cas_path(&cas_root, hash);
232 let _ = std::fs::remove_file(&path);
233 }
234 }
235 let _ = cas::gc(&cas_root, &live_hashes);
237 }
238
239 if target_dir.exists() {
241 std::fs::remove_dir_all(&target_dir)
242 .attach_with(|| format!("Failed to remove build directory {}", target_dir.display()))?;
243 }
244
245 index.remove_build(target_build);
247 index.save(&builds_path)?;
248
249 Ok(())
250}
251
252fn dump_all_translations(game_dir: &Path, build: u32, output_dir: &Path) -> Result<(), Report> {
255 let texts_dir = game_dir.join("bin").join(build.to_string()).join("res/texts");
256 if !texts_dir.exists() {
257 tracing::warn!("Translations directory not found: {}", texts_dir.display());
258 return Ok(());
259 }
260 for entry in std::fs::read_dir(&texts_dir)
261 .attach_with(|| format!("Failed to read translations directory {}", texts_dir.display()))?
262 .flatten()
263 {
264 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
265 continue;
266 }
267 let lang = entry.file_name();
268 let mo_src = entry.path().join("LC_MESSAGES/global.mo");
269 if mo_src.exists() {
270 let mo_dest = output_dir.join("translations").join(&lang).join("LC_MESSAGES/global.mo");
271 std::fs::create_dir_all(mo_dest.parent().unwrap())?;
272 std::fs::copy(&mo_src, &mo_dest)?;
273 }
274 }
275 Ok(())
276}
277
278fn store_and_link(
282 data: &[u8],
283 rel_path: &str,
284 vfs_dir: &Path,
285 cas_root: &Path,
286 file_hashes: &mut BTreeMap<String, String>,
287) -> Result<(), Report> {
288 let hash = cas::store(cas_root, data)?;
289 let link_path = vfs_dir.join(rel_path.trim_start_matches('/'));
290 cas::link_file(cas_root, &hash, &link_path)?;
291 file_hashes.insert(rel_path.trim_start_matches('/').to_string(), hash);
292 Ok(())
293}
294
295fn extract_vfs_dir_cas(
296 vfs: &VfsPath,
297 vfs_path: &str,
298 vfs_dir: &Path,
299 cas_root: &Path,
300 file_hashes: &mut BTreeMap<String, String>,
301 progress: Option<&ProgressBar>,
302) -> Result<(), Report> {
303 let dir = match vfs.join(vfs_path) {
304 Ok(d) => d,
305 Err(_) => return Ok(()),
306 };
307 let walker = match dir.walk_dir() {
308 Ok(w) => w,
309 Err(_) => return Ok(()),
310 };
311
312 for entry in walker.flatten() {
313 let metadata = match entry.metadata() {
314 Ok(m) => m,
315 Err(_) => continue,
316 };
317 if metadata.file_type != VfsFileType::File {
318 continue;
319 }
320 let rel = entry.as_str();
321 let mut buf = Vec::new();
322 match entry.open_file() {
323 Ok(mut f) => f.read_to_end(&mut buf)?,
324 Err(e) => {
325 tracing::warn!("Failed to open VFS file {rel}: {e}");
326 continue;
327 }
328 };
329 store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
330 if let Some(pb) = progress {
331 pb.inc(1);
332 }
333 }
334 Ok(())
335}
336
337fn extract_vfs_file_cas(
338 vfs: &VfsPath,
339 vfs_path: &str,
340 vfs_dir: &Path,
341 cas_root: &Path,
342 file_hashes: &mut BTreeMap<String, String>,
343) -> Result<(), Report> {
344 let file = match vfs.join(vfs_path) {
345 Ok(f) => f,
346 Err(_) => {
347 tracing::warn!("VFS path not found (skipping): {vfs_path}");
348 return Ok(());
349 }
350 };
351 let mut buf = Vec::new();
352 match file.open_file() {
353 Ok(mut f) => f.read_to_end(&mut buf)?,
354 Err(_) => {
355 tracing::warn!("Could not open VFS file (skipping): {vfs_path}");
356 return Ok(());
357 }
358 };
359 store_and_link(&buf, vfs_path, vfs_dir, cas_root, file_hashes)?;
360 Ok(())
361}
362
363fn extract_map_files_cas(
364 vfs: &VfsPath,
365 parent_dir: &str,
366 filenames: &[&str],
367 vfs_dir: &Path,
368 cas_root: &Path,
369 file_hashes: &mut BTreeMap<String, String>,
370 progress: Option<&ProgressBar>,
371) -> Result<(), Report> {
372 let parent = match vfs.join(parent_dir) {
373 Ok(d) => d,
374 Err(_) => return Ok(()),
375 };
376 let entries = match parent.read_dir() {
377 Ok(e) => e,
378 Err(_) => return Ok(()),
379 };
380
381 for entry in entries {
382 if !entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
383 continue;
384 }
385 for filename in filenames {
386 let file_path = match entry.join(filename) {
387 Ok(f) => f,
388 Err(_) => continue,
389 };
390 if !file_path.exists().unwrap_or(false) {
391 continue;
392 }
393 let rel = file_path.as_str();
394 let mut buf = Vec::new();
395 match file_path.open_file() {
396 Ok(mut f) => f.read_to_end(&mut buf)?,
397 Err(e) => {
398 tracing::warn!("Failed to open VFS file {rel}: {e}");
399 continue;
400 }
401 };
402 store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
403 if let Some(pb) = progress {
404 pb.inc(1);
405 }
406 }
407 }
408 Ok(())
409}
410
411fn count_vfs_dir_files(vfs: &VfsPath, dir: &str) -> u64 {
414 let mut count = 0;
415 if let Ok(vfs_dir_path) = vfs.join(dir)
416 && let Ok(walker) = vfs_dir_path.walk_dir()
417 {
418 for entry in walker.flatten() {
419 if entry.metadata().map(|m| m.file_type == VfsFileType::File).unwrap_or(false) {
420 count += 1;
421 }
422 }
423 }
424 count
425}
426
427fn count_map_files(vfs: &VfsPath, parent_dir: &str, filenames: &[&str]) -> u64 {
428 let mut count = 0;
429 if let Ok(parent) = vfs.join(parent_dir)
430 && let Ok(entries) = parent.read_dir()
431 {
432 for entry in entries {
433 if entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
434 for filename in filenames {
435 if entry.join(filename).is_ok_and(|f: VfsPath| f.exists().unwrap_or(false)) {
436 count += 1;
437 }
438 }
439 }
440 }
441 }
442 count
443}