Skip to main content

rakata_extract/
capsule.rs

1//! Capsule archive primitives.
2//!
3//! Capsules are single archive files used in KotOR content workflows:
4//! ERF-family archives (`.erf`, `.mod`, `.sav`, `.hak`) and RIM (`.rim`).
5//!
6//! ## Scope
7//! - Read one capsule from bytes or disk.
8//! - Query `(resref, type)` payloads case-insensitively through indexed lookups.
9//! - Enumerate contained resources with lightweight metadata views.
10
11use std::collections::HashMap;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use thiserror::Error;
16
17use rakata_core::ResRef;
18use rakata_core::ResourceTypeCode;
19use rakata_formats::{
20    read_erf_from_bytes, read_rim_from_bytes, read_save_archive_from_bytes, Erf, ErfBinaryError,
21    ErfResource, Rim, RimBinaryError, RimResource,
22};
23
24/// Read-only capsule resource view.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct CapsuleResourceRef<'a> {
27    /// Resource name (borrowed). Use `.as_bytes()` for raw byte access
28    /// or the Display impl (`format!("{resref}")`) for a string view.
29    pub resref: &'a ResRef,
30    /// Raw on-disk type code.
31    pub resource_type: ResourceTypeCode,
32    /// Borrowed payload bytes.
33    pub data: &'a [u8],
34}
35
36/// In-memory capsule representation.
37#[derive(Debug, Clone)]
38pub struct Capsule {
39    path: Option<PathBuf>,
40    archive: CapsuleArchive,
41    /// O(1) lookup index: `(lowercase_resref, type) -> Vec index`.
42    resource_index: HashMap<(ResRef, ResourceTypeCode), usize>,
43}
44
45/// Supported capsule archive families.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum CapsuleArchive {
48    /// ERF-family archive container.
49    Erf(Erf),
50    /// RIM archive container.
51    Rim(Rim),
52}
53
54impl Capsule {
55    /// Reads a capsule from bytes.
56    pub fn read_from_bytes(bytes: &[u8]) -> Result<Self, CapsuleError> {
57        if bytes.len() < 4 {
58            return Err(CapsuleError::InvalidMagic {
59                magic: [0, 0, 0, 0],
60            });
61        }
62        let magic = [bytes[0], bytes[1], bytes[2], bytes[3]];
63        let archive = match &magic {
64            b"RIM " => CapsuleArchive::Rim(read_rim_from_bytes(bytes)?),
65            b"ERF " | b"MOD " | b"HAK " => CapsuleArchive::Erf(read_erf_from_bytes(bytes)?),
66            // Save helpers intentionally accept either MOD or SAV signatures and
67            // normalize the in-memory type for canonical usage.
68            b"SAV " => CapsuleArchive::Erf(read_save_archive_from_bytes(bytes)?),
69            _ => return Err(CapsuleError::InvalidMagic { magic }),
70        };
71        let resource_index = build_capsule_index(&archive);
72        Ok(Self {
73            path: None,
74            archive,
75            resource_index,
76        })
77    }
78
79    /// Reads a capsule from a file path.
80    pub fn read_from_file(path: impl AsRef<Path>) -> Result<Self, CapsuleError> {
81        let path = path.as_ref();
82        let bytes = fs::read(path).map_err(|source| CapsuleError::Io {
83            path: path.to_path_buf(),
84            source,
85        })?;
86        let mut capsule = Self::read_from_bytes(&bytes)?;
87        capsule.path = Some(path.to_path_buf());
88        Ok(capsule)
89    }
90
91    /// Returns the source path when loaded from disk.
92    pub fn path(&self) -> Option<&Path> {
93        self.path.as_deref()
94    }
95
96    /// Returns the underlying archive value.
97    pub fn archive(&self) -> &CapsuleArchive {
98        &self.archive
99    }
100
101    /// Returns the first matching payload for `(resref, type)`.
102    pub fn resource(&self, resref: &ResRef, resource_type: ResourceTypeCode) -> Option<&[u8]> {
103        let &i = self.resource_index.get(&(*resref, resource_type))?;
104        match &self.archive {
105            CapsuleArchive::Erf(erf) => Some(erf.resources.get(i)?.data.as_slice()),
106            CapsuleArchive::Rim(rim) => Some(rim.resources.get(i)?.data.as_slice()),
107        }
108    }
109
110    /// Enumerates all resources in container order.
111    pub fn resources(&self) -> impl Iterator<Item = CapsuleResourceRef<'_>> {
112        let (erf_iter, rim_iter) = match &self.archive {
113            CapsuleArchive::Erf(erf) => (Some(erf.resources.iter().map(erf_resource_ref)), None),
114            CapsuleArchive::Rim(rim) => (None, Some(rim.resources.iter().map(rim_resource_ref))),
115        };
116        erf_iter
117            .into_iter()
118            .flatten()
119            .chain(rim_iter.into_iter().flatten())
120    }
121
122    /// Returns the number of resources in the capsule.
123    pub fn resource_count(&self) -> usize {
124        match &self.archive {
125            CapsuleArchive::Erf(erf) => erf.resources.len(),
126            CapsuleArchive::Rim(rim) => rim.resources.len(),
127        }
128    }
129}
130
131/// Builds an `(ResRef, ResourceTypeCode) -> index` lookup for a capsule archive.
132fn build_capsule_index(archive: &CapsuleArchive) -> HashMap<(ResRef, ResourceTypeCode), usize> {
133    match archive {
134        CapsuleArchive::Erf(erf) => {
135            let mut index = HashMap::with_capacity(erf.resources.len());
136            for (i, resource) in erf.resources.iter().enumerate() {
137                index
138                    .entry((resource.resref, resource.resource_type))
139                    .or_insert(i);
140            }
141            index
142        }
143        CapsuleArchive::Rim(rim) => {
144            let mut index = HashMap::with_capacity(rim.resources.len());
145            for (i, resource) in rim.resources.iter().enumerate() {
146                index
147                    .entry((resource.resref, resource.resource_type))
148                    .or_insert(i);
149            }
150            index
151        }
152    }
153}
154
155fn erf_resource_ref(resource: &ErfResource) -> CapsuleResourceRef<'_> {
156    CapsuleResourceRef {
157        resref: &resource.resref,
158        resource_type: resource.resource_type,
159        data: &resource.data,
160    }
161}
162
163fn rim_resource_ref(resource: &RimResource) -> CapsuleResourceRef<'_> {
164    CapsuleResourceRef {
165        resref: &resource.resref,
166        resource_type: resource.resource_type,
167        data: &resource.data,
168    }
169}
170
171/// Errors produced by capsule primitives.
172#[derive(Debug, Error)]
173pub enum CapsuleError {
174    /// I/O failure while reading an on-disk capsule.
175    #[error("I/O failure for `{path}`: {source}")]
176    Io {
177        /// Path being read.
178        path: PathBuf,
179        /// Underlying OS error.
180        #[source]
181        source: std::io::Error,
182    },
183    /// Capsule signature is unsupported.
184    #[error("unsupported capsule magic: {magic:?}")]
185    InvalidMagic {
186        /// First four bytes of the file.
187        magic: [u8; 4],
188    },
189    /// ERF-family parse error.
190    #[error(transparent)]
191    Erf(#[from] ErfBinaryError),
192    /// RIM parse error.
193    #[error(transparent)]
194    Rim(#[from] RimBinaryError),
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    use rakata_core::ResourceType;
202    use rakata_formats::{write_erf_to_vec, write_rim_to_vec, ErfFileType};
203
204    #[test]
205    fn reads_erf_capsule_and_queries_resource() {
206        let mut erf = Erf::new(ErfFileType::Erf);
207        let dlg_type = ResourceTypeCode::from(ResourceType::Dlg);
208        erf.push_resource(
209            ResRef::new("module").expect("valid resref"),
210            dlg_type,
211            b"dialog".to_vec(),
212        );
213        let bytes = write_erf_to_vec(&erf).expect("write erf");
214
215        let capsule = Capsule::read_from_bytes(&bytes).expect("read capsule");
216        let resref = ResRef::new("module").expect("valid resref");
217        assert_eq!(capsule.resource(&resref, dlg_type), Some(&b"dialog"[..]));
218        assert_eq!(capsule.resource_count(), 1);
219    }
220
221    #[test]
222    fn reads_rim_capsule_and_queries_resource() {
223        let mut rim = Rim::new();
224        let git_type = ResourceTypeCode::from(ResourceType::Git);
225        rim.push_resource(
226            ResRef::new("module").expect("valid resref"),
227            git_type,
228            b"git".to_vec(),
229        );
230        let bytes = write_rim_to_vec(&rim).expect("write rim");
231
232        let capsule = Capsule::read_from_bytes(&bytes).expect("read capsule");
233        let resref = ResRef::new("module").expect("valid resref");
234        assert_eq!(capsule.resource(&resref, git_type), Some(&b"git"[..]));
235        assert_eq!(capsule.resource_count(), 1);
236    }
237
238    #[test]
239    fn rejects_unknown_capsule_magic() {
240        let err = Capsule::read_from_bytes(b"ABCDpayload").expect_err("must fail");
241        assert!(matches!(err, CapsuleError::InvalidMagic { .. }));
242    }
243}