Skip to content

Precombined Mesh Format

Byte-level specification for the BSPackedCombinedSharedGeomDataExtra extra-data block that Creation Kit bakes into *_OC.nif precombined meshes.

Byte order is little-endian throughout.


1. Where the block lives in the NIF

NiHeader
  ...
  Block[N] BSFadeNode              <-- scene root
    extraDataList : ref[] ----\    (NiAVObject-level extra data list)
  Block[...] BSTriShape ...    |
  Block[M] BSPackedCombinedSharedGeomDataExtra  <-- THIS BLOCK
    ^-- referenced from BSFadeNode.extraDataList

BSPackedCombinedSharedGeomDataExtra is attached to the root BSFadeNode via its extraDataList, not to any individual BSTriShape. In precombined NIFs, the BSTriShapees in the file are typically the non-precombined leftovers (decals, animated pieces); the bulk of the cell geometry lives inside this extra-data block. Each entry in objects[] corresponds to a precombined source mesh, identified by a hash of its filename (see section 3).

Block ID in the NIF header's block-type table is the ASCII string "BSPackedCombinedSharedGeomDataExtra".


2. Parent: NiExtraData

BSPackedCombinedSharedGeomDataExtra inherits from NiExtraData, which inherits from NiObject. NiObject::Sync is empty. NiExtraData::Sync contributes exactly one field:

Field Type Size Notes
name NiStringRef 4 bytes Signed int32 index into the header's string table. -1 means no string.

So the block starts with a 4-byte string reference, then the class-specific payload below.


3. Payload

Read order:

Offset Field Type Size Notes
+0 vertexDesc VertexDesc (uint64) 8 Bit-packed; see §4. Shared by all BSPackedGeomData children — the top-level vertexDesc dictates the per-vertex layout for the entire block.
+8 numVertices uint32 4 Total vertex count across all objects. Informational — actual per-object counts live in BSPackedGeomData.numVertices.
+12 numTriangles uint32 4 Total triangle count across all objects. Informational.
+16 unkFlags1 uint32 4 Unknown.
+20 unkFlags2 uint32 4 Unknown.
+24 numData uint32 4 Number of precombined source objects. objects[] and data[] are BOTH this length.
+28 objects[numData] BSPackedGeomObject[] 8 × numData See §5.
+28+8N data[numData] BSPackedGeomData[] variable See §6.

Note: objects and data are parallel arrays. objects[i] names and locates (via hash + offset) the source mesh for data[i].


4. VertexDesc (8 bytes, uint64 bit-packed)

The entire vertex layout is encoded in one uint64. Low 40 bits hold per-attribute byte offsets into the vertex struct (nibbles) and the total vertex "main size"; top 20 bits (positions 44..63) hold the VertexFlags.

Size field

vertex_main_size = ((desc >> 8) & 0xFF) * 4      # bytes

Flag field

Flags are at bit 44, as a uint16 bitfield:

flags = (desc >> 44) & 0xFFFF
Flag Hex Bit Meaning
VF_VERTEX 0x0001 0 Has positions
VF_UV 0x0002 1 Has UV0
VF_UV_2 0x0004 2 Has UV1
VF_NORMAL 0x0008 3 Has normals (+ bitangentY)
VF_TANGENT 0x0010 4 Has tangents (+ bitangentZ). Only read when VF_NORMAL is also set.
VF_COLORS 0x0020 5 Has vertex colors
VF_SKINNED 0x0040 6 Has skin weights + bone indices
VF_LANDDATA 0x0080 7 Landscape data (not used here)
VF_EYEDATA 0x0100 8 Eye data (float, not used here)
VF_FULLPREC 0x0400 10 Positions are full float32; else float16.

5. BSPackedGeomObject (8 bytes)

Field Type Notes
fileNameHash uint32 Hash of the source mesh's full file path relative to Data\Meshes\, lowercased, backslash-separated, including extension. Matches the standard Bethesda asset name hash.
dataOffset uint32 Byte offset into the combined vertex buffer. Redundant for parsing — vertex data is streamed inline per-object.

6. BSPackedGeomData (variable, one per object)

