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
| Property | Value |
|---|---|
| Extension(s) | .mdl |
| Magic Signature | Text (filedependancy) or Binary (\0 byte header) |
| Type | 3D Hierarchical Mesh |
| Rust Reference | View 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.
| Block | Size | Located by |
|---|---|---|
| Wrapper | 12 bytes | Always at 0x00 |
| Geometry header | 80 bytes | Content +0x00, so file 0x0C |
| Model header | 116 bytes | Content +0x50, immediately after the geometry header |
| Node tree | varies | root_node_ptr at content +0x28, then each node’s own child array |
| Animation headers | 136 bytes each | The animation array at content +0x58 |
| Animation node trees | varies | Each animation header’s own root pointer |
| Name offset array | 4 bytes per entry | name_offsets_ptr at content +0xB8, name_count entries |
| Name strings | NUL-terminated | Each entry of the name offset array |
| Vertex data | mdx_size bytes | The 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.
+0x4Cis the clearest case: a plain2in the file, andGetType() | 0x80after load.+0x00,+0x04and+0x48carry 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
CExoArrayListheader’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)
| Offset | Field | Type | Notes |
|---|---|---|---|
0x00 | zero_marker | u32 | Always 0. This is what distinguishes binary from ASCII, since an ASCII model starts with a keyword. |
0x04 | mdl_content_size | u32 | Bytes following the wrapper. Plus 12 it is the file length, exactly, in every retail model. |
0x08 | mdx_file_size | u32 | Length of the companion .mdx. |
Geometry header (80 bytes, content +0x00)
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x00 | function pointers | u32 x 2 | Toolset vtable pointers left in the file. Meaningless as stored. |
+0x08 | model_name | char[32] | NUL-terminated. |
+0x28 | root_node_ptr | u32 | Content-relative. Entry point for the whole node tree. |
+0x2C | node_count | u32 | Not the number of nodes in this file. See the note below. |
+0x30 | runtime arrays | 24 bytes | Zero in every retail model. |
+0x48 | ref_count | u32 | Zero on disk; the loader puts a resource handle here. |
+0x4C | model_type | u8 | 2 for geometry in every retail model. Becomes GetType() | 0x80 once loaded. |
+0x4D | padding | 3 bytes | Genuinely uninitialised rather than zeroed, so a writer should not assume it can validate them. |
Model header (116 bytes, content +0x50)
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x50 | classification | u8 | A bit flag. See below. |
+0x51 | subclassification | u8 | |
+0x52 | unknown | u8 | Zero in every retail model. |
+0x53 | affected_by_fog | u8 | 0 or 1. |
+0x54 | num_child_models | u32 | Zero in every retail model. |
+0x58 | animation array | u32 x 3 | Pointer, count, capacity. The capacity is a runtime field. |
+0x64 | supermodel_ref | u32 | A leaked pointer, not a file value. See below. |
+0x68 | bounding_min | f32 x 3 | |
+0x74 | bounding_max | f32 x 3 | |
+0x80 | radius | f32 | Bounding sphere. |
+0x84 | animation_scale | f32 | |
+0x88 | supermodel_name | char[32] | NUL-terminated, spanning +0x88..+0xA8. Uses the literal string NULL for “none”. |
+0xA8 | off_anim_root | u32 | Content-relative. Equals root_node_ptr in the large majority of models. |
+0xAC | mdx_source_offset | u32 | Where 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. |
+0xB0 | mdx_size | u32 | Matches the companion file’s real length in every retail model. Read twice: once to size the GL pool, once as the copy length. |
+0xB4 | unread | u32 | Zero in every retail model, and nothing reads it. Reset consumes both neighbours and never touches this word, and ResetLite does not reference it either. |
+0xB8 | name_offsets_ptr | u32 | Content-relative, to an array of u32 string offsets. |
+0xBC | name_count | u32 |
Node header (80 bytes, every node type)
Every node begins with this, and type-specific data follows it.
| Offset | Field | Type | Notes |
|---|---|---|---|
+0x00 | type_flags | u16 | Bit field selecting what follows. See below. |
+0x02 | node_number | u16 | |
+0x04 | name_index | u16 | Index into the name table. |
+0x06 | padding | u16 | |
+0x08 | off_root | u32 | Zero on disk. |
+0x0C | off_parent | u32 | Content-relative. |
+0x10 | position | f32 x 3 | |
+0x1C | orientation | f32 x 4 | Quaternion, w first. |
+0x2C | child array | u32 x 3 | Pointer, count, capacity. Recursing this is how the tree is walked. |
+0x38 | controller keys | u32 x 3 | |
+0x44 | controller data | u32 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:
| Flags | Node kind |
|---|---|
0x0001 | Dummy, a bare transform |
0x0003 | Light |
0x0005 | Emitter |
0x0011 | Reference |
0x0021 | Trimesh |
0x0061 | Skin |
0x0121 | Dangly mesh |
0x0221 | AABB walkmesh |
0x0821 | Saber |
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:
| Value | Meaning |
|---|---|
0x00 | Other |
0x01 | Effect |
0x02 | Tile |
0x04 | Character |
0x08 | Door |
0x10 | Lightsaber |
0x20 | Placeable |
0x40 | Flyer |
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_countcounts the supermodel chain, not this file A reader that validatesnode_countagainst 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_nameisNULL, 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’snode_countplus 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_refat+0x64is 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
NULLis a name, not an absence Every retail model writessupermodel_name, and the overwhelming majority write the four charactersNULLrather than leaving the field empty. A reader testing for an empty string to mean “no supermodel” will try to resolve a model calledNULL.
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.
| Type | What it is |
|---|---|
| Base | A node with no geometry, used as a group or a pivot. |
| Light | A light source, with lens flare and shadow settings. |
| Emitter | A particle system: fountains, single shots, lightning, explosions. |
| Camera | A viewport anchor for dialogue cinematics. Defined by the engine, used by no retail model. |
| Reference | An attachment point naming another model to load at it. |
| TriMesh | Triangle geometry with static vertex arrays. |
| SkinMesh | A mesh deformed at runtime by skeleton bone weights. |
| AnimMesh | A mesh carrying per-vertex animation sampled into the file. Defined by the engine, used by no retail model. |
| DanglyMesh | A mesh driven by swing constraints: displacement, tightness, period. |
| AABB | A collision tree holding the model’s own walkmesh. |
| Saber | Quad 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 Event | Ghidra Provenance & Engine Behaviour |
|---|---|
| Binary vs ASCII Detection | The 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 Mapping | The first 12 bytes are the wrapper, giving the sizes of the .mdl content and the companion .mdx. |
| In-Memory Heap Dump | The 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 Property | Engine Behaviour |
|---|---|
| Sub-node Allocation Sizes | A node’s allocation size follows its type mask. A base node takes 80 bytes, an Emitter 304, and a Skin 512. |
| Parent/Child Graph Resolution | Each 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 Property | Ghidra Provenance & Engine Behaviour |
|---|---|
| LOD Suffix Generation | Where cullWithLOD is set, the engine calls FindModel(name + "_x") and then FindModel(name + "_z"), attaching lower-detail geometry chosen by viewport distance. |
| Animation Bone Binding | Building 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 Keyframes | A 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:
- Skeleton / Animation Tracing: Flags animation nodes whose
node_numberis0, 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. - 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. - Emitter Detonation Allocation: Flags an
Emitterbinding thedetonatekey (controller502) while declaring itself a"Fountain". The engine routes controller502only through its"Explosion"path. - 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.