πŸ›‘οΈ TA Archive

One Unit, 100% Decoded: AMPH (ARMALT) β€” 3DO, COB to BOS, Masks, Light

Knowledge Base Β· Worked Example Β· 100% Coverage

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.

Why this unit? AMPH β€” the Amphibious Heavy Laser Tank (Armada, level 2) β€” is small enough to cover completely (5193 bytes of mesh, 2093 bytes of bytecode, 447 code dwords, 11 scripts) yet exercises every mechanism a unit can have: piece hierarchy, engine hooks, hidden pieces, a reload timer, a restore thread, aiming with inverted pitch, muzzle flashes, recoil, out-parameter queries, and a four-tier death sequence. Nothing in the file is left unexplained.

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:

FileSizeSHA-256What it is
ARMALT.3DO51931f6a910e8286d6e6b7f91af4c9333bdc31a9534a731f95e895367cad1446b524the mesh: pieces, vertices, textured polygons
ARMALT.COB2093bf1bac7f8f54da43b72b197aadea155ff95c0ca1e1cb616befba6171b73fd580the compiled animation + logic scripts
ARMALT.FBI1391b4a328120d243d6cfec8aa023c4f3ce6fd1bdbcd70a2e0a52f29633f0873ed64the 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

#PieceParentOffset (raw 3DO units)Offset (elmo)VertsPrims
0baseβ€” (root)(0, 0, 0)(0, 0, 0)10829
1turretbase(0, 851968, 393216)(0, 5.2, 2.4)6818
2barrelturret(0, 98304, βˆ’720896)(0, 0.6, βˆ’4.4)93
3flare1barrel(0, 0, βˆ’983040)(0, 0, βˆ’6.0)73
4flare2base(0, 1179648, 524288)(0, 7.2, 3.2)10

Raw unit: 1 elmo = 163840 (851968 Γ· 163840 = 5.2 exactly β€” 3DO coordinates are integral elmoΒ·163840, so every offset divides cleanly).

base108 v Β· 29 primsturret+5.2 y Β· +2.4 zbarrel+0.6 y Β· βˆ’4.4 zflare1βˆ’6.0 z Β· color 208flare21 vert Β· 0 prims Β· COB-ghostparent links Β· 3DO world offsets (elmo) Β· COB animates along these axesbarrel points βˆ’z; COB recoil moves barrel z to βˆ’2 elmo and back

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:

PieceTextures (prim count)Specials
basecamoflage4Γ—2, camoflage5Γ—4, camoflage6Γ—6, ArmCam3bΓ—3, ArmCam3cΓ—2, ArmCam3dΓ—5, ArmCam4bΓ—2, Tredside1Γ—2, Tredside2Γ—2, 32xlogosΓ—21 untextured prim (tex null, color 0 β€” never drawn)
turretcolorsmdΓ—1, colorsdkΓ—3, ArmCam3aΓ—2, ArmCam3bΓ—1, ArmCam3cΓ—1, Armpanel3Γ—1, noise6aΓ—2, noise6cΓ—1, noise6dΓ—33 untextured prims (invisible)
barrelmetal3aΓ—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
Two invisible kinds β€” do not confuse them. A prim with 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.
AMPH zero-pose render
The zero-pose: the mesh through the render pipeline with no script applied (CobAnim(model, {}, [])) β€” the assembly reference for every animation judgement.
AMPH animation frame 0
Animation frame 000: the rest pose after 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):

#FieldValueHexMeaning
0versionSignature40x00000004TA (Kingdoms uses 6)
1numScripts110x0000000bscript names
2numPieces40x00000004piece names (the COB knows only 4!)
3lengthOfScripts4470x000001bfcode size in DWORDs
4numberOfStaticVars10x00000001global variables (s0)
5reserved00x00000000always 0
6offsetToScriptCodeIndexArray18320x0000072811 DWORD start offsets
7offsetToScriptNameOffsetArray18760x0000075411 DWORD absolute file offsets
8offsetToPieceNameOffsetArray19200x000007804 DWORD absolute file offsets
9offsetToScriptCode440x0000002ccode section starts right after the header
10offsetToNameArray19360x00000790NUL-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:

#ScriptDWORDsRole
0RockUnit0 … 76engine hook: chassis rock when firing
1HitByWeapon76 … 115engine hook: jolt when hit
2Create115 … 124unit spawn: hide list + defaults
3SetMaxReloadTime124 … 135engine sets the restore delay
4RestoreAfterDelay135 … 155the only way home for the gun
5AimPrimary155 … 192turret/bullet aiming
6FirePrimary192 … 219flash + recoil
7QueryPrimary219 … 227out-param: muzzle piece
8AimFromPrimary227 … 235out-param: aim origin piece
9SweetSpot235 … 243out-param: weak point
10Killed243 … 447death: four damage tiers
Look past the 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, not turret.
  • flare2 exists 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

