Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TriMesh Derived & Computed Fields Reference

This page documents which TriMesh fields the engine recomputes at load time versus which ones a writer must supply correctly, so a hand-built mesh doesn’t silently mismatch what the engine reconstructs. It covers every field on MdlMesh and MdlFace that can be derived from geometry: what each one means, how community tools handle it, and the algorithm needed to recompute it. Evidence is drawn from Ghidra decompilation of swkotor.exe (K1 GOG build), cross-checked against vanilla .mdl/.mdx assets. The tables below are lookup surfaces, meant to be searched rather than read start to end, and they are the reference for future model-editing API work.

At a Glance

PropertyValue
Extension(s).mdl
DomainGeometry Math / Model Reconstruction
Rust ReferenceView rakata_formats::MdlNodeTriMesh in Rustdocs

Field Categories

  • User-authored: Provided by the modeller. Never recomputed.
  • Derivable: Can be recomputed from geometry. Tools recompute on ASCII import / model rebuild; preserve verbatim on binary roundtrip.
  • Runtime-only: Written by the engine at load time. On-disk values are meaningless stubs.

1. Internal CExoArrayList Fields (+0x98 .. +0xC8)

The five CExoArrayList slots in the TriMesh header form a coordinated GL index buffer submission system. Each stores a 12-byte header (ptr/count/alloc) in the mesh header plus a single u32 data value in the content blob.

1.1 vertex_indices (+0x98): Dead in KotOR

What it is: A legacy engine array block. In BioWare’s older titles (like Neverwinter Nights), this block pointed to vertex index data. In KOTOR, the engine never actually looks at this field at all.

Community tools:

  • mdledit: Misidentifies as cTexture3 (12-byte string). Byte-exact preserve.
  • mdlops: Reads as raw bytes via darray struct. Byte-exact preserve.
  • PyKotor: Reads as indices_counts. Byte-exact preserve.
  • xoreos/reone: Skip entirely.

Vanilla values: Always zeros (ptr=0, count=0, alloc=0).

Rakata Processing Rule: Store as [u8; 12] for lossless preservation, or zero on write. No computation needed.

1.2 left_over_faces (+0xA4): Dead in KotOR

What it is: Another legacy array block. In NWN, this stored “left over” face geometry. In KOTOR, the engine updates the pointer as it loads but never reads the data back during the rendering cycle.

Community tools:

  • mdledit: Misidentifies as cTexture4 (12-byte string). Byte-exact preserve.
  • mdlops: Reads as raw bytes via darray struct. Points to the packed u16 vertex index data (mdlops uses this as the indirection to find face indices).
  • PyKotor: Reads as indices_offsets. Byte-exact preserve.
  • xoreos: The only field it actually follows. Reads the pointer to find packed u16 face vertex indices.
  • reone: Reads as indicesOffsetArrayDef. Uses first element as pointer to u16 index data.

Vanilla values: All zeros (ptr=0, count=0, alloc=0), in every mesh of every model indexed by a retail chitin.key.

Note

A description of this slot as typically non-zero belongs to the next three Non-zero pointer, count 1, alloc 1 is exactly what +0xB0, +0xBC and +0xC8 carry. +0xA4 is empty throughout, so a source attaching that description here has it on the wrong slot.

Rakata Processing Rule: Store the raw pointer and count variables. The pointer is content-relative and must be explicitly backpatched on write to point to the packed u16 face index data block.

1.3 vertex_indices_count (+0xB0): Derivable

What it is: Single u32 value = total number of u16 vertex indices in the face index buffer.

Formula: face_count * 3

Community tools:

  • mdledit: Recomputes on every write (nVertIndicesCount = Faces.size() * 3).
  • mdlops: Recomputes on ASCII import.
  • PyKotor: Preserves from binary, creates empty for new models.

Rakata Processing Rule: Dynamically derive from faces.len() * 3. Never store a static value in the struct.

1.4 mdx_offsets (+0xBC): Derivable (pointer)

What it is: Single u32 value = content-relative offset to the packed u16 face vertex index data in the MDL content blob.

Community tools:

  • mdledit: Writes placeholder, backpatches when VertIndices data is written.
  • mdlops: Same approach.
  • PyKotor: Same approach.

Rakata Processing Rule: Compute strictly at serialization time via the binary writer. Never store a static value in the struct.

1.5 index_buffer_pools / Inverted Counter (+0xC8): Preserve or Derive

What it is: A u32. On disk it is a sequence counter that numbers meshes in an inverted pattern. At load the engine overwrites that same memory with an OpenGL handle, so the stored value never survives into the running model.

