3DO — Hierarchical 3D Models
A
.3dofile is a tree of 3D objects — vertices, primitives, and texture references arranged in a parent/child hierarchy. Every unit in the game, every static map feature with geometry, and every projectile with a 3D model uses this format. There is no animation data in a 3DO; pieces are moved at runtime by the unit's COB script.
Tip
Try it yourself.
# 3DO is not currently surfaced as a kbot CLI subcommand, but it's
# rendered live by the Asset Explorer:
kbot mount ~/games/totala --server # opens the web UI
# then browse to objects3d/armcom.3do
See the CLI kbot mount reference.
From Go. Use formats/objects3d:
import "github.com/coreprime/kbot/formats/objects3d"
model, _ := objects3d.LoadFromFile("objects3d/armcom.3do")
fmt.Println(model.Root.Name, "—",
model.TotalVertices(), "verts,",
model.TotalPrimitives(), "prims,",
"textures:", model.Textures())
for _, obj := range model.AllObjects {
fmt.Printf(" %s (%d verts)\n", obj.Name, len(obj.Vertices))
}
Why 3DO is built the way it is
.3do was Cavedog's answer to "how do we let scripts animate a unit
without expensive matrix math?" — the answer is hierarchy + per-piece
transforms. The unit is a tree of named pieces:
armcom (root)
├── pelvis
│ ├── torso
│ │ ├── lupperarm
│ │ │ └── lforearm
│ │ │ └── lhand
│ │ ├── rupperarm ...
│ │ └── head
│ ├── lthigh
│ │ └── lleg
│ └── rthigh
│ └── rleg
└── ...
When the COB script says turn rupperarm to x-axis <30> speed <60>;,
the engine rotates exactly that piece — and everything attached below it
(forearm, hand, gun-muzzle) follows automatically. There are no skin
weights and no bones in the modern sense; every piece is a rigid sub-mesh
attached at a fixed offset from its parent.
On-disk layout
Each object is a 52-byte header followed by named-array payloads. The header carries offsets to its children, siblings, vertices, and primitives.
typedef struct {
int32 VersionSignature; // Always 1
int32 NumberOfVertexes;
int32 NumberOfPrimitives;
int32 OffsetToSelectionPrim; // → primitive used as selection rectangle
// for the root; -1 for non-root objects
int32 XFromParent; // Fixed-point offset from parent piece
int32 YFromParent; // (Y is up; the engine renders Y-up)
int32 ZFromParent;
int32 OffsetToObjectName; // → NUL-terminated piece name string
int32 Always0;
int32 OffsetToVertexArray;
int32 OffsetToPrimitiveArray;
int32 OffsetToSiblingObject; // 0 = end of sibling chain
int32 OffsetToChildObject; // 0 = leaf piece
} Object;
A vertex is three int32 fixed-point components:
typedef struct { int32 X, Y, Z; } Vertex;
The fixed-point convention is whole-number = game world unit; divide by 65536 for sub-unit precision. Cavedog units are typically tens to hundreds of world units across.
A primitive is a face (or a single point, or a line):
typedef struct {
int32 ColorIndex; // Palette index for solid-coloured faces (low byte used)
int32 NumberOfVertexIndexes; // 1 = point, 2 = line, 3 = tri, 4 = quad
int32 Always0;
int32 OffsetToVertexIndexArray; // → uint16[NumberOfVertexIndexes]
int32 OffsetToTextureName; // → NUL string, or 0 for untextured
int32 Unknown1; // Editor scratch
int32 Unknown2; // Editor scratch
int32 IsColored; // flag word: bit 0 set = solid-coloured
} Primitive;
The vertex-index array referenced by OffsetToVertexIndexArray is a
flat list of uint16 indices into the object's local vertex array
(not a global pool).
Hierarchy traversal
Each object header points at the first child and the next sibling. This is the classic "first-child / next-sibling" tree encoding — easy to write a depth-first walk for:
def walk(file, offset, depth=0):
obj = read_object(file, offset)
yield (depth, obj.name)
if obj.child_offset:
yield from walk(file, obj.child_offset, depth + 1)
if obj.sibling_offset:
yield from walk(file, obj.sibling_offset, depth)
The root object is at file offset 0. In every retail model its
SiblingOffset is 0, but the game follows the root's sibling chain
too: each root sibling (and its subtree) is loaded as a further
top-level piece after the root's subtree, and drawn at its own vertex
coordinates — the game's piece transform starts at the root and applies
no piece offsets to them. kbot's model JSON carries them as
rootSiblings, and the asset explorer counts and lists them.
Every piece name in the COB script must match a piece name in this tree
(case-insensitive); a mismatch will cause the script's animation to
silently no-op.
Texturing
OffsetToTextureName points at an ASCII string that names a sequence
in anims/textures.gaf (or its TA: Kingdoms equivalent). A few things
to know:
- There are no UV coordinates. The engine generates UVs from the
primitive's vertex order. To "rotate" a texture you re-order the
vertex indices in the primitive's
VertexIndexArray. The original 3do Builder editor exposed this as a "texture orientation" knob. IsColoredis a flag word and only bit 0 matters. More than half of the retail primitives store other values in it (textured faces with bit 0 clear, untextured ones with bit 0 set). A face with bit 0 set is filled with the low byte ofColorIndex.OffsetToTextureName == 0means no texture. If bit 0 ofIsColoredis clear too, the primitive is not drawn — Cavedog uses this for collision-only or pure script-anchor faces.- A texture name that does not resolve (looked up case-insensitively)
fills the face with palette index
0xd1, coloured or not. - Only four-corner faces are textured in TA, corner 0 at the texture's top-left texel and then clockwise. An uncoloured textured triangle or n-gon is not drawn (TA: Kingdoms models texture them).
- Faces are drawn from the front only: the side their corner order faces by the right-hand rule, in file coordinates.
- In Cavedog-built files the texture name pool starts at file offset
0x34(immediately after the root object's header). This is a convention, not a format requirement.
Important
How the game draws a primitive (kbot-io's Primitive.Style):
if (texture_name != "" && !texture_found) fill(0xd1);
else if (IsColored & 1) fill(ColorIndex & 0xff);
else if (texture_found && corners == 4) texture();
else /* not drawn */;
Getting this wrong is the most common 3DO rendering bug — a model
with collision-only faces ends up filled with random palette colour.
kbot's unit viewer model JSON applies these rules server-side (each
primitive carries style, fillIndex and colorRGB, and faces the
game never draws are left out), and the feature drawer's object
previews render with them.
Worked example — armsy.3do (Arm Shipyard)
The original Dark Rain walkthrough used the Arm Shipyard. Reading the root header gives:
00000000 Object
01 00 00 00 VersionSignature = 1
C4 00 00 00 VertexCount = 196 (0xC4)
6D 00 00 00 PrimitiveCount = 109 (0x6D)
00 00 00 00 SelectionPrim = 0 (= primitive #0)
00 00 00 00 X XFromParent = 0
00 00 00 00 Y YFromParent = 0
00 00 00 00 Z ZFromParent = 0
E5 1A 00 00 → "base"
00 00 00 00 Always0
15 04 00 00 → VertexArray
45 0D 00 00 → PrimitiveArray
00 00 00 00 SiblingOffset = 0 (root has no sibling)
EA 1A 00 00 → child object (turret1)
Following the child/sibling chain produces the piece tree:
base
├── turret1
│ └── nano1
│ └── beam1
├── turret2
│ └── nano2
│ └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2
Some leaves like slip are single-vertex objects with zero
primitives — those exist only as anchor points for COB scripts
(emit-sfx from slip;, move slip to z-axis [-10] speed [20];).
Gotchas
Warning
Y is up, not Z. Cavedog's tooling and most third-party docs use Y for the vertical axis. If you import a 3DO into a Z-up engine (Blender, modern Unreal) without swapping axes, every unit lies sideways.
- Vertices are local to the object, not global. Two objects can have identical vertex arrays — that's a sign of mirrored geometry, not shared data.
OffsetToSelectionPrimis an index, and that primitive is never drawn. The game moves it into slot 0 and skips slot 0. Retail models store indices on child pieces too;-1means none. kbot's model JSON keeps the hidden primitive flaggedhiddenand pointsselectionPrimat it.Always0really is always 0 in observed content. It's been the "tell us if you find a counter-example" field since 1998 and nobody has.- Fixed-point scale is
1 / 65536— i.e. the low 16 bits of eachint32coordinate are the fractional part. A vertex written0x00010000is "1.0 world units". - Texture names are case-insensitive but case-preserving. The engine hashes lowercased strings; the file stores whatever case the editor produced.
- TA: Kingdoms uses an evolved variant with additional per-primitive
data and a true skinning model. kbot does not currently parse it; the
iiCompleteDestruction project (see kbot's
CLAUDE.md) has working TA:K mesh code.
Typical sizes
| Asset | Range observed in Cavedog objects3d/*.3do |
|---|---|
| File size | 400 B – 60 KB |
| Vertices per model | 8 – 1000 (commander: ~250) |
| Primitives per model | 6 – 800 |
| Pieces per model | 1 – 80 (Krogoth) |
box.3do (trivial cube) |
420 B |
armcom.3do (Arm Commander) |
~25 KB |
corkrog.3do (Krogoth) |
~60 KB |
| Per-object header | always 52 bytes |
| Per-primitive header | always 32 bytes |
Appendix — Full hex dissection of objects3d/box.3do
box.3do is a 420-byte cube — one root object, 8 vertices, 6 quad
faces, 4 different textures, no children. It's small enough to walk
end-to-end and exercises every field in the format.
Full hex dump:
00000000: 0100 0000 0800 0000 0600 0000 0400 0000 ← Object header
00000010: 0000 0000 0000 0000 0000 0000 a001 0000 (52 bytes)
00000020: 0000 0000 8000 0000 e000 0000 0000 0000
00000030: 0000 0000 4172 6d56 3362 0041 726d 5633 ← Texture-name pool
00000040: 6300 4172 6d56 3361 0041 726d 5633 6400 "ArmV3b\0..."
00000050: 0100 0200 0000 0300 0200 0600 0400 0000 ← Vertex-index array
00000060: 0600 0200 0100 0500 0500 0100 0300 0700 (uint16 × 24)
00000070: 0000 0400 0700 0300 0600 0500 0700 0400
00000080: 0000 f0ff 0000 0000 0000 f0ff 0000 f0ff ← Vertex array
00000090: 0080 1f00 0000 1000 0000 f0ff 0080 1f00 (8 × 12 bytes)
000000a0: 0000 f0ff 0000 f0ff 0000 0000 0000 1000
000000b0: 0000 1000 0000 0000 0000 f0ff 0000 1000
000000c0: 0080 1f00 0000 1000 0000 1000 0080 1f00
000000d0: 0000 f0ff 0000 1000 0000 0000 0000 1000
000000e0: 0000 0000 0400 0000 0000 0000 5000 0000 ← Primitive 0
000000f0: 3400 0000 0000 0000 0000 0000 0000 0000 (32 bytes)
00000100: 0000 0000 0400 0000 0000 0000 5800 0000 ← Primitive 1
00000110: 3b00 0000 0000 0000 0000 0000 0000 0000
00000120: 0000 0000 0400 0000 0000 0000 6000 0000 ← Primitive 2
00000130: 4200 0000 0000 0000 0000 0000 0000 0000
00000140: 0000 0000 0400 0000 0000 0000 6800 0000 ← Primitive 3
00000150: 3b00 0000 0000 0000 0000 0000 0000 0000
00000160: 0000 0000 0400 0000 0000 0000 7000 0000 ← Primitive 4
00000170: 3b00 0000 0000 0000 0000 0000 0000 0000
00000180: 0000 0000 0400 0000 0000 0000 7800 0000 ← Primitive 5
00000190: 4900 0000 0000 0000 0000 0000 0000 0000
000001a0: 424f 5800 ← "BOX\0"
1. Root object header (0x00..0x34)
| Offset | Bytes | Field | Value |
|---|---|---|---|
0x00 |
01 00 00 00 |
VersionSignature |
1 ✓ |
0x04 |
08 00 00 00 |
NumberOfVertexes |
8 (cube corners) |
0x08 |
06 00 00 00 |
NumberOfPrimitives |
6 (cube faces) |
0x0C |
04 00 00 00 |
OffsetToSelectionPrim |
4 |
0x10 |
00 00 00 00 |
XFromParent |
0 |
0x14 |
00 00 00 00 |
YFromParent |
0 |
0x18 |
00 00 00 00 |
ZFromParent |
0 |
0x1C |
a0 01 00 00 |
OffsetToObjectName |
0x1A0 → "BOX" |
0x20 |
00 00 00 00 |
Always0 |
0 |
0x24 |
80 00 00 00 |
OffsetToVertexArray |
0x80 |
0x28 |
e0 00 00 00 |
OffsetToPrimitiveArray |
0xE0 |
0x2C |
00 00 00 00 |
OffsetToSiblingObject |
0 (root has no sibling) |
0x30 |
00 00 00 00 |
OffsetToChildObject |
0 (leaf — no children) |
2. Texture-name pool (0x34..0x50)
Four NUL-terminated strings, packed back-to-back:
| Offset | String |
|---|---|
0x34 |
"ArmV3b" |
0x3B |
"ArmV3c" |
0x42 |
"ArmV3a" |
0x49 |
"ArmV3d" |
These reference sequences in anims/textures.gaf (or in a TA:K palette
sheet). Cavedog's convention puts the texture pool at 0x34 —
immediately after the root header — but the format only requires the
offsets to be valid; the strings can live anywhere.
3. Vertex-index array (0x50..0x80)
A flat uint16[24] block — six runs of four indices, one run per quad
face. Reading two bytes at a time:
| Offset | Indices | Used by primitive |
|---|---|---|
0x50 |
1, 2, 0, 3 |
Primitive 0 |
0x58 |
2, 6, 4, 0 |
Primitive 1 |
0x60 |
6, 2, 1, 5 |
Primitive 2 |
0x68 |
5, 1, 3, 7 |
Primitive 3 |
0x70 |
0, 4, 7, 3 |
Primitive 4 |
0x78 |
6, 5, 7, 4 |
Primitive 5 |
Each row is "the four corners (in CCW order) of one cube face," expressed as indices into the vertex array below.
4. Vertex array (0x80..0xE0)
Eight vertices × (int32 X, int32 Y, int32 Z) = 96 bytes. Reading the
first three with little-endian decode:
| # | Bytes | (X, Y, Z) as signed int32 |
|---|---|---|
| 0 | 00 00 f0 ff 00 00 00 00 00 00 f0 ff |
(-0x1000, 0, -0x1000) |
| 1 | 00 00 f0 ff 00 80 1f 00 00 00 10 00 |
(-0x1000, +0x1F8000, +0x1000) |
| 2 | 00 00 f0 ff 00 80 1f 00 00 00 f0 ff |
(-0x1000, +0x1F8000, -0x1000) |
| … |
±0x1000 on X and Z place the corners at ±4096 fixed-point units. Y
sweeps from 0 (base) to +0x1F8000 (top of the cube). Apply the
1/65536 fixed-point scale and you get a roughly 0.0625-world-unit cube
in X/Z, with Y reaching about 31 world units in height — TA's authoring
tools use a stretched-Y convention for "tall props".
5. Primitive array (0xE0..0x1A0)
Six primitives × 32 bytes. Reading primitive 0 at 0xE0:
| Offset | Bytes | Field | Value |
|---|---|---|---|
0xE0 |
00 00 00 00 |
ColorIndex |
0 (ignored — textured) |
0xE4 |
04 00 00 00 |
NumberOfVertexIndexes |
4 (quad) |
0xE8 |
00 00 00 00 |
Always0 |
0 |
0xEC |
50 00 00 00 |
OffsetToVertexIndexArray |
0x50 (the first 4-tuple above) |
0xF0 |
34 00 00 00 |
OffsetToTextureName |
0x34 → "ArmV3b" |
0xF4 |
00 00 00 00 |
Unknown1 |
0 |
0xF8 |
00 00 00 00 |
Unknown2 |
0 |
0xFC |
00 00 00 00 |
IsColored |
0 (textured) |
Summarising all six primitives:
| # | Offset | VtxIdxArray | TextureName | Texture |
|---|---|---|---|---|
| 0 | 0xE0 |
0x50 |
0x34 |
ArmV3b |
| 1 | 0x100 |
0x58 |
0x3B |
ArmV3c |
| 2 | 0x120 |
0x60 |
0x42 |
ArmV3a |
| 3 | 0x140 |
0x68 |
0x3B |
ArmV3c |
| 4 | 0x160 |
0x70 |
0x3B |
ArmV3c |
| 5 | 0x180 |
0x78 |
0x49 |
ArmV3d |
Three faces share the ArmV3c texture, the other three get unique
ones. Texture reuse is just texture-pool offset reuse — the file does
not deduplicate explicitly.
6. Object name (0x1A0..0x1A4)
424f 5800 → "BOX\0"
Total file size: 0x1A4 = 420 bytes. ✓
Reading it back with kbot
# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do
From Go (formats/objects3d):
import "github.com/coreprime/kbot/formats/objects3d"
model, _ := objects3d.LoadFromFile("objects3d/box.3do")
fmt.Println(model.Root.Name) // BOX
fmt.Println(len(model.Root.Vertices)) // 8
fmt.Println(len(model.Root.Primitives)) // 6
fmt.Println(model.Textures()) // [ArmV3b ArmV3c ArmV3a ArmV3d]
Live examples in the reference catalogue
Units sorted by 3DO complexity (the Objectname field in the FBI is
the model's filename without extension — see
reference-ta/ta-units.md):
- Trivial —
BOX(the walkthrough above; 420 bytes, 8 verts, 6 quads), most mines (ARMMINE1–6,CORMINE1–6). - Single-piece —
ARMRAD(radar),ARMTIDE(tidal generator) — one-object meshes good for studying primitive layout. - Bipedal kbots —
ARMCK,ARMHAM,ARMPW— multi-piece hierarchies you can compare against theWalkscript in their COBs. - Vehicles with turrets —
ARMBULL,ARMSAM,CORREAP— show theturret1 → barrel1parent/child pattern. - Largest models —
CORKROG(Krogoth) andARMCOM(Commander) — the most complex.3dofiles in the game.
See also
- COB — script commands that animate 3DO pieces by name.
- GAF — texture pages (
anims/textures.gaf) referenced by 3DO primitives. - PAL — palette used to colour untextured primitives.
- FBI — unit definitions that reference 3DO models by
ObjectName(= 3DO filename without extension). - Glossary — piece, anchor object, fixed-point.