TriMesh Derived & Computed Fields Reference
This document catalogs derived or computable fields specifically impacting TriMesh generation for MDL/MDX structures.
At a Glance
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Domain | Geometry Math / Model Reconstruction |
| Rust Reference | View rakata_formats::MdlNodeTriMesh in Rustdocs |
Data Model Structure
Rakata attempts to make building a TriMesh as painless as possible by handling the complex math under the hood.
- Derived Fields: Rakata explicitly understands the difference between data you must supply (like static 3D coordinates) and data that can safely be calculated on the fly (like bounding limits, spherical radii, or adjacency maps). The
rakata-formatsAPI automatically calculates all of these required boundaries for you seamlessly whenever you serialize the file!
Engine Audits & Decompilation
This document catalogues every field on MdlMesh and MdlFace that can be
derived from geometry, documenting what each field means, how community tools
handle it, and what algorithm is needed to recompute it. This is the reference
for future model-editing API work.
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 location dynamically but completely forgets to actually use or read the data during the OpenGL rendering cycle. rendering loop.
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: 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: Typically non-zero. The pointer value points to the packed u16 face vertex index data. Count is 1, alloc is 1.
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 standard 32-bit number. On the physical hard drive, this acts exclusively as a sequence counter that numbers meshes using a bizarre “inverted” counting pattern. However, the moment the engine loads the file into memory, it deletes this number and overwrites the exact memory space with an OpenGL hardware connection handle.
The inverted counter formula (from mdledit asciipostprocess.cpp:1024):
mesh_counter: sequential 1-based index across all mesh nodes in DFS tree order.
Saber meshes consume TWO increments (one per inverted counter).
Quo = mesh_counter / 100
Mod = mesh_counter % 100
inverted_counter = (2^Quo) * 100 - mesh_counter
+ (Mod != 0 ? Quo * 100 : 0)
+ (Quo != 0 ? 0 : -1)
Example sequence: 98, 97, 96, …, 1, 0, 100, 199, 198, …, 101, 200, …
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 (yielding exactly 6 bytes per face). Each 3-piece triplet tells the renderer which three vertex dots to connect to draw one flat triangle. This entire block is physically uploaded straight to the graphics card to render the final model.
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 raw distance measured straight from the physical center of the world (origin) to the face’s flat surface along its normal vector.
Formula: plane_distance = -dot(plane_normal, positions[v0])
Note: some tools negate this differently. Verify against vanilla data.
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 (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 – defines the mesh topology.
4. Mesh Bounding Geometry – Derivable
4.1 bounding_min / bounding_max ([f32; 3])
What it is: A perfect, square box drawn tightly around every single vertex dot in the model (an Axis-Aligned Bounding Box).
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 (from Ghidra, confirmed in mdl_mdx.md):
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 mathematical collision-detection tree (Binary Space Partition) built over the faces of the mesh. It recursively slices the physics block into smaller and smaller floating boxes so the engine can quickly determine if a player bumps into a wall, saving it from checking collision against every single polygon.
When needed: Only for MdlNodeData::Aabb nodes (walkmesh-like collision
geometry). Regular render meshes don’t have AABB trees.
Node layout: 40 bytes (see mdl_mdx.md for full struct).
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 distinct fields are explicitly user-authored or carried over from tooling. Rakata must treat them strictly as rigid payload endpoints. They are never mathematically recomputed across the pipeline:
| 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. The CExoArrayLists are always empty (all zeros) in vanilla KotOR, so mdledit’s string-based read/write produces byte-identical results.
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 – needed for the pathfinding walk algorithm. 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 (from mdledit binarywrite.cpp) 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: inverted counter 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.
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.