State
  • PC β€” the dword index (we print it as the number left of each line).
  • Stack β€” operands for every operation; push-const/push-arg/push-static feed 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.
Control flow
  • return v ends a script (v is the script's result).
  • jump/jump-if-zero are the only branches β€” if, while and the compare operators are all compiled into push/compare/jump triples.
  • call-script runs a sub to completion; start-script forks a thread the engine interleaves.
  • sleep ms is the clock: it advances the unit's script time and lets other threads run.
  • signal v / set-signal-mask v implement engine interrupts: any thread whose mask shares a bit with a raised signal is killed.

3.2 The anatomy of an opcode word

one DWORD = 32 bit0x100namespaceop class0x10001 = move …trailing dwordspiece/axis/target indiceslow bitspush kind: 1/2/4Example words from ARMALT.COB:0x10021001=push-const(kind 1)0x10021002=push-arg(kind 2)0x10021004=push-static(kind 4)0x10002000=turn+2 trailing0x10013000=sleep0x10066000=jump-if-zero+1 trailing (target dword)
The mislabel trap. The classic opcode tables label 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;   // s0

Create() { 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;
}
The three traps in nine instructions. (1) Interrupt protocol: 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 c0

228 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
The classic muzzle bug. The engine asks 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 …
The complete flag-word map (exact values from the bytecode):
Severityclass (arg1)barrelbaseflare1turret
≀ 25132|256 = 28832|2048 = 208032|256 = 28832|512 = 544
≀ 5024|256 = 26032|2048 = 20804|256 = 2601|512 = 513
≀ 9934|8|16|2|256 = 28632|2048 = 20804|8|16|2|256 = 2861|512 = 513
else34|8|16|2|256 = 28632|2048 = 20804|8|16|2|256 = 2861|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:

FlagValueMeaning (original comment)
SHATTER1the piece shatters into debris instead of flying off whole
EXPLODE_ON_HIT2explodes when it hits the ground
FALL4falls with gravity instead of flying off
SMOKE8smoke trail
FIRE16fire trail
BITMAPONLY32no fly-off/shatter β€” only a bitmap explosion is rendered
BITMAP1..BITMAP5256…4096explosion bitmap type 1–5
BITMAPNUKE8192nuke explosion bitmap
BITMAPMASK16128mask 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:

FormatDefinitionProof from ARMALT.COB
Angle / rotationBAM 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 / offsetraw int32, 163840 = 1 elmo
elmo = 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 speedBAM per second9100 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 speedelmoΒ·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.
Timemillisecondssleep 150 = the flash window; sleep s0 = the restore delay (3000 default, or 2Γ— the 950 ms reload).
Degrees vs milliseconds is undecidable from the bits. 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)NameTrailingStackSeen in ARMALT
0x10021001push-const v1+1everywhere
0x10021002push-arg n1+1RockUnit, AimPrimary, Killed
0x10021004push-static n1+1RestoreAfterDelay
0x10023002store-arg n ← v1βˆ’1Query/AimFrom/SweetSpot, Killed
0x10023003store-static n ← v1βˆ’1Create, SetMaxReloadTime
0x10002000turn piece axis2βˆ’2RockUnit, Aim, Restore
0x10001000move piece axis2βˆ’2FirePrimary
0x10005000/0x6000show / hide10Create, FirePrimary, Killed
0x10013000sleep ms0βˆ’1FirePrimary, Restore
0x10011000/0x12000wait-for-turn/wait-for-move20RockUnit, Aim, Fire
0x10052000le (compare)0βˆ’1Killed's ladder
0x10032000sub0βˆ’1AimPrimary (the pitch inversion)
0x10033000mul0βˆ’1SetMaxReloadTime
0x10036000or (bitwise!)0βˆ’1Killed's flag words
0x10066000jump-if-zero β†’1βˆ’1Killed's ladder
0x10061000/0x62000start-script/call-script2varAimPrimary (start-script #4)
0x10065000return v0βˆ’1all scripts
0x10067000/0x68000signal/set-signal-mask0βˆ’1AimPrimary
0x10071000explode piece1βˆ’2Killed (Γ—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:

The shading formula
// 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_shading for diagnostics.)
  • Two-sided: the abs() in lam means 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-7 wins 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

Textured prims β€” 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).
Palette prims β€” 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:

OpcodeEffect in the gameTypical useExample unit
0x10007000 cachepiece texture is static againundo after an animated phaseAPART: Create marks 10 pieces dont-cache + dont-shade2 (glowing alien machinery β€” bright, dynamic surfaces)
0x10008000 dont-cachepiece is re-textured every frame (dynamic texture: glow cycles, animated surfaces)glow pieces, radar screens
0x1000a000 dont-shadepiece ignores scene shading (fullbright)lights, emissive partsAPART / any glowing unit
0x1000e000 dont-shade2a 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:

AMPH muzzle flash frame
Frame 022 (t = 1.76 s) β€” inside the flash window [1.74, 1.89 s]: the three color-208 quads of 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).
AMPH recoil frame
Frame 024 (t = 1.92 s) β€” flash off, barrel recoiled 2 elmo (compare the gap between barrel and turret). Diffing the two frames isolates flash pixels from movement pixels β€” the flash-proof method.

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)EventSource
0Create() β€” hide list, staticsscript #2
400AimPrimary(8192, 3641) β€” +45Β° yaw / +20Β° pitchscript #5 (the site-wide aim convention)
1734…4 Γ— FirePrimary() on a 950 ms gridscript #6 paced by Weapon1=CORE_CANLASER
5534AimPrimary(0, 0) β€” the aim call the engine sends nextscript #5
5534…RestoreAfterDelay() finishes the returnscript #4 (its sleep is engine pacing)
7467rest pose = seamloop 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)channelfromtoreading
400.0 β†’ 1686.0turret turn y08192yaw to +45Β° at 6370 BAM/s (matches Ξ”/speed exactly)
400.0 β†’ 1733.7barrel turn x0βˆ’3641pitch to βˆ’20Β° β€” negative: the AimPrimary inversion
1733.7 β†’ 1737.7barrel move z0βˆ’327680shot 1 recoil (2 elmo, 4 ms)
1883.7 β†’ 2550.4barrel move zβˆ’3276800return at 3 elmo/s (667 ms)
2683.7 β†’ 3500.4barrel move zshot 2: same pairthe salvo grid is the weapon reload (950 ms)
3633.7 β†’ 4450.4barrel move zshot 3: same pair
4583.7 β†’ 5400.4barrel move zshot 4: same pair
5533.7 β†’ 6819.7turret turn y81920close: Aim(0,0) + RestoreAfterDelay
5533.7 β†’ 6867.4barrel turn xβˆ’36410and 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.

