1use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30
31use rakata_core::fs::find_case_insensitive_file;
32use rakata_core::ResourceTypeCode;
33use rakata_core::{ResRef, ResRefError};
34use rakata_formats::{
35 read_erf_from_bytes, read_rim_from_bytes, Erf, ErfBinaryError, Rim, RimBinaryError,
36};
37use thiserror::Error;
38
39use crate::util::trace_debug;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum CompositeModuleSource {
44 DialogErf,
46 SupplementalRim,
48 AreaRim,
50 AreaExtendedRim,
52 MainRim,
54 ModuleMod,
56}
57
58impl CompositeModuleSource {
59 fn suffix(self) -> &'static str {
60 match self {
61 Self::DialogErf => "_dlg.erf",
62 Self::SupplementalRim => "_s.rim",
63 Self::AreaRim => "_a.rim",
64 Self::AreaExtendedRim => "_adx.rim",
65 Self::MainRim => ".rim",
66 Self::ModuleMod => ".mod",
67 }
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ResolvedResource<'a> {
74 pub source: CompositeModuleSource,
76 pub resref: ResRef,
78 pub resource_type: ResourceTypeCode,
80 pub data: &'a [u8],
82}
83
84type ArchiveIndex = HashMap<(ResRef, ResourceTypeCode), usize>;
86
87#[derive(Debug, Clone)]
89pub struct CompositeModule {
90 module_root: String,
91 dialog_erf: Option<Erf>,
92 dialog_erf_index: ArchiveIndex,
93 supplemental_rim: Option<Rim>,
94 supplemental_rim_index: ArchiveIndex,
95 main_rim: Option<Rim>,
96 main_rim_index: ArchiveIndex,
97 main_rim_source: Option<CompositeModuleSource>,
98 module_mod: Option<Erf>,
99 module_mod_index: ArchiveIndex,
100}
101
102impl CompositeModule {
103 #[cfg_attr(
107 feature = "tracing",
108 tracing::instrument(
109 level = "debug",
110 skip(module_root, main_rim, supplemental_rim, dialog_erf)
111 )
112 )]
113 pub fn from_archives(
114 module_root: impl Into<String>,
115 main_rim: Option<Rim>,
116 supplemental_rim: Option<Rim>,
117 dialog_erf: Option<Erf>,
118 ) -> Result<Self, CompositeModuleError> {
119 Self::from_archives_with_mod(module_root, main_rim, supplemental_rim, dialog_erf, None)
120 }
121
122 #[cfg_attr(
127 feature = "tracing",
128 tracing::instrument(
129 level = "debug",
130 skip(module_root, main_rim, supplemental_rim, dialog_erf, module_mod)
131 )
132 )]
133 pub fn from_archives_with_mod(
134 module_root: impl Into<String>,
135 main_rim: Option<Rim>,
136 supplemental_rim: Option<Rim>,
137 dialog_erf: Option<Erf>,
138 module_mod: Option<Erf>,
139 ) -> Result<Self, CompositeModuleError> {
140 let module_root = module_root.into();
141 trace_debug!(
142 has_main_rim = main_rim.is_some(),
143 has_supplemental_rim = supplemental_rim.is_some(),
144 has_dialog_erf = dialog_erf.is_some(),
145 has_module_mod = module_mod.is_some(),
146 "building composite module from archives"
147 );
148 if dialog_erf.is_none()
149 && supplemental_rim.is_none()
150 && main_rim.is_none()
151 && module_mod.is_none()
152 {
153 return Err(CompositeModuleError::MissingAllSources {
154 module_root: module_root.clone(),
155 });
156 }
157 let main_rim_source = if main_rim.is_some() {
158 Some(CompositeModuleSource::MainRim)
159 } else {
160 None
161 };
162 let dialog_erf_index = build_erf_index(dialog_erf.as_ref());
163 let supplemental_rim_index = build_rim_index(supplemental_rim.as_ref());
164 let main_rim_index = build_rim_index(main_rim.as_ref());
165 let module_mod_index = build_erf_index(module_mod.as_ref());
166 Ok(Self {
167 module_root,
168 dialog_erf,
169 dialog_erf_index,
170 supplemental_rim,
171 supplemental_rim_index,
172 main_rim,
173 main_rim_index,
174 main_rim_source,
175 module_mod,
176 module_mod_index,
177 })
178 }
179
180 #[cfg_attr(
184 feature = "tracing",
185 tracing::instrument(
186 level = "debug",
187 skip(module_root, main_rim_bytes, supplemental_rim_bytes, dialog_erf_bytes)
188 )
189 )]
190 pub fn from_bytes(
191 module_root: impl Into<String>,
192 main_rim_bytes: Option<&[u8]>,
193 supplemental_rim_bytes: Option<&[u8]>,
194 dialog_erf_bytes: Option<&[u8]>,
195 ) -> Result<Self, CompositeModuleError> {
196 Self::from_bytes_with_mod(
197 module_root,
198 main_rim_bytes,
199 supplemental_rim_bytes,
200 dialog_erf_bytes,
201 None,
202 )
203 }
204
205 #[cfg_attr(
210 feature = "tracing",
211 tracing::instrument(
212 level = "debug",
213 skip(
214 module_root,
215 main_rim_bytes,
216 supplemental_rim_bytes,
217 dialog_erf_bytes,
218 module_mod_bytes
219 )
220 )
221 )]
222 pub fn from_bytes_with_mod(
223 module_root: impl Into<String>,
224 main_rim_bytes: Option<&[u8]>,
225 supplemental_rim_bytes: Option<&[u8]>,
226 dialog_erf_bytes: Option<&[u8]>,
227 module_mod_bytes: Option<&[u8]>,
228 ) -> Result<Self, CompositeModuleError> {
229 let module_root = module_root.into();
230 let main_rim = main_rim_bytes
231 .map(read_rim_from_bytes)
232 .transpose()
233 .map_err(|source| CompositeModuleError::ParseRim {
234 source,
235 source_kind: CompositeModuleSource::MainRim,
236 })?;
237 let supplemental_rim = supplemental_rim_bytes
238 .map(read_rim_from_bytes)
239 .transpose()
240 .map_err(|source| CompositeModuleError::ParseRim {
241 source,
242 source_kind: CompositeModuleSource::SupplementalRim,
243 })?;
244 let dialog_erf = dialog_erf_bytes
245 .map(read_erf_from_bytes)
246 .transpose()
247 .map_err(|source| CompositeModuleError::ParseErf {
248 source,
249 source_kind: CompositeModuleSource::DialogErf,
250 })?;
251 let module_mod = module_mod_bytes
252 .map(read_erf_from_bytes)
253 .transpose()
254 .map_err(|source| CompositeModuleError::ParseErf {
255 source,
256 source_kind: CompositeModuleSource::ModuleMod,
257 })?;
258 trace_debug!(
259 has_main_rim = main_rim.is_some(),
260 has_supplemental_rim = supplemental_rim.is_some(),
261 has_dialog_erf = dialog_erf.is_some(),
262 has_module_mod = module_mod.is_some(),
263 "parsed composite module parts from bytes"
264 );
265 Self::from_archives_with_mod(
266 module_root,
267 main_rim,
268 supplemental_rim,
269 dialog_erf,
270 module_mod,
271 )
272 }
273
274 #[cfg_attr(
279 feature = "tracing",
280 tracing::instrument(level = "debug", skip(directory, module_root))
281 )]
282 pub fn load_from_directory(
283 directory: impl AsRef<Path>,
284 module_root: impl Into<String>,
285 ) -> Result<Self, CompositeModuleError> {
286 let directory = directory.as_ref();
287 let module_root = module_root.into();
288
289 let mut main_rim_source = None;
290 let main_rim = if let Some(area_rim) =
291 load_optional_rim(directory, &module_root, CompositeModuleSource::AreaRim)?
292 {
293 main_rim_source = Some(CompositeModuleSource::AreaRim);
294 Some(area_rim)
295 } else if let Some(area_extended_rim) = load_optional_rim(
296 directory,
297 &module_root,
298 CompositeModuleSource::AreaExtendedRim,
299 )? {
300 main_rim_source = Some(CompositeModuleSource::AreaExtendedRim);
301 Some(area_extended_rim)
302 } else if let Some(base_rim) =
303 load_optional_rim(directory, &module_root, CompositeModuleSource::MainRim)?
304 {
305 main_rim_source = Some(CompositeModuleSource::MainRim);
306 Some(base_rim)
307 } else {
308 None
309 };
310 let module_mod =
311 load_optional_erf(directory, &module_root, CompositeModuleSource::ModuleMod)?;
312 let supplemental_rim = if module_mod.is_none() {
313 load_optional_rim(
314 directory,
315 &module_root,
316 CompositeModuleSource::SupplementalRim,
317 )?
318 } else {
319 trace_debug!(
320 source = ?CompositeModuleSource::SupplementalRim,
321 "skipping supplemental rim load because module .mod is present"
322 );
323 None
324 };
325 let dialog_erf =
326 load_optional_erf(directory, &module_root, CompositeModuleSource::DialogErf)?;
327
328 trace_debug!(
329 has_main_rim = main_rim.is_some(),
330 has_supplemental_rim = supplemental_rim.is_some(),
331 has_dialog_erf = dialog_erf.is_some(),
332 has_module_mod = module_mod.is_some(),
333 chosen_main_source = ?main_rim_source,
334 "loaded composite module parts from directory"
335 );
336
337 if dialog_erf.is_none()
338 && supplemental_rim.is_none()
339 && main_rim.is_none()
340 && module_mod.is_none()
341 {
342 return Err(CompositeModuleError::MissingAllSources { module_root });
343 }
344
345 let dialog_erf_index = build_erf_index(dialog_erf.as_ref());
346 let supplemental_rim_index = build_rim_index(supplemental_rim.as_ref());
347 let main_rim_index = build_rim_index(main_rim.as_ref());
348 let module_mod_index = build_erf_index(module_mod.as_ref());
349 Ok(Self {
350 module_root,
351 dialog_erf,
352 dialog_erf_index,
353 supplemental_rim,
354 supplemental_rim_index,
355 main_rim,
356 main_rim_index,
357 main_rim_source,
358 module_mod,
359 module_mod_index,
360 })
361 }
362
363 pub fn module_root(&self) -> &str {
365 &self.module_root
366 }
367
368 pub fn has_source(&self, source: CompositeModuleSource) -> bool {
370 match source {
371 CompositeModuleSource::DialogErf => self.dialog_erf.is_some(),
372 CompositeModuleSource::SupplementalRim => self.supplemental_rim.is_some(),
373 CompositeModuleSource::AreaRim => {
374 self.main_rim_source == Some(CompositeModuleSource::AreaRim)
375 }
376 CompositeModuleSource::AreaExtendedRim => {
377 self.main_rim_source == Some(CompositeModuleSource::AreaExtendedRim)
378 }
379 CompositeModuleSource::MainRim => {
380 self.main_rim_source == Some(CompositeModuleSource::MainRim)
381 }
382 CompositeModuleSource::ModuleMod => self.module_mod.is_some(),
383 }
384 }
385
386 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(resref = %resref, resource_type = resource_type.raw_id())))]
388 pub fn resolve(
389 &self,
390 resref: &ResRef,
391 resource_type: ResourceTypeCode,
392 ) -> Option<ResolvedResource<'_>> {
393 let key = (*resref, resource_type);
394
395 if let Some(dialog_erf) = &self.dialog_erf {
396 if let Some(&i) = self.dialog_erf_index.get(&key) {
397 let resource = &dialog_erf.resources[i];
398 trace_debug!(source = ?CompositeModuleSource::DialogErf, data_len = resource.data.len(), "composite module hit");
399 return Some(ResolvedResource {
400 source: CompositeModuleSource::DialogErf,
401 resref: *resref,
402 resource_type,
403 data: resource.data.as_slice(),
404 });
405 }
406 }
407
408 if let Some(supplemental_rim) = &self.supplemental_rim {
409 if let Some(&i) = self.supplemental_rim_index.get(&key) {
410 let resource = &supplemental_rim.resources[i];
411 trace_debug!(source = ?CompositeModuleSource::SupplementalRim, data_len = resource.data.len(), "composite module hit");
412 return Some(ResolvedResource {
413 source: CompositeModuleSource::SupplementalRim,
414 resref: *resref,
415 resource_type,
416 data: resource.data.as_slice(),
417 });
418 }
419 }
420
421 if let Some(main_rim) = &self.main_rim {
422 if let Some(&i) = self.main_rim_index.get(&key) {
423 let resource = &main_rim.resources[i];
424 let source = self
425 .main_rim_source
426 .unwrap_or(CompositeModuleSource::MainRim);
427 trace_debug!(source = ?source, data_len = resource.data.len(), "composite module hit");
428 return Some(ResolvedResource {
429 source,
430 resref: *resref,
431 resource_type,
432 data: resource.data.as_slice(),
433 });
434 }
435 }
436
437 if let Some(module_mod) = &self.module_mod {
438 if let Some(&i) = self.module_mod_index.get(&key) {
439 let resource = &module_mod.resources[i];
440 trace_debug!(source = ?CompositeModuleSource::ModuleMod, data_len = resource.data.len(), "composite module hit");
441 return Some(ResolvedResource {
442 source: CompositeModuleSource::ModuleMod,
443 resref: *resref,
444 resource_type,
445 data: resource.data.as_slice(),
446 });
447 }
448 }
449
450 trace_debug!("composite module miss");
451 None
452 }
453
454 #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self), fields(resref = resref, resource_type = resource_type_id)))]
458 #[doc(hidden)]
459 pub fn resolve_raw(
460 &self,
461 resref: &str,
462 resource_type_id: u16,
463 ) -> Result<Option<ResolvedResource<'_>>, CompositeModuleError> {
464 let resref = ResRef::new(resref).map_err(|source| CompositeModuleError::InvalidResRef {
465 value: resref.to_string(),
466 source,
467 })?;
468 Ok(self.resolve(&resref, ResourceTypeCode::from_raw_id(resource_type_id)))
469 }
470}
471
472#[derive(Debug, Error)]
474pub enum CompositeModuleError {
475 #[error("failed to read {source_kind:?} at `{}`: {source}", path.display())]
477 Io {
478 #[source]
480 source: std::io::Error,
481 path: PathBuf,
483 source_kind: CompositeModuleSource,
485 },
486 #[error("failed to parse {source_kind:?} as RIM: {source}")]
488 ParseRim {
489 #[source]
491 source: RimBinaryError,
492 source_kind: CompositeModuleSource,
494 },
495 #[error("failed to parse {source_kind:?} as ERF: {source}")]
497 ParseErf {
498 #[source]
500 source: ErfBinaryError,
501 source_kind: CompositeModuleSource,
503 },
504 #[error("invalid resource query resref `{value}`: {source}")]
506 InvalidResRef {
507 value: String,
509 #[source]
511 source: ResRefError,
512 },
513 #[error("module `{module_root}` has no available source files (`.rim`, `_a.rim`, `_adx.rim`, `_s.rim`, `_dlg.erf`, `.mod`)")]
515 MissingAllSources {
516 module_root: String,
518 },
519}
520
521fn build_erf_index(erf: Option<&Erf>) -> ArchiveIndex {
523 let Some(erf) = erf else {
524 return HashMap::new();
525 };
526 let mut index = HashMap::with_capacity(erf.resources.len());
527 for (i, resource) in erf.resources.iter().enumerate() {
528 index
529 .entry((resource.resref, resource.resource_type))
530 .or_insert(i);
531 }
532 index
533}
534
535fn build_rim_index(rim: Option<&Rim>) -> ArchiveIndex {
537 let Some(rim) = rim else {
538 return HashMap::new();
539 };
540 let mut index = HashMap::with_capacity(rim.resources.len());
541 for (i, resource) in rim.resources.iter().enumerate() {
542 index
543 .entry((resource.resref, resource.resource_type))
544 .or_insert(i);
545 }
546 index
547}
548
549fn load_optional_rim(
550 directory: &Path,
551 module_root: &str,
552 source_kind: CompositeModuleSource,
553) -> Result<Option<Rim>, CompositeModuleError> {
554 let file_name = format!("{module_root}{}", source_kind.suffix());
555 let Some(path) = find_case_insensitive_file(directory, &file_name).map_err(|source| {
556 CompositeModuleError::Io {
557 source,
558 path: directory.join(&file_name),
559 source_kind,
560 }
561 })?
562 else {
563 trace_debug!(source = ?source_kind, "composite source file not found");
564 return Ok(None);
565 };
566 trace_debug!(source = ?source_kind, path = %path.display(), "reading composite source file");
567 let bytes = std::fs::read(&path).map_err(|source| CompositeModuleError::Io {
568 source,
569 path: path.clone(),
570 source_kind,
571 })?;
572 read_rim_from_bytes(&bytes)
573 .map(Some)
574 .map_err(|source| CompositeModuleError::ParseRim {
575 source,
576 source_kind,
577 })
578}
579
580fn load_optional_erf(
581 directory: &Path,
582 module_root: &str,
583 source_kind: CompositeModuleSource,
584) -> Result<Option<Erf>, CompositeModuleError> {
585 let file_name = format!("{module_root}{}", source_kind.suffix());
586 let Some(path) = find_case_insensitive_file(directory, &file_name).map_err(|source| {
587 CompositeModuleError::Io {
588 source,
589 path: directory.join(&file_name),
590 source_kind,
591 }
592 })?
593 else {
594 trace_debug!(source = ?source_kind, "composite source file not found");
595 return Ok(None);
596 };
597 trace_debug!(source = ?source_kind, path = %path.display(), "reading composite source file");
598 let bytes = std::fs::read(&path).map_err(|source| CompositeModuleError::Io {
599 source,
600 path: path.clone(),
601 source_kind,
602 })?;
603 read_erf_from_bytes(&bytes)
604 .map(Some)
605 .map_err(|source| CompositeModuleError::ParseErf {
606 source,
607 source_kind,
608 })
609}
610
611#[cfg(test)]
612mod tests {
613 use std::fs;
614
615 use rakata_core::ResRef;
616 use rakata_core::{ResourceType, ResourceTypeCode};
617 use rakata_formats::{write_erf_to_vec, write_rim_to_vec, ErfFileType};
618 use tempfile::TempDir;
619
620 use super::{CompositeModule, CompositeModuleError, CompositeModuleSource};
621
622 fn sample_main_rim(payload: &[u8]) -> rakata_formats::Rim {
623 let mut rim = rakata_formats::Rim::new();
624 rim.push_resource(
625 ResRef::new("module").expect("valid resref"),
626 ResourceTypeCode::from(ResourceType::Dlg),
627 payload.to_vec(),
628 );
629 rim
630 }
631
632 fn sample_supplemental_rim(payload: &[u8]) -> rakata_formats::Rim {
633 let mut rim = rakata_formats::Rim::new();
634 rim.push_resource(
635 ResRef::new("module").expect("valid resref"),
636 ResourceTypeCode::from(ResourceType::Dlg),
637 payload.to_vec(),
638 );
639 rim
640 }
641
642 fn sample_dialog_erf(payload: &[u8]) -> rakata_formats::Erf {
643 let mut erf = rakata_formats::Erf::new(ErfFileType::Erf);
644 erf.push_resource(
645 ResRef::new("module").expect("valid resref"),
646 ResourceTypeCode::from(ResourceType::Dlg),
647 payload.to_vec(),
648 );
649 erf
650 }
651
652 fn sample_module_mod(payload: &[u8]) -> rakata_formats::Erf {
653 let mut erf = rakata_formats::Erf::new(ErfFileType::Mod);
654 erf.push_resource(
655 ResRef::new("module").expect("valid resref"),
656 ResourceTypeCode::from(ResourceType::Dlg),
657 payload.to_vec(),
658 );
659 erf
660 }
661
662 fn temp_test_dir() -> TempDir {
663 TempDir::new().expect("create tempdir")
664 }
665
666 #[test]
667 fn resolve_prefers_dialog_over_supplemental_and_main() {
668 let module = CompositeModule::from_archives(
669 "m01aa",
670 Some(sample_main_rim(b"main")),
671 Some(sample_supplemental_rim(b"supp")),
672 Some(sample_dialog_erf(b"dlg")),
673 )
674 .expect("composite should build");
675
676 let resref = ResRef::new("module").expect("valid resref");
677 let resolved = module
678 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
679 .expect("resource should resolve");
680 assert_eq!(resolved.source, CompositeModuleSource::DialogErf);
681 assert_eq!(resolved.data, b"dlg");
682 }
683
684 #[test]
685 fn resolve_prefers_supplemental_when_dialog_missing() {
686 let module = CompositeModule::from_archives(
687 "m01aa",
688 Some(sample_main_rim(b"main")),
689 Some(sample_supplemental_rim(b"supp")),
690 None,
691 )
692 .expect("composite should build");
693
694 let resref = ResRef::new("module").expect("valid resref");
695 let resolved = module
696 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
697 .expect("resource should resolve");
698 assert_eq!(resolved.source, CompositeModuleSource::SupplementalRim);
699 assert_eq!(resolved.data, b"supp");
700 }
701
702 #[test]
703 fn resolve_falls_back_to_main() {
704 let module =
705 CompositeModule::from_archives("m01aa", Some(sample_main_rim(b"main")), None, None)
706 .expect("composite should build");
707
708 let resref = ResRef::new("module").expect("valid resref");
709 let resolved = module
710 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
711 .expect("resource should resolve");
712 assert_eq!(resolved.source, CompositeModuleSource::MainRim);
713 assert_eq!(resolved.data, b"main");
714 }
715
716 #[test]
717 fn resolve_falls_back_to_mod_when_other_parts_missing() {
718 let module = CompositeModule::from_archives_with_mod(
719 "m01aa",
720 None,
721 None,
722 None,
723 Some(sample_module_mod(b"mod")),
724 )
725 .expect("composite should build");
726
727 let resref = ResRef::new("module").expect("valid resref");
728 let resolved = module
729 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
730 .expect("resource should resolve");
731 assert_eq!(resolved.source, CompositeModuleSource::ModuleMod);
732 assert_eq!(resolved.data, b"mod");
733 }
734
735 #[test]
736 fn resolve_prefers_main_rim_over_mod() {
737 let module = CompositeModule::from_archives_with_mod(
738 "m01aa",
739 Some(sample_main_rim(b"main")),
740 None,
741 None,
742 Some(sample_module_mod(b"mod")),
743 )
744 .expect("composite should build");
745
746 let resref = ResRef::new("module").expect("valid resref");
747 let resolved = module
748 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
749 .expect("resource should resolve");
750 assert_eq!(resolved.source, CompositeModuleSource::MainRim);
751 assert_eq!(resolved.data, b"main");
752 }
753
754 #[test]
755 fn resolve_returns_none_when_not_found() {
756 let module =
757 CompositeModule::from_archives("m01aa", Some(sample_main_rim(b"main")), None, None)
758 .expect("composite should build");
759
760 let resref = ResRef::new("missing").expect("valid resref");
761 assert!(module
762 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
763 .is_none());
764 }
765
766 #[test]
767 fn resolve_raw_validates_resref() {
768 let module =
769 CompositeModule::from_archives("m01aa", Some(sample_main_rim(b"main")), None, None)
770 .expect("composite should build");
771
772 let err = module
776 .resolve_raw("名前", ResourceType::Dlg.type_id())
777 .expect_err("invalid resref must fail");
778 assert!(matches!(err, CompositeModuleError::InvalidResRef { .. }));
779 }
780
781 #[test]
782 fn from_archives_requires_at_least_one_source() {
783 let err = CompositeModule::from_archives("m01aa", None, None, None)
784 .expect_err("empty sources should fail");
785 assert!(matches!(
786 err,
787 CompositeModuleError::MissingAllSources { .. }
788 ));
789 }
790
791 #[test]
792 fn load_from_directory_parses_present_sources() {
793 let temp = temp_test_dir();
794 let dir = temp.path();
795 let root = "m01aa";
796 let main_bytes = write_rim_to_vec(&sample_main_rim(b"main")).expect("main rim bytes");
797 let supp_bytes =
798 write_rim_to_vec(&sample_supplemental_rim(b"supp")).expect("supplemental rim bytes");
799 let dlg_bytes = write_erf_to_vec(&sample_dialog_erf(b"dlg")).expect("dialog erf bytes");
800
801 fs::write(dir.join(format!("{root}.rim")), main_bytes).expect("write main rim");
802 fs::write(dir.join(format!("{root}_s.rim")), supp_bytes).expect("write supplemental rim");
803 fs::write(dir.join(format!("{root}_dlg.erf")), dlg_bytes).expect("write dialog erf");
804
805 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
806 let resref = ResRef::new("module").expect("valid resref");
807 let resolved = module
808 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
809 .expect("resource should resolve");
810 assert_eq!(resolved.source, CompositeModuleSource::DialogErf);
811 assert_eq!(resolved.data, b"dlg");
812 }
813
814 #[test]
815 fn load_from_directory_prefers_mod_over_supplemental_fallback() {
816 let temp = temp_test_dir();
817 let dir = temp.path();
818 let root = "m01aa";
819 let main_bytes = write_rim_to_vec(&sample_main_rim(b"main")).expect("main rim bytes");
820 let supp_bytes =
821 write_rim_to_vec(&sample_supplemental_rim(b"supp")).expect("supplemental rim bytes");
822 let mod_bytes = write_erf_to_vec(&sample_module_mod(b"mod")).expect("module mod bytes");
823
824 fs::write(dir.join(format!("{root}.rim")), main_bytes).expect("write main rim");
825 fs::write(dir.join(format!("{root}_s.rim")), supp_bytes).expect("write supplemental rim");
826 fs::write(dir.join(format!("{root}.mod")), mod_bytes).expect("write module mod");
827
828 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
829 assert!(module.has_source(CompositeModuleSource::ModuleMod));
830 assert!(!module.has_source(CompositeModuleSource::SupplementalRim));
831
832 let resref = ResRef::new("module").expect("valid resref");
833 let resolved = module
834 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
835 .expect("resource should resolve");
836 assert_eq!(resolved.source, CompositeModuleSource::MainRim);
837 assert_eq!(resolved.data, b"main");
838 }
839
840 #[test]
841 fn load_from_directory_prefers_area_rim_over_main_rim() {
842 let temp = temp_test_dir();
843 let dir = temp.path();
844 let root = "m01aa";
845 let main_bytes = write_rim_to_vec(&sample_main_rim(b"main")).expect("main rim bytes");
846 let area_bytes = write_rim_to_vec(&sample_main_rim(b"area")).expect("area rim bytes");
847
848 fs::write(dir.join(format!("{root}.rim")), main_bytes).expect("write main rim");
849 fs::write(dir.join(format!("{root}_a.rim")), area_bytes).expect("write area rim");
850
851 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
852 assert!(module.has_source(CompositeModuleSource::AreaRim));
853 assert!(!module.has_source(CompositeModuleSource::MainRim));
854
855 let resref = ResRef::new("module").expect("valid resref");
856 let resolved = module
857 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
858 .expect("resource should resolve");
859 assert_eq!(resolved.source, CompositeModuleSource::AreaRim);
860 assert_eq!(resolved.data, b"area");
861 }
862
863 #[test]
864 fn load_from_directory_uses_area_extended_when_area_missing() {
865 let temp = temp_test_dir();
866 let dir = temp.path();
867 let root = "m01aa";
868 let main_bytes = write_rim_to_vec(&sample_main_rim(b"main")).expect("main rim bytes");
869 let area_extended_bytes =
870 write_rim_to_vec(&sample_main_rim(b"adx")).expect("area-extended rim bytes");
871
872 fs::write(dir.join(format!("{root}.rim")), main_bytes).expect("write main rim");
873 fs::write(dir.join(format!("{root}_adx.rim")), area_extended_bytes)
874 .expect("write area-extended rim");
875
876 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
877 assert!(module.has_source(CompositeModuleSource::AreaExtendedRim));
878 assert!(!module.has_source(CompositeModuleSource::AreaRim));
879 assert!(!module.has_source(CompositeModuleSource::MainRim));
880
881 let resref = ResRef::new("module").expect("valid resref");
882 let resolved = module
883 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
884 .expect("resource should resolve");
885 assert_eq!(resolved.source, CompositeModuleSource::AreaExtendedRim);
886 assert_eq!(resolved.data, b"adx");
887 }
888
889 #[test]
890 fn load_from_directory_prefers_area_over_area_extended() {
891 let temp = temp_test_dir();
892 let dir = temp.path();
893 let root = "m01aa";
894 let area_bytes = write_rim_to_vec(&sample_main_rim(b"area")).expect("area rim bytes");
895 let area_extended_bytes =
896 write_rim_to_vec(&sample_main_rim(b"adx")).expect("area-extended rim bytes");
897
898 fs::write(dir.join(format!("{root}_a.rim")), area_bytes).expect("write area rim");
899 fs::write(dir.join(format!("{root}_adx.rim")), area_extended_bytes)
900 .expect("write area-extended rim");
901
902 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
903 assert!(module.has_source(CompositeModuleSource::AreaRim));
904 assert!(!module.has_source(CompositeModuleSource::AreaExtendedRim));
905
906 let resref = ResRef::new("module").expect("valid resref");
907 let resolved = module
908 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
909 .expect("resource should resolve");
910 assert_eq!(resolved.source, CompositeModuleSource::AreaRim);
911 assert_eq!(resolved.data, b"area");
912 }
913
914 #[test]
915 fn load_from_directory_matches_source_files_case_insensitively() {
916 let temp = temp_test_dir();
917 let dir = temp.path();
918 let root = "m01aa";
919 let main_bytes = write_rim_to_vec(&sample_main_rim(b"main")).expect("main rim bytes");
920
921 fs::write(dir.join("M01AA.RIM"), main_bytes).expect("write upper-case source file");
922
923 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
924 let resref = ResRef::new("module").expect("valid resref");
925 let resolved = module
926 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
927 .expect("resource should resolve");
928 assert_eq!(resolved.source, CompositeModuleSource::MainRim);
929 assert_eq!(resolved.data, b"main");
930 }
931
932 #[test]
933 fn load_from_directory_uses_deterministic_case_collision_tiebreak() {
934 let temp = temp_test_dir();
935 let dir = temp.path();
936 let root = "m01aa";
937 let lower_bytes = write_rim_to_vec(&sample_main_rim(b"lower")).expect("lower bytes");
938 let upper_bytes = write_rim_to_vec(&sample_main_rim(b"upper")).expect("upper bytes");
939
940 fs::write(dir.join("m01aa.rim"), lower_bytes).expect("write lower-case source file");
941 if fs::write(dir.join("M01AA.RIM"), upper_bytes).is_err() {
943 return;
944 }
945
946 let module = CompositeModule::load_from_directory(dir, root).expect("load composite");
947 let resref = ResRef::new("module").expect("valid resref");
948 let resolved = module
949 .resolve(&resref, ResourceTypeCode::from(ResourceType::Dlg))
950 .expect("resource should resolve");
951
952 assert_eq!(resolved.data, b"upper");
955 }
956}