The value function, derived from the retail corpus. Read as shape, the sequence is a set of checkpoints at 100 x 2^k where the value equals the counter, separated by runs that descend between them:

c: the 1-based mesh counter

T = 100
while T < c: T = T * 2

if   c == T:    value = c          # checkpoint
elif T == 100:  value = 99 - c     # the first run, which is the special case
else:           value = T + T/2 - c

Example sequence: 98, 97, 96, …, 1, 0, 100, 199, 198, …, 101, 200, …

Two properties hold across the range tested. The function is injective, and the value 99 is never produced: the first run descends to zero and the first checkpoint is 100, so that gap is a real discontinuity rather than an off-by-one.

Warning

mdledit’s formula for this field is wrong above counter 299 The two agree exactly for every counter below 300 and disagree at every counter from 300 onward. On the deepest model with an unambiguous counter, the function above reproduces every stored value and mdledit’s reproduces the first 299 of them.

The divergence only shows on models with at least three hundred meshes, and most have nowhere near that, so a tool carrying the wrong formula can look correct indefinitely. Rakata carried it too: a model that large written by a build of this library older than the function above has wrong values in this field.

Important

Which counter a mesh gets is not recoverable from the file The function above turns a counter into a value. It does not tell you which counter a given mesh takes, and nothing in the file does either.

Stored values are distinct within every model, so the field is a genuine per-model identifier. But the counter is not a function of mesh ordinal: single-mesh models carry twenty-five different values rather than always the first of the sequence, and the counter matches DFS ordinal in only about a fifth of mesh-bearing models. No ordering tried makes the value set contiguous: not DFS, not node offset, not reverse DFS, not name index, not sabers-last. Models exist whose five meshes carry counters 2, 5, 3, 1 and 8, so the sequence numbers more meshes than the file contains.

The economical reading is that the counter indexes the authoring scene’s mesh list, including meshes never exported, which the binary does not carry.

The consequence is a real constraint on any round-tripping tool. A writer producing new models may assign counters freely and use the function above. A writer aiming to reproduce a specific retail model byte-for-byte must preserve the stored value, because it cannot be recomputed from anything in the file.

Note

Saber meshes take no counter at all They write none of the three single-u32 blocks, leaving +0xB0, +0xBC and +0xC8 with a count of zero and a stale pointer. The identity is exact in both directions across the corpus: every saber mesh does this and no non-saber mesh does.

So a saber consumes no increment. A reading that has them consuming two is a hypothesis rather than a measurement, and the identity above refutes it.

Community tools:

  • mdledit: Preserves from binary. Recomputes from formula only for ASCII import when value is missing (!nMeshInvertedCounter.Valid()).
  • mdlops: Recomputes on ASCII import using same formula.
  • PyKotor: Preserves from binary.

Rakata Processing Rule: Map as a static u32 field to perfectly preserve binary roundtripping. When natively constructing new models, dynamically compute the inverted sequence according to the formula using a DFS mesh counter.


2. Packed u16 Face Vertex Indices

What it is: A tightly packed list of u16 index triplets, exactly 6 bytes per face. Each triplet names the three vertices of one triangle, and the whole block is uploaded to the graphics card as the index buffer.

Relationship to MdlFace: The packed u16 data is identical to MdlFace.vertex_indices for each face, laid out sequentially. It is fully redundant with the face array.

Community tools:

  • mdledit: Reads from binary into nVertIndices (3 u16 per face, stored alongside face data). Writes from face data.
  • mdlops: Reads as vertindexes darray. Writes from face data on ASCII import.
  • xoreos/reone: Read from the pointer at +0xA4 or +0xBC.

Rakata Processing Rule: Always dynamically derive identical copies directly from faces[i].vertex_indices during binary emission. Never map a redundant array inside the Rakata struct.


3. Face Fields (MdlFace, 32 bytes per face)

3.1 plane_normal ([f32; 3]): Derivable

What it is: The geometric direction the triangle’s flat surface is facing (a unit normal vector).

Formula:

edge1 = positions[v1] - positions[v0]
edge2 = positions[v2] - positions[v0]
normal = normalize(cross(edge1, edge2))

Community tools: All tools that recompute adjacency also recompute normals.

3.2 plane_distance (f32): Derivable

What it is: The distance from the origin to the face’s plane, measured along the normal.

Formula: plane_distance = -dot(plane_normal, positions[v0])

Tools differ over the sign, so this was checked rather than assumed: sampled across vanilla faces, the stored value matches -dot(plane_normal, positions[v0]) and never +dot. The handful that match neither are degenerate faces, the same ones this page records as carrying NaN. The formula above is what vanilla carries.

