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