GFF (Generic File Format)
The Generic File Format (GFF) is BioWare’s core binary serialization format, functioning like a binary JSON object or XML tree. It holds arbitrarily nested structures, typed fields, and lists, powering UI layouts, character sheets, dialogues, and area descriptions.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .gff, .utc, .uti, .utp, .ute, .utd, .dlg, .are, .ifo, etc. |
| Magic Signature | Target type (e.g. UTC ) / V3.2 |
| Type | Generic Hierarchical Data |
| Rust Reference | View rakata_formats::Gff in Rustdocs |
File Layout
A 56-byte header holding an offset-and-count pair for each of six sections, and the sections themselves. The write order below is fixed and every section is located by its own header entry, so a reader never has to assume adjacency. (The audit further down counts seven contiguous sections, which is the same file: it counts the header as the first.)
Header (56 bytes)
| Offset | Field | Offset | Field |
|---|---|---|---|
0x00 | file_type (fourcc) | 0x04 | version (fourcc, always V3.2) |
0x08 | struct_offset | 0x0C | struct_count |
0x10 | field_offset | 0x14 | field_count |
0x18 | label_offset | 0x1C | label_count |
0x20 | field_data_offset | 0x24 | field_data_count |
0x28 | field_indices_offset | 0x2C | field_indices_count |
0x30 | list_indices_offset | 0x34 | list_indices_count |
The last three counts are byte lengths, not element counts, unlike the first three. Field data is a heterogeneous blob and both index arrays are u32 runs whose element boundaries only make sense once you know which struct or field is pointing into them.
Struct record (12 bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | struct_id | i32 | Not a free tag. Several formats read it as a discriminator. See below. |
0x04 | data_or_offset | u32 | Overloaded. See below. |
0x08 | field_count | u32 | How many fields this struct has. |
Warning
struct_idcarries real meaning in several formats, and a writer that invents one breaks the file silently The container itself does nothing with the value. Per-format readers do, and they read it instead of a field, so there is no label to search for and nothing reports a wrong value.Two attested cases. A UTC’s
Equip_ItemListelement carries the equipment slot as its struct id, as a single bit: across every creature inchitin.keyand the module RIM archives the ids that occur are1,2,8,16,32,128,256,512,1024,16384,32768,65536and131072, and nothing else. A GIT object list uses one fixed id per list, which is how an element knows what kind of object it is.So a writer copying a struct from one place to another must carry its id, and a writer creating one must know which value the format expects. Emitting
-1everywhere produces a file that parses and loses every equipped item.A GIT’s
Creature Listis the case traced end to end. The loader compares each element’s id against the one value it expects and skips the element entirely when it differs, silently, moving straight to the next index. The creature does not load, nothing is defaulted, and nothing fails.One accessor is the only way to reach an element’s id,
CResGFF::GetElementType, and the reader class hands no raw struct pointer outside itself: every method takes and returns a wrapper carrying an index. So a list whose loader never calls that accessor has ids nothing can be reading, and the lists below are settled rather than merely unobserved.A UTC’s
ItemListnever reads its element ids, in the same function that readsEquip_ItemList’s. Neither doesClassList, norFeatList, nor any list in a DLG, nor a GIT’sCameraList. The sequential ids anItemListcarries and the constant everyFeatListelement carries are whatever a writer happened to put there.
-1genuinely means nothing where a format does not use the field, which is most structs and every one this page describes generically.
Which record is the root
Struct record 0. Nothing in the header says so, which is the gap: a reader holding the struct array and the field array has no stated place to begin.
Measured rather than assumed, and by the property that defines a root rather than by position: across every UTC and GIT in chitin.key and the module RIM archives, no struct field and no list element ever points at index 0. Every other record is reachable from it and it is reachable from nothing.
Field record (12 bytes)
| Offset | Field | Type |
|---|---|---|
0x00 | field_type | u32 |
0x04 | label_index | u32 |
0x08 | data_or_offset | u32 |
label_index indexes the label section, which is a flat array of 16-byte NUL-padded names. Labels are shared, so two fields with the same name in different structs point at one entry.
Important
Two fields with the same label in one struct: the first wins and the second is unreachable Lookup is a linear scan across the struct’s fields in stored order, comparing the label bytes and returning the first match (
CResGFF::GetFieldByLabel). A duplicate is neither an error nor a replacement. The later field is dead weight nothing ever reads.Which makes on-disk order load-bearing, because “first” means first in the file and nothing else. A tool that changes a field by appending a fresh copy of the label has written a file where its own edit does not take effect, and no part of the format reports it.
Important
A one-field struct stores its field differently, so growing one to two fields is a rewrite
field_countselects between two physical shapes. A struct with exactly one field puts that field’s index straight into its owndata_or_offset, with no indirection at all. A struct with two or more puts a byte offset there instead, pointing into the shared field indices array.Reading this is easy enough, since the count tells you which shape you are looking at. Writing into an existing file is where it bites: adding a field to a struct that already has two or more appends to that shared array and nothing else moves, but adding a second field to a struct that has one has to lift the existing index out of
data_or_offset, put both indices into a fresh span of the array, and grow the header’s count to match. Same operation from the caller’s side, two different things underneath.
Field type codes
Eighteen codes, and the first ten are the ordinary scalars. The Storage column is the part a reader has to get right: it says whether data_or_offset holds the value itself or points somewhere, and where.
| Code | Type | Storage |
|---|---|---|
0 | BYTE | Value, in the low byte |
1 | CHAR | Value, signed, in the low byte |
2 | WORD | Value, in the low two bytes |
3 | SHORT | Value, signed, in the low two bytes |
4 | DWORD | Value |
5 | INT | Value, signed |
6 | DWORD64 | Offset into field data; 8 bytes |
7 | INT64 | Offset into field data; 8 bytes, signed |
8 | FLOAT | Value, reinterpreted as f32 |
9 | DOUBLE | Offset into field data; 8 bytes |
10 | CExoString | Offset into field data |
11 | CResRef | Offset into field data |
12 | CExoLocString | Offset into field data |
13 | VOID | Offset into field data |
14 | Struct | A struct-array index, not an offset |
15 | List | Byte offset into the list indices array |
16 | Vector4 | Offset into field data; four f32 |
17 | Vector3 | Offset into field data; three f32 |
18 | StrRef | Encoding not established. Defined by the format and present in none of the four archive families surveyed below, so nothing attests how it stores its value. See the note under the table. |
The four-byte scalars sit in data_or_offset directly rather than being pointed at. Anything wider than four bytes is in the field-data blob, including DWORD64, INT64 and DOUBLE, which is easy to miss because their siblings are immediate.
Warning
Code
18has no storage rule here WhetherStrRefholds its value immediately like the other four-byte scalars, or points into the field-data blob, is not established: it occurs in none of the four archive families, so no file demonstrates either reading.INT64andDOUBLEare also absent from the static archives but do have rules, taken from the codes around them and from saves;18has neither.A reader that meets one should surface it rather than guess. Picking the wrong branch reads a plausible number out of the wrong region, failing as quietly as the
Structcase below.
The complex encodings
Each is written at the offset data_or_offset gives, counted from field_data_offset.
| Type | Layout at that offset |
|---|---|
CExoString | u32 byte length, then exactly that many bytes. Not NUL-terminated. |
CResRef | u8 byte length, then that many bytes. The length is one byte, not four, and the value is capped at 16. |
CExoLocString | u32 total size of everything after this word, then u32 StrRef, then u32 substring count, then that many substrings. Each substring is a u32 language-and-gender id, a u32 length, and that many bytes. See below for what the id holds. |
VOID | u32 byte length, then that many raw bytes. |
Vector3 | Three f32: x, y, z. |
Vector4 | Four f32: w, x, y, z. Scalar first. |
Note
Only one language-and-gender id is attested The conventional encoding is
language * 2 + gender,0being masculine, so an English feminine substring is1. (Provenance: paraphrase. General knowledge about the format, not something this binary was seen doing.)Every substring in
chitin.keyand the module RIM archives carries id0, across the handful of localised strings holding inline text at all. The multiplier and the gender bit are unattested here, so the formula is worth treating as a convention rather than a measurement.Language codes themselves, and the one the engine special-cases, are on the TLK page with their codepage mapping.
Important
A
Structfield holds an index, not an offset Code14puts a direct index into the struct array indata_or_offset. Every other pointer-like field in the format holds a byte offset, so this one reads as an inconsistency and is easy to implement as an offset by analogy with its neighbours.The two readings do not fail loudly against each other. Struct records are 12 bytes, so index
nand byte offset12nare both plausible numbers landing inside the same section, and a reader using the wrong one produces a file that parses into the wrong tree rather than one that errors.
List is the other indirection. data_or_offset is a byte offset into the list indices array, where a u32 count is followed by that many struct-array indices, one per element.
Important
An empty list is not the same thing as an absent field Three states are distinguishable in the file: the label is absent, present with a count of zero, or present with elements. An empty list is an ordinary field record of type
15whosedata_or_offsetpoints at au32zero, with no struct indices after it.Offset
0is legal and used, so a reader must not read a zero offset as a missing field. Empty lists are ordinary rather than exceptional across every GFF inchitin.keyand the module RIM archives.A writer that drops a list rather than emitting a zero count has changed the file’s meaning. Four format pages turn on this, and UTC records a case where absent and present-but-empty behave identically on a fresh template and diverge on an object that already holds values.
A CExoLocString in chitin.key is almost always a bare StrRef pointing into the TLK, with no inline text at all: only a handful carry any, and each of those carries exactly one substring, with language-and-gender id 0.
That is a fact about chitin.key and nothing else. Module RIMs and saves were not in it, and a save is where player-entered text would have to live, so whether either exercises the substring loop is unchecked rather than settled. Implement the loop and test it against something other than the base archives.
Important
The substring wins and the StrRef is the fallback, not the other way round Reading one of these, the engine looks for the inline substring matching the running language and gender first, unconditionally, and only consults the StrRef when that lookup comes back empty (
CExoLocString::GetString). A field carrying a valid StrRef and a matching substring displays the substring.The obvious reading is the reverse, because
0xFFFFFFFFin the StrRef looks like a “use the inline text instead” marker. It is not one. The engine never inspects the StrRef’s validity to make this choice, so the invalid value is simply the case where the fallback has nothing to offer either.The rule lives on the type rather than on any field. Dialogue lines, item property descriptions, map notes and the save’s stat block all read through the same accessor.
Language 0 ignores the gender bit. The packed substring id is language * 2 + gender, but the gender term is only honoured when the language is non-zero. For language 0 the lookup always probes the ungendered slot, whatever gender was asked for. So a gendered language-0 substring is text the engine will never display.
The write side collapses it identically: asked to add a gendered substring under language 0, the engine lands it in the ungendered slot. So the engine’s own code cannot produce two language-0 substrings split by gender, and their absence from any corpus is structural rather than a gap in what has been looked at. A tool writing one is writing something the engine never would. Why the default language is special is not established, and it is not visible from the code either way.
How these were established
(Provenance: measured.) The encodings are derived from the bytes rather than inherited from another project’s header, and checked by tiling: decoding every field with the rules above, the field-data blob closes exactly in every GFF in a retail chitin.key and in a save corpus, with no gap, no overlap, and the last span ending on field_data_count. The six sections likewise tile from byte 56 to end of file in every GFF of both.
A wrong length rule for any variable-length type leaves a hole or an overrun, and a wrong reading of Struct or List puts an index outside its array, so a corpus that tiles with no slack constrains every rule at once. Saves are included because they carry type codes chitin.key does not.
Which codes actually appear, and where
Reported per archive, because each family exercises a different part of the code space. Four have been opened: the GFFs in chitin.key, the module RIM archives under modules/ and rims/, the static ERF archives (lips/ plus patch.erf), and a save corpus.
| Code | chitin.key | Module RIMs | Static ERFs | Saves |
|---|---|---|---|---|
0 2 3 4 5 8 10 11 12 14 15 17 | ● | ● | partial | ● |
13 VOID | — | ● | — | ● |
16 Vector4 | — | ● | — | ● |
1 CHAR, 6 DWORD64 | — | — | — | ● |
7 INT64, 9 DOUBLE | — | — | — | — |
VOID and Vector4 are not save-only. Both are absent from chitin.key and present in module RIMs, so a reader working from the base BIFs alone will not meet them and a reader working from any module will.
Every Vector4 in either population is a GIT Orientation, which corroborates rather than complicates the audit below: GIT’s camera list really is the format’s only Vector4 call site, and the field is written by the toolset into static GITs as well as by the engine into saved ones. VOID in a module RIM is likewise narrow; in saves it carries the packed value arrays in GLOBALVARS.res and a flag block in PARTYTABLE.res.
CHAR and DWORD64 are the codes that really are save-only: absent from chitin.key, from every module RIM and from the static ERFs, and abundant in saves. INT64 and DOUBLE appear in none of the four, which is an absence with a named scope rather than a reason to think the codes are unreal. A reader must still handle them.
Note
Why
Vector4’s component order can be read off the files Thew, x, y, zorder is documented from the engine below, and saved GITs confirm it independently. Across everyVector4in the save fixtures the second and third slots are zero and the first and fourth vary, and all of them are unit-length. A camera that only turns on the spot has a non-zero scalar and one non-zero axis term, which underw, x, y, zis exactly the first and fourth slots. Under anx, y, z, wreading the non-zero pair would have to be the third and fourth instead.The identity value settles it from the other side. One camera carries
(1, 0, 0, 0), the identity underw, x, y, z; under the alternative that same value is a half-turn aboutx, which is not an orientation a resting camera holds.
Text encoding
GFF stores text as bytes and declares no encoding anywhere in the file, so a reader cannot learn it from the container. There is no lookup in the engine either: it hands the bytes to the process’s ANSI codepage, which is Windows-1252 on a western install. The TLK page documents the one place the engine special-cases a language and what follows from that.
In practice the text in chitin.key is almost entirely ASCII. The only byte outside that range anywhere in its strings and resrefs is the plus-or-minus sign, in the Pazaak card names. Treating GFF text as single-byte and decoding it as Windows-1252 reproduces that archive exactly, and nothing in it requires a multi-byte sequence. Module RIMs and saves were not scanned for this, and player-entered text lives in the latter.
The overloaded word
data_or_offset is the mechanic the whole format turns on, and it means different things depending on context rather than carrying a discriminator of its own:
- On a field whose type fits in four bytes, it is the value. For anything larger, so strings, resrefs, doubles, structs and lists, it is an offset into the field data blob or into the index arrays.
- On a struct with exactly one field, it is that field’s index directly. With any other count it is a byte offset into the field indices array, where
field_countconsecutive indices live.
A struct with field_count == 0 has no defined meaning for the word, and no vanilla file provides one: not a single zero-field struct occurs in any GFF in chitin.key or the module RIM archives. The rule above still applies literally, so the word is an offset into the field indices array naming zero consecutive indices, and nothing dereferences it. A reader must not follow it, and a writer creating one has no vanilla precedent to match. UTI describes what the engine does with an empty entry in a PropertiesList, which is a code path a hand-authored file can reach even though no shipped file does.
So the same u32 is variously an immediate value, an offset into one of three different regions, or an index. What it is at any point follows from the field’s type and the struct’s field count, never from anything stored beside it. A reader that guesses wrong here does not fail loudly; it reads a plausible number out of the wrong region.
Engine Audits & Decompilation
Binary: swkotor.exe
Serialization Architecture (WriteGFFFile)
Derived from 0x00413030 / 0x004113d0.
The writer builds the whole file in memory and emits seven sections back to back in a fixed order, with no padding or alignment bytes between them. It records each section’s byte offset into the 56-byte header as it goes. This is the path save games and area extraction both write through.
| Order | Section | Size |
|---|---|---|
| 1 | Header | 56 bytes (0x38) |
| 2 | Struct array | 12B × struct_count |
| 3 | Field array | 12B × field_count |
| 4 | Label array | 16B × label_count |
| 5 | Field data blob | Variable |
| 6 | Field indices | Variable |
| 7 | List indices | Variable |
Warning
The label array holds fixed 16-byte elements, so a label longer than 16 characters is truncated on write.
Note
The GFF version is always
V3.2. The header’s version field is written byCResGFF::CreateGFFFile(0x00411260) from a single global value, and the version string a caller passes in is ignored. So the on-disk version never varies, even where the calling code asks for something else (several save-game writers requestV2.0, but it never reaches disk). Read theV3.2you observe; the version is not a per-resource signal.
Note
Field-label lookup is case-sensitive. Every
CResGFF::ReadField*wrapper resolves its label throughCResGFF::GetFieldByLabel(0x00411630), which copies the requested label into a fixed 16-byte buffer with no case-folding and compares it against each field’s stored label with an inlined byte-for-byte comparison, not a case-insensitive string function. A label that differs from the one the engine’s own code constructs only in capitalization never matches:FortBonusagainst the engine’sfortbonusis a different string, not a fallback path. This holds for the wholeReadField*family, every scalar and string type, so exact-string field matching in a reader is the behaviourally correct model of this engine, not a shortcut that happens to work on vanilla data.
Important
A
Vector4field’s on-disk component order isw, x, y, z. Slot0isw, slot1x, slot2y, slot3z, so the identity quaternion is[1.0, 0.0, 0.0, 0.0]and not[0.0, 0.0, 0.0, 1.0].
CResGFF::ReadFieldQuaternion(0x004121b0) andCResGFF::WriteFieldQuaternion(0x00412ca0) both assign field by field rather than blitting memory, so the order is legible rather than inferred.Confirmed at the format’s only
Vector4-typed call site, GIT’sCameraList[].Orientation, and cross-checked against the engine’sQuaternionstruct layout, which is the same struct the MDL format already documents.
Note
Carry-over versus fresh-literal is a property of the read call, not of the field, and it recurs on every format page in this section. Every GFF scalar read takes a “default if absent” argument, and what sits in that argument is the whole distinction:
Mechanism The default argument holds Carry-over The field’s already-constructed value on the object, so an absent field leaves it where it was. Fresh literal An unrelated literal baked into the call site, stamped over whatever the object held. Either way the assignment always executes, with no visible branch on presence, which is why the two are indistinguishable from the resulting value alone.
They can diverge sharply. UTC’s
LoadCreaturefields include five where the read literal disagrees with the constructor outright. Naming which mechanism a field uses is the most repeated question these pages answer, so it is defined once here.
Engine Blueprints: Specialized GFF Containers
The sections above describe raw GFF nodes. The engine also uses GFF as a wrapper for a family of fixed layouts called blueprints: creatures, dialogue trees, placeables, area parameters, and the rest.
Each blueprint page documents how the K1 GOG executable (swkotor.exe) maps that layout into memory through its Load*FromGFF functions, which is what rakata-lint validates against.
How far to trust any given claim is a per-page question, and each page answers it. Every Engine Audits section names the function it was read from and its provenance level, and those levels differ: some rows are traced instruction by instruction, some are measured against real files, and most are derived from one decompilation pass that nothing has separately re-checked.
Reading the field tables
Every blueprint page ends with a field table generated from the schema itself, so no label can be quietly left out. It sits at the end because it runs long, and the page’s prose comes first. Each table splits in two.
What the engine does with each field holds the labels where the engine’s behaviour is established, with what it does and what it holds when a file omits the label.
Fields nobody has examined holds the rest. That is a gap, not a finding: nobody has established whether the engine reads them, which is not the same as establishing that it does not. Many still have an answer under When absent, because the two questions were settled separately.
A bracketed number sends you to the finding under the table. The same number on many rows means one passage settles all of them, which is worth seeing before you rely on any single row.
Note
The typed blueprint structs documented below (
Utc,Uti,Are,Git,Dlg,Ifo,Utd,Ute,Utm,Utp,Uts,Utt,Utw) are projections over raw GFF, not replacements. Eachfrom_gffextracts only the documented fields and silently drops anything else;to_gffwrites only those documented fields. The rawGfftree stays alongside the typed view for callers that need byte-exact fidelity. See Typed Views and Raw GFF in the architecture guide for the full rationale and the choose-which-layer guidance.
The Blueprint Engine Audits
Each audit in this section’s navigation bar records the fields a format declares, how the engine loads them, and the rules it enforces beyond what the container’s own structure guarantees.
A field can be structurally valid GFF and still break the engine. rakata-lint flags those cases, and these audits are what it validates against.
| Ext | Type | Core Function |
|---|---|---|
.are | Area Static Blueprint | An area’s static properties: weather, day/night limits, and physics constraints. |
.dlg | Dialogue | One conversation: the branching graph of lines and replies, with the cinematic actions attached to them. |
.git | Game Instance Template | Where everything in an area is: placement, orientation, and which template each object spawns from. |
.ifo | Module Info | Module-level metadata: the entry point, the module’s scripts, and its spawn state. |
.utc | Creature | A creature: every NPC, enemy, droid and companion, with its stats and appearance. |
.utd | Door | A door: the transition it opens onto, plus its lock and trap state. |
.ute | Encounter | An encounter: the boundary and spawn points deciding what appears, and when. |
.uti | Item | An item: every weapon, suit of armour, medpac and upgrade, with its properties. |
.utm | Store | A merchant’s store: the inventory it offers and its buy and sell markup. |
.utp | Placeable | A placeable: containers, scenery, and traps the player can interact with. |
.uts | Sound | A positional sound emitter: its clips, and how volume falls off with distance. |
.utt | Trigger | A trigger: an invisible polygon that fires scripts when something enters it. |
.utw | Waypoint | A waypoint: a named position used for navigation and area transitions. |