Warcraft III MDX Model Format
MDX (also written as MDLX) is Warcraft III's binary 3D model format. It stores geometry, materials, bones, animations, particle emitters, and more in a chunked binary layout.
File Layout
An MDX file is a sequence of tagged chunks. Each chunk begins with a 4-byte FourCC identifier followed by a 4-byte chunk size in bytes. The top-level structure is:
MDLX ← magic / version header
VERS <size> <version> ← format version (800 for WC3, 1000 / 1500 for Reforged)
MODL <size> <modelInfo> ← global model info (name, bounds)
SEQS <size> [sequence]… ← named animation sequences
GLBS <size> [globalSeq]… ← global sequence durations
TEXS <size> [texture]… ← texture path list
MTLS <size> [material]… ← materials (layer stacks)
GEOS <size> [geoset]… ← geometry (vertices + faces)
BONE <size> [bone]… ← skeleton bones
HELP <size> [helper]… ← helper nodes (invisible pivot points)
ATCH <size> [attach]… ← attachment points (e.g. "Overhead")
PIVT <size> [pivot]… ← per-node pivot positions
PREM <size> [emitter]… ← particle emitter v1
PRE2 <size> [emitter2]… ← particle emitter v2
RIBB <size> [ribbon]… ← ribbon emitters
EVTS <size> [event]… ← event objects (sounds, etc.)
CLID <size> [collision]… ← collision shapes
LITE <size> [light]… ← light nodes
Not all chunks are present in every model. The Warcraft III loader (games/warcraft-3/renderer/mdx/r_mdx_load.c) dispatches on each FourCC tag.
Node Hierarchy
All nodes (bones, helpers, attachments, emitters, event objects, collision shapes, lights) share a common node header:
| Field | Type | Description |
|---|---|---|
size |
DWORD |
Total size of this node record in bytes |
name |
char[80] |
Human-readable name |
objectId |
DWORD |
Unique node index (0-based) |
parentId |
DWORD |
Parent node index (0xFFFFFFFF = root) |
flags |
DWORD |
Node type and billboarding flags |
The flags field uses the following bitmasks:
| Flag | Value | Meaning |
|---|---|---|
Helper |
0 |
Plain helper (pivot only) |
DontInheritTranslation |
1 |
Ignore parent translation |
DontInheritRotation |
2 |
Ignore parent rotation |
DontInheritScaling |
4 |
Ignore parent scale |
Billboarded |
8 |
Always faces the camera |
BillboardedLockX |
16 |
Billboarded on X axis only |
BillboardedLockY |
32 |
Billboarded on Y axis only |
BillboardedLockZ |
64 |
Billboarded on Z axis only |
CameraAnchored |
128 |
Positioned relative to the camera |
Bone |
256 |
This node is a skeleton bone |
Light |
512 |
This node is a light source |
EventObject |
1024 |
Triggers events (sounds, effects) |
Attachment |
2048 |
Named attachment point |
ParticleEmitter |
4096 |
Particle emitter |
CollisionShape |
8192 |
Physics collision volume |
RibbonEmitter |
16384 |
Ribbon/trail emitter |
Runtime EVTS consumers are documented in MDX Event Objects: SND resolves animation sounds, SPN resolves Splats\SpawnData.slk child models, SPL/FPT resolve Splats\SplatData.slk terrain splats/footprints, and UBR resolves Splats\UberSplatData.slk terrain splats. SPN children carry their own event-crossing state so nested SND/SPN/SPL/FPT/UBR events can execute within a bounded presentation depth.
PREM (ParticleEmitter1) is parsed as its own model-emitter record rather than as PRE2. The loader preserves the static EmissionRate, Gravity, Longitude, Latitude, Path, LifeSpan, and InitVelocity values plus KPEE, KPEG, KPLN, KPLT, KPEL, KPES, and KPEV tracks and the EmitterUsesMDL / EmitterUsesTGA node flags. Runtime model-particle emission is still intentionally unsupported; parsing first prevents the renderer from discarding the authored dependency while emission direction, child animation, and TGA-mode behavior remain to be implemented from verified retail cases.
Keyframe Tracks (Animated Values)
Animation data is stored as keyframe tracks. Each track is identified by a 4-byte tag that encodes the node type and the animated property. For example:
| Tag | Target | Property |
|---|---|---|
KGTR |
Node | Translation |
KGRT |
Node | Rotation (quaternion) |
KGSC |
Node | Scale |
KMTA |
Material layer | Alpha |
KMTE |
Material layer | Emissive gain |
KP2V |
Particle emitter v2 | Visibility |
KP2E |
Particle emitter v2 | Emission rate |
KLAV |
Light | Ambient intensity |
A keyframe track record starts with:
DWORD numKeys
DWORD interpolationType // 0=none, 1=linear, 2=hermite, 3=bezier
DWORD globalSeqId // 0xFFFFFFFF if not driven by a global seq
[key × numKeys]
Each key is { DWORD frame; <value>; [<inTan>; <outTan>] } where the tangent pair is only present for hermite/bezier interpolation.
Geoset (Geometry)
A geoset is a set of vertices sharing one material, which may have multiple texture layers. Its sub-chunks are:
| Sub-chunk | Description |
|---|---|
VRTX |
Vertex positions — float[3] each |
NRMS |
Vertex normals — float[3] each |
UVAS |
Number of UV channels; followed by one UVBS per channel |
UVBS |
Vertex count followed by float[2] per vertex, for one UV channel |
PTYP |
Primitive types (always 4 = triangles) |
PCNT |
Primitive counts |
PVTX |
Vertex index list (triangle list) |
GNDX |
Bone group index per vertex |
MTGC |
Vertex count per bone group |
MATS |
Bone indices for each bone group |
The renderer builds a static VBO from VRTX/NRMS/UV0 and a matrix palette from the MATS/GNDX/MTGC
tables for GPU skinning. Extra UV channels share a compact secondary buffer; each material layer's coordId
selects the matching geoset VAO. Reading the last UVBS over UV0 corrupts multi-layer cave materials.
See cave material UV channels.
Geoset Flags
The flags field of a geoset (mdxGeoFlags_t) controls render state:
| Flag | Value | Meaning |
|---|---|---|
Unshaded |
0x01 |
No lighting — use vertex colour directly |
TwoSided |
0x10 |
Disable back-face culling |
Unfogged |
0x20 |
Not affected by distance fog |
NoDepthTest |
0x40 |
Always drawn on top |
NoDepthSet |
0x80 |
Do not write to the depth buffer |
Material Layers
Each material is a stack of one or more layers rendered in order (additive blending for particle effects, etc.). A layer record includes:
- filter mode (none, transparent, blend, additive, add-alpha, modulate, modulate2×)
- texture index (into the
TEXStable) - texture animation index
- flag bits (unshaded, sphere env map, two-sided, unfogged, no-depth-test, no-depth-set)
- animated alpha (
KMTAtrack)
Particle Emitter 2 (PRE2) Filter Modes
Each PRE2 emitter carries its own FilterMode. It is presentation data and must be copied to every spawned particle; defaulting every emitter to additive blending changes smoke and other translucent effects dramatically.
| MDX value | Authored mode | Shared particle mode | Blend behavior |
|---|---|---|---|
0 |
Blend | BLEND_MODE_BLEND |
source alpha over destination |
1 |
Additive | BLEND_MODE_ADD |
source alpha + destination |
2 |
Modulate | BLEND_MODE_MODULATE |
multiply source/destination |
3 |
Modulate2x | BLEND_MODE_MODULATE_2X |
doubled modulation |
4 |
AlphaKey | BLEND_MODE_ALPHAKEY |
alpha test / coverage |
The shared particle renderer predates the MDX path and its BLEND_MODE_ADD/BLEND_MODE_ADDALPHA names have legacy semantics. MDLX_ParticleBlendMode() is therefore the explicit format-to-renderer conversion point rather than relying on enum ordinals. Invalid PRE2 filter values are diagnosed by the loader and normalized to Blend.
This matters directly for building-damage effects: their fire model can contain both emissive/additive flame particles and ordinary blended smoke. Treating every PRE2 emitter as additive causes grey smoke textures to brighten the framebuffer and appear pale or white.
Ribbon Emitters (RIBB)
RIBB is not a geoset and not PRE2 tails. GhostWolf: emitters that emit lines, which are all connected together to form a ribbon of quads (Hive MDX spec). Magos: the strip has length 0 until the node moves.
Each emitter is a node plus:
| Field | Meaning |
|---|---|
heightAbove / heightBelow |
Edge half-width along the node's local Y (KRHA / KRHB) |
alpha, color |
Vertex tint (KRAL, KRCO) |
lifespan |
Edge lifetime in seconds |
emissionRate |
Edges per second |
rows / columns / textureSlot |
Material UV cell (KRTX) |
materialId |
MTLS index (filter mode, unshaded, texture) |
gravity |
World −Z pull on live edges |
KRVS |
Visibility; default 1 when the current sequence has no keys |
ZigguratMissile (uzg1) authors three BlizRibbon* emitters parented to waving helpers, material 0 = Textures\Ghost2.blp Add Alpha. That is the decorated in-flight trail. PRE2 BlizParticle02 is a Death squirt, not the trail.
Loader: MDLX_ReadRIBB in r_mdx_load.c. Trails are per renderEntity_t.number so concurrent missiles do not share one strip. MDLX_GetModelKeytrackValue already ignores keys outside the current sequence, so a lone KRVS key at Death frame 0 hides the ribbon in Death and leaves Stand/Birth at the default 1.
Trail lifecycle is engine-owned (renderer/r_trail.h, renderer/r_trail.c — the Q3 split: games own emitter state and materials, the engine owns edge history and strip geometry). The WC3 adapter (r_mdx_ribbons.c) only samples keytracks, computes the world edge, advances its per-model trail_t store, and converts the returned trailVert_t strip to MDX VERTEX (identity bone bind, face normal). Engine semantics: each trail carries its own stamp of the last now_ms it advanced. A second draw in the same frame (shadow/lights pass) gets dt = 0 and only re-emits the strip; the guard is per-trail, not per-instance, so multi-emitter models advance each trail exactly once. A gap past TRAIL_STALE_MS (250 ms) with a live stamp clears the trail, which drops stale edges when an edict number is reused by a new missile or a culled entity reappears (a respawn inside the same tick still slips through). The emission accumulator clamps at 2 before emitting, so a hitch emits at most 2 edges instead of stacking rate*dt coincident ones. U is age / lifespan (oldest edge at the cell end), so adding or expiring an edge never rescales the strip's UVs; two fresh edges cover only a narrow slice, not the whole texture. Edge color/alpha is stored per edge, so animated KRAL/KRCO stay historic instead of repainting the whole strip.
Draw uses the MDX material layer (not the particle billboard shader) so Ghost2 unwraps along the strip. Tests: renderer_model.mdx_ribbon_* and renderer_model.mdx_ribb_loader_*. mdxtool --dump-all prints RIBB rows.
Animation Sequences
Each entry in SEQS describes one named clip:
| Field | Type | Description |
|---|---|---|
name |
char[80] |
Sequence name (e.g. "Stand", "Walk", "Attack") |
interval |
DWORD[2] |
[startFrame, endFrame] in milliseconds |
moveSpeed |
float |
Ground speed during the animation |
flags |
DWORD |
1 = non-looping |
rarity |
float |
Random weight for stand variations |
syncPoint |
DWORD |
Sync reference frame |
extent |
Extent |
Bounding box + sphere for this clip |
Standard sequence names are: Stand, Walk, Attack, Attack Slam, Attack 2, Decay Flesh, Decay Bone, Death, Dissipate, Portrait, Spell, Stand Channel, Stand Ready, Stand Work.
Related Source Files
| Source | Purpose |
|---|---|
games/warcraft-3/renderer/mdx/r_mdx.h |
All MDX struct definitions |
games/warcraft-3/renderer/mdx/r_mdx_load.c |
Chunk parser and model loader |
games/warcraft-3/renderer/mdx/r_mdx_render.c |
Per-frame skinning and draw calls |
games/warcraft-3/renderer/mdx/r_mdx_interpolation.c |
Keyframe track evaluation |
games/warcraft-3/renderer/mdx/r_mdx_particles.c |
PRE2 particle emitters |
games/warcraft-3/renderer/mdx/r_mdx_ribbons.c |
RIBB trail update and strip build |
renderer/r_trail.h, renderer/r_trail.c |
Engine trail history and strip synthesis (game-agnostic) |
games/warcraft-3/renderer/mdx/r_mdx_geoset.c |
Geoset draw and ribbon material pass |