Editor/Conversion/Pipeline/MaterialPipeline.cs

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.

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