Todas las páginas de formatos ↓
Unidades, scripts y configuración

3DO: modelos 3D jerárquicos

Fuente: coreprime/kbot @ 0ab97bf · Editar en GitHub

Un archivo .3do es un árbol de objetos 3D: vértices, primitivas y referencias a texturas organizados en una jerarquía de padres e hijos. Lo usan todas las unidades del juego, todos los elementos estáticos del mapa que tienen geometría y todos los proyectiles con modelo 3D. Un 3DO no contiene datos de animación; el script COB de la unidad mueve las piezas durante la partida.

Tip

Pruébalo tú mismo.

# 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

Consulta la referencia de kbot mount de la CLI.

Desde Go. Usa 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))
}

Por qué 3DO está construido así

.3do fue la respuesta de Cavedog a la pregunta «¿cómo dejamos que los scripts animen una unidad sin cálculos matriciales costosos?», y la respuesta es jerarquía + transformaciones por pieza. La unidad es un árbol de piezas con nombre:

armcom (root)
├── pelvis
│   ├── torso
│   │   ├── lupperarm
│   │   │   └── lforearm
│   │   │       └── lhand
│   │   ├── rupperarm  ...
│   │   └── head
│   ├── lthigh
│   │   └── lleg
│   └── rthigh
│       └── rleg
└── ...

Cuando el script COB dice turn rupperarm to x-axis <30> speed <60>;, el motor gira exactamente esa pieza, y todo lo que cuelga de ella (antebrazo, mano, boca del arma) la sigue automáticamente. No hay pesos de skinning ni huesos en el sentido moderno; cada pieza es una submalla rígida unida a su padre con un desplazamiento fijo.


Estructura en disco

Cada objeto es una cabecera de 52 bytes seguida de los datos de sus arrays con nombre. La cabecera contiene los desplazamientos de sus hijos, hermanos, vértices y primitivas.

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;

Un vértice son tres componentes int32 en coma fija:

typedef struct { int32 X, Y, Z; } Vertex;

La convención de coma fija es parte entera = unidad del mundo del juego; divide por 65 536 para la precisión por debajo de la unidad. Las unidades de Cavedog suelen medir de decenas a cientos de unidades del mundo de ancho.

Una primitiva es una cara (o un solo punto, o una línea):

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;

El array de índices de vértices al que apunta OffsetToVertexIndexArray es una lista plana de índices uint16 en el array de vértices local del objeto (no en un pool global).


Recorrido de la jerarquía

Cada cabecera de objeto apunta al primer hijo y al siguiente hermano. Es la clásica codificación de árboles «primer hijo / siguiente hermano», con la que es fácil escribir un recorrido en profundidad:

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)

El objeto raíz está en el desplazamiento 0 del archivo. En todos los modelos de la versión comercial su SiblingOffset es 0, pero el juego también sigue la cadena de hermanos de la raíz: cada hermano de la raíz (y su subárbol) se carga como una pieza de nivel superior más, después del subárbol de la raíz, y se dibuja en sus propias coordenadas de vértice: la transformación de piezas del juego empieza en la raíz y no les aplica ningún desplazamiento de pieza. El JSON del modelo de kbot los incluye como rootSiblings, y el explorador de recursos los cuenta y los enumera. Cada nombre de pieza del script COB debe coincidir con un nombre de pieza de este árbol (sin distinguir mayúsculas); si no coincide, la animación del script no hará nada, sin avisar.


Texturizado

OffsetToTextureName apunta a una cadena ASCII que nombra una secuencia de anims/textures.gaf (o de su equivalente en TA: Kingdoms). Algunas cosas que conviene saber:

  • No hay coordenadas UV. El motor genera las UV a partir del orden de los vértices de la primitiva. Para «girar» una textura, se reordenan los índices de vértice del VertexIndexArray de la primitiva. El editor original 3do Builder lo ofrecía como un control de «orientación de la textura».
  • IsColored es una palabra de flags y solo importa el bit 0. Más de la mitad de las primitivas de la versión comercial guardan en él otros valores (caras texturizadas con el bit 0 a cero, caras sin textura con el bit 0 activado). Una cara con el bit 0 activado se rellena con el byte bajo de ColorIndex.
  • OffsetToTextureName == 0 significa sin textura. Si además el bit 0 de IsColored está a cero, la primitiva no se dibuja: Cavedog lo usa para caras que solo sirven de colisión o de puro anclaje para scripts.
  • Un nombre de textura que no se resuelve (se busca sin distinguir mayúsculas) rellena la cara con el índice de la paleta 0xd1, tenga color o no.
  • En TA solo se texturizan las caras de cuatro esquinas, con la esquina 0 en el téxel superior izquierdo de la textura y las demás en sentido horario. Un triángulo o un polígono de n lados texturizado y sin color no se dibuja (los modelos de TA: Kingdoms sí los texturizan).
  • Las caras solo se dibujan por delante: por el lado hacia el que mira el orden de sus esquinas según la regla de la mano derecha, en las coordenadas del archivo.
  • En los archivos creados por Cavedog, el pool de nombres de texturas empieza en el desplazamiento 0x34 del archivo (justo después de la cabecera del objeto raíz). Es una convención, no un requisito del formato.

