ERF (Encapsulated Resource File)
An ERF is a self-contained archive: it carries its own table of what is inside it, so unlike a BIF it needs no external KEY index to resolve a resource. Modules (.mod) and save games (.sav) are both ERFs.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .erf, .mod, .hak, .sav |
| Magic Signatures | ERF , MOD , HAK (version V1.0). Not SAV , see below. |
| Type | Self-Contained Archive |
| Rust Reference | View rakata_formats::Erf in Rustdocs |
File Layout
An ERF is five blocks, and only the header sits at a fixed address. Everything else is found by reading a pointer back out of the header, so while the blocks are laid out contiguously in every file you’ll ever meet, that ordering is a convention rather than a rule the format enforces.
| Block | Size | Located by |
|---|---|---|
| Header | 160 bytes | Always at 0x00 |
| Localized string block | localized_string_size bytes | localized_strings_offset |
| Key table | 24 bytes per entry | keys_offset |
| Resource table | 8 bytes per entry | resources_offset |
| Resource payload | remainder of the file | Each entry’s own data_offset |
Note that entry_count governs both tables at once. The key table and the resource table are parallel arrays: key n describes what resource n is, and resource n says where its bytes live.
Header (160 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | file_type | fourcc | ERF , MOD or HAK . The trailing space is part of the signature. |
0x04 | version | fourcc | V1.0 for anything KotOR reads. See below. |
0x08 | localized_string_count | u32 | |
0x0C | localized_string_size | u32 | Byte length of the whole string block, not a count of entries. |
0x10 | entry_count | u32 | Governs the key table and the resource table together. |
0x14 | localized_strings_offset | u32 | |
0x18 | keys_offset | u32 | |
0x1C | resources_offset | u32 | |
0x20 | build_year | u32 | Years since 1900. |
0x24 | build_day | u32 | Day of the year. |
0x28 | description_strref | i32 | |
0x2C | reserved | 116 bytes | The dead zone described further down. |
Key Entry (24 bytes each)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | resref | char[16] | Null-padded, not null-terminated. |
0x10 | resource_id | u32 | Selects the resource-table row. Not the packed word KEY uses. See below. |
0x14 | type_id | u16 | See the resource type codes. |
0x16 | unused | u16 |
Resource Entry (8 bytes each)
| Offset | Field | Type |
|---|---|---|
0x00 | data_offset | u32 |
0x04 | data_size | u32 |
What the two ERF populations agree on
Two sets of ERF archives have been read byte by byte. Naming them separately matters because one is much narrower than “vanilla ERFs” suggests.
- Shipped archives: every
.modunderlips/, pluspatch.erf. Almost all lip-sync data, so an invariant measured only here describes one content type. - Save archives: every
SAVEGAME.savin a save corpus, plus the per-module ERFs nested inside them.
Five invariants hold in both:
| Invariant | Shipped | Saves |
|---|---|---|
| Localized string block empty | ● | ● |
| 116-byte reserved zone all-zero | ● | ● |
Key entry unused u16 at +0x16 zero | ● | ● |
Key entry resource_id equals the entry’s index | ● | ● |
| Blocks tile with zero gaps and no tail | ● | ● |
resource_id shares a name with KEY’s packed word without sharing its meaning. A KEY entry has to say which archive as well as which entry, so it packs two numbers. An ERF resolves inside itself, leaving nothing to pack.
But it is the pairing key, not a restatement of the row it sits beside. The engine mounts an archive by walking the key table in file order and, for each entry, taking the resource row named by that entry’s resource_id. The loop’s own counter bounds the walk and selects nothing. So an entry’s position in the key table decides only when it is read, and its resource_id decides what it is paired with.
That distinction is invisible in every archive anyone has: the invariant above holds in both populations, so the two readings agree everywhere and only diverge on a file no writer produces. It matters to a writer regardless, because emitting the field by counting is correct by accident rather than by rule.
And the engine does not pair them the same way everywhere. Mounting an archive into the resource system uses resource_id, as above. Unpacking one walks both tables under a shared index and pairs by position instead. Two paths, two rules, and nothing reconciles them.
So an archive whose resource_id values are not its indices would mount as one set of resources and unpack as a different one, from the same bytes, with neither path complaining. Which is the practical reason to keep emitting the field by counting even though the mount path would accept anything: it is the only value both readings agree on.
A duplicate resref and type is dropped, and the first one wins
The key table is mounted into a hash table keyed on resref and type together. A second entry whose resref and type already occupy a matching slot is detected as a duplicate and discarded without touching the table, so the entry that appears first in file order is the one that resolves and the later one is unreachable rather than merged or overriding.
Uniform across every archive kind. .mod, .erf, .hak, .sav and .nwm all mount through the same path and there is no per-kind branch for this to differ across.
The engine identifies the offending resource by name internally when it happens, building a diagnostic that names it. Whether that message reaches anywhere a person would see is unconfirmed, so do not expect a duplicate to announce itself. The drop is silent as far as anyone has established.
Quirks
Each of these changes what a reader or a writer has to do.
A .sav file does not contain SAV magic
The extension and the signature come apart, and conflating them produces a file the engine refuses. A save archive is an ERF-family container carrying MOD in file_type, as the save page records independently. No validation branch for a SAV signature exists anywhere in the loader, so such a file is not read leniently, it fails.
Measured, not reasoned from the loader: every SAVEGAME.sav in a save corpus and every per-module ERF inside them carries MOD with version V1.0, at both levels.
Rakata accepts SAV magic in compatibility mode only, for inspecting whatever produced such a file, and never emits it.
build_year and build_day are written on save and unread on load
The audit below folds both into the engine’s unread set. That is true of the loader and misleading about the format: every save archive carries a real date, build_year as years since 1900 and build_day as a day-of-year, both taking many distinct values across a save corpus.
So “the engine never reads it” and “a writer may leave it zero” are different claims here, and only the first is established. These are the case in the engine ignores this is not you may leave it out where something outside the traced loader consumes the value. description_strref is the one field in the group that really is zero everywhere.
The localized string block has no documented entry layout
Nothing in either population carries one. localized_string_count and localized_string_size are zero in every shipped archive and every save archive, at both nesting levels, so the gap spans both producers and a writer emitting a description has nothing to work from.
One trap follows. localized_strings_offset and keys_offset are both 160 across the save archives, and with the block empty they name the same byte. A reader deriving the key table by adding localized_string_size to the string offset gets the right answer for the wrong reason, and breaks on the first archive that populates one.
Some .mod files carry a blank block
An extra unused block sits between the key table and the resource table in some archives, left behind by older tooling. Both tables are located by their own header offsets rather than by following on from each other, so a reader that trusts those offsets handles the padded and unpadded variants alike. One that assumes the tables are adjacent reads garbage.
V1.1 exists and KotOR will not touch it
The version field has a second value in the wild, V1.1, from later Aurora-family games. The KotOR engine validates for V1.0 exactly, so a V1.1 archive is not a KotOR archive whatever its extension says. Rakata’s strict reader matches the engine; its compatibility mode accepts V1.1 for inspecting foreign archives, not for producing anything the game loads.
Engine Audits & Decompilation
Read from CExoEncapsulatedFile::LoadHeader at 0x0040e1f0 in swkotor.exe, with each subsection naming its own function below. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
Capsule Header Initialization (CExoEncapsulatedFile::LoadHeader)
Mapped from 0x0040e1f0.
| Action | Engine Behaviour |
|---|---|
| Signature Check | The header must match ERF , MOD or HAK exactly, paired with the V1.0 version string. |
| Unchecked Saves | There is no validation branch for a SAV signature. A file loaded as a save game (param flag 1) falls through the same tree and is still required to carry MOD , so an archive with SAV magic fails validation here rather than being read leniently. |
| Header Truncation | The loader pulls the entire 160-byte header into scope (CExoFile::Read(..., 0xa0)) and then reads a set of offsets rather than a range: 0x00, 0x04, 0x08, 0x0C, 0x10, 0x14 and 0x1C. 0x18 (keys_offset) is skipped even though it sits inside that span, traced by reading every dereference of the buffer in this function, where 0x18 never appears as a load, seek or comparison. Nothing from 0x20 onward is touched either. The buffer is retained for the object’s lifetime rather than discarded, it is simply never read past 0x1C. |
Tip
The dead zone is larger than the reserved block, and it is not discarded The obvious dead region is
0x2Cto0xA0, 116 bytes of old BioWare build metadata. The engine’s actual unread set is bigger:0x18is skipped, and so is everything from0x20on, which foldsbuild_year,build_dayanddescription_strrefinto it. Unread is not the same as unwritten: see the note above on the dates the save writer puts there. Nor is any of it discarded, since the whole 160-byte buffer lives as long as the object does and is simply never read past0x1C.How
keys_offsetbeing unread squares with the archive working. Resource lookups in this class index the resource-list array built from0x1Cby raw numeric position, never by name, so nothing in it needs the key table.keys_offsetis correct in every vanilla file and should be written correctly regardless, because the engine’s own writer uses it to place entries even though no reader consults it.
The header and the three tables are read sequentially, not by their offsets
A save archive is read by CERFFile, not the class above, and it never seeks by any of the three block offsets in the header. CERFFile::ReadHeaderVariance starts at 0xa0, the fixed end of the header, and takes the localized string block, then the key table, then the resource table in that order, sizing each from localized_string_count and entry_count alone. CERFFile::Read does parse keys_offset and resources_offset into memory beforehand; nothing then reads them back.
So those four regions have to be contiguous and in that order. A file that puts them somewhere else and says so in the header is one this reader mis-parses, with every offset field in it correct.
The payload is the exception, and the only part of the layout that is genuinely free: each entry’s bytes are found through its own data_offset, which is read. So a writer may place the payload where it likes and must not move the four regions above it.
Warning
Key entry
nand resource entrynmust describe the same resource, and nothing checks that they do This is the unpack path specifically, and it is not how the same archive pairs when it is mounted. Unpacking walks both tables under one index: the output filename comes from the key entry, the bytes come from the resource entry at the same position, the two are never compared, and the resource is never found by name. Mounting pairs by the key entry’sresource_idinstead, so the two paths only agree because every real archive has those two numbers equal.So a resource’s identity is its name and the bytes carrying that name are positional. Sort one table without the other and the result is a correctly named file holding a different resource’s contents, with nothing reporting a problem.
The engine’s own writer cannot produce that:
CERFFile::WriteResourceemits both entries for one file in a single call under a shared index. The correspondence is a property of the writer, and the reader assumes it.
Order within the two tables is free, since nothing matches a key against anything by name. A writer may order entries however it likes as long as both tables are ordered together.
These rules apply to a nested archive as much as a top-level one, because nothing opens a nested archive in place. CExoEncapsulatedFile::OpenFile branches on a stored type tag and every branch opens a named file from disk; no path in either class opens a container at an offset inside an already-open parent. An archive carried inside another is extracted to its own file first and reopened from the top, which is how a save’s per-module archives are read. Reading one in place instead yields the same bytes, and is a thing the engine never does.
Read from CERFFile::Read (0x005dce50), ReadHeaderVariance (0x005dd3c0), ExportFilesFromERF (0x005dd710) and WriteResource (0x005ddbc0), plus CExoEncapsulatedFile::OpenFile (0x0040dc30). Provenance: traced. Scoped to those two classes; a third consumer of this format, if one exists, was not looked for.