3D Model File Format
Binary, little-endian static (single-frame) model format.
.3D files are static (single-frame) models. For the animated multi-frame variant, see 3DC.md.
Versions
Four versions exist: v2.6, v2.7, v4.0, v5.0. See GOG vs Original CD Differences for which directories contain which versions.
- v2.6 / v2.7 — Different header semantics and face data encoding from v4.0/v5.0.
- v5.0 — Adds Section4 (SubObject BVH).
Header (64 bytes)
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 4 | [u8; 4] | version | ASCII version string, e.g. v4.0 |
| 0x04 | 4 | u32 | num_vertices | Vertex count |
| 0x08 | 4 | u32 | num_faces | Face count |
| 0x0C | 4 | u32 | radius | Bounding radius in 1/256 engine units |
| 0x10 | 4 | u32 | num_frames | Animation frame count. 1 for v4.0/v5.0 .3D files; 0 in the v2.7 static files. |
| 0x14 | 4 | u32 | offset_frame_data | Offset to frame data array |
| 0x18 | 4 | u32 | total_face_vertices | Sum of all face vertex counts. Also the entry count for the vertex-normal indirection table. |
| 0x1C | 4 | u32 | offset_section4 | Offset to Section4 (SubObject BVH). 0 when absent. Engine header copier zeroes this field at load time. |
| 0x20 | 4 | u32 | section4_count | Number of Section4 entries. |
| 0x24 | 4 | u32 | unused_24 | Unused; the engine bulk-copies the 64-byte header but never reads this field. Always 0 in v4.0/v5.0. Non-zero in 5 v2.6 files in /3dart (values 4490, 6434). Parsers should ignore it. |
| 0x28 | 4 | u32 | offset_normal_indices | Offset to vertex-normal indirection table. Each entry is a u32 file offset into the vertex-normal data section. Count = total_face_vertices. |
| 0x2C | 4 | u32 | offset_vertex_normals | Offset to normal data (f32 × 3 per normal record). |
| 0x30 | 4 | u32 | offset_vertex_coords | Offset to vertex coordinate data. |
| 0x34 | 4 | u32 | offset_face_normals | Offset to face normal data. |
| 0x38 | 4 | u32 | total_face_vertices_dup | Duplicate of field at 0x18 in v4.0/v5.0 (engine header copier copies this from file). Different value in v2.6/v2.7 but the Glide engine rejects those versions. |
| 0x3C | 4 | u32 | offset_face_data | Offset to face data. Always 64 (0x40) in v4.0/v5.0. Variable (112–3736) in v2.6/v2.7. |
v2.6/v2.7 Header Differences
The header is the same 64 bytes, but many fields have different semantics:
| Field | v4.0/v5.0 meaning | v2.6/v2.7 meaning |
|---|---|---|
| 0x18 | total_face_vertices (count) | Offset to vertex-normal data. Parsers should remap this into the 0x2C slot (vertex-normal offset) and zero this field. The engine also zeroes it during frame construction. Not a vertex count despite sharing the offset with v4.0’s total_face_vertices. |
| 0x1C | offset_section4 (0 in .3DC) | Often non-zero but zeroed by the engine header copier regardless of file value. Not Section4 (SubObject BVH does not exist in v2.6/v2.7). Discarded at load time. |
| 0x24 | Always 0 | Non-zero in 5 files (RICHA001=4490, GARDA001-004=6434) |
| 0x2C | offset_vertex_normals | Unrelated to vertex normals in v2.x files. Acts as a variant marker: 0 for v2.7 .3D, 1 for .3DC (both v2.6 and v2.7). Not an offset — treat as a flag. |
| 0x38 | Duplicate of 0x18 | Does NOT equal 0x18 in v2.7. Copied by the engine header copier. However, the Glide engine rejects v2.6/v2.7 files at load time (version byte < '4'), so this field is only used in practice for v4.0+ where it duplicates 0x18. |
| 0x3C | Always 64 | Variable (112–3736) — face data does NOT start after header |
v2.6/v2.7 Frame Data Differences
The 16-byte frame data record has different field semantics:
| Field | v4.0/v5.0 | v2.6/v2.7 |
|---|---|---|
| offset 0x00 (vertex_offset) | File offset to vertex data | Header + frame header size (always 88) — not a useful offset |
| offset 0x04 (normal_offset) | File offset to face normal data | Face data byte count. The total size in bytes of all face records. |
| offset 0x08 (reserved) | Always 0 | Gap byte count between face data end and vertex data (0 when no gap). |
| offset 0x0C (frame_type) | 0, 2, 4, or 8 | Values like 8209 (0x2011), 16402 (0x4012) — different encoding |
v2.6/v2.7 Engine Header Handling
At load time, the software renderer copies the 64-byte header and then zeroes the offset fields 0x18, 0x1C, 0x30, 0x34, 0x38, and 0x3C. It reads the file sequentially using frame_data[0].normal_offset as the face data byte count and frame_data[0].reserved as the gap between face data and vertex data, rebuilding the section layout in memory. The raw offset fields in the file header are not used for data access.
v2.6/v2.7 Face Data Differences
| Aspect | v4.0/v5.0 | v2.6/v2.7 |
|---|---|---|
| Face texture header | 5 bytes (u8 + u32) | 3 bytes (u8 + u16) |
| tex_hi values | 61–91, 255 | 0, 6, 15, 18, 20, 30, 36, 44, 50, 60, 63 |
| Solid-color faces | tex_hi = 0xFF | texture_id < 2 indicates solid color |
| Texture ID decoding | BCD-like: (raw >> 8) - 4000000 | Simple: texture_id = raw >> 7 |
| Vertex index | Direct index | Byte offset (divide by 12) |
Parsers must remap header fields before use. See External References for the remap logic.
Glide version gate: The Glide loader rejects model versions below v4.0.
This gate belongs to RGFX.EXE; it does not describe the software engine’s
v2.6/v2.7 asset path. External parsers support both model generations.
Section Layout
Sections appear in this order (offsets from header):
- Face Data — at 0x40,
num_facesvariable-size records - Vertex Coordinates —
num_vertices× 12 bytes - Face Normals —
num_faces× 12 bytes - Frame Data —
num_frames× 16-byte records - Section4 — SubObject BVH entries (when present)
- Vertex-Normal Indirection Table —
total_face_vertices×u32 - Vertex Normals —
num_vertices× 12 bytes
Frame Data
For v4.0/v5.0, located at offset_frame_data as an array of num_frames × 16-byte records. Software animations instead use the v2.6/v2.7 animation directory.
For v4.0/v5.0 .3D files this is a single entry pointing to the base geometry.
The v2.7 static files store a zero frame count; that value does not make the
static geometry empty.
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 4 | u32 | vertex_offset | File offset to vertex position data |
| 0x04 | 4 | u32 | normal_offset | File offset to face normal data |
| 0x08 | 4 | u32 | reserved | Always 0 in v4.0/v5.0. |
| 0x0C | 4 | u32 | frame_type | 0 for static .3D models in v4.0. See 3DC.md for animated frame types. |
vertex_offset and normal_offset point to the base vertex coordinate and face normal sections (same values as header fields 0x30 and 0x34).
Face Data
Located at offset_face_data (0x40 in v4.0/v5.0; variable in v2.6/v2.7). Array of num_faces variable-size records.
v4.0 / v5.0 Face Record
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 1 | u8 | vertex_count | Number of vertices in this face (3–10) |
| 0x01 | 1 | u8 | tex_hi | Per-face flags/high byte field (often 0xFF for solid-color faces) |
| 0x02 | 4 | u32 | texture_data_raw | Packed texture encoding value |
| 0x06 | 4 | u32 | unused_04 | Unused — always 0x00000000. Parsers should skip. |
| 0x0A | 8×N | FaceVertex[N] | vertices | N = vertex_count |
Texture Decoding (v4.0 / v5.0)
Solid color (if texture_data_raw >> 20 == 0x0FFF):
color_index = (texture_data_raw >> 8) & 0xFFtex_hiis always 0xFF for solid-color faces.
Textured (otherwise):
-
Texture file ID (BCD-like encoding):
tmp = (texture_data_raw >> 8) - 4000000 ones = (tmp / 250) % 40 tens = ((tmp - ones*250) / 1000) % 100 hundreds = (tmp - ones*250 - tens*1000) / 4000 texture_id = ones + tens + hundredsThis yields the TEXBSI.### file number.
-
Image sub-ID within that TEXBSI file:
ones = (texture_data_raw & 0xFF) % 10 tens = ((texture_data_raw & 0xFF) / 40) * 10 image_id = ones + tens
v2.6 / v2.7 Face Record
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 1 | u8 | vertex_count | Number of vertices |
| 0x01 | 1 | u8 | u1 | Separate byte field (not part of texture encoding) |
| 0x02 | 2 | u16 | texture_data | Packed texture encoding |
| 0x04 | 4 | u32 | unused_04 | Unused — always 0x00000000. Parsers should skip. |
| 0x08 | 8×N | FaceVertex[N] | vertices | N = vertex_count |
Texture decode from texture_data (the u16 at offset 0x02):
- Solid color if
(texture_data >> 7) < 2:color_index = texture_data & 0xFF - Textured:
texture_id = texture_data >> 7,image_id = texture_data & 0x7F
FaceVertex
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 4 | u32 | vertex_index | Index into vertex coordinate array. For v2.6/v2.7: byte offset, divide by 12. For v4.0+: direct index. |
| 0x04 | 2 | i16 | u_delta | U texture coordinate delta |
| 0x06 | 2 | i16 | v_delta | V texture coordinate delta |
UV coordinates are cumulative deltas in 4-bit fixed-point format: each vertex’s absolute U/V = previous vertex’s U/V + this delta. First vertex in the face starts from 0. The accumulated coordinate does not wrap to 16 bits. The renderer converts each signed delta to pixel-space float before accumulation, then applies the texture descriptor scale. A signed 32-bit fixed-point sum represents every possible face exactly.
The raw i16 values are in 1/16th pixel precision. To convert to pixel-space texture coordinates, multiply by 1/16 (or divide by 16). To normalize to 0–1 UV range, divide by 16 × texture_width (U) and 16 × texture_height (V). This fixed-point scale applies identically to first-vertex values and subsequent deltas.
Vertex Coordinates
Located at offset_vertex_coords. Array of num_vertices × 12 bytes.
v2.6/v2.7 animated .3DC files: Read positions from the software animation directory, not from a guessed offset after the faces. Compressed frames add shifted deltas to the directory’s reference frame. See software animation data. Static v2.6/v2.7 .3D files use offset_vertex_coords directly.
Face Normals
Located at offset_face_normals. Array of num_faces × 12 bytes.
Each normal is 3 × i32, scaled by 1/256.0. Per-face normals (one per face, not per vertex).
Vertex Normals
Located at offset_vertex_normals (header offset 0x2C). The indirection table determines the required normal records; its largest referenced index can exceed num_vertices - 1. Each record occupies 12 bytes. Without an indirection table, normals use direct vertex indexing and there are num_vertices records.
Each entry is 3 × f32 (IEEE 754 single-precision). Per-vertex normals (unit vectors). Some entries may be NaN (bit pattern 0xFFC00000 for all three components), indicating no normal was computed for that vertex.
Vertex-Normal Indirection Table
Located at offset_normal_indices (header offset 0x28). Array of total_face_vertices × u32.
Each entry is a file offset pointing into the vertex-normal data section. Maps each face-vertex to its vertex normal, allowing faces that share a vertex to use different normals (for hard edges).
Shading Model Selection
Per-vertex shading is determined by the indirection table and vertex normal validity:
| Condition | Shading | Normal used |
|---|---|---|
| Indirection table present, referenced vertex normal is valid | Smooth (Gouraud) | Vertex normal from indirection lookup |
Indirection table absent, vertex_normals[vertex_index] is valid | Smooth (Gouraud) | Vertex normal by direct index |
Vertex normal is NaN (0xFFC00000) or unavailable | Flat | Face normal |
For glTF export, vertex normals whose squared length is zero, subnormal or non-finite also fall back to the authored face normal. This prevents invalid normalization in renderers.
.3D files typically have the indirection table (per-face-vertex normal control). .3DC files always omit it (offset_normal_indices = 0) and fall back to direct vertex-index lookup.
Section4: SubObject BVH
Located at offset_section4 (header offset 0x1C). Contains section4_count variable-size entries. Present when offset and count are non-zero.
Each entry is a bounding-volume node followed by an ordered face-reference list. The fixed header is 18 bytes in v4.0 and 30 bytes in v5.0. The three extent floats exist only in v5.0; reading them in v4.0 consumes face references instead.
| Offset | Size | Type | Name | Description |
|---|---|---|---|---|
| 0x00 | 12 | i32[3] | center | Model-local center in 1/256 engine units. |
| 0x0C | 4 | u32 | radius | Bounding radius in 1/256 engine units. |
| 0x10 | 2 | u16 | face_count | Number of references following this header. |
| 0x12 | 12 | f32[3] | extents | Present only in v5.0. |
Runtime centers and radii are floating-point values obtained by dividing the
stored integers by 256. The v5.0 extent factors remain unchanged. Runtime face
indices are the stored indices divided by four, in the same order. The five
nodes of ISLAND.ROB model HBBLD04 contain 26 ordered face references.
Each six-byte reference contains a u32 absolute file offset to the face record
and a u16 face index multiplied by four. Both identify the same face in the
model’s original face table. The next node begins immediately after the last
reference; there is no padding between nodes.
The installed ROB archives contain 3,820 embedded models with these lists: 260 v4.0 and 3,560 v5.0 models, with 22,931 nodes and 222,322 references. Every reference’s file offset agrees with its divided-by-four face index. Lists can repeat a face across nodes: 105 models have repeated references. They need not cover every rendered face: nine palm-tree models omit faces. Preserve the lists and their order rather than reconstructing them from all rendered faces.
External References
- UESP: Mod:Model Files — community-maintained model-format notes and version behavior.
- uesp/redguard-3dfiletest
Redguard3dFile.cpp— DaveHumphrey’s C++ parser, including v2.6/v2.7 header remap logic (ConvertHeader27). - RGUnity/redguard-unity
RG3DFile.cs— 3D model importer (face/texture decoding).