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
MdlMeshandMdlFacethat 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 ofswkotor.exe(K1 GOG build), cross-checked against vanilla.mdl/.mdxassets. 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
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Domain | Geometry Math / Model Reconstruction |
| Rust Reference | View 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,+0xBCand+0xC8carry.+0xA4is 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-
u32blocks, leaving+0xB0,+0xBCand+0xC8with 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
vertindexesdarray. 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
.woktree 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:
- Compute AABB of all face centroids.
- Choose split axis (longest AABB dimension).
- Sort faces by centroid along split axis.
- Split at median into left/right subsets.
- 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:
| Field | Source |
|---|---|
| Vertex positions, normals, UVs, tangent space | 3D modeller |
| Vertex colors | 3D modeller or material editor |
| Texture names (texture_0, texture_1) | Material assignment |
| Diffuse/ambient colors | Material properties |
| Transparency hint, light_mapped, beaming, etc. | Material flags |
| Surface ID per face | Surface type assignment |
| Vertex indices per face | Mesh topology |
| Controller keyframes | Animation data |
| Bone weights, indices, bonemap | Rigging tool |
| Emitter properties | Particle editor |
7. Tool Cross-Reference: CExoArrayList Naming
The naming across tools is wildly inconsistent:
| Offset | Engine (Ghidra) | rakata | mdledit | mdlops | PyKotor | xoreos |
|---|---|---|---|---|---|---|
| +0x98 | vertex_indices | vertex_indices_array | cTexture3 | pntr_to_vert_num | indices_counts | (skip) |
| +0xA4 | left_over_faces | left_over_faces_array | cTexture4 | pntr_to_vert_loc | indices_offsets | offOffVerts |
| +0xB0 | vertex_indices_count | vertex_indices_count_array | IndexCounterArray | array3 | counters | (skip) |
| +0xBC | mdx_offsets | mdx_offsets_array | IndexLocationArray | (backpatch only) | (not modeled) | offOffVerts |
| +0xC8 | index_buffer_pools | index_buffer_pools_array | MeshInvertedCounterArray | inv_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:
| Property | MDL Face Adjacency | BWM Walkmesh Adjacency |
|---|---|---|
| Storage | u16 per edge | i32 per edge |
| Encoding | Plain face index | face_index * 3 + edge_index |
| No-neighbor | 0xFFFF | -1 (0xFFFFFFFF) |
| Purpose | GL rendering hints | Pathfinding / 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:
- Face array (32 bytes per face)
vertex_indices_countdata (single u32:face_count * 3)- Content vertex positions (12 bytes per vertex, only for MDL content blob)
mdx_offsetsdata (single u32: placeholder, backpatched)index_buffer_poolsdata (single u32: the per-mesh sequence value)- Packed u16 vertex indices (
face_count * 3u16 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.