Total Annihilation unit animations are written in BOS (a small C-like
language) and shipped compiled as .COB bytecode. The .bos sources we
have are only the main files β the animation libraries they #include from
anim3d\<unit>\ were never shipped, because the compiler inlines includes at
compile time and the game only needs the bytecode.
That means sequences like the Annihilator’s box-opening animation are invisible in the shipped text files, but fully present in the bytecode. This page documents the container format and the two number encodings needed to read it back, and ends with the recovered Annihilator sequence.
COB container layout
The header is 11 little-endian DWORDs (TA version signature 4; TA: Kingdoms
uses 6 plus an extra sub-header and a sound-name table):
| # | Field | Meaning |
|---|---|---|
| 0 | versionSignature | 4 = TA, 6 = TA:K |
| 1 | numScripts | number of script names |
| 2 | numPieces | number of piece names |
| 3 | lengthOfScripts | code size in DWORDs |
| 4 | numberOfStaticVars | global variables |
| 5 | reserved | always 0 |
| 6 | offsetToScriptCodeIndexArray | index array |
| 7 | offsetToScriptNameOffsetArray | absolute byte offsets |
| 8 | offsetToPieceNameOffsetArray | absolute byte offsets |
| 9 | offsetToScriptCode | start of the code section |
| 10 | offsetToNameArray | start of the trailing string pool |
Both name arrays are arrays of DWORD absolute file offsets pointing at
NUL-terminated strings β so you can read the script and piece names without
decoding a single instruction. For ARMANNI.COB that yields 18 scripts and 20
pieces, including the two scripts we cannot see in the text sources:
activatescr, deactivatescr, SmokeUnit, Go, Stop, InitState, RequestState,
Create, Activate, Deactivate, SetMaxReloadTime, RestoreAfterDelay, AimPrimary,
TargetCleared, FirePrimary, QueryPrimary, SweetSpot, Killed
activatescr and deactivatescr are exactly the missing animation libraries β
Go() and Stop() call them:
Go() { dont-cache <19 pieces>; call-script activatescr(); spin radar ... ; }
Stop() { active = FALSE; stop-spin radar; turn radar to x-axis <0> ...;
call-script deactivatescr(); cache <19 pieces>; }
Movement opcodes
Each movement instruction pushes its operands as constants, then emits the
opcode followed by two post-data DWORDs (piece index, axis index β axis
0=x, 1=y, 2=z):
| Opcode | Instruction | Operands on stack |
|---|---|---|
0x10001000 | move (animated) | speed, distance |
0x10002000 | turn (animated) | speed, angle |
0x10003000 | spin | speed |
0x1000B000 | move-now | distance |
0x1000C000 | turn-now | angle |
0x10011000 / 0x10012000 | wait-for-turn / wait-for-move | β |
Constants are pushed with 0x10021001, immediately followed by the value.
Because the pair speed, target always sits directly in front of the opcode,
a movement sequence can be extracted with a linear scan β no full interpreter
required.
The two number formats
Angles use binary angle measurement (BAM), not floats:
raw = degrees * 65536 / 360 degrees = raw * 360 / 65536
Verified: ARMANNI.BOS contains spin radar around x-axis speed <100> and the
bytecode holds 18204 β and 18204 * 360 / 65536 = 100.000Β° exactly.
Distances are in elmos, scaled by 163840:
raw = elmos * 163840 3DO units = raw / 5
Verified: ARMARAD.BOS has move post to y-axis [9.1] speed [16.134753], and
the bytecode holds 1490944 for the distance β 1490944 / 9.1 = 163840 exactly.
Since one elmo is 32768 3DO units, a COB distance divides by 5 to reach the
same units the model files use.
Note the asymmetry: because turn/move push both operands, turn-now and
move-now push only the target β a 0 there means “back to the rest pose”.
Recovered: the Annihilator activation sequence
activatescr (ARMANNI.COB, offsets @24β@1052) β opening, all pieces animate
in parallel, total duration β 0.52 s:
| Piece | Motion | Target | Speed |
|---|---|---|---|
| case5 / case6 | move y | β1196032 / β1204224 | β14 elmo/s |
| case1 / case4 | turn z | +89.39Β° | β172Β°/s |
| case2 / case3 | turn z | β90.00Β° | β173Β°/s |
| plate1 | move y | +1032188 | β12 elmo/s |
| arm | move y | +918192 | β11 elmo/s |
| container1 / container2 | turn z | +90.60Β° / β90.00Β° | β174Β°/s |
| container1 / container2 | move x | +336960 / β312300 | |
| door1 | turn x / move z | +87.57Β° / β270720 | |
| door2 | turn z / move x | +91.82Β° / +262800 | |
| plate2 | move x | β386100 | |
| barrel | move z | +1590048 | |
| energy | move z | +532800 | |
| radar | turn x | +93.04Β° | β179Β°/s |
deactivatescr (@1532β@2512) is the exact mirror image: every piece returns
to zero, in reverse order. And InitState() (@1124β@1484) is the instant
version of the open pose β a row of move-now/turn-now statements using the
same targets, which is how the engine restores state without animating.
So the box does not merely “fold open”: six walls hinge 90Β° about their bottom edge, both side containers swing and slide, two doors open, the barrel and energy drums extend, and the turret assembly rises β while the radar dish, already tilted 93Β°, starts spinning at exactly 100Β°/s.
Why this matters for the archive
Reading bytecode means the archive can display the original animations for
units whose source libraries were never shipped, instead of approximations. The
same technique applies to every other unit: walk, open, close and
activation scripts are all in the .cob files, and the name tables tell you
which pieces they drive before you decode anything.
A companion tool counts and locates every file across all installed archives
(10,560 files from 296 .hpi/.gp3/.ufo volumes), so a format’s inputs can be
found without re-scanning the installation each time.