Field Type Size Notes
numVertices uint32 4 Vertex count for THIS object.
lodLevels uint32 4 Usually 1..3.
triCountLod0 uint32 4 Triangle count for LOD0 (the real mesh).
triOffsetLod0 uint32 4 Index-buffer offset for LOD0. Informational.
triCountLod1 uint32 4
triOffsetLod1 uint32 4
triCountLod2 uint32 4
triOffsetLod2 uint32 4
combined.count uint32 4 Number of BSPackedGeomDataCombined entries.
combined[...] BSPackedGeomDataCombined[] 72 × count Per-instance transforms — one entry per REFR that instances this object.
vertexDesc uint64 8 Per-object VertexDesc. Should equal the top-level one. If they differ, trust this one for this data[i].
vertData[numVertices] BSVertexData[] variable See §7.
triangles[sum of LOD counts] Triangle[] 6 × N Three uint16 indices per triangle.

Total triangle count in the buffer = triCountLod0 + triCountLod1 + triCountLod2. For rendering use only the first triCountLod0 triangles — the rest are LOD variants.


7. BSVertexData (per-vertex)

Reads are strictly conditional on vertexDesc flags. For each vertex in order:

7a. Position + bitangentX

Branch on vertex_main_size:

Case A — vertex_main_size <= 16 (common for FO4 precombined):

  • If VF_FULLPREC set:
  • position.x, position.y, position.z — 3 × float32 (12 bytes)
  • bitangentXfloat32 (4 bytes)
  • Total: 16 bytes
  • Else (half-precision, default for FO4 precombined):
  • position.x, position.y, position.z — 3 × float16 (6 bytes)
  • bitangentXfloat16 (2 bytes)
  • Total: 8 bytes

Case B — vertex_main_size > 16: - position.x, position.y, position.z — 3 × float32 (12 bytes) - extra[]((vertex_main_size - 16) / 4) × float32 - bitangentXfloat32 (4 bytes) - Total: vertex_main_size bytes

7b. UVs — only if VF_UV

  • ufloat16 (2 bytes)
  • vfloat16 (2 bytes)
  • Total: 4 bytes

7c. Normal + bitangentY — only if VF_NORMAL

  • normal.x, normal.y, normal.z — 3 × int8 (3 bytes)
  • bitangentYint8 (1 byte)
  • Total: 4 bytes
  • Decode: (byte - 128) / 127.0 or treat as signed byte / 127.0

7d. Tangent + bitangentZ — only if VF_NORMAL AND VF_TANGENT

  • tangent.x, tangent.y, tangent.z — 3 × int8 (3 bytes)
  • bitangentZint8 (1 byte)
  • Total: 4 bytes

7e. Vertex color — only if VF_COLORS

  • 4 × uint8 RGBA (4 bytes)

7f. Skinning — only if VF_SKINNED

  • weights[4] — 4 × float16 (8 bytes)
  • boneIndices[4] — 4 × uint8 (4 bytes)
  • Total: 12 bytes

7g. Eye data — only if VF_EYEDATA

  • eyeDatafloat32 (4 bytes)

Typical FO4 precombined vertex stride

For a vertex with VF_VERTEX | VF_UV | VF_NORMAL | VF_TANGENT and no VF_FULLPREC:

Chunk Size
position (half) + bitangentX (half) 8
UV (2× half) 4
normal (3× int8) + bitangentY (int8) 4
tangent (3× int8) + bitangentZ (int8) 4
Total 20 bytes / vertex

With VF_FULLPREC it becomes 28 bytes / vertex. With VF_COLORS add 4.


8. BSPackedGeomDataCombined (72 bytes — one per REFR instance)

Offset Field Type Size Notes
+0 grayscaleToPaletteScale float32 4 Engine material param; non-geometric. Usually 1.0.
+4 rotation float32[3][3] 36 Row-major 3x3 rotation matrix.
+40 translation float32[3] 12 World-space XYZ translation, in the CELL's local coords.
+52 scale float32 4 Uniform scale.
+56 bounds.center float32[3] 12 BoundingSphere center.
+68 bounds.radius float32 4 BoundingSphere radius.
Total 72 bytes

Rotation matrix convention

The rotation matrix stores rows; NIFs write it row by row. When you have nine floats m00 m01 m02 m10 m11 m12 m20 m21 m22 as read from disk, the mapping is:

rot = [[m00, m01, m02],
       [m10, m11, m12],
       [m20, m21, m22]]

This is the same rotation that appears on NiNode.rotation elsewhere in the NIF — Bethesda's column-vector convention (v' = M * v). Do not apply the REFR Euler-to-basis conversion here — ESM REFR records use Euler angles, but this block gives the final 3x3 directly.

Instance count per BSPackedGeomData

