xlce: fixed-row data tables
- Source
docs/formats/xlce.md- SHA-256
ce3b7ab3…88db0038
Header · String pool · Padding · Undocumented tables · How the game reads it · Rebuilding · Leads
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. |
Header
| 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.
- confirmed: a string field is 8 bytes: a u64 offset whose upper 4 bytes are zero (900/900). Source: the scan. Gibbed
StringDescriptoragrees (8 bytes in Reborn; fails if the upper 32 bits aren't zero). - confirmed: the offset is measured from the start of the table (the magic), and points into the pool after the rows (900/900). Source: the scan; the round trip. Gibbed
ExportSheetagrees. - confirmed: an empty string is a NUL in the pool, pointed at like any other string (300 of the 900), not an offset of 0. Source: the scan; DuckDB over
15.class_text_keys.jsonland74.object_file_data.jsonl. - likely: an offset of 0 means no string. The decoded data shows it as
null, and anullencodes back to 0 with nothing added to the pool (Rebuilding). Owner decided (2026-10-12). None of the 900 offsets in the documented tables is 0. Source:0x140489d60(inlocal/exe/decomp/0x1404882f0.c), which turns a table's five string fields into pointers only where they aren't 0 (if (*field != 0) *field += table); the gateAStringOffsetOfZeroIsNull. GibbedStringDescriptorreads 0 as the empty string. - likely: the strings are in the game's text encoding, the same as
txtetext. Source: GibbedExportSheet(decodes each string with its Reborn text decoder). All 900 strings in the documented tables are printable ASCII (DuckDB, as above), so they don't test it. - likely: a string can start with the header the game skips before a text lookup (txte.md): U+F8FC, then 5 bytes,
02 02 01, the length of the rest of the string + 1,08. Every string insystem/system_data.dat#0and#9starts with it, all in that form. The decoded data shows it as a tag,{f8fc 02 02 01 08 08}nothing, named by its code point as txte's{f8fd xx}is, with all 5 bytes in hex, and encodes it back byte for byte; the length isn't recomputed, so an edit to the rest of the string changes it by hand. Only a header whose first byte has bit 0 clear (5 bytes) is shown this way; a tag that isn't 5 bytes, has bit 0 set in its first, or holds00is refused. Source: DuckDB over the exported strings ofdata/game/system/system_data.dat/0.bg_data.jsonland9.text_key_swap.jsonl, 2026-10-13: every one starts{f8fc 02 02 01 xx 08}, withxxthe length in bytes of the rest + 1;Text_Lookup(0x1402b95d0) skips it; the gateASkippedHeaderShowsAsATag. - confirmed: a table has a pool when its entry is larger than
align16(16 + row_count × row_size) + 16. Source: the scan: of the 564xlcetables onlyobject_file_datahas a pool, and its entry (4,384 bytes) is the only one past that size (3,200). GibbedExportSheetagrees (hasExtraData). - confirmed: the pool starts right after the last row, at 16 +
row_count×row_size. Source: the scan (object_file_data's pool is in row order from there, andclass_text_keys's lowest offset is the end of its rows).
Padding
- confirmed: after the rows (and the string pool, if any), the entry is zero-padded to a 16-byte boundary. When the data already ends on a boundary, 16 more zero bytes follow. So the padded size is
align16(data_size + 1). This may be added by thepakd, not the table (containers.md): the onexlcethat isn't apakdentry, the whole fileeffect/EffectMaterialData.bin, ends right after its rows, on a boundary, with no padding (confirmed, hex inspection and the R28 round trip). Source: the scan, 564/564xlcetables; the round trip. Gibbed'shasExtraDatathreshold inExportSheetfits it for tables without a pool. Not every table with its own header follows it:entry_unit_1has 24 zeros where this rule gives 8.
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).
- likely: the game reads a table in place, from three header fields:
row_countas the loop bound,row_offsetas a signed offset from the table's start to the rows, androw_sizeas the stride; rowiis attable + row_offset + i × row_size. Source:0x1402d4a30(every relocation loop). - likely: it doesn't check the magic, and doesn't require
row_offsetto be 16. Entry#15ofbattle_data_release.datshows it: its first 4 bytes are zero, yet the loader reads it as a table like the others. Source:0x1402d4a30(slot0x140, no check before the loop); hex of#15(00 00 00 00, thenrow_count256,row_offset16,row_size40). - likely: at load, the game turns each string field into a pointer in place, by adding the table's address to its
str_ofs64; only for the fields the loader knows are strings, table by table. So in its tables an offset of 0 would point at the table's own magic, not at an empty string; other loaders leave a 0 as it is, no string (String pool). Source:0x1402d4a30(*field += tablefor each string field it lists);0x140489d60(adds only where the field isn't 0). - likely: the loaders don't check
row_countagainst any limit; a limit, where there is one, is in the code that uses a table. Source:0x1402d4a30,0x1402b9cd0(no comparisons on the counts).
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
- Which tables have a string field with offset 0 (no string); none of the documented tables does (their empty strings are NULs in the pool).
- Which tables have a pool that is not in row order, and why (repeated strings, gaps): of the documented tables,
class_text_keys;text_keys(#35) is another. - In-game test: a table with a new string appended to its pool (Rebuilding) loads and shows it.
- Whether the padding belongs to the table or to the
pakd. - legacy/formats/xlce.md (
../../legacy/formats/xlce.md) has earlier observations to recheck (reference only).