MDL file parser used in the Editor conversion pipeline. It reads a Source 1 MDL binary and extracts model metadata: name, version, bones, materials, skin families, body parts, meshes, attachments, sequences and animation descriptors into managed classes.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace GmodAssetImporter;
public sealed class MdlModel
{
public string Name = "";
public int Version;
public List<BoneData> Bones { get; } = new();
public List<string> MaterialNames { get; } = new();
public List<string> CdMaterials { get; } = new();
public List<short[]> SkinFamilies { get; } = new();
public int SkinReferenceCount;
public List<MdlBodyPart> BodyParts { get; } = new();
public List<AttachmentData> Attachments { get; } = new();
public List<MdlSequence> Sequences { get; } = new();
public List<MdlAnimDesc> AnimDescs { get; } = new();
public byte[] RawData;
}
public sealed class MdlBodyPart
{
public string Name = "";
public List<MdlStudioModel> Models { get; } = new();
}
public sealed class MdlStudioModel
{
public string Name = "";
public int VertexCount;
public int VertexIndex; // vertex index into VVD LOD0 (header stores a byte offset)
public List<MdlMesh> Meshes { get; } = new();
}
public sealed class MdlMesh
{
public int Material;
public int VertexCount;
public int VertexOffset; // relative to model
}
public sealed class MdlSequence
{
public string Name = "";
public int AnimIndex; // local anim index (first blend)
public bool Looping;
public float Fps = 30;
}
public sealed class MdlAnimDesc
{
public string Name = "";
public float Fps = 30;
public int FrameCount = 1;
public int Flags;
public int AnimIndex; // offset to anim data relative to this animdesc
public long DescOffset;
}
public static class MdlReader
{
const int IdStudio = 0x54534449; // "IDST"
const int StudioVertexSize = 48; // sizeof( mstudiovertex_t )
public static MdlModel Load( string path ) => Load( File.ReadAllBytes( path ) );
public static MdlModel Load( byte[] data )
{
using var ms = new MemoryStream( data );
using var br = new BinaryReader( ms, Encoding.ASCII, leaveOpen: true );
var id = br.ReadInt32();
if ( id != IdStudio )
throw new InvalidDataException( $"Invalid MDL id 0x{id:X8}" );
var version = br.ReadInt32();
if ( version < 44 || (version > 49 && version != 53) )
throw new InvalidDataException( $"Unsupported MDL version {version}" );
br.ReadInt32(); // checksum
var name = BinaryUtil.ReadNullTerminated( br, 64 );
br.ReadInt32(); // length
ms.Position += 12 * 6; // eye, illum, hull min/max, view bb min/max (6 vectors)
br.ReadInt32(); // flags
var numBones = br.ReadInt32();
var boneIndex = br.ReadInt32();
br.ReadInt32(); br.ReadInt32(); // bonecontrollers
var numHitboxSets = br.ReadInt32();
var hitboxSetIndex = br.ReadInt32();
var numLocalAnim = br.ReadInt32();
var localAnimIndex = br.ReadInt32();
var numLocalSeq = br.ReadInt32();
var localSeqIndex = br.ReadInt32();
br.ReadInt32(); br.ReadInt32(); // activitylistversion, eventsindexed
var numTextures = br.ReadInt32();
var textureIndex = br.ReadInt32();
var numCdTextures = br.ReadInt32();
var cdTextureIndex = br.ReadInt32();
var numSkinRef = br.ReadInt32();
var numSkinFamilies = br.ReadInt32();
var skinIndex = br.ReadInt32();
var numBodyParts = br.ReadInt32();
var bodyPartIndex = br.ReadInt32();
var numAttachments = br.ReadInt32();
var attachmentIndex = br.ReadInt32();
// skip remaining header fields we don't need for offsets already captured
_ = numHitboxSets;
_ = hitboxSetIndex;
var mdl = new MdlModel
{
Name = name,
Version = version,
RawData = data,
SkinReferenceCount = numSkinRef
};
ReadBones( br, mdl, boneIndex, numBones );
ReadMaterials( br, mdl, textureIndex, numTextures, cdTextureIndex, numCdTextures );
ReadSkinFamilies( br, mdl, skinIndex, numSkinFamilies, numSkinRef );
ReadBodyParts( br, mdl, bodyPartIndex, numBodyParts );
ReadAttachments( br, mdl, attachmentIndex, numAttachments );
ReadAnimDescs( br, mdl, localAnimIndex, numLocalAnim );
ReadSequences( br, mdl, localSeqIndex, numLocalSeq );
return mdl;
}
static void ReadBones( BinaryReader br, MdlModel mdl, int index, int count )
{
for ( var i = 0; i < count; i++ )
{
var offset = index + i * 216; // mstudiobone_t is 216 bytes on v48/49
if ( offset + 216 > br.BaseStream.Length )
{
// older versions can be 184 — try smaller stride fallback
offset = index + i * 184;
}
br.BaseStream.Position = offset;
var szNameIndex = br.ReadInt32();
var parent = br.ReadInt32();
br.BaseStream.Position = offset + 8 + 6 * 4; // skip bonecontroller[6]
var pos = BinaryUtil.ReadVec3( br );
var quat = BinaryUtil.ReadQuat( br );
br.BaseStream.Position = offset + 8 + 24 + 12 + 16 + 12; // after pos/quat/rot
// Actually layout: nameindex(4) parent(4) bonecontroller[6](24) pos(12) quat(16) rot(12) posscale(12) rotscale(12) poseToBone[12](48) qAlignment(16) flags(4) proc(8) surfaceprop(4) contents(4) unused[8](32)
// Safer: re-read from known offsets
br.BaseStream.Position = offset;
szNameIndex = br.ReadInt32();
parent = br.ReadInt32();
br.BaseStream.Position = offset + 32; // after controllers
pos = BinaryUtil.ReadVec3( br );
quat = BinaryUtil.ReadQuat( br );
var rot = BinaryUtil.ReadVec3( br );
var boneName = BinaryUtil.ReadStringAt( br, offset, szNameIndex );
if ( string.IsNullOrEmpty( boneName ) )
boneName = $"bone{i}";
var euler = BinaryUtil.QuaternionToEulerDegrees( quat );
mdl.Bones.Add( new BoneData
{
Name = boneName,
Parent = parent,
Position = pos,
Quaternion = quat,
Rotation = new Vec3( euler[0], euler[1], euler[2] )
} );
_ = rot;
}
}
static void ReadMaterials( BinaryReader br, MdlModel mdl, int textureIndex, int numTextures, int cdIndex, int numCd )
{
for ( var i = 0; i < numTextures; i++ )
{
var offset = textureIndex + i * 64; // mstudiotexture_t
br.BaseStream.Position = offset;
var nameIndex = br.ReadInt32();
var name = BinaryUtil.ReadStringAt( br, offset, nameIndex );
mdl.MaterialNames.Add( string.IsNullOrEmpty( name ) ? $"material{i}" : name );
}
for ( var i = 0; i < numCd; i++ )
{
br.BaseStream.Position = cdIndex + i * 4;
var nameIndex = br.ReadInt32();
var name = BinaryUtil.ReadStringAt( br, 0, nameIndex );
// cdtextureindex entries are absolute offsets from file start
if ( string.IsNullOrEmpty( name ) )
{
br.BaseStream.Position = nameIndex;
name = BinaryUtil.ReadNullTerminated( br, 256 );
}
mdl.CdMaterials.Add( PathResolve.NormalizeSlashes( name ).Trim( '/' ) );
}
}
static void ReadSkinFamilies( BinaryReader br, MdlModel mdl, int skinIndex, int families, int refs )
{
if ( families <= 0 || refs <= 0 ) return;
br.BaseStream.Position = skinIndex;
for ( var f = 0; f < families; f++ )
{
var row = new short[refs];
for ( var r = 0; r < refs; r++ )
row[r] = br.ReadInt16();
mdl.SkinFamilies.Add( row );
}
}
static void ReadBodyParts( BinaryReader br, MdlModel mdl, int index, int count )
{
for ( var i = 0; i < count; i++ )
{
var bpOffset = index + i * 16; // mstudiobodyparts_t
br.BaseStream.Position = bpOffset;
var szNameIndex = br.ReadInt32();
var numModels = br.ReadInt32();
br.ReadInt32(); // base
var modelIndex = br.ReadInt32();
var bpName = BinaryUtil.ReadStringAt( br, bpOffset, szNameIndex );
if ( string.IsNullOrEmpty( bpName ) ) bpName = $"body{i}";
var bp = new MdlBodyPart { Name = bpName };
for ( var m = 0; m < numModels; m++ )
{
var modelOffset = bpOffset + modelIndex + m * 148; // mstudiomodel_t ~148
br.BaseStream.Position = modelOffset;
var modelName = BinaryUtil.ReadNullTerminated( br, 64 );
br.ReadInt32(); // type
br.ReadSingle(); // boundingradius
var numMeshes = br.ReadInt32();
var meshIndex = br.ReadInt32();
var numVertices = br.ReadInt32();
var vertexByteOffset = br.ReadInt32(); // byte offset into VVD vertex data
// tangentsindex, attachments, eyeballs follow — ignore
var studioModel = new MdlStudioModel
{
Name = string.IsNullOrEmpty( modelName ) ? $"{bpName}_{m}" : modelName,
VertexCount = numVertices,
VertexIndex = vertexByteOffset / StudioVertexSize
};
for ( var me = 0; me < numMeshes; me++ )
{
var meshOffset = modelOffset + meshIndex + me * 116; // mstudiomesh_t
br.BaseStream.Position = meshOffset;
var material = br.ReadInt32();
br.ReadInt32(); // modelindex
var meshVertexCount = br.ReadInt32();
var meshVertexOffset = br.ReadInt32();
studioModel.Meshes.Add( new MdlMesh
{
Material = material,
VertexCount = meshVertexCount,
VertexOffset = meshVertexOffset
} );
}
bp.Models.Add( studioModel );
}
mdl.BodyParts.Add( bp );
}
}
static void ReadAttachments( BinaryReader br, MdlModel mdl, int index, int count )
{
for ( var i = 0; i < count; i++ )
{
// mstudioattachment_t: sznameindex, flags, localbone, local[12], unused[8] = 92 bytes typically on v48
var offset = index + i * 92;
if ( offset + 20 > br.BaseStream.Length ) break;
br.BaseStream.Position = offset;
var nameIndex = br.ReadInt32();
br.ReadInt32(); // flags
var localBone = br.ReadInt32();
// local matrix 3x4
var m00 = br.ReadSingle(); var m01 = br.ReadSingle(); var m02 = br.ReadSingle(); var m03 = br.ReadSingle();
var m10 = br.ReadSingle(); var m11 = br.ReadSingle(); var m12 = br.ReadSingle(); var m13 = br.ReadSingle();
var m20 = br.ReadSingle(); var m21 = br.ReadSingle(); var m22 = br.ReadSingle(); var m23 = br.ReadSingle();
var attName = BinaryUtil.ReadStringAt( br, offset, nameIndex );
var boneName = localBone >= 0 && localBone < mdl.Bones.Count ? mdl.Bones[localBone].Name : "";
// Extract translation from matrix column
var origin = new Vec3( m03, m13, m23 );
// Approximate yaw/pitch/roll from rotation basis
var yaw = MathF.Atan2( m10, m00 ) * (180f / MathF.PI);
var pitch = MathF.Atan2( -m20, MathF.Sqrt( m21 * m21 + m22 * m22 ) ) * (180f / MathF.PI);
var roll = MathF.Atan2( m21, m22 ) * (180f / MathF.PI);
mdl.Attachments.Add( new AttachmentData
{
Name = string.IsNullOrEmpty( attName ) ? $"attachment{i}" : attName,
ParentBone = boneName,
Origin = origin,
Angles = new Vec3( pitch, yaw, roll )
} );
_ = (m01, m02, m11, m12);
}
}
static void ReadAnimDescs( BinaryReader br, MdlModel mdl, int index, int count )
{
// mstudioanimdesc_t size varies; v48 is 100 bytes
const int stride = 100;
for ( var i = 0; i < count; i++ )
{
var offset = index + i * stride;
if ( offset + stride > br.BaseStream.Length ) break;
br.BaseStream.Position = offset;
br.ReadInt32(); // baseptr
var nameIndex = br.ReadInt32();
var fps = br.ReadSingle();
var flags = br.ReadInt32();
var numframes = br.ReadInt32();
br.ReadInt32(); // nummovements
br.ReadInt32(); // movementindex
msSkip( br, 6 * 4 ); // ikrulezeroframe etc / deprecated
var animindex = br.ReadInt32();
var animName = BinaryUtil.ReadStringAt( br, offset, nameIndex );
mdl.AnimDescs.Add( new MdlAnimDesc
{
Name = string.IsNullOrEmpty( animName ) ? $"anim{i}" : animName,
Fps = fps > 0 ? fps : 30,
FrameCount = Math.Max( 1, numframes ),
Flags = flags,
AnimIndex = animindex,
DescOffset = offset
} );
}
}
static void ReadSequences( BinaryReader br, MdlModel mdl, int index, int count )
{
// mstudioseqdesc_t is large (~212+). We only need name, flags, anim index.
const int stride = 212;
for ( var i = 0; i < count; i++ )
{
var offset = index + i * stride;
if ( offset + 80 > br.BaseStream.Length ) break;
br.BaseStream.Position = offset;
br.ReadInt32(); // baseptr
var szNameIndex = br.ReadInt32();
var szActivity = br.ReadInt32();
var flags = br.ReadInt32();
br.ReadInt32(); // activity
br.ReadInt32(); // actweight
br.ReadInt32(); // numevents
br.ReadInt32(); // eventindex
msSkip( br, 12 * 2 ); // bbmin bbmax
var numblends = br.ReadInt32();
var animindexindex = br.ReadInt32(); // index to short array of anim indices
_ = szActivity;
_ = numblends;
var seqName = BinaryUtil.ReadStringAt( br, offset, szNameIndex );
var animIdx = 0;
if ( animindexindex != 0 )
{
br.BaseStream.Position = offset + animindexindex;
animIdx = br.ReadInt16();
}
var fps = 30f;
if ( animIdx >= 0 && animIdx < mdl.AnimDescs.Count )
fps = mdl.AnimDescs[animIdx].Fps;
mdl.Sequences.Add( new MdlSequence
{
Name = string.IsNullOrEmpty( seqName ) ? $"seq{i}" : seqName,
AnimIndex = animIdx,
Looping = (flags & 0x0001) != 0, // STUDIO_LOOPING
Fps = fps
} );
}
}
static void msSkip( BinaryReader br, int bytes ) => br.BaseStream.Position += bytes;
}