Editor pipeline for converting Source MDL/VVD/VTX model files into VMDL and FBX assets. It loads MDL/VVD/VTX, resolves materials, builds meshes and skin groups, writes FBX per mesh and a .vmdl, and optionally exports materials or logs animation sequence names.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace GmodAssetImporter;
/// <summary>MDL/VVD/VTX to VMDL + FBX (ModelDoc-preferred) conversion.</summary>
public static class MdlPipeline
{
public static ConversionResult ConvertModel( string mdlPath, ConversionOptions options )
{
var result = new ConversionResult();
var writeLog = options.OnLog ?? (_ => { });
try
{
if ( string.IsNullOrWhiteSpace( options.OutputRoot ) )
throw new InvalidOperationException( "Output root is required" );
options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
? PathResolve.DetectGmodRoot( mdlPath )
: options.GmodRoot;
writeLog( $"Loading {mdlPath}" );
var mdl = MdlReader.Load( mdlPath );
var vvdPath = Path.ChangeExtension( mdlPath, ".vvd" );
if ( !File.Exists( vvdPath ) )
throw new FileNotFoundException( "Companion .vvd not found", vvdPath );
var vtxPath = VtxReader.FindVtxPath( mdlPath );
if ( vtxPath == null )
throw new FileNotFoundException( "Companion .vtx not found", Path.ChangeExtension( mdlPath, ".vtx" ) );
var vvd = VvdReader.Load( vvdPath );
var vtx = VtxReader.Load( vtxPath );
writeLog( $"VVD verts={vvd.Vertices.Count}, VTX bodyparts={vtx.BodyParts.Count}" );
var modelDirUnderOutput = options.PreservePaths
? PathResolve.GetModelsRelativeDirectory( mdlPath, options.GmodRoot )
: "models/converted";
var modelName = PathResolve.SanitizeAssetName( Path.GetFileNameWithoutExtension( mdlPath ) );
var absModelDir = Path.Combine( options.OutputRoot, modelDirUnderOutput.Replace( '/', Path.DirectorySeparatorChar ) );
Directory.CreateDirectory( absModelDir );
// VMDL paths must be Assets-relative (e.g. test/models/...), not only models/...
var modelAssetDirectory = PathResolve.MakeAssetsRelative( absModelDir, options.OutputRoot );
var context = new ModelBuildContext
{
ModelName = modelName,
ModelAssetDirectory = modelAssetDirectory,
Scale = options.ModelScale,
IncludeBindPose = true
};
context.Bones.AddRange( mdl.Bones );
context.Attachments.AddRange( mdl.Attachments );
// Materials
if ( !options.ImportMaterials && mdl.MaterialNames.Count > 0 )
writeLog( $"Skipping {mdl.MaterialNames.Count} material(s) (Import materials off); model keeps expected .vmat paths" );
var materialResults = new Dictionary<int, MaterialBuildResult>();
for ( var i = 0; i < mdl.MaterialNames.Count; i++ )
{
var token = ResolveMaterialToken( mdl.MaterialNames[i], mdl.CdMaterials, options.GmodRoot );
var mat = options.ImportMaterials
? MaterialPipeline.ConvertMaterial( token, options, mdl.CdMaterials, result )
: MaterialPipeline.GetExpectedMaterial( token, options );
materialResults[i] = mat;
context.MaterialRemaps.Add( new MaterialRemap
{
// Must match FBX material slot name + ".vmat" (ModelDoc import convention)
From = PathResolve.MaterialRemapFromName( mdl.MaterialNames[i] ),
To = mat.VmatAssetPath
} );
}
BuildSkinGroups( mdl, materialResults, context );
// Geometry from bodyparts / VTX → FBX (preferred by ModelDoc; SMD is deprecated)
for ( var bpIndex = 0; bpIndex < mdl.BodyParts.Count; bpIndex++ )
{
var bp = mdl.BodyParts[bpIndex];
var bodyGroup = new ImporterBodyGroup { Name = PathResolve.StripMeshSourceExtension( bp.Name ) };
for ( var modelIndex = 0; modelIndex < bp.Models.Count; modelIndex++ )
{
var studioModel = bp.Models[modelIndex];
var meshName = PathResolve.SanitizeAssetName( $"{bp.Name}_{studioModel.Name}" );
if ( string.IsNullOrEmpty( meshName ) )
meshName = $"mesh_{bpIndex}_{modelIndex}";
var export = BuildMeshExport( meshName, studioModel, mdl, vvd, vtx, bpIndex, modelIndex, materialResults );
if ( export.Triangles.Count == 0 )
{
writeLog( $"Skipping empty mesh {meshName}" );
continue;
}
var fbxFile = meshName + ".fbx";
var fbxAbs = Path.Combine( absModelDir, fbxFile );
FbxWriter.WriteMesh( fbxAbs, context.Bones, export );
result.WrittenFiles.Add( fbxAbs );
export.FileName = fbxFile;
context.Meshes.Add( export );
bodyGroup.Choices.Add( new ImporterBodyGroupChoice
{
Name = PathResolve.StripMeshSourceExtension( studioModel.Name ),
MeshNames = { meshName }
} );
}
if ( bodyGroup.Choices.Count > 0 )
context.BodyGroups.Add( bodyGroup );
}
if ( context.Meshes.Count == 0 )
throw new InvalidOperationException( "No meshes extracted from model" );
if ( options.ExportAnimations )
ExportSequences( mdl, context, writeLog );
else if ( mdl.Sequences.Count > 0 )
writeLog( $"Skipping {mdl.Sequences.Count} sequence clip(s) (AnimBindPose only; enable Export sequence clips for names log)" );
var vmdlUnderOutput = PathResolve.CombineAsset( modelDirUnderOutput, modelName + ".vmdl" );
var vmdlAbs = Path.Combine( options.OutputRoot, vmdlUnderOutput.Replace( '/', Path.DirectorySeparatorChar ) );
var vmdlAsset = PathResolve.MakeAssetsRelative( vmdlAbs, options.OutputRoot );
VmdlWriter.Write( vmdlAbs, context );
result.WrittenFiles.Add( vmdlAbs );
result.Success = true;
result.Message = $"Converted {modelName} -> {vmdlAsset}";
writeLog( result.Message );
}
catch ( Exception ex )
{
result.Success = false;
result.Message = ex.Message;
writeLog( $"Error: {ex.Message}" );
}
return result;
}
/// <summary>Write only the VMAT/PNG materials an .mdl references; no mesh or .vmdl.</summary>
public static ConversionResult ConvertModelMaterials( string mdlPath, ConversionOptions options )
{
var result = new ConversionResult();
var writeLog = options.OnLog ?? (_ => { });
try
{
options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
? PathResolve.DetectGmodRoot( mdlPath )
: options.GmodRoot;
writeLog( $"Loading materials for {mdlPath}" );
var mdl = MdlReader.Load( mdlPath );
foreach ( var name in mdl.MaterialNames )
{
var token = ResolveMaterialToken( name, mdl.CdMaterials, options.GmodRoot );
MaterialPipeline.ConvertMaterial( token, options, mdl.CdMaterials, result );
}
result.Success = true;
result.Message = $"Converted {mdl.MaterialNames.Count} material(s) for {Path.GetFileNameWithoutExtension( mdlPath )}";
writeLog( result.Message );
}
catch ( Exception ex )
{
result.Success = false;
result.Message = ex.Message;
writeLog( $"Error: {ex.Message}" );
}
return result;
}
static string ResolveMaterialToken( string materialName, List<string> cdMaterials, string gmodRoot )
{
materialName = PathResolve.NormalizeSlashes( materialName ).Trim( '/' );
if ( cdMaterials.Count == 0 )
return materialName;
// Source tries every $cdmaterials folder in order; the material name may itself contain subfolders
var roots = PathResolve.EnumerateMaterialSearchRoots( gmodRoot, null ).ToList();
foreach ( var cd in cdMaterials )
{
var candidate = PathResolve.CombineAsset( cd, materialName );
if ( VmtParser.ResolveVmtPath( candidate, roots ) != null )
return candidate;
}
return materialName.Contains( '/' )
? materialName
: PathResolve.CombineAsset( cdMaterials[0], materialName );
}
static void BuildSkinGroups( MdlModel mdl, Dictionary<int, MaterialBuildResult> materials, ModelBuildContext context )
{
if ( mdl.SkinFamilies.Count <= 1 ) return;
for ( var family = 1; family < mdl.SkinFamilies.Count; family++ )
{
var row = mdl.SkinFamilies[family];
var group = new MaterialGroupData { Name = $"skin_{family}" };
for ( var slot = 0; slot < row.Length && slot < mdl.SkinReferenceCount; slot++ )
{
var matIndex = row[slot];
if ( matIndex < 0 || matIndex >= mdl.MaterialNames.Count ) continue;
if ( !materials.TryGetValue( matIndex, out var mat ) ) continue;
// Remap the family-0 material for this slot → family material
var baseIndex = ResolveSkinMaterial( mdl, slot );
var sourceName = baseIndex >= 0 && baseIndex < mdl.MaterialNames.Count
? mdl.MaterialNames[baseIndex]
: mdl.MaterialNames[matIndex];
group.Remaps.Add( new MaterialRemap
{
From = PathResolve.MaterialRemapFromName( sourceName ),
To = mat.VmatAssetPath
} );
}
if ( group.Remaps.Count > 0 )
context.MaterialGroups.Add( group );
}
}
/// <summary>Studio mesh material is a skin reference; family 0 maps it to a texture index.</summary>
static int ResolveSkinMaterial( MdlModel mdl, int skinRef )
{
if ( mdl.SkinFamilies.Count == 0 ) return skinRef;
var row = mdl.SkinFamilies[0];
return skinRef >= 0 && skinRef < row.Length ? row[skinRef] : skinRef;
}
static MeshExport BuildMeshExport(
string meshName,
MdlStudioModel studioModel,
MdlModel mdl,
VvdFile vvd,
VtxFile vtx,
int bpIndex,
int modelIndex,
Dictionary<int, MaterialBuildResult> materials )
{
var export = new MeshExport { Name = meshName };
var baseVertex = studioModel.VertexIndex;
// Copy this model's vertices into local list
for ( var i = 0; i < studioModel.VertexCount; i++ )
{
var srcIndex = baseVertex + i;
if ( srcIndex < 0 || srcIndex >= vvd.Vertices.Count )
{
export.Vertices.Add( new MeshVertex() );
continue;
}
var src = vvd.Vertices[srcIndex];
var copy = new MeshVertex
{
Position = new Vec3( src.Position.X, src.Position.Y, src.Position.Z ),
Normal = new Vec3( src.Normal.X, src.Normal.Y, src.Normal.Z ),
Uv = new Vec2( src.Uv.X, src.Uv.Y )
};
foreach ( var w in src.Weights )
copy.Weights.Add( new BoneWeight { Bone = w.Bone, Weight = w.Weight } );
export.Vertices.Add( copy );
}
if ( bpIndex < vtx.BodyParts.Count
&& modelIndex < vtx.BodyParts[bpIndex].Models.Count
&& vtx.BodyParts[bpIndex].Models[modelIndex].Lods.Count > 0 )
{
var lod = vtx.BodyParts[bpIndex].Models[modelIndex].Lods[0];
for ( var meshIdx = 0; meshIdx < lod.Meshes.Count; meshIdx++ )
{
var vtxMesh = lod.Meshes[meshIdx];
// FBX material slot must match DefaultMaterialGroup remap "from" (sans .vmat)
var materialName = "default";
var meshVertexOffset = 0;
if ( meshIdx < studioModel.Meshes.Count )
{
var studioMesh = studioModel.Meshes[meshIdx];
meshVertexOffset = studioMesh.VertexOffset;
var matId = ResolveSkinMaterial( mdl, studioMesh.Material );
if ( matId >= 0 && matId < mdl.MaterialNames.Count )
materialName = PathResolve.MaterialSlotName( mdl.MaterialNames[matId] );
else if ( materials.TryGetValue( matId, out var mat ) )
materialName = PathResolve.MaterialSlotName( mat.VmatAssetPath );
}
// VTX vertex ids are relative to the studio mesh, not the studio model
var indices = vtxMesh.Indices;
var vertexCount = export.Vertices.Count;
for ( var i = 0; i + 2 < indices.Count; i += 3 )
{
var a = meshVertexOffset + indices[i];
var b = meshVertexOffset + indices[i + 1];
var c = meshVertexOffset + indices[i + 2];
if ( a >= vertexCount || b >= vertexCount || c >= vertexCount )
continue;
export.Triangles.Add( new MeshTriangle
{
A = a,
B = b,
C = c,
MaterialName = materialName
} );
}
}
}
return export;
}
static void ExportSequences( MdlModel mdl, ModelBuildContext context, Action<string> writeLog )
{
// ModelDoc rejects SMD as AnimFile ("unrecognized file type"). Until we decode Source
// anim RLE into FBX clips, keep AnimBindPose only and surface sequence names in the log.
_ = context;
var names = new List<string>();
var used = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var seq in mdl.Sequences )
{
var animName = PathResolve.SanitizeAssetName( seq.Name );
if ( string.IsNullOrEmpty( animName ) || !used.Add( animName ) )
continue;
names.Add( animName );
}
if ( names.Count > 0 )
writeLog( $"Sequence names ({names.Count}, not exported — need FBX anims): {string.Join( ", ", names.Take( 24 ) )}{(names.Count > 24 ? "…" : "")}" );
}
}