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 document catalogs derived or computable fields specifically impacting TriMesh generation for MDL/MDX structures.

At a Glance

PropertyValue
Extension(s).mdl
DomainGeometry Math / Model Reconstruction
Rust ReferenceView 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-formats API 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 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 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:

  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 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:

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. 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:

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 – 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:

  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: inverted counter 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.

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.