Skip to main content

rakata_extract/
game_resources.rs

1//! Unified resource accessor for a game installation directory.
2//!
3//! [`GameResources`] searches `modules/*.rim` files and `KEY/BIF` archives
4//! to find or enumerate resources across an entire game installation.
5//! RIM resources take precedence over KEY/BIF for the same resref.
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use rakata_core::ResourceType;
11use rakata_formats::key::KeyResourceEntry;
12use rakata_formats::rim::{read_rim_from_bytes, RimResource};
13
14use crate::chitin::ChitinError;
15use crate::keyfile::KeyFileError;
16use crate::Chitin;
17
18/// Result of [`GameResources::find_pair`]: `(primary_data, companion_data, source_label)`.
19pub type ResourcePair = (Vec<u8>, Option<Vec<u8>>, String);
20
21/// Errors produced by [`GameResources`] operations.
22#[derive(Debug, thiserror::Error)]
23pub enum GameResourcesError {
24    /// Failed to read or parse `chitin.key` / BIF archives.
25    #[error(transparent)]
26    Chitin(#[from] ChitinError),
27    /// Failed during KEY/BIF seek-read.
28    #[error(transparent)]
29    KeyFile(#[from] KeyFileError),
30    /// Filesystem I/O error (reading RIM files, enumerating directories).
31    #[error("I/O error for `{path}`: {source}")]
32    Io {
33        /// Path that caused the error.
34        path: PathBuf,
35        /// Underlying I/O error.
36        source: std::io::Error,
37    },
38    /// Failed to parse a RIM archive.
39    #[error("RIM parse error for `{path}`: {source}")]
40    Rim {
41        /// Path to the RIM file.
42        path: PathBuf,
43        /// Underlying RIM parse error.
44        source: rakata_formats::rim::RimBinaryError,
45    },
46}
47
48/// Unified resource accessor for a game installation directory.
49///
50/// Searches `modules/*.rim` files and KEY/BIF archives. RIM resources
51/// take precedence over KEY/BIF for the same resref. Supports both
52/// single-resource lookup and bulk enumeration.
53///
54/// # Examples
55///
56/// ```no_run
57/// use rakata_core::ResourceType;
58/// use rakata_extract::GameResources;
59///
60/// let game = GameResources::open("/path/to/game")?;
61///
62/// // Find a single resource
63/// if let Some((data, source)) = game.find("c_humanoid01", ResourceType::Mdl)? {
64///     println!("Found in {source}, {} bytes", data.len());
65/// }
66/// # Ok::<(), rakata_extract::GameResourcesError>(())
67/// ```
68pub struct GameResources {
69    chitin: Chitin,
70    rim_paths: Vec<PathBuf>,
71}
72
73impl GameResources {
74    /// Open a game installation directory.
75    ///
76    /// Loads `chitin.key` and enumerates `modules/*.rim` paths (sorted).
77    /// RIM contents are read lazily during find/iteration calls.
78    pub fn open(game_root: impl AsRef<Path>) -> Result<Self, GameResourcesError> {
79        let game_root = game_root.as_ref();
80        let chitin = Chitin::read_from_root(game_root)?;
81
82        let modules_dir = game_root.join("modules");
83        let rim_paths = enumerate_rim_files(&modules_dir)?;
84
85        Ok(Self { chitin, rim_paths })
86    }
87
88    /// Check whether a resource exists anywhere in the installation.
89    pub fn exists(
90        &self,
91        resref: &str,
92        resource_type: ResourceType,
93    ) -> Result<bool, GameResourcesError> {
94        Ok(self.find(resref, resource_type)?.is_some())
95    }
96
97    /// Find and extract a single resource by resref.
98    ///
99    /// Searches RIM files first, then KEY/BIF. Returns `(data, source_label)`
100    /// or `None` if not found.
101    pub fn find(
102        &self,
103        resref: &str,
104        resource_type: ResourceType,
105    ) -> Result<Option<(Vec<u8>, String)>, GameResourcesError> {
106        let type_code = resource_type.into();
107
108        // Search RIM files first.
109        for rim_path in &self.rim_paths {
110            let rim = read_rim_file(rim_path)?;
111            let resref_parsed = resref.parse().unwrap_or_default();
112            if let Some(data) = rim.resource(&resref_parsed, type_code) {
113                let label = rim_label(rim_path);
114                return Ok(Some((data.to_vec(), label)));
115            }
116        }
117
118        // Fall back to KEY/BIF.
119        if let Some(data) = self
120            .chitin
121            .key_file()
122            .resource(&resref.parse().unwrap_or_default(), type_code)?
123        {
124            return Ok(Some((data, "KEY/BIF".into())));
125        }
126
127        Ok(None)
128    }
129
130    /// Find a resource and its companion (e.g., MDL + MDX, TPC + TXI).
131    ///
132    /// Returns `(data, companion_data, source_label)` or `None` if the
133    /// primary resource is not found. The companion may be `None` even when
134    /// the primary exists.
135    pub fn find_pair(
136        &self,
137        resref: &str,
138        primary_type: ResourceType,
139        companion_type: ResourceType,
140    ) -> Result<Option<ResourcePair>, GameResourcesError> {
141        let primary_code = primary_type.into();
142        let companion_code = companion_type.into();
143
144        // Search RIM files first.
145        for rim_path in &self.rim_paths {
146            let rim = read_rim_file(rim_path)?;
147            let resref_parsed = resref.parse().unwrap_or_default();
148            if let Some(primary_data) = rim.resource(&resref_parsed, primary_code) {
149                let companion_data = rim
150                    .resource(&resref_parsed, companion_code)
151                    .map(|d| d.to_vec());
152                let label = rim_label(rim_path);
153                return Ok(Some((primary_data.to_vec(), companion_data, label)));
154            }
155        }
156
157        // Fall back to KEY/BIF.
158        let resref_parsed = resref.parse().unwrap_or_default();
159        if let Some(primary_data) = self
160            .chitin
161            .key_file()
162            .resource(&resref_parsed, primary_code)?
163        {
164            let companion_data = self
165                .chitin
166                .key_file()
167                .resource(&resref_parsed, companion_code)?;
168            return Ok(Some((primary_data, companion_data, "KEY/BIF".into())));
169        }
170
171        Ok(None)
172    }
173
174    /// Iterate all unique resources of a given type across the installation.
175    ///
176    /// Calls `f(resref, data, source_label)` for each unique resource.
177    /// RIM resources are yielded first and take precedence -- if a resref
178    /// appears in both a RIM and KEY/BIF, only the RIM version is yielded.
179    pub fn for_each<F>(
180        &self,
181        resource_type: ResourceType,
182        mut f: F,
183    ) -> Result<(), GameResourcesError>
184    where
185        F: FnMut(&str, Vec<u8>, &str),
186    {
187        let type_code = resource_type.into();
188        let mut seen: BTreeSet<String> = BTreeSet::new();
189
190        // RIM files first.
191        for rim_path in &self.rim_paths {
192            let rim = read_rim_file(rim_path)?;
193            let label = rim_label(rim_path);
194            for res in &rim.resources {
195                if res.resource_type == type_code {
196                    let key = res.resref.to_string();
197                    if !seen.contains(&key) {
198                        f(&key, res.data.clone(), &label);
199                        seen.insert(key);
200                    }
201                }
202            }
203        }
204
205        // KEY/BIF resources.
206        for_each_key_resource(
207            &self.chitin,
208            type_code,
209            &mut seen,
210            &mut |resref, data, label| f(resref, data, label),
211        )?;
212
213        Ok(())
214    }
215
216    /// Iterate all unique resources with a companion type.
217    ///
218    /// Calls `f(resref, data, companion_data, source_label)` for each unique
219    /// primary resource. The companion may be `None`.
220    pub fn for_each_pair<F>(
221        &self,
222        primary_type: ResourceType,
223        companion_type: ResourceType,
224        mut f: F,
225    ) -> Result<(), GameResourcesError>
226    where
227        F: FnMut(&str, Vec<u8>, Option<Vec<u8>>, &str),
228    {
229        let primary_code = primary_type.into();
230        let companion_code = companion_type.into();
231        let mut seen: BTreeSet<String> = BTreeSet::new();
232
233        // RIM files first.
234        for rim_path in &self.rim_paths {
235            let rim = read_rim_file(rim_path)?;
236            let label = rim_label(rim_path);
237            for_each_pair_in_rim(
238                &rim,
239                primary_code,
240                companion_code,
241                &label,
242                &mut seen,
243                &mut f,
244            );
245        }
246
247        // KEY/BIF resources.
248        for_each_key_resource_pair(
249            &self.chitin,
250            primary_code,
251            companion_code,
252            &mut seen,
253            &mut |resref, data, companion, label| f(resref, data, companion, label),
254        )?;
255
256        Ok(())
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Internal helpers
262// ---------------------------------------------------------------------------
263
264/// Enumerate and sort `.rim` files in the modules directory.
265fn enumerate_rim_files(modules_dir: &Path) -> Result<Vec<PathBuf>, GameResourcesError> {
266    let entries = match std::fs::read_dir(modules_dir) {
267        Ok(entries) => entries,
268        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
269        Err(e) => {
270            return Err(GameResourcesError::Io {
271                path: modules_dir.to_path_buf(),
272                source: e,
273            })
274        }
275    };
276
277    let mut paths: Vec<PathBuf> = entries
278        .filter_map(|entry| {
279            let path = entry.ok()?.path();
280            let ext = path.extension()?.to_str()?;
281            if ext.eq_ignore_ascii_case("rim") {
282                Some(path)
283            } else {
284                None
285            }
286        })
287        .collect();
288
289    paths.sort();
290    Ok(paths)
291}
292
293/// Read and parse a RIM file.
294fn read_rim_file(path: &Path) -> Result<rakata_formats::rim::Rim, GameResourcesError> {
295    let bytes = std::fs::read(path).map_err(|e| GameResourcesError::Io {
296        path: path.to_path_buf(),
297        source: e,
298    })?;
299    read_rim_from_bytes(&bytes).map_err(|e| GameResourcesError::Rim {
300        path: path.to_path_buf(),
301        source: e,
302    })
303}
304
305/// Extract a human-readable label from a RIM path (just the filename).
306fn rim_label(path: &Path) -> String {
307    path.file_name()
308        .and_then(|n| n.to_str())
309        .unwrap_or("unknown")
310        .to_string()
311}
312
313/// Yield matching resources from a parsed RIM with companion lookup.
314fn for_each_pair_in_rim(
315    rim: &rakata_formats::rim::Rim,
316    primary_code: rakata_core::ResourceTypeCode,
317    companion_code: rakata_core::ResourceTypeCode,
318    label: &str,
319    seen: &mut BTreeSet<String>,
320    f: &mut impl FnMut(&str, Vec<u8>, Option<Vec<u8>>, &str),
321) {
322    // Collect primary resources first to avoid borrow conflicts.
323    let primaries: Vec<&RimResource> = rim
324        .resources
325        .iter()
326        .filter(|r| r.resource_type == primary_code)
327        .collect();
328
329    for primary in primaries {
330        let key = primary.resref.to_string();
331        if !seen.insert(key.clone()) {
332            continue;
333        }
334        let companion = rim
335            .resource(&primary.resref, companion_code)
336            .map(|d| d.to_vec());
337        f(&key, primary.data.clone(), companion, label);
338    }
339}
340
341/// Iterate KEY/BIF resources of a given type, skipping already-seen resrefs.
342fn for_each_key_resource(
343    chitin: &Chitin,
344    type_code: rakata_core::ResourceTypeCode,
345    seen: &mut BTreeSet<String>,
346    f: &mut impl FnMut(&str, Vec<u8>, &str),
347) -> Result<(), GameResourcesError> {
348    let key = chitin.key();
349    let entries: Vec<&KeyResourceEntry> = key
350        .resources
351        .iter()
352        .filter(|e| e.resource_type == type_code)
353        .collect();
354
355    for entry in entries {
356        let lc = entry.resref.to_string();
357        if !seen.insert(lc.clone()) {
358            continue;
359        }
360        let data = chitin
361            .key_file()
362            .read_resource_by_seek(entry.resource_id.bif_index(), entry.resource_id)?;
363        f(&lc, data, "KEY/BIF");
364    }
365
366    Ok(())
367}
368
369/// Iterate KEY/BIF resource pairs, skipping already-seen resrefs.
370fn for_each_key_resource_pair(
371    chitin: &Chitin,
372    primary_code: rakata_core::ResourceTypeCode,
373    companion_code: rakata_core::ResourceTypeCode,
374    seen: &mut BTreeSet<String>,
375    f: &mut impl FnMut(&str, Vec<u8>, Option<Vec<u8>>, &str),
376) -> Result<(), GameResourcesError> {
377    let key = chitin.key();
378    let entries: Vec<&KeyResourceEntry> = key
379        .resources
380        .iter()
381        .filter(|e| e.resource_type == primary_code)
382        .collect();
383
384    for entry in entries {
385        let lc = entry.resref.to_string();
386        if !seen.insert(lc.clone()) {
387            continue;
388        }
389        let data = chitin
390            .key_file()
391            .read_resource_by_seek(entry.resource_id.bif_index(), entry.resource_id)?;
392
393        // Look up companion in KEY.
394        let companion = chitin.key_file().resource(&entry.resref, companion_code)?;
395
396        f(&lc, data, companion, "KEY/BIF");
397    }
398
399    Ok(())
400}