TO:R Data Map

xlce: fixed-row data tables

Source
docs/formats/xlce.md
SHA-256
ce3b7ab3…88db0038

The game's data-table format: a 16-byte header, then fixed-size rows, then an optional string pool. The first four bytes, the magic, read xlce; Gibbed reads byte 3 (0x65, the e) as a version, 101, but no code reads it. Tables sit as entries in a pakd archive (containers.md). Individual tables are in docs/tables/ (../tables/).

Based on Every xlce entry a table doc covers, 564: 22 in battle/battle_data_release.dat, #0 of the 536 battle/entry/entry_unit_<n>.dat files, and 6 in menu/menu_data.dat; the tables with their own header (class_text_keys, entry_unit_1, pgrs), whose rows and rebuilding follow the same rules; Gibbed's Gibbed.TacticsOgre.ExportSheet/Program.cs and Gibbed.TacticsOgre.SheetFormats/StringDescriptor.cs (reference_tools/Gibbed.LetUsClingTogether/projects/).
Checked 2026-10-11
Method Layout first taken from Gibbed's sources. How the game reads it: static analysis of the unpacked executable with Ghidra 12.1.4, through the decompiled code in local/exe/decomp/, and hex inspection of battle_data_release.dat#15. Checked against the game data, 2026-10-11: the round trip, tor round-trip local/game/Data/battle --docs docs (539/539 files, 1,095/1,095 tables byte-identical) and tor round-trip local/game/Data/menu/menu_data.dat --docs docs (1/1 file, 6/6 tables); the scan, a C# program (session scratchpad r22/tables.cs) that rebuilt every one of those tables from its layout alone, with no byte taken from the original (1,110/1,110 byte-identical, the 9 pgrs tables included), and tallied the header, string-pool and padding facts below. 2026-10-12 (R19): entry_unit_pack00.dat and entry_unit_pack01.dat are claimed by the entry-unit table docs too, and tor round-trip local/game/Data/battle --docs docs gives 539/539 files, 1,099/1,099 tables byte-identical; the counts below are from the scan, before them. 2026-10-12 (R25): the 987 xlce entries with no table doc, in 445 files across battle/, event/, menu/, sound/, textseq/, worldmap/, WarrenReport/, bg/, system/ and title/, decode as Undocumented tables; tor round-trip local/game/Data --docs docs gives 34,227/34,227 files, 2,104/2,104 tables byte-identical, and read-back 34,227/34,227 the same as the original. So every xlce entry in Data/ fits the Header and Padding rules or keeps its tail as it is; the facts and counts below are still from the documented tables only. No in-game tests yet.
Offset Size Type Name Meaning Confidence Source
0x0 4 raw[4] magic xlce (564/564). The game doesn't check it (How the game reads it); no code reads byte 3 (0x65). confirmed The scan; 0x1402d4a30 reads only +4, +8 and +0xC. Gibbed ExportSheet reads xlc and a version byte, which must be 101.
0x4 4 s32 row_count Number of rows. Gibbed rejects a negative count. confirmed The round trip; the scan. Gibbed ExportSheet agrees (entryCount).
0x8 4 s32 row_offset Where the rows start, from the table's start; 16 in all 564. The game uses it as an offset; Gibbed rejects any other value. confirmed The scan; 0x1402d4a30. Gibbed ExportSheet agrees (dataOffset).
0xC 4 s32 row_size Size of one row in bytes; in every table, the size its schema lays out. Gibbed rejects a negative size. confirmed The round trip (the decode refuses a table whose row_size differs from its schema's). Gibbed ExportSheet agrees (entrySize).
0x10 row_count × row_size — rows The rows, packed with no gaps. Each table's fields are in its schema (schemas/<name>.yaml) and its page in tables/ (../tables/). confirmed The round trip; the scan. Gibbed ExportSheet agrees (reads entryCount rows of entrySize from dataOffset).

String pool

Some tables have a pool of NUL-terminated strings right after the last row. Rows point into it with str_ofs64 fields (types).

Two documented tables have string fields: object_file_data (xlce) and class_text_keys (its own header); 900 str_ofs64 values in all.

Padding

Undocumented tables

An xlce entry that no table schema matches is still decoded, so its rows can be profiled in DuckDB before anyone writes its schema: rows from the Header (row_count rows of row_size bytes from row_offset), each row cut into the one field below, repeated every Size bytes up to row_size − 1, each copy named unk_<offset> for its own offset. The string pool and padding aren't decoded; they're kept and rebuilt as Rebuilding says for a table with no string field. Its decoded data is <n>.xlce.jsonl, decoded: "xlce" in entries.jsonl, and each row also has a derived _hex column, the whole row as hex, for reading wider values at any offset (toolchain/README.md (../../toolchain/README.md#duckdb-and-datagrip)). A schema for the entry replaces this as soon as it matches.

Offset Size Type Name Meaning Confidence Source
0x0 1 u8 unk_0x00 One byte of the row; not yet documented. — The round trip and read-back over all of Data/, 2026-10-12: 987/987 undocumented tables byte-identical.

How the game reads it

From the loader of battle/battle_data_release.dat, 0x1402d4a30, and 0x1402b9cd0, which walks the text tables (containers.md has a lead on what it is) (static analysis with Ghidra 12.1.4, unpacked executable SHA-256 7b0230a1…; so at most likely). Which function reads which table is in the file's page (battle_data_release).

Rebuilding

How a decoded table is encoded back into its entry. Every rule is proven by the round trip (1,110/1,110 tables byte-identical, see Method); the scan also rebuilt every table with no byte taken from the original, so the layouts leave no byte out.

Part Rule Confidence Source
Header Rewritten from the Header layout with row_count set to the number of rows; magic, row_offset and row_size keep their values. A non-xlce table rewrites its own Header section the same way. confirmed The round trip; the scan.
Rows Packed from each field in the table's schema, in row order, with no gaps. Several bits[a:b] fields share their byte; any bits no field names, and any bytes the layout leaves out, are kept from the original row (no documented table has any). confirmed The round trip; the scan.
String pool, in order When the pool holds exactly the strings the rows point at, in row then field order, each once, NUL-terminated, starting right after the last row: it is rebuilt from the row values, and each str_ofs64 is set to the new string's offset from the magic. confirmed The round trip; the scan (object_file_data).
No string A null string (String pool) is written as offset 0 and takes no place in the pool, in or out of order; an offset of 0 is skipped when checking whether a pool is in order. likely The gate AStringOffsetOfZeroIsNull (a null row decodes from 0, encodes back byte for byte, and an edit to null drops its string from the pool), 2026-10-12. No documented table has one yet.
String pool, not in order Otherwise the pool is kept byte for byte, and pointers only shift by how much the rows grew or shrank. A string the pool already holds is pointed at where it is. A new string is appended after the pool's last string (just past its NUL, or past the last string a row points at, whichever is later), NUL-terminated, once however many fields use it, and the padding after it is recomputed per Padding; no existing byte or pointer moves, since the game turns offsets into pointers and never walks the pool (How the game reads it). When the bytes after the pool's last string aren't exactly that padding, a new string is refused with an error. Owner decided (2026-10-11) for text_keys (#35), whose pool is out of order and ends in that padding (its last string ends at 0x4D0B4, then 12 zeros, the padding rule's 12). confirmed (unchanged pools); likely (new strings) The round trip; the scan (class_text_keys). New strings: the gate ANewStringInAKeptPoolDecodesBack (two rows given one new key in class_text_keys decode back with it, written once, every byte before it unchanged), 2026-10-12; R19 scan of #35's tail. No in-game test yet.
Bytes after the data Rebuilt per Padding when the original bytes there are exactly that padding; otherwise kept as they are. confirmed The round trip; the scan (all 564 xlce tables are rebuilt; the 109 entry_unit_1 tables with 24 zeros are kept).

How the decoded data keeps every f32 value lossless, NaNs included, is in toolchain/README.md (../../toolchain/README.md#decoded-data).

Leads