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