SLK Spreadsheet Format
SLK (Symbolic Link) is a plain-text spreadsheet interchange format that Warcraft III uses to store game data tables — unit statistics, item data, ability data, upgrade data, and more. The game ships with a large set of .slk files inside the MPQ archive.
File Format Overview
An SLK file is line-oriented. Each line begins with a record type identifier followed by semicolon-separated fields:
| Record | Meaning |
|---|---|
ID |
File identification line — always ID;PWXL;N;E for WC3 SLK files |
B |
Advisory dimension bounds — total rows (Y) and columns (X) |
C |
Cell data — row (Y), column (X), and value (K) |
F |
Cell/column formatting; X and Y still update the current coordinates |
E |
End-of-file marker |
The first row (Y=1) contains column headers. Subsequent rows hold data records. The value field K can be:
- A bare number: K123 or K3.14
- A quoted string: K"Footman"
X and Y are stateful. A record that omits either coordinate keeps the previous value, including coordinate changes made by an F record. A missing K does not create a cell. Do not allocate solely from B: shipped files contain bounds larger than their populated cell range.
Key SLK Files
| File (inside MPQ) | Contents |
|---|---|
Units\UnitData.slk |
Base unit stats (HP, speed, armour, attack) |
Units\UnitUI.slk |
Unit display names, icons, sound sets |
Units\UnitWeapons.slk |
Attack type, projectile, range |
Units\UnitBalance.slk |
Build time, cost, food |
Units\UnitAbilities.slk |
Per-unit ability assignments |
Units\AbilityData.slk |
Spell data (cooldown, cast range, damage) |
Units\ItemData.slk |
Item stats |
Units\UpgradeData.slk |
Research costs and effects |
Doodads\Doodads.slk |
Doodad model paths and properties |
Units\DestructableData.slk |
Destructible HP, death animation |
INI / Profile Files
Alongside SLK files, Warcraft III uses profile.txt style INI files (war3mapSkin.txt, TriggerData.txt, etc.). These follow a standard [Section]\nKey=Value layout. OpenWarcraft3 parses both via games/warcraft-3/sheet/sheet.c.
Parsing in OpenWarcraft3
The Warcraft III SLK/profile parser lives in games/warcraft-3/sheet/. It works in three phases:
Phase 1 — Field scanning (ScanSLKField)
FS_ParseSLK_Buffer scans the input span line by line without copying lines into a fixed buffer. For each C or F record, ScanSLKField extracts one semicolon-delimited field at a time. For K fields that start with ", it switches to quote-aware mode: reads until the matching closing ", decoding "" as a literal double-quote and treating semicolons inside quotes as ordinary characters.
Bare (unquoted) fields for X, Y, and numeric K values are scanned up to the next ; or end-of-line as before. The previous SheetParseTokens/GetToken approach that blindly NUL-terminated every ; has been replaced; quoted semicolons now parse correctly.
Phase 2 — Cell storage (FS_FillSheetCell)
For each C record the parser extracts Y, X, and K fields and calls FS_FillSheetCell(x, y, text), which appends the text to a process-global string arena and links a sheetCell_t node into a process-global pool. Cells with X=0 or Y=0 are skipped before the call. FS_FillSheetCell checks both the cell pool and the text arena for exhaustion and logs to stderr before returning early if either is full. F records update the same current X/Y state but do not create cells.
typedef struct SheetCell {
LPSTR text; // pointer into the flat string buffer
USHORT column;
USHORT row;
LPSHEET next;
} sheetCell_t;
Phase 3 — Row assembly (FS_MakeRowsFromSheet)
After all cells are parsed, FS_MakeRowsFromSheet converts the flat cell list into an array of sheetRow_t records, one per data row. The first row provides the column header names; subsequent rows are turned into sheetField_t key-value pairs keyed by header name.
The parser-owned row list remains the compatibility representation for INI files. Every runtime SLK is decoded once at startup into a flat typed array defined in games/warcraft-3/game/g_unitrow.h:
UnitBalance.slkUnitData.slkUnitUI.slkUnitWeapons.slkUnitAbilities.slkAbilityData.slkItemData.slkDestructableData.slkDoodads.slkUberSplatData.slkUnitAckSounds.slk
Every typed row contains its source FOURCC and every ROC/TFT column. Native column names exist only in the DDX schemas; C members use semantic names such as agilityPerLevel. Optional slkField_t.id object-data IDs are readable four-character strings rather than packed integer constants. slk_stores[] in games/warcraft-3/game/g_metadata.c initializes the public g_UnitBalance, g_UnitWeapons, and corresponding per-SLK arrays and indexes once. Production retains no raw source pointer in the registry. ShutdownUnitData frees the typed strings, arrays, and indexes.
Spawned entities bind immutable pointers to their rows once in SP_CallSpawn, so gameplay uses unit->balance->agilityPerLevel, unit->ui->modelFile, or unit->weapons->attack2.damageBase. The mutable values derived at spawn remain in unit->runtime; this is separate from the immutable UnitBalance_t. Code that only has a FOURCC uses G_UnitBalance(id) and the other indexed lookup functions. Typed passthrough UNIT_*, ITEM_*, and DESTRUCTABLE_* macros are not part of this path; only Profile/INI fields remain macro-backed.
UnitsMetaData is a compile-time FOURCC dispatch table. Descriptor IDs are readable four-character string literals compared at runtime as fixed-width DWORD keys. Each descriptor also contains the offsetof(edict_t, cachedRow), the offsetof(TypedRow_t, member), and its bzFieldType_t. UnitMetaString, UnitMetaInteger, UnitMetaBoolean, and UnitMetaReal find that descriptor, read the immutable row pointer already cached on the edict, and address the typed member directly. Startup does not bind SLK or column-name strings, and these accessors do not consult the global typed-row indexes.
BZ_FIELD_CSTR values are duplicated while decoding, so typed rows do not borrow parser-arena strings. FS_SLKFreeRows walks the same DDX schema, frees each unique string offset, and frees the row array. This unique-offset rule is required for release aliases such as ItemData class/itemClass and DestructableData Name/name.
ROC/TFT columns that moved between tables remain represented in both owning row types. Accessors resolve the known release split, for example UnitBalance.collision in TFT versus UnitData.collision in ROC. AbilityData's ROC Data11..Data34 and TFT DataA1..DataI4 columns map into one data[level][slot] array during decoding. Test builds expose G_SetSLKRows to replace and restore a typed fixture; production builds do not retain that source pointer.
FOURCC-keyed tables use the sorted slkIndex_t sidecar. UnitAckSounds.slk has arbitrary-length labels such as FootmanWhat; its row struct owns the full source row name and lookup compares that string in the flat array. Do not truncate sound labels into FOURCC keys.
SplatData.slk and UnitCombatSounds.slk were previously loaded but had no consumers, so they are no longer loaded. Add a typed row and registry entry before introducing a runtime consumer for either table.
Verified Archive Characteristics
The following was measured with build/bin/mpqtool against the startup SLKs loaded by InitGame and InitUnitData:
| Data set | Cells | Approx. stored value bytes | Data-row upper bound |
|---|---|---|---|
ROC (War3.mpq) |
148,596 | 984,282 | 3,956 |
TFT (War3x.mpq) |
333,954 | 2,088,980 | 7,382 |
- ROC uses omitted coordinates heavily and contains thousands of
Frecords. StatefulX/Yhandling must be preserved. - The inspected startup tables contain no quoted semicolons and no lines at or above the current 1024-byte line limit.
Bis not allocation truth. ROCUberSplatData.slkdeclares 136 rows but populates only 44; ROCUnitAckSounds.slkdeclares 20 columns but populates 19. TFT has similar mismatches inUberSplatData.slkandUnitData.slk.- The current three one-million-entry pools occupy about 72 MiB on a 64-bit build before the separate 8 MiB text arena, despite the TFT startup corpus containing about 334,000 cells and fewer than 7,400 data rows.
Current Risks
- Cached lists have shared mutable ownership.
FS_ParseSLKandFS_ParseINIcache and return the samesheetRow_tlist.InitUnitDatathen appends cached INI lists by rewriting each tail'snext. Sinceconfig_filesandprofile_filesoverlap, one aggregate can rewrite another and repeated rows can form cycles. A cache entry must own an immutable table; aggregate lookup order must not be represented by splicing owned row lists. - Pool exhaustion corrupts memory. Row and field cursors still have no capacity checks.
FS_FillSheetCellnow checks the cell pool and text arena and logs before returning early, but theFS_MakeRowsFromSheetrow/field pools remain unchecked. - Long values are silently truncated. The per-field buffer in
ScanSLKFieldisMAX_SHEET_LINE(1024) bytes; values longer than 1023 bytes are truncated without a warning. The confirmed startup corpus has no such values. Cache keys inNormalizeSheetKeyare also silently truncated at 256 bytes. - Errors are not actionable. Invalid magic, malformed coordinates, unsupported records, allocation failure, and truncation generally return partial data or
NULLwithout a filename, line, or reason. - Compatibility lookups are linear.
FS_FindSheetCellstill scans Profile/INI rows and fields. Runtime SLK lookups use sorted indexes, and spawned entities retain direct row pointers. - Production parsing is now unit-tested.
FS_ParseSLK_Bufferis non-static and declared incommon.h. Part 5 oft_slk.cexercises it directly: stateful omitted Y, stateful omitted X, F-record coordinate advance, advisory B bounds, quoted semicolons,""quote escapes, zero coordinate rejection, and no-magic-line tolerance. - Legacy parser storage is process-scoped. Cache entries and parser pools survive for the process lifetime. Typed SLK arrays and their owned strings are independent and are released by
ShutdownUnitData.
External Implementations
- wc3libs uses an ANTLR grammar that keeps semicolons inside quoted values, preserves stateful coordinates, represents values as typed data, and exposes keyed object/field maps. Its loader requires
Band allocates directly from it, which is too strict for Warcraft files with unreliable bounds. - HiveWE validates the
IDrecord, returns explicit load errors, owns dynamic strings/maps, ignoresB, preserves coordinate state, and provides row/column indexes. Its specialized line parser assumes a narrow token order and does not provide a generally quote-aware field lexer. - mdx-m3-viewer deliberately ignores
B, grows a sparse matrix from observed coordinates, and then builds case-insensitive keyed rows. Like the current parser, it splits raw semicolons and therefore is not a tokenization reference. - Warsmash similarly demonstrates dynamic typed rows and keyed lookup, but also splits raw semicolons. Its storage model is useful; its lexer is not.
No implementation should be copied wholesale. The common useful contract is owned dynamic storage, observed-coordinate growth, stateful coordinates, explicit errors, and keyed access. wc3libs supplies the strongest lexical model; HiveWE supplies the closest table API model.
Migration Plan
- ✅ Done.
FS_ParseSLK_Bufferis non-static and declared incommon.h.SheetParseTokens/GetTokenreplaced byScanSLKField, a quote-aware scanner that reads directly from the input span (no line copy).FS_FillSheetCellnow validates X/Y (≥1) and checks pool/arena bounds. Part 5 oft_slk.ccovers: quoted semicolons,""escapes, omittedX/Y, F-record coordinate advance, wrong/missingB, zero-coordinate rejection, and no-magic-line tolerance. - Remaining from original step 2: long-value truncation warning and full removal of the
MAX_SHEET_LINEper-field cap. - Introduce an owned
sheetTable_tbehind the existingsheetRow_tresult. Allocate cells/rows/fields/strings per table, fail with filename and line diagnostics, and add cache reset/destruction. KeepsheetRow_tstable until consumers migrate. - Stop mutating cached row chains. Represent profile precedence as an array of table pointers, as
abilityConfigTablesalready does, or build non-owning aggregate link nodes separate from cached rows. - Add per-table row and case-insensitive column indexes as sidecars. Make
FS_FindSheetCelluse the index while retaining linked rows for iteration-heavy callers. - After all consumers use table ownership explicitly, remove the global pools,
last_parsed_sheet_tail, and the duplicate tail caches in the sheet and game modules.
Example SLK Snippet
ID;PWXL;N;E
B;Y5;X4
C;Y1;X1;K"unitID"
C;Y1;X2;K"HP"
C;Y1;X3;K"moveSpeed"
C;Y1;X4;K"name"
C;Y2;X1;K"hfoo"
C;Y2;X2;K200
C;Y2;X3;K270
C;Y2;X4;K"Footman"
C;Y3;X1;K"hpea"
C;Y3;X2;K250
C;Y3;X3;K270
C;Y3;X4;K"Peasant"
E
Related Source Files
| Source | Purpose |
|---|---|
games/warcraft-3/sheet/parser.c |
Token parser helpers |
games/warcraft-3/sheet/sheet.c |
SLK parser and row accessor |
common/shared.h |
sheetRow_t, sheetField_t type definitions |
games/warcraft-3/game/g_metadata.c |
Unit metadata lookups from SLK tables |
games/warcraft-3/game/g_spawn.c |
Unit spawning using SLK data |