Editor/Conversion/Source1/MdlReader.cs

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.

File Access
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;
}