2DA (2D Array)
2DAs are data tables defining the engine’s core rules and constraints (such as item costs and Force powers, which the engine internally stores as spells.2da). They bridge the gap between human-readable text for modding and fast-loading binaries for the final game.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .2da |
| Magic Signature | 2DA / V2.b (Binary) or V2.0 (Text) |
| Type | Tabular Data |
| Rust Reference | View rakata_formats::TwoDa in Rustdocs |
File Layout (binary V2.b)
Six blocks, none of them located by an offset. Everything is found by parsing sequentially, which is why a malformed block does not produce a wrong lookup so much as a wrong everything after it.
| Block | Contents |
|---|---|
| Signature | 2DA + V2.b + LF. A single LF, not CRLF, in every vanilla table measured. Emit CRLF and every field after it sits one byte off. |
| Column headers | One per delimiter, the run terminated by a NUL. The delimiter is a tab in chitin.key and a NUL in thirteen rims/ copies; see below |
| Row count | A single u32 |
| Row labels | One per delimiter, same delimiter as the headers in that file |
| Cell offset table | u16 per cell, rows x columns, row-major |
| String-table size | A single u16. See below: the engine skips it, and it is not free to a writer |
| String table | NUL-terminated cell strings, referenced by the offsets above |
Three delimiting conventions in one file
The header run is terminated by a NUL, the rows are counted by a u32, and the headers and row labels are delimited one by one. See counted, terminated, or neither. Assuming one convention throughout is the single easiest way to misparse this format.
The string-table size field
Important
The size field is skipped by the engine and is still not yours to choose The
u16between the offset table and the strings is read by nothing. The loader steps over it with a+2and never validates it, which is where the phrase “orphaned size field” comes from, and it is why a file carrying any value at all still loads.It is not arbitrary in practice. Across every 2DA in
chitin.key, the field holds exactly the number of bytes of string table that follow it, without exception, and adding the two bytes for the field itself lands on the end of the file every time.So the engine’s answer and the writer’s answer differ, which is the general case this manual states elsewhere. Write the remaining byte count. A reader should not depend on it, because nothing makes the engine enforce it, and a tool that trusted it would be trusting a field the game never checks.
The delimiter, and the thirteen twinned tables
Warning
Thirteen tables ship twice, and the two copies use different delimiters The delimiter is a tab: one after each column header, one after each row label. That holds for every 2DA in
chitin.key, without exception.Thirteen of those tables also appear in
rims/global.rim, and that copy is byte-identical except that every delimiter tab is a NUL instead. (rims/miniglobal.rimcarries the same thirteen in the same form, and is never mounted: the string does not occur in the executable at all.) Established by diffing the two copies of each: the number of differing bytes equals the column count plus the row count exactly, every difference is a0x09where the other copy has0x00, and the offset table and string table are untouched. The two copies are the same length, so the substitution is one byte for one.The thirteen are
appearance,appearancesndset,baseitems,bodybag,doortypes,genericdoors,heads,inventorysnds,placeableobjsnds,placeables,portraits,soundsetandtraps. Every other 2DA in that archive is byte-identical to itschitin.keycopy, tabs included.The NUL-delimited copy is the one the game reads, so handling both delimiters is required rather than prudent.
global.rimis mounted by name, and the key table’s search order puts RIM archives above the base archives with no per-resource-type branching, so a lookup for any of the thirteen finds therims/copy first and never reacheschitin.key.A reader that assumes a tab therefore does not fail on an obscure variant. It fails on the live copy of thirteen of the most-read tables in the game, reading each header run as a single column name. Both forms terminate that run with a NUL, so a reader that scans for the terminator rather than the separator survives either.
Warning
u16cell offsets cap the string table at 64 KiB Every cell points into the string table with a two-byte offset, so no cell’s text can begin beyond byte 65,535 of that block. Deduplication is what keeps real tables comfortably inside it: repeated strings share one entry, and a 2DA column is usually a handful of distinct values repeated down thousands of rows. A generator that writes each cell’s string separately can overflow a table the game ships without trouble.
The text form (V2.0) carries the same logical table with none of this machinery: no offset matrix and no shared string table, just whitespace-separated cells, with **** standing in for an empty one.
Two ways a text cell comes out blank, and they differ
V2.0 has two conventions that both leave a cell looking empty, and they resolve to different values. Conflating them writes the wrong content into short rows.
An explicit **** becomes the empty string. The row-parsing loop checks each raw token for that literal and stores "" for the cell. This runs unconditionally and consults nothing else.
A row that runs out of tokens gets the table’s default. Where a row carries fewer tab-separated fields than the table has columns, every trailing cell it never supplied is filled with the value from the file’s DEFAULT: block. That is the whole of what DEFAULT: governs.
So **** and a short row are not two spellings of one idea. One says “empty here”, the other says “whatever this table falls back to”.
The DEFAULT: block
It sits on the line after the version, before the column headers, and it is optional. The parser reads that line’s first token, uppercases it, and accepts two spellings: DEFAULT: as a single token, or DEFAULT followed by a separate token beginning with :, which absorbs a stray space before the colon. Either match consumes one further token as the default value.
A line matching neither spelling is not an error. The parser moves straight on to column headers and no default is ever set, which leaves a short row’s trailing cells with nothing to fill them from.
Read from C2DA::Load2DArray at 0x004143b0. Provenance: traced, through the tokenizing path every fresh text-format 2DA takes. A second path through the same function, gated by a flag from the resource helper, skips this logic in favour of an offset scan over an already-processed buffer; what sets that flag was not followed.
Engine Audits & Decompilation
Read from C2DA::Load2DArray at 0x004143b0 in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.
| Pipeline Event | Ghidra Provenance & Engine Behavior |
|---|---|
| Magic/Version Gate | The engine first checks for the "2DA " signature. It then branches down a binary parsing path for "V2.b" or a text parsing path for "V2.0". Any other version string triggers an instant load failure. |
Binary Load (V2.b) | The parser starts with an 8-byte skip into the file (data_ptr = raw_data_ptr + 8), jumping right past the header to the starting newline character. Column headers are a delimited run closed by a NUL; the delimiter itself is a tab in some copies and a NUL in others, as measured above. The cell offsets are then parsed as an array of u16 integers (rows × cols) in row-major order. |
Text Load (V2.0) | The text parser strips whitespace and newlines, reads an optional DEFAULT: block off the line after the version, then column headers, then rows. It runs _strlwr on all column headers to convert them to lowercase, but this never breaks a mixed-case column lookup, because column-name resolution is case-insensitive on both load paths, not just on this one. Two separate conventions produce a blank-looking cell; see below. |
Tip
Orphaned Size Field: In binary row blocks, the engine steps over the 2-byte
cell_data_sizeu16with a+2and neither reads nor validates it. That is a statement about the loader only; what a writer should put there is above.