Editor/Conversion/Source1/VmtParser.cs

Parser for Source1 VMT material files. It tokenizes KeyValues-like text, strips comments, reads shader name and key/value pairs into VmtData, provides helpers to resolve texture/material paths and parse floats.

File Access
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;

namespace GmodAssetImporter;

/// <summary>
/// Minimal KeyValues VMT reader for Source1 material fields we care about.
/// </summary>
public sealed class VmtData
{
	public string Shader { get; set; } = "";
	public Dictionary<string, string> Params { get; } = new( StringComparer.OrdinalIgnoreCase );

	public string Get( string key, string fallback = null )
	{
		return Params.TryGetValue( key, out var v ) ? v : fallback;
	}

	public bool HasFlag( string key )
	{
		if ( !Params.TryGetValue( key, out var v ) )
			return false;
		v = v.Trim();
		return v is "1" or "true" or "yes";
	}

	public string BaseTexture => Get( "$basetexture" ) ?? Get( "$basetexture2" );
	public string BumpMap => Get( "$bumpmap" ) ?? Get( "$normalmap" );
	public string SelfIllumMask => Get( "$selfillummask" ) ?? Get( "$selfillumtexture" );
	public bool Translucent => HasFlag( "$translucent" );
	public bool AlphaTest => HasFlag( "$alphatest" );
	public bool SelfIllum => HasFlag( "$selfillum" );
}

public static class VmtParser
{
	static readonly Regex PairRegex = new(
		@"^""?([$]?[\w./\\-]+)""?\s+""?([^""\r\n]*)""?\s*$",
		RegexOptions.Compiled | RegexOptions.CultureInvariant );

	public static VmtData Parse( string text )
	{
		var data = new VmtData();
		if ( string.IsNullOrWhiteSpace( text ) )
			return data;

		// Strip comments
		var sb = new StringBuilder( text.Length );
		using ( var reader = new StringReader( text ) )
		{
			string line;
			while ( (line = reader.ReadLine()) != null )
			{
				var comment = line.IndexOf( "//", StringComparison.Ordinal );
				if ( comment >= 0 )
					line = line[..comment];
				sb.AppendLine( line );
			}
		}

		var tokens = Tokenize( sb.ToString() );
		if ( tokens.Count == 0 )
			return data;

		var i = 0;
		if ( tokens[i] != "{" )
		{
			data.Shader = Unquote( tokens[i] );
			i++;
		}

		ParseBlock( tokens, ref i, data.Params );
		return data;
	}

	public static VmtData ParseFile( string path )
	{
		var text = File.ReadAllText( path );
		return Parse( text );
	}

	static void ParseBlock( List<string> tokens, ref int i, Dictionary<string, string> dest )
	{
		if ( i < tokens.Count && tokens[i] == "{" )
			i++;

		while ( i < tokens.Count )
		{
			var t = tokens[i];
			if ( t == "}" )
			{
				i++;
				return;
			}

			if ( i + 1 < tokens.Count && tokens[i + 1] == "{" )
			{
				// nested block — skip
				i += 2;
				SkipBlock( tokens, ref i );
				continue;
			}

			if ( i + 1 < tokens.Count )
			{
				var key = Unquote( tokens[i] );
				var value = Unquote( tokens[i + 1] );
				if ( key != "{" && key != "}" && value != "{" )
					dest[key] = value;
				i += 2;
				continue;
			}

			i++;
		}
	}

	static void SkipBlock( List<string> tokens, ref int i )
	{
		var depth = 1;
		while ( i < tokens.Count && depth > 0 )
		{
			if ( tokens[i] == "{" ) depth++;
			else if ( tokens[i] == "}" ) depth--;
			i++;
		}
	}

	static List<string> Tokenize( string text )
	{
		var list = new List<string>();
		var i = 0;
		while ( i < text.Length )
		{
			var c = text[i];
			if ( char.IsWhiteSpace( c ) )
			{
				i++;
				continue;
			}

			if ( c is '{' or '}' )
			{
				list.Add( c.ToString() );
				i++;
				continue;
			}

			if ( c == '"' )
			{
				i++;
				var start = i;
				while ( i < text.Length && text[i] != '"' )
					i++;
				list.Add( text[start..i] );
				if ( i < text.Length ) i++;
				continue;
			}

			var s = i;
			while ( i < text.Length && !char.IsWhiteSpace( text[i] ) && text[i] is not '{' and not '}' and not '"' )
				i++;
			list.Add( text[s..i] );
		}

		return list;
	}

	static string Unquote( string s )
	{
		s = (s ?? "").Trim();
		if ( s.Length >= 2 && s[0] == '"' && s[^1] == '"' )
			return s[1..^1];
		return s;
	}

	/// <summary>
	/// Resolve a VMT texture token (materials-relative, no extension) against search roots.
	/// </summary>
	public static string ResolveVtfPath( string textureToken, IEnumerable<string> searchRoots )
	{
		if ( string.IsNullOrWhiteSpace( textureToken ) )
			return null;

		var token = PathResolve.NormalizeSlashes( textureToken )
			.TrimStart( '/' )
			.Replace( "materials/", "", StringComparison.OrdinalIgnoreCase );
		token = PathResolve.StripExtension( token );

		var candidates = new List<string>();
		foreach ( var root in searchRoots )
		{
			if ( string.IsNullOrWhiteSpace( root ) )
				continue;
			candidates.Add( Path.Combine( root, token.Replace( '/', Path.DirectorySeparatorChar ) + ".vtf" ) );
			candidates.Add( Path.Combine( root, "materials", token.Replace( '/', Path.DirectorySeparatorChar ) + ".vtf" ) );
		}

		return PathResolve.FindExistingFile( candidates );
	}

	public static string ResolveVmtPath( string materialToken, IEnumerable<string> searchRoots )
	{
		if ( string.IsNullOrWhiteSpace( materialToken ) )
			return null;

		var token = PathResolve.NormalizeSlashes( materialToken )
			.TrimStart( '/' )
			.Replace( "materials/", "", StringComparison.OrdinalIgnoreCase );
		token = PathResolve.StripExtension( token );

		var candidates = new List<string>();
		foreach ( var root in searchRoots )
		{
			if ( string.IsNullOrWhiteSpace( root ) )
				continue;
			candidates.Add( Path.Combine( root, token.Replace( '/', Path.DirectorySeparatorChar ) + ".vmt" ) );
			candidates.Add( Path.Combine( root, "materials", token.Replace( '/', Path.DirectorySeparatorChar ) + ".vmt" ) );
		}

		return PathResolve.FindExistingFile( candidates );
	}

	public static float TryParseFloat( string value, float fallback )
	{
		if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var f ) )
			return f;
		return fallback;
	}
}