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¶
Flag field¶
Flags are at bit 44, as a uint16 bitfield:
| 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_FULLPRECset: position.x, position.y, position.z— 3 ×float32(12 bytes)bitangentX—float32(4 bytes)- Total: 16 bytes
- Else (half-precision, default for FO4 precombined):
position.x, position.y, position.z— 3 ×float16(6 bytes)bitangentX—float16(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
- bitangentX — float32 (4 bytes)
- Total: vertex_main_size bytes
7b. UVs — only if VF_UV¶
u—float16(2 bytes)v—float16(2 bytes)- Total: 4 bytes
7c. Normal + bitangentY — only if VF_NORMAL¶
normal.x, normal.y, normal.z— 3 ×int8(3 bytes)bitangentY—int8(1 byte)- Total: 4 bytes
- Decode:
(byte - 128) / 127.0or 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)bitangentZ—int8(1 byte)- Total: 4 bytes
7e. Vertex color — only if VF_COLORS¶
- 4 ×
uint8RGBA (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¶
eyeData—float32(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:
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 ×
uint16vertex indices. - Indices are local to this
BSPackedGeomData.vertData[]— do not apply the top-levelnumVerticesas a global offset. - Use only the first
triCountLod0triangles; 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}