combined.count is the number of REFR instances of this source mesh in the cell.


9. Triangle layout

After vertData, BSPackedGeomData writes the index buffer:

  • Each triangle = 3 × uint16 vertex indices.
  • Indices are local to this BSPackedGeomData.vertData[] — do not apply the top-level numVertices as a global offset.
  • Use only the first triCountLod0 triangles; skip the LOD1/LOD2 tails unless you actually want distance LODs.

10. Full read pseudocode

import struct

def read_vertex_desc(buf, o):
    desc, = struct.unpack_from("<Q", buf, o)
    vsize = ((desc >> 8) & 0xFF) * 4
    flags = (desc >> 44) & 0xFFFF
    return desc, vsize, flags, o + 8

def read_vertex(buf, o, vsize, flags):
    VF_VERTEX, VF_UV, VF_NORMAL, VF_TANGENT, VF_COLORS, VF_SKINNED, VF_EYEDATA, VF_FULLPREC = \
        0x1, 0x2, 0x8, 0x10, 0x20, 0x40, 0x100, 0x400
    v = {}
    if vsize <= 16:
        if flags & VF_FULLPREC:
            x, y, z, btx = struct.unpack_from("<4f", buf, o); o += 16
        else:
            x, y, z, btx = struct.unpack_from("<4e", buf, o); o += 8
        v["pos"] = (x, y, z); v["btx"] = btx
    else:
        x, y, z = struct.unpack_from("<3f", buf, o); o += 12
        n_extra = (vsize - 16) // 4
        v["extra"] = struct.unpack_from(f"<{n_extra}f", buf, o); o += n_extra * 4
        btx, = struct.unpack_from("<f", buf, o); o += 4
        v["pos"] = (x, y, z); v["btx"] = btx
    if flags & VF_UV:
        u, vv = struct.unpack_from("<2e", buf, o); o += 4
        v["uv"] = (u, vv)
    if flags & VF_NORMAL:
        nx, ny, nz, bty = struct.unpack_from("<4b", buf, o); o += 4
        v["normal"] = (nx/127.0, ny/127.0, nz/127.0); v["bty"] = bty/127.0
        if flags & VF_TANGENT:
            tx, ty, tz, btz = struct.unpack_from("<4b", buf, o); o += 4
            v["tangent"] = (tx/127.0, ty/127.0, tz/127.0); v["btz"] = btz/127.0
    if flags & VF_COLORS:
        v["color"] = struct.unpack_from("<4B", buf, o); o += 4
    if flags & VF_SKINNED:
        v["weights"] = struct.unpack_from("<4e", buf, o); o += 8
        v["bones"]   = struct.unpack_from("<4B", buf, o); o += 4
    if flags & VF_EYEDATA:
        v["eye"], = struct.unpack_from("<f", buf, o); o += 4
    return v, o

def read_packed_combined(buf, o):
    name_ref, = struct.unpack_from("<i", buf, o); o += 4
    _, top_vsize, top_flags, o = read_vertex_desc(buf, o)
    num_vertices, num_tris, u1, u2, num_data = struct.unpack_from("<5I", buf, o); o += 20
    objs = []
    for _ in range(num_data):
        h, off = struct.unpack_from("<II", buf, o); o += 8
        objs.append((h, off))
    datas = []
    for _ in range(num_data):
        nverts, lods, t0c, t0o, t1c, t1o, t2c, t2o = struct.unpack_from("<8I", buf, o); o += 32
        n_comb, = struct.unpack_from("<I", buf, o); o += 4
        combined = []
        for _ in range(n_comb):
            fields = struct.unpack_from("<f9f3ff3ff", buf, o); o += 72
            combined.append(fields)
        _, vsize, flags, o = read_vertex_desc(buf, o)
        verts = []
        for _ in range(nverts):
            v, o = read_vertex(buf, o, vsize, flags)
            verts.append(v)
        total_tris = t0c + t1c + t2c
        tris = list(struct.iter_unpack("<3H", buf[o : o + total_tris * 6])); o += total_tris * 6
        datas.append({
            "num_vertices": nverts, "lod_levels": lods,
            "tri_count_lod0": t0c, "combined": combined,
            "vertex_desc": (vsize, flags), "verts": verts, "tris": tris,
        })
    return {"name": name_ref, "top_vertex_desc": (top_vsize, top_flags),
            "num_vertices": num_vertices, "num_triangles": num_tris,
            "objects": objs, "data": datas}