Skip to main content

siglus_scene_vm/
resource.rs

1//! Siglus-like resource lookup for BG images and movies.
2//!
3//! This stage implements the file search policy used by the original helpers in
4//! `eng_dir.cpp`:
5//! - `tnm_find_g00_sub`: try `g00/<name>.<ext>` in the order
6//!   `g00 -> bmp -> png -> jpg -> dds`
7//! - `tnm_find_g00`: search append directories from the current append entry to
8//!   the end of `Select.ini`
9//! - `tnm_find_mov`: search append directories from the current append entry to
10//!   the end of `Select.ini`, with extension order `wmv -> mpg -> avi`
11//!
12//! We keep the existing explicit-path behavior for the port, but normal resource
13//! resolution follows the original directory search order.
14
15use 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    // std::fs::read preallocates from metadata().len(). fsdev currently
70    // reports an invalid size through the custom Horizon std target, turning
71    // even a small key.toml into a bogus OOM. Read incrementally instead.
72    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            // A positive cache entry can become stale if an external process
97            // replaces/removes a resource while the engine is running. Drop
98            // the affected directory state and resolve once more.
99            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/// Fold only ASCII case while preserving every non-ASCII code unit/byte.
134///
135/// Siglus asset names are overwhelmingly ASCII + Japanese.  This deliberately
136/// avoids locale-sensitive Unicode lower-casing/normalization while matching
137/// the case-insensitive behavior that matters for original Windows game data.
138#[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 full-file resolutions.  We intentionally do not cache misses:
192    /// files such as captures/save thumbnails can be created while the engine
193    /// is running.
194    positive_files: HashMap<PathBuf, PathBuf>,
195    /// Lazy per-directory case-folded entry maps.  A directory is enumerated
196    /// only after exact lookup failed for a component under that directory.
197    directories: HashMap<PathBuf, DirectoryCaseIndex>,
198}
199
200/// Native Windows-compatible path resolver cache.
201///
202/// This is deliberately a directory cache, not a startup-time project-wide
203/// file database.  Keys are absolute directory/request paths, so independent
204/// game roots coexist without sharing lookup state.
205#[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                // The directory changed after it was indexed.  Rebuild once;
340                // this is also the external-mutation fallback for stale hits.
341                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/// Invalidate resolver state for a file/directory that was created, removed or
380/// renamed by the engine.  External mutations are detected lazily from parent
381/// directory metadata on misses/stale directory hits; engine-owned writes use
382/// this hook so even filesystems with coarse directory mtimes remain correct.
383#[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    // Browser File objects are registered as an immutable snapshot for one
412    // launch. Runtime-generated browser files are not currently inserted into
413    // that JavaScript index.
414}
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        // `siglusFileExists` already uses the browser-side case-insensitive
420        // index built from the selected directory, so no Rust-side directory
421        // walk is necessary here.
422        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    // libnx's fsdev mount accepts `sdmc:` paths through open(2), but this
472    // custom Horizon std target currently reports false from metadata/is_file
473    // for those same paths. Verify the exact shipped filename by opening it;
474    // desktop targets retain the case-insensitive directory resolver below.
475    #[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
503/// Resolve an existing game path using the Windows case-insensitive semantics
504/// expected by Siglus scripts and resource data. This works for files or
505/// directories.
506pub fn resolve_game_path(path: &Path) -> Result<Option<PathBuf>> {
507    resolve_windows_case_insensitive_path(path)
508}
509
510/// Resolve an existing game file using Windows case-insensitive semantics.
511pub 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
564/// Return the size of an existing game file while preserving the same
565/// case-insensitive lookup semantics as the rest of the Siglus game VFS.
566///
567/// Native hosts can query filesystem metadata directly after resolution.  The
568/// browser target has no meaningful `std::fs::Metadata`, so use the VFS bytes
569/// instead.  Callers that only need the file length must use this helper rather
570/// than depending on a host-specific metadata type.
571pub(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    // Read only the 16-byte executable key here. An unrelated malformed
656    // optional setting in key.toml must not make a valid key look absent.
657    // Full Gameexe options are built later and have their own non-fatal
658    // fallback path.
659    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    // Resource-only key recovery retains several large Scene.pck working
672    // buffers. On Horizon it is both unsuitable for the constrained startup
673    // heap and unnecessary for a packaged game that provides key.toml. Trust
674    // that explicit key; without one, let the normal decoder report a concrete
675    // encrypted-resource error rather than attempting a high-memory crack.
676    #[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        // Plain-text Gameexe.ini does not provide the encrypted resource pair
704        // required by the resource-only cracker. Keep the configured behavior.
705        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                // Easy-link packs keep their scene chunks uncompressed, so no
761                // compressed-resource structure exists to prove or disprove the
762                // key. The crack cannot converge on them either; keep the
763                // configured key instead of spending minutes on a futile run.
764                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    // The resource crack needs the LZSS container headers of the scene chunks.
792    // An easy-link pack has none, and its uncompressed chunks are exactly what
793    // the loader will use, so a crack that cannot converge would only delay
794    // the boot.
795    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            // Persistence is optional. Keep the recovered key in the process
837            // cache so the current game can continue; the next launch will try
838            // recovery again if the directory is still not writable.
839            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        // Horizon must not enter the desktop resource-key recovery path: it
859        // retains large Scene.pck working buffers before the pack itself is
860        // loaded. `key.toml` is included with the deployment bundle and the
861        // assets crate now opens that exact file directly on fsdev mounts.
862        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                // A malformed/unreadable key.toml must not make successful
911                // resource recovery unusable. Fall back to the normal built-in
912                // Gameexe mask and the in-memory recovered key.
913                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
936/// Return the append selected by the original startup path.
937///
938/// `tnm_init_dir()` initializes `Gp_dir->append_dir/append_name` from the first
939/// `Select.ini` entry unless a command-line override is present.  The Rust host
940/// currently has no `select_scene_pck` CLI equivalent, so startup always uses
941/// this first entry.  Missing `Select.ini` is equivalent to an empty append.
942pub(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
952/// Resolve the one active Scene.pck used by the original lexer.
953///
954/// The C++ path is `Gp_dir->exe_dir + "\\" + Gp_dir->append_dir +
955/// "\\Scene.pck"`; it does not scan later append entries when a scene name is
956/// missing.  Keeping the active append explicit is important because save/load
957/// and return-to-menu can legitimately switch Scene.pck.
958pub 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    // Historical extracted layouts used by this port sometimes place the root
969    // Scene.pck under Data/. Preserve that compatibility only for the empty
970    // append; an explicit Select.ini append must remain exact like C++.
971    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
1011/// Resolve the Gameexe file used by the original startup path.
1012///
1013/// `C_tnm_ini::analize_func()` opens
1014/// `module_dir + Select.ini.first_dir() + LCL_GAMEEXE_DAT`; Gameexe is therefore
1015/// selected from the same initial append as Scene.pck, not independently from
1016/// the project root. Localized candidate names are retained for this port.
1017pub 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
1128/// Find an image path for BG loading.
1129///
1130/// Original Siglus logic:
1131/// 1. Search append directories from the current append entry, in order.
1132/// 2. In each directory, search `g00/` first.
1133/// 3. If not found, search `bg/`.
1134/// 4. For each directory, extension order is: g00, bmp, png, jpg, dds.
1135///
1136/// If `name` already contains an extension, we only test that extension.
1137pub 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
1171/// Find an image path restricted to the `g00/` directory.
1172///
1173/// Original Siglus logic searches append directories from the current append
1174/// entry to the end of `Select.ini`.
1175pub 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
1373/// Per-project append-dir list cache. `Select.ini` is configuration state, not
1374/// a live resource directory; parsing it once per concrete project root avoids
1375/// repeated filesystem work without turning file-existence misses into global
1376/// truths.
1377static 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/// Enumerate possible Emote PSB files without recursively scanning the game.
1418///
1419/// This deliberately mirrors the scope of original `tnm_find_psb`: only the
1420/// `dat` directory of each Select.ini append is considered.  Candidates are
1421/// ordered by file size so key probing/bruteforce can use the cheapest PSB
1422/// first, while the runtime's actual named-file resolution can still follow
1423/// original append order later.
1424#[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
1658/// Return the exact suffix of Select.ini append entries beginning at the
1659/// current append directory. Unlike the broader resource resolver, original
1660/// `tnm_find_dat`/`tnm_find_psb` do not fall back to the beginning when the
1661/// current append directory is absent from the Select.ini list.
1662fn 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
1673/// Resolve an Emote PSB exactly like original `tnm_find_psb`: locate the
1674/// current append entry in Select.ini, then search that entry and later ones
1675/// in order, appending `.psb` under `dat/`.
1676pub(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
1694/// Resolve a literal file under `dat/` with the exact original
1695/// `tnm_find_dat` append-list search used by Emote mouth-volume tables.
1696pub(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}