Editor material conversion pipeline. Converts Source VTF/VMT files into PNG and VMAT assets, decodes VTF images, parses VMTs, writes VMATs, and processes entire folders with optional recursion.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace GmodAssetImporter;
/// <summary>
/// Texture/material conversion. GMod: .vtf = texture pixels, .vmt = material recipe.
/// Primary texture port is VTF → PNG; VMT is optional (used when converting models).
/// </summary>
public static class MaterialPipeline
{
/// <summary>
/// Convert a single .vtf. Uses a sibling .vmt when present; otherwise writes a basic color VMAT,
/// or PNG only for obvious non-color maps (normal/mask/...).
/// </summary>
public static ConversionResult ConvertVtfFile( string vtfPath, ConversionOptions options )
{
var siblingVmt = Path.ChangeExtension( vtfPath, ".vmt" );
if ( File.Exists( siblingVmt ) )
return ConvertVmtFile( siblingVmt, 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( vtfPath )
: options.GmodRoot;
if ( IsNonColorTexture( vtfPath ) )
{
var assetPath = ConvertVtfToPng( vtfPath, options, result, writeLog );
result.Success = assetPath != null;
result.Message = assetPath != null ? $"Converted texture -> {assetPath} (non-color map, no VMAT)" : $"Failed to convert {vtfPath}";
return result;
}
var token = PathResolve.GetMaterialsRelativeToken( vtfPath, options.GmodRoot );
var mat = ConvertMaterial( token, options, Array.Empty<string>(), result, vtfPath );
result.Success = !mat.UsedFallback;
result.Message = mat.UsedFallback ? $"Failed to convert {vtfPath}" : $"Converted material -> {mat.VmatAssetPath}";
}
catch ( Exception ex )
{
result.Success = false;
result.Message = ex.Message;
writeLog( $"Error: {ex.Message}" );
}
return result;
}
/// <summary>
/// Decode VTF bytes to an Assets-relative PNG path. Returns null on failure.
/// </summary>
public static string ConvertVtfToPng(
string vtfPath,
ConversionOptions options,
ConversionResult result,
Action<string> writeLog )
{
writeLog ??= (_ => { });
if ( !File.Exists( vtfPath ) )
{
writeLog( $"VTF not found: {vtfPath}" );
return null;
}
try
{
var image = VtfReader.Load( vtfPath );
var token = PathResolve.GetMaterialsRelativeToken( vtfPath, options.GmodRoot );
var dir = PathResolve.CombineAsset(
"materials",
Path.GetDirectoryName( token.Replace( '/', Path.DirectorySeparatorChar ) )?.Replace( '\\', '/' ) ?? "" );
var baseName = PathResolve.SanitizeAssetName( Path.GetFileName( token ) );
if ( string.IsNullOrEmpty( baseName ) )
baseName = "texture";
// Keep Source texture name; do not invent _color unless the file already uses a map suffix.
var underOutput = PathResolve.CombineAsset( dir, "source_files", baseName + ".png" );
var abs = Path.Combine( options.OutputRoot, underOutput.Replace( '/', Path.DirectorySeparatorChar ) );
PngWriter.Write( abs, image.Width, image.Height, image.Rgba );
result.WrittenFiles.Add( abs );
var assetPath = PathResolve.MakeAssetsRelative( abs, options.OutputRoot );
writeLog( $"Wrote {assetPath} ({image.Width}x{image.Height})" );
return assetPath;
}
catch ( Exception ex )
{
writeLog( $"VTF decode failed for {vtfPath}: {ex.Message}" );
result.Warnings.Add( ex.Message );
return null;
}
}
public static MaterialBuildResult ConvertMaterial(
string materialToken,
ConversionOptions options,
IEnumerable<string> cdMaterials,
ConversionResult result,
string sourceFilePath = null,
ISet<string> usedVtfs = null )
{
var writeLog = options.OnLog ?? (_ => { });
var token = NormalizeMaterialToken( materialToken );
if ( string.IsNullOrWhiteSpace( token ) )
return WriteFallback( "default", options, result );
var searchRoots = PathResolve.EnumerateMaterialSearchRoots( options.GmodRoot, cdMaterials ).ToList();
var localRoot = GetLocalMaterialsRoot( sourceFilePath, token );
if ( localRoot != null && !searchRoots.Contains( localRoot, StringComparer.OrdinalIgnoreCase ) )
searchRoots.Add( localRoot );
var vmtPath = sourceFilePath != null && sourceFilePath.EndsWith( ".vmt", StringComparison.OrdinalIgnoreCase )
? sourceFilePath
: VmtParser.ResolveVmtPath( token, searchRoots );
string colorAsset = null;
string normalAsset = null;
string selfIllumAsset = null;
var translucent = false;
var alphaTest = false;
if ( vmtPath != null )
{
try
{
var vmt = VmtParser.ParseFile( vmtPath );
translucent = vmt.Translucent;
alphaTest = vmt.AlphaTest;
colorAsset = ConvertTexture( vmt.BaseTexture, "_color", searchRoots, options, result, writeLog, usedVtfs );
normalAsset = ConvertTexture( vmt.BumpMap, "_normal", searchRoots, options, result, writeLog, usedVtfs );
if ( vmt.SelfIllum )
selfIllumAsset = ConvertTexture( vmt.SelfIllumMask ?? vmt.BaseTexture, "_selfillum", searchRoots, options, result, writeLog, usedVtfs );
}
catch ( Exception ex )
{
writeLog( $"VMT parse failed for '{token}': {ex.Message}" );
result.Warnings.Add( ex.Message );
}
}
else
{
// Try texture token directly as a VTF
colorAsset = ConvertTexture( token, "_color", searchRoots, options, result, writeLog, usedVtfs );
if ( colorAsset == null )
{
writeLog( $"Material not found: {token} - writing fallback VMAT" );
return WriteFallback( token, options, result );
}
}
var vmatAbs = GetVmatAbsolutePath( token, options );
var vmatAsset = PathResolve.MakeAssetsRelative( vmatAbs, options.OutputRoot );
VmatWriter.Write( vmatAbs, new VmatWriteRequest
{
ColorTextureAssetPath = colorAsset,
NormalTextureAssetPath = normalAsset,
SelfIllumTextureAssetPath = selfIllumAsset,
Translucent = translucent,
AlphaTest = alphaTest
} );
result.WrittenFiles.Add( vmatAbs );
writeLog( $"Wrote {vmatAsset}" );
return new MaterialBuildResult
{
SourceMaterialToken = materialToken,
VmatAssetPath = vmatAsset,
UsedFallback = colorAsset == null
};
}
/// <summary>
/// The .vmat path <see cref="ConvertMaterial"/> would write for this token, without writing anything.
/// </summary>
public static MaterialBuildResult GetExpectedMaterial( string materialToken, ConversionOptions options )
{
var token = NormalizeMaterialToken( materialToken );
if ( string.IsNullOrWhiteSpace( token ) )
token = "default";
var vmatAbs = GetVmatAbsolutePath( token, options );
return new MaterialBuildResult
{
SourceMaterialToken = materialToken,
VmatAssetPath = PathResolve.MakeAssetsRelative( vmatAbs, options.OutputRoot ),
UsedFallback = false
};
}
static string NormalizeMaterialToken( string materialToken )
{
var token = PathResolve.NormalizeSlashes( materialToken ?? "" ).Trim( '/' );
token = token.Replace( "materials/", "", StringComparison.OrdinalIgnoreCase );
return PathResolve.StripExtension( token );
}
static string GetVmatAbsolutePath( string token, ConversionOptions options )
{
var matDir = PathResolve.CombineAsset( "materials", Path.GetDirectoryName( token.Replace( '/', Path.DirectorySeparatorChar ) )?.Replace( '\\', '/' ) ?? "" );
var matName = PathResolve.SanitizeAssetName( Path.GetFileName( token ) );
var underOutput = PathResolve.CombineAsset( matDir, matName + ".vmat" );
return Path.Combine( options.OutputRoot, underOutput.Replace( '/', Path.DirectorySeparatorChar ) );
}
public static ConversionResult ConvertVmtFile( string vmtPath, ConversionOptions options )
{
var result = new ConversionResult();
try
{
options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
? PathResolve.DetectGmodRoot( vmtPath )
: options.GmodRoot;
var token = PathResolve.GetMaterialsRelativeToken( vmtPath, options.GmodRoot );
ConvertMaterial( token, options, Array.Empty<string>(), result, vmtPath );
result.Success = true;
result.Message = $"Converted material {token}";
}
catch ( Exception ex )
{
result.Success = false;
result.Message = ex.Message;
}
return result;
}
/// <summary>
/// Folder material pass: every .vmt becomes a VMAT (+ its PNG textures). Loose .vtf files no VMT uses
/// get a basic color VMAT, except obvious non-color maps (normal/mask/...) which stay PNG-only.
/// </summary>
public static ConversionResult ConvertMaterialsFolder( string folder, ConversionOptions options )
{
var result = new ConversionResult { Success = true };
var writeLog = options.OnLog ?? (_ => { });
var searchOption = options.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
if ( !Directory.Exists( folder ) )
{
result.Success = false;
result.Message = "Folder not found";
return result;
}
options.GmodRoot = string.IsNullOrWhiteSpace( options.GmodRoot )
? PathResolve.DetectGmodRoot( folder )
: options.GmodRoot;
var vmts = Directory.GetFiles( folder, "*.vmt", searchOption );
var vtfs = Directory.GetFiles( folder, "*.vtf", searchOption );
var usedVtfs = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var vmatCount = 0;
writeLog( $"Detected {vmts.Length + vtfs.Length} material file(s) ({vmts.Length} .vmt, {vtfs.Length} .vtf)" );
foreach ( var vmt in vmts )
{
try
{
var token = PathResolve.GetMaterialsRelativeToken( vmt, options.GmodRoot );
ConvertMaterial( token, options, Array.Empty<string>(), result, vmt, usedVtfs );
vmatCount++;
}
catch ( Exception ex )
{
result.Warnings.Add( $"{vmt}: {ex.Message}" );
writeLog( $"Failed: {vmt}: {ex.Message}" );
}
}
var vmtNames = new HashSet<string>( vmts.Select( v => Path.ChangeExtension( Path.GetFullPath( v ), null ) ), StringComparer.OrdinalIgnoreCase );
foreach ( var vtf in vtfs )
{
var full = Path.GetFullPath( vtf );
if ( usedVtfs.Contains( full ) || vmtNames.Contains( Path.ChangeExtension( full, null ) ) )
continue;
if ( IsNonColorTexture( vtf ) )
{
ConvertVtfToPng( vtf, options, result, writeLog );
continue;
}
var token = PathResolve.GetMaterialsRelativeToken( vtf, options.GmodRoot );
var mat = ConvertMaterial( token, options, Array.Empty<string>(), result, vtf, usedVtfs );
if ( !mat.UsedFallback )
vmatCount++;
}
result.Success = result.WrittenFiles.Count > 0;
result.Message = $"Converted {vmatCount} material(s)";
writeLog( result.Message );
return result;
}
static readonly string[] NonColorSuffixes =
{
"_normal", "_n", "_nrm", "_bump", "_normalmap", "_exp", "_exponent", "_mask", "_masks", "_spec", "_phong", "_selfillum", "_illum", "_envmap", "_ao"
};
static bool IsNonColorTexture( string vtfPath )
{
var name = Path.GetFileNameWithoutExtension( vtfPath );
return NonColorSuffixes.Any( s => name.EndsWith( s, StringComparison.OrdinalIgnoreCase ) );
}
/// <summary>
/// Materials root a source file lives under, derived by stripping its materials-relative token,
/// so textures resolve even when no GMod root is set.
/// </summary>
static string GetLocalMaterialsRoot( string sourceFilePath, string token )
{
if ( string.IsNullOrWhiteSpace( sourceFilePath ) )
return null;
var noExt = PathResolve.StripExtension( PathResolve.NormalizeSlashes( Path.GetFullPath( sourceFilePath ) ) );
var suffix = "/" + token.Trim( '/' );
if ( noExt.EndsWith( suffix, StringComparison.OrdinalIgnoreCase ) )
return noExt[..^suffix.Length].Replace( '/', Path.DirectorySeparatorChar );
return Path.GetDirectoryName( Path.GetFullPath( sourceFilePath ) );
}
static string ConvertTexture(
string textureToken,
string suffix,
List<string> searchRoots,
ConversionOptions options,
ConversionResult result,
Action<string> writeLog,
ISet<string> usedVtfs )
{
if ( string.IsNullOrWhiteSpace( textureToken ) )
return null;
var vtf = VmtParser.ResolveVtfPath( textureToken, searchRoots );
if ( vtf == null )
{
writeLog( $"Missing VTF for {textureToken}" );
return null;
}
usedVtfs?.Add( Path.GetFullPath( vtf ) );
try
{
var image = VtfReader.Load( vtf );
var texToken = PathResolve.NormalizeSlashes( textureToken )
.Trim( '/' )
.Replace( "materials/", "", StringComparison.OrdinalIgnoreCase );
texToken = PathResolve.StripExtension( texToken );
var dir = PathResolve.CombineAsset( "materials", Path.GetDirectoryName( texToken.Replace( '/', Path.DirectorySeparatorChar ) )?.Replace( '\\', '/' ) ?? "" );
var baseName = PathResolve.SanitizeAssetName( Path.GetFileName( texToken ) );
// Avoid double suffix if already present
if ( !baseName.EndsWith( suffix, StringComparison.OrdinalIgnoreCase ) )
baseName += suffix;
var underOutput = PathResolve.CombineAsset( dir, "source_files", baseName + ".png" );
var abs = Path.Combine( options.OutputRoot, underOutput.Replace( '/', Path.DirectorySeparatorChar ) );
PngWriter.Write( abs, image.Width, image.Height, image.Rgba );
result.WrittenFiles.Add( abs );
var assetPath = PathResolve.MakeAssetsRelative( abs, options.OutputRoot );
writeLog( $"Wrote {assetPath}" );
return assetPath;
}
catch ( Exception ex )
{
writeLog( $"VTF decode failed for {textureToken}: {ex.Message}" );
result.Warnings.Add( ex.Message );
return null;
}
}
static MaterialBuildResult WriteFallback( string token, ConversionOptions options, ConversionResult result )
{
var matName = PathResolve.SanitizeAssetName( Path.GetFileName( token ) );
if ( string.IsNullOrEmpty( matName ) ) matName = "fallback";
var dir = PathResolve.CombineAsset( "materials", Path.GetDirectoryName( token.Replace( '/', Path.DirectorySeparatorChar ) )?.Replace( '\\', '/' ) ?? "converted" );
var underOutput = PathResolve.CombineAsset( dir, matName + ".vmat" );
var vmatAbs = Path.Combine( options.OutputRoot, underOutput.Replace( '/', Path.DirectorySeparatorChar ) );
var vmatAsset = PathResolve.MakeAssetsRelative( vmatAbs, options.OutputRoot );
VmatWriter.Write( vmatAbs, new VmatWriteRequest() );
result.WrittenFiles.Add( vmatAbs );
result.Warnings.Add( $"Fallback VMAT for '{token}'" );
return new MaterialBuildResult
{
SourceMaterialToken = token,
VmatAssetPath = vmatAsset,
UsedFallback = true
};
}
}