AMPH loop β€” 7467 ms (to scale)aim (1.3 s)4-shot salvo on the 950 ms reload gridclose + restore (1.9 s)0 ms173455347467barrel z (recoil): 4 ms out, 667 ms back β€” the spikes are the shotsturret y: +45Β° hold through the salvo, then homeflare1 windows (150 ms each)
AMPH QA composite, four labelled frames
The QA composite used to sign the loop off: 4 labelled frames (phase midpoints), checked for pose correctness (turret visibly slewed at t=0.80, flash at the barrel tip at t=1.75, recoil at t=1.91, rest restored at t=6.92), assembly integrity and artefacts.

7 Β· Validation: what "100%" is checked against

The gates this loop passed (every published animation runs them; failures reject instead of rendering something pretty):
GateMethodAMPH result
Frame countPNGs on disk == printed count == scenario length94 == 94 == 94
GIF honestyn_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
Seamframe 0 vs the pose at t=LOOP rendered with the loop's own camera envelope, pixel-diff0 diff pixels
Motionframe 0 vs 4 samples across the loop (frozen-statue detector)differs at all samples
Flash windowspixel counts inside/outside the vis windowsvisible exactly in [1.74, 1.89] etc.
Value sanityevery segment's v0 chains to the previous v1 (servo semantics), no t1 < t014/14 chain clean
Why the seam test needs the camera envelope. Rendering the t=LOOP pose as a standalone image re-fits the camera to that single frame β€” the fit differs slightly from the loop's shared envelope and the diff fills with thousands of fake pixels. The loop pose must be rendered with 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.

The setup. ggs' BOS to BASM v0.82 and Mafia's BASM v0.8.1.1 (the community toolchain at 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

ScriptOps (orig / new)Structure
RockUnit39 / 39identical β€” opcodes and operands
HitByWeapon9 / 9identical
Create11 / 11identical
SetMaxReloadTime20 / 20identical
RestoreAfterDelay37 / 37identical
AimPrimary27 / 27identical
FirePrimary8 / 8identical
QueryPrimary8 / 8identical
AimFromPrimary8 / 8identical
SweetSpot204 / 200same 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).

What this proves. The reconstruction in Β§3.4 is not merely plausible β€” it is the unit's program: a second, independent compiler accepts it and reproduces the original machine code. Combined with Β§2.2 (the two checksums) and Β§7 (the render checks), every layer of AMPH is now closed: mesh, container, bytecode, source, masks, light, animation β€” 100% decoded, and the decode round-trips.

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

Terms
  • 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 z move, turret y turn).
  • 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).
Related pages
Reproduce it yourself. Disassemble: 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.