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.
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;
}
}