Utility class for resolving and normalizing filesystem and game asset paths used by the editor importer. It normalizes slashes, strips extensions, finds Garry's Mod root by looking for models/materials folders, builds relative model/material tokens, counts files, sanitizes asset names, and converts absolute paths into Assets-relative paths.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace GmodAssetImporter;
public static class PathResolve
{
public static string NormalizeSlashes( string path )
{
return (path ?? "").Replace( '\\', '/' ).Trim();
}
public static string StripExtension( string path )
{
path = NormalizeSlashes( path );
var idx = path.LastIndexOf( '.' );
return idx > 0 ? path[..idx] : path;
}
public static string GetFileNameWithoutExtension( string path )
{
path = NormalizeSlashes( path );
var name = Path.GetFileNameWithoutExtension( path );
return name ?? "";
}
/// <summary>
/// Walk upward from <paramref name="startPath"/> looking for a folder that contains models/ or materials/.
/// </summary>
public static string DetectGmodRoot( string startPath )
{
if ( string.IsNullOrWhiteSpace( startPath ) )
return "";
var dir = Directory.Exists( startPath ) ? startPath : Path.GetDirectoryName( startPath );
while ( !string.IsNullOrEmpty( dir ) )
{
var models = Path.Combine( dir, "models" );
var materials = Path.Combine( dir, "materials" );
if ( Directory.Exists( models ) || Directory.Exists( materials ) )
return dir;
// GarrysMod\garrysmod layout
var garrysmod = Path.Combine( dir, "garrysmod" );
if ( Directory.Exists( Path.Combine( garrysmod, "models" ) ) || Directory.Exists( Path.Combine( garrysmod, "materials" ) ) )
return garrysmod;
dir = Path.GetDirectoryName( dir );
}
return "";
}
/// <summary>
/// If path is under gmodRoot/models/..., return models/... relative path without extension.
/// </summary>
public static string GetModelsRelativeDirectory( string mdlPath, string gmodRoot )
{
mdlPath = NormalizeSlashes( Path.GetFullPath( mdlPath ) );
gmodRoot = NormalizeSlashes( Path.GetFullPath( gmodRoot ?? "" ) );
if ( string.IsNullOrEmpty( gmodRoot ) )
{
var dir = NormalizeSlashes( Path.GetDirectoryName( mdlPath ) ?? "" );
var idx = dir.IndexOf( "/models/", StringComparison.OrdinalIgnoreCase );
if ( idx >= 0 )
return dir[(idx + 1)..];
return "models";
}
var modelsRoot = NormalizeSlashes( Path.Combine( gmodRoot, "models" ) );
var mdlDir = NormalizeSlashes( Path.GetDirectoryName( mdlPath ) ?? "" );
if ( mdlDir.StartsWith( modelsRoot, StringComparison.OrdinalIgnoreCase ) )
{
var rel = mdlDir[modelsRoot.Length..].TrimStart( '/' );
return string.IsNullOrEmpty( rel ) ? "models" : $"models/{rel}";
}
return "models";
}
/// <summary>
/// Relative materials path for a VTF/VMT under materials/… (no extension), e.g. models/weapons/gun.
/// </summary>
public static string GetMaterialsRelativeToken( string materialFilePath, string gmodRoot )
{
materialFilePath = NormalizeSlashes( Path.GetFullPath( materialFilePath ) );
gmodRoot = string.IsNullOrWhiteSpace( gmodRoot ) ? "" : NormalizeSlashes( Path.GetFullPath( gmodRoot ) );
string relative = null;
if ( !string.IsNullOrEmpty( gmodRoot ) )
{
var materialsRoot = NormalizeSlashes( Path.Combine( gmodRoot, "materials" ) );
if ( materialFilePath.StartsWith( materialsRoot + "/", StringComparison.OrdinalIgnoreCase )
|| materialFilePath.StartsWith( materialsRoot, StringComparison.OrdinalIgnoreCase ) )
{
relative = materialFilePath.Length > materialsRoot.Length
? materialFilePath[materialsRoot.Length..].TrimStart( '/' )
: "";
}
}
if ( relative == null )
{
var idx = materialFilePath.IndexOf( "/materials/", StringComparison.OrdinalIgnoreCase );
if ( idx >= 0 )
relative = materialFilePath[(idx + "/materials/".Length)..];
}
relative ??= Path.GetFileName( materialFilePath );
return StripExtension( relative.Trim( '/' ) );
}
/// <summary>
/// Count Source1 texture/material files under a folder (for UI / batch detection).
/// </summary>
public static (int Vtf, int Vmt, int Mdl) CountSourceAssets( string folder, bool recursive )
{
if ( !Directory.Exists( folder ) )
return (0, 0, 0);
var search = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
return (
Directory.GetFiles( folder, "*.vtf", search ).Length,
Directory.GetFiles( folder, "*.vmt", search ).Length,
Directory.GetFiles( folder, "*.mdl", search ).Length
);
}
public static string CombineAsset( params string[] parts )
{
return NormalizeSlashes( string.Join( "/", parts.Where( p => !string.IsNullOrWhiteSpace( p ) ).Select( p => p.Trim( '/' ) ) ) );
}
public static IEnumerable<string> EnumerateMaterialSearchRoots( string gmodRoot, IEnumerable<string> cdMaterials )
{
if ( !string.IsNullOrWhiteSpace( gmodRoot ) )
{
yield return Path.Combine( gmodRoot, "materials" );
yield return gmodRoot;
}
foreach ( var cd in cdMaterials ?? Array.Empty<string>() )
{
var clean = NormalizeSlashes( cd ).Trim( '/' );
if ( string.IsNullOrEmpty( clean ) )
continue;
if ( !string.IsNullOrWhiteSpace( gmodRoot ) )
yield return Path.Combine( gmodRoot, "materials", clean.Replace( '/', Path.DirectorySeparatorChar ) );
}
}
public static string FindExistingFile( IEnumerable<string> candidates )
{
foreach ( var c in candidates )
{
if ( !string.IsNullOrWhiteSpace( c ) && File.Exists( c ) )
return c;
}
return null;
}
public static string SanitizeAssetName( string name )
{
name = StripMeshSourceExtension( NormalizeSlashes( name ?? "" ).Trim( '/' ) );
// Material tokens can be paths — keep leaf name only for FBX/VMDL slots.
name = Path.GetFileName( name ) ?? name;
name = StripMeshSourceExtension( name );
foreach ( var c in Path.GetInvalidFileNameChars() )
name = name.Replace( c, '_' );
return name.Replace( ' ', '_' );
}
/// <summary>
/// ModelDoc DefaultMaterialGroup "from" name: FBX material slot + ".vmat".
/// </summary>
public static string MaterialRemapFromName( string sourceMaterialName )
{
var slot = MaterialSlotName( sourceMaterialName );
if ( string.IsNullOrEmpty( slot ) )
slot = "default";
return slot + ".vmat";
}
/// <summary>
/// Leaf material name with only known asset extensions stripped, so names like
/// "gun.body" and "gun.mag" stay distinct slots.
/// </summary>
public static string MaterialSlotName( string sourceMaterialName )
{
var slot = SanitizeAssetName( sourceMaterialName ?? "" );
return string.IsNullOrEmpty( slot ) ? "default" : slot;
}
/// <summary>
/// Strip trailing mesh/source extensions (.smd, .fbx, …) so names don't become foo.smd.fbx.
/// </summary>
public static string StripMeshSourceExtension( string name )
{
name = NormalizeSlashes( name ?? "" );
string[] exts = { ".smd", ".fbx", ".dmx", ".obj", ".mdl", ".vmdl", ".vta", ".vmt", ".vmat" };
var stripped = true;
while ( stripped )
{
stripped = false;
foreach ( var ext in exts )
{
if ( name.EndsWith( ext, StringComparison.OrdinalIgnoreCase ) )
{
name = name[..^ext.Length];
stripped = true;
}
}
}
return name;
}
/// <summary>
/// Convert an absolute path (or path under output root) into a game asset path
/// relative to the project's Assets folder, e.g. test/models/weapons/gun.fbx.
/// </summary>
public static string MakeAssetsRelative( string absolutePath, string outputRoot = null )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) )
return "";
var abs = NormalizeSlashes( Path.GetFullPath( absolutePath ) );
const string marker = "/assets/";
var idx = abs.IndexOf( marker, StringComparison.OrdinalIgnoreCase );
if ( idx >= 0 )
return abs[(idx + marker.Length)..].Trim( '/' );
if ( !string.IsNullOrWhiteSpace( outputRoot ) )
{
var root = NormalizeSlashes( Path.GetFullPath( outputRoot ) ).TrimEnd( '/' );
if ( abs.StartsWith( root + "/", StringComparison.OrdinalIgnoreCase ) )
return abs[(root.Length + 1)..];
if ( abs.Equals( root, StringComparison.OrdinalIgnoreCase ) )
return "";
}
return Path.GetFileName( abs ) ?? "";
}
/// <summary>
/// Absolute file under OutputRoot → Assets-relative game path.
/// </summary>
public static string AssetPathUnderOutput( string outputRoot, string relativeUnderOutput )
{
var abs = Path.Combine( outputRoot ?? "", (relativeUnderOutput ?? "").Replace( '/', Path.DirectorySeparatorChar ) );
return MakeAssetsRelative( abs, outputRoot );
}
}