1use std::borrow::ToOwned;
4use std::cmp::Ordering;
5use std::ffi::OsStr;
6use std::fmt::{Display, Formatter};
7use std::path::{Path, PathBuf};
8use std::string::ToString;
9use std::sync::RwLock;
10
11use anyhow::{Result, bail};
12use rayon::prelude::*;
13use walkdir::WalkDir;
14
15use crate::block::Block;
16use crate::everything::{Everything, FilesError};
17use crate::game::Game;
18use crate::helpers::TigerHashSet;
19use crate::item::Item;
20#[cfg(any(feature = "vic3", feature = "eu5"))]
21use crate::mod_metadata::ModMetadata;
22#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
23use crate::modfile::ModFile;
24use crate::parse::ParserMemory;
25use crate::pathtable::{PathTable, PathTableIndex};
26use crate::report::{
27 ErrorKey, Severity, add_loaded_dlc_root, add_loaded_mod_root, err, fatal, report,
28};
29use crate::token::Token;
30use crate::util::fix_slashes_for_target_platform;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
37pub enum FileKind {
38 Internal,
40 Clausewitz,
42 Jomini,
43 Vanilla,
45 Dlc(u8),
47 LoadedMod(u8),
49 Mod,
51}
52
53impl FileKind {
54 pub fn counts_as_vanilla(&self) -> bool {
55 match self {
56 FileKind::Clausewitz | FileKind::Jomini | FileKind::Vanilla | FileKind::Dlc(_) => true,
57 FileKind::Internal | FileKind::LoadedMod(_) | FileKind::Mod => false,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)]
67pub enum FileStage {
68 #[cfg(feature = "eu5")]
69 LoadingScreen,
70 #[cfg(feature = "eu5")]
71 MainMenu,
72 #[cfg(feature = "eu5")]
73 InGame,
74 NoStage,
75}
76
77impl FileStage {
78 fn with_dir(self, path: &Path) -> PathBuf {
79 let toplevel: Option<&'static str> = match self {
80 #[cfg(feature = "eu5")]
81 FileStage::LoadingScreen => Some("loading_screen"),
82 #[cfg(feature = "eu5")]
83 FileStage::MainMenu => Some("main_menu"),
84 #[cfg(feature = "eu5")]
85 FileStage::InGame => Some("in_game"),
86 FileStage::NoStage => None,
87 };
88 let mut p = path.to_owned();
90 if let Some(toplevel) = toplevel {
91 p.push(toplevel);
92 }
93 p
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct FileEntry {
99 path: PathBuf,
102 stage: FileStage,
104 kind: FileKind,
106 idx: Option<PathTableIndex>,
110 fullpath: PathBuf,
112}
113
114impl FileEntry {
115 pub fn new(path: PathBuf, stage: FileStage, kind: FileKind, fullpath: PathBuf) -> Self {
116 assert!(path.file_name().is_some());
117 Self { path, stage, kind, idx: None, fullpath }
118 }
119
120 pub fn stage(&self) -> FileStage {
121 self.stage
122 }
123
124 pub fn kind(&self) -> FileKind {
125 self.kind
126 }
127
128 pub fn path(&self) -> &Path {
129 &self.path
130 }
131
132 pub fn fullpath(&self) -> &Path {
133 &self.fullpath
134 }
135
136 #[allow(clippy::missing_panics_doc)]
139 pub fn filename(&self) -> &OsStr {
140 self.path.file_name().unwrap()
141 }
142
143 fn store_in_pathtable(&mut self) {
144 assert!(self.idx.is_none());
145 self.idx = Some(PathTable::store(self.path.clone(), self.fullpath.clone()));
146 }
147
148 pub fn path_idx(&self) -> Option<PathTableIndex> {
149 self.idx
150 }
151}
152
153impl Display for FileEntry {
154 fn fmt(&self, fmt: &mut Formatter) -> Result<(), std::fmt::Error> {
155 write!(fmt, "{}", self.path.display())
156 }
157}
158
159impl PartialOrd for FileEntry {
160 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
161 Some(self.cmp(other))
162 }
163}
164
165impl Ord for FileEntry {
166 fn cmp(&self, other: &Self) -> Ordering {
167 #[allow(clippy::unnecessary_unwrap)]
170 let ord = if self.idx.is_some() && other.idx.is_some() {
171 self.idx.unwrap().cmp(&other.idx.unwrap())
172 } else {
173 self.path.cmp(&other.path)
174 };
175
176 let ord = if ord == Ordering::Equal { self.stage.cmp(&other.stage) } else { ord };
179
180 if ord == Ordering::Equal { self.kind.cmp(&other.kind) } else { ord }
182 }
183}
184
185pub trait FileHandler<T: Send>: Sync + Send {
187 fn config(&mut self, _config: &Block) {}
189
190 fn subpath(&self) -> PathBuf;
194
195 fn load_file(&self, entry: &FileEntry, parser: &ParserMemory) -> Option<T>;
200
201 fn handle_file(&mut self, entry: &FileEntry, loaded: T);
204
205 fn finalize(&mut self) {}
208}
209
210#[derive(Clone, Debug)]
211pub struct LoadedMod {
212 kind: FileKind,
214
215 #[allow(dead_code)]
217 label: String,
218
219 root: PathBuf,
221
222 replace_paths: Vec<PathBuf>,
224}
225
226impl LoadedMod {
227 fn new_main_mod(root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
228 Self { kind: FileKind::Mod, label: "MOD".to_string(), root, replace_paths }
229 }
230
231 fn new(kind: FileKind, label: String, root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
232 Self { kind, label, root, replace_paths }
233 }
234
235 pub fn root(&self) -> &Path {
236 &self.root
237 }
238
239 pub fn kind(&self) -> FileKind {
240 self.kind
241 }
242
243 pub fn should_replace(&self, path: &Path) -> bool {
244 self.replace_paths.iter().any(|p| p == path)
245 }
246}
247
248#[derive(Debug)]
249pub struct Fileset {
250 vanilla_root: Option<PathBuf>,
252
253 #[cfg(feature = "jomini")]
255 clausewitz_root: Option<PathBuf>,
256
257 #[cfg(feature = "jomini")]
259 jomini_root: Option<PathBuf>,
260
261 the_mod: LoadedMod,
263
264 pub loaded_mods: Vec<LoadedMod>,
266
267 loaded_dlcs: Vec<LoadedMod>,
269
270 config: Option<Block>,
272
273 files: Vec<FileEntry>,
275
276 ordered_files: Vec<FileEntry>,
278
279 filename_tokens: Vec<Token>,
282
283 filenames: TigerHashSet<PathBuf>,
285
286 directories: RwLock<TigerHashSet<PathBuf>>,
288
289 used: RwLock<TigerHashSet<String>>,
291}
292
293impl Fileset {
294 pub fn new(vanilla_dir: Option<&Path>, mod_root: PathBuf, replace_paths: Vec<PathBuf>) -> Self {
295 let vanilla_root = if Game::is_jomini() {
296 vanilla_dir.map(|dir| dir.join("game"))
297 } else {
298 vanilla_dir.map(ToOwned::to_owned)
299 };
300 #[cfg(feature = "jomini")]
301 let clausewitz_root = vanilla_dir.map(|dir| dir.join("clausewitz"));
302 #[cfg(feature = "jomini")]
303 let jomini_root = vanilla_dir.map(|dir| dir.join("jomini"));
304
305 Fileset {
306 vanilla_root,
307 #[cfg(feature = "jomini")]
308 clausewitz_root,
309 #[cfg(feature = "jomini")]
310 jomini_root,
311 the_mod: LoadedMod::new_main_mod(mod_root, replace_paths),
312 loaded_mods: Vec::new(),
313 loaded_dlcs: Vec::new(),
314 config: None,
315 files: Vec::new(),
316 ordered_files: Vec::new(),
317 filename_tokens: Vec::new(),
318 filenames: TigerHashSet::default(),
319 directories: RwLock::new(TigerHashSet::default()),
320 used: RwLock::new(TigerHashSet::default()),
321 }
322 }
323
324 pub fn config(
325 &mut self,
326 config: Block,
327 #[allow(unused_variables)] workshop_dir: Option<&Path>,
328 #[allow(unused_variables)] paradox_dir: Option<&Path>,
329 ) -> Result<()> {
330 let config_path = config.loc.fullpath();
331 for block in config.get_field_blocks("load_mod") {
332 let mod_idx;
333 if let Ok(idx) = u8::try_from(self.loaded_mods.len()) {
334 mod_idx = idx;
335 } else {
336 bail!("too many loaded mods, cannot process more");
337 }
338
339 let default_label = || format!("MOD{mod_idx}");
340 let label =
341 block.get_field_value("label").map_or_else(default_label, ToString::to_string);
342
343 if Game::is_ck3() || Game::is_imperator() || Game::is_hoi4() {
344 #[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
345 if let Some(path) = get_modfile(&label, config_path, block, paradox_dir) {
346 let modfile = ModFile::read(&path)?;
347 eprintln!(
348 "Loading secondary mod {label} from: {}{}",
349 modfile.modpath().display(),
350 modfile
351 .display_name()
352 .map_or_else(String::new, |name| format!(" \"{name}\"")),
353 );
354 let kind = FileKind::LoadedMod(mod_idx);
355 let loaded_mod = LoadedMod::new(
356 kind,
357 label.clone(),
358 modfile.modpath().clone(),
359 modfile.replace_paths(),
360 );
361 add_loaded_mod_root(label);
362 self.loaded_mods.push(loaded_mod);
363 } else {
364 bail!(
365 "could not load secondary mod from config; missing valid `modfile` or `workshop_id` field"
366 );
367 }
368 } else if Game::is_vic3() || Game::is_eu5() {
369 #[cfg(any(feature = "vic3", feature = "eu5"))]
370 if let Some(pathdir) = get_mod(&label, config_path, block, workshop_dir) {
371 match ModMetadata::read(&pathdir) {
372 Ok(metadata) => {
373 eprintln!(
374 "Loading secondary mod {label} from: {}{}",
375 pathdir.display(),
376 metadata
377 .display_name()
378 .map_or_else(String::new, |name| format!(" \"{name}\"")),
379 );
380 let kind = FileKind::LoadedMod(mod_idx);
381 let loaded_mod = LoadedMod::new(
382 kind,
383 label.clone(),
384 pathdir,
385 metadata.replace_paths(),
386 );
387 add_loaded_mod_root(label);
388 self.loaded_mods.push(loaded_mod);
389 }
390 Err(e) => {
391 eprintln!(
392 "could not load secondary mod {label} from: {}",
393 pathdir.display()
394 );
395 eprintln!(" because: {e}");
396 }
397 }
398 } else {
399 bail!(
400 "could not load secondary mod from config; missing valid `mod` or `workshop_id` field"
401 );
402 }
403 }
404 }
405 self.config = Some(config);
406 Ok(())
407 }
408
409 fn should_replace(&self, path: &Path, kind: FileKind) -> bool {
410 if kind == FileKind::Mod {
411 return false;
412 }
413 if kind < FileKind::Mod && self.the_mod.should_replace(path) {
414 return true;
415 }
416 for loaded_mod in &self.loaded_mods {
417 if kind < loaded_mod.kind && loaded_mod.should_replace(path) {
418 return true;
419 }
420 }
421 false
422 }
423
424 fn scan(
425 &mut self,
426 path: &Path,
427 stage: FileStage,
428 kind: FileKind,
429 ) -> Result<(), walkdir::Error> {
430 for entry in WalkDir::new(path) {
431 let entry = entry?;
432 if entry.depth() == 0 || !entry.file_type().is_file() {
433 continue;
434 }
435 let inner_path = entry.path().strip_prefix(path).unwrap();
437 if inner_path.starts_with(".git") {
438 continue;
439 }
440 let inner_dir = inner_path.parent().unwrap_or_else(|| Path::new(""));
441 if self.should_replace(inner_dir, kind) {
442 continue;
443 }
444 self.files.push(FileEntry::new(
445 inner_path.to_path_buf(),
446 stage,
447 kind,
448 entry.path().to_path_buf(),
449 ));
450 }
451 Ok(())
452 }
453
454 #[allow(clippy::nonminimal_bool)] fn scan_stage(&mut self, stage: FileStage) -> Result<(), FilesError> {
456 #[cfg(feature = "jomini")]
457 if let Some(path) = &self.clausewitz_root {
458 let path = stage.with_dir(path);
459 if !(Game::is_eu5() && !path.exists()) {
460 self.scan(&path, stage, FileKind::Clausewitz)
461 .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
462 }
463 }
464 #[cfg(feature = "jomini")]
465 if let Some(path) = &self.jomini_root {
466 let path = stage.with_dir(path);
467 if !(Game::is_eu5() && !path.exists()) {
468 self.scan(&path, stage, FileKind::Jomini)
469 .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
470 }
471 }
472 if let Some(path) = &self.vanilla_root {
473 let path = stage.with_dir(path);
474 if !(Game::is_eu5() && !path.exists()) {
475 self.scan(&path, stage, FileKind::Vanilla)
476 .map_err(|e| FilesError::VanillaUnreadable { path: path.clone(), source: e })?;
477 }
478 #[cfg(feature = "hoi4")]
479 if Game::is_hoi4() {
480 self.load_dlcs(&path.join("integrated_dlc"))?;
481 }
482 if !Game::is_eu5() {
484 self.load_dlcs(&path.join("dlc"))?;
485 }
486 }
487 for loaded_mod in &self.loaded_mods.clone() {
489 let path = stage.with_dir(loaded_mod.root());
490 if !(Game::is_eu5() && !path.exists()) {
491 self.scan(&path, stage, loaded_mod.kind())
492 .map_err(|e| FilesError::ModUnreadable { path: path.clone(), source: e })?;
493 }
494 }
495 let path = stage.with_dir(self.the_mod.root());
496 if !(Game::is_eu5() && !path.exists()) {
497 self.scan(&path, stage, FileKind::Mod)
498 .map_err(|e| FilesError::ModUnreadable { path: path.clone(), source: e })?;
499 }
500 Ok(())
501 }
502
503 pub fn scan_all(&mut self) -> Result<(), FilesError> {
504 if Game::is_eu5() {
505 #[cfg(feature = "eu5")]
506 self.scan_stage(FileStage::LoadingScreen)?;
507 #[cfg(feature = "eu5")]
508 self.scan_stage(FileStage::MainMenu)?;
509 #[cfg(feature = "eu5")]
510 self.scan_stage(FileStage::InGame)?;
511 } else {
512 self.scan_stage(FileStage::NoStage)?;
513 }
514 Ok(())
515 }
516
517 pub fn load_dlcs(&mut self, dlc_root: &Path) -> Result<(), FilesError> {
518 for entry in WalkDir::new(dlc_root).max_depth(1).sort_by_file_name().into_iter().flatten() {
519 if entry.depth() == 1 && entry.file_type().is_dir() {
520 let label = entry.file_name().to_string_lossy().to_string();
521 let idx =
522 u8::try_from(self.loaded_dlcs.len()).expect("more than 256 DLCs installed");
523 let dlc = LoadedMod::new(
524 FileKind::Dlc(idx),
525 label.clone(),
526 entry.path().to_path_buf(),
527 Vec::new(),
528 );
529 self.scan(dlc.root(), FileStage::NoStage, dlc.kind()).map_err(|e| {
531 FilesError::VanillaUnreadable { path: dlc.root().to_path_buf(), source: e }
532 })?;
533 self.loaded_dlcs.push(dlc);
534 add_loaded_dlc_root(label);
535 }
536 }
537 Ok(())
538 }
539
540 pub fn finalize(&mut self) {
541 self.files.sort();
544
545 for entry in self.files.drain(..) {
547 if let Some(prev) = self.ordered_files.last_mut() {
548 if entry.path == prev.path {
549 *prev = entry;
550 } else {
551 self.ordered_files.push(entry);
552 }
553 } else {
554 self.ordered_files.push(entry);
555 }
556 }
557
558 for entry in &mut self.ordered_files {
559 let token = Token::new(&entry.filename().to_string_lossy(), (&*entry).into());
560 self.filename_tokens.push(token);
561 entry.store_in_pathtable();
562 self.filenames.insert(entry.path.clone());
563 }
564 }
565
566 pub fn get_files_under<'a>(&'a self, subpath: &'a Path) -> &'a [FileEntry] {
567 let start = self.ordered_files.partition_point(|entry| entry.path < subpath);
568 let end = start
569 + self.ordered_files[start..].partition_point(|entry| entry.path.starts_with(subpath));
570 &self.ordered_files[start..end]
571 }
572
573 pub fn filter_map_under<F, T>(&self, subpath: &Path, f: F) -> Vec<T>
574 where
575 F: Fn(&FileEntry) -> Option<T> + Sync + Send,
576 T: Send,
577 {
578 self.get_files_under(subpath).par_iter().filter_map(f).collect()
579 }
580
581 pub fn handle<T: Send, H: FileHandler<T>>(&self, handler: &mut H, parser: &ParserMemory) {
582 if let Some(config) = &self.config {
583 handler.config(config);
584 }
585 let subpath = handler.subpath();
586 let entries = self.filter_map_under(&subpath, |entry| {
587 handler.load_file(entry, parser).map(|loaded| (entry.clone(), loaded))
588 });
589 for (entry, loaded) in entries {
590 handler.handle_file(&entry, loaded);
591 }
592 handler.finalize();
593 }
594
595 pub fn mark_used(&self, file: &str) {
596 let file = file.strip_prefix('/').unwrap_or(file);
597 self.used.write().unwrap().insert(file.to_string());
598 }
599
600 pub fn exists(&self, key: &str) -> bool {
601 let key = key.strip_prefix('/').unwrap_or(key);
602 let filepath = if Game::is_hoi4() && key.contains('\\') {
603 PathBuf::from(key.replace('\\', "/"))
604 } else {
605 PathBuf::from(key)
606 };
607 self.filenames.contains(&filepath)
608 }
609
610 pub fn iter_keys(&self) -> impl Iterator<Item = &Token> {
611 self.filename_tokens.iter()
612 }
613
614 pub fn entry_exists(&self, key: &str) -> bool {
615 if self.exists(key) {
617 return true;
618 }
619
620 let dir = key.strip_prefix('/').unwrap_or(key);
622 let dirpath = Path::new(dir);
623
624 if self.directories.read().unwrap().contains(dirpath) {
625 return true;
626 }
627
628 match self.ordered_files.binary_search_by_key(&dirpath, |fe| fe.path.as_path()) {
629 Ok(_) => unreachable!(),
631 Err(idx) => {
632 if self.ordered_files[idx].path.starts_with(dirpath) {
634 self.directories.write().unwrap().insert(dirpath.to_path_buf());
635 return true;
636 }
637 }
638 }
639 false
640 }
641
642 pub fn verify_entry_exists(&self, entry: &str, token: &Token, max_sev: Severity) {
643 self.mark_used(&entry.replace("//", "/"));
644 if !self.entry_exists(entry) {
645 let msg = format!("file or directory {entry} does not exist");
646 report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
647 .msg(msg)
648 .loc(token)
649 .push();
650 }
651 }
652
653 #[cfg(feature = "ck3")] pub fn verify_exists(&self, file: &Token) {
655 self.mark_used(&file.as_str().replace("//", "/"));
656 if !self.exists(file.as_str()) {
657 let msg = "referenced file does not exist";
658 report(ErrorKey::MissingFile, Item::File.severity()).msg(msg).loc(file).push();
659 }
660 }
661
662 pub fn verify_exists_implied(&self, file: &str, t: &Token, max_sev: Severity) {
663 self.mark_used(&file.replace("//", "/"));
664 if !self.exists(file) {
665 let msg = format!("file {file} does not exist");
666 report(ErrorKey::MissingFile, Item::File.severity().at_most(max_sev))
667 .msg(msg)
668 .loc(t)
669 .push();
670 }
671 }
672
673 pub fn verify_exists_implied_crashes(&self, file: &str, t: &Token) {
674 self.mark_used(&file.replace("//", "/"));
675 if !self.exists(file) {
676 let msg = format!("file {file} does not exist");
677 fatal(ErrorKey::Crash).msg(msg).loc(t).push();
678 }
679 }
680
681 pub fn validate(&self, _data: &Everything) {
682 let common_dirs = match Game::game() {
683 #[cfg(feature = "ck3")]
684 Game::Ck3 => crate::ck3::tables::misc::COMMON_DIRS,
685 #[cfg(feature = "vic3")]
686 Game::Vic3 => crate::vic3::tables::misc::COMMON_DIRS,
687 #[cfg(feature = "imperator")]
688 Game::Imperator => crate::imperator::tables::misc::COMMON_DIRS,
689 #[cfg(feature = "eu5")]
690 Game::Eu5 => crate::eu5::tables::misc::COMMON_DIRS,
691 #[cfg(feature = "hoi4")]
692 Game::Hoi4 => crate::hoi4::tables::misc::COMMON_DIRS,
693 };
694 let common_subdirs_ok = match Game::game() {
695 #[cfg(feature = "ck3")]
696 Game::Ck3 => crate::ck3::tables::misc::COMMON_SUBDIRS_OK,
697 #[cfg(feature = "vic3")]
698 Game::Vic3 => crate::vic3::tables::misc::COMMON_SUBDIRS_OK,
699 #[cfg(feature = "imperator")]
700 Game::Imperator => crate::imperator::tables::misc::COMMON_SUBDIRS_OK,
701 #[cfg(feature = "eu5")]
702 Game::Eu5 => crate::eu5::tables::misc::COMMON_SUBDIRS_OK,
703 #[cfg(feature = "hoi4")]
704 Game::Hoi4 => crate::hoi4::tables::misc::COMMON_SUBDIRS_OK,
705 };
706 let mut warned: Vec<&Path> = Vec::new();
708 'outer: for entry in &self.ordered_files {
709 if !entry.path.to_string_lossy().ends_with(".txt") {
710 continue;
711 }
712 if entry.path == OsStr::new("common/achievement_groups.txt") {
713 continue;
714 }
715 #[cfg(feature = "hoi4")]
716 if Game::is_hoi4() {
717 for valid in crate::hoi4::tables::misc::COMMON_FILES {
718 if <&str as AsRef<Path>>::as_ref(valid) == entry.path {
719 continue 'outer;
720 }
721 }
722 }
723 let dirname = entry.path.parent().unwrap();
724 if warned.contains(&dirname) {
725 continue;
726 }
727 if !entry.path.starts_with("common") {
728 let joined = Path::new("common").join(&entry.path);
730 for valid in common_dirs {
731 if joined.starts_with(valid) {
732 let msg = format!("file in unexpected directory {}", dirname.display());
733 let info = format!("did you mean common/{} ?", dirname.display());
734 err(ErrorKey::Filename).msg(msg).info(info).loc(entry).push();
735 warned.push(dirname);
736 continue 'outer;
737 }
738 }
739 continue;
740 }
741
742 for valid in common_subdirs_ok {
743 if entry.path.starts_with(valid) {
744 continue 'outer;
745 }
746 }
747
748 for valid in common_dirs {
749 if <&str as AsRef<Path>>::as_ref(valid) == dirname {
750 continue 'outer;
751 }
752 }
753
754 if entry.path.starts_with("common/scripted_values") {
755 let msg = "file should be in common/script_values/";
756 err(ErrorKey::Filename).msg(msg).loc(entry).push();
757 } else if (Game::is_ck3() || Game::is_imperator())
758 && entry.path.starts_with("common/on_actions")
759 {
760 let msg = "file should be in common/on_action/";
761 err(ErrorKey::Filename).msg(msg).loc(entry).push();
762 } else if (Game::is_vic3() || Game::is_hoi4())
763 && entry.path.starts_with("common/on_action")
764 {
765 let msg = "file should be in common/on_actions/";
766 err(ErrorKey::Filename).msg(msg).loc(entry).push();
767 } else if Game::is_vic3() && entry.path.starts_with("common/modifiers") {
768 let msg = "file should be in common/static_modifiers since 1.7";
769 err(ErrorKey::Filename).msg(msg).loc(entry).push();
770 } else if Game::is_ck3() && entry.path.starts_with("common/vassal_contracts") {
771 let msg = "common/vassal_contracts was replaced with common/subject_contracts/contracts/ in 1.16";
772 err(ErrorKey::Filename).msg(msg).loc(entry).push();
773 } else {
774 let msg = format!("file in unexpected directory `{}`", dirname.display());
775 err(ErrorKey::Filename).msg(msg).loc(entry).push();
776 }
777 warned.push(dirname);
778 }
779 }
780
781 pub fn check_unused_dds(&self, _data: &Everything) {
782 let mut vec = Vec::new();
783 for entry in &self.ordered_files {
784 let pathname = entry.path.to_string_lossy();
785 if entry.path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("dds"))
786 && !entry.path.starts_with("gfx/interface/illustrations/loading_screens")
787 && !self.used.read().unwrap().contains(pathname.as_ref())
788 {
789 vec.push(entry);
790 }
791 }
792 for entry in vec {
793 report(ErrorKey::UnusedFile, Severity::Untidy)
794 .msg("Unused DDS files")
795 .abbreviated(entry)
796 .push();
797 }
798 }
799}
800
801#[cfg(any(feature = "ck3", feature = "imperator", feature = "hoi4"))]
802fn get_modfile(
803 label: &String,
804 config_path: &Path,
805 block: &Block,
806 paradox_dir: Option<&Path>,
807) -> Option<PathBuf> {
808 let mut path: Option<PathBuf> = None;
809 if let Some(modfile) = block.get_field_value("modfile") {
810 let modfile_path = fix_slashes_for_target_platform(
811 config_path
812 .parent()
813 .unwrap() .join(modfile.as_str()),
815 );
816 if modfile_path.exists() {
817 path = Some(modfile_path);
818 } else {
819 eprintln!("Could not find mod {label} at: {}", modfile_path.display());
820 }
821 }
822 if path.is_none() {
823 if let Some(workshop_id) = block.get_field_value("workshop_id") {
824 match paradox_dir {
825 Some(p) => {
826 path = Some(fix_slashes_for_target_platform(
827 p.join(format!("mod/ugc_{workshop_id}.mod")),
828 ));
829 }
830 None => eprintln!("workshop_id defined, but could not find paradox directory"),
831 }
832 }
833 }
834 path
835}
836
837#[cfg(any(feature = "vic3", feature = "eu5"))]
838fn get_mod(
839 label: &String,
840 config_path: &Path,
841 block: &Block,
842 workshop_dir: Option<&Path>,
843) -> Option<PathBuf> {
844 let mut path: Option<PathBuf> = None;
845 if let Some(modfile) = block.get_field_value("mod") {
846 let mod_path = fix_slashes_for_target_platform(
847 config_path
848 .parent()
849 .unwrap() .join(modfile.as_str()),
851 );
852 if mod_path.exists() {
853 path = Some(mod_path);
854 } else {
855 eprintln!("Could not find mod {label} at: {}", mod_path.display());
856 }
857 }
858 if path.is_none() {
859 if let Some(workshop_id) = block.get_field_value("workshop_id") {
860 match workshop_dir {
861 Some(w) => {
862 path = Some(fix_slashes_for_target_platform(w.join(workshop_id.as_str())));
863 }
864 None => eprintln!("workshop_id defined, but could not find workshop"),
865 }
866 }
867 }
868 path
869}