1use anyhow::{Result, bail};
16use std::collections::HashMap;
17use std::fs;
18#[cfg(target_os = "horizon")]
19use std::io::Read;
20use std::path::{Path, PathBuf};
21
22use std::path::Component;
23
24#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
25use crate::wasm_vfs::SiglusVfs;
26#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
27use std::ffi::{OsStr, OsString};
28#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
29use std::time::SystemTime;
30
31#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
32fn path_to_wasm_vfs(path: &Path) -> String {
33 path.to_string_lossy()
34 .replace('\\', "/")
35 .split('/')
36 .filter(|part| !part.is_empty() && *part != ".")
37 .collect::<Vec<_>>()
38 .join("/")
39}
40
41#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
42pub(crate) fn wasm_path_exists(path: &Path) -> bool {
43 crate::wasm_vfs::WasmDirectoryVfs::new().exists(&path_to_wasm_vfs(path))
44}
45
46#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
47pub(crate) fn wasm_path_is_file(path: &Path) -> bool {
48 wasm_path_exists(path)
49}
50
51#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
52pub(crate) fn wasm_path_is_dir(path: &Path) -> bool {
53 !crate::wasm_vfs::WasmDirectoryVfs::new()
54 .list_dir(&path_to_wasm_vfs(path))
55 .unwrap_or_default()
56 .is_empty()
57}
58
59#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
60pub fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
61 crate::wasm_vfs::WasmDirectoryVfs::new().read_all(&path_to_wasm_vfs(path))
62}
63
64#[cfg(target_os = "horizon")]
65pub fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
66 let Some(resolved) = resolve_windows_case_insensitive_file(path)? else {
67 bail!("file not found: {}", path.display());
68 };
69 let mut file = fs::File::open(&resolved)?;
73 let mut bytes = Vec::new();
74 let mut chunk = [0u8; 64 * 1024];
75 loop {
76 let count = file.read(&mut chunk)?;
77 if count == 0 {
78 break;
79 }
80 bytes.extend_from_slice(&chunk[..count]);
81 }
82 Ok(bytes)
83}
84
85#[cfg(all(
86 not(target_os = "horizon"),
87 not(all(target_arch = "wasm32", target_os = "unknown"))
88))]
89pub fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
90 let Some(resolved) = resolve_windows_case_insensitive_file(path)? else {
91 bail!("file not found: {}", path.display());
92 };
93 match fs::read(&resolved) {
94 Ok(bytes) => Ok(bytes),
95 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
96 invalidate_game_path_cache(path);
100 invalidate_game_path_cache(&resolved);
101 let Some(re_resolved) = resolve_windows_case_insensitive_file(path)? else {
102 bail!("file not found: {}", path.display());
103 };
104 Ok(fs::read(re_resolved)?)
105 }
106 Err(err) => Err(err.into()),
107 }
108}
109
110#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
111pub fn read_file_to_string(path: &Path) -> Result<String> {
112 let bytes = read_file_bytes(path)?;
113 Ok(String::from_utf8(bytes)?)
114}
115
116#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
117pub fn read_file_to_string(path: &Path) -> Result<String> {
118 Ok(String::from_utf8(read_file_bytes(path)?)?)
119}
120
121#[cfg(all(not(all(target_arch = "wasm32", target_os = "unknown")), unix))]
122type FoldedPathComponent = Vec<u8>;
123
124#[cfg(all(not(all(target_arch = "wasm32", target_os = "unknown")), windows))]
125type FoldedPathComponent = Vec<u16>;
126
127#[cfg(all(
128 not(all(target_arch = "wasm32", target_os = "unknown")),
129 not(any(unix, windows))
130))]
131type FoldedPathComponent = String;
132
133#[cfg(all(not(all(target_arch = "wasm32", target_os = "unknown")), unix))]
139fn fold_windows_component(name: &OsStr) -> FoldedPathComponent {
140 use std::os::unix::ffi::OsStrExt;
141
142 name.as_bytes()
143 .iter()
144 .map(|b| b.to_ascii_lowercase())
145 .collect()
146}
147
148#[cfg(all(not(all(target_arch = "wasm32", target_os = "unknown")), windows))]
149fn fold_windows_component(name: &OsStr) -> FoldedPathComponent {
150 use std::os::windows::ffi::OsStrExt;
151
152 name.encode_wide()
153 .map(|unit| {
154 if (b'A' as u16..=b'Z' as u16).contains(&unit) {
155 unit + (b'a' - b'A') as u16
156 } else {
157 unit
158 }
159 })
160 .collect()
161}
162
163#[cfg(all(
164 not(all(target_arch = "wasm32", target_os = "unknown")),
165 not(any(unix, windows))
166))]
167fn fold_windows_component(name: &OsStr) -> FoldedPathComponent {
168 name.to_string_lossy()
169 .chars()
170 .map(|ch| ch.to_ascii_lowercase())
171 .collect()
172}
173
174#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
175#[derive(Debug, Clone)]
176enum FoldedDirectoryEntry {
177 Unique(OsString),
178 Conflict(Vec<OsString>),
179}
180
181#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
182#[derive(Debug)]
183struct DirectoryCaseIndex {
184 modified: Option<SystemTime>,
185 entries: HashMap<FoldedPathComponent, FoldedDirectoryEntry>,
186}
187
188#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
189#[derive(Debug, Default)]
190struct NativePathResolverCache {
191 positive_files: HashMap<PathBuf, PathBuf>,
195 directories: HashMap<PathBuf, DirectoryCaseIndex>,
198}
199
200#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
206static NATIVE_PATH_RESOLVER_CACHE: std::sync::Mutex<Option<NativePathResolverCache>> =
207 std::sync::Mutex::new(None);
208
209#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
210fn native_cache_key(path: &Path) -> PathBuf {
211 path.to_path_buf()
212}
213
214#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
215fn directory_modified(path: &Path) -> Option<SystemTime> {
216 fs::metadata(path).and_then(|m| m.modified()).ok()
217}
218
219#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
220fn scan_directory_case_index(parent: &Path) -> Result<Option<DirectoryCaseIndex>> {
221 let read_dir = match fs::read_dir(parent) {
222 Ok(v) => v,
223 Err(err)
224 if matches!(
225 err.kind(),
226 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
227 ) =>
228 {
229 return Ok(None);
230 }
231 Err(err) => return Err(err.into()),
232 };
233
234 let mut entries: HashMap<FoldedPathComponent, FoldedDirectoryEntry> = HashMap::new();
235 for entry in read_dir {
236 let entry = entry?;
237 let name = entry.file_name();
238 let key = fold_windows_component(&name);
239 use std::collections::hash_map::Entry;
240 match entries.entry(key) {
241 Entry::Vacant(v) => {
242 v.insert(FoldedDirectoryEntry::Unique(name));
243 }
244 Entry::Occupied(mut o) => {
245 let value = o.get_mut();
246 match value {
247 FoldedDirectoryEntry::Unique(existing) => {
248 if existing != &name {
249 let first = existing.clone();
250 *value = FoldedDirectoryEntry::Conflict(vec![first, name]);
251 }
252 }
253 FoldedDirectoryEntry::Conflict(names) => {
254 if !names.iter().any(|v| v == &name) {
255 names.push(name);
256 }
257 }
258 }
259 }
260 }
261 }
262
263 for value in entries.values_mut() {
264 if let FoldedDirectoryEntry::Conflict(names) = value {
265 names.sort();
266 names.dedup();
267 }
268 }
269
270 Ok(Some(DirectoryCaseIndex {
271 modified: directory_modified(parent),
272 entries,
273 }))
274}
275
276#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
277fn cached_directory_entry(
278 parent: &Path,
279 requested: &OsStr,
280 force_refresh: bool,
281) -> Result<Option<FoldedDirectoryEntry>> {
282 let parent_key = native_cache_key(parent);
283 let folded = fold_windows_component(requested);
284
285 let (has_index, cached_modified, cached_entry) = {
286 let mut guard = NATIVE_PATH_RESOLVER_CACHE
287 .lock()
288 .unwrap_or_else(|poisoned| poisoned.into_inner());
289 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
290 match cache.directories.get(&parent_key) {
291 Some(index) => (true, index.modified, index.entries.get(&folded).cloned()),
292 None => (false, None, None),
293 }
294 };
295
296 if !force_refresh {
297 if let Some(entry) = cached_entry {
298 return Ok(Some(entry));
299 }
300 if has_index && directory_modified(parent) == cached_modified {
301 return Ok(None);
302 }
303 }
304
305 let Some(index) = scan_directory_case_index(parent)? else {
306 let mut guard = NATIVE_PATH_RESOLVER_CACHE
307 .lock()
308 .unwrap_or_else(|poisoned| poisoned.into_inner());
309 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
310 cache.directories.remove(&parent_key);
311 return Ok(None);
312 };
313 let result = index.entries.get(&folded).cloned();
314
315 let mut guard = NATIVE_PATH_RESOLVER_CACHE
316 .lock()
317 .unwrap_or_else(|poisoned| poisoned.into_inner());
318 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
319 cache.directories.insert(parent_key, index);
320 Ok(result)
321}
322
323#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
324fn resolve_cached_directory_entry(parent: &Path, requested: &OsStr) -> Result<Option<PathBuf>> {
325 let mut refreshed = false;
326 loop {
327 let Some(entry) = cached_directory_entry(parent, requested, refreshed)? else {
328 return Ok(None);
329 };
330 match entry {
331 FoldedDirectoryEntry::Unique(actual_name) => {
332 let candidate = parent.join(actual_name);
333 if candidate.exists() {
334 return Ok(Some(candidate));
335 }
336 if refreshed {
337 return Ok(None);
338 }
339 refreshed = true;
342 }
343 FoldedDirectoryEntry::Conflict(names) => {
344 bail!(
345 "case-insensitive path conflict for {} under {}: {}",
346 requested.to_string_lossy(),
347 parent.display(),
348 names
349 .iter()
350 .map(|name| parent.join(name).display().to_string())
351 .collect::<Vec<_>>()
352 .join(", ")
353 );
354 }
355 }
356 }
357}
358
359#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
360fn positive_file_cache_get(path: &Path) -> Option<PathBuf> {
361 let key = native_cache_key(path);
362 let mut guard = NATIVE_PATH_RESOLVER_CACHE
363 .lock()
364 .unwrap_or_else(|poisoned| poisoned.into_inner());
365 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
366 cache.positive_files.get(&key).cloned()
367}
368
369#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
370fn positive_file_cache_insert(path: &Path, resolved: &Path) {
371 let key = native_cache_key(path);
372 let mut guard = NATIVE_PATH_RESOLVER_CACHE
373 .lock()
374 .unwrap_or_else(|poisoned| poisoned.into_inner());
375 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
376 cache.positive_files.insert(key, resolved.to_path_buf());
377}
378
379#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
384pub(crate) fn invalidate_game_path_cache(path: &Path) {
385 let parent = path.parent().unwrap_or_else(|| Path::new("."));
386 let parent_key = native_cache_key(parent);
387 let path_key = native_cache_key(path);
388
389 let mut guard = NATIVE_PATH_RESOLVER_CACHE
390 .lock()
391 .unwrap_or_else(|poisoned| poisoned.into_inner());
392 let cache = guard.get_or_insert_with(NativePathResolverCache::default);
393 cache
394 .directories
395 .retain(|dir, _| dir != &parent_key && dir != &path_key && !dir.starts_with(&path_key));
396 cache.positive_files.retain(|requested, resolved| {
397 if requested == &path_key || requested.starts_with(&path_key) {
398 return false;
399 }
400 let resolved_key = native_cache_key(resolved);
401 let requested_parent = requested.parent();
402 let resolved_parent = resolved_key.parent();
403 requested_parent != Some(parent_key.as_path())
404 && resolved_parent != Some(parent_key.as_path())
405 && !resolved_key.starts_with(&path_key)
406 });
407}
408
409#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
410pub(crate) fn invalidate_game_path_cache(_path: &Path) {
411 }
415
416pub(crate) fn resolve_windows_case_insensitive_path(path: &Path) -> Result<Option<PathBuf>> {
417 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
418 {
419 if wasm_path_exists(path) {
423 return Ok(Some(path.to_path_buf()));
424 }
425 return Ok(None);
426 }
427
428 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
429 {
430 if path.exists() {
431 return Ok(Some(path.to_path_buf()));
432 }
433
434 let mut cur = PathBuf::new();
435 for component in path.components() {
436 match component {
437 Component::Prefix(prefix) => cur.push(prefix.as_os_str()),
438 Component::RootDir => cur.push(component.as_os_str()),
439 Component::CurDir => {}
440 Component::ParentDir => cur.push(".."),
441 Component::Normal(name) => {
442 let exact = cur.join(name);
443 if exact.exists() {
444 cur = exact;
445 continue;
446 }
447
448 let parent = if cur.as_os_str().is_empty() {
449 Path::new(".")
450 } else {
451 cur.as_path()
452 };
453 let Some(actual) = resolve_cached_directory_entry(parent, name)? else {
454 return Ok(None);
455 };
456 cur = actual;
457 }
458 }
459 }
460
461 Ok(cur.exists().then_some(cur))
462 }
463}
464
465pub(crate) fn resolve_windows_case_insensitive_file(path: &Path) -> Result<Option<PathBuf>> {
466 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
467 {
468 return Ok(wasm_path_is_file(path).then_some(path.to_path_buf()));
469 }
470
471 #[cfg(target_os = "horizon")]
476 {
477 return Ok(std::fs::File::open(path).ok().map(|_| path.to_path_buf()));
478 }
479
480 #[cfg(all(
481 not(target_os = "horizon"),
482 not(all(target_arch = "wasm32", target_os = "unknown"))
483 ))]
484 {
485 if let Some(cached) = positive_file_cache_get(path) {
486 return Ok(Some(cached));
487 }
488 if path.is_file() {
489 positive_file_cache_insert(path, path);
490 return Ok(Some(path.to_path_buf()));
491 }
492 let Some(resolved) = resolve_windows_case_insensitive_path(path)? else {
493 return Ok(None);
494 };
495 if !resolved.is_file() {
496 return Ok(None);
497 }
498 positive_file_cache_insert(path, &resolved);
499 Ok(Some(resolved))
500 }
501}
502
503pub fn resolve_game_path(path: &Path) -> Result<Option<PathBuf>> {
507 resolve_windows_case_insensitive_path(path)
508}
509
510pub fn resolve_game_file(path: &Path) -> Result<Option<PathBuf>> {
512 resolve_windows_case_insensitive_file(path)
513}
514
515pub fn game_path_exists(path: &Path) -> bool {
516 match resolve_windows_case_insensitive_path(path) {
517 Ok(Some(_)) => true,
518 Ok(None) => false,
519 Err(err) => {
520 log::error!(
521 "case-insensitive game path resolution failed for {}: {:#}",
522 path.display(),
523 err
524 );
525 false
526 }
527 }
528}
529
530pub fn game_file_exists(path: &Path) -> bool {
531 match resolve_windows_case_insensitive_file(path) {
532 Ok(Some(_)) => true,
533 Ok(None) => false,
534 Err(err) => {
535 log::error!(
536 "case-insensitive game file resolution failed for {}: {:#}",
537 path.display(),
538 err
539 );
540 false
541 }
542 }
543}
544
545#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
546pub(crate) fn open_game_file(path: &Path) -> Result<fs::File> {
547 let Some(resolved) = resolve_windows_case_insensitive_file(path)? else {
548 bail!("file not found: {}", path.display());
549 };
550 match fs::File::open(&resolved) {
551 Ok(file) => Ok(file),
552 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
553 invalidate_game_path_cache(path);
554 invalidate_game_path_cache(&resolved);
555 let Some(re_resolved) = resolve_windows_case_insensitive_file(path)? else {
556 bail!("file not found: {}", path.display());
557 };
558 Ok(fs::File::open(re_resolved)?)
559 }
560 Err(err) => Err(err.into()),
561 }
562}
563
564pub(crate) fn game_file_len(path: &Path) -> Result<u64> {
572 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
573 {
574 return Ok(read_file_bytes(path)?.len() as u64);
575 }
576
577 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
578 {
579 let Some(resolved) = resolve_windows_case_insensitive_file(path)? else {
580 bail!("file not found: {}", path.display());
581 };
582 match fs::metadata(&resolved) {
583 Ok(metadata) => Ok(metadata.len()),
584 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
585 invalidate_game_path_cache(path);
586 invalidate_game_path_cache(&resolved);
587 let Some(re_resolved) = resolve_windows_case_insensitive_file(path)? else {
588 bail!("file not found: {}", path.display());
589 };
590 Ok(fs::metadata(re_resolved)?.len())
591 }
592 Err(err) => Err(err.into()),
593 }
594 }
595}
596
597fn first_existing_file_windows_ci(
598 candidates: impl IntoIterator<Item = PathBuf>,
599) -> Result<Option<PathBuf>> {
600 for candidate in candidates {
601 if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
602 return Ok(Some(path));
603 }
604 }
605 Ok(None)
606}
607
608pub fn load_project_key_toml(
609 project_dir: &Path,
610) -> Result<Option<siglus_assets::key_toml::KeyTomlConfig>> {
611 let Some(path) = resolve_windows_case_insensitive_file(&project_dir.join("key.toml"))? else {
612 return Ok(None);
613 };
614 let text = read_file_to_string(&path)?;
615 Ok(Some(siglus_assets::key_toml::parse_key_toml(&text)?))
616}
617
618pub fn load_project_emote_key(project_dir: &Path) -> Result<Option<u32>> {
619 Ok(load_project_key_toml(project_dir)?.and_then(|cfg| cfg.emote_key))
620}
621
622#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
623static PROJECT_EXE_KEY_CACHE: std::sync::OnceLock<
624 std::sync::Mutex<HashMap<PathBuf, Option<[u8; 16]>>>,
625> = std::sync::OnceLock::new();
626
627#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
628fn project_exe_key_cache() -> &'static std::sync::Mutex<HashMap<PathBuf, Option<[u8; 16]>>> {
629 PROJECT_EXE_KEY_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
630}
631
632#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
633fn cache_project_exe_key(project_dir: &Path, key: Option<[u8; 16]>) -> Option<[u8; 16]> {
634 let mut cache = project_exe_key_cache()
635 .lock()
636 .unwrap_or_else(|poisoned| poisoned.into_inner());
637 cache.insert(project_dir.to_path_buf(), key);
638 key
639}
640
641#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
642fn cached_project_exe_key(project_dir: &Path) -> Option<Option<[u8; 16]>> {
643 let cache = project_exe_key_cache()
644 .lock()
645 .unwrap_or_else(|poisoned| poisoned.into_inner());
646 cache.get(project_dir).copied()
647}
648
649#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
650fn resolve_project_exe_key(project_dir: &Path) -> Option<[u8; 16]> {
651 if let Some(cached) = cached_project_exe_key(project_dir) {
652 return cached;
653 }
654
655 let configured_key = match siglus_assets::key_toml::load_key16_from_project_dir(project_dir) {
660 Ok(key) => key,
661 Err(err) => {
662 log::error!(
663 "failed to load configured Siglus EXE key from {}: {:#}; attempting resource key recovery",
664 project_dir.display(),
665 err
666 );
667 None
668 }
669 };
670
671 #[cfg(target_os = "horizon")]
677 {
678 if configured_key.is_none() {
679 log::warn!(
680 "no key.toml EXE key under {}; automatic key recovery is disabled on Horizon",
681 project_dir.display()
682 );
683 }
684 return cache_project_exe_key(project_dir, configured_key);
685 }
686
687 let game_path = match find_initial_gameexe_path(project_dir) {
688 Ok(path) => path,
689 Err(err) => {
690 log::error!(
691 "automatic Siglus EXE key recovery could not locate Gameexe.dat under {}: {:#}",
692 project_dir.display(),
693 err
694 );
695 return cache_project_exe_key(project_dir, configured_key);
696 }
697 };
698 if !game_path
699 .extension()
700 .and_then(|ext| ext.to_str())
701 .is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
702 {
703 return cache_project_exe_key(project_dir, configured_key);
706 }
707 let scene_path = match find_scene_pck_path(project_dir) {
708 Ok(path) => path,
709 Err(err) => {
710 log::error!(
711 "automatic Siglus EXE key recovery could not locate Scene.pck under {}: {:#}",
712 project_dir.display(),
713 err
714 );
715 return cache_project_exe_key(project_dir, configured_key);
716 }
717 };
718 let game = match read_file_bytes(&game_path) {
719 Ok(bytes) => bytes,
720 Err(err) => {
721 log::error!(
722 "automatic Siglus EXE key recovery failed to read {}: {:#}",
723 game_path.display(),
724 err
725 );
726 return cache_project_exe_key(project_dir, configured_key);
727 }
728 };
729 let scene = match read_file_bytes(&scene_path) {
730 Ok(bytes) => bytes,
731 Err(err) => {
732 log::error!(
733 "automatic Siglus EXE key recovery failed to read {}: {:#}",
734 scene_path.display(),
735 err
736 );
737 return cache_project_exe_key(project_dir, configured_key);
738 }
739 };
740
741 match siglus_key_recovery::resources_require_exe_key(&game, &scene) {
742 Ok(false) => return cache_project_exe_key(project_dir, configured_key),
743 Ok(true) => {}
744 Err(err) => {
745 log::error!(
746 "automatic Siglus EXE key recovery could not inspect resource encryption headers in {}: {}; keeping configured key",
747 project_dir.display(),
748 err
749 );
750 return cache_project_exe_key(project_dir, configured_key);
751 }
752 }
753
754 if let Some(key) = configured_key {
755 match siglus_key_recovery::check_key(&game, &scene, &key) {
756 Ok(siglus_key_recovery::KeyStatus::Accepted) => {
757 return cache_project_exe_key(project_dir, Some(key));
758 }
759 Ok(siglus_key_recovery::KeyStatus::Unverifiable) => {
760 log::info!(
765 "Siglus resources under {} store uncompressed scene chunks; keeping the configured EXE key without recovery",
766 project_dir.display()
767 );
768 return cache_project_exe_key(project_dir, Some(key));
769 }
770 Ok(siglus_key_recovery::KeyStatus::Mismatch) => {
771 log::error!(
772 "configured Siglus EXE key under {} failed resource validation; attempting automatic recovery",
773 project_dir.display()
774 );
775 }
776 Err(err) => {
777 log::error!(
778 "configured Siglus EXE key under {} could not be validated: {}; attempting automatic recovery",
779 project_dir.display(),
780 err
781 );
782 }
783 }
784 } else {
785 log::warn!(
786 "no usable Siglus EXE key configured under {}; attempting automatic resource recovery",
787 project_dir.display()
788 );
789 }
790
791 match siglus_key_recovery::scene_pack_is_compressed(&scene) {
796 Ok(true) => {}
797 Ok(false) => {
798 log::warn!(
799 "Scene.pck under {} stores uncompressed scene chunks; skipping automatic EXE key recovery",
800 project_dir.display()
801 );
802 return cache_project_exe_key(project_dir, configured_key);
803 }
804 Err(err) => {
805 log::error!(
806 "could not inspect the Scene.pck storage form under {}: {}",
807 project_dir.display(),
808 err
809 );
810 }
811 }
812
813 let recovered = match siglus_key_recovery::recover_key_from_resources(&game, &scene) {
814 Ok(key) => key,
815 Err(err) => {
816 log::error!(
817 "automatic Siglus EXE key recovery failed for {}: {}; continuing with the configured key state",
818 project_dir.display(),
819 err
820 );
821 return cache_project_exe_key(project_dir, configured_key);
822 }
823 };
824
825 log::warn!(
826 "recovered and hard-validated a Siglus EXE key for {}",
827 project_dir.display()
828 );
829
830 match siglus_assets::key_toml::write_key16_to_project_dir(project_dir, recovered) {
831 Ok(path) => {
832 invalidate_game_path_cache(&path);
833 log::info!("stored recovered Siglus EXE key in {}", path.display());
834 }
835 Err(err) => {
836 log::error!(
840 "recovered Siglus EXE key for {} but could not write key.toml: {:#}; using the recovered key in memory for this process",
841 project_dir.display(),
842 err
843 );
844 }
845 }
846
847 cache_project_exe_key(project_dir, Some(recovered))
848}
849
850pub fn load_scene_pck_decode_options(
851 project_dir: &Path,
852) -> Result<siglus_assets::scene_pck::ScenePckDecodeOptions> {
853 #[cfg(any(
854 all(target_arch = "wasm32", target_os = "unknown"),
855 target_os = "horizon"
856 ))]
857 {
858 return siglus_assets::scene_pck::ScenePckDecodeOptions::from_project_dir(project_dir);
863 }
864
865 #[cfg(all(
866 not(target_os = "horizon"),
867 not(all(target_arch = "wasm32", target_os = "unknown"))
868 ))]
869 {
870 let string_encryption_override = load_project_key_toml(project_dir)?
871 .map(|cfg| cfg.override_string_encryption)
872 .unwrap_or_default();
873 Ok(siglus_assets::scene_pck::ScenePckDecodeOptions {
874 exe_angou_element: resolve_project_exe_key(project_dir).map(|key| key.to_vec()),
875 easy_angou_code: Some(siglus_assets::keys::SCENE_KEY.to_vec()),
876 string_encryption_override,
877 })
878 }
879}
880
881pub fn load_gameexe_decode_options(
882 project_dir: &Path,
883) -> Result<siglus_assets::gameexe::GameexeDecodeOptions> {
884 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
885 {
886 let mut opt = siglus_assets::gameexe::GameexeDecodeOptions::default();
887 opt.game_angou_code = Some(siglus_assets::keys::GAMEEXE_KEY.to_vec());
888 if let Some(cfg) = load_project_key_toml(project_dir)? {
889 opt.exe_key16 = cfg.exe_key16;
890 opt.base_angou_code = cfg.base_angou_code;
891 if cfg.game_angou_code.is_some() {
892 opt.game_angou_code = cfg.game_angou_code;
893 }
894 if let Some(order) = cfg.chain_order {
895 opt.chain_order = order;
896 }
897 }
898 Ok(opt)
899 }
900
901 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
902 {
903 let recovered_or_configured = resolve_project_exe_key(project_dir);
904 match siglus_assets::gameexe::GameexeDecodeOptions::from_project_dir(project_dir) {
905 Ok(mut opt) => {
906 opt.exe_key16 = recovered_or_configured;
907 Ok(opt)
908 }
909 Err(err) => {
910 log::error!(
914 "failed to build Gameexe decode options from key.toml under {}: {:#}; using built-in defaults with the resolved EXE key",
915 project_dir.display(),
916 err
917 );
918 let mut opt = siglus_assets::gameexe::GameexeDecodeOptions {
919 exe_key16: recovered_or_configured,
920 game_angou_code: Some(siglus_assets::keys::GAMEEXE_KEY.to_vec()),
921 ..Default::default()
922 };
923
924 Ok(opt)
925 }
926 }
927 }
928}
929
930#[derive(Debug, Clone, PartialEq, Eq)]
931pub(crate) struct SelectIniAppendEntry {
932 pub dir: String,
933 pub name: String,
934}
935
936pub(crate) fn initial_select_ini_append(project_dir: &Path) -> SelectIniAppendEntry {
943 parse_select_ini_append_entries(project_dir)
944 .into_iter()
945 .next()
946 .unwrap_or(SelectIniAppendEntry {
947 dir: String::new(),
948 name: String::new(),
949 })
950}
951
952pub fn find_scene_pck_path_for_append(project_dir: &Path, append_dir: &str) -> Result<PathBuf> {
959 let mut base = project_dir.to_path_buf();
960 if !append_dir.is_empty() {
961 base = base.join(append_dir);
962 }
963 let candidate = base.join("Scene.pck");
964 if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
965 return Ok(path);
966 }
967
968 if append_dir.is_empty() {
972 let legacy = project_dir.join("Data").join("Scene.pck");
973 if let Some(path) = resolve_windows_case_insensitive_file(&legacy)? {
974 return Ok(path);
975 }
976 }
977
978 bail!(
979 "Scene.pck not found for append {:?} under {}",
980 append_dir,
981 project_dir.display()
982 )
983}
984
985pub fn find_scene_pck_path(project_dir: &Path) -> Result<PathBuf> {
986 let append = initial_select_ini_append(project_dir);
987 find_scene_pck_path_for_append(project_dir, &append.dir)
988}
989
990const GAMEEXE_CANDIDATES: &[&str] = &[
991 "Gameexe.dat",
992 "Gameexe.ini",
993 "gameexe.dat",
994 "gameexe.ini",
995 "GameexeEN.dat",
996 "GameexeEN.ini",
997 "GameexeZH.dat",
998 "GameexeZH.ini",
999 "GameexeZHTW.dat",
1000 "GameexeZHTW.ini",
1001 "GameexeDE.dat",
1002 "GameexeDE.ini",
1003 "GameexeES.dat",
1004 "GameexeES.ini",
1005 "GameexeFR.dat",
1006 "GameexeFR.ini",
1007 "GameexeID.dat",
1008 "GameexeID.ini",
1009];
1010
1011pub fn find_initial_gameexe_path(project_dir: &Path) -> Result<PathBuf> {
1018 let append = initial_select_ini_append(project_dir);
1019 let mut base = project_dir.to_path_buf();
1020 if !append.dir.is_empty() {
1021 base = base.join(&append.dir);
1022 }
1023 for name in GAMEEXE_CANDIDATES {
1024 let candidate = base.join(name);
1025 if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
1026 return Ok(path);
1027 }
1028 }
1029 bail!(
1030 "Gameexe.dat not found for initial append {:?} under {}",
1031 append.dir,
1032 project_dir.display()
1033 )
1034}
1035
1036fn format_tried_paths(paths: &[PathBuf]) -> String {
1037 if paths.is_empty() {
1038 return String::from("<none>");
1039 }
1040 paths
1041 .iter()
1042 .map(|p| p.display().to_string())
1043 .collect::<Vec<_>>()
1044 .join("; ")
1045}
1046
1047#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1048pub enum PctType {
1049 G00,
1050 Bmp,
1051 Png,
1052 Jpg,
1053 Dds,
1054}
1055
1056impl PctType {
1057 pub fn ext(self) -> &'static str {
1058 match self {
1059 PctType::G00 => "g00",
1060 PctType::Bmp => "bmp",
1061 PctType::Png => "png",
1062 PctType::Jpg => "jpg",
1063 PctType::Dds => "dds",
1064 }
1065 }
1066}
1067
1068const ORDER: [PctType; 5] = [
1069 PctType::G00,
1070 PctType::Bmp,
1071 PctType::Png,
1072 PctType::Jpg,
1073 PctType::Dds,
1074];
1075
1076const MOV_ORDER: [(&str, i32); 4] = [("wmv", 1), ("mpg", 2), ("avi", 3), ("omv", 4)];
1077
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1079pub enum MovieType {
1080 Wmv = 1,
1081 Mpg = 2,
1082 Avi = 3,
1083 Omv = 4,
1084}
1085
1086#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1087pub enum SoundType {
1088 Wav = 1,
1089 Nwa = 2,
1090 Ogg = 3,
1091 Owp = 4,
1092 Ovk = 5,
1093}
1094
1095impl SoundType {
1096 pub fn ext(self) -> &'static str {
1097 match self {
1098 Self::Wav => "wav",
1099 Self::Nwa => "nwa",
1100 Self::Ogg => "ogg",
1101 Self::Owp => "owp",
1102 Self::Ovk => "ovk",
1103 }
1104 }
1105}
1106
1107impl MovieType {
1108 pub fn from_id(id: i32) -> Option<Self> {
1109 match id {
1110 1 => Some(Self::Wmv),
1111 2 => Some(Self::Mpg),
1112 3 => Some(Self::Avi),
1113 4 => Some(Self::Omv),
1114 _ => None,
1115 }
1116 }
1117
1118 pub fn ext(self) -> &'static str {
1119 match self {
1120 Self::Wmv => "wmv",
1121 Self::Mpg => "mpg",
1122 Self::Avi => "avi",
1123 Self::Omv => "omv",
1124 }
1125 }
1126}
1127
1128pub fn find_bg_image(project_dir: &Path, name: &str) -> Result<(PathBuf, PctType)> {
1138 find_bg_image_with_append_dir(project_dir, "", name)
1139}
1140
1141pub fn find_bg_image_with_append_dir(
1142 project_dir: &Path,
1143 current_append_dir: &str,
1144 name: &str,
1145) -> Result<(PathBuf, PctType)> {
1146 if name.is_empty() {
1147 bail!("empty bg name");
1148 }
1149
1150 let as_path = Path::new(name);
1151 if as_path.components().count() > 1 {
1152 let candidate = project_dir.join(as_path);
1153 if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
1154 let pct = pct_from_path(&candidate)?;
1155 return Ok((candidate, pct));
1156 }
1157 }
1158
1159 for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
1160 if let Ok(found) = find_in_subdir(project_dir, &append_dir, "g00", name) {
1161 return Ok(found);
1162 }
1163 if let Ok(found) = find_in_subdir(project_dir, &append_dir, "bg", name) {
1164 return Ok(found);
1165 }
1166 }
1167
1168 bail!("bg resource not found: {name}");
1169}
1170
1171pub fn find_g00_image(project_dir: &Path, name: &str) -> Result<(PathBuf, PctType)> {
1176 find_g00_image_with_append_dir(project_dir, "", name)
1177}
1178
1179pub fn find_g00_image_with_append_dir(
1180 project_dir: &Path,
1181 current_append_dir: &str,
1182 name: &str,
1183) -> Result<(PathBuf, PctType)> {
1184 if name.is_empty() {
1185 bail!("empty image name");
1186 }
1187
1188 let as_path = Path::new(name);
1189 if as_path.components().count() > 1 {
1190 let candidate = project_dir.join(as_path);
1191 if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
1192 let pct = pct_from_path(&candidate)?;
1193 return Ok((candidate, pct));
1194 }
1195 }
1196
1197 for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
1198 if let Ok(found) = find_in_subdir(project_dir, &append_dir, "g00", name) {
1199 return Ok(found);
1200 }
1201 }
1202
1203 bail!("g00 resource not found: {name}");
1204}
1205
1206pub fn find_mov_path(project_dir: &Path, file_name: &str) -> Result<(PathBuf, MovieType)> {
1207 find_mov_path_with_append_dir(project_dir, "", file_name)
1208}
1209
1210pub fn find_omv_path_with_append_dir(
1211 project_dir: &Path,
1212 current_append_dir: &str,
1213 file_name: &str,
1214) -> Result<PathBuf> {
1215 if file_name.is_empty() {
1216 bail!("empty movie name");
1217 }
1218
1219 let p = Path::new(file_name);
1220 if p.is_absolute() {
1221 if let Some(path) = resolve_windows_case_insensitive_file(p)?
1222 && movie_type_from_path(&path)? == MovieType::Omv
1223 {
1224 return Ok(path);
1225 }
1226 bail!("omv movie not found: {file_name}");
1227 }
1228
1229 if p.components().count() > 1 {
1230 let candidate = project_dir.join(p);
1231 if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)?
1232 && movie_type_from_path(&candidate)? == MovieType::Omv
1233 {
1234 return Ok(candidate);
1235 }
1236 }
1237
1238 let (stem, explicit_ext) = split_name_ext(file_name);
1239 if let Some(ext) = explicit_ext
1240 && !ext.eq_ignore_ascii_case("omv")
1241 {
1242 bail!("object movie requires .omv: {file_name}");
1243 }
1244
1245 for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
1246 let base = base_in_append(project_dir, &append_dir, "mov");
1247 let p = base.join(format!("{stem}.omv"));
1248 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1249 return Ok(path);
1250 }
1251 }
1252
1253 bail!("omv movie not found: {file_name}");
1254}
1255
1256pub fn find_mov_path_with_append_dir(
1257 project_dir: &Path,
1258 current_append_dir: &str,
1259 file_name: &str,
1260) -> Result<(PathBuf, MovieType)> {
1261 if file_name.is_empty() {
1262 bail!("empty movie name");
1263 }
1264
1265 let p = Path::new(file_name);
1266 if p.is_absolute() {
1267 if let Some(path) = resolve_windows_case_insensitive_file(p)? {
1268 let ty = movie_type_from_path(&path)?;
1269 return Ok((path, ty));
1270 }
1271 bail!("movie not found: {file_name}");
1272 }
1273
1274 if p.components().count() > 1 {
1275 let candidate = project_dir.join(p);
1276 if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
1277 let ty = movie_type_from_path(&candidate)?;
1278 return Ok((candidate, ty));
1279 }
1280 }
1281
1282 let (stem, explicit_ext) = split_name_ext(file_name);
1283 for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
1284 let base = base_in_append(project_dir, &append_dir, "mov");
1285 if let Some(ext) = explicit_ext {
1286 let ty = movie_type_from_ext(ext)?;
1287 let p = base.join(format!("{stem}.{ext}"));
1288 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1289 return Ok((path, ty));
1290 }
1291 continue;
1292 }
1293
1294 for (ext, ty_id) in MOV_ORDER {
1295 let p = base.join(format!("{stem}.{ext}"));
1296 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1297 let ty = MovieType::from_id(ty_id).expect("valid movie type");
1298 return Ok((path, ty));
1299 }
1300 }
1301 }
1302
1303 bail!("movie not found: {file_name}");
1304}
1305
1306pub fn find_audio_path_with_append_dir(
1307 project_dir: &Path,
1308 current_append_dir: &str,
1309 subdir: &str,
1310 file_name: &str,
1311) -> Result<(PathBuf, SoundType)> {
1312 if file_name.is_empty() {
1313 bail!("empty audio name");
1314 }
1315
1316 let p = Path::new(file_name);
1317 if p.is_absolute() {
1318 if let Some(path) = resolve_windows_case_insensitive_file(p)? {
1319 let ty = sound_type_from_path(&path)?;
1320 return Ok((path, ty));
1321 }
1322 bail!("audio not found: {file_name}; tried={}", p.display());
1323 }
1324
1325 if p.components().count() > 1 {
1326 let candidate = project_dir.join(p);
1327 if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
1328 let ty = sound_type_from_path(&candidate)?;
1329 return Ok((candidate, ty));
1330 }
1331 }
1332
1333 let (stem, explicit_ext) = split_name_ext(file_name);
1334 let order = [
1335 SoundType::Wav,
1336 SoundType::Nwa,
1337 SoundType::Ogg,
1338 SoundType::Owp,
1339 SoundType::Ovk,
1340 ];
1341
1342 let mut tried = Vec::new();
1343 for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
1344 let base = base_in_append(project_dir, &append_dir, subdir);
1345 if let Some(ext) = explicit_ext {
1346 let ty = sound_type_from_ext(ext)?;
1347 let p = base.join(format!("{stem}.{ext}"));
1348 tried.push(p.clone());
1349 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1350 return Ok((path, ty));
1351 }
1352 continue;
1353 }
1354
1355 for ty in order {
1356 let p = base.join(format!("{stem}.{}", ty.ext()));
1357 tried.push(p.clone());
1358 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1359 return Ok((path, ty));
1360 }
1361 }
1362 }
1363
1364 bail!(
1365 "audio not found: {file_name}; project_dir={}; current_append_dir={}; subdir={}; tried={}",
1366 project_dir.display(),
1367 current_append_dir,
1368 subdir,
1369 format_tried_paths(&tried)
1370 );
1371}
1372
1373static APPEND_DIRS_CACHE: std::sync::Mutex<Option<HashMap<PathBuf, Vec<String>>>> =
1378 std::sync::Mutex::new(None);
1379
1380pub(crate) fn ordered_append_dirs(project_dir: &Path, current_append_dir: &str) -> Vec<String> {
1381 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1382 let project_key = native_cache_key(project_dir);
1383 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1384 let project_key = project_dir.to_path_buf();
1385
1386 let mut dirs = {
1387 let mut guard = APPEND_DIRS_CACHE
1388 .lock()
1389 .unwrap_or_else(|poisoned| poisoned.into_inner());
1390 let cache = guard.get_or_insert_with(HashMap::new);
1391 if let Some(cached_dirs) = cache.get(&project_key) {
1392 cached_dirs.clone()
1393 } else {
1394 let dirs = parse_select_ini_append_dirs(project_dir);
1395 cache.insert(project_key, dirs.clone());
1396 dirs
1397 }
1398 };
1399 if dirs.is_empty() {
1400 dirs.push(String::new());
1401 }
1402
1403 if current_append_dir.is_empty() {
1404 return dirs;
1405 }
1406
1407 if let Some(pos) = dirs
1408 .iter()
1409 .position(|d| d.eq_ignore_ascii_case(current_append_dir))
1410 {
1411 return dirs.into_iter().skip(pos).collect();
1412 }
1413
1414 dirs
1415}
1416
1417#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1425pub(crate) fn find_emote_psb_candidates(project_dir: &Path) -> Result<Vec<PathBuf>> {
1426 let mut candidates: Vec<(u64, PathBuf)> = Vec::new();
1427
1428 for append_dir in ordered_append_dirs(project_dir, "") {
1429 let requested_dat = base_in_append(project_dir, &append_dir, "dat");
1430 let Some(dat_dir) = resolve_windows_case_insensitive_path(&requested_dat)? else {
1431 continue;
1432 };
1433 if !dat_dir.is_dir() {
1434 continue;
1435 }
1436
1437 let entries = match fs::read_dir(&dat_dir) {
1438 Ok(entries) => entries,
1439 Err(err) => {
1440 log::warn!(
1441 "Emote key preload: cannot enumerate {}: {}",
1442 dat_dir.display(),
1443 err
1444 );
1445 continue;
1446 }
1447 };
1448 for entry in entries {
1449 let entry = match entry {
1450 Ok(entry) => entry,
1451 Err(err) => {
1452 log::warn!(
1453 "Emote key preload: failed to read an entry under {}: {}",
1454 dat_dir.display(),
1455 err
1456 );
1457 continue;
1458 }
1459 };
1460 let path = entry.path();
1461 if !path
1462 .extension()
1463 .and_then(|ext| ext.to_str())
1464 .is_some_and(|ext| ext.eq_ignore_ascii_case("psb"))
1465 {
1466 continue;
1467 }
1468 let metadata = match entry.metadata() {
1469 Ok(metadata) if metadata.is_file() => metadata,
1470 Ok(_) => continue,
1471 Err(err) => {
1472 log::warn!("Emote key preload: cannot stat {}: {}", path.display(), err);
1473 continue;
1474 }
1475 };
1476 candidates.push((metadata.len(), path));
1477 }
1478 }
1479
1480 candidates.sort_by(|(len_a, path_a), (len_b, path_b)| {
1481 len_a.cmp(len_b).then_with(|| path_a.cmp(path_b))
1482 });
1483 candidates.dedup_by(|(_, path_a), (_, path_b)| path_a == path_b);
1484 Ok(candidates.into_iter().map(|(_, path)| path).collect())
1485}
1486
1487fn parse_select_ini_append_entries(project_dir: &Path) -> Vec<SelectIniAppendEntry> {
1488 let candidates = [
1489 project_dir.join("Select.ini"),
1490 project_dir.join("select.ini"),
1491 ];
1492 let path = match first_existing_file_windows_ci(candidates) {
1493 Ok(Some(path)) => path,
1494 Ok(None) | Err(_) => {
1495 return vec![SelectIniAppendEntry {
1496 dir: String::new(),
1497 name: String::new(),
1498 }];
1499 }
1500 };
1501
1502 let Ok(bytes) = read_file_bytes(&path) else {
1503 return vec![SelectIniAppendEntry {
1504 dir: String::new(),
1505 name: String::new(),
1506 }];
1507 };
1508 let text = match String::from_utf8(bytes.clone()) {
1509 Ok(text) => text,
1510 Err(_) => {
1511 let (text, _, _) = encoding_rs::SHIFT_JIS.decode(&bytes);
1512 text.into_owned()
1513 }
1514 };
1515
1516 let mut out = Vec::new();
1517 for raw_line in text.lines() {
1518 let line = raw_line.trim_end_matches('\r');
1519 if line.is_empty() {
1520 continue;
1521 }
1522 let mut cols = line.split('\t');
1523 let dir = cols.next().unwrap_or("");
1524 let name = cols.next().unwrap_or("");
1525 if cols.next().is_some() {
1526 continue;
1527 }
1528 out.push(SelectIniAppendEntry {
1529 dir: dir.to_string(),
1530 name: name.to_string(),
1531 });
1532 }
1533
1534 if out.is_empty() {
1535 out.push(SelectIniAppendEntry {
1536 dir: String::new(),
1537 name: String::new(),
1538 });
1539 }
1540 out
1541}
1542
1543fn parse_select_ini_append_dirs(project_dir: &Path) -> Vec<String> {
1544 parse_select_ini_append_entries(project_dir)
1545 .into_iter()
1546 .map(|entry| entry.dir)
1547 .collect()
1548}
1549
1550fn base_in_append(project_dir: &Path, append_dir: &str, subdir: &str) -> PathBuf {
1551 let mut base = project_dir.to_path_buf();
1552 if !append_dir.is_empty() {
1553 base = base.join(append_dir);
1554 }
1555 if !subdir.is_empty() {
1556 base = base.join(subdir);
1557 }
1558 base
1559}
1560
1561fn find_in_subdir(
1562 project_dir: &Path,
1563 append_dir: &str,
1564 subdir: &str,
1565 name: &str,
1566) -> Result<(PathBuf, PctType)> {
1567 let base = base_in_append(project_dir, append_dir, subdir);
1568
1569 let (stem, explicit_ext) = split_name_ext(name);
1570 if let Some(ext) = explicit_ext {
1571 let pct = pct_from_ext(ext)?;
1572 let p = base.join(format!("{stem}.{ext}"));
1573 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1574 return Ok((path, pct));
1575 }
1576 bail!("not found");
1577 }
1578
1579 for pct in ORDER {
1580 let p = base.join(format!("{stem}.{}", pct.ext()));
1581 if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
1582 return Ok((path, pct));
1583 }
1584 }
1585
1586 bail!("not found");
1587}
1588
1589fn split_name_ext(name: &str) -> (&str, Option<&str>) {
1590 if let Some((a, b)) = name.rsplit_once('.')
1591 && !a.is_empty()
1592 && !b.is_empty()
1593 {
1594 return (a, Some(b));
1595 }
1596 (name, None)
1597}
1598
1599fn pct_from_path(p: &Path) -> Result<PctType> {
1600 let ext = p
1601 .extension()
1602 .and_then(|s| s.to_str())
1603 .unwrap_or("")
1604 .to_ascii_lowercase();
1605 pct_from_ext(&ext)
1606}
1607
1608fn pct_from_ext(ext: &str) -> Result<PctType> {
1609 match ext.to_ascii_lowercase().as_str() {
1610 "g00" => Ok(PctType::G00),
1611 "bmp" => Ok(PctType::Bmp),
1612 "png" => Ok(PctType::Png),
1613 "jpg" | "jpeg" => Ok(PctType::Jpg),
1614 "dds" => Ok(PctType::Dds),
1615 _ => bail!("unknown extension: {ext}"),
1616 }
1617}
1618
1619fn sound_type_from_path(p: &Path) -> Result<SoundType> {
1620 let ext = p
1621 .extension()
1622 .and_then(|s| s.to_str())
1623 .unwrap_or("")
1624 .to_ascii_lowercase();
1625 sound_type_from_ext(&ext)
1626}
1627
1628fn sound_type_from_ext(ext: &str) -> Result<SoundType> {
1629 match ext.to_ascii_lowercase().as_str() {
1630 "wav" => Ok(SoundType::Wav),
1631 "nwa" => Ok(SoundType::Nwa),
1632 "ogg" => Ok(SoundType::Ogg),
1633 "owp" => Ok(SoundType::Owp),
1634 "ovk" => Ok(SoundType::Ovk),
1635 _ => bail!("unknown sound extension: {ext}"),
1636 }
1637}
1638
1639fn movie_type_from_path(p: &Path) -> Result<MovieType> {
1640 let ext = p
1641 .extension()
1642 .and_then(|s| s.to_str())
1643 .unwrap_or("")
1644 .to_ascii_lowercase();
1645 movie_type_from_ext(&ext)
1646}
1647
1648fn movie_type_from_ext(ext: &str) -> Result<MovieType> {
1649 match ext.to_ascii_lowercase().as_str() {
1650 "wmv" => Ok(MovieType::Wmv),
1651 "mpg" => Ok(MovieType::Mpg),
1652 "avi" => Ok(MovieType::Avi),
1653 "omv" => Ok(MovieType::Omv),
1654 _ => bail!("unknown movie extension: {ext}"),
1655 }
1656}
1657
1658fn strict_append_dirs_from_current(project_dir: &Path, current_append_dir: &str) -> Vec<String> {
1663 let dirs = parse_select_ini_append_dirs(project_dir);
1664 let Some(pos) = dirs
1665 .iter()
1666 .position(|d| d.eq_ignore_ascii_case(current_append_dir))
1667 else {
1668 return Vec::new();
1669 };
1670 dirs.into_iter().skip(pos).collect()
1671}
1672
1673pub(crate) fn resolve_emote_psb_path(
1677 project_dir: &Path,
1678 current_append_dir: &str,
1679 file_name: &str,
1680) -> Result<Option<PathBuf>> {
1681 if file_name.is_empty() {
1682 return Ok(None);
1683 }
1684 for append_dir in strict_append_dirs_from_current(project_dir, current_append_dir) {
1685 let candidate =
1686 base_in_append(project_dir, &append_dir, "dat").join(format!("{file_name}.psb"));
1687 if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
1688 return Ok(Some(path));
1689 }
1690 }
1691 Ok(None)
1692}
1693
1694pub(crate) fn resolve_dat_file_path(
1697 project_dir: &Path,
1698 current_append_dir: &str,
1699 file_name: &str,
1700) -> Result<Option<PathBuf>> {
1701 if file_name.is_empty() {
1702 return Ok(None);
1703 }
1704 for append_dir in strict_append_dirs_from_current(project_dir, current_append_dir) {
1705 let candidate = base_in_append(project_dir, &append_dir, "dat").join(file_name);
1706 if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
1707 return Ok(Some(path));
1708 }
1709 }
1710 Ok(None)
1711}