Important

Cómo dibuja el juego una primitiva (Primitive.Style de kbot-io):

if (texture_name != "" && !texture_found)  fill(0xd1);
else if (IsColored & 1)                   fill(ColorIndex & 0xff);
else if (texture_found && corners == 4)   texture();
else                                      /* not drawn */;

Equivocarse en esto es el error de renderizado de 3DO más común: un modelo con caras solo de colisión acaba relleno de colores aleatorios de la paleta. El JSON de modelos del visor de unidades de kbot aplica estas reglas en el servidor (cada primitiva lleva style, fillIndex y colorRGB, y las caras que el juego nunca dibuja se dejan fuera), y las vistas previas de objetos del panel de elementos se dibujan con ellas.


Ejemplo desarrollado: armsy.3do (Arm Shipyard)

El recorrido original de Dark Rain usaba el Arm Shipyard. Al leer la cabecera raíz se obtiene:

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)

Al seguir la cadena de hijos y hermanos se obtiene el árbol de piezas:

base
├── turret1
│   └── nano1
│       └── beam1
├── turret2
│   └── nano2
│       └── beam2
├── slip
├── light
├── explode
├── explode1
└── explode2

Algunas hojas, como slip, son objetos de un solo vértice sin primitivas: solo existen como puntos de anclaje para los scripts COB (emit-sfx from slip;, move slip to z-axis [-10] speed [20];).


Escollos

Warning

El eje vertical es Y, no Z. Las herramientas de Cavedog y la mayoría de la documentación de terceros usan Y como eje vertical. Si importas un 3DO en un motor con Z hacia arriba (Blender, el Unreal moderno) sin intercambiar los ejes, todas las unidades quedan tumbadas.

  • Los vértices son locales al objeto, no globales. Dos objetos pueden tener arrays de vértices idénticos: es señal de geometría reflejada, no de datos compartidos.
  • OffsetToSelectionPrim es un índice, y esa primitiva nunca se dibuja. El juego la mueve a la posición 0 y se salta la posición 0. Los modelos de la versión comercial también guardan índices en las piezas hijas; -1 significa ninguna. El JSON del modelo de kbot conserva la primitiva oculta marcada como hidden y apunta selectionPrim hacia ella.
  • Always0 es de verdad siempre 0 en el contenido observado. Desde 1998 es el campo de «avisadnos si encontráis un contraejemplo», y nadie lo ha encontrado.
  • La escala de coma fija es 1 / 65536, es decir, los 16 bits bajos de cada coordenada int32 son la parte fraccionaria. Un vértice escrito como 0x00010000 vale «1,0 unidades del mundo».
  • Los nombres de textura no distinguen mayúsculas, pero las conservan. El motor calcula el hash de las cadenas en minúsculas; el archivo guarda las mayúsculas y minúsculas que produjera el editor.
  • TA: Kingdoms usa una variante evolucionada con datos adicionales por primitiva y un modelo de skinning de verdad. kbot no la analiza por ahora; el proyecto iiCompleteDestruction (consulta el CLAUDE.md de kbot) tiene código funcional de mallas de TA:K.

Tamaños habituales

