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

MDL Format (Model Hierarchy)

An .mdl file is a model’s structure: a tree of nodes, meaning bones, meshes, lights and emitters, each carrying its transform, its textures, and the controllers that animate it. The vertex data is not here. It lives in the companion .mdx, which each mesh node addresses by offset.

At a Glance

PropertyValue
Extension(s).mdl
Magic SignatureText (filedependancy) or Binary (\0 byte header)
Type3D Hierarchical Mesh
Rust ReferenceView rakata_formats::Mdl in Rustdocs

File Layout

A binary MDL is one contiguous blob behind a 12-byte wrapper, and almost nothing in it sits at a fixed address. Past the two headers, every block is found by reading a pointer back out of an earlier one, so a reader navigates rather than walks.

Two things make this format unlike the archive formats. The pointers are content-relative, counted from the byte after the wrapper rather than from the start of the file, so a reader adds 0x0C to every one of them. And the vertex data is not here at all. It lives in the companion .mdx, addressed by byte offsets that are relative to that file.

BlockSizeLocated by
Wrapper12 bytesAlways at 0x00
Geometry header80 bytesContent +0x00, so file 0x0C
Model header116 bytesContent +0x50, immediately after the geometry header
Node treevariesroot_node_ptr at content +0x28, then each node’s own child array
Animation headers136 bytes eachThe animation array at content +0x58
Animation node treesvariesEach animation header’s own root pointer
Name offset array4 bytes per entryname_offsets_ptr at content +0xB8, name_count entries
Name stringsNUL-terminatedEach entry of the name offset array
Vertex datamdx_size bytesThe separate .mdx file, per mesh via mdx_data_offset

That chain is not a guess about how the blocks are usually arranged. Followed on every model indexed by a retail chitin.key, it closes on all of them: the content size accounts for the file exactly, both arrays land inside it, every name offset reaches a terminated string, and the node tree walks to completion without a pointer leaving the file.

Note

What “retail model” means on this page Every measurement here was taken over the models indexed by chitin.key. The module RIM archives also carry MDL resources, and those were checked separately: each one is a same-named copy of a model already in the base archives, and every one is byte-identical to it. So the module archives contribute no distinct model bytes and the claims below cover the shipped set rather than one archive of it.

Saves contain no models at all, so nothing here speaks to them and nothing needs to.

Important

The file and the loaded struct are not the same map. The engine does not parse MDL field by field. It copies the blob into memory and then rewrites relative offsets into absolute pointers in place, which means several offsets hold one thing on disk and a different thing once resident. +0x4C is the clearest case: a plain 2 in the file, and GetType() | 0x80 after load. +0x00, +0x04 and +0x48 carry nothing meaningful on disk at all and are filled in by the loader.

Every offset on this page describes the file. The deep dive documents the loaded form and says so in its own heading. Reading one map as though it were the other produces contradictions that are artefacts of the mix-up rather than facts about the format.

Which is awkward, because the per-node-type layouts are only on that page. This page’s tables stop at the node base, so anyone implementing a Light or a Skin has to read the loaded map and use it against a file. That is safe, and here is the rule that makes it safe.

Offsets are identical in both maps. Only the contents of certain words differ. The engine copies the blob whole and then rewrites pointers in place, so nothing moves. Within a node’s type-specific record, a word differs between the two forms only if it is one of these:

  • A relocated pointer. On disk it is an offset from the blob’s origin; in memory it is an absolute address. Every CExoArrayList header’s pointer word, and every standalone pointer field, is one of these.
  • A field the loader fills in. Populated at load and meaningless on disk.

Everything else is the same bytes in the same place, scalars included. So a Light’s seven tail scalars or a Skin’s counts read identically from a file and from memory, while the array-header pointers beside them do not. The deep dive’s per-type tables carry a relocation column naming exactly which words fall in the first group, which is what makes them usable against a file.

Wrapper (12 bytes)

OffsetFieldTypeNotes
0x00zero_markeru32Always 0. This is what distinguishes binary from ASCII, since an ASCII model starts with a keyword.
0x04mdl_content_sizeu32Bytes following the wrapper. Plus 12 it is the file length, exactly, in every retail model.
0x08mdx_file_sizeu32Length of the companion .mdx.

Geometry header (80 bytes, content +0x00)

