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 | Collision sphere radius |
| 0x10 | 4 | u32 | num_frames | Animation frame count. Always 1 for .3D 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), likely leftovers from an older build pipeline. Parsers should ignore. |
| 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 per-vertex normal data (f32 × 3 per vertex). |
| 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 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 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 0x08 (reserved) | Always 0 | Vertex offset adjustment (values up to 63,662) |
| offset 0x0C (frame_type) | 0, 2, 4, or 8 | Values like 8209 (0x2011), 16402 (0x4012) — different encoding |
v2.6/v2.7 Face Data Differences
| Aspect | v4.0/v5.0 | v2.6/v2.7 |
|---|---|---|
| Face texture header | 5 bytes (u16 + u16 + u8) | 3 bytes (u8 + u16) |
| tex_hi values | 61–91, 255 | 0, 6, 15, 18, 20, 30, 36, 44, 50, 60, 63 |
| Solid-color faces | ~5% of faces (tex_hi=0xFF) | Rare — 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.
Engine version gate: The shipped engine rejects files where the version byte (offset 0x01) is less than ASCII '4' (0x34). This means v2.6/v2.7 .3DC files on the game disc are not loaded at runtime — they are development-era leftovers. The engine only loads v4.0+ files. External parsers (DaveHumphrey, RGUnity, this project) support both versions for preservation completeness.
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
Located at offset_frame_data. Array of num_frames × 16-byte records.
For .3D files this is always a single entry pointing to the base geometry.
| 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. Used as vertex offset adjustment in v2.6/v2.7. |
| 0x0C | 4 | u32 | frame_type | 0 for static .3D models in v4.0. See 3DC.md for animated frame types. Different encoding in v2.6/v2.7. |
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 (always 0x40). 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 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.
Face Vertex Count Distribution
| Vertices/Face | Frequency |
|---|---|
| 3 (triangle) | ~93% |
| 4 (quad) | ~6.5% |
| 5–10 | <0.5% |
Vertex Coordinates
Located at offset_vertex_coords. Array of num_vertices × 12 bytes.
Each vertex is 3 × i32 (signed 32-bit integers), scaled by 1/256.0 to get world-space coordinates.
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). Array of num_vertices × 12 bytes.
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 |
.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 with face references:
| Size | Type | Description |
|---|---|---|
| 4 | i32 | Center X |
| 4 | i32 | Center Y |
| 4 | i32 | Center Z |
| 4 | u32 | Radius |
| 2 | u16 | Face reference count |
| 4 | f32 | Extent X |
| 4 | f32 | Extent Y |
| 4 | f32 | Extent Z |
| 6×N | Face references (u32 offset + u16 index, divide index by 4) |
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— production parser implementation used by the Unity reimplementation (cross-check for face/texture decoding behavior).