Recurso Rango observado en los objects3d/*.3do de Cavedog
Tamaño del archivo 400 B – 60 KB
Vértices por modelo 8 – 1000 (comandante: ~250)
Primitivas por modelo 6 – 800
Piezas por modelo 1 – 80 (Krogoth)
box.3do (cubo trivial) 420 B
armcom.3do (Arm Commander) ~25 KB
corkrog.3do (Krogoth) ~60 KB
Cabecera por objeto siempre 52 bytes
Cabecera por primitiva siempre 32 bytes

Apéndice: disección hexadecimal completa de objects3d/box.3do

box.3do es un cubo de 420 bytes: un objeto raíz, 8 vértices, 6 caras cuadriláteras, 4 texturas distintas y ningún hijo. Es lo bastante pequeño para recorrerlo de principio a fin y ejercita todos los campos del formato.

Volcado hexadecimal completo:

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. Cabecera del objeto raíz (0x00..0x34)

Desplazamiento Bytes Campo Valor
0x00 01 00 00 00 VersionSignature 1 ✓
0x04 08 00 00 00 NumberOfVertexes 8 (esquinas del cubo)
0x08 06 00 00 00 NumberOfPrimitives 6 (caras del cubo)
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 (la raíz no tiene hermanos)
0x30 00 00 00 00 OffsetToChildObject 0 (hoja: sin hijos)

2. Pool de nombres de texturas (0x34..0x50)

Cuatro cadenas terminadas en NUL, una detrás de otra:

Desplazamiento Cadena
0x34 "ArmV3b"
0x3B "ArmV3c"
0x42 "ArmV3a"
0x49 "ArmV3d"

Hacen referencia a secuencias de anims/textures.gaf (o de una hoja de paleta de TA:K). La convención de Cavedog coloca el pool de texturas en 0x34, justo después de la cabecera raíz, pero el formato solo exige que los desplazamientos sean válidos; las cadenas pueden estar en cualquier sitio.

3. Array de índices de vértices (0x50..0x80)

Un bloque plano uint16[24]: seis grupos de cuatro índices, uno por cada cara cuadrilátera. Leyendo de dos en dos bytes:

Desplazamiento Índices Lo usa la primitiva
0x50 1, 2, 0, 3 Primitiva 0
0x58 2, 6, 4, 0 Primitiva 1
0x60 6, 2, 1, 5 Primitiva 2
0x68 5, 1, 3, 7 Primitiva 3
0x70 0, 4, 7, 3 Primitiva 4
0x78 6, 5, 7, 4 Primitiva 5

Cada fila son «las cuatro esquinas (en sentido antihorario) de una cara del cubo», expresadas como índices en el array de vértices de abajo.

4. Array de vértices (0x80..0xE0)

Ocho vértices × (int32 X, int32 Y, int32 Z) = 96 bytes. Leyendo los tres primeros en little-endian:

# Bytes (X, Y, Z) como int32 con signo
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 en X y Z sitúa las esquinas a ±4096 unidades de coma fija. Y va de 0 (la base) a +0x1F8000 (la parte superior del cubo). Aplica la escala de coma fija de 1/65 536 y obtienes un cubo de unas 0,0625 unidades del mundo en X/Z, con Y llegando a unas 31 unidades del mundo de altura: las herramientas de creación de TA usan una convención de Y estirada para los «objetos altos».

5. Array de primitivas (0xE0..0x1A0)

Seis primitivas × 32 bytes. Leyendo la primitiva 0 en 0xE0:

Desplazamiento Bytes Campo Valor
0xE0 00 00 00 00 ColorIndex 0 (se ignora: tiene textura)
0xE4 04 00 00 00 NumberOfVertexIndexes 4 (cuadrilátero)
0xE8 00 00 00 00 Always0 0
0xEC 50 00 00 00 OffsetToVertexIndexArray 0x50 (la primera cuaterna de arriba)
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 (con textura)

Resumen de las seis primitivas:

# Desplazamiento VtxIdxArray TextureName Textura
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

Tres caras comparten la textura ArmV3c y las otras tres tienen una propia. Reutilizar una textura no es más que reutilizar un desplazamiento del pool de texturas: el archivo no elimina duplicados de forma explícita.

6. Nombre del objeto (0x1A0..0x1A4)

424f 5800   →  "BOX\0"

Tamaño total del archivo: 0x1A4 = 420 bytes. ✓

Cómo leerlo con kbot

# The web UI dissects 3DOs interactively
kbot mount $(kbot ctx path) --server
# → browse to objects3d/box.3do

Desde 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]

Ejemplos reales en el catálogo de referencia

Unidades ordenadas por la complejidad de su 3DO (el campo Objectname del FBI es el nombre de archivo del modelo sin extensión; consulta reference-ta/ta-units.md):

  • Triviales: BOX (el recorrido de arriba; 420 bytes, 8 vértices, 6 cuadriláteros) y la mayoría de las minas (ARMMINE1–6, CORMINE1–6).
  • De una sola pieza: ARMRAD (radar), ARMTIDE (generador mareomotriz); mallas de un solo objeto que van bien para estudiar la estructura de las primitivas.
  • Kbots bípedos: ARMCK, ARMHAM, ARMPW; jerarquías de varias piezas que puedes comparar con el script Walk de sus COB.
  • Vehículos con torreta: ARMBULL, ARMSAM, CORREAP; muestran el patrón padre/hijo turret1 → barrel1.
  • Los modelos más grandes: CORKROG (Krogoth) y ARMCOM (Commander), los archivos .3do más complejos del juego.

Véase también

  • COB: órdenes de script que animan las piezas de un 3DO por su nombre.
  • GAF: páginas de texturas (anims/textures.gaf) a las que hacen referencia las primitivas 3DO.
  • PAL: la paleta con la que se colorean las primitivas sin textura.
  • FBI: definiciones de unidades que hacen referencia a modelos 3DO mediante ObjectName (= nombre de archivo del 3DO sin extensión).
  • Glosario: piece, anchor object, fixed-point.