OffsetFieldTypeNotes
+0x00function pointersu32 x 2Toolset vtable pointers left in the file. Meaningless as stored.
+0x08model_namechar[32]NUL-terminated.
+0x28root_node_ptru32Content-relative. Entry point for the whole node tree.
+0x2Cnode_countu32Not the number of nodes in this file. See the note below.
+0x30runtime arrays24 bytesZero in every retail model.
+0x48ref_countu32Zero on disk; the loader puts a resource handle here.
+0x4Cmodel_typeu82 for geometry in every retail model. Becomes GetType() | 0x80 once loaded.
+0x4Dpadding3 bytesGenuinely uninitialised rather than zeroed, so a writer should not assume it can validate them.

Model header (116 bytes, content +0x50)

OffsetFieldTypeNotes
+0x50classificationu8A bit flag. See below.
+0x51subclassificationu8
+0x52unknownu8Zero in every retail model.
+0x53affected_by_fogu80 or 1.
+0x54num_child_modelsu32Zero in every retail model.
+0x58animation arrayu32 x 3Pointer, count, capacity. The capacity is a runtime field.
+0x64supermodel_refu32A leaked pointer, not a file value. See below.
+0x68bounding_minf32 x 3
+0x74bounding_maxf32 x 3
+0x80radiusf32Bounding sphere.
+0x84animation_scalef32
+0x88supermodel_namechar[32]NUL-terminated, spanning +0x88..+0xA8. Uses the literal string NULL for “none”.
+0xA8off_anim_rootu32Content-relative. Equals root_node_ptr in the large majority of models.
+0xACmdx_source_offsetu32Where in the .mdx the vertex-pool copy starts. Zero in every retail model, so the copy runs from the beginning. InputBinary::Reset reads it and then overwrites the word with the GL pool handle, so the loaded struct holds something else entirely.
+0xB0mdx_sizeu32Matches the companion file’s real length in every retail model. Read twice: once to size the GL pool, once as the copy length.
+0xB4unreadu32Zero in every retail model, and nothing reads it. Reset consumes both neighbours and never touches this word, and ResetLite does not reference it either.
+0xB8name_offsets_ptru32Content-relative, to an array of u32 string offsets.
+0xBCname_countu32

Node header (80 bytes, every node type)

Every node begins with this, and type-specific data follows it.

OffsetFieldTypeNotes
+0x00type_flagsu16Bit field selecting what follows. See below.
+0x02node_numberu16
+0x04name_indexu16Index into the name table.
+0x06paddingu16
+0x08off_rootu32Zero on disk.
+0x0Coff_parentu32Content-relative.
+0x10positionf32 x 3
+0x1Corientationf32 x 4Quaternion, w first.
+0x2Cchild arrayu32 x 3Pointer, count, capacity. Recursing this is how the tree is walked.
+0x38controller keysu32 x 3
+0x44controller datau32 x 3

Node type flags

The low bit marks a node header and the rest select attached data. Retail models use nine combinations and nothing else:

FlagsNode kind
0x0001Dummy, a bare transform
0x0003Light
0x0005Emitter
0x0011Reference
0x0021Trimesh
0x0061Skin
0x0121Dangly mesh
0x0221AABB walkmesh
0x0821Saber

Animation node trees are different in kind: every node in one is a bare 0x0001, carrying a transform and controllers and nothing else.

Two defined bits appear in no retail model at all. 0x0008 marks a camera node and 0x0080 an animated mesh; both are real engine flags with no vanilla asset using them, so a reader will not meet either in shipped content and should still handle them rather than reject them.

Classification

classification at +0x50 is a bit flag rather than a small enumeration, which is why its values jump. All eight defined bits are attested in retail models:

ValueMeaning
0x00Other
0x01Effect
0x02Tile
0x04Character
0x08Door
0x10Lightsaber
0x20Placeable
0x40Flyer

No retail model combines two of them, so in practice the byte reads as a single selection even though the encoding would permit more.

Warning

node_count counts the supermodel chain, not this file A reader that validates node_count against the nodes it can actually reach will reject every retail model that names a supermodel, which is hundreds of correct files.

For a model whose supermodel_name is NULL, the field does equal the number of reachable nodes, exactly. For a model naming a real supermodel it equals its own reachable nodes plus the supermodel’s node_count plus one, with no exceptions anywhere in retail content. Since the supermodel’s own count is itself cumulative, the value describes the whole resolved inheritance chain rather than anything present in the file holding it.

Treat it as a hint about the assembled model, not as a checksum over the bytes in front of you.

Note

supermodel_ref at +0x64 is not a file field It holds a heap pointer left behind by whatever built the model, non-zero in about one retail model in eight, and it is non-zero for exactly the models that name a real supermodel. There is nothing to validate and nothing to preserve: the loader overwrites it with the result of its own lookup. A writer should emit zero and a reader should ignore it.

