Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

PropertyValue
Extension(s).erf, .mod, .hak, .sav
Magic SignaturesERF , MOD , HAK (version V1.0). Not SAV , see below.
TypeSelf-Contained Archive
Rust ReferenceView 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.

BlockSizeLocated by
Header160 bytesAlways at 0x00
Localized string blocklocalized_string_size byteslocalized_strings_offset
Key table24 bytes per entrykeys_offset
Resource table8 bytes per entryresources_offset
Resource payloadremainder of the fileEach 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)

OffsetFieldTypeNotes
0x00file_typefourccERF , MOD or HAK . The trailing space is part of the signature.
0x04versionfourccV1.0 for anything KotOR reads. See below.
0x08localized_string_countu32
0x0Clocalized_string_sizeu32Byte length of the whole string block, not a count of entries.
0x10entry_countu32Governs the key table and the resource table together.
0x14localized_strings_offsetu32
0x18keys_offsetu32
0x1Cresources_offsetu32
0x20build_yearu32Years since 1900.
0x24build_dayu32Day of the year.
0x28description_strrefi32
0x2Creserved116 bytesThe dead zone described further down.

Key Entry (24 bytes each)

OffsetFieldTypeNotes
0x00resrefchar[16]Null-padded, not null-terminated.
0x10resource_idu32Selects the resource-table row. Not the packed word KEY uses. See below.
0x14type_idu16See the resource type codes.
0x16unusedu16

Resource Entry (8 bytes each)

OffsetFieldType
0x00data_offsetu32
0x04data_sizeu32

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 .mod under lips/, plus patch.erf. Almost all lip-sync data, so an invariant measured only here describes one content type.
  • Save archives: every SAVEGAME.sav in a save corpus, plus the per-module ERFs nested inside them.

Five invariants hold in both:

InvariantShippedSaves
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.

ActionEngine Behaviour
Signature CheckThe header must match ERF , MOD or HAK exactly, paired with the V1.0 version string.
Unchecked SavesThere 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 TruncationThe 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 0x2C to 0xA0, 116 bytes of old BioWare build metadata. The engine’s actual unread set is bigger: 0x18 is skipped, and so is everything from 0x20 on, which folds build_year, build_day and description_strref into 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 past 0x1C.

How keys_offset being unread squares with the archive working. Resource lookups in this class index the resource-list array built from 0x1C by raw numeric position, never by name, so nothing in it needs the key table. keys_offset is 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 n and resource entry n must 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’s resource_id instead, 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::WriteResource emits 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.