3.3 surface_id (u32): User-authored

What it is: Material/surface type identifier. Determines footstep sounds, walkability, etc. in walkmeshes; material properties in render meshes.

Not derivable. Assigned by the modeller or inherited from the source asset.

3.4 adjacent ([u16; 3]): Derivable

What it is: For each edge of the triangle, the index of the face sharing that edge. 0xFFFF means no adjacent face (boundary edge).

Edge-to-adjacent mapping:

  • adjacent[0]: face sharing edge (v0, v1)
  • adjacent[1]: face sharing edge (v1, v2)
  • adjacent[2]: face sharing edge (v2, v0)

Rakata Hash-Map Adjacency Algorithm:

1. Build position_key(v) = format!("{:.4e},{:.4e},{:.4e}", pos[0], pos[1], pos[2])

2. Build vertex_group: HashMap<String, Vec<usize>>
   For each vertex index i:
       vertex_group[position_key(i)].push(i)

3. Build vertex_to_faces: HashMap<usize, Vec<usize>>
   For each face f, for each vertex v in face.vertex_indices:
       vertex_to_faces[v].push(f)

4. Build face_set(vertex_index) -> HashSet<usize>:
   Collect all faces touching any vertex in the same position group:
       group = vertex_group[position_key(vertex_index)]
       union of vertex_to_faces[g] for all g in group

5. For each face f:
   For each edge (va, vb) in [(v0,v1), (v1,v2), (v2,v0)]:
       candidates = face_set(va) & face_set(vb) - {f}
       adjacent[edge] = if candidates.is_empty() { 0xFFFF }
                        else { min(candidates) }

Complexity: O(F * V_avg) where V_avg is the average number of faces per vertex group. Effectively O(F) for well-behaved meshes.

No-neighbor sentinel: 0xFFFF (u16::MAX). All tools agree except PyKotor which incorrectly uses 0 (a bug: face 0 is a valid index).

Non-manifold edges: When more than 2 faces share an edge, tools differ:

  • mdledit: First match wins, logs a warning.
  • mdlops: Arbitrary (hash iteration order).
  • PyKotor: Smallest face index wins (min(candidates)).

Rakata Processing Rule: Always use min(candidates) internally so evaluation remains deterministic and aligns with PyKotor output. If non-manifold geometric edges are detected, the formatter must throw a logger warning.

Important: Vertex matching must be position-based, not index-based. Meshes commonly have duplicate vertices at the same position with different normals/UVs (hard edges, UV seams). Index-based matching would miss adjacency across these seams.

3.5 vertex_indices ([u16; 3]): User-authored

What it is: The three vertex indices forming this triangle.

Not derivable. It defines the mesh topology.


4. Mesh Bounding Geometry: Derivable

4.1 bounding_min / bounding_max ([f32; 3])

What it is: The axis-aligned bounding box enclosing every vertex in the mesh.

Formula:

bounding_min = [min of all positions[i][0], min of [1], min of [2]]
bounding_max = [max of all positions[i][0], max of [1], max of [2]]

4.2 bsphere_center / bsphere_radius ([f32; 3], f32)

What it is: Minimum bounding sphere enclosing all vertices. Used by the engine for frustum culling (PartTriMesh::GetMinimumSphere at 0x00443330).

Engine algorithm, traced and recorded in the MDL deep dive:

center = average of all vertex positions (centroid)
radius = max distance from center to any vertex

This is NOT the true minimum bounding sphere (Welzl’s algorithm), but a simpler centroid-based approximation. Matches what vanilla files contain.

4.3 total_surface_area (f32)

What it is: Sum of all triangle areas in the mesh.

Formula:

For each face:
    edge1 = positions[v1] - positions[v0]
    edge2 = positions[v2] - positions[v0]
    area += 0.5 * length(cross(edge1, edge2))
total_surface_area = sum of all face areas

5. AABB Tree: Derivable (complex)

What it is: A collision-detection tree built over the faces of the mesh. It recursively subdivides them into nested boxes, so a collision query tests a handful of faces instead of every polygon.

When needed: Only for MdlNodeData::Aabb nodes (walkmesh-like collision geometry). Regular render meshes don’t have AABB trees.

Node layout: 40 bytes, written in DFS preorder. The full record is in the MDL deep dive.

Warning

This is the MDL tree, not the walkmesh one, and they are different sizes An MDL AABB node is 40 bytes. The node in a BWM walkmesh is 44. Both numbers are right for their own format.

