One Unit, 100% Decoded: AMPH (ARMALT)
A complete, self-contained walk through one Total Annihilation unit: the ARMALT.3DO mesh, the ARMALT.COB bytecode reconstructed line-by-line back into BOS source, every mask and number format the game uses, and the exact lighting model that turns it all into a picture. Every number on this page was measured from the real files β no estimates, no folklore. Follow it top to bottom and you can decode any unit yourself.
0 Β· The artifact set
Everything below is derived from three files. They ship inside the unit archive (Units.ufo for AMPH; the original release ships it in rev31.gp3) and are extracted once into a working cache. Verify you have the same bytes:
| File | Size | SHA-256 | What it is |
|---|---|---|---|
ARMALT.3DO | 5193 | 1f6a910e8286d6e6b7f91af4c9333bdc31a9534a731f95e895367cad1446b524 | the mesh: pieces, vertices, textured polygons |
ARMALT.COB | 2093 | bf1bac7f8f54da43b72b197aadea155ff95c0ca1e1cb616befba6171b73fd580 | the compiled animation + logic scripts |
ARMALT.FBI | 1391 | b4a328120d243d6cfec8aa023c4f3ce6fd1bdbcd70a2e0a52f29633f0873ed64 | the unit definition (stats, weapon assignment) |
The FBI tells us which weapon the scripts pace: Weapon1=CORE_CANLASER, reloadtime 950 ms from the [CORE_CANLASER] TDF section β a Core weapon on an Armada unit (a quirk of this expansion unit, taken literally).
1 Β· The 3DO mesh
A .3do is a tiny scene graph: a flat list of pieces, each with a parent index, a static offset from that parent, a vertex list, and a list of primitives (the drawn polygons). There are no UV coordinates and no normals in the file β both are derived at render time (see TA 3DO Texture Mapping).
1.1 The piece table
| # | Piece | Parent | Offset (raw 3DO units) | Offset (elmo) | Verts | Prims |
|---|---|---|---|---|---|---|
| 0 | base | β (root) | (0, 0, 0) | (0, 0, 0) | 108 | 29 |
| 1 | turret | base | (0, 851968, 393216) | (0, 5.2, 2.4) | 68 | 18 |
| 2 | barrel | turret | (0, 98304, β720896) | (0, 0.6, β4.4) | 9 | 3 |
| 3 | flare1 | barrel | (0, 0, β983040) | (0, 0, β6.0) | 7 | 3 |
| 4 | flare2 | base | (0, 1179648, 524288) | (0, 7.2, 3.2) | 1 | 0 |
Raw unit: 1 elmo = 163840 (851968 Γ· 163840 = 5.2 exactly β 3DO coordinates are integral elmoΒ·163840, so every offset divides cleanly).
1.2 Primitives and textures
Each primitive is a polygon (mostly quads) with 3β4 vertex indices and either a texture name or a palette colour index. The complete texture inventory of AMPH:
| Piece | Textures (prim count) | Specials |
|---|---|---|
| base | camoflage4Γ2, camoflage5Γ4, camoflage6Γ6, ArmCam3bΓ3, ArmCam3cΓ2, ArmCam3dΓ5, ArmCam4bΓ2, Tredside1Γ2, Tredside2Γ2, 32xlogosΓ2 | 1 untextured prim (tex null, color 0 β never drawn) |
| turret | colorsmdΓ1, colorsdkΓ3, ArmCam3aΓ2, ArmCam3bΓ1, ArmCam3cΓ1, Armpanel3Γ1, noise6aΓ2, noise6cΓ1, noise6dΓ3 | 3 untextured prims (invisible) |
| barrel | metal3aΓ1, metal3cΓ1, metal3dΓ1 | β |
| flare1 | β | 3 prims, tex null, color 208 β pure palette-colour quads = the muzzle flash |
| flare2 | β | 0 prims β an invisible SFX anchor |
tex=null, color=0 carries no image and no colour: the renderer skips it entirely (our pipeline does; the game draws nothing useful there either). A prim with tex=null, color=208 is a flat palette-colour polygon β flare1's flash quads. The number 208 is an index into the unit palette; Core units flash red/orange through the same mechanism (see COB-Exact Unit Animation for the colour-trap warning). And flare2 (1 vertex, 0 prims) is not geometry at all β it is a coordinate anchor where the engine spawns effects (an engine-side weapon or smoke emitter). The COB never mentions it; it exists purely for the engine.
CobAnim(model, {}, [])) β the assembly reference for every animation judgement.
Create() β identical to the zero-pose except flare1 is hidden (hide flare1 is the first instruction in Create).2 Β· The COB container
.COB = Compiled Object script: a small header, then pure little-endian DWORD code. AMPH's header decoded field by field (11 DWORDs, offset 0β¦43):
| # | Field | Value | Hex | Meaning |
|---|---|---|---|---|
| 0 | versionSignature | 4 | 0x00000004 | TA (Kingdoms uses 6) |
| 1 | numScripts | 11 | 0x0000000b | script names |
| 2 | numPieces | 4 | 0x00000004 | piece names (the COB knows only 4!) |
| 3 | lengthOfScripts | 447 | 0x000001bf | code size in DWORDs |
| 4 | numberOfStaticVars | 1 | 0x00000001 | global variables (s0) |
| 5 | reserved | 0 | 0x00000000 | always 0 |
| 6 | offsetToScriptCodeIndexArray | 1832 | 0x00000728 | 11 DWORD start offsets |
| 7 | offsetToScriptNameOffsetArray | 1876 | 0x00000754 | 11 DWORD absolute file offsets |
| 8 | offsetToPieceNameOffsetArray | 1920 | 0x00000780 | 4 DWORD absolute file offsets |
| 9 | offsetToScriptCode | 44 | 0x0000002c | code section starts right after the header |
| 10 | offsetToNameArray | 1936 | 0x00000790 | NUL-terminated name strings |
2.1 The script index β and a compiler signature hiding in the code
The index array gives every script's start in DWORDs. Regions end at the next larger start:
| # | Script | DWORDs | Role |
|---|---|---|---|
| 0 | RockUnit | 0 β¦ 76 | engine hook: chassis rock when firing |
| 1 | HitByWeapon | 76 β¦ 115 | engine hook: jolt when hit |
| 2 | Create | 115 β¦ 124 | unit spawn: hide list + defaults |
| 3 | SetMaxReloadTime | 124 β¦ 135 | engine sets the restore delay |
| 4 | RestoreAfterDelay | 135 β¦ 155 | the only way home for the gun |
| 5 | AimPrimary | 155 β¦ 192 | turret/bullet aiming |
| 6 | FirePrimary | 192 β¦ 219 | flash + recoil |
| 7 | QueryPrimary | 219 β¦ 227 | out-param: muzzle piece |
| 8 | AimFromPrimary | 227 β¦ 235 | out-param: aim origin piece |
| 9 | SweetSpot | 235 β¦ 243 | out-param: weak point |
| 10 | Killed | 243 β¦ 447 | death: four damage tiers |
return. RockUnit ends with return at dword 38 β but its region runs to 76. Bytes 39β75 are not code at all: they are the compiler's signature stored as ASCII in the code section:"Build by COBBLER Ver4.0 Copyright @1998 DIGITAL CONCEPT SOFTWARE ([email protected]) / http://www.β¦com/DCS/ &PWD (shore, rehman )"
A third-party compiler ("COBBLER", 1998) built this unit's bytecode β not Cavedog's stock BOS compiler. This is not a curiosity only: any disassembler that blindly decodes region bodies will print garbage opcodes there (our tool prints ??? 6c697542β¦). Execution never reaches it because of the return; region extents and code extents are not the same thing.
2.2 Piece binding: names, not indices
The COB's piece table has 4 entries β base, flare1, turret, barrel β while the 3DO has 5 pieces in a different order. Three rules follow, and all three bite real tools:
- COB bytecode addresses pieces by index into the COB's own name list. The disassembler must print names from that list, never from the 3DO.
- The COB list order (base, flare1, turret, barrel) β 3DO order (base, turret, barrel, flare1, flare2). Index 1 in the COB is
flare1, notturret. flare2exists only in the 3DO. A COB piece-name mismatch against the mesh is the classic "ghost piece" warning β here it is benign (the piece is a geometry-free anchor), but a mismatched animated piece means your renderer silently freezes it.
3 Β· From COB to BOS
BOS is the C-like language the scripts were written in; COB is what the compiler emitted. To go back you need one model: a stack machine with three storage kinds and jump-based control flow.
3.1 The machine
- PC β the dword index (we print it as the number left of each line).
- Stack β operands for every operation;
push-const/push-arg/push-staticfeed it. - Args
a0β¦a15β the call arguments; also the out-parameters the engine reads after return. - Statics
s0β¦β per-unit globals (AMPH has exactly one). - Piece channels β animated state, owned by the engine's interpolator, driven by
turn/move/spin.
return vends a script (v is the script's result).jump/jump-if-zeroare the only branches βif,whileand the compare operators are all compiled into push/compare/jump triples.call-scriptruns a sub to completion;start-scriptforks a thread the engine interleaves.sleep msis the clock: it advances the unit's script time and lets other threads run.signal v/set-signal-mask vimplement engine interrupts: any thread whose mask shares a bit with a raised signal is killed.
3.2 The anatomy of an opcode word
0x10055000 as and and 0x10056000 as xor. They are wrong: those are the == and != condition operators (proved by corhlt's three-way phase selector, which only selects correctly when bodies run on equality). The real ladder is lt/le/gt/ge (0x10051β¦0x54), eq (0x55), neq (0x56), and (0x57), or (0x58), not (0x5a) β and a separate bitwise or (0x10036000) that builds flag words like 256|2. AMPH's Killed uses both kinds; mixing them up silently changes every branch. This ladder is confirmed by two independent community sources: basm ops.txt (Mafia's BASM assembler source, cc.tauniverse.com) lists 0x10055000 = cmp/equal, 0x10057000 = land, 0x10035000 = bitwise and, 0x10036000 = bitwise or, 0x10038000 = bitwise not, 0x1005A000 = logical not; and Format_Cob.pas (ggs' BOS compiler source) names them Opcode_Equal, Opcode_LogicalAnd etc. The same registry also holds ops unseen in AMPH: rand (0x10041000), getuv (0x10042000), set (unit value, 0x10082000), attach/drop (cargo, 0x10083000/0x10084000), play-sound (0x10072000), map-command (0x10073000). And the 0x10051000 words other units show at script starts are piece declarations (piece base, β¦;) β COBBLER omits them, ggs' compiler emits them. Finally, explode takes one stack operand (the flag word) plus one trailing piece dword β DoExplode in the compiler source settles that.3.3 Every script, bytecode β source
Each block below is the real annotated disassembly (left column of the evidence) next to its reconstructed BOS. Reading order is the script index order. The numbers in the opcode lines are the PC in dwords; the parenthesised values are our unit conversions (degrees for BAM, elmo for raw moves) β Β§4 derives them.
0 Β· 38 RockUnit(x, z) β the firing rock
Engine hook: the game tilts the chassis with the recoil offsets it passes in. It is not part of any animation loop (engine-driven), but the bytecode is real:
2 push-const 9100 // = 49.99Β°/s (turn speed) 4 push-arg 0 6 turn base x-axis speed=c9100 target=a0 9 push-const 9100 11 push-arg 1 13 turn base z-axis speed=c9100 target=a1 16 wait-for-turn base z-axis 19 wait-for-turn base x-axis 22 push-const 3640 // 20Β°/s β the settle is slower than the jolt 24 push-const 0 26 turn base z-axis speed=c3640 target=c0 29 push-const 3640 31 push-const 0 33 turn base x-axis speed=c3640 target=c0 36 push-const 0 38 return c0 39β¦75 // COBBLER version string β dead bytes, never executed
// BOS reconstruction RockUnit(x, z) { turn base to x-axis x speed 9100; turn base to z-axis z speed 9100; wait-for-turn base z-axis; wait-for-turn base x-axis; turn base to z-axis 0 speed 3640; turn base to x-axis 0 speed 3640; return 0; }
Note the push order: turn pops target first, speed second β the pushes appear in the bytecode as speed then target, because the compiler emits operands so the last push is on top. Getting this backwards aims every unit at its own speed value.
76 Β· 114 HitByWeapon(x, z) β same shape, twice as fast
78 push-const 19110 // 105Β°/s β a hit jolt is snappier than a shot rock 80 push-arg 1 82 turn base z-axis speed=c19110 target=a1 β¦ // (same structure as RockUnit: x, waits, slower settle at 5460 = 30Β°/s) 112 push-const 0 114 return c0
HitByWeapon(x, z) { turn base to z-axis z speed 19110; turn base to x-axis x speed 19110; wait-for-turn base z-axis; wait-for-turn base x-axis; turn base to z-axis 0 speed 5460; turn base to x-axis 0 speed 5460; return 0; }
115 Β· 123 Create() β the hide list owns visibility
115 hide flare1 117 push-const 3000 119 store-static 0 <- c3000 // s0 = 3000 ms restore delay 121 push-const 0 123 return c0
static-var reloadDelay; // s0Create() { hide flare1; reloadDelay = 3000; return 0; }
Two facts hide here. Visibility is a script concern: the flash piece starts hidden and only FirePrimary shows it β a renderer that ignores the hide list shows a permanently lit muzzle. And the static 3000 is the default restore delay in milliseconds (not degrees β the same number would be 16.5Β° if it were BAM; only context tells them apart).
124 Β· 134 SetMaxReloadTime(t) β the engine negotiates the restore delay
125 push-arg 0 127 push-const 2 129 mul a0 c2 130 store-static 0 <- ? // s0 = t * 2 132 push-const 0 134 return c0
SetMaxReloadTime(t) { reloadDelay = t * 2; return 0; }
The engine calls this with the weapon's reload time; AMPH stores 2Γ it β the documented "restore delay = 2 Γ the longest reload" pattern. With Weapon1=CORE_CANLASER (950 ms) the in-game restore fires 1900 ms after the last aim; the compiled default from Create (3000 ms) applies before the engine ever calls this.
135 Β· 154 RestoreAfterDelay() β the gun's only way home
135 push-static 0 137 sleep s0 // the engine pacing, in ms 138 push-const 6370 140 push-const 0 142 turn turret y-axis speed=c6370 target=c0 145 push-const 2730 147 push-const 0 149 turn barrel x-axis speed=c2730 target=c0 152 push-const 0 154 return c0
RestoreAfterDelay() { sleep reloadDelay; turn turret to y-axis 0 speed 6370; turn barrel to x-axis 0 speed 2730; return 0; }
This is why AimPrimary can simply return 1: the gun re-centers itself through this thread, and every aim call re-arms it (start-script at the end of Aim). Units whose restore parks pieces at odd angles (fixed reload latches) have no other home path β dropping the restore from a reconstruction freezes them mid-aim forever.
155 Β· 191 AimPrimary(heading, pitch) β signals, inverted pitch, threads
157 push-const 2 159 signal c2 // kill any earlier aim still running 160 push-const 2 162 set-signal-mask c2 // β¦and accept that kill for ourselves 163 push-const 6370 165 push-arg 0 167 turn turret y-axis speed=c6370 target=a0 170 push-const 2730 172 push-const 0 174 push-arg 1 176 sub c0 a1 // (0 - pitch) β THE INVERSION 177 turn barrel x-axis speed=? target=c2730 180 wait-for-turn turret y-axis 183 wait-for-turn barrel x-axis 186 start-script script#4() // = RestoreAfterDelay 189 push-const 1 191 return c1 // 1 = "aim in progress, call me again"
AimPrimary(heading, pitch) { signal 2; set-signal-mask 2; turn turret to y-axis heading speed 6370; turn barrel to x-axis (0 - pitch) speed 2730; // pitch is inverted! wait-for-turn turret y-axis; wait-for-turn barrel x-axis; start-script RestoreAfterDelay(); return 1; }
signal 2 raises the aim bit before masking to it β a second aim call kills the first mid-turn. (2) Inverted pitch: the compiler folds 0 β pitch into the target with sub; a transcription that aims at +pitch tilts the barrel behind the turret. (3) Return 1 is protocol, not a boolean: it tells the engine the aim is not finished (more calls follow).192 Β· 218 FirePrimary() β flash window and recoil
192 show flare1 // flash ON 194 push-const 81920000 // 500 elmo/s β effectively instant 196 push-const -327680 // -2 elmo β the recoil distance 198 move barrel z-axis speed=c81920000 target=c-327680 201 push-const 150 203 sleep c150 // the flash window: 150 ms 204 hide flare1 // flash OFF 206 wait-for-move barrel z-axis 209 push-const 491520 // 3 elmo/s β the return is slow on purpose 211 push-const 0 213 move barrel z-axis speed=c491520 target=c0 216 push-const 0 218 return c0
FirePrimary() { show flare1; move barrel to z-axis -327680 speed 81920000; // snap back 2 elmo sleep 150; hide flare1; wait-for-move barrel z-axis; move barrel to z-axis 0 speed 491520; // 666 ms return return 0; }
The flash is exactly 150 ms wide and the recoil asymmetry is by design: kick back at 500 elmo/s (~4 ms, invisible), return at 3 elmo/s (667 ms, the visible settle). The muzzle piece z-axis is the bore direction β negative z is away from the turret (see the piece table), so negative = deeper into the tank = recoil.
219 Β· 242 The three out-parameter scripts β where the muzzle lives
220 push-const 1 222 store-arg 0 <- c1 // QueryPrimary: piece 1 = flare1 224 push-const 0 226 return c0228 push-const 2 230 store-arg 0 <- c2 // AimFromPrimary: piece 2 = turret 232 push-const 0 234 return c0
236 push-const 0 238 store-arg 0 <- c0 // SweetSpot: piece 0 = base 240 push-const 0 242 return c0
QueryPrimary(piece) { piece = 1; return 0; } // flare1 β the MUZZLE AimFromPrimary(piece) { piece = 2; return 0; } // turret β aim origin SweetSpot(piece) { piece = 0; return 0; } // base β weak point
QueryPrimary "which piece is the muzzle?" β and the answer arrives through the out-parameter (store-arg 0), not through the return value (which is just a success flag). The number is an index into the COB piece table: 1 = flare1 here. Tools that use the return value spawn muzzle effects at the unit origin; tools that read the index but index the 3DO list point at the turret. Both wrong answers look plausible on screen.243 Β· 446 Killed(severity) β four damage tiers, flag words by bitwise-or
The longest script (204 dwords) is a severity ladder: β€25, β€50, β€99, everything else. Each tier picks a class (written to arg1) and a per-piece flag word built with bitwise or chains, then explodes the pieces. The structure repeats verbatim per tier β shown once, with the ladder compressed:
245 hide flare1 // never leave a lit flash on a wreck 247 push-arg 0 249 push-const 25 251 le a0 c25 // severity <= 25 ? 252 jump-if-zero -> 289 // no: next tier 254 push-const 1 256 store-arg 1 <- c1 // class = 1 (light wreck) 258 push-const 32 260 push-const 256 262 or c32 c256 // flag word 32|256 = 288 263 explode barrel 265 push-const 32 267 push-const 2048 269 or c32 c2048 // 32|2048 = 2080 270 explode base 272 β¦ or c32 c256 ; explode flare1 // 288 279 β¦ or c32 c512 ; explode turret // 32|512 = 544 286 push-const 0 288 return c0 289 β¦ // tier 2: severity <= 50 β class 2 β¦ 331 β¦ // tier 3: severity <= 99 β class 3 β¦ 391 β¦ // tier 4: else β class 3 β¦
| Severity | class (arg1) | barrel | base | flare1 | turret |
|---|---|---|---|---|---|
| β€ 25 | 1 | 32|256 = 288 | 32|2048 = 2080 | 32|256 = 288 | 32|512 = 544 |
| β€ 50 | 2 | 4|256 = 260 | 32|2048 = 2080 | 4|256 = 260 | 1|512 = 513 |
| β€ 99 | 3 | 4|8|16|2|256 = 286 | 32|2048 = 2080 | 4|8|16|2|256 = 286 | 1|512 = 513 |
| else | 3 | 4|8|16|2|256 = 286 | 32|2048 = 2080 | 4|8|16|2|256 = 286 | 1|2|512 = 515 |
The bits are not guesswork: they are Cavedog's own explosion-flag registry from EXPTYPE.H (Copyright 1997 Cavedog Entertainment), shipped with the community toolchain at cc.tauniverse.com:
| Flag | Value | Meaning (original comment) |
|---|---|---|
SHATTER | 1 | the piece shatters into debris instead of flying off whole |
EXPLODE_ON_HIT | 2 | explodes when it hits the ground |
FALL | 4 | falls with gravity instead of flying off |
SMOKE | 8 | smoke trail |
FIRE | 16 | fire trail |
BITMAPONLY | 32 | no fly-off/shatter β only a bitmap explosion is rendered |
BITMAP1..BITMAP5 | 256β¦4096 | explosion bitmap type 1β5 |
BITMAPNUKE | 8192 | nuke explosion bitmap |
BITMAPMASK | 16128 | mask over the bitmap bits |
So tier 1 is clean bitmap explosions (32|256 = BITMAPONLY|BITMAP1), tier 2 makes barrel and flare fall (4|256) and shatters the turret (1|512), and tiers 3β4 are the full wreck: falling, smoking, burning, exploding on ground impact (4|8|16|2|256). The tank's body keeps its 32|2048 bitmap-only base explosion at every tier. Our previews leave explosions engine-side (the loop shows the unit, not its death) β the bytecode is documented so death sequences can be reconstructed later. The tier class travels through arg1, another out-parameter the engine reads back.
3.4 The complete BOS listing
Put together, this is all 447 dwords of ARMALT.COB as source. Handled sections: 11 scripts, 1 static, 4 piece references, every opcode. (Syntax normalized to BOS 1.x statement style; the bytecode above is the ground truth.)
// ===================================================================== // ARMALT.COB β 100% reconstruction Β· "Amph" (Amphibious Heavy Laser Tank) // compiled with COBBLER 4.0 (1998) Β· 447 dwords Β· 11 scripts Β· 1 static // piece table (COB order!): base=0 flare1=1 turret=2 barrel=3 // =====================================================================static-var reloadDelay; // s0, milliseconds
RockUnit(x, z) { // engine hook: fire rock turn base to x-axis x speed 9100; turn base to z-axis z speed 9100; wait-for-turn base z-axis; wait-for-turn base x-axis; turn base to z-axis 0 speed 3640; turn base to x-axis 0 speed 3640; return 0; }
HitByWeapon(x, z) { // engine hook: hit jolt turn base to z-axis z speed 19110; turn base to x-axis x speed 19110; wait-for-turn base z-axis; wait-for-turn base x-axis; turn base to z-axis 0 speed 5460; turn base to x-axis 0 speed 5460; return 0; }
Create() { hide flare1; reloadDelay = 3000; return 0; }
SetMaxReloadTime(t) { reloadDelay = t * 2; return 0; }
RestoreAfterDelay() { sleep reloadDelay; turn turret to y-axis 0 speed 6370; turn barrel to x-axis 0 speed 2730; return 0; }
AimPrimary(heading, pitch) { signal 2; set-signal-mask 2; turn turret to y-axis heading speed 6370; turn barrel to x-axis (0 - pitch) speed 2730; wait-for-turn turret y-axis; wait-for-turn barrel x-axis; start-script RestoreAfterDelay(); return 1; }
FirePrimary() { show flare1; move barrel to z-axis -327680 speed 81920000; sleep 150; hide flare1; wait-for-move barrel z-axis; move barrel to z-axis 0 speed 491520; return 0; }
QueryPrimary(piece) { piece = 1; return 0; } AimFromPrimary(piece) { piece = 2; return 0; } SweetSpot(piece) { piece = 0; return 0; }
Killed(severity) { hide flare1; if (severity <= 25) { severity = 1; explode barrel 32|256; explode base 32|2048; explode flare1 32|256; explode turret 32|512; } else if (severity <= 50) { severity = 2; explode barrel 4|256; explode base 32|2048; explode flare1 4|256; explode turret 1|512; } else if (severity <= 99) { severity = 3; explode barrel 4|8|16|2|256; explode base 32|2048; explode flare1 4|8|16|2|256; explode turret 1|512; } else { severity = 3; explode barrel 4|8|16|2|256; explode base 32|2048; explode flare1 4|8|16|2|256; explode turret 1|2|512; } return 0; }
The severity = 1/2/3 lines are store-arg 1 β the wreck class written back to the engine through the second parameter. The if/else chain is purely le+jump-if-zero: there is no branch opcode in the format beyond jump.
4 Β· The masks: every number format in one place
A COB instruction stream contains exactly four kinds of numbers. Mixing them up is the number-one source of broken decoders β a speed read as an angle is off by a factor of ~182, a raw move read as an elmo value floats the piece into orbit. Here is the complete table, verified against AMPH's own constants:
| Format | Definition | Proof from ARMALT.COB |
|---|---|---|
| Angle / rotation | BAM int32, 65536 = 360Β°deg = raw Β· 360 / 65536 | 9100 β 49.988Β°, 6370 β 34.991Β°, 2730 β 14.996Β°, 3640 β 19.995Β°, 19110 β 104.974Β°, 5460 β 29.993Β° β every constant is a human round degree value with rounding noise (50, 35, 15, 20, 105, 30). That pattern is the evidence that the unit is degrees: random BAM values would not cluster around round numbers. |
| Distance / offset | raw int32, 163840 = 1 elmoelmo = raw / 163840 | recoil β327680 = β2.0 elmo exactly; 3DO offsets 851968 = 5.2, 720896 = 4.4, 983040 = 6.0 β all integral elmo (the format's own quantisation). |
| Turn speed | BAM per second | 9100 BAM/s = 49.988Β°/s β the RockUnit rock takes ~0.5 s for its small offsets; durations computed as Ξ/speed match the script's own wait-for-turn structure. |
| Move speed | elmoΒ·163840 per second (i.e. raw/s) | recoil out: 81920000 raw/s = 500 elmo/s β 2 elmo in 4 ms ("instant"); return: 491520 raw/s = 3 elmo/s β 2 elmo in 667 ms β the visible settle. |
| Time | milliseconds | sleep 150 = the flash window; sleep s0 = the restore delay (3000 default, or 2Γ the 950 ms reload). |
push-const 3000 in Create is 3000 ms (a restore delay); push-const 2730 in AimPrimary is 14.996Β°/s (a turn speed). Both are plain integers. Only the consuming opcode tells you the unit: sleep takes ms, turn speeds are BAM/s, turn targets are BAM, move targets are raw. Read the opcode first, convert second β and when a constant looks odd (Big Bertha's 2Β°/s elevation), it is odd in the game, not in your decoder.4.1 The bitwise-or flag words
Besides arithmetic (add/sub/mul/div/mod, 0x10031β¦0x35) the format has a bitwise or (0x10036000) used to assemble flag words β distinct from the logical or/and/not (0x10057/0x58/0x5a) used for branch conditions. AMPH's Killed is the worked example: 32|256, 4|8|16|2|256, 1|2|512 are pure bit words consumed by explode. Our interpreter records them as one integer per piece; the engine's explosion registry interprets the bits.
4.2 The complete opcode map used by this unit (and the full table)
| Word (masked) | Name | Trailing | Stack | Seen in ARMALT |
|---|---|---|---|---|
| 0x10021001 | push-const v | 1 | +1 | everywhere |
| 0x10021002 | push-arg n | 1 | +1 | RockUnit, AimPrimary, Killed |
| 0x10021004 | push-static n | 1 | +1 | RestoreAfterDelay |
| 0x10023002 | store-arg n β v | 1 | β1 | Query/AimFrom/SweetSpot, Killed |
| 0x10023003 | store-static n β v | 1 | β1 | Create, SetMaxReloadTime |
| 0x10002000 | turn piece axis | 2 | β2 | RockUnit, Aim, Restore |
| 0x10001000 | move piece axis | 2 | β2 | FirePrimary |
| 0x10005000/0x6000 | show / hide | 1 | 0 | Create, FirePrimary, Killed |
| 0x10013000 | sleep ms | 0 | β1 | FirePrimary, Restore |
| 0x10011000/0x12000 | wait-for-turn/wait-for-move | 2 | 0 | RockUnit, Aim, Fire |
| 0x10052000 | le (compare) | 0 | β1 | Killed's ladder |
| 0x10032000 | sub | 0 | β1 | AimPrimary (the pitch inversion) |
| 0x10033000 | mul | 0 | β1 | SetMaxReloadTime |
| 0x10036000 | or (bitwise!) | 0 | β1 | Killed's flag words |
| 0x10066000 | jump-if-zero β | 1 | β1 | Killed's ladder |
| 0x10061000/0x62000 | start-script/call-script | 2 | var | AimPrimary (start-script #4) |
| 0x10065000 | return v | 0 | β1 | all scripts |
| 0x10067000/0x68000 | signal/set-signal-mask | 0 | β1 | AimPrimary |
| 0x10071000 | explode piece | 1 | β2 | Killed (Γ16) |
Opcodes not in this unit but needed for other units: spin/stop-spin (0x1003/0x4) for continuous rotation, move-now/turn-now (0x1000b/0xc) for snaps, emit-sfx (0x10009) for engine effects, cache/dont-cache and dont-shade/dont-shade2 (0x10007/0x8/a/e) for the render flags of Β§5, get family (0x10041/0x42/0x43) for engine values, and the condition ladder lt/le/gt/ge/eq/neq/and/or/not (0x10051β¦0x5a). Beware: older tables label 0x55/0x56 as and/xor β they are eq/neq (Β§3.2).
5 Β· Light: how five pieces become a picture
Nothing in the 3DO says how bright anything is. Lighting is computed per drawn triangle by the renderer from three inputs: the face colour (texture average or palette index), the face normal (computed from the vertices), and one fixed light direction. This is the exact model our previews use β the constants are the production values:
// one normal per triangle (cross product of two edges) N = (B - A) Γ (C - A) // the scene light, normalized L = normalize(0.5, 0.75, -0.42) // flat shading, two-sided (abs), per triangle lam = |N Β· L| / |N| shade = base_color Γ (0.72 + 0.5 Γ lam) // clamped to 255/channel
- Flat, not smooth: every triangle gets one colour β the faceted look TA models are built for. (Our pipeline can also run
uniform_shadingfor diagnostics.) - Two-sided: the
abs()inlammeans back faces are lit too β a camera-side convention; TA models rely on being closed shells. - The floor is 0.72: even a face pointing away from the light keeps 72% brightness β the reason TA units never go pitch black on their dark side.
- Depth: a per-pixel Z-buffer decides visibility (
depth β€ zbuf + 1e-7wins ties β inclusive, so coplanar triangles from the same quad don't leave hairline cracks). Painter order is only a diagnostic fallback.
5.1 Where the base colour comes from
face_color("metal3c", 0) resolves the texture to a representative colour (the unit textures are packed into a per-piece atlas; see TA 3DO Texture Mapping for the atlas/UV reconstruction). AMPH's inventory: camouflage plates (camoflage4/5/6), armour panels (ArmCam3aβd, ArmCam4b, Armpanel3), tread sides (Tredside1/2), noise fills (noise6a/c/d), colour reference plates (colorsmd/dk), the unit logo (32xlogos), and gun metal (metal3a/c/d).tex=null, color=208 draws a flat palette colour: flare1's three flash quads are pure colour 208 (a bright yellow-white in the ARM palette). This is how every muzzle flash, glow and warning light in TA is drawn β no texture, just an index. The flash "lights" the scene only visually (the shading formula applies to it like any face); the game's real light model is this simple too.5.2 The COB render flags
Four opcodes let a script change how its pieces are drawn. None occur in ARMALT (its lighting is entirely the default), so here is the complete reference β with the unit that does use them as cross-check:
| Opcode | Effect in the game | Typical use | Example unit |
|---|---|---|---|
0x10007000 cache | piece texture is static again | undo after an animated phase | APART: Create marks 10 pieces dont-cache + dont-shade2 (glowing alien machinery β bright, dynamic surfaces) |
0x10008000 dont-cache | piece is re-textured every frame (dynamic texture: glow cycles, animated surfaces) | glow pieces, radar screens | |
0x1000a000 dont-shade | piece ignores scene shading (fullbright) | lights, emissive parts | APART / any glowing unit |
0x1000e000 dont-shade2 | a second shading exemption flag (the game's two shade stages) | same family as dont-shade |
Honest scope: these flags are game-side render-state; our preview pipeline documents them as metadata (it draws every piece with the Β§5 formula). Their exact in-game interaction (which of the two shade stages is which) is engine behaviour β flagged as such here instead of guessed. For animating units the flags never enter a loop; they are set once in Create.
5.3 The flash as light: visibility windows are timing data
AMPH's only dynamic "light" is the muzzle flash, and it is not geometry motion but visibility timing: show flare1 β¦ sleep 150 β¦ hide flare1. The compiled window is exactly [shot, shot+150 ms]. Our pipeline extracts these windows as first-class data (the vis table in Β§6) so the JS player and the GIF reproduce the flash at the right frames β and so QA can count flash pixels instead of eyeballing them:

flare1 are visible at the barrel tip. Note the flash is at the tip (flare1 sits β6.0 elmo down the barrel from the turret).
6 Β· The replay: bytecode to animation
Decoding is only complete when it runs. The published AMPH animation is a deterministic replay of the bytecode above through a documented scenario convention (the same one for every unit on the site):
| t (ms) | Event | Source |
|---|---|---|
| 0 | Create() β hide list, statics | script #2 |
| 400 | AimPrimary(8192, 3641) β +45Β° yaw / +20Β° pitch | script #5 (the site-wide aim convention) |
| 1734β¦ | 4 Γ FirePrimary() on a 950 ms grid | script #6 paced by Weapon1=CORE_CANLASER |
| 5534 | AimPrimary(0, 0) β the aim call the engine sends next | script #5 |
| 5534β¦ | RestoreAfterDelay() finishes the return | script #4 (its sleep is engine pacing) |
| 7467 | rest pose = seam | loop end |
6.1 The segment table β the animation as data
Every turn/move the scripts issued becomes one linear segment (t0, t1, channel, v0, v1) in raw units. This is the complete animation of AMPH β 14 segments, nothing hidden:
| t0 β t1 (ms) | channel | from | to | reading |
|---|---|---|---|---|
| 400.0 β 1686.0 | turret turn y | 0 | 8192 | yaw to +45Β° at 6370 BAM/s (matches Ξ/speed exactly) |
| 400.0 β 1733.7 | barrel turn x | 0 | β3641 | pitch to β20Β° β negative: the AimPrimary inversion |
| 1733.7 β 1737.7 | barrel move z | 0 | β327680 | shot 1 recoil (2 elmo, 4 ms) |
| 1883.7 β 2550.4 | barrel move z | β327680 | 0 | return at 3 elmo/s (667 ms) |
| 2683.7 β 3500.4 | barrel move z | shot 2: same pair | the salvo grid is the weapon reload (950 ms) | |
| 3633.7 β 4450.4 | barrel move z | shot 3: same pair | ||
| 4583.7 β 5400.4 | barrel move z | shot 4: same pair | ||
| 5533.7 β 6819.7 | turret turn y | 8192 | 0 | close: Aim(0,0) + RestoreAfterDelay |
| 5533.7 β 6867.4 | barrel turn x | β3641 | 0 | and back to level |
Zero-length segments (1733.7 β 1733.7 on both turn channels) are the aim waits resolving β kept because they mark the phase boundary where firing starts. The visibility table alongside: flare1 false@0, true@1740, false@1890, true@2690, false@2840, true@3640, false@3790, true@4590, false@4740 β four 150 ms flashes, one per shot. Shot times logged for the effects layer: 1883.7, 2833.7, 3783.7, 4733.7 ms.

7 Β· Validation: what "100%" is checked against
| Gate | Method | AMPH result |
|---|---|---|
| Frame count | PNGs on disk == printed count == scenario length | 94 == 94 == 94 |
| GIF honesty | n_frames == number of frame runs (PIL merges identical neighbours; a static render collapses to 1) | 74 runs / 94 PNGs β merges are the hold frames, movement is proven below |
| Seam | frame 0 vs the pose at t=LOOP rendered with the loop's own camera envelope, pixel-diff | 0 diff pixels |
| Motion | frame 0 vs 4 samples across the loop (frozen-statue detector) | differs at all samples |
| Flash windows | pixel counts inside/outside the vis windows | visible exactly in [1.74, 1.89] etc. |
| Value sanity | every segment's v0 chains to the previous v1 (servo semantics), no t1 < t0 | 14/14 chain clean |
env_times=[0, LOOP], the same envelope the frames used. Corollary: a per-frame camera fit makes a moving unit pulse; the envelope is computed once from the full loop and reused (the camera constants are baked into the playback data: center, extent, yaw 0.5, pitch β0.785).8 Β· The round trip: proof by recompilation
Sections 1β7 reconstruct the bytecode as source. The strongest possible check is to hand that source to a real period compiler and compare the machine output byte for byte. That test was run while writing this article.
cc.tauniverse.com/boscompiler.html, Β©2003 Central Consciousness; both with source, both Windows binaries β run under Wine) compile the reconstructed ARMALT.BOS from Β§3.4 into a fresh ARMALT.COB. The original file was built by a different compiler entirely β COBBLER 4.0, Β©1998 Digital Concept Software ([email protected]) β whose signature blob sits in the original's dword region 39β75 (Β§2.2). The community compiler knows this blob too: its source calls the marker Opcode_CobblerCrap = $6C697542 ("Cobbler signature β First 4 bytes are 'Buil'") and skips it when reading COB files.Result
| Script | Ops (orig / new) | Structure |
|---|---|---|
RockUnit | 39 / 39 | identical β opcodes and operands |
HitByWeapon | 9 / 9 | identical |
Create | 11 / 11 | identical |
SetMaxReloadTime | 20 / 20 | identical |
RestoreAfterDelay | 37 / 37 | identical |
AimPrimary | 27 / 27 | identical |
FirePrimary | 8 / 8 | identical |
QueryPrimary | 8 / 8 | identical |
AimFromPrimary | 8 / 8 | identical |
SweetSpot | 204 / 200 | same vocabulary; the original's if/else ladder lowers to one skip-jump per branch, the community compiler falls through with shared targets |
Killed | β / β | same constants and flag words; one encoding quirk below |
Nine of eleven scripts compile back to bit-identical instruction streams β same opcodes, same operand words (speeds 9100/3640/19110/5460/6370/2730, the 150 ms flash, the 3 s reload, the 29999 sleep, all piece indices). The two survivors differ only in how branches are lowered and in one argument-slot detail (store-arg 0 vs store-arg 1 for the severity out-parameter β a compiler-personality difference in how out-parameter slots are numbered, not a semantic difference).
Reproduce it
# toolchain: BOS_to_BASM.exe + BASM.exe under Wine, ini in [Settings] # section (IncludePaths, AssemblerExeName=BASM.exe, PauseOnException=0) wine BOS_to_BASM.exe ARMALT.BOS # -> ARMALT.basm -> ARMALT.cob python3 _cob_dis.py ARMALT.cob # structure dump # compare op streams with jumps masked (targets shift with region offsets)
The reconstructed ARMALT.BOS written for this test differs from Β§3.4 only in syntax required by the compiler: pieces need an explicit piece base, flare1, turret, barrel; declaration (which is what the mysterious 0x10051000 dwords encode in other units' COBs), and the query out-parameter cannot be named piece (reserved word).
9 Β· Glossary and where to go next
- BAM β binary angle measure: 65536 = 360Β°, signed int32.
- elmo β TA's world unit (β 1 meter-ish); raw = elmo Γ 163840.
- piece β one transform node of the 3DO tree; COB animates pieces, never vertices.
- channel β one animatable axis of one piece (
barrel zmove,turret yturn). - segment β one linear slide/turn in the replay table.
- seam β the loop boundary; must be pixel-identical.
- out-parameter β an engine result written into an argument slot (
store-arg). - engine-side β effects the game spawns outside the script (missiles, smoke, explosions).
- COB Bytecode β the container format and the recovered Annihilator sequence.
- COB-Exact Unit Animation β the transform/sign conventions and engine-effect gaps, with evidence from 18+ units.
- TA 3DO Texture Mapping β how UVs and texture atlases are reconstructed.
- Unit Building Basics β FBI/TDF definitions and the scripting overview.
- AMPH render control page β the frame-by-frame player for the loop documented here.
python3 _cob_dis.py ARMALT.COB (any COB β the script prints the index, pieces and every instruction with unit conversions). Render: run the aim/fire family driver with the unit id; it executes the bytecode through a stack-machine interpreter (_cob_vm.py), converts at the matrix boundary only, and writes the segment table, PNG frames, GIF and the playback bundle. All conventions on this page are the ones the tooling implements β if your decoder disagrees with a number here, the annotated bytecode is the arbiter.