COB / BOS — Unit Scripting Bytecode
Every unit in Total Annihilation runs a script that animates its pieces, emits weapon flashes, plays sound effects, and responds to game events. The source language is BOS ("Basic Object Script"); the compiled bytecode the engine actually runs is COB ("Cobble" — Cavedog's pun for "compiled BOS"). One file per unit, named after the unit's
ObjectNamefrom its FBI.
Tip
Try it yourself.
# Decompile a unit's bytecode back to readable BOS
kbot cob decompile scripts/ARMCOM.cob
# Disassemble to opcode-level assembly with control-flow arrows
kbot cob disassemble scripts/ARMCOM.cob -a
# Lint a whole directory for common script issues
kbot cob lint scripts/ -q
# Round-trip the entire scripts/ tree to verify byte-perfect compile
kbot cob roundtrip scripts/
See the CLI kbot cob reference
for every flag (and the full lint-rules table).
From Go. Use kbot-io's formats/scripting:
import "github.com/coreprime/kbot-io/formats/scripting"
cob, _ := scripting.LoadFromFile("scripts/ARMCOM.cob")
fmt.Printf("%d scripts, %d pieces\n", cob.NumScripts, cob.NumPieces)
for i, name := range cob.ScriptNames {
fmt.Printf(" [%d] %s\n", i, name)
}
Two languages, one file format
| Stage | Format | Tooling |
|---|---|---|
| Source | BOS — C-like, structured | Hand-written; Switeck's Scriptor; kbot cob decompile round-trips from COB |
| Compiled | COB — stack-based bytecode | Loaded by the engine; produced by Scriptor or kbot cob compile |
The engine only ever sees COB. BOS is a convenience for human authors;
when you "edit a unit's script" you're really editing the BOS source,
recompiling to COB, and shipping the COB in the unit's add-on .ufo.
A minimal example:
// armcom.bos — fragment from the Arm Commander
piece base, pelvis, lthigh, lleg;
#define SIG_WALK 2
Create() {
hide pelvis;
set-signal-mask SIG_WALK;
}
Walk() {
set-signal-mask SIG_WALK;
while (1) {
turn lthigh to x-axis [-25] speed [50];
turn lleg to x-axis [50] speed [100];
wait-for-turn lthigh around x-axis;
sleep 100;
}
}
kbot cob compile lowers this into a binary blob. kbot cob disassemble -a shows the opcode stream side-by-side with control-flow
arrows; kbot cob decompile does the reverse, recovering BOS that
almost always round-trips byte-identical (kbot cob roundtrip
validates this across the entire game catalogue).
A source compiles as a TA script (COB version 4) unless it starts
with .version 6. The compiler refuses what TA 3.1c would mis-run
rather than writing a placeholder: the TA: Kingdoms constructs
(play-sound, Mission-Command, __tak_math_*, .sound_name) and
% are errors in a TA script (TA has no modulo instruction, and
0x10037000, the word % used to become, is its bitwise XOR), as are
unknown identifiers and functions that need more than the game's 32
stack slots. What still compiles but deserves a look — a function
defined twice (every call binds to the first definition, as the
game's name lookup does) — is printed as a warning, together with any
finding of the TA compatibility lint rules; --strict
makes warnings fail the command. kbot cob assemble applies the same
checks to hand-written listings.
COB on-disk layout
Important
Two dialects. This section describes COB v4, the dialect
Total Annihilation ships. TA: Kingdoms uses COB v6
(VersionSignature == 6), which keeps everything below but extends
the header by 8 bytes and inserts an additional command-string
offset table after the piece-name array. See TA: Kingdoms — COB v6
deltas for the v6 specifics; the
opcode list applies to both with the additional TAK-only opcodes
documented in Appendix C.
typedef struct { // 44-byte header (11 × uint32)
uint32 VersionSignature; // 4 for TA, 6 for TA: Kingdoms
uint32 NumScripts; // Number of functions (Create, Killed, …)
uint32 NumPieces; // Number of named pieces
uint32 LengthOfScripts; // Total code size, in DWORDs
uint32 NumberOfStaticVars; // Static-variable count
uint32 UKZero; // Always 0 in retail bytecode; purpose unknown
uint32 OffsetToScriptCodeIndexArray; // → uint32 × NumScripts: code-index per script
uint32 OffsetToScriptNameOffsetArray; // → uint32 × NumScripts: absolute offsets into string pool
uint32 OffsetToPieceNameOffsetArray; // → uint32 × NumPieces: absolute offsets into string pool
uint32 OffsetToScriptCode; // → packed uint32 opcode stream
uint32 OffsetToNameArray; // Byte just past the piece-name offset array (= sound-name offset table start for TAK v6, or string-pool start for TA v4)
} COBHeader;
Note
The canonical field names match Scriptor's HEADER_COB struct. Earlier
kbot revisions called LengthOfScripts / NumberOfStaticVars /
UKZero / OffsetToNameArray "Unknown0/1/2/3" — same offsets, just
with the documented identities now applied.
Then follow, in this layout:
┌─ Script code ─────────────────────────────┐ uint32 opcodes/operands, packed
├─ Script code-index table ─────────────────┤ uint32[NumScripts] — *not* byte offsets
├─ Script name offset table ────────────────┤ uint32[NumScripts]
├─ Piece name offset table ─────────────────┤ uint32[NumPieces]
└─ String pool ─────────────────────────────┘ NUL-terminated names, in order
Resolving a script
The code-index table contains uint32 word offsets, not byte
offsets. To find the bytecode for script number i:
uint32 first_instr_word = ScriptCodeIndexArray[i];
uint32 byte_offset = OffsetToScriptCode + first_instr_word * 4;
The script ends where the next script begins (or at the start of the index table, for the last script).
Important
String offsets in the name tables are absolute file offsets, not string-pool-relative. Cavedog's tooling never moved the string pool around so this didn't matter in practice; if you reorganise a COB by hand, every offset has to be updated.
The opcode stream
COB is a stack-based virtual machine. Most opcodes pop arguments from a per-script data stack, do their thing, and push a result back. Some opcodes have inline operands (jump targets, piece numbers).
Each opcode is one uint32 with the layout:
MSB LSB
┌─────────┬─────────┬─────────┬─────────┐
│ 0x10 │ family │ op │ flags │
└─────────┴─────────┴─────────┴─────────┘
The high byte is always 0x10; family/op/flags vary per opcode. The
game does not compare the whole word: it dispatches on
word & 0x100FF000, so bits outside that mask are ignored and
0x10064001 runs as JUMP (kbot's disassembler writes such a word
JUMP@0x10064001 so it assembles back unchanged). PUSH (0x10021000)
and POP (0x10023000) then read their source or destination from
the low three bits. kbot-io's
scripting/opcodes.go is the catalogue;
scripting.Opcodes() lists every instruction with its inline operands
and stack effect.
Opcode families
| Family | Examples | Notes |
|---|---|---|
0x100x |
MOVE, TURN, SPIN, SHOW, HIDE, EMIT_SFX |
Piece animation. Piece# and axis# follow inline. |
0x101x |
WAIT_FOR_TURN, WAIT_FOR_MOVE, SLEEP |
Async barriers — the script yields until the condition is met. |
0x102x |
PUSH_CONST, PUSH_LOCAL, PUSH_STATIC, POP_* |
Stack manipulation. |
0x103x |
ADD, SUB, MUL, DIV, BITWISE_AND, BITWISE_OR, XOR, NOT |
Arithmetic & bitwise. There is no modulo. |
0x104x |
RAND, GET_UNIT_VALUE, GET, IS_CARRYING_UNIT, CARRIER_UNIT_ID |
Engine queries. |
0x105x |
<, <=, >, >=, ==, !=, &&, ||, XOR_ALT, ! |
Comparisons & logical ops. XOR_ALT is a bitwise XOR, not a logical one. |
0x106x |
START_SCRIPT, CALL_SCRIPT, DISCARD_CALL, JUMP, RETURN, JUMP_IF_FALSE, SIGNAL, SET_SIGNAL_MASK |
Control flow and concurrency. |
0x107x |
EXPLODE, PLAY_SOUND |
Special effects. PLAY_SOUND is TA: Kingdoms only. |
0x108x |
SET_VALUE, ATTACH_UNIT, DROP_UNIT |
Engine mutations. |
A small but representative sampler:
0x10021001 PUSH_CONST <value> ( -- v )
0x10021002 PUSH_LOCAL <var#> ( -- v )
0x10023002 POP_LOCAL <var#> ( v -- )
0x10033000 MUL ( a b -- a*b )
0x10051000 LESS_THAN ( a b -- a<b )
0x10066000 JUMP_IF_FALSE <byteOffset> ( v -- ) pops; falls through if v != 0
0x10064000 JUMP <byteOffset> ( -- )
0x10065000 RETURN ( v -- ) pops the return value
0x10001000 MOVE <piece> <axis> ( speed distance -- )
0x10003000 SPIN <piece> <axis> ( speed -- )
0x1000F000 EMIT_SFX <piece> ( flags -- )
0x10068000 SET_SIGNAL_MASK ( mask -- )
Note
Jump offsets are byte offsets relative to the start of the script
code section (not the current instruction). JUMP 0x4C means "jump
to OffsetToScriptCode + 0x4C". The disassembler's -a flag draws
arrows between matching instructions to make this obvious.
Concurrency model
A unit can have many scripts running concurrently — Walk, Aim, SmokeUnit, MoveWatcher, etc. They're cooperatively scheduled:
sleep <ticks>yields for N ticks (1/30 s each).wait-for-turn <piece> around <axis>yields until that piece finishes its currentturnanimation. Same forwait-for-move.signal <n>kills every script whosesignal_mask & n != 0.set-signal-mask <n>declares which signals the current script is interested in.
The signal mask is how scripts implement preemption: MoveWatcher sets
mask SIG_MOVE and watches velocity; when the unit stops moving it
sends SIG_MOVE, killing the walk animations cleanly before starting
the idle pose.
Warning
SET_SIGNAL_MASK only registers interest — it does not kill
anything. Sending signal X is what stops scripts with mask X set.
The signal-never-signalled lint rule in kbot cob lint catches
scripts that set a mask nobody ever signals (which is almost always
a bug).
How preemption actually plays out
A common pattern: MoveUnit triggers a Walk animation script; when
movement stops the engine sends SIG_MOVE to clean up cleanly before
Idle starts. The sequence:
Engine MoveUnit script Walk script Idle script
│ │ │ │
│ unit ordered to move │ │ │
├─ start-script MoveUnit──▶│ │ │
│ ├─ set-signal-mask │ │
│ │ SIG_MOVE │ │
│ ├─ start-script Walk─▶│ │
│ │ ├─ set-signal-mask │
│ │ │ SIG_MOVE │
│ │ │ (loops: │
│ │ │ turn pieces, │
│ │ │ sleep, repeat) │
│ │ │ │
│ movement completes │ │ │
├─ engine sets velocity=0 │ │ │
│ ├─ signal SIG_MOVE ──┐│ │
│ │ ││ │
│ ◀─── KILLED ────────────────────────────── ◀┤ │
│ ◀─── KILLED ──────────┤ │ │
│ │
├─ start-script Idle ─────────────────────────────────────────────▶│
│ │ (loops)
Three things to notice:
- Both scripts that set
SIG_MOVEare killed when the signal fires — includingMoveUnititself (it doesn't matter; its work is done). - The signalling script (
MoveUnit) survives long enough to send the signal before being killed; the runtime checks masks after thesignalopcode returns. Idledoesn't setSIG_MOVE, so the nextsignal SIG_MOVEwon't touch it.
Worked example — cortruck.cob (Core Construction Truck)
kbot cob decompile scripts/cortruck.cob:
piece base;
SmokeUnit() {
var local_0, local_1, local_2;
while (get BUILD_PERCENT_LEFT) { sleep 400; }
while (1) {
local_0 = get HEALTH;
if (local_0 < 66) {
local_2 = (256 | 2);
if (rand(1, 66) < local_0) { local_2 = (256 | 1); }
emit-sfx local_2 from base;
}
local_1 = (local_0 * 50);
if (local_1 < 200) { local_1 = 200; }
sleep local_1;
}
return 0;
}
Create() { return 0; }
Killed() {
var severity;
if (severity <= 25) { explode base type (32 | 256); return 0; }
if (severity <= 50) { explode base type (32 | 256); return 0; }
if (severity <= 99) { explode base type (32 | 256); return 0; }
explode base type (32 | 256);
}
The corresponding disassembly (kbot cob disassemble -a -s SmokeUnit)
shows the actual opcode stream, with JUMP_IF_FALSE/JUMP arrows
threaded through the while/if bodies — useful when the decompiler
output is ambiguous.
Linting
kbot cob lint (and the asset explorer's Lint tab) runs kbot-io's
default rule set: style rules drawn from real-world bugs in Cavedog's
own scripts and from community-submitted mod patches, two file-level
checks, and — for every COB that does not declare version 6 — the TA
3.1c compatibility rules. The style catalogue:
| Rule | Severity | Catches |
|---|---|---|
unused-piece |
warning | Piece declared but never used in any script. |
unused-static / unused-local |
warning | Variable allocated but never read. |
always-true / dead-code |
info / warning | if(1) / while(0). |
long-function / high-complexity |
warning | > 100 instructions or > 15 cyclomatic complexity. |
invalid-call |
error | call-script to a non-existent function. |
speed-zero |
warning | move/turn with speed <0> — animation never completes. |
empty-function |
info | Function body is only return 0. |
duplicate-animation |
warning | Two identical animation commands back-to-back. |
sleep-only-guard |
info | if block contains nothing but sleep. |
duplicate-if |
info | Two ifs with the same condition in a row. |
raw-signal |
info | signal 4 instead of signal SIG_AIM. |
unnamed-global |
info | global_3 instead of a named static. |
signal-never-signalled |
warning | Script masks a signal nobody emits. |
recursive-call |
warning | call-script cycle. |
duplicate-function |
warning | Two scripts share a name; the game only calls the first. |
malformed-cob |
error / warning | The file does not load, or a script's code is truncated (error); name tables the reader tolerated (warning). |
The TA compatibility rules report what TA 3.1c faults on or runs
differently. They are errors, appear in --ci SARIF output like the
others, and skip TA: Kingdoms (version 6) files:
| Rule | Catches |
|---|---|
ta-kingdoms-opcode |
PLAY_SOUND, MISSION_COMMAND or a TAK_MATH_* operator in a TA script. TA has no handler for them and the script faults when it reaches one. |
ta-unknown-opcode |
An opcode word no game runs. |
ta-push-flags |
A PUSH whose low bits are not 1 (constant), 2 (local) or 4 (static), or a POP whose low bits are not 2 or 4 — including the flagless 0x10021000 and 0x10021008 some tools wrote. |
ta-stack-limit |
More than 32 STACK_ALLOCs, or locals plus pending values beyond the game's 32 stack slots; the game does not check and overwrites the memory after the script. |
ta-get-arguments |
A GET reached with fewer than five pending values. GET always pops a port and four arguments, so a one-argument get takes the rest from the function's locals and overwrites them. |
ta-stack-underflow |
Any other instruction that pops more values than are pending, or paths that join with different stack depths. |
ta-discard-call |
DISCARD_CALL with more than four arguments. |
The full rule descriptions are in the kbot README. The decompiler deliberately preserves the original variable and piece numbering so that a decompile→lint→fix→compile cycle highlights mods needing maintenance.
Gotchas
Warning
call-script is 0x10062000. 0x10063000 is a different
instruction, DISCARD_CALL: it has the same two inline words
(script number and argument count) but pops the arguments and runs
nothing. No retail script uses it; kbot's decompiler writes it as
__discard_call(word, args…).
Warning
Several published opcode tables name the bitwise words wrongly.
TA runs 0x10037000 as bitwise XOR (not modulo — TA has no
modulo instruction), 0x10038000 as the unary bitwise NOT (not
XOR), and 0x10059000 as a second bitwise XOR (not a logical XOR:
6 XOR_ALT 3 is 5). 0x1003A000 is a TA: Kingdoms operator that TA
faults on. Listings written with the old names (MOD,
BITWISE_XOR, BITWISE_NOT, LOGICAL_XOR) still assemble to the
words they were made from.
- All offsets in the COB header are absolute file offsets, but the
ScriptCodeIndexArrayentries are word offsets within the code section. Easy to confuse them. - Piece numbers in opcodes are indices into the piece name table, not strings. The COB carries the names as a string pool; the runtime uses indices for speed.
get UNIT_VALUEopcodes vary subtly between TA and TA: Kingdoms. TA: Kingdoms added value IDs for magic regen, command-points, etc.; TA's value table is more compact. kbot's decompiler currently labels the TA set; TAK port numbers ≥ 21 are emitted as numeric literals (which still round-trip through the assembler). See TA: Kingdoms — COB v6 deltas for the full delta.- Variables come in two flavours:
local_N(function-local stack slot) andglobal_N(per-unit static persisting across calls). The decompiler emitslocal_N/global_Nby default; rename them in source for readability. - The Cavedog Scriptor tool occasionally inlines
CURRENT_SPEEDas a literal29, breakingMoveWatcher-style scripts after a round trip. kbot's decompiler restores the symbolic name; if you must round-trip through Scriptor, parenthesise:(get CURRENT_SPEED) > 5.
Typical sizes
| Asset | Range observed in Cavedog scripts/*.cob |
|---|---|
| File size | 0.5 KB – 25 KB |
| Header overhead | always 44 bytes |
| Scripts per unit | 3 – 30 (typical units: 5–10) |
| Pieces per unit | 3 – 80 (Krogoth ≈ 80) |
| Code section size | 300 B – 20 KB (varies wildly with complexity) |
armcom.cob (commander) |
~14 KB, 30 scripts |
cortruck.cob (simple unit) |
~750 B, 3 scripts |
TA: Kingdoms — COB v6 deltas
Note
This section is the TA:K-only delta. Everything above describes COB v4 (TA). TA: Kingdoms keeps the same stack VM, the same string pool, the same opcode encoding scheme, and the same script-name / piece-name machinery; the differences are an extended header, an additional string table appended after the piece-name array, and four new opcodes.
v6 file layout
┌─ 44-byte v4 header ──────────────────────────────────┐
│ VersionSignature == 6, NumScripts, NumPieces, … │
├─ Sub-header (8 bytes, TAK-only) ─────────────────────┤ (soundNameOffsetTableStart, soundNameCount)
├─ Script code (uint32 opcode stream) ─────────────────┤ same encoding as v4
├─ ScriptCodeIndexArray (uint32 × NumScripts) ─────────┤ unchanged
├─ ScriptNameOffsetArray (uint32 × NumScripts) ────────┤ unchanged
├─ PieceNameOffsetArray (uint32 × NumPieces) ──────────┤ unchanged
├─ SoundNameOffsetArray (uint32 × Count) ──────────────┤ TAK-only — count from sub-header[1]
└─ String pool ────────────────────────────────────────┘ script names → piece names → sound names (in that order)
The 8-byte sub-header sits between OffsetToNameArray (end of the
canonical 11-DWORD header at byte 44) and the start of the code
section (OffsetToScriptCode, typically 0x34 = 52). It carries two
little-endian uint32s:
| Field | Meaning |
|---|---|
[0] |
Absolute file offset of the sound-name offset table. Always equal to OffsetToPieceNameOffsetArray + NumPieces × 4 — i.e. the byte just past the piece-name offset array. Redundant with the canonical layout; the writer reconstructs it. |
[1] |
Number of sound names in the table. Authoritative count. |
Each entry in the sound-name offset table is an absolute file
offset of a NUL-terminated string in the string pool — things like
"SetMission o 1, s", "create ARASWORD", "ARROW10". The
bytecode references them by index (not by offset) via the
MISSION_COMMAND opcode (see Appendix C).
The table is called the sound-name array because the canonical
Scriptor compiler registers strings into it via the ADDSOUND action;
the actual contents are engine command strings rather than audio
filenames.
kbot's representation
scripting.COB carries the
sound names as a structured []string field — no opaque-byte
preservation. Everything else (the sub-header values, the offset
table, the per-string offsets in the pool) is reconstructed from
this list on write:
type COB struct {
// …canonical TA fields…
SoundNames []string // TAK-only; nil for v4
}
For v4 files the slice is always nil and both the sub-header and sound-name offset table are omitted from the output.
The disassembler / decompiler emit each string as a .sound_name
directive so they survive a round-trip through .coba and .bos:
.version 6
.statics 6
.sound_name "create NPCEMEN"
.sound_name "SetMission o 1, s"
.sound_name "ARROW10"
.script Start
…
New opcodes
TAK .cob files use instructions TA 3.1c has no handler for
(PLAY_SOUND, MISSION_COMMAND and the TAK_MATH_* operators) plus
DONT_SHADOW, which TA runs as a no-op. Their exact game-side
semantics are not documented, but their binary layout (size + inline
parameter count) is known from every retail TAK .cob, which is
enough for the disassemble→assemble round-trip to recover the
original bytes verbatim. See
Appendix C for the full reference.
Round-trip status
| Pipeline | TA | TA: Kingdoms |
|---|---|---|
disassemble → assemble (byte-identical) |
278 / 278 ✅ | 267 / 267 ✅ |
decompile → compile (byte-identical) |
278 / 278 ✅ | Simple unit scripts and many mission COBs (≈ 70 / 267) round-trip byte-perfect. The remaining failures are control-flow patterns where the decompiler's structured if/while reconstruction picks a JUMP layout that the compiler regenerates slightly differently — the bytecode is semantically equivalent (every TAK opcode, command string, sub-header, and the v6 version word survives), just not byte-identical. The same kind of mismatch would affect any decompile→compile cycle on a non-trivial script. |
BOS surface for TAK extensions
The decompiler emits these pseudo-calls so TAK COBs round-trip through
BOS. The compiler accepts the same syntax, but only after .version 6
(dont-shadow excepted — TA runs it as a no-op). dont-shadow and
Mission-Command use Scriptor's canonical TAK keywords; the math
opcodes have no documented name in Scriptor (it labels them ?? and
????), so kbot keeps the __tak_math_* placeholders:
// Statement-level (emitted on their own line):
dont-shadow(<piece>); // DONT_SHADOW — disables shadow casting for one piece
Mission-Command("sound name", arg1, arg2, …); // MISSION_COMMAND — engine call (statement form drops the return value)
return_val = Mission-Command("sound name", args…); // assignment form keeps the pushed result
// Expression-level (binary operators: two values popped, one pushed):
local_x = __tak_math_09(a, b); // TAK_MATH_09
local_x = __tak_math_0a(a, b); // TAK_MATH_0A
local_x = __tak_math_0b(a, b); // TAK_MATH_0B
Mission-Command's first argument is the sound name as a quoted
string — the compiler looks it up in the sound-name table (so the
corresponding .sound_name directive must appear earlier in the
file). The remaining arguments are pushed onto the stack and the
engine consumes them.
Two top-of-file directives carry the rest of the v6 metadata:
.version 6
.sound_name "create NPCEMEN"
.sound_name "SetMission o 1, s"
.sound_name "ARROW10"
.version is always emitted (so v4 TA files round-trip identically
through the same pipeline). Each .sound_name is one entry in the
per-COB sound-name table; the writer rebuilds the 8-byte sub-header
and the sound-name offset table from len(SoundNames) and the order
the entries appear.
The TAK STACK_ALLOC ; RETURN epilogue every value-less return;
emits in v6 is handled by the compiler: return; lowers to
STACK_ALLOC ; RETURN when .version 6, return <expr>; lowers to
<expr> ; RETURN without a prefix (matching Cavedog's compiler).
Reproduce on your own install:
kbot ctx use kingdoms # or wherever you registered TAK
kbot cob roundtrip
Port symbols beyond port 20
TA: Kingdoms scripts use get_unit_value ports up to 46
(CURRENT_SPEED, VETERAN_LEVEL, magic-pool fields, etc.). kbot's
decompiler currently labels only ports 1–20 symbolically (see
Appendix B); TAK ports
≥ 21 are emitted as numeric literals. The bytecode stores them as
integers either way, so round-trips are unaffected — only the BOS
readability suffers. Adding TAK port names is tracked separately.
Appendix A — Full opcode reference
Every instruction TA or TA: Kingdoms runs. Source of truth is kbot-io's
formats/scripting/opcodes.go.
Stack notation uses Forth-style ( before -- after ). Inline
columns show how many uint32 words follow the opcode word itself
(consumed during decode, not from the stack).
Animation — piece manipulation (0x100x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10001000 |
MOVE |
2 (piece, axis) |
( speed dist -- ) |
move <piece> to <axis> <dist> speed <speed>; |
0x10002000 |
TURN |
2 (piece, axis) |
( speed dir -- ) |
turn <piece> to <axis> <dir> speed <speed>; |
0x10003000 |
SPIN |
2 (piece, axis) |
( speed -- ) |
spin <piece> around <axis> speed <speed>; |
0x10004000 |
STOP_SPIN |
2 (piece, axis) |
( decel -- ) |
stop-spin <piece> around <axis> [decel <d>]; |
0x10005000 |
SHOW |
1 (piece) |
( -- ) |
show <piece>; |
0x10006000 |
HIDE |
1 (piece) |
( -- ) |
hide <piece>; |
0x10007000 |
CACHE |
1 (piece) |
( -- ) |
cache <piece>; |
0x10008000 |
DONT_CACHE |
1 (piece) |
( -- ) |
dont-cache <piece>; |
0x10009000 |
PIECE_OP_09 |
1 (piece) |
( a b -- ) |
__piece_op_09(<piece>, a, b); — TA runs it but its unit scripts do nothing with it; no retail script uses it. |
0x1000A000 |
DONT_SHADOW |
1 (piece) |
( -- ) |
dont-shadow <piece>; — TA runs it as a no-op; see Appendix C. |
0x1000B000 |
MOVE_NOW |
2 (piece, axis) |
( pos -- ) |
move <piece> to <axis> <pos> now; |
0x1000C000 |
TURN_NOW |
2 (piece, axis) |
( angle -- ) |
turn <piece> to <axis> <angle> now; |
0x1000D000 |
SHADE |
1 (piece) |
( -- ) |
shade <piece>; |
0x1000E000 |
DONT_SHADE |
1 (piece) |
( -- ) |
dont-shade <piece>; |
0x1000F000 |
EMIT_SFX |
1 (piece) |
( type -- ) |
emit-sfx <type> from <piece>; |
Wait operations (0x101x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10011000 |
WAIT_FOR_TURN |
2 (piece, axis) |
( -- ) |
wait-for-turn <piece> around <axis>; |
0x10012000 |
WAIT_FOR_MOVE |
2 (piece, axis) |
( -- ) |
wait-for-move <piece> along <axis>; |
0x10013000 |
SLEEP |
0 | ( ticks -- ) |
sleep <ticks>; |
Stack manipulation (0x102x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10021000 |
PUSH_IMM |
1 (value) |
— | Flagless PUSH: the game faults on it (ta-push-flags). Never written by the compiler. |
0x10021001 |
PUSH_CONST |
1 (value) |
( -- v ) |
<literal> in any expression |
0x10021002 |
PUSH_LOCAL |
1 (var#) |
( -- v ) |
local_N in any expression |
0x10021004 |
PUSH_STATIC |
1 (var#) |
( -- v ) |
global_N in any expression |
0x10022000 |
STACK_ALLOC |
0 | ( -- ) |
(implicit — one per var and parameter) |
0x10023002 |
POP_LOCAL |
1 (var#) |
( v -- ) |
local_N = expr; |
0x10023004 |
POP_STATIC |
1 (var#) |
( v -- ) |
global_N = expr; |
0x10024000 |
POP_STACK |
0 | ( v -- ) |
(discards an expression's result) |
Arithmetic (0x103x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10031000 |
ADD |
0 | ( a b -- a+b ) |
a + b |
0x10032000 |
SUB |
0 | ( a b -- a-b ) |
a - b |
0x10033000 |
MUL |
0 | ( a b -- a*b ) |
a * b |
0x10034000 |
DIV |
0 | ( a b -- a/b ) |
a / b |
0x10035000 |
BITWISE_AND |
0 | ( a b -- a&b ) |
a & b |
0x10036000 |
BITWISE_OR |
0 | ( a b -- a|b ) |
a | b |
0x10037000 |
XOR |
0 | ( a b -- a^b ) |
a ^ b |
0x10038000 |
NOT |
0 | ( a -- ~a ) |
~a |
TA has no modulo instruction: % is a compile error in a TA script.
0x10039000–0x1003B000 are the TA: Kingdoms TAK_MATH_* operators
(Appendix C); TA faults on them.
Engine queries (0x104x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10041000 |
RAND |
0 | ( low high -- rnd ) |
rand(low, high) |
0x10042000 |
GET_UNIT_VALUE |
0 | ( port -- value ) |
get <PORT> |
0x10043000 |
GET |
0 | ( port a b c d -- value ) |
get <PORT>(a, b, …) / get(port, …) — always pops a port and four arguments; the compiler pushes zeros for missing ones |
0x10044000 |
IS_CARRYING_UNIT |
0 | ( unit -- bool ) |
__is_carrying_unit(unit) — 1 when that unit rides on this one |
0x10045000 |
CARRIER_UNIT_ID |
0 | ( -- id ) |
__carrier_unit_id() — the unit carrying this one, or 0 |
See Appendix B for the port catalogue.
Comparison & logical (0x105x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10051000 |
LESS_THAN |
0 | ( a b -- a<b ) |
a < b |
0x10052000 |
LESS_OR_EQUAL |
0 | ( a b -- a<=b ) |
a <= b |
0x10053000 |
GREATER_THAN |
0 | ( a b -- a>b ) |
a > b |
0x10054000 |
GREATER_EQUAL |
0 | ( a b -- a>=b ) |
a >= b |
0x10055000 |
EQUAL |
0 | ( a b -- a==b ) |
a == b |
0x10056000 |
NOT_EQUAL |
0 | ( a b -- a!=b ) |
a != b |
0x10057000 |
LOGICAL_AND |
0 | ( a b -- a&&b ) |
a AND b |
0x10058000 |
LOGICAL_OR |
0 | ( a b -- a||b ) |
a OR b |
0x10059000 |
XOR_ALT |
0 | ( a b -- a^b ) |
a XOR b — a bitwise XOR like 0x10037000 (6 XOR 3 is 5), not a logical one |
0x1005A000 |
LOGICAL_NOT |
0 | ( a -- !a ) |
NOT a |
Control flow & concurrency (0x106x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10061000 |
START_SCRIPT |
2 (script#, argc) |
( args… -- ) |
start-script Foo(a, b); |
0x10062000 |
CALL_SCRIPT |
2 (script#, argc) |
( args… -- ) |
call-script Foo(a, b); |
0x10063000 |
DISCARD_CALL |
2 (word, argc) |
( args… -- ) |
__discard_call(word, args…); — pops the arguments (more than four overrun the game's buffer) and runs nothing |
0x10064000 |
JUMP |
1 (offset) |
( -- ) |
(compiler — goto, loops) |
0x10065000 |
RETURN |
0 | ( v -- ) |
return v; |
0x10066000 |
JUMP_IF_FALSE |
1 (offset) |
( v -- ) |
(compiler — if/while) |
0x10067000 |
SIGNAL |
0 | ( mask -- ) |
signal <mask>; |
0x10068000 |
SET_SIGNAL_MASK |
0 | ( mask -- ) |
set-signal-mask <mask>; |
offset for JUMP / JUMP_IF_FALSE is a byte offset relative to
OffsetToScriptCode, not relative to the current instruction.
Effects (0x107x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10071000 |
EXPLODE |
1 (piece) |
( type -- ) |
explode <piece> type <type>; |
0x10072000 |
PLAY_SOUND |
1 (vol) |
( sound -- result ) |
play-sound(<sound>, <vol>); — TA: Kingdoms only (.version 6); TA faults on it |
Set operations (0x108x)
| Opcode | Mnemonic | Inline | Stack effect | BOS syntax |
|---|---|---|---|---|
0x10082000 |
SET_VALUE |
0 | ( port value -- ) |
set <PORT_NAME> to <value>; |
0x10083000 |
ATTACH_UNIT |
1 (piece) |
( unit -- ) |
attach-unit <unit> to <piece>; |
0x10084000 |
DROP_UNIT |
0 | ( unit -- ) |
drop-unit <unit>; |
Opcode encoding
Every opcode word is 0x10CCSSFF, where:
CC= the category byte (animation, math, control flow, …).SS= the sub-command nibble in the high nibble of the second byte from the top.FF= the flags byte.
The game dispatches on word & 0x100FF000 and ignores the other bits,
so a word such as 0x10064001 runs as JUMP; the disassembler writes
it JUMP@0x10064001 and the assembler reads that back to the same
word. PUSH and POP read their source or destination from the low
three bits: PUSH accepts 1 (constant), 2 (local) and 4 (static),
POP accepts 2 and 4, and any other value faults the script.
Words no game runs are rendered as UNKNOWN_0x10xxxxxx by the
disassembler (and reported by ta-unknown-opcode); if you find one in
a real file, please open an issue with the COB sample.
Appendix B — get_unit_value port table
The 20 ports kbot's decompiler currently labels symbolically. Real TA has ~80; TA: Kingdoms adds another ~15 for magic-related quantities. Anything above port 20 is rendered as a numeric literal in the decompiler output today.
| Port | Symbolic name | Returns | Notes |
|---|---|---|---|
| 1 | ACTIVATION |
bool | Is the unit's "active" toggle on (radar, jammer, fortifications)? |
| 2 | STANDINGMOVEORDERS |
enum | 0 = hold position, 1 = manoeuvre, 2 = roam. |
| 3 | STANDINGFIREORDERS |
enum | 0 = hold fire, 1 = return fire, 2 = fire at will. |
| 4 | HEALTH |
0–100 | Percentage of MaxDamage remaining. |
| 5 | INBUILDSTANCE |
bool | Is the unit currently extending nano-lathes? |
| 6 | BUSY |
bool | Is the unit currently busy with an order? |
| 7 | PIECE_XZ |
packed | Combined X/Z of a piece (encoded). Used for projectile aiming. |
| 8 | PIECE_Y |
int | Y-coordinate of a piece relative to the unit origin. |
| 9 | UNIT_XZ |
packed | Combined X/Z of the unit. |
| 10 | UNIT_Y |
int | Y-coordinate of the unit. |
| 11 | UNIT_HEIGHT |
int | Height above terrain. |
| 12 | XZ_ATAN |
int | atan2 between two XZ-encoded values (for aim turrets). |
| 13 | XZ_HYPOT |
int | Hypotenuse between two XZ-encoded values. |
| 14 | ATAN |
int | atan2(y, x) of two pushed values. |
| 15 | HYPOT |
int | hypot(x, y). |
| 16 | GROUND_HEIGHT |
int | Terrain elevation at the unit's position. |
| 17 | BUILD_PERCENT_LEFT |
0–100 | Used by SmokeUnit to know construction progress. 0 once built. |
| 18 | YARD_OPEN |
bool | For yard-map buildings: are the doors open? |
| 19 | BUGGER_OFF |
bool | Set by the engine when the unit needs to move out of the way. |
| 20 | ARMORED |
bool | Is the unit's armoured stance enabled (Bulldog, Goliath, etc.)? |
Higher ports include CURRENT_SPEED (29 — famously inlined as a
literal by the Cavedog Scriptor; see Gotchas above), MAX_ID,
UNIT_TEAM, UNIT_BUILD_PERCENT, BUILDER, UNIT_ALLIED, and many
more. The full list lives in Cavedog's internal documentation; the
community wiki at the TAUniverse forums has the most comprehensive
public catalogue.
TA: Kingdoms extends the port range. The TAK-only ports kbot recognises
(decompiler renders the symbolic name; compiler accepts it as input)
are taken from Scriptor's [UNITVLAUES] table:
| Port | Symbol | Notes |
|---|---|---|
| 21 | WEAPON_AIM_ABORTED |
Set when a weapon's aim solution is cancelled. |
| 22 | WEAPON_READY |
Weapon has reloaded and is ready to fire. |
| 23 | WEAPON_LAUNCH_NOW |
Engine has fired the weapon. |
| 26 | FINISHED_DYING |
Death animation has completed. |
| 27 | ORIENTATION |
Unit's facing direction. |
| 28 | IN_WATER |
Unit is in water. |
| 29 | CURRENT_SPEED |
Current movement speed (shared with TA — Scriptor numbers it the same here). |
| 31 | MAGIC_DEATH |
Unit died from magic damage. |
| 32 | VETERAN_LEVEL |
Veterancy tier. |
| 34 | ON_ROAD |
Unit is on a road tile. |
Appendix C — TA: Kingdoms opcodes
Note
TA:K-only opcodes. None of these appear in retail TA .cob
files. DONT_SHADOW and MISSION_COMMAND use the canonical
mnemonics from Scriptor (Switeck's TAK-aware compiler/decompiler);
the math ops have no documented semantics — Scriptor labels them
?? and ????, so kbot keeps the TAK_MATH_09 / TAK_MATH_0A /
TAK_MATH_0B placeholders. Retail TA: Kingdoms scripts use each as
a binary operator (two values popped, one pushed) when packing and
unpacking bit fields. TA 3.1c has no handler for PLAY_SOUND,
MISSION_COMMAND or the math operators; DONT_SHADOW it runs as a
no-op.
| Opcode | Mnemonic | Inline | Sites in retail TAK | Notes |
|---|---|---|---|---|
0x1000A000 |
DONT_SHADOW |
1 (piece) |
66 | Disables shadow casting for a single piece. Sits in the animation category next to DONT_SHADE (0x1000E000) — kbot earlier called this TAK_ANIM_0A. Same on-disk shape as the other ANIM_* ops: 4-byte opcode + one piece DWORD. BOS keyword: dont-shadow <piece>;. |
0x10039000 |
TAK_MATH_09 |
0 | 264 | Math-category binary operator, 4 bytes total: ( a b -- r ). BOS: __tak_math_09(a, b). |
0x1003A000 |
TAK_MATH_0A |
0 | — | Same shape. Some opcode tables call it BITWISE_NOT; TA's bitwise NOT is 0x10038000. BOS: __tak_math_0a(a, b). |
0x1003B000 |
TAK_MATH_0B |
0 | 66 | Same shape as TAK_MATH_09. BOS: __tak_math_0b(a, b). |
0x10073000 |
MISSION_COMMAND |
2 (soundNameIdx, argCount) |
3,885 | Engine command call. The first inline DWORD is an index into the COB's SoundNames table; the second is the number of values to pop off the stack as arguments. The opcode pushes a single result back onto the stack (typically dropped via a following POP_STACK or stored via POP_STATIC/POP_LOCAL). By far the most common TAK-only opcode. BOS keyword: Mission-Command("name", args…) — matches Scriptor's canonical surface. |
Encoding (recap)
Every COB opcode word is 0x10CCSSFF where CC is the category and
SS is the sub-command — these TAK opcodes simply occupy
previously-unused CC:SS slots inside the existing categories rather
than introducing a new category byte. DONT_SHADOW lives in the
animation category alongside DONT_CACHE/DONT_SHADE, and
MISSION_COMMAND in the effect category alongside EXPLODE and
PLAY_SOUND.
Tip
Cross-reference real bytecode.
kbot ctx use tak-30bb-flat
kbot cob disassemble scripts/araat.cob -a | grep -E 'DONT_SHADOW|MISSION|TAK_MATH|PLAY_SOUND'
Live examples in the reference catalogue
Real units exercising the format extremes — open these to see actual script complexity rather than worked-in-text examples:
CORKROG— Krogoth (Core) — one of the largest COBs in the game (~25 KB, 30+ scripts, complex multi-piece animations).ARMCOM— Arm Commander — representative "rich" unit: walk/aim/idle/build cycles, multiple weapons, D-gun handling.CORTRUCK— Construction Truck — near-minimal COB used as the walkthrough on this page (3 scripts:SmokeUnit,Create,Killed).ARMSAM— Samson — good example of a turret unit (turret1rotate-to-aim, recoil animation,wait-for-turnsynchronisation).
Decompile any locally with kbot cob decompile scripts/<name>.cob
once a kbot context is set.