TorqueScript — V12 Compiler
Every .cs file the engine loads gets compiled to .cs.dso, and the compiled form is preferred on every
later load (Packaging). This page documents that compiled
format and what compiling to it actually does — the .cs → .cs.dso direction. For reading a .dso back
into readable TorqueScript, see TorqueScript — V12 Decompiler, which builds
directly on the format documented here.
Evidence basis
A note on evidence, unusual for this handbook. Every other page cites Tribes2.exe disassembly
([binary]) or shipped TorqueScript ([script]). This page’s claims come from a different, empirical
method: client analysis of V12, build 25034 — a from-scratch parser and disassembler for .cs.dso, built in
this project and cross-checked against every real compiled file this project could gather from vanilla
Tribes 2, its community patches, and its documented mods. That parser has now been run, cleanly, against
2,589 real .cs.dso files: every one parses to a valid end-of-file with zero structural warnings, and
every claim below that can be checked against a real .cs source pair (dozens of them, across several mods
in this handbook) has been checked. This tier is marked [dso-verified] — confirmed by successfully
parsing and reconstructing real compiled files, not by reading anyone’s source tree. It is a different kind
of evidence than [binary] disassembly, and where the two might conflict, this handbook trusts what
2,589 real files actually contain over any single secondary account of the format.
The client-side decompiler tool documented on the Decompiler page is this parser — every fact on this page is exactly what it takes to make that tool work correctly against real files, no more and no less.
The file layout
A .dso is five sections in a fixed order, no section headers, nothing self-describing beyond sizes and
counts [dso-verified]:
u32 version
u32 globalStringTableSize (bytes) ; u8[] globalStrings // NUL-terminated entries
u32 globalFloatCount ; f64[] globalFloats
u32 functionStringTableSize (bytes); u8[] functionStrings
u32 functionFloatCount ; f64[] functionFloats
u32 codeSize (logical words)
u32 lineBreakPairCount
<code stream> // byte-packed — see below
u32[2 × lineBreakPairCount] // raw u32 pairs, NOT byte-packed
<ident table> // see below
Two string tables and two float tables exist because a script’s file-scope code and its function bodies
draw from separate pools — which pool is active is decided by whether the code currently executing sits
inside a function body or not, tracked structurally by this project’s parser via each FUNC_DECL’s own
endIp operand, and confirmed correct by every real file’s literals resolving to sensible, printable text
rather than out-of-range garbage.
Two version constants, one build in scope
Every sample .dso compiled for build 25034 — 2,375 of the 2,589 files surveyed — opens with AE 00 00 00
little-endian at byte 0: 174. A separate cohort of 214 files, all from a distribution self-labelled
Grunts_V24834, instead carries 140 (8C 00 00 00). Build 24834 predates build 25034 — Tribes 2’s own
in-game version reporting numbers builds this way — so this reads as the compiler’s version constant simply
increasing once between those two builds, the same way file formats routinely gain a version bump between
releases.
This handbook covers build 25034 exclusively, so treat 174 as the operative value. A .dso carrying 140
was compiled by an earlier build’s compiler; a decompiler written against 174’s format has, empirically,
had no trouble reading it too — the version bump did not change the structural layout, only the version
field’s own value.
The code stream is not a flat array
u32 codeSize is a count of logical code words, not bytes. On disk, each logical word is packed as
one byte if its value is under 0xFF, or 0xFF followed by a full little-endian u32 if not
[dso-verified]. Reading the stream as a flat u32[codeSize] array — the natural first guess — misparses
almost every real file, since opcodes themselves are small numbers (0–83) and pack to one byte the
overwhelming majority of the time; only large literal operands and far jump targets are likely to trigger
the escape. This project’s parser applies the packed reading uniformly and lands exactly on the ident
table’s own start byte in all 2,589 files — the strongest practical confirmation available that this rule
is right and complete.
String and float tables: two different reference mechanisms
Operands that point into a table use one of two schemes depending on what they point at, and a decompiler has to know which is which per opcode [dso-verified]:
| Table | Operand is | Example opcode |
|---|---|---|
| Float table | An index | LOADIMMED_FLT reads curFloatTable[code[ip]] |
| String table | A byte offset | LOADIMMED_STR reads curStringTable + code[ip] |
Neither mechanism touches identifiers — variable names, field names, function and namespace names. Those go through a third, entirely different path.
The ident table: every identifier is a patch, not a literal
This is the single most important structural fact for reading a .dso by hand. At compile time, every
identifier operand — a %local or $global name, a field name, a function or namespace or package name, a
datablock’s parent name in CREATE_OBJECT — is written into the code stream as a literal zero, and its
real value is recorded separately as (byteOffset, instruction-pointer[]) pairs, trailing the code stream
and line-break pairs on disk:
u32 identCount
per entry:
u32 globalStringByteOffset // always into the GLOBAL string table, even for function-local identifiers
u32 ipCount
u32[ipCount] ips // every code-stream position needing this identifier patched in
At load time, for every recorded ip, that code-stream word is replaced with the interned string found at
globalStrings + byteOffset. Three consequences worth internalising, all directly confirmed by decompiling
real files:
- Every identifier resolves through the global string table, regardless of whether the identifier itself is function-local — the function/global table split described above only governs string and float literals, not identifiers.
- Local variable names are fully recoverable, not approximated. Decompiling a
%localname from a real file’s ident table produces the actual name the author wrote (%c,%deleted,%make, and so on across dozens of tested functions) — there is no separate, lossier path for locals the way some later engines’ release builds strip them to%argN. - A zero-length name is valid, and appears in real files: one function argument encountered in this
project’s corpus has an ident-table entry whose byte offset points exactly to the end of the string
table — a legitimate empty string, not corruption. A parser that rejects
offset === tableLengthas out-of-range will wrongly flag this as a bad file.
Case is not always what the source wrote
String interning is case-insensitive, first-case-wins: whichever spelling of a name is interned first,
anywhere in the file, fixes the case every later reference decompiles to — regardless of how that specific
occurrence was spelled in the original source. This is directly observable, not just a theoretical rule:
decompiling a real Construction-family script whose source consistently writes %Deleted (capital D) at
every occurrence recovers %deleted (lowercase) throughout; decompiling Editor::create() against its own
paired source — which consistently writes Editor and create — recovers editor::Create instead,
lowercase namespace and capitalised method, neither matching the source’s own casing at that specific
usage. The interning order that produces this is a whole-file, cross-occurrence phenomenon — not simply
“first line wins” in source order — so a decompiled identifier’s exact casing should be treated as
approximate, even though the identifier itself is exact.
The complete opcode set
All 84 values, 0 through 83. The Seen column is this project’s own corpus count — how many times each opcode appears across the 2,589 real files surveyed — not a citation to anyone else’s source. An opcode seen thousands of times, in files whose behavior this project has independently confirmed by decompiling them against known paired source, is about as empirically confirmed as a fact can be without disassembling the interpreter loop itself.
| # | Opcode | Operands | Effect | Seen |
|---|---|---|---|---|
| 0 | FUNC_DECL |
name, namespace, package (STE); hasBody, endIp, argc:u32; argNames[argc] (STE) | Registers the function; jumps past the body on linear execution | 39,394 |
| 1 | CREATE_OBJECT |
parentName (STE); isDatablock, failJump:u32 | Instantiates from the pending class/name/args frame | 24,054 |
| 2 | CREATE_DATABLOCK |
— | Never observed — see “Eight dead opcodes” below | 0 |
| 3 | NAME_OBJECT |
— | Never observed | 0 |
| 4 | ADD_OBJECT |
root:u8 | Registers the object into its group | 24,054 |
| 5 | END_OBJECT |
root:u8 | Closes the object scope | 24,054 |
| 6 | JMPIFFNOT |
target | Pop float, jump if false | 20,198 |
| 7 | JMPIFNOT |
target | Pop int, jump if false | 80,701 |
| 8 | JMPIFF |
target | Pop float, jump if true | 223 |
| 9 | JMPIF |
target | Pop int, jump if true | 9,615 |
| 10 | JMPIFNOT_NP |
target | Non-popping; this is && |
12,385 |
| 11 | JMPIF_NP |
target | Non-popping; this is \|\| |
8,954 |
| 12 | JMP |
target | Unconditional jump | 39,139 |
| 13 | RETURN |
— | Ends execution; return value is the current STR working string | 61,097 |
| 14 | CMPEQ |
— | Pop 2 floats, push int == |
16,764 |
| 15 | CMPGR |
— | Pop 2 floats, push int > |
9,918 |
| 16 | CMPGE |
— | Pop 2 floats, push int >= |
1,907 |
| 17 | CMPLT |
— | Pop 2 floats, push int < |
18,450 |
| 18 | CMPLE |
— | Pop 2 floats, push int <= |
3,667 |
| 19 | CMPNE |
— | Pop 2 floats, push int != |
6,481 |
| 20 | XOR |
— | Pop 2 ints, push ^ |
1 |
| 21 | MOD |
— | Pop 2 ints, push % |
74 |
| 22 | BITAND |
— | Pop 2 ints, push & |
1,275 |
| 23 | BITOR |
— | Pop 2 ints, push \| |
2,611 |
| 24 | NOT |
— | ! in place, int stack |
15,634 |
| 25 | NOTF |
— | Pop float, push int !float |
5,057 |
| 26 | ONESCOMPLEMENT |
— | ~ in place, int stack |
162 |
| 27 | SHR |
— | Pop 2 ints, push >> |
21 |
| 28 | SHL |
— | Pop 2 ints, push << |
244 |
| 29 | AND |
— | Never observed — see below | 0 |
| 30 | OR |
— | Never observed | 0 |
| 31 | ADD |
— | Pop 2 floats, push + |
25,745 |
| 32 | SUB |
— | Pop 2 floats, push - |
8,325 |
| 33 | MUL |
— | Pop 2 floats, push * |
8,877 |
| 34 | DIV |
— | Pop 2 floats, push / |
4,681 |
| 35 | NEG |
— | Unary - in place, float stack |
11,348 |
| 36 | SETCURVAR |
varName (STE) | Set variable cursor — lookup only, no create | 518,218 |
| 37 | SETCURVAR_CREATE |
varName (STE) | Set variable cursor, creating if missing | 125,912 |
| 38 | SETCURVAR_ARRAY |
— | Variable name comes from the current STR (array-subscript form) | 17,414 |
| 39 | SETCURVAR_ARRAY_CREATE |
— | Same, creating if missing | 34,575 |
| 40 | LOADVAR_UINT |
— | Push current variable as int | 11,490 |
| 41 | LOADVAR_FLT |
— | Push current variable as float | 96,666 |
| 42 | LOADVAR_STR |
— | Push current variable as string | 444,637 |
| 43 | SAVEVAR_UINT |
— | Write variable from int stack top | 34,113 |
| 44 | SAVEVAR_FLT |
— | Write from float | 25,808 |
| 45 | SAVEVAR_STR |
— | Write from STR | 100,566 |
| 46 | SETCUROBJECT |
— | Cursor = Sim::findObject(STR) |
154,321 |
| 47 | SETCUROBJECT_NEW |
— | Cursor = the object just created | 263,783 |
| 48 | SETCURFIELD |
fieldName (STE) | Set field cursor, resets the field-array buffer | 418,104 |
| 49 | SETCURFIELD_ARRAY |
— | Field-array subscript comes from STR | 82,212 |
| 50 | LOADFIELD_UINT |
— | Read current field as int | 5,302 |
| 51 | LOADFIELD_FLT |
— | Read current field as float | 27,534 |
| 52 | LOADFIELD_STR |
— | Read current field as string | 77,883 |
| 53 | SAVEFIELD_UINT |
— | Never observed — field writes always go through _FLT or _STR |
0 |
| 54 | SAVEFIELD_FLT |
— | Write field from float | 2,897 |
| 55 | SAVEFIELD_STR |
— | Write field from string | 307,385 |
| 56 | STR_TO_UINT |
— | Convert STR → int, push | 4,411 |
| 57 | STR_TO_FLT |
— | Convert STR → float, push | 33,895 |
| 58 | STR_TO_NONE |
— | Discard STR (result used as a statement) | 559,297 |
| 59 | FLT_TO_UINT |
— | Pop float, push int (truncate) | 39 |
| 60 | FLT_TO_STR |
— | Pop float, set STR | 21,346 |
| 61 | FLT_TO_NONE |
— | Pop float, discard | 25,592 |
| 62 | UINT_TO_FLT |
— | Pop int, push float | 114 |
| 63 | UINT_TO_STR |
— | Pop int, set STR | 3,499 |
| 64 | UINT_TO_NONE |
— | Pop int, discard | 51,989 |
| 65 | LOADIMMED_UINT |
value:u32, inline | Push literal int | 52,834 |
| 66 | LOADIMMED_FLT |
float table index | Push float from table | 76,337 |
| 67 | TAG_TO_STR |
string table offset | Tagged-string literal (single-quoted in source) | 42,378 |
| 68 | LOADIMMED_STR |
string table offset | Push string literal (double-quoted in source) | 591,208 |
| 69 | LOADIMMED_IDENT |
STE, ident-patched | Push a bareword’s text as a string | 248,242 |
| 70 | CALLFUNC_RESOLVE |
fnName, fnNamespace (STE); callType:u32 | Plain function call — see “Calls compile like object construction” | 155,905 |
| 71 | CALLFUNC |
fnName (STE); operand; callType:u32 | Method/parent call, or a resolved plain call — see below | 127,953 |
| 72 | PROCESS_ARGS |
— | Never observed | 0 |
| 73 | ADVANCE_STR |
— | Commit the pending value and start a new segment — the @ operator |
502,576 |
| 74 | ADVANCE_STR_APPENDCHAR |
char literal | Commit, then append one literal char — SPC=0x20, TAB=0x09, NL=0x0A |
12,260 |
| 75 | ADVANCE_STR_COMMA |
— | Commit, then append an underscore, not a comma — the name is misleading | 28,253 |
| 76 | ADVANCE_STR_NUL |
— | Commit, then append a NUL byte | 35,649 |
| 77 | REWIND_STR |
— | Bracket around an array-subscript sub-expression (§ below) | 127,033 |
| 78 | TERMINATE_REWIND_STR |
— | Closes the REWIND_STR bracket |
416,056 |
| 79 | COMPARE_STR |
— | Push int result of $= / !$= on the last two built values |
35,649 |
| 80 | PUSH |
— | Commit the current value as one call argument | 615,510 |
| 81 | PUSH_FRAME |
— | Open a new call-argument frame — covers one whole call | 307,912 |
| 82 | BREAK |
— | Never observed — a debugger breakpoint hook, not compiler output | 0 |
| 83 | INVALID |
— | Never observed — the dispatch table’s own poison value | 0 |
One entry deserves a second look if you’re writing your own decoder: 75, ADVANCE_STR_COMMA, appends
'_', not the , its name implies — every real occurrence across the corpus confirms this, never a comma.
Eight dead opcodes
2, 3, 29, 30, 53, 72, 82, and 83 never appear in any of the 2,589 real files surveyed. For 82
(BREAK) and 83 (INVALID), that’s expected by design — a debugger breakpoint hook and a dispatch
table’s own poison/terminator value are never things a compiler would emit. The other six are more
interesting:
- 29 (
AND) and 30 (OR) are provably dead by construction, not just by absence:&&and||compile to jump sequences instead (see “Short-circuit&&and\|\|” below), and that is the only source construct that could plausibly reach these two opcodes. - 2 (
CREATE_DATABLOCK) and 3 (NAME_OBJECT) sit numerically right next toCREATE_OBJECT(1) in the enum, suggesting an earlier or alternate object-construction path that real build-25034 output never takes —CREATE_OBJECTalone handles both plain objects and datablocks in every sampled file. - 53 (
SAVEFIELD_UINT) is the most interesting absence: its read counterpart,LOADFIELD_UINT(50), is used (5,302 times) — but every field write in the corpus goes throughSAVEFIELD_FLTorSAVEFIELD_STRinstead, never the UINT form. Fields read as integers sometimes; they’re never written as one directly. - 72 (
PROCESS_ARGS) has no confirmed purpose in this project’s evidence — it simply never appears.
If you see one of these eight in a real file, something unusual produced it — a hand-crafted .dso, a
different compiler build, or a corrupt capture.
Pre-resolution is the only form a file on disk ever shows
67 (TAG_TO_STR) and 70 (CALLFUNC_RESOLVE) are named for what they resolve to on first execution —
a tagged string and a cached function-call target, respectively — which strongly suggests they rewrite
themselves in memory once run [inferred], a plausible optimisation this project has not independently
confirmed by watching the interpreter run. What is directly confirmed, from every one of 2,589 files: a
.dso read straight off disk only ever shows the pre-resolution opcodes (TAG_TO_STR, CALLFUNC_RESOLVE)
— never a hypothetical already-resolved form. That is exactly what you want as a decompiler author, since a
resolved-call form would depend on runtime namespace state a static reader has no access to anyway.
Binary operators evaluate right-to-left
Comparing Canvas.getContent() == EditorGui.getId() against its own compiled form shows getId(EditorGui)
computed and pushed first, getContent(Canvas) computed and pushed second — the reverse of source
reading order. This holds generally: for ADD/SUB/MUL/DIV, the bitwise/shift ops, and all six
comparisons, the right-hand operand is evaluated and pushed before the left-hand operand, which then
sits on top of the stack. It’s invisible for commutative operators (+, *, ==, &, \|, ^) — the
order doesn’t change the result — but it matters for a decompiler rendering -, /, %, <<, >>, or an
ordered comparison correctly: the second value popped is the left operand, not the first.
Short-circuit && and \|\|
a && b and a \|\| b compile to jump sequences, confirmed against real conditionals across the corpus
(e.g. isObject(EditorGui) && EditorGui.loadingMission):
a && b → <a>; JMPIFNOT_NP end; <b>; end:
a || b → <a>; JMPIF_NP end; <b>; end:
Both use the non-popping conditional jumps (10, 11): if a alone determines the result, its value is
left on the stack as the expression’s result and b is skipped; otherwise a’s value is consumed by
falling into b, which becomes the result instead. The combined value only exists once control reaches the
shared jump target — a decompiler has to defer rendering a && b until whatever instruction sits at that
target (almost always the next conditional jump) actually consumes it.
Control-flow shapes
TorqueScript’s structured control flow becomes flat jumps at compile time, in shapes confirmed against real
if/else blocks across the corpus:
If / else:
<test>; JMPIFNOT/JMPIFFNOT elseOrEnd; <ifBlock>; [JMP end; <elseBlock>]
An int-typed test emits JMPIFNOT; a float-typed test emits JMPIFFNOT — which variant appears tells you
the test expression’s inferred type.
While / for: a leading test with a conditional jump to the loop’s exit, the body, a continue-point, the
loop’s increment/step expression if any, then a second test jumping back to the body start. for and
while compile to the identical shape — TorqueScript’s for(init; test; step) desugars entirely at
compile time, so nothing in the bytecode itself distinguishes a for loop from a hand-written while with
the init hoisted above it and the step placed at the bottom. This is a genuine, provable loss: two
different source forms produce byte-identical bytecode.
Ternary:
<test>; JMPIFNOT falseStart; <trueExpr>; JMP end; falseStart: <falseExpr>; end:
Structurally identical to if/else with both arms present — the two are only distinguishable because if/else appears as a statement and ternary appears as an expression feeding into something else.
Calls and object construction share one convention
A function call and a new Object() { } block both build their arguments the same way: PUSH_FRAME opens
a frame, each argument is built and finalised with PUSH, and the frame closes only when something
consumes it — CALLFUNC/CALLFUNC_RESOLVE for a call, CREATE_OBJECT for a construction. For
CREATE_OBJECT specifically, the first committed argument is the class name and the second is the
object’s name; any further arguments are positional constructor arguments. CREATE_OBJECT’s own
word-operand is unrelated — it is only used for a new Class(Name) : Parent inheritance clause, and is
blank in the far more common case without one.
A call’s callType operand distinguishes plain calls from method calls. A plain call (exec("x.cs"),
isObject(x)) resolves through CALLFUNC_RESOLVE with callType = 0 and an unpatched namespace operand.
An object-method call (Editor.close()) instead pushes the target object as its first argument and
sets a nonzero callType — the object argument is the receiver, not a normal parameter, which is why
Editor.close() and close(Editor) are the same bytecode shape distinguished only by that flag.
Array-subscript assignment reuses the same buffer
$var[i] = value; builds value first, then the array key (LOADIMMED_IDENT for the base name, followed
by the index expression), then brackets that key-building with REWIND_STR / TERMINATE_REWIND_STR before
the actual variable-cursor opcode (SETCURVAR_ARRAY/_CREATE) consumes it — leaving value sitting
untouched, ready for the SAVEVAR_* that follows. The LOADIMMED_IDENT immediately before the key is the
reliable marker: it never appears anywhere except to spell out an array access’s base name, so it’s the
split point between “the value already built for this statement” and “the subscript this array access is
about to consume.”
Related
- Packaging — when and why
.dsofiles are generated, and the staleness trap - File formats —
.dsoin context with the rest of the shipped formats - TorqueScript — the language this bytecode implements; read this first if you haven’t
- TorqueScript — V12 Decompiler — reading this format back to
.cs, including a client-side tool - Debugging —
setEchoFileLoads, and diagnosing stale-.dsosymptoms