Editor/WeaponImporter/Tool/Engine/AssetCompiler.cs
using Editor;
using Sandbox;
namespace WeaponImporter.Tool;
/// <summary>Outcome of compiling one generated file.</summary>
public sealed record CompileResult( string Path, bool Success, IReadOnlyList<string> Errors )
{
public static CompileResult Failed( string path, string error ) => new( path, false, new[] { error } );
}
/// <summary>
/// Registers and compiles generated assets. The asset system exposes no compile-error API, so
/// errors are read from the slice of the editor log written while compiling.
/// </summary>
public static class AssetCompiler
{
/// <summary>Absolute path inside the current project's assets folder.</summary>
public static string AssetsRoot => Project.Current?.GetAssetsPath() ?? "";
public static string Absolute( string relative ) => System.IO.Path.GetFullPath( System.IO.Path.Combine( AssetsRoot, relative.Replace( '/', System.IO.Path.DirectorySeparatorChar ) ) );
/// <summary>The file being imported: writing over it (or its compiled form) is refused.</summary>
public static string Protected { get; set; }
private static void Guard( string abs )
{
if ( Protected is { Length: > 0 } source && (string.Equals( abs, source, StringComparison.OrdinalIgnoreCase ) || string.Equals( abs, source + "_c", StringComparison.OrdinalIgnoreCase )) )
throw new InvalidOperationException( $"Refusing to overwrite the imported file {source}." );
}
/// <summary>Writes a text file (only when it changed) and returns whether it was rewritten.</summary>
public static bool WriteText( string relative, string text )
{
var abs = Absolute( relative );
Guard( abs );
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( abs )! );
if ( System.IO.File.Exists( abs ) && System.IO.File.ReadAllText( abs ) == text )
return false;
System.IO.File.WriteAllText( abs, text );
return true;
}
public static bool WriteBytes( string relative, byte[] bytes )
{
var abs = Absolute( relative );
Guard( abs );
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( abs )! );
if ( System.IO.File.Exists( abs ) && new System.IO.FileInfo( abs ).Length == bytes.Length && System.IO.File.ReadAllBytes( abs ).AsSpan().SequenceEqual( bytes ) )
return false;
System.IO.File.WriteAllBytes( abs, bytes );
return true;
}
/// <summary>Registers every file with the asset system (main thread).</summary>
public static async Task RegisterAsync( IEnumerable<string> relativePaths )
{
await EditorThread.SwitchToMainThread();
foreach ( var rel in relativePaths )
{
var abs = Absolute( rel );
if ( System.IO.File.Exists( abs ) )
AssetSystem.RegisterFile( abs );
}
}
/// <summary>
/// Compiles an asset and waits for a fresh compiled file. Freshly written inputs need a
/// moment of quiet or the engine abandons the compile, so it retries on that.
/// </summary>
public static async Task<CompileResult> CompileAsync( string relative, float timeoutSeconds = 180f, CancellationToken cancel = default )
{
await EditorThread.SwitchToMainThread();
var abs = Absolute( relative );
if ( !System.IO.File.Exists( abs ) )
return CompileResult.Failed( relative, "file does not exist" );
for ( var attempt = 0; attempt < 3; attempt++ )
{
var logStart = EngineLog.Length();
var asset = AssetSystem.FindByPath( abs ) ?? AssetSystem.RegisterFile( abs );
if ( asset is null )
return CompileResult.Failed( relative, "the asset system did not accept the file" );
var compiled = asset.GetCompiledFile( true ) ?? abs + "_c";
var before = System.IO.File.Exists( compiled ) ? System.IO.File.GetLastWriteTimeUtc( compiled ) : DateTime.MinValue;
var started = DateTime.UtcNow;
asset.Compile( true );
while ( (DateTime.UtcNow - started).TotalSeconds < timeoutSeconds )
{
cancel.ThrowIfCancellationRequested();
await EditorThread.Delay( 200, cancel );
if ( asset.IsCompileFailed )
break;
compiled = asset.GetCompiledFile( true ) ?? compiled;
if ( System.IO.File.Exists( compiled ) && System.IO.File.GetLastWriteTimeUtc( compiled ) > before )
return new CompileResult( relative, true, Array.Empty<string>() );
}
var log = EngineLog.Since( logStart );
var name = System.IO.Path.GetFileName( relative );
if ( log.Any( l => l.Contains( "abandoning", StringComparison.OrdinalIgnoreCase ) && l.Contains( name, StringComparison.OrdinalIgnoreCase ) ) )
{
await EditorThread.Delay( 2000, cancel );
continue;
}
var errors = EngineLog.ErrorsFor( log, name );
return new CompileResult( relative, false, errors.Count > 0 ? errors : new[] { asset.IsCompileFailed ? "compile failed (no details in the log)" : "compile timed out" } );
}
return CompileResult.Failed( relative, "compile was abandoned repeatedly" );
}
/// <summary>Loads a model after compiling, polling until it is valid and has the expected sequences.</summary>
public static async Task<Model> LoadModelAsync( string relative, IReadOnlyCollection<string> expectSequences = null, float timeoutSeconds = 20f )
{
await EditorThread.SwitchToMainThread();
var started = DateTime.UtcNow;
Model model = null;
while ( (DateTime.UtcNow - started).TotalSeconds < timeoutSeconds )
{
model = Model.Load( relative );
if ( model is not null && !model.IsError )
{
if ( expectSequences is null || expectSequences.All( s => model.AnimationNames.Contains( s ) ) )
return model;
}
await EditorThread.Delay( 250 );
}
return model;
}
}
/// <summary>Reads the editor log file (the only place compile errors show up).</summary>
public static class EngineLog
{
public static string LogPath
{
get
{
foreach ( var dir in new[] { Environment.CurrentDirectory, AppContext.BaseDirectory, System.IO.Path.GetDirectoryName( Environment.ProcessPath ?? "" ) ?? "" } )
{
var candidate = System.IO.Path.Combine( dir, "logs", "sbox-dev.log" );
if ( System.IO.File.Exists( candidate ) )
return candidate;
var parent = System.IO.Path.GetDirectoryName( dir.TrimEnd( '\\', '/' ) );
if ( parent is not null && System.IO.File.Exists( System.IO.Path.Combine( parent, "logs", "sbox-dev.log" ) ) )
return System.IO.Path.Combine( parent, "logs", "sbox-dev.log" );
}
return "";
}
}
public static long Length()
{
try
{
var path = LogPath;
return path.Length > 0 ? new System.IO.FileInfo( path ).Length : 0;
}
catch ( Exception )
{
return 0;
}
}
public static List<string> Since( long offset )
{
var lines = new List<string>();
try
{
var path = LogPath;
if ( path.Length == 0 )
return lines;
using var fs = new System.IO.FileStream( path, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete );
if ( offset > fs.Length )
offset = 0;
fs.Seek( offset, System.IO.SeekOrigin.Begin );
using var reader = new System.IO.StreamReader( fs );
string line;
while ( (line = reader.ReadLine()) is not null )
lines.Add( line );
}
catch ( Exception )
{
}
return lines;
}
/// <summary>Error-looking lines that mention the file, ModelDoc or the resource compiler (last 12).</summary>
public static List<string> ErrorsFor( IEnumerable<string> lines, string fileName )
{
return lines
.Where( l => (l.Contains( "error", StringComparison.OrdinalIgnoreCase ) || l.Contains( "failed", StringComparison.OrdinalIgnoreCase ) || l.Contains( "missing", StringComparison.OrdinalIgnoreCase ) || l.Contains( "warning", StringComparison.OrdinalIgnoreCase ))
&& (l.Contains( fileName, StringComparison.OrdinalIgnoreCase ) || l.Contains( "resourcecompiler", StringComparison.OrdinalIgnoreCase ) || l.Contains( "ModelDoc", StringComparison.OrdinalIgnoreCase ) || l.Contains( "dmx", StringComparison.OrdinalIgnoreCase )) )
.Select( l => l.Trim() )
.TakeLast( 12 )
.ToList();
}
}