1use 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
263pub 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
306pub 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}