Note

NULL is a name, not an absence Every retail model writes supermodel_name, and the overwhelming majority write the four characters NULL rather than leaving the field empty. A reader testing for an empty string to mean “no supermodel” will try to resolve a model called NULL.

Node types

Which of these a node is comes from the type flags above, and the flags also decide how many bytes the node occupies. Two are defined by the engine and used by no retail model, marked as such in the table.

TypeWhat it is
BaseA node with no geometry, used as a group or a pivot.
LightA light source, with lens flare and shadow settings.
EmitterA particle system: fountains, single shots, lightning, explosions.
CameraA viewport anchor for dialogue cinematics. Defined by the engine, used by no retail model.
ReferenceAn attachment point naming another model to load at it.
TriMeshTriangle geometry with static vertex arrays.
SkinMeshA mesh deformed at runtime by skeleton bone weights.
AnimMeshA mesh carrying per-vertex animation sampled into the file. Defined by the engine, used by no retail model.
DanglyMeshA mesh driven by swing constraints: displacement, tightness, period.
AABBA collision tree holding the model’s own walkmesh.
SaberQuad arrays used only for lightsaber blade trails.

Engine Audits & Decompilation

Deep Dive: For an exhaustive archive of the Ghidra decompilation notes detailing the exact byte-level layout of the binary MDL format and engine loading pipeline, refer to the MDL & MDX Deep Dive.

Read from Input::Read (0x004a14b0) and InputBinary::ResetMdlNode (0x004a0900) in swkotor.exe. Provenance: derived, not attested. The rows below have not been separately re-derived, so they sit on the reverse-engineering queue.

Loading and Wrapper Validation

Pipeline EventGhidra Provenance & Engine Behaviour
Binary vs ASCII DetectionThe engine checks the file’s first byte. A \0 sends the asset down the InputBinary path; text ("filedependancy" or "newmodel") sends it to the FuncInterp ASCII parser instead.
Wrapper MappingThe first 12 bytes are the wrapper, giving the sizes of the .mdl content and the companion .mdx.
In-Memory Heap DumpThe engine allocates the sizes the wrapper gives, memcpys both the .mdl and the .mdx into memory, then runs the recursive Reset path to rewrite the content-relative offsets as absolute addresses.

Node Dispatch Architecture

InputBinary::ResetMdlNode walks the tree downward, matching each node against a 16-bit type flag running from 0x0001 (base node) to 0x0821 (lightsaber).

Mapped PropertyEngine Behaviour
Sub-node Allocation SizesA node’s allocation size follows its type mask. A base node takes 80 bytes, an Emitter 304, and a Skin 512.
Parent/Child Graph ResolutionEach node reaches its children through a pointer array embedded in the node. Those pointers are content-relative on disk, so the Reset pass has to rewrite every one into an absolute address; a node whose pointer is left unrelocated is unreachable from its parent.

Mapped Behaviour Quirks

Mapped PropertyGhidra Provenance & Engine Behaviour
LOD Suffix GenerationWhere cullWithLOD is set, the engine calls FindModel(name + "_x") and then FindModel(name + "_z"), attaching lower-detail geometry chosen by viewport distance.
Animation Bone BindingBuilding the live hierarchy, the engine matches bones on the node_id integer and never on the node’s name string. A bone whose id does not appear in that array is not bound to the runtime hierarchy.
Self-Describing KeyframesA keyframe’s width comes from its own controller type rather than a table: the engine masks the type with & 0x0F to decide whether the value is one float (a scale), three (a position), or four (a quaternion).

Proposed Linter Rules (Rakata-Lint)

rakata-lint reads GFF formats only and does not parse .mdl yet. The behaviours above suggest these diagnostics:

Planned Lint Diagnostics:

  1. Skeleton / Animation Tracing: Flags animation nodes whose node_number is 0, since every keyframe then targets the root bone and the model holds its bind pose instead of animating. This is the bug the deep dive records as freezing characters in T-pose.
  2. Controller Mask Encoding: Checks that a controller’s type is masked against the Bezier bit (0x10) before its rows are read, since taking the raw value misreads the row width and the misalignment carries through the rest of the block.
  3. Emitter Detonation Allocation: Flags an Emitter binding the detonate key (controller 502) while declaring itself a "Fountain". The engine routes controller 502 only through its "Explosion" path.
  4. Name Graph Sanitization: Reports name-table entries no node references. These are walkmesh node names left over from the build pipeline, and the engine only ever looks the table up by name_index, so they are inert; see the deep dive for what they are and why Rakata does not preserve them.