The two trees do the same job and are easy to conflate, and the failure is quiet: read a .wok tree at a 40-byte stride and the fields still land on plausible floats and small integers, so you get a tree that parses and describes nothing. This page separates MDL from BWM adjacency a few sections up for the same reason; the node size needs the same care.

Build algorithm: Recursive spatial partition:

  1. Compute AABB of all face centroids.
  2. Choose split axis (longest AABB dimension).
  3. Sort faces by centroid along split axis.
  4. Split at median into left/right subsets.
  5. Recurse on each subset until single-face leaves.

Community tools generally don’t rebuild AABB trees from scratch. They preserve the existing tree or require external tooling to generate it.


6. Fields That Are NOT Derivable

These fields are user-authored or carried over from tooling. Rakata preserves them verbatim and never recomputes them:

FieldSource
Vertex positions, normals, UVs, tangent space3D modeller
Vertex colors3D modeller or material editor
Texture names (texture_0, texture_1)Material assignment
Diffuse/ambient colorsMaterial properties
Transparency hint, light_mapped, beaming, etc.Material flags
Surface ID per faceSurface type assignment
Vertex indices per faceMesh topology
Controller keyframesAnimation data
Bone weights, indices, bonemapRigging tool
Emitter propertiesParticle editor

7. Tool Cross-Reference: CExoArrayList Naming

The naming across tools is wildly inconsistent:

OffsetEngine (Ghidra)rakatamdleditmdlopsPyKotorxoreos
+0x98vertex_indicesvertex_indices_arraycTexture3pntr_to_vert_numindices_counts(skip)
+0xA4left_over_facesleft_over_faces_arraycTexture4pntr_to_vert_locindices_offsetsoffOffVerts
+0xB0vertex_indices_countvertex_indices_count_arrayIndexCounterArrayarray3counters(skip)
+0xBCmdx_offsetsmdx_offsets_arrayIndexLocationArray(backpatch only)(not modeled)offOffVerts
+0xC8index_buffer_poolsindex_buffer_pools_arrayMeshInvertedCounterArrayinv_count(not modeled)(skip)

Note: mdledit’s identification of +0x98/+0xA4 as texture name slots is incorrect for KotOR. In NWN, the mesh header has 4 texture name slots (64 bytes each) at this region. KotOR reduced to 2 texture names (32 bytes each at +0x58/+0x78) and repurposed the remaining space as CExoArrayList headers.

Two of those five headers are empty in vanilla, +0x98 and +0xA4, and the other three are not: +0xB0, +0xBC and +0xC8 each carry a non-zero pointer with a count and alloc of 1 in almost every mesh. mdledit’s string-based read/write still produces byte-identical results, because it preserves the bytes rather than interpreting them, but the reason is preservation rather than the region being blank.


8. MDL vs BWM Adjacency Encoding

A critical distinction for anyone working with both formats:

PropertyMDL Face AdjacencyBWM Walkmesh Adjacency
Storageu16 per edgei32 per edge
EncodingPlain face indexface_index * 3 + edge_index
No-neighbor0xFFFF-1 (0xFFFFFFFF)
PurposeGL rendering hintsPathfinding / collision

BWM’s edge-encoded adjacency tells you not just WHICH face is adjacent, but WHICH EDGE of that face connects, which the pathfinding walk algorithm needs. MDL only needs to know which face, not which edge.


9. Write-Order Dependencies

When writing a mesh node, fields must be emitted in a specific order because some fields are content-relative pointers that must be backpatched. The canonical order, read off the block pointers of every populated mesh in the retail corpus, is:

  1. Face array (32 bytes per face)
  2. vertex_indices_count data (single u32: face_count * 3)
  3. Content vertex positions (12 bytes per vertex, only for MDL content blob)
  4. mdx_offsets data (single u32: placeholder, backpatched)
  5. index_buffer_pools data (single u32: the per-mesh sequence value)
  6. Packed u16 vertex indices (face_count * 3 u16 values)

After step 6, backpatch the mdx_offsets pointer to point to the start of step 6’s data.

It is a contiguous layout, not merely an ordering. Testing whether each block ends exactly where the next begins, at the sizes above, it abuts with zero gaps in every populated mesh measured. A writer that emits these in order but pads between them produces something no retail mesh resembles.

The only meshes that deviate are sabers, which write none of the three single-u32 blocks at all and so have nothing to order. They are the exception throughout this section rather than a separate case.

mdledit’s binarywrite.cpp writes the same order, which corroborates the derivation rather than supplying it. The corpus is the evidence here, and the contiguity and the saber exception are things the transcription does not carry.

CExoArrayList headers at +0x98..+0xC8 are written as part of the mesh extra header (332 bytes), with pointer values backpatched after the data is written.