Skip to main content

rakata_extract/
composite_module.rs

1//! Composite module resource resolution.
2//!
3//! KotOR module content is commonly split across up to three archive files:
4//!
5//! ```text
6//! <root>.rim       (base module resources)
7//! <root>_a.rim     (area replacement base resources; replaces <root>.rim)
8//! <root>_adx.rim   (extended area replacement base resources; fallback when _a is absent)
9//! <root>_s.rim     (supplemental module resources)
10//! <root>_dlg.erf   (K2 dialog resources)
11//! <root>.mod       (single-file module ERF archive)
12//! ```
13//!
14//! This module provides an idiomatic Rust abstraction for composing those
15//! sources and resolving `(resref, type)` lookups with deterministic precedence.
16//!
17//! ## Default Precedence
18//! ```text
19//! 1. <root>_dlg.erf
20//! 2. <root>_s.rim
21//! 3. <root>_a.rim (if present) else <root>_adx.rim (if present) else <root>.rim
22//! 4. <root>.mod
23//! ```
24//!
25//! This matches the project's current canonical precedence candidate and keeps
26//! conflict behavior explicit and testable.
27
28use 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/// Source archive kind inside a composite module.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub enum CompositeModuleSource {
44    /// `<root>_dlg.erf`
45    DialogErf,
46    /// `<root>_s.rim`
47    SupplementalRim,
48    /// `<root>_a.rim`
49    AreaRim,
50    /// `<root>_adx.rim`
51    AreaExtendedRim,
52    /// `<root>.rim`
53    MainRim,
54    /// `<root>.mod`
55    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/// One resolved resource view returned by [`CompositeModule`] queries.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ResolvedResource<'a> {
74    /// Source archive where this resource was found.
75    pub source: CompositeModuleSource,
76    /// Canonical query resref.
77    pub resref: ResRef,
78    /// Raw on-disk resource type code.
79    pub resource_type: ResourceTypeCode,
80    /// Borrowed payload bytes.
81    pub data: &'a [u8],
82}
83
84/// Per-archive resource index for O(1) `(resref, type)` lookups.
85type ArchiveIndex = HashMap<(ResRef, ResourceTypeCode), usize>;
86
87/// In-memory composite module made of optional RIM/ERF parts.
88#[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    /// Builds a composite module from parsed archive values.
104    ///
105    /// At least one source archive must be present.
106    #[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    /// Builds a composite module from parsed archive values, including optional
123    /// `.mod` single-file module archive.
124    ///
125    /// At least one source archive must be present.
126    #[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    /// Builds a composite module from optional archive byte slices.
181    ///
182    /// At least one source archive must be provided.
183    #[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    /// Builds a composite module from optional archive byte slices, including
206    /// optional `.mod` single-file module archive bytes.
207    ///
208    /// At least one source archive must be provided.
209    #[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    /// Loads a composite module from a directory and module root name.
275    ///
276    /// Missing source files are treated as absent module parts.
277    /// At least one of the three canonical part files must exist.
278    #[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    /// Returns the module root name used by this composite view.
364    pub fn module_root(&self) -> &str {
365        &self.module_root
366    }
367
368    /// Returns `true` when the specified source archive is present.
369    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    /// Resolves a resource using composite precedence.
387    #[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    /// Resolves a resource from raw query values.
455    ///
456    /// The `resref` value is validated before lookup.
457    #[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/// Errors returned by [`CompositeModule`] constructors and lookups.
473#[derive(Debug, Error)]
474pub enum CompositeModuleError {
475    /// A filesystem read failed while loading a module part.
476    #[error("failed to read {source_kind:?} at `{}`: {source}", path.display())]
477    Io {
478        /// Source I/O error.
479        #[source]
480        source: std::io::Error,
481        /// Path that failed to read.
482        path: PathBuf,
483        /// Source kind being loaded.
484        source_kind: CompositeModuleSource,
485    },
486    /// A RIM source failed to parse.
487    #[error("failed to parse {source_kind:?} as RIM: {source}")]
488    ParseRim {
489        /// Source parse error.
490        #[source]
491        source: RimBinaryError,
492        /// Source kind being parsed.
493        source_kind: CompositeModuleSource,
494    },
495    /// An ERF source failed to parse.
496    #[error("failed to parse {source_kind:?} as ERF: {source}")]
497    ParseErf {
498        /// Source parse error.
499        #[source]
500        source: ErfBinaryError,
501        /// Source kind being parsed.
502        source_kind: CompositeModuleSource,
503    },
504    /// Query `resref` failed validation.
505    #[error("invalid resource query resref `{value}`: {source}")]
506    InvalidResRef {
507        /// Original query value.
508        value: String,
509        /// Validation error.
510        #[source]
511        source: ResRefError,
512    },
513    /// No canonical module source file was present.
514    #[error("module `{module_root}` has no available source files (`.rim`, `_a.rim`, `_adx.rim`, `_s.rim`, `_dlg.erf`, `.mod`)")]
515    MissingAllSources {
516        /// Module root that was requested.
517        module_root: String,
518    },
519}
520
521/// Builds an `(ResRef, ResourceTypeCode) -> index` lookup for ERF resources.
522fn 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
535/// Builds an `(ResRef, ResourceTypeCode) -> index` lookup for RIM resources.
536fn 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        // Characters with no Windows-1252 mapping (CJK, emoji) are
773        // the only resref inputs the validator still rejects post
774        // engine audit. See resref.rs for the full rule set.
775        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        // Some filesystems are case-insensitive and cannot store both names.
942        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        // Tie-break rule prefers lexicographically-first original name when
953        // lowercase keys are equal (`M01AA.RIM` before `m01aa.rim`).
954        assert_eq!(resolved.data, b"upper");
955    }
956}