MDL_FILES.md 11 KB

MDL Files

These seem to be simple model files containing the required information to render 3D objects. They're also used in some 2D backgrounds (like 2879436369) for bones.

NOTE: This documentation page is not completed yet as there's still work left to do on the reverse engineering of this file format. For now just a structure, describing the file format is here

//
// 010 editor template
//
//
typedef struct {
    float x <fgcolor=cRed>;
    float y <fgcolor=cGreen>;
    float z <fgcolor=cBlue>;
} VECTOR3;

typedef struct {
    float x <fgcolor=cRed>;
    float y <fgcolor=cGreen>;
    float z <fgcolor=cBlue>;
    float w <fgcolor=cPurple>;
} VECTOR4;

typedef struct {
    float x <fgcolor=cRed>;
    float y <fgcolor=cGreen>;
} VECTOR2;

typedef struct {
    DWORD x <fgcolor=cRed>;
    DWORD y <fgcolor=cGreen>;
    DWORD z <fgcolor=cBlue>;
    DWORD w <fgcolor=cPurple>;
} BLENDINDICES;

typedef struct {
    VECTOR3 vertex <bgcolor=cRed>;
    BLENDINDICES blendindices;
    VECTOR4 blendweight;
    VECTOR2 uv <bgcolor=cBlue>;
} VERTEX;

typedef struct {
    WORD x;
    WORD y;
    WORD z;
} VERTEXINDICE;

typedef struct {
    CHAR header[];
    DWORD first;
    DWORD second;
    DWORD third;
    CHAR json[];
    DWORD fourth;
    DWORD fifth;
    DWORD vertexByteLength;
    VERTEX vertices[vertexByteLength / sizeof(VERTEX)];
    DWORD indicesByteLength;
    VERTEXINDICE indices[indicesByteLength / sizeof (VERTEXINDICE)] <bgcolor=cYellow>;
} MDLVHEADER;

typedef struct {
    BYTE tmp;
    DWORD type;
    DWORD unk1;
} BONEENTRYHEADER;

typedef struct {
    BONEENTRYHEADER header;
    DWORD entryByteLength;
    float v[entryByteLength / sizeof (float)];
    CHAR info[];
} BONEENTRY;

typedef struct {
    BONEENTRYHEADER header;
} BONE2ENTRY;

typedef struct {
    CHAR header[];
    DWORD mightBeByteLength;
    DWORD numberOfBones;
    BONEENTRY bones[numberOfBones]<optimize=false>;
    BONE2ENTRY unk[numberOfBones]<optimize=false>;
} MDLSHEADER;

typedef struct {
    CHAR header[];
} MDLAHEADER;

typedef struct {
    MDLVHEADER mdlv;
    MDLSHEADER mdls;
} MDLFILE;

LittleEndian ();
MDLFILE file;

// mdlv => vertices information
// mdls => skinning information
// mdla => animation information

MDLV header and meshes (from wallpaper64.exe's loader)

The template above only fits single-mesh puppets. The real layout, as sub_1401D9860 reads it:

CHAR   magic[]            // "MDLV0023", the number after MDLV is the version
DWORD  defaultFormat      // vertex format for files older than 15
DWORD  materialsPerMesh   // strings in front of every mesh, 1 in every sample seen
DWORD  meshCount
MESH   meshes[meshCount]
typedef struct {
    CHAR   material[][materialsPerMesh]; // "materials/models/foo.json"
    DWORD  flags;          // version >= 4. Bit 1: 32 bit indices (else 16 bit). Bit 2: another DWORD follows
    FLOAT  bboxMin[3];     // version >= 17
    FLOAT  bboxMax[3];
    DWORD  format;         // version >= 15, see below
    DWORD  vertexByteLength;
    BYTE   vertices[vertexByteLength];
    DWORD  indexByteLength;
    BYTE   indices[indexByteLength];  // triangle list
    // version 23 only (the 2.4 engine reads up to 19): BYTE a, BYTE b, [DWORD n, BYTE[n] if b], DWORD m, BYTE[m]
    // static models write six zero bytes here, puppets fill it
} MESH;

The vertex format is a bitmask, and the vertex is the enabled components packed in this order (table at 0x140369CE0/0x140369D50):

bit size bit size
0x1 position 12 0x10/0x20 texcoord0 as vec3/vec4 12/16
0x10000 position4? 16 0x40/0x80/0x100 texcoord1 vec2/3/4 8/12/16
0x2 normal 12 0x200/0x400/0x800 texcoord2 8/12/16
0x4 tangent4 16 0x1000/0x2000/0x4000 texcoord3 8/12/16
0x800000 blend indices 16 0x20000/0x40000/0x80000 8/12/16
0x1000000 blend weights 16 0x100000/0x200000/0x400000 8/12/16
0x8 texcoord0 vec2 8 0x8000 16

Static 3D models are 0xf (48 bytes), skinned ones and the "wide" puppets 0x180000f (80 bytes), the narrow puppets 0x1800009 (52 bytes). One puppet (3771392318) uses 0x181000e, no 0x1 but the 16 byte 0x10000 in its place, most likely a vec4 position. After the last mesh a version 13+ file names its next section (MDLS...), an empty string when there is none.

Vertex blend indices/weights

VERTEX.blendindices/blendweight are always the 32 bytes immediately before the trailing UV pair, regardless of the overall vertex stride:

blendIndicesOffset = vertexStride - 40   // 4x DWORD, values are bone indices into the MDLS bone array
blendWeightsOffset = vertexStride - 24   // 4x float, sums to 1.0
uvOffset            = vertexStride - 8

The "narrow" (52-byte) stride is exactly position(12) + blendindices(16) + blendweight(16) + uv(8). The "wide" (80-byte, and similar) stride inserts a normal + tangent4 between position and the blend data: position(12) + normal(12) + tangent4(16) + blendindices(16) + blendweight(16) + uv(8). This is not a second bone slot as previously assumed - puppet meshes only ever carry one set of up to 4 blend influences.

MDLS bone array (first array only)

CHAR   header[9]        // "MDLSxxxx\0"
DWORD  mdlaOffset        // absolute file offset where MDLA begins (matches the MDLA search marker)
DWORD  boneCount
BONE   bones[boneCount]
typedef struct {
    BYTE   tmp;
    DWORD  type;
    INT32  parent;        // index into bones[], -1 for a root bone
    DWORD  matrixByteLength;  // always 64 (16 floats) in every sample seen
    FLOAT  bindLocalMatrix[16]; // row-major 4x4, row-vector convention (matches the engine's `mul(v,M)=v*M`
                                 // HLSL convention elsewhere); rotation/scale block has always been identity in
                                 // every sample seen, translation sits in row 3 (elements 12/13 = tx/ty, 14 = tz=0)
    CHAR   name[];         // null-terminated, always empty in every sample seen
} BONE;

A second array (numberOfBones more entries, previously named BONE2ENTRY in this doc) follows and has not been decoded - it isn't needed for skinning since the per-bone inverse-bind matrix can be derived directly from the local bind matrices above by walking the parent chain. mdlaOffset gives an exact byte length for the whole MDLS section, so the second array can safely be skipped wholesale rather than parsed.

MDLA (baked animation clips)

CHAR   header[9]     // "MDLAxxxx\0"
DWORD  A             // close to file size; exact meaning/use unclear, not needed for playback
DWORD  clipCount
DWORD  C             // for single-clip files this equals the scene.json object's
                      // animationlayers[].animation id; ambiguous with >1 clip - match clips by
                      // name instead (scene.json's animationlayers[].name), not by this field
DWORD  D             // always 0 in every sample seen
CLIP   clips[clipCount]
typedef struct {
    CHAR   name[];        // null-terminated, matches scene.json's animationlayers[].name
    CHAR   mode[];         // null-terminated, "loop" observed; other values unconfirmed
    FLOAT  fps;
    DWORD  frameCount;
    DWORD  flag;           // always 0 in every sample seen
    DWORD  boneCount;      // should match the MDLS bone count
    BONETRACK tracks[boneCount];  // same order as the MDLS bone array
} CLIP;
typedef struct {
    DWORD  zero;           // always 0 in every sample seen
    DWORD  trackByteLength; // == (frameCount + 1) * 36
    SAMPLE samples[frameCount + 1]; // fixed-rate, one every 1/fps seconds; sample[frameCount] ==
                                     // sample[0] for a "loop" clip (closes the cycle exactly)
} BONETRACK;
typedef struct {
    FLOAT  posX, posY, posZ;
    FLOAT  rotX, rotY, rotZ;  // Euler angles in radians; rotX/rotY are 0 in every 2D puppet sample seen
    FLOAT  scaleX, scaleY, scaleZ;
} SAMPLE;

There is a small (a few dozen to ~900 bytes, seen to vary per clip), still-undecoded trailer between one clip's last bone track and the next clip's name string (or EOF for the last clip). It doesn't matter for playback of a single matched clip; a parser reading multiple clips sequentially needs to resynchronize past it (e.g. by scanning forward for the next plausible clip header) rather than assuming a fixed size.

MDAT (attachment points)

An optional section between MDLS and MDLA, present on puppets that have named points other objects can follow via scene.json's "attachment" field (e.g. an orb or a weapon rigidly stuck to a hand bone). When absent, the MDLS jump field goes straight to MDLA instead.

CHAR   header[9]     // "MDATxxxx\0"
DWORD  mdlaOffset     // absolute file offset where MDLA begins, same role as MDLS's jump field
WORD   pointCount
WORD   boneIndex0     // point[0]'s bone index - see note below, this is NOT padding
POINT  points[pointCount]
typedef struct {
    CHAR   name[];         // null-terminated, matches scene.json's "attachment" value on a child object
    FLOAT  localMatrix[16]; // row-major 4x4, same convention as BONE.bindLocalMatrix - the point's transform
                             // relative to this point's bone index
    WORD   nextBoneIndex;   // bone index for points[i+1], NOT for this point - see note below. Absent
                             // entirely on the last point (nothing follows it to index)
} POINT;

The bone index for points[i] is written one slot early: points[0]'s index is the WORD immediately after pointCount (previously assumed to be an unused/padding field), and points[i]'s own trailing WORD is actually points[i+1]'s index. The last point has no trailing WORD at all. Reading a WORD after every point's matrix (including the last) overruns two bytes past the end of the MDAT section, landing on the next section's magic bytes (MDLA/MDAT read back as a byte-swapped "implausible" bone index); the shifted reading above consumes the section's declared byte length exactly, confirmed against mikasaback_puppet.mdl (2 points, "hair" and "eye" - MDAT section byte length matches exactly under the shifted reading, both resolved bone indices fall comfortably inside that puppet's 73-bone skeleton, and the "hair" attachment already renders correctly using its half of this scheme).

The point's live offset from its bind pose is (animatedBoneWorld[boneIndex] * localMatrix).translation - (bindBoneWorld[boneIndex] * localMatrix).translation; a child object with a matching "attachment" adds that offset to its own local origin instead of (or on top of) following a plain "parent" relationship.

Only cross-checked against mikasaback_puppet.mdl and (for the general shape) spiritblossomahribase_puppet.mdl, which declares 3 points where the 3rd previously failed every plausibility check under the old (unshifted) reading - worth re-checking against the shifted reading above, since that file hasn't been re-verified since this was found. Parsing still stops at the first implausible entry and keeps whatever points were read successfully rather than risking garbage data.

Reverse-engineered by cross-referencing multiple real puppet .mdl files pulled from Workshop content (ahriarm_puppet.mdl, ahritailbottom_puppet.mdl, spiritblossomahribase_puppet.mdl) - matching decoded bind translations/keyframe values against scene.json, and validating that decoded byte offsets exactly consume every byte up to the known MDLA/EOF boundary. No official documentation or decompilation was available for this part of the format.