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::{bail, Result};
16use std::fs;
17use std::path::{Path, PathBuf};
18
19use std::path::Component;
20#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
21use crate::wasm_vfs::SiglusVfs;
22
23
24#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
25fn path_to_wasm_vfs(path: &Path) -> String {
26    path.to_string_lossy()
27        .replace('\\', "/")
28        .split('/')
29        .filter(|part| !part.is_empty() && *part != ".")
30        .collect::<Vec<_>>()
31        .join("/")
32}
33
34#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
35pub(crate) fn wasm_path_exists(path: &Path) -> bool {
36    crate::wasm_vfs::WasmDirectoryVfs::new().exists(&path_to_wasm_vfs(path))
37}
38
39#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
40pub(crate) fn wasm_path_is_file(path: &Path) -> bool {
41    wasm_path_exists(path)
42}
43
44#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
45pub(crate) fn wasm_path_is_dir(path: &Path) -> bool {
46    !crate::wasm_vfs::WasmDirectoryVfs::new()
47        .list_dir(&path_to_wasm_vfs(path))
48        .unwrap_or_default()
49        .is_empty()
50}
51
52#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
53pub(crate) fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
54    crate::wasm_vfs::WasmDirectoryVfs::new().read_all(&path_to_wasm_vfs(path))
55}
56
57#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
58pub(crate) fn read_file_bytes(path: &Path) -> Result<Vec<u8>> {
59    Ok(fs::read(path)?)
60}
61
62#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
63pub(crate) fn read_file_to_string(path: &Path) -> Result<String> {
64    let bytes = read_file_bytes(path)?;
65    Ok(String::from_utf8(bytes)?)
66}
67
68#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
69pub(crate) fn read_file_to_string(path: &Path) -> Result<String> {
70    Ok(fs::read_to_string(path)?)
71}
72
73fn path_component_eq_windows(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
74    a.to_string_lossy().eq_ignore_ascii_case(&b.to_string_lossy())
75}
76
77fn resolve_windows_case_insensitive_path(path: &Path) -> Result<Option<PathBuf>> {
78    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
79    {
80        if wasm_path_exists(path) {
81            return Ok(Some(path.to_path_buf()));
82        }
83        return Ok(None);
84    }
85
86    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
87    if path.exists() {
88        return Ok(Some(path.to_path_buf()));
89    }
90
91    let mut cur = PathBuf::new();
92    for component in path.components() {
93        match component {
94            Component::Prefix(prefix) => cur.push(prefix.as_os_str()),
95            Component::RootDir => cur.push(component.as_os_str()),
96            Component::CurDir => {}
97            Component::ParentDir => cur.push(".."),
98            Component::Normal(name) => {
99                let exact = cur.join(name);
100                if exact.exists() {
101                    cur = exact;
102                    continue;
103                }
104
105                let parent = if cur.as_os_str().is_empty() {
106                    Path::new(".")
107                } else {
108                    cur.as_path()
109                };
110                if !parent.is_dir() {
111                    return Ok(None);
112                }
113
114                let mut matches = Vec::new();
115                for entry in fs::read_dir(parent)? {
116                    let entry = entry?;
117                    if path_component_eq_windows(&entry.file_name(), name) {
118                        matches.push(entry.path());
119                    }
120                }
121
122                match matches.len() {
123                    0 => return Ok(None),
124                    1 => cur = matches.remove(0),
125                    _ => {
126                        matches.sort();
127                        bail!(
128                            "case-insensitive path conflict for {} under {}: {}",
129                            name.to_string_lossy(),
130                            parent.display(),
131                            matches
132                                .iter()
133                                .map(|p| p.display().to_string())
134                                .collect::<Vec<_>>()
135                                .join(", ")
136                        );
137                    }
138                }
139            }
140        }
141    }
142
143    Ok(cur.exists().then_some(cur))
144}
145
146fn resolve_windows_case_insensitive_file(path: &Path) -> Result<Option<PathBuf>> {
147    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
148    {
149        return Ok(wasm_path_is_file(path).then_some(path.to_path_buf()));
150    }
151
152    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
153    if path.is_file() {
154        return Ok(Some(path.to_path_buf()));
155    }
156    let Some(resolved) = resolve_windows_case_insensitive_path(path)? else {
157        return Ok(None);
158    };
159    Ok(resolved.is_file().then_some(resolved))
160}
161
162fn first_existing_file_windows_ci(candidates: impl IntoIterator<Item = PathBuf>) -> Result<Option<PathBuf>> {
163    for candidate in candidates {
164        if let Some(path) = resolve_windows_case_insensitive_file(&candidate)? {
165            return Ok(Some(path));
166        }
167    }
168    Ok(None)
169}
170
171fn format_tried_paths(paths: &[PathBuf]) -> String {
172    if paths.is_empty() {
173        return String::from("<none>");
174    }
175    paths
176        .iter()
177        .map(|p| p.display().to_string())
178        .collect::<Vec<_>>()
179        .join("; ")
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum PctType {
184    G00,
185    Bmp,
186    Png,
187    Jpg,
188    Dds,
189}
190
191impl PctType {
192    pub fn ext(self) -> &'static str {
193        match self {
194            PctType::G00 => "g00",
195            PctType::Bmp => "bmp",
196            PctType::Png => "png",
197            PctType::Jpg => "jpg",
198            PctType::Dds => "dds",
199        }
200    }
201}
202
203const ORDER: [PctType; 5] = [
204    PctType::G00,
205    PctType::Bmp,
206    PctType::Png,
207    PctType::Jpg,
208    PctType::Dds,
209];
210
211const MOV_ORDER: [(&str, i32); 4] = [("wmv", 1), ("mpg", 2), ("avi", 3), ("omv", 4)];
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum MovieType {
215    Wmv = 1,
216    Mpg = 2,
217    Avi = 3,
218    Omv = 4,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum SoundType {
223    Wav = 1,
224    Nwa = 2,
225    Ogg = 3,
226    Owp = 4,
227    Ovk = 5,
228}
229
230impl SoundType {
231    pub fn ext(self) -> &'static str {
232        match self {
233            Self::Wav => "wav",
234            Self::Nwa => "nwa",
235            Self::Ogg => "ogg",
236            Self::Owp => "owp",
237            Self::Ovk => "ovk",
238        }
239    }
240}
241
242impl MovieType {
243    pub fn from_id(id: i32) -> Option<Self> {
244        match id {
245            1 => Some(Self::Wmv),
246            2 => Some(Self::Mpg),
247            3 => Some(Self::Avi),
248            4 => Some(Self::Omv),
249            _ => None,
250        }
251    }
252
253    pub fn ext(self) -> &'static str {
254        match self {
255            Self::Wmv => "wmv",
256            Self::Mpg => "mpg",
257            Self::Avi => "avi",
258            Self::Omv => "omv",
259        }
260    }
261}
262
263/// Find an image path for BG loading.
264///
265/// Original Siglus logic:
266/// 1. Search append directories from the current append entry, in order.
267/// 2. In each directory, search `g00/` first.
268/// 3. If not found, search `bg/`.
269/// 4. For each directory, extension order is: g00, bmp, png, jpg, dds.
270///
271/// If `name` already contains an extension, we only test that extension.
272pub fn find_bg_image(project_dir: &Path, name: &str) -> Result<(PathBuf, PctType)> {
273    find_bg_image_with_append_dir(project_dir, "", name)
274}
275
276pub fn find_bg_image_with_append_dir(
277    project_dir: &Path,
278    current_append_dir: &str,
279    name: &str,
280) -> Result<(PathBuf, PctType)> {
281    if name.is_empty() {
282        bail!("empty bg name");
283    }
284
285    let as_path = Path::new(name);
286    if as_path.components().count() > 1 {
287        let candidate = project_dir.join(as_path);
288        if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
289            let pct = pct_from_path(&candidate)?;
290            return Ok((candidate, pct));
291        }
292    }
293
294    for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
295        if let Ok(found) = find_in_subdir(project_dir, &append_dir, "g00", name) {
296            return Ok(found);
297        }
298        if let Ok(found) = find_in_subdir(project_dir, &append_dir, "bg", name) {
299            return Ok(found);
300        }
301    }
302
303    bail!("bg resource not found: {name}");
304}
305
306/// Find an image path restricted to the `g00/` directory.
307///
308/// Original Siglus logic searches append directories from the current append
309/// entry to the end of `Select.ini`.
310pub fn find_g00_image(project_dir: &Path, name: &str) -> Result<(PathBuf, PctType)> {
311    find_g00_image_with_append_dir(project_dir, "", name)
312}
313
314pub fn find_g00_image_with_append_dir(
315    project_dir: &Path,
316    current_append_dir: &str,
317    name: &str,
318) -> Result<(PathBuf, PctType)> {
319    if name.is_empty() {
320        bail!("empty image name");
321    }
322
323    let as_path = Path::new(name);
324    if as_path.components().count() > 1 {
325        let candidate = project_dir.join(as_path);
326        if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
327            let pct = pct_from_path(&candidate)?;
328            return Ok((candidate, pct));
329        }
330    }
331
332    for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
333        if let Ok(found) = find_in_subdir(project_dir, &append_dir, "g00", name) {
334            return Ok(found);
335        }
336    }
337
338    bail!("g00 resource not found: {name}");
339}
340
341pub fn find_mov_path(project_dir: &Path, file_name: &str) -> Result<(PathBuf, MovieType)> {
342    find_mov_path_with_append_dir(project_dir, "", file_name)
343}
344
345pub fn find_omv_path_with_append_dir(
346    project_dir: &Path,
347    current_append_dir: &str,
348    file_name: &str,
349) -> Result<PathBuf> {
350    if file_name.is_empty() {
351        bail!("empty movie name");
352    }
353
354    let p = Path::new(file_name);
355    if p.is_absolute() {
356        if let Some(path) = resolve_windows_case_insensitive_file(p)? {
357            if movie_type_from_path(&path)? == MovieType::Omv {
358                return Ok(path);
359            }
360        }
361        bail!("omv movie not found: {file_name}");
362    }
363
364    if p.components().count() > 1 {
365        let candidate = project_dir.join(p);
366        if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
367            if movie_type_from_path(&candidate)? == MovieType::Omv {
368                return Ok(candidate);
369            }
370        }
371    }
372
373    let (stem, explicit_ext) = split_name_ext(file_name);
374    if let Some(ext) = explicit_ext {
375        if !ext.eq_ignore_ascii_case("omv") {
376            bail!("object movie requires .omv: {file_name}");
377        }
378    }
379
380    for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
381        let base = base_in_append(project_dir, &append_dir, "mov");
382        let p = base.join(format!("{stem}.omv"));
383        if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
384            return Ok(path);
385        }
386    }
387
388    bail!("omv movie not found: {file_name}");
389}
390
391pub fn find_mov_path_with_append_dir(
392    project_dir: &Path,
393    current_append_dir: &str,
394    file_name: &str,
395) -> Result<(PathBuf, MovieType)> {
396    if file_name.is_empty() {
397        bail!("empty movie name");
398    }
399
400    let p = Path::new(file_name);
401    if p.is_absolute() {
402        if let Some(path) = resolve_windows_case_insensitive_file(p)? {
403            let ty = movie_type_from_path(&path)?;
404            return Ok((path, ty));
405        }
406        bail!("movie not found: {file_name}");
407    }
408
409    if p.components().count() > 1 {
410        let candidate = project_dir.join(p);
411        if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
412            let ty = movie_type_from_path(&candidate)?;
413            return Ok((candidate, ty));
414        }
415    }
416
417    let (stem, explicit_ext) = split_name_ext(file_name);
418    for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
419        let base = base_in_append(project_dir, &append_dir, "mov");
420        if let Some(ext) = explicit_ext {
421            let ty = movie_type_from_ext(ext)?;
422            let p = base.join(format!("{stem}.{ext}"));
423            if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
424                return Ok((path, ty));
425            }
426            continue;
427        }
428
429        for (ext, ty_id) in MOV_ORDER {
430            let p = base.join(format!("{stem}.{ext}"));
431            if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
432                let ty = MovieType::from_id(ty_id).expect("valid movie type");
433                return Ok((path, ty));
434            }
435        }
436    }
437
438    bail!("movie not found: {file_name}");
439}
440
441pub fn find_audio_path_with_append_dir(
442    project_dir: &Path,
443    current_append_dir: &str,
444    subdir: &str,
445    file_name: &str,
446) -> Result<(PathBuf, SoundType)> {
447    if file_name.is_empty() {
448        bail!("empty audio name");
449    }
450
451    let p = Path::new(file_name);
452    if p.is_absolute() {
453        if let Some(path) = resolve_windows_case_insensitive_file(p)? {
454            let ty = sound_type_from_path(&path)?;
455            return Ok((path, ty));
456        }
457        bail!("audio not found: {file_name}; tried={}", p.display());
458    }
459
460    if p.components().count() > 1 {
461        let candidate = project_dir.join(p);
462        if let Some(candidate) = resolve_windows_case_insensitive_file(&candidate)? {
463            let ty = sound_type_from_path(&candidate)?;
464            return Ok((candidate, ty));
465        }
466    }
467
468    let (stem, explicit_ext) = split_name_ext(file_name);
469    let order = [
470        SoundType::Wav,
471        SoundType::Nwa,
472        SoundType::Ogg,
473        SoundType::Owp,
474        SoundType::Ovk,
475    ];
476
477    let mut tried = Vec::new();
478    for append_dir in ordered_append_dirs(project_dir, current_append_dir) {
479        let base = base_in_append(project_dir, &append_dir, subdir);
480        if let Some(ext) = explicit_ext {
481            let ty = sound_type_from_ext(ext)?;
482            let p = base.join(format!("{stem}.{ext}"));
483            tried.push(p.clone());
484            if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
485                return Ok((path, ty));
486            }
487            continue;
488        }
489
490        for ty in order {
491            let p = base.join(format!("{stem}.{}", ty.ext()));
492            tried.push(p.clone());
493            if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
494                return Ok((path, ty));
495            }
496        }
497    }
498
499    bail!(
500        "audio not found: {file_name}; project_dir={}; current_append_dir={}; subdir={}; tried={}",
501        project_dir.display(),
502        current_append_dir,
503        subdir,
504        format_tried_paths(&tried)
505    );
506}
507
508pub(crate) fn ordered_append_dirs(project_dir: &Path, current_append_dir: &str) -> Vec<String> {
509    let mut dirs = parse_select_ini_append_dirs(project_dir);
510    if dirs.is_empty() {
511        dirs.push(String::new());
512    }
513
514    if current_append_dir.is_empty() {
515        return dirs;
516    }
517
518    if let Some(pos) = dirs.iter().position(|d| d == current_append_dir) {
519        return dirs.into_iter().skip(pos).collect();
520    }
521
522    dirs
523}
524
525fn parse_select_ini_append_dirs(project_dir: &Path) -> Vec<String> {
526    let mut candidates = vec![project_dir.join("Select.ini")];
527    candidates.push(project_dir.join("select.ini"));
528
529    let path = match first_existing_file_windows_ci(candidates) {
530        Ok(Some(path)) => path,
531        Ok(None) | Err(_) => return vec![String::new()],
532    };
533
534    let Ok(text) = read_file_to_string(&path) else {
535        return vec![String::new()];
536    };
537
538    let mut out = Vec::new();
539    for raw_line in text.lines() {
540        let line = raw_line.trim_end_matches('\r');
541        if line.is_empty() {
542            continue;
543        }
544        let mut cols = line.split('\t');
545        let dir = cols.next().unwrap_or("");
546        let _name = cols.next();
547        if cols.next().is_some() {
548            continue;
549        }
550        out.push(dir.to_string());
551    }
552
553    if out.is_empty() {
554        out.push(String::new());
555    }
556    out
557}
558
559fn base_in_append(project_dir: &Path, append_dir: &str, subdir: &str) -> PathBuf {
560    let mut base = project_dir.to_path_buf();
561    if !append_dir.is_empty() {
562        base = base.join(append_dir);
563    }
564    if !subdir.is_empty() {
565        base = base.join(subdir);
566    }
567    base
568}
569
570fn find_in_subdir(
571    project_dir: &Path,
572    append_dir: &str,
573    subdir: &str,
574    name: &str,
575) -> Result<(PathBuf, PctType)> {
576    let base = base_in_append(project_dir, append_dir, subdir);
577
578    let (stem, explicit_ext) = split_name_ext(name);
579    if let Some(ext) = explicit_ext {
580        let pct = pct_from_ext(ext)?;
581        let p = base.join(format!("{stem}.{ext}"));
582        if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
583            return Ok((path, pct));
584        }
585        bail!("not found");
586    }
587
588    for pct in ORDER {
589        let p = base.join(format!("{stem}.{}", pct.ext()));
590        if let Some(path) = resolve_windows_case_insensitive_file(&p)? {
591            return Ok((path, pct));
592        }
593    }
594
595    bail!("not found");
596}
597
598fn split_name_ext(name: &str) -> (&str, Option<&str>) {
599    if let Some((a, b)) = name.rsplit_once('.') {
600        if !a.is_empty() && !b.is_empty() {
601            return (a, Some(b));
602        }
603    }
604    (name, None)
605}
606
607fn pct_from_path(p: &Path) -> Result<PctType> {
608    let ext = p
609        .extension()
610        .and_then(|s| s.to_str())
611        .unwrap_or("")
612        .to_ascii_lowercase();
613    pct_from_ext(&ext)
614}
615
616fn pct_from_ext(ext: &str) -> Result<PctType> {
617    match ext.to_ascii_lowercase().as_str() {
618        "g00" => Ok(PctType::G00),
619        "bmp" => Ok(PctType::Bmp),
620        "png" => Ok(PctType::Png),
621        "jpg" | "jpeg" => Ok(PctType::Jpg),
622        "dds" => Ok(PctType::Dds),
623        _ => bail!("unknown extension: {ext}"),
624    }
625}
626
627fn sound_type_from_path(p: &Path) -> Result<SoundType> {
628    let ext = p
629        .extension()
630        .and_then(|s| s.to_str())
631        .unwrap_or("")
632        .to_ascii_lowercase();
633    sound_type_from_ext(&ext)
634}
635
636fn sound_type_from_ext(ext: &str) -> Result<SoundType> {
637    match ext.to_ascii_lowercase().as_str() {
638        "wav" => Ok(SoundType::Wav),
639        "nwa" => Ok(SoundType::Nwa),
640        "ogg" => Ok(SoundType::Ogg),
641        "owp" => Ok(SoundType::Owp),
642        "ovk" => Ok(SoundType::Ovk),
643        _ => bail!("unknown sound extension: {ext}"),
644    }
645}
646
647fn movie_type_from_path(p: &Path) -> Result<MovieType> {
648    let ext = p
649        .extension()
650        .and_then(|s| s.to_str())
651        .unwrap_or("")
652        .to_ascii_lowercase();
653    movie_type_from_ext(&ext)
654}
655
656fn movie_type_from_ext(ext: &str) -> Result<MovieType> {
657    match ext.to_ascii_lowercase().as_str() {
658        "wmv" => Ok(MovieType::Wmv),
659        "mpg" => Ok(MovieType::Mpg),
660        "avi" => Ok(MovieType::Avi),
661        "omv" => Ok(MovieType::Omv),
662        _ => bail!("unknown movie extension: {ext}"),
663    }
664}