Skip to main content

rakata_formats/erf/
mod.rs

1//! ERF/MOD/HAK binary reader and writer.
2//!
3//! This module provides a strict, roundtrip-safe implementation of the KotOR
4//! ERF family container format. Compatibility mode can also parse legacy
5//! non-canonical variants (for example `SAV ` headers).
6//!
7//! ## Shape of the container
8//!
9//! A fixed 160-byte header, a localized string block, then two parallel tables
10//! -- keys and resources, both sized by `entry_count` -- and finally the
11//! payload. Key `n` says what resource `n` is; resource `n` says where its
12//! bytes live.
13//!
14//! Every block after the header is found by an offset read out of the header
15//! rather than by following on from the block before it. That is why the blank
16//! block some `.mod` files carry between the two tables costs a reader nothing:
17//! trusting the offsets absorbs it, while assuming the tables are adjacent
18//! reads garbage.
19//!
20//! Byte-level field maps for the header and both entry records live in
21//! `docs/src/formats/archives/erf.md`, with the engine's own load sequence.
22
23mod index;
24mod layout;
25mod reader;
26mod rewrite;
27mod writer;
28
29pub use index::{ErfIndex, ErfIndexEntry};
30pub use reader::{
31    read_erf, read_erf_from_bytes, read_erf_from_bytes_with_options, read_erf_with_options,
32    read_save_archive, read_save_archive_from_bytes,
33};
34pub use rewrite::{rewrite_erf, rewrite_erf_to_vec, ErfRewriteError};
35pub use writer::{
36    write_erf, write_erf_to_vec, write_erf_to_vec_with_options, write_erf_with_options,
37    write_save_archive, write_save_archive_to_vec,
38};
39
40use thiserror::Error;
41
42use rakata_core::{
43    DecodeTextError, EncodeTextError, LanguageId, ResRef, ResRefError, ResourceTypeCode, StrRef,
44    TextEncoding, MAX_RESREF_LEN,
45};
46
47use crate::binary::{self, DecodeBinary, EncodeBinary};
48
49/// ERF-family binary header size.
50const FILE_HEADER_SIZE: usize = 160;
51/// Key table element size.
52const KEY_ENTRY_SIZE: usize = 24;
53/// Resource table element size.
54const RESOURCE_ENTRY_SIZE: usize = 8;
55/// Legacy MOD archives reserve an additional blank block between key and
56/// resource tables.
57const MOD_BLANK_BLOCK_ENTRY_SIZE: usize = 8;
58/// ERF container version used by KotOR.
59const ERF_VERSION_V10: [u8; 4] = *b"V1.0";
60/// Compatibility-only ERF-family version accepted by some Aurora variants.
61const ERF_VERSION_V11: [u8; 4] = *b"V1.1";
62/// ERF localized-string and resref text encoding.
63const ERF_TEXT_ENCODING: TextEncoding = TextEncoding::Windows1252;
64
65/// Supported ERF-family container signatures.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum ErfFileType {
68    /// Generic ERF archive (`ERF `).
69    Erf,
70    /// Module archive (`MOD `).
71    Mod,
72    /// Save archive (`SAV `).
73    ///
74    /// This signature is not canonical for KotOR; the canonical save-game
75    /// archive signature is `MOD `.
76    Sav,
77    /// Hak pack archive (`HAK `).
78    Hak,
79}
80
81impl ErfFileType {
82    fn from_fourcc(fourcc: [u8; 4]) -> Option<Self> {
83        match &fourcc {
84            b"ERF " => Some(Self::Erf),
85            b"MOD " => Some(Self::Mod),
86            b"SAV " => Some(Self::Sav),
87            b"HAK " => Some(Self::Hak),
88            _ => None,
89        }
90    }
91
92    fn from_fourcc_with_mode(fourcc: [u8; 4], mode: ErfReadMode) -> Option<Self> {
93        match mode {
94            ErfReadMode::CanonicalK1 => match &fourcc {
95                b"ERF " => Some(Self::Erf),
96                b"MOD " => Some(Self::Mod),
97                b"HAK " => Some(Self::Hak),
98                _ => None,
99            },
100            ErfReadMode::CompatibilityAurora => Self::from_fourcc(fourcc),
101        }
102    }
103
104    fn fourcc(self) -> [u8; 4] {
105        match self {
106            Self::Erf => *b"ERF ",
107            Self::Mod => *b"MOD ",
108            Self::Sav => *b"SAV ",
109            Self::Hak => *b"HAK ",
110        }
111    }
112}
113
114/// Reader options for ERF/MOD/HAK parsing.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub struct ErfReadOptions {
117    /// Input-profile behavior for signature/version acceptance.
118    pub input: ErfReadMode,
119}
120
121impl Default for ErfReadOptions {
122    fn default() -> Self {
123        Self {
124            input: ErfReadMode::CanonicalK1,
125        }
126    }
127}
128
129/// Input-profile behavior for ERF-family readers.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
131pub enum ErfReadMode {
132    /// Canonical KotOR profile:
133    /// - accepted signatures: `ERF `, `MOD `, `HAK `
134    /// - accepted version: `V1.0`
135    #[default]
136    CanonicalK1,
137    /// Compatibility profile for broader Aurora-style archives:
138    /// - accepted signatures: `ERF `, `MOD `, `SAV `, `HAK `
139    /// - accepted versions: `V1.0`, `V1.1`
140    CompatibilityAurora,
141}
142
143/// Layout mode for MOD key/resource table spacing during writes.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
145pub enum ModLayout {
146    /// Write tightly packed MOD archives (keys immediately followed by resource table).
147    #[default]
148    Tight,
149    /// Write legacy MOD archives with an 8-byte-per-entry zero block between
150    /// key and resource tables.
151    WithBlankBlock,
152}
153
154/// Writer options for ERF-family serialization.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
156pub struct ErfWriteOptions {
157    /// Layout policy used when writing `MOD ` archives.
158    pub mod_layout: ModLayout,
159    /// Output-profile behavior for serialized header signatures.
160    pub output: ErfWriteMode,
161}
162
163impl Default for ErfWriteOptions {
164    fn default() -> Self {
165        Self {
166            mod_layout: ModLayout::default(),
167            output: ErfWriteMode::CanonicalK1,
168        }
169    }
170}
171
172/// Output-profile behavior for ERF-family writers.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
174pub enum ErfWriteMode {
175    /// Canonical KotOR profile:
176    /// - save archives serialize with `MOD ` header magic.
177    #[default]
178    CanonicalK1,
179    /// Compatibility profile:
180    /// - preserves/emits `SAV ` header magic when selected by caller.
181    CompatibilityAurora,
182}
183
184/// One localized ERF description entry.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct ErfLocalizedString {
187    /// Language ID.
188    pub language_id: LanguageId,
189    /// Localized text.
190    pub text: String,
191}
192
193/// One resource entry stored in an ERF-family archive.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct ErfResource {
196    /// Resource name (up to 16 bytes on disk).
197    pub resref: ResRef,
198    /// The key exactly as the file spelled it, when this came from one.
199    ///
200    /// A [`ResRef`] folds case so a comparison is free, which is right for
201    /// resolution and loses what the file said. The engine's own archives
202    /// spell most keys uppercase, and `ExportFilesFromERF` takes an extracted
203    /// file's name straight off the key, so folding on the way out renames
204    /// every entry in a rewritten archive.
205    ///
206    /// `None` for an entry built rather than read, which has no original to
207    /// keep and is written from [`Self::resref`]. Where both exist this wins:
208    /// letting them disagree silently is the failure that carrying it avoids.
209    pub name_as_read: Option<[u8; MAX_RESREF_LEN]>,
210    /// Resource type code from the key table.
211    ///
212    /// Unknown numeric IDs are preserved as raw values.
213    pub resource_type: ResourceTypeCode,
214    /// Resource payload bytes.
215    pub data: Vec<u8>,
216}
217
218/// In-memory ERF-family container.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct Erf {
221    /// Container signature.
222    pub file_type: ErfFileType,
223    /// Build year (`years since 1900`).
224    pub build_year: u32,
225    /// Build day of year (`1..=366`).
226    pub build_day: u32,
227    /// Description TLK string reference (`StrRef::invalid()` when absent).
228    pub description_strref: StrRef,
229    /// Reserved 116-byte block at header offset 0x2C--0x9F.
230    ///
231    /// Confirmed engine-dead by Ghidra analysis of `AddEncapsulatedContents`
232    /// (0x0040f3c0): the full 160-byte header is read into a stack buffer but
233    /// only offsets 0x00, 0x04, 0x10, and 0x18 are subsequently accessed.
234    /// Preserved verbatim for lossless roundtrip; new files initialize to zero.
235    pub reserved: [u8; 116],
236    /// Localized description entries.
237    pub localized_strings: Vec<ErfLocalizedString>,
238    /// Ordered archive resources.
239    pub resources: Vec<ErfResource>,
240}
241
242impl Erf {
243    /// Creates an empty archive for `file_type`.
244    pub fn new(file_type: ErfFileType) -> Self {
245        Self {
246            file_type,
247            build_year: 0,
248            build_day: 0,
249            description_strref: StrRef::invalid(),
250            reserved: [0u8; 116],
251            localized_strings: Vec::new(),
252            resources: Vec::new(),
253        }
254    }
255
256    /// Appends one resource entry.
257    pub fn push_resource(
258        &mut self,
259        resref: ResRef,
260        resource_type: ResourceTypeCode,
261        data: Vec<u8>,
262    ) {
263        self.resources.push(ErfResource {
264            resref,
265            name_as_read: None,
266            resource_type,
267            data,
268        });
269    }
270
271    /// Returns the first matching resource payload.
272    pub fn resource(&self, resref: &ResRef, resource_type: ResourceTypeCode) -> Option<&[u8]> {
273        self.resources
274            .iter()
275            .find(|resource| resource.resref == *resref && resource.resource_type == resource_type)
276            .map(|resource| resource.data.as_slice())
277    }
278}
279
280impl DecodeBinary for Erf {
281    type Error = ErfBinaryError;
282
283    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
284        read_erf_from_bytes(bytes)
285    }
286}
287
288impl EncodeBinary for Erf {
289    type Error = ErfBinaryError;
290
291    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
292        write_erf_to_vec(self)
293    }
294}
295
296/// Errors produced while parsing or serializing ERF binary data.
297#[derive(Debug, Error)]
298pub enum ErfBinaryError {
299    /// I/O read/write failure.
300    #[error(transparent)]
301    Io(#[from] std::io::Error),
302    /// Header signature is not a supported ERF family type.
303    #[error("invalid ERF magic: {0:?}")]
304    InvalidMagic([u8; 4]),
305    /// Header version is unsupported.
306    #[error("invalid ERF version: {0:?}")]
307    InvalidVersion([u8; 4]),
308    /// Header/body layout is invalid or truncated.
309    #[error("invalid ERF header: {0}")]
310    InvalidHeader(String),
311    /// Archive content is structurally invalid.
312    #[error("invalid ERF data: {0}")]
313    InvalidData(String),
314    /// Value cannot fit on-disk integer width.
315    #[error("value overflow while writing field `{0}`")]
316    ValueOverflow(&'static str),
317    /// Resource name validation failed during read.
318    #[error("invalid resref at {context}: {source}")]
319    InvalidResRef {
320        /// Field context.
321        context: String,
322        /// Validation error details.
323        #[source]
324        source: ResRefError,
325    },
326    /// Text cannot be represented as ERF encoding.
327    #[error("ERF text encoding failed for {context}: {source}")]
328    TextEncoding {
329        /// Value context.
330        context: String,
331        /// Encoding error details.
332        #[source]
333        source: EncodeTextError,
334    },
335    /// Text bytes cannot be decoded as ERF encoding.
336    #[error("ERF text decoding failed for {context}: {source}")]
337    TextDecoding {
338        /// Value context.
339        context: String,
340        /// Decoding error details.
341        #[source]
342        source: DecodeTextError,
343    },
344}
345
346impl From<binary::BinaryLayoutError> for ErfBinaryError {
347    fn from(error: binary::BinaryLayoutError) -> Self {
348        Self::InvalidHeader(error.to_string())
349    }
350}