Editor/ImportValidation.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Editor.Assets;
using Sandbox;
namespace ImportUnityPackage;
/// <summary>Development validation: fresh import, resource load, and actual preview-scene placement.</summary>
public static class ImportValidation
{
static Task activeTask;
static bool validateTerrain;
static CancellationTokenSource cancellation;
public sealed record Check( string Path, string Kind, bool Success, string Detail );
[ConCmd( "unity_import_check_channels" )]
public static void CheckChannels()
{
var source = Path.Combine( Path.GetTempPath(), "unity-channel-test-" + Guid.NewGuid().ToString( "N" ) + ".png" );
try
{
using var fixture = new Bitmap( 2, 1 );
fixture.SetPixel( 0, 0, new Color( 0.2f, 0.4f, 0.6f, 0.8f ) );
fixture.SetPixel( 1, 0, new Color( 0.8f, 0.6f, 0.4f, 0.2f ) );
File.WriteAllBytes( source, fixture.ToPng() );
for ( int channel = 0; channel < 4; channel++ )
{
using var output = Bitmap.CreateFromBytes( TextureChannels.Extract( source, channel, 1, false, default ) );
var value = output.GetPixel( 0, 0 );
var expected = (channel + 1) * 0.2f;
if ( Math.Abs( value.r - expected ) > 0.01 || Math.Abs( value.g - expected ) > 0.01 || Math.Abs( value.b - expected ) > 0.01 || value.a < 0.99 )
throw new Exception( $"Channel {channel} extraction has incorrect pixel values: {value}" );
}
using var inverted = Bitmap.CreateFromBytes( TextureChannels.Extract( source, 3, 0.5, true, default ) );
if ( Math.Abs( inverted.GetPixel( 0, 0 ).r - 0.6f ) > 0.01 ) throw new Exception( "Smoothness scaling/inversion failed" );
Log.Info( "UNITY_CHANNEL_TEST PASS: RGBA separation and scaled smoothness inversion" );
}
finally { File.Delete( source ); }
}
[ConCmd( "unity_import_validate" )]
public static void Start( string package, bool terrain = false, int skip = 0 ) => StartFiltered( package, "", terrain, skip );
[ConCmd( "unity_import_validate_subset" )]
public static void StartFiltered( string package, string filter, bool terrain = false, int skip = 0 )
{
if ( activeTask is { IsCompleted: false } ) { Log.Warning( "Unity import validation is already running." ); return; }
validateTerrain = terrain;
cancellation = new CancellationTokenSource();
activeTask = RunFiles( package, Math.Max( 0, skip ), filter );
}
[ConCmd( "unity_import_validate_cancel" )]
public static void Cancel() => cancellation?.Cancel();
[ConCmd( "unity_import_recheck" )]
public static void Recheck( string directory )
{
if ( activeTask is { IsCompleted: false } ) { Log.Warning( "Unity import validation is already running." ); return; }
cancellation = new CancellationTokenSource();
activeTask = RecheckAsync( directory );
}
static async Task RecheckAsync( string directory )
{
try
{
using var report = JsonDocument.Parse( File.ReadAllText( File.Exists( directory ) ? directory : Path.Combine( directory, "unity-import-report.json" ) ) );
await Run( report.RootElement.GetProperty( "Package" ).GetString(), directory );
}
catch ( OperationCanceledException ) { Log.Info( "UNITY_VALIDATION cancelled" ); }
finally { cancellation.Dispose(); cancellation = null; }
}
static Task RunFiles( string path, int skip ) => RunFiles( path, skip, "" );
static async Task RunFiles( string path, int skip, string filter )
{
try
{
var packages = Directory.Exists( path ) ? Directory.GetFiles( path, "*.unitypackage" ).OrderBy( f => new FileInfo( f ).Length ).ToArray() : new[] { path };
foreach ( var package in packages.Skip( skip ) ) { cancellation.Token.ThrowIfCancellationRequested(); await Run( package, null, filter ); }
}
catch ( OperationCanceledException ) { Log.Info( "UNITY_VALIDATION cancelled" ); }
finally { cancellation.Dispose(); cancellation = null; }
}
static Task Run( string package, string existing = null ) => Run( package, existing, "" );
static async Task Run( string package, string existing, string filter )
{
var checks = new List<Check>();
var reportName = Path.GetFileNameWithoutExtension( package ) + (string.IsNullOrEmpty( filter ) ? "" : "-subset-" + Path.GetFileName( filter ));
var reportDirectory = Path.Combine( Project.Current.GetRootPath(), ".verification", "engine", reportName );
Directory.CreateDirectory( reportDirectory );
foreach ( var preview in Directory.GetFiles( reportDirectory, "*.png" ) ) File.Delete( preview );
string destination = null;
string[] reviewNotes = Array.Empty<string>();
void Save( bool complete ) => File.WriteAllText( Path.Combine( reportDirectory, "result.json" ), JsonSerializer.Serialize(
new { Package = package, Destination = destination, Complete = complete, Checked = checks.Count, Failed = checks.Count( c => !c.Success ), ReviewNotes = reviewNotes, Checks = checks },
new JsonSerializerOptions { WriteIndented = true } ) );
try
{
Log.Info( $"UNITY_VALIDATION reading {package}" );
var assetsRoot = Project.Current.GetAssetsPath();
ImportResult result;
if ( existing == null )
{
using var archive = await Task.Run( () => UnityArchive.Read( package, null, cancellation.Token ) );
if ( !string.IsNullOrEmpty( filter ) )
{
// An @JSON manifest selects several exact source paths in one archive pass.
var selectedPaths = filter.StartsWith( "@" )
? new HashSet<string>( (JsonSerializer.Deserialize<string[]>( File.ReadAllText( filter[1..] ) ) ?? Array.Empty<string>())
.Select( p => p.Replace( '\\', '/' ).Replace( "Assets/", "", StringComparison.OrdinalIgnoreCase ) ), StringComparer.OrdinalIgnoreCase )
: null;
foreach ( var asset in archive.Assets ) asset.Selected = asset.Kind == UnityAssetKind.Model &&
(selectedPaths != null ? selectedPaths.Contains( asset.Path.Replace( "Assets/", "", StringComparison.OrdinalIgnoreCase ) ) : asset.Path.Contains( filter, StringComparison.OrdinalIgnoreCase ));
if ( !archive.Assets.Any( a => a.Selected ) ) throw new InvalidOperationException( "No models matched the requested selection." );
}
result = await Task.Run( () => UnityImport.Run( archive, assetsRoot, ImportOptions.Auto( validateTerrain ), null, cancellation.Token, TextureChannels.Extract ) );
}
else
{
using var report = JsonDocument.Parse( File.ReadAllText( File.Exists( existing ) ? existing : Path.Combine( existing, "unity-import-report.json" ) ) );
var baseDirectory = report.RootElement.TryGetProperty( "Destination", out var root ) ? root.GetString() : existing;
result = new( baseDirectory, report.RootElement.GetProperty( "Assets" ).GetInt32(), report.RootElement.GetProperty( "Converted" ).GetInt32(),
report.RootElement.GetProperty( "Files" ).EnumerateArray().Select( f => Path.Combine( baseDirectory, f.GetString() ) ).ToArray(), Array.Empty<string>() );
}
destination = result.Directory;
reviewNotes = result.Warnings;
Save( false );
Log.Info( $"UNITY_VALIDATION imported {result.AssetCount} sources to {destination}" );
// Match the window: register mesh sources as well as generated resources before compiling dependencies.
foreach ( var file in result.Files.Where( f => UnityArchive.Classify( f ) is UnityAssetKind.Texture or UnityAssetKind.Model ||
Path.GetExtension( f ) is ".vmat" or ".tmat" or ".vmdl" or ".vtex" ) ) AssetSystem.RegisterFile( file );
foreach ( var file in result.Files.Where( f => UnityArchive.Classify( f ) == UnityAssetKind.Texture ) )
{
cancellation.Token.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' );
try
{
AssetSystem.RegisterFile( file );
var texturePath = relative;
if ( UnityImport.NeedsTextureResource( file ) )
{
var textureAsset = AssetSystem.RegisterFile( file + ".vtex" );
await textureAsset.CompileIfNeededAsync();
texturePath += ".vtex";
}
using var texture = Texture.Load( texturePath );
checks.Add( new( relative, "texture", texture != null && !texture.IsError, texture == null ? "null texture" : $"{texture.Width}x{texture.Height}" ) );
}
catch ( Exception ex ) { checks.Add( new( relative, "texture", false, ex.Message ) ); }
if ( checks.Count % 20 == 0 ) { Save( false ); Log.Info( $"UNITY_VALIDATION {checks.Count} checked; {checks.Count( c => !c.Success )} failed" ); }
await Task.Delay( 1 );
}
// Queue a bounded number of independent material compiles on the editor context.
using var compileSlots = new SemaphoreSlim( 4 );
await Task.WhenAll( result.Files.Where( f => f.EndsWith( ".vmat" ) || f.EndsWith( ".tmat" ) ).Select( async file =>
{
await compileSlots.WaitAsync( cancellation.Token );
try { await AssetSystem.RegisterFile( file ).CompileIfNeededAsync(); }
finally { compileSlots.Release(); }
} ) );
foreach ( var file in result.Files.Where( f => f.EndsWith( ".vmat" ) || f.EndsWith( ".tmat" ) || f.EndsWith( ".vmdl" ) ) )
{
cancellation.Token.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath( assetsRoot, file ).Replace( '\\', '/' );
try
{
var asset = AssetSystem.RegisterFile( file );
await asset.CompileIfNeededAsync();
// The return value is false when an already compiled asset needs no work.
if ( !asset.IsCompiled || asset.IsCompileFailed ) throw new Exception( "Asset compilation failed" );
if ( file.EndsWith( ".vmdl" ) )
{
var model = await Model.LoadAsync( asset.Path );
if ( model == null || model.IsError || model.MeshCount == 0 ) throw new Exception( "Missing/error/empty model" );
var materials = model.Materials.ToArray();
var missing = materials.Where( m => m == null || m.ShaderName.Contains( "error", StringComparison.OrdinalIgnoreCase ) ||
!m.Name.StartsWith( "imported/", StringComparison.OrdinalIgnoreCase ) ||
(m.Name.Contains( "/_defaults/unassigned", StringComparison.OrdinalIgnoreCase ) || m.Name.Contains( "/_unity_generated/defaults/unassigned", StringComparison.OrdinalIgnoreCase )) ).Select( m => m?.Name ?? "null" ).ToArray();
using var preview = AssetPreview.CreateForAsset( asset );
await preview.InitializeScene();
await preview.InitializeAsset();
preview.ScreenSize = new Vector2Int( 192, 192 );
preview.UpdateScene( 0.125f, 0.1f );
if ( !preview.PrimaryObject.IsValid() || preview.PrimaryObject.GetComponent<ModelRenderer>()?.Model == null )
throw new Exception( "Model could not be placed into the preview scene" );
using var bitmap = new Bitmap( 192, 192 );
await preview.RenderToBitmap( bitmap );
File.WriteAllBytes( Path.Combine( reportDirectory, $"{checks.Count:D4}-{Path.GetFileNameWithoutExtension( file )}.png" ), bitmap.ToPng() );
checks.Add( new( relative, "model-scene", missing.Length == 0, $"{model.MeshCount} meshes; bounds {model.Bounds.Size}; materials: {string.Join( ", ", materials.Select( m => m?.Name ?? "null" ) )}; missing: {string.Join( ", ", missing )}" ) );
}
else if ( file.EndsWith( ".vmat" ) )
{
var material = await Material.LoadAsync( asset.Path );
checks.Add( new( relative, "material", material != null && !material.ShaderName.Contains( "error", StringComparison.OrdinalIgnoreCase ), material?.ShaderName ?? "null" ) );
}
else checks.Add( new( relative, "terrain-material", true, "Compiled" ) );
}
catch ( Exception ex ) { checks.Add( new( relative, Path.GetExtension( file ), false, ex.Message ) ); }
Save( false );
Log.Info( $"UNITY_VALIDATION {checks.Count} checked; {checks.Count( c => !c.Success )} failed; {relative}" );
await Task.Delay( 1 );
}
Save( true );
Log.Info( $"UNITY_VALIDATION COMPLETE: {checks.Count} checked, {checks.Count( c => !c.Success )} failed. {reportDirectory}" );
}
catch ( OperationCanceledException ) { Save( false ); throw; }
catch ( Exception ex ) { checks.Add( new( package, "fatal", false, ex.ToString() ) ); Save( true ); Log.Error( $"UNITY_VALIDATION FAILED {ex}" ); }
}
}