s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (22 total matches found)
grubs.importunitypackage / Editor/Core/ImportStorage.cs
Editor library
using System;
using System.IO;
using System.Threading;

namespace ImportUnityPackage;

/// <summary>Bounded retries for Windows sharing/access errors during directory finalization.</summary>
internal static class ImportStorage
{
	internal static bool IsTemporaryAccessError( Exception exception ) =>
		exception is IOException or UnauthorizedAccessException && (exception.HResult & 0xffff) is 5 or 32 or 33;

	internal static void Retry( Action operation, CancellationToken cancel, Action<int> retrying = null )
	{
		for ( var attempt = 0; ; attempt++ )
		{
			cancel.ThrowIfCancellationRequested();
			try { operation(); return; }
			catch ( Exception ex ) when ( IsTemporaryAccessError( ex ) && attempt < 6 )
			{
				retrying?.Invoke( attempt + 1 );
				// Maximum wait is 4.05 seconds. A cancellation interrupts the wait immediately.
				if ( cancel.WaitHandle.WaitOne( Math.Min( 150 << attempt, 1000 ) ) ) cancel.ThrowIfCancellationRequested();
			}
		}
	}
}
grubs.importunitypackage / Editor/Core/GeneratedAssetFiles.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;

namespace ImportUnityPackage;

/// <summary>Names derived assets beside their sources, reusing matching content without an on-disk index.</summary>
internal sealed class GeneratedAssetFiles
{
	readonly string scratch;
	readonly string destination;
	readonly HashSet<string> reserved;
	readonly List<(string Token, string Preferred, string File)> pending = new();
	readonly Dictionary<string, string> resolved = new();
	readonly HashSet<string> assigned = new( StringComparer.OrdinalIgnoreCase );
	readonly Dictionary<string, Dictionary<int, string>> existing = new( StringComparer.OrdinalIgnoreCase );

	public GeneratedAssetFiles( ImportPlan plan, string stage, string destination )
	{
		this.destination = destination;
		scratch = Path.Combine( Path.GetDirectoryName( stage ), "generated" );
		reserved = plan.Archive.Assets.Select( a => a.Path ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		foreach ( var item in plan.Assets )
		{
			if ( item.Vmat ) reserved.Add( Path.ChangeExtension( item.Asset.Path, ".vmat" ) );
			if ( item.Tmat ) reserved.Add( Path.ChangeExtension( item.Asset.Path, ".tmat" ) );
			if ( item.Vmdl ) reserved.Add( Path.ChangeExtension( item.Asset.Path, ".vmdl" ) );
			if ( UnityImport.NeedsTextureResource( item.Asset.Path ) ) reserved.Add( item.Asset.Path + ".vtex" );
		}
	}

	public (string Reference, string File) Add( string source, string purpose, string extension )
	{
		Directory.CreateDirectory( scratch );
		var token = $"iup:generated:{pending.Count}";
		var file = Path.Combine( scratch, pending.Count + ".iup" );
		var preferred = Path.ChangeExtension( source, null ) + "_iup_" + purpose + extension;
		pending.Add( (token, preferred, file) );
		return (token, file);
	}

	public void Complete( Func<string, string> output, CancellationToken cancel )
	{
		foreach ( var item in pending )
		{
			cancel.ThrowIfCancellationRequested();
			var hash = ImportMergePlan.HashFile( item.File, cancel ) ?? throw new IOException( "Generated asset is missing: " + item.Preferred );
			var candidates = Existing( item.Preferred, cancel );
			string Name( int number ) => number == 0 ? item.Preferred : Path.ChangeExtension( item.Preferred, null ) + "_" + number + Path.GetExtension( item.Preferred );
			// Look for matching numbered variants even when an earlier suffix has been deleted.
			var match = candidates.Where( p => p.Value == hash ).OrderBy( p => p.Key ).Select( p => Name( p.Key ) )
				.FirstOrDefault( path => !reserved.Contains( path ) );
			var chosen = match;
			if ( chosen == null )
			{
				var number = 0;
				while ( true )
				{
					cancel.ThrowIfCancellationRequested();
					chosen = Name( number );
					if ( !reserved.Contains( chosen ) && !candidates.ContainsKey( number ) && !Directory.Exists( Path.Combine( destination, chosen ) ) ) break;
					number++;
				}
				candidates[number] = hash;
			}
			resolved[item.Token] = "Imported/" + chosen;
			if ( assigned.Add( chosen ) ) File.Move( item.File, output( chosen ) );
			else File.Delete( item.File );
		}
	}

	Dictionary<int, string> Existing( string preferred, CancellationToken cancel )
	{
		if ( existing.TryGetValue( preferred, out var result ) ) return result;
		result = new();
		var directory = Path.GetDirectoryName( Path.Combine( destination, preferred ) );
		UnityImport.CheckDirectory( directory );
		if ( Directory.Exists( directory ) )
		{
			var pattern = new Regex( "^" + Regex.Escape( Path.GetFileNameWithoutExtension( preferred ) ) + @"(?:_([1-9][0-9]*))?" + Regex.Escape( Path.GetExtension( preferred ) ) + "$", RegexOptions.IgnoreCase );
			foreach ( var file in Directory.EnumerateFiles( directory ) )
			{
				cancel.ThrowIfCancellationRequested();
				var number = Number( file, pattern );
				if ( number < 0 ) continue;
				UnityImport.CheckDirectory( file );
				result[number] = ImportMergePlan.HashFile( file, cancel );
			}
		}
		existing[preferred] = result;
		return result;
	}

	static int Number( string path, Regex pattern )
	{
		var match = pattern.Match( Path.GetFileName( path ) );
		if ( !match.Success ) return -1;
		return !match.Groups[1].Success ? 0 : int.TryParse( match.Groups[1].Value, out var number ) ? number : -1;
	}

	public string ResolveDocument( string text ) => Regex.Replace( text, "\"iup:generated:[0-9]+\"", m => UnityMaterial.Quote( resolved[m.Value[1..^1]] ) );
}
grubs.importunitypackage / Editor/Core/UnityImport.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;

namespace ImportUnityPackage;

public record ImportOptions( bool Materials, bool Models, bool Vmat, bool Tmat, bool Vmdl )
{
	public bool Automatic { get; init; }
	public static ImportOptions Auto( bool additionalTerrainMaterials = false ) => new( true, true, true, additionalTerrainMaterials, true ) { Automatic = true };
}
public record ImportIssue( string Asset, string Code, string Message )
{
	// Compatibility fallbacks are warnings; failing to produce a selected resource is an error.
	public string Severity => Code == "conversion-skipped" ? "Error" : "Warning";
}
public record ImportResult( string Directory, int AssetCount, int ConvertedCount, string[] Files, string[] Warnings )
{
	public ImportIssue[] Unresolved { get; init; } = Array.Empty<ImportIssue>();
	public string ReportJson { get; init; }
	public void ExportReport( string path ) => File.WriteAllText( path, ReportJson );
	public string[] ChangedFiles { get; init; } = Array.Empty<string>();
	public string[] Information { get; init; } = Array.Empty<string>();
}
public record TextureChannelRequest( string Source, string Destination, int Channel, double Scale, bool Invert );
public delegate void ExtractTextureChannels( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel );
public delegate byte[] ExtractTextureChannel( string source, int channel, double scale, bool invert, CancellationToken cancel );

public static class UnityImport
{
	public static bool NeedsTextureResource( string path ) => Path.GetExtension( path ).ToLowerInvariant() is ".exr" or ".psd" or ".tif" or ".tiff";
	public static bool IsEnabled( UnityAsset asset, ImportOptions options ) => asset.Kind switch
	{
		UnityAssetKind.Material or UnityAssetKind.Texture => options.Materials,
		UnityAssetKind.Model or UnityAssetKind.ModelSupport => options.Models,
		_ => false
	};

	public static HashSet<UnityAsset> Selection( UnityArchive archive, ImportOptions options ) =>
		ImportCatalog.Read( archive ).CreatePlan( options ).Assets.Select( a => a.Asset ).ToHashSet();

	internal static string ReadText( string path )
	{
		if ( new FileInfo( path ).Length > 16 * 1024 * 1024 ) throw new InvalidDataException( "Material or metadata exceeds the 16 MiB text limit." );
		return File.ReadAllText( path );
	}

	public static ImportResult Run( UnityArchive archive, string assetsDirectory, ImportOptions options,
		IProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel = null, ExtractTextureChannels extractChannels = null )
		=> Run( ImportCatalog.Read( archive, cancel ).CreatePlan( options ), assetsDirectory, progress, cancel, extractChannel, extractChannels );

	public static ImportResult Run( ImportPlan plan, string assetsDirectory,
		IProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel = null, ExtractTextureChannels extractChannels = null )
	{
		using var merge = ImportMergePlan.Create( plan, assetsDirectory, cancel );
		merge.Prepare( progress, cancel, extractChannel, extractChannels );
		var result = merge.Commit( progress, cancel );
		merge.Dispose();
		return result with { Warnings = result.Warnings.Concat( merge.CleanupWarnings ).Distinct().ToArray() };
	}

	internal static ImportResult PrepareFiles( ImportPlan plan, string stageDirectory, string destination,
		IProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel, ExtractTextureChannels extractChannels = null )
	{
		cancel.ThrowIfCancellationRequested();
		var archive = plan.Archive;
		var options = plan.Options;
		var selected = plan.Assets.Select( a => a.Asset ).ToArray();
		if ( selected.Length == 0 ) throw new InvalidOperationException( "Select at least one supported asset." );
		var stage = Path.GetFullPath( stageDirectory );
		CheckDirectory( stage );
		const string prefix = "Imported/";
		var warnings = new List<string>( archive.Warnings );
		var information = new List<string>();
		var unresolved = new List<ImportIssue>( plan.Issues );
		warnings.AddRange( plan.Issues.Select( i => $"{i.Asset}: {i.Message}" ) );
		void Issue( string asset, string code, string message )
		{
			unresolved.Add( new( asset, code, message ) );
			warnings.Add( $"{asset}: {message}" );
		}
		var files = new List<string>();
		var outputs = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var textures = selected.Where( a => a.Kind == UnityAssetKind.Texture ).ToDictionary( a => a.Guid, a => prefix + a.Path, StringComparer.OrdinalIgnoreCase );
		var textureSources = selected.Where( a => a.Kind == UnityAssetKind.Texture ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var generatedFiles = new GeneratedAssetFiles( plan, stage, destination );
		var documents = new List<(string File, string Text)>();
		var generatedChannels = new Dictionary<string, string>();
		var channelRequests = new List<TextureChannelRequest>();
		var channelTimer = new System.Diagnostics.Stopwatch();
		var shaders = archive.Assets.Where( a => a.Path.EndsWith( ".shader", StringComparison.OrdinalIgnoreCase ) ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var shaderText = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		var convertedMaterials = new Dictionary<string, UnityMaterial>( StringComparer.OrdinalIgnoreCase );
		var conversions = 0;
		var operation = "creating the staging folder";
		string currentAsset = null;
		Exception failure = null;
		var preserveStage = false;
		try
		{
			Directory.CreateDirectory( stage );
			string Output( string relative )
			{
				if ( !outputs.Add( relative ) ) throw new InvalidDataException( $"Two assets generate the same output: {relative}" );
				var full = Path.GetFullPath( Path.Combine( stage, relative + ".iup" ) );
				if ( !full.StartsWith( stage + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) ) throw new InvalidDataException( "Invalid output path." );
				Directory.CreateDirectory( Path.GetDirectoryName( full ) );
				files.Add( relative );
				return full;
			}
			for ( var i = 0; i < selected.Length; i++ )
			{
				cancel.ThrowIfCancellationRequested();
				var asset = selected[i];
				operation = "writing imported files";
				currentAsset = asset.Path;
				var planned = plan.Find( asset );
				if ( asset.Kind == UnityAssetKind.Model && asset.ModelInfo.CollisionOnly )
					information.Add( $"{asset.Path}: used only by Unity MeshColliders; kept as a collision source without a visible VMDL. Physics setup is not recreated." );
				progress?.Report( new( 0.8 * i / selected.Length, $"Preparing {asset.Path}", "Preparing assets" ) );
				using ( var source = File.OpenRead( asset.Source ) )
				using ( var target = new FileStream( Output( asset.Path ), FileMode.CreateNew ) )
						UnityArchive.Copy( source, target, cancel );
				if ( asset.Kind == UnityAssetKind.Texture && NeedsTextureResource( asset.Path ) )
				{
					// Keep source pixels/HDR data intact and give the editor a loadable texture resource.
					File.WriteAllText( Output( asset.Path + ".vtex" ), JsonSerializer.Serialize( new
					{
						Images = new[] { prefix + asset.Path }, InputColorSpace = "Linear", OutputColorSpace = "Linear",
						OutputFormat = asset.Path.EndsWith( ".exr", StringComparison.OrdinalIgnoreCase ) ? "RGBA16161616F" : "BC7",
						OutputMipAlgorithm = "Box", OutputTypeString = "2D"
					}, new JsonSerializerOptions { WriteIndented = true } ) );
					conversions++;
				}
				if ( planned.Vmat || planned.Tmat )
				{
					try
					{
						var material = UnityMaterial.Parse( ReadText( asset.Source ) );
						if ( material.ShaderGuid != null && shaders.TryGetValue( material.ShaderGuid, out var shader ) )
						{
							if ( !shaderText.TryGetValue( shader.Guid, out var source ) ) shaderText[shader.Guid] = source = ReadText( shader.Source );
							material.ConfigureShader( source );
						}
						if ( extractChannel != null || extractChannels != null )
						{
							material.PrepareChannels( (guid, channel, scale, invert) =>
							{
								if ( !textureSources.TryGetValue( guid, out var source ) )
								{
									Issue( asset.Path, "missing-texture", $"Missing or unsupported texture {guid}." );
									return null;
								}
								var key = $"{guid}_{channel}_{scale.ToString( System.Globalization.CultureInfo.InvariantCulture )}_{invert}";
								if ( !generatedChannels.TryGetValue( key, out var generated ) )
								{
									var purpose = channel switch { 0 => "metallic", 1 => "ao", 2 => "height", _ => invert ? "roughness" : "opacity" };
									var derived = generatedFiles.Add( source.Path, purpose, ".png" );
									generated = derived.Reference;
									var output = derived.File;
									if ( extractChannels != null ) channelRequests.Add( new( source.Source, output, channel, scale, invert ) );
									else File.WriteAllBytes( output, extractChannel( source.Source, channel, scale, invert, cancel ) );
									generatedChannels[key] = generated;
								}
								return generated;
							} );
						}
						string Resolve( string guid )
						{
							if ( textures.TryGetValue( guid, out var texture ) ) return texture;
							Issue( asset.Path, "missing-texture", $"Missing or unsupported texture {guid}." );
							return null;
						}
						if ( planned.Vmat ) { documents.Add( (Output( Path.ChangeExtension( asset.Path, ".vmat" ) ), material.ToVmat( Resolve )) ); conversions++; }
						convertedMaterials[asset.Guid] = material;
						if ( planned.Tmat ) { documents.Add( (Output( Path.ChangeExtension( asset.Path, ".tmat" ) ), material.ToTmat( Resolve )) ); conversions++; }
						warnings.AddRange( material.Warnings.Select( w => $"{asset.Path}: {w}" ) );
						information.AddRange( material.Information.Select( m => $"{asset.Path}: {m}" ) );
					}
					catch ( InvalidDataException ex ) { Issue( asset.Path, "conversion-skipped", $"Conversion skipped. {ex.Message}" ); }
				}
				if ( planned.Vmdl )
				{
					var meshPath = prefix + asset.Path;
					if ( asset.Path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase ) && UnityFbxCompatibility.Normalize( asset.Source, cancel ) is byte[] normalized )
					{
						var repaired = generatedFiles.Add( asset.Path, "repaired", ".fbx" );
						File.WriteAllBytes( repaired.File, normalized );
						meshPath = repaired.Reference;
						warnings.Add( $"{asset.Path}: removed an exact duplicate vertex array after a closing brace in a derived FBX; original source preserved." );
					}
					var info = asset.ModelInfo;
					var scale = asset.Path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase ) ? info.ImportScale( asset.Metadata == null ? null : ReadText( asset.Metadata ) ) : 1;
					var remaps = files.Where( f => f.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) )
						.GroupBy( Path.GetFileNameWithoutExtension, StringComparer.OrdinalIgnoreCase )
						.Where( g => g.Count() == 1 ).ToDictionary( g => g.Key.ToLowerInvariant() + ".vmat", g => prefix + g.Single(), StringComparer.OrdinalIgnoreCase );
					var meshName = Path.GetFileNameWithoutExtension( asset.Path ).ToLowerInvariant();
					if ( remaps.TryGetValue( meshName + ".vmat", out var matchingMaterial ) )
					{
						// Some exporters append an LOD suffix to slots that share the model's base material.
						for ( var lod = 0; lod <= 8; lod++ ) remaps.TryAdd( $"{meshName}_lod{lod}.vmat", matchingMaterial );
					}
					var prefabMaterials = selected.Where( a => info.PrefabMaterials.Contains( a.Guid, StringComparer.OrdinalIgnoreCase ) &&
						outputs.Contains( Path.ChangeExtension( a.Path, ".vmat" ) ) ).ToArray();
					foreach ( var slot in info.Materials )
					{
						var semantic = prefabMaterials.Where( a => UnityModel.Normalize( Path.GetFileNameWithoutExtension( a.Path ) ) == UnityModel.Normalize( slot ) ).ToArray();
						if ( semantic.Length == 1 ) remaps[slot.ToLowerInvariant() + ".vmat"] = prefix + Path.ChangeExtension( semantic[0].Path, ".vmat" );
					}
					foreach ( var (slot, albedoFile) in info.MaterialAlbedoFiles )
					{
						if ( remaps.ContainsKey( slot.ToLowerInvariant() + ".vmat" ) ) continue;
						var candidates = (prefabMaterials.Length > 0 ? prefabMaterials : selected.Where( a => a.Kind == UnityAssetKind.Material ).ToArray())
							.Where( a => convertedMaterials.TryGetValue( a.Guid, out var material ) && material.ColorTextureGuids.Any( guid =>
								textureSources.TryGetValue( guid, out var texture ) && Path.GetFileNameWithoutExtension( texture.Path ).Equals(
									Path.GetFileNameWithoutExtension( albedoFile ), StringComparison.OrdinalIgnoreCase ) ) ).ToArray();
						// Unity often keeps the old diffuse filename as the material name
						// after repacking its texture. Require a unique prefab-assigned material.
						if ( candidates.Length == 0 ) candidates = prefabMaterials.Where( a =>
							Path.GetFileNameWithoutExtension( a.Path ).Equals( Path.GetFileNameWithoutExtension( albedoFile ), StringComparison.OrdinalIgnoreCase ) ).ToArray();
						if ( candidates.Length == 1 && outputs.Contains( Path.ChangeExtension( candidates[0].Path, ".vmat" ) ) )
							remaps[slot.ToLowerInvariant() + ".vmat"] = prefix + Path.ChangeExtension( candidates[0].Path, ".vmat" );
					}
					foreach ( var (slot, guid) in info.PrefabSlotMaterials )
					{
						var assigned = selected.FirstOrDefault( a => a.Guid.Equals( guid, StringComparison.OrdinalIgnoreCase ) && outputs.Contains( Path.ChangeExtension( a.Path, ".vmat" ) ) );
						if ( assigned != null ) remaps[slot.ToLowerInvariant() + ".vmat"] = prefix + Path.ChangeExtension( assigned.Path, ".vmat" );
					}
					foreach ( var slot in info.UnresolvedPrefabSlots ) remaps.Remove( slot.ToLowerInvariant() + ".vmat" );
					var defaultMaterial = prefabMaterials.Length == 1 && info.PrefabSlotMaterials.Count == 0 && info.AssignmentWarnings.Count == 0 ? prefix + Path.ChangeExtension( prefabMaterials[0].Path, ".vmat" ) : null;
					if ( options.Materials && options.Vmat && defaultMaterial == null && info.Materials.Count == 0 && asset.Path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase ) )
					{
						var neutralPath = generatedFiles.Add( asset.Path, "unassigned", ".vmat" );
						var neutral = new UnityMaterial();
						neutral.Colors["_Color"] = new[] { 0.5, 0.5, 0.5, 1.0 };
						File.WriteAllText( neutralPath.File, neutral.ToVmat( _ => null ) );
						conversions++;
						defaultMaterial = neutralPath.Reference;
						Issue( asset.Path, "unassigned-material", "FBX contains no named material slots and no unambiguous prefab assignment was resolved; using a neutral material." );
					}
					if ( options.Materials && options.Vmat && defaultMaterial == null )
						foreach ( var slot in info.RenderedMaterials.Where( s => !remaps.ContainsKey( s.ToLowerInvariant() + ".vmat" ) ) )
							Issue( asset.Path, "unassigned-material", $"No converted material was resolved for slot '{slot}'." );
					documents.Add( (Output( Path.ChangeExtension( asset.Path, ".vmdl" ) ), ModelDocument( meshPath, remaps, defaultMaterial, info.HighestDetailMeshes, scale )) );
					conversions++;
					information.Add( $"{asset.Path}: review scale, orientation, material assignments, collision and animations in ModelDoc." );
				}
			}
			operation = "extracting texture channels";
			currentAsset = null;
			progress?.Report( new( 0.8, $"Preparing {channelRequests.Count} texture channels", "Converting textures" ) );
			channelTimer.Start();
			if ( channelRequests.Count > 0 ) extractChannels( channelRequests, cancel );
			channelTimer.Stop();
			progress?.Report( new( 0.95, "Choosing generated filenames…", "Naming generated files" ) );
			generatedFiles.Complete( Output, cancel );
			foreach ( var document in documents )
			{
				cancel.ThrowIfCancellationRequested();
				File.WriteAllText( document.File, generatedFiles.ResolveDocument( document.Text ) );
			}
			var unsupported = archive.Assets.Count( a => a.Kind == UnityAssetKind.Unsupported );
			if ( unsupported > 0 ) information.Add( $"{unsupported} unsupported files (such as scripts, scenes and prefabs) were excluded." );
			var issues = unresolved.Distinct().ToArray();
			var report = new
			{
				Package = Path.GetFileName( archive.FileName ), Assets = selected.Length, Converted = conversions,
				Files = files.ToArray(), Unresolved = issues,
				TextureProcessing = new { ChannelsGenerated = channelRequests.Count, SourceDecodes = channelRequests.Select( r => r.Source ).Distinct().Count(), ElapsedMilliseconds = channelTimer.ElapsedMilliseconds },
				Selection = plan.Assets.Select( a => new { Source = a.Asset.Path, a.Explicit, a.OutputLabel,
					RequiredBy = a.RequiredBy.Select( d => new { Source = d.Asset.Path, d.Reason } ).ToArray() } ).ToArray(),
				MaterialConversions = selected.Where( a => convertedMaterials.ContainsKey( a.Guid ) ).Select( a => new
				{
					Source = a.Path, convertedMaterials[a.Guid].ShaderName, convertedMaterials[a.Guid].ShaderGuid,
					Conversion = "Common material properties; shader programs are not translated",
					Vmat = plan.Find( a ).Vmat, Tmat = plan.Find( a ).Tmat
				} ).ToArray(),
				Information = information.ToArray(), Warnings = warnings.Distinct().ToArray()
			};
			operation = "preparing import details";
			currentAsset = null;
			var reportJson = JsonSerializer.Serialize( report, new JsonSerializerOptions { WriteIndented = true } );
			cancel.ThrowIfCancellationRequested();
			var prepared = new ImportResult( stage, selected.Length, conversions, files.Select( f => Path.Combine( stage, f + ".iup" ) ).ToArray(), warnings.Distinct().ToArray() ) { Unresolved = issues, Information = information.ToArray(), ReportJson = reportJson };
			preserveStage = true;

			return prepared;
		}
		catch ( Exception ex )
		{
			failure = ex;
			if ( ex is IOException or UnauthorizedAccessException )
			{
				failure = new IOException( $"Import failed while {operation}" + (currentAsset == null ? "" : $" for '{currentAsset}'") +
					$". Staging folder: '{stage}'. Windows error {ex.HResult & 0xffff}: {ex.Message}", ex );
				throw failure;
			}
			throw;
		}
		finally
		{
			if ( !preserveStage && Directory.Exists( stage ) )
			{
				try { ImportWorkspace.RemoveTree( stage ); }
				catch ( Exception cleanup ) when ( cleanup is IOException or UnauthorizedAccessException )
				{
					// Preserve the original conversion/cancellation error if cleanup also fails.
					if ( failure == null ) throw new IOException( $"Could not remove staging folder '{stage}'. {cleanup.Message}", cleanup );
					failure.Data["StagingCleanupError"] = $"Could not remove '{stage}': {cleanup}";
				}
			}
		}
	}

	internal static void CheckDirectory( string path )
	{
		for ( var current = new DirectoryInfo( Path.GetFullPath( path ) ); current != null; current = current.Parent )
			if ( (Directory.Exists( current.FullName ) || File.Exists( current.FullName )) && File.GetAttributes( current.FullName ).HasFlag( FileAttributes.ReparsePoint ) )
				throw new IOException( $"Import destination cannot pass through a symbolic link: {current.FullName}" );
	}

	static string ModelDocument( string mesh, Dictionary<string, string> remaps, string defaultMaterial, string[] highestDetailMeshes, double scale ) => $$"""
		<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->
		{
			rootNode =
			{
				_class = "RootNode"
				children =
				[
					{
						_class = "MaterialGroupList"
						children =
						[
							{
								_class = "DefaultMaterialGroup"
								remaps = [ {{string.Join( ", ", remaps.Select( r => "{ from = " + UnityMaterial.Quote( r.Key ) + " to = " + UnityMaterial.Quote( r.Value ) + " }" ) )}} ]
								use_global_default = {{(defaultMaterial != null ? "true" : "false")}}
								global_default_material = {{UnityMaterial.Quote( defaultMaterial ?? "" )}}
							}
						]
					},
					{
						_class = "RenderMeshList"
						children =
						[
							{
								_class = "RenderMeshFile"
								filename = {{UnityMaterial.Quote( mesh )}}
								import_filter =
								{
									exclude_by_default = {{(highestDetailMeshes.Length > 0 ? "true" : "false")}}
									exception_list = [ {{string.Join( ", ", highestDetailMeshes.Select( UnityMaterial.Quote ) )}} ]
								}
								import_scale = {{scale.ToString( "0.#########", System.Globalization.CultureInfo.InvariantCulture )}}
								import_translation = [ 0.0, 0.0, 0.0 ]
								import_rotation = [ 0.0, 0.0, 0.0 ]
							}
						]
					}
				]
				model_archetype = ""
			}
		}
		""";
}
grubs.importunitypackage / Editor/Core/ImportCompletion.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace ImportUnityPackage;

/// <summary>Short completion text, with detailed warnings and errors retained in the report.</summary>
public sealed record ImportCompletion( int AssetCount, string[] Warnings, string[] Errors, bool Cancelled )
{
	public string Title => Cancelled ? "Import preparation cancelled" : Errors.Length > 0 ? "Import completed with errors" : "Import complete";
	public string Counts => $"Warnings: {Warnings.Length} · Errors: {Errors.Length}";
	public string Message => Cancelled ? $"Imported files kept. Resource preparation stopped.\n{Counts}" : $"{AssetCount} assets imported.\n{Counts}";
	public string Status => $"{(Errors.Length == 0 && !Cancelled ? "✓ " : "")}{Title}\n{Message}";

	public static ImportCompletion Create( ImportResult result, IEnumerable<string> preparationWarnings, IEnumerable<string> preparationErrors, bool cancelled )
	{
		var errors = result.Unresolved.Where( i => i.Severity == "Error" ).Select( i => $"{i.Asset}: {i.Message}" )
			.Concat( preparationErrors ?? Array.Empty<string>() ).Distinct().ToArray();
		var warnings = result.Warnings.Concat( result.Unresolved.Where( i => i.Severity == "Warning" ).Select( i => $"{i.Asset}: {i.Message}" ) )
			.Concat( preparationWarnings ?? Array.Empty<string>() ).Except( errors ).Distinct().ToArray();
		return new( result.AssetCount, warnings, errors, cancelled );
	}

	public string AppendReport( string json )
	{
		if ( string.IsNullOrWhiteSpace( json ) ) json = "{}";
		var report = JsonNode.Parse( json );
		report["Completion"] = JsonSerializer.SerializeToNode( new { Title, AssetCount, Cancelled, Warnings, Errors } );
		return report.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );
	}
}
grubs.importunitypackage / Editor/Core/UnityFileId.cs
Editor library
using System;
using System.Buffers.Binary;
using System.Text;

namespace ImportUnityPackage;

/// <summary>Unity's modern model subasset IDs use seed-zero XXH64 of a type and hierarchy path.</summary>
internal static class UnityFileId
{
	// XXH64 algorithm: https://github.com/Cyan4973/xxHash/blob/dev/doc/xxhash_spec.md
	// Unity path format: https://discussions.unity.com/t/fbx-submesh-fileids/803882
	internal static long Renderer( string path ) => Hash( "Type:MeshRenderer->" + path + "/MeshRenderer0" );
	internal static long Hash( string text )
	{
		unchecked
		{
			const ulong p1 = 0x9E3779B185EBCA87, p2 = 0xC2B2AE3D27D4EB4F, p3 = 0x165667B19E3779F9,
				p4 = 0x85EBCA77C2B2AE63, p5 = 0x27D4EB2F165667C5;
			static ulong Rotate( ulong v, int n ) => (v << n) | (v >> (64 - n));
			static ulong Round( ulong v, ulong lane ) => unchecked( Rotate( v + lane * p2, 31 ) * p1 );
			static ulong Merge( ulong h, ulong v ) => unchecked( (h ^ Round( 0, v )) * p1 + p4 );
			ReadOnlySpan<byte> data = Encoding.UTF8.GetBytes( text );
			int offset = 0;
			ulong hash = p5;
			if ( data.Length >= 32 )
			{
				ulong a = p1 + p2, b = p2, c = 0, d = 0UL - p1;
				while ( offset <= data.Length - 32 )
				{
					a = Round( a, BinaryPrimitives.ReadUInt64LittleEndian( data[offset..] ) );
					b = Round( b, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 8)..] ) );
					c = Round( c, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 16)..] ) );
					d = Round( d, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 24)..] ) );
					offset += 32;
				}
				hash = Rotate( a, 1 ) + Rotate( b, 7 ) + Rotate( c, 12 ) + Rotate( d, 18 );
				hash = Merge( Merge( Merge( Merge( hash, a ), b ), c ), d );
			}
			hash += (ulong)data.Length;
			while ( offset <= data.Length - 8 )
			{
				hash = Rotate( hash ^ Round( 0, BinaryPrimitives.ReadUInt64LittleEndian( data[offset..] ) ), 27 ) * p1 + p4;
				offset += 8;
			}
			if ( offset <= data.Length - 4 )
			{
				hash = Rotate( hash ^ BinaryPrimitives.ReadUInt32LittleEndian( data[offset..] ) * p1, 23 ) * p2 + p3;
				offset += 4;
			}
			while ( offset < data.Length ) hash = Rotate( hash ^ data[offset++] * p5, 11 ) * p1;
			hash ^= hash >> 33; hash *= p2; hash ^= hash >> 29; hash *= p3; hash ^= hash >> 32;
			return (long)hash;
		}
	}
}
grubs.importunitypackage / Editor/Core/ImportWorkspace.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;

namespace ImportUnityPackage;

/// <summary>Short-lived work under Assets/Imported/.iup-temp. Leases protect active runs during cleanup.</summary>
public sealed class ImportWorkspace : IDisposable
{
	public string DirectoryPath { get; }
	public List<string> Warnings { get; } = new();
	readonly string root;
	FileStream lease;
	public ImportWorkspace( string assetsDirectory )
	{
		root = Path.GetFullPath( Path.Combine( assetsDirectory, "Imported", ".iup-temp" ) );
		UnityImport.CheckDirectory( root );
		using var gate = Enter( root );
		Sweep( root, Warnings );
		Directory.CreateDirectory( root );
		DirectoryPath = Path.Combine( root, Guid.NewGuid().ToString( "N" ) );
		Directory.CreateDirectory( DirectoryPath );
		try { lease = new FileStream( Path.Combine( DirectoryPath, ".active" ), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None ); }
		catch { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root ); throw; }
	}

	public static string[] CleanupAbandoned( string assetsDirectory )
	{
		var root = Path.GetFullPath( Path.Combine( assetsDirectory, "Imported", ".iup-temp" ) );
		UnityImport.CheckDirectory( root );
		var warnings = new List<string>();
		using var gate = Enter( root );
		Sweep( root, warnings );
		RemoveEmptyRoot( root );
		return warnings.ToArray();
	}

	static void Sweep( string root, List<string> warnings )
	{
		if ( !Directory.Exists( root ) ) return;
		foreach ( var run in Directory.EnumerateDirectories( root ) )
		{
			if ( !Guid.TryParseExact( Path.GetFileName( run ), "N", out _ ) ) continue;
			try
			{
				UnityImport.CheckDirectory( Path.Combine( run, ".active" ) );
				// The exclusive lease stays open throughout an active run, including other editor processes.
				using ( new FileStream( Path.Combine( run, ".active" ), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None ) ) { }
			}
			catch ( IOException ex ) when ( (ex.HResult & 0xffff) is 32 or 33 ) { continue; }
			catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
			{
				warnings.Add( $"Temporary folder could not be checked for cleanup: {run}. {ex.Message}" );
				continue;
			}
			try { RemoveTree( run ); }
			catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException ) { warnings.Add( $"Temporary folder could not be removed: {run}. {ex.Message}" ); }
		}
	}

	// All recursive deletion is constrained to importer work and rejects linked directories/files.
	internal static void RemoveTree( string path )
	{
		if ( !Directory.Exists( path ) ) return;
		path = Path.GetFullPath( path );
		var run = new DirectoryInfo( path );
		while ( run != null && run.Parent?.Name != ".iup-temp" ) run = run.Parent;
		if ( run == null || !Guid.TryParseExact( run.Name, "N", out _ ) || !string.Equals( run.Parent.Parent?.Name, "Imported", StringComparison.OrdinalIgnoreCase ) )
			throw new IOException( "Refusing to clean outside Assets/Imported/.iup-temp run folders." );
		UnityImport.CheckDirectory( path );
		void Check( string directory )
		{
			foreach ( var item in Directory.EnumerateFileSystemEntries( directory ) )
			{
				var flags = File.GetAttributes( item );
				if ( flags.HasFlag( FileAttributes.ReparsePoint ) ) throw new IOException( "Refusing to clean linked temporary content: " + item );
				if ( flags.HasFlag( FileAttributes.Directory ) ) Check( item );
			}
		}
		Check( path );
		ImportStorage.Retry( () => Directory.Delete( path, true ), CancellationToken.None );
	}

	static void RemoveEmptyRoot( string root )
	{
		if ( Directory.Exists( root ) && !Directory.EnumerateFileSystemEntries( root ).Any() ) Directory.Delete( root );
	}

	public void Dispose()
	{
		if ( lease == null ) return;
		using var gate = Enter( root );
		lease.Dispose(); lease = null;
		try { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root ); }
		catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
		{
			Warnings.Add( $"Temporary folder could not be removed: {DirectoryPath}. Cleanup will be retried next time the importer opens. {ex.Message}" );
		}
	}

	static IDisposable Enter( string root ) => new Gate( "iup-temp-" + Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( root.ToLowerInvariant() ) ) ) );
	sealed class Gate : IDisposable
	{
		readonly Mutex mutex;
		public Gate( string name ) { mutex = new Mutex( false, name ); try { mutex.WaitOne(); } catch ( AbandonedMutexException ) { } }
		public void Dispose() { mutex.ReleaseMutex(); mutex.Dispose(); }
	}
}
grubs.importunitypackage / Editor/Core/UnityModel.cs
Editor library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

public sealed class UnityModel
{
	public sealed record RendererBinding( string Mesh, string[] Slots );
	public Dictionary<long, RendererBinding> RendererSlots { get; } = new();
	public Dictionary<string, string> PrefabSlotMaterials { get; } = new( StringComparer.OrdinalIgnoreCase );
	public HashSet<string> UnresolvedPrefabSlots { get; } = new( StringComparer.OrdinalIgnoreCase );
	public List<string> AssignmentWarnings { get; } = new();
	public bool CollisionOnly { get; internal set; }
	public List<string> Materials { get; } = new();
	public List<string> Meshes { get; } = new();
	public List<string> PrefabMaterials { get; } = new();
	public Dictionary<string, string> MaterialAlbedoFiles { get; } = new( StringComparer.OrdinalIgnoreCase );
	public double? UnitScaleCentimeters { get; private set; }
	public double ImportScale( string metadata )
	{
		double Setting( string key, double fallback )
		{
			var match = Regex.Match( metadata ?? "", @"(?m)^\s*" + key + @":\s*([-+0-9.eE]+)" );
			return match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) && double.IsFinite( value ) ? value : fallback;
		}
		var globalScale = Setting( "globalScale", 1 );
		var meters = Setting( "useFileScale", 1 ) == 0 ? 1 : (UnitScaleCentimeters ?? 2.54) / 100;
		var scale = globalScale * meters / 0.0254;
		if ( !double.IsFinite( scale ) || scale <= 0 ) throw new InvalidDataException( "Model import scale must be finite and positive." );
		return scale;
	}
	public string[] HighestDetailMeshes => Meshes.Where( n => Regex.IsMatch( n, @"(?i)(?:^|[_ .-])LOD0(?:$|[_ .-])" ) ).ToArray();
	public string[] RenderedMaterials
	{
		get
		{
			var meshes = HighestDetailMeshes;
			var bindings = RendererSlots.Values.Where( b => meshes.Length == 0 || meshes.Contains( b.Mesh ) ).ToArray();
			return bindings.Length > 0 && bindings.All( b => b.Slots.Length > 0 ) ? bindings.SelectMany( b => b.Slots ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray() : Materials.ToArray();
		}
	}
	public static string Normalize( string name ) => Regex.Replace( name.ToLowerInvariant(), "[^a-z]", "" );

	public static UnityModel Read( UnityAsset asset )
	{
		var result = new UnityModel();
		if ( !asset.Path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase ) ) return result;
		using var stream = File.OpenRead( asset.Source );
		using var reader = new BinaryReader( stream, Encoding.UTF8 );
		var header = Encoding.ASCII.GetString( reader.ReadBytes( 23 ) );
		if ( !header.StartsWith( "Kaydara FBX Binary", StringComparison.Ordinal ) )
		{
			stream.Position = 0;
			using var textReader = new StreamReader( stream );
			var links = new UnityAsciiFbxLinks();
			var asciiBindings = new UnityFbxBindings();
			string line;
			while ( (line = textReader.ReadLine()) != null )
			{
				links.Observe( line );
				asciiBindings.ObserveAscii( line );
				var unit = Regex.Match( line, "^\\s*(?:P|Property):\\s*\"UnitScaleFactor\".*?,\\s*([-+0-9.eE]+)\\s*$" );
				if ( unit.Success && double.TryParse( unit.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var centimeters ) && centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;
				var material = Regex.Match( line, "^\\s*Material:\\s*(?:[-0-9]+,\\s*)?\"Material::([^\"]+)\"" );
				if ( material.Success && !result.Materials.Contains( material.Groups[1].Value ) ) result.Materials.Add( material.Groups[1].Value );
				var mesh = Regex.Match( line, "^\\s*Model:\\s*(?:[-0-9]+,\\s*)?\"Model::([^\"]+)\",\\s*\"Mesh\"" );
				if ( mesh.Success && !result.Meshes.Contains( mesh.Groups[1].Value ) ) result.Meshes.Add( mesh.Groups[1].Value );
			}
			foreach ( var link in links.Resolve() ) result.MaterialAlbedoFiles[link.Key] = link.Value;
			asciiBindings.Apply( result, asset.Metadata != null && Regex.IsMatch( UnityImport.ReadText( asset.Metadata ), @"(?m)^\s*preserveHierarchy:\s*1\s*$" ) );
			return result;
		}
		var wide = reader.ReadUInt32() >= 7500;
		var materialNames = new Dictionary<long, string>();
		var textureFiles = new Dictionary<long, string>();
		var diffuseLinks = new List<(long Texture, long Material)>();
		var bindings = new UnityFbxBindings();
		int nodes = 0;
		void ReadNodes( long limit, bool objects = false, bool connections = false, long textureId = 0, bool settings = false )
		{
			while ( stream.Position + (wide ? 25 : 13) <= limit )
			{
				if ( ++nodes > 200000 ) throw new InvalidDataException( "Too many FBX nodes." );
				var end = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var properties = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var propertyBytes = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();
				var nameBytes = reader.ReadByte();
				if ( end == 0 ) return;
				if ( end <= stream.Position || end > stream.Length ) throw new InvalidDataException( "Invalid FBX node offset." );
				var name = Encoding.UTF8.GetString( reader.ReadBytes( nameBytes ) );
				var children = stream.Position + propertyBytes;
				if ( children > end ) throw new InvalidDataException( "Invalid FBX property size." );
				object Property()
				{
						var type = (char)reader.ReadByte();
						if ( type == 'L' ) return reader.ReadInt64();
						if ( type == 'I' ) return reader.ReadInt32();
						if ( type == 'D' ) return reader.ReadDouble();
						if ( type == 'F' ) return reader.ReadSingle();
						if ( type == 'S' )
						{
							var length = reader.ReadInt32();
							if ( length < 0 || length > 1024 * 1024 || stream.Position + length > children ) throw new InvalidDataException( "Invalid FBX string." );
							return Encoding.UTF8.GetString( reader.ReadBytes( length ) );
						}
						throw new InvalidDataException( "Unsupported FBX object property." );
				}
				if ( objects && name is "Material" or "Model" or "Texture" && properties >= 3 )
				{
					var id = Convert.ToInt64( Property() );
					var objectName = (Property() as string ?? "").Split( '\0' )[0];
					var typeName = Property() as string;
					if ( name == "Material" ) { result.Materials.Add( objectName ); materialNames[id] = objectName; bindings.Materials[id] = objectName; }
					if ( name == "Model" )
					{
						bindings.Nodes[id] = (objectName, typeName == "Mesh");
						if ( typeName == "Mesh" ) result.Meshes.Add( objectName );
					}
					if ( name == "Texture" ) { stream.Position = children; ReadNodes( end, textureId: id ); }
				}
				else if ( textureId != 0 && name is "FileName" or "RelativeFilename" && properties > 0 ) textureFiles[textureId] = Property() as string;
				else if ( settings && name is "P" or "Property" && properties >= 4 && properties <= 8 )
				{
					if ( Property() as string == "UnitScaleFactor" )
					{
						object value = null;
						for ( var index = 1; index < properties; index++ ) value = Property();
						var centimeters = Convert.ToDouble( value, CultureInfo.InvariantCulture );
						if ( centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;
					}
				}
				else if ( connections && name == "C" && properties >= 3 )
				{
					var kind = Property() as string;
					var child = Convert.ToInt64( Property() );
					var parent = Convert.ToInt64( Property() );
					var channel = properties >= 4 ? Property() as string ?? "" : "";
					if ( kind == "OO" ) bindings.Connections.Add( (child, parent) );
					if ( kind == "OP" && (channel == "DiffuseColor" || channel.EndsWith( "|base_color_map", StringComparison.Ordinal )) ) diffuseLinks.Add( (child, parent) );
				}
				if ( name == "Objects" ) { stream.Position = children; ReadNodes( end, true ); }
				if ( name == "Connections" ) { stream.Position = children; ReadNodes( end, connections: true ); }
				if ( name == "GlobalSettings" || settings && name is "Properties70" or "Properties60" ) { stream.Position = children; ReadNodes( end, settings: true ); }
				stream.Position = end;
			}
		}
		ReadNodes( stream.Length, false );
		bindings.Apply( result, asset.Metadata != null && Regex.IsMatch( UnityImport.ReadText( asset.Metadata ), @"(?m)^\s*preserveHierarchy:\s*1\s*$" ) );
		foreach ( var link in diffuseLinks )
			if ( materialNames.TryGetValue( link.Material, out var material ) && textureFiles.TryGetValue( link.Texture, out var file ) && !string.IsNullOrEmpty( file ) )
				result.MaterialAlbedoFiles[material] = file.Replace( '\\', '/' ).Split( '/' ).Last();
		return result;
	}

	public static void AssignPrefabMaterials( UnityArchive archive ) => UnityPrefabBindings.Apply( archive );
}
grubs.importunitypackage / Editor/UnityAssetTree.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace ImportUnityPackage;

public sealed class UnityAssetTree : TreeView
{
	readonly Func<ImportPlan> plan;
	readonly Action changed;
	readonly Action<UnityAsset> describe;
	public UnityAssetTree( Widget parent, Func<ImportPlan> plan, Action changed, Action<UnityAsset> describe ) : base( parent )
	{
		this.plan = plan;
		this.changed = changed;
		this.describe = describe;
		MultiSelect = false;
	}

	public void Load( IEnumerable<UnityAsset> assets )
	{
		var root = new AssetNode( "Assets", this );
		foreach ( var asset in assets )
		{
			var parts = asset.Path.Split( '/' );
			var node = root;
			foreach ( var part in parts[..^1] )
			{
				var folder = node.Children.OfType<AssetNode>().FirstOrDefault( c => c.Asset == null && c.Name == part );
				if ( folder == null ) { folder = new AssetNode( part, this ); node.AddItem( folder ); }
				node = folder;
			}
			node.AddItem( new AssetNode( parts[^1], this ) { Asset = asset } );
		}
		SetItems( new[] { root } );
		Open( root );
	}

	public void SetExpanded( bool expanded )
	{
		foreach ( var root in Items.OfType<TreeNode>() )
		{
			if ( expanded ) Open( root, recursive: true );
			else Close( root, recursive: true );
		}
	}

	public void ChangeExpandedLayer( bool expand )
	{
		var candidates = new List<(TreeNode Node, int Depth)>();
		void Visit( TreeNode node, int depth )
		{
			var children = node.Children.ToArray();
			if ( children.Length == 0 ) return;
			// A child's layout position reflects the actual open state, including
			// changes made with folder arrows or the keyboard, even offscreen.
			var open = TryGetItemRect( children[0], out _ );
			if ( expand != open ) candidates.Add( (node, depth) );
			if ( open ) foreach ( var child in children ) Visit( child, depth + 1 );
		}
		foreach ( var root in Items.OfType<TreeNode>() ) Visit( root, 0 );
		if ( candidates.Count == 0 ) return;
		var layer = expand ? candidates.Min( c => c.Depth ) : candidates.Max( c => c.Depth );
		foreach ( var (node, depth) in candidates.Where( c => c.Depth == layer ) )
		{
			// Clear remembered child expansion so opening one layer cannot reveal several.
			Close( node, recursive: true );
			if ( expand ) Open( node );
		}
	}

	protected override bool OnItemPressed( VirtualWidget item, MouseEvent e )
	{
		if ( !base.OnItemPressed( item, e ) ) return false; // Leave folder expand arrows functional.
		if ( e.LeftMouseButton && item.Object is AssetNode node ) node.ToggleSelection();
		return true;
	}

	sealed class AssetNode : TreeNode
	{
		readonly UnityAssetTree owner;
		public UnityAsset Asset { get; init; }
		public AssetNode( string name, UnityAssetTree owner ) { Name = name; this.owner = owner; }
		IEnumerable<UnityAsset> Leaves => Asset != null ? new[] { Asset } : Children.OfType<AssetNode>().SelectMany( c => c.Leaves );
		public void ToggleSelection()
		{
			owner.describe( Asset );
			// Dependencies remain included until their last selected parent is removed.
			if ( Asset != null && !Asset.Selected && owner.plan()?.Find( Asset ) != null ) return;
			var eligible = Leaves.Where( a => a.Kind != UnityAssetKind.Unsupported ).ToArray();
			var select = !eligible.All( a => owner.plan()?.Find( a ) != null );
			foreach ( var asset in eligible ) asset.Selected = select;
			owner.changed();
		}
		public override void OnKeyPress( KeyEvent e )
		{
			if ( e.Key == KeyCode.Space ) { ToggleSelection(); e.Accepted = true; }
		}
		public override void OnPaint( VirtualWidget item )
		{
			PaintSelection( item );
			var plan = owner.plan();
			var entry = Asset == null ? null : plan?.Find( Asset );
			var eligible = Leaves.Where( a => a.Kind != UnityAssetKind.Unsupported ).ToArray();
			var count = eligible.Count( a => plan?.Find( a ) != null );
			var active = eligible.Length > 0;
			var checkbox = !active || count == 0 ? "check_box_outline_blank" : count == eligible.Length ? "check_box" : "indeterminate_check_box";
			if ( entry is { Explicit: false } ) checkbox = "link";
			Paint.SetPen( active ? Theme.Text : Theme.Text.WithAlpha( 0.35f ) );
			Paint.DrawIcon( item.Rect, checkbox, 18, TextFlag.LeftCenter );
			Paint.DrawIcon( item.Rect.Shrink( 24, 0, 0, 0 ), Asset == null ? "folder" : Asset.Kind switch
			{
				UnityAssetKind.Material => "palette", UnityAssetKind.Texture => "image", UnityAssetKind.Model => "view_in_ar", _ => "description"
			}, 18, TextFlag.LeftCenter );
			var annotation = Asset?.Kind == UnityAssetKind.Unsupported ? " (unsupported)" : entry == null ? "" :
				$" · {entry.OutputLabel}" + (!entry.Explicit ? " · dependency" : "") + (plan.Issues.Any( i => i.Asset == Asset.Path ) ? " · issue" : "");
			Paint.DrawText( item.Rect.Shrink( 48, 0, 0, 0 ), Name + annotation, TextFlag.LeftCenter );
		}
	}
}
grubs.importunitypackage / Editor/Core/UnityAsciiFbxLinks.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>Legacy ASCII FBX attaches texture/material objects to the mesh by name.</summary>
internal sealed class UnityAsciiFbxLinks
{
	readonly Dictionary<string, string> textures = new( StringComparer.Ordinal );
	readonly List<(string Child, string Parent)> connections = new();
	int depth;
	int objectsDepth = -1;
	int textureDepth = -1;
	string texture;

	public void Observe( string line )
	{
		if ( Regex.IsMatch( line, @"^\s*Objects:\s*\{" ) ) objectsDepth = depth + 1;
		if ( objectsDepth > 0 && depth == objectsDepth )
		{
			var start = Regex.Match( line, "^\\s*Texture:\\s*\"(Texture::[^\"]+)\"" );
			if ( start.Success ) { texture = start.Groups[1].Value; textureDepth = depth + 1; }
		}
		if ( texture != null && depth >= textureDepth )
		{
			var file = Regex.Match( line, "^\\s*(?:FileName|Filename|RelativeFilename):\\s*\"([^\"]+)\"" );
			if ( file.Success ) textures[texture] = file.Groups[1].Value.Replace( '\\', '/' ).Split( '/' ).Last();
		}
		var connection = Regex.Match( line, "^\\s*Connect:\\s*\"OO\",\\s*\"([^\"]+)\",\\s*\"([^\"]+)\"" );
		if ( connection.Success ) connections.Add( (connection.Groups[1].Value, connection.Groups[2].Value) );
		var structural = line.Contains( '"' ) ? Regex.Replace( line, "\"[^\"]*\"", "" ) : line;
		structural = structural.Split( ';' )[0];
		depth += structural.Count( c => c == '{' ) - structural.Count( c => c == '}' );
		if ( depth < textureDepth ) { texture = null; textureDepth = -1; }
		if ( depth < objectsDepth ) objectsDepth = -1;
	}

	public IEnumerable<KeyValuePair<string, string>> Resolve()
	{
		var candidates = new List<(string Material, string File)>();
		foreach ( var mesh in connections.Where( c => c.Parent.StartsWith( "Model::", StringComparison.Ordinal ) ).GroupBy( c => c.Parent ) )
		{
			var materials = mesh.Select( c => c.Child ).Where( c => c.StartsWith( "Material::", StringComparison.Ordinal ) ).Distinct().ToArray();
			var maps = mesh.Select( c => c.Child ).Where( textures.ContainsKey ).Distinct().ToArray();
			// Without polygon-slot information, multiple materials/textures are ambiguous.
			if ( materials.Length == 1 && maps.Length == 1 ) candidates.Add( (materials[0]["Material::".Length..], textures[maps[0]]) );
		}
		foreach ( var material in candidates.GroupBy( c => c.Material ) )
		{
			var files = material.Select( c => c.File ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
			if ( files.Length == 1 ) yield return new( material.Key, files[0] );
		}
	}
}
grubs.importunitypackage / Editor/Core/UnityFbxCompatibility.cs
Editor library
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;

namespace ImportUnityPackage;

/// <summary>Repairs a redundant array emitted by some legacy ASCII FBX exporters.</summary>
public static class UnityFbxCompatibility
{
	public static byte[] Normalize( string source, CancellationToken cancel )
	{
		// Ordinary/binary models remain byte-for-byte copies. Bound optional text processing.
		using var stream = File.OpenRead( source );
		if ( stream.Length > 32 * 1024 * 1024 ) return null;
		var header = new byte[32];
		var length = stream.Read( header );
		if ( !Encoding.ASCII.GetString( header, 0, length ).StartsWith( "; FBX ", StringComparison.Ordinal ) ) return null;
		stream.Position = 0;
		using var reader = new StreamReader( stream, new UTF8Encoding( false, true ), false );
		string text;
		try { text = reader.ReadToEnd(); }
		catch ( DecoderFallbackException ) { return null; }
		return NormalizeText( text, cancel ) is string normalized ? Encoding.UTF8.GetBytes( normalized ) : null;
	}

	internal static string NormalizeText( string text, CancellationToken cancel )
	{
		cancel.ThrowIfCancellationRequested();
		const string numbers = @"[-+0-9.eE,\s]+";
		var timeout = TimeSpan.FromSeconds( 2 );
		var tails = Regex.Matches( text, @"(?m)^[ \t]*}(?<tail>,[-+0-9.eE, \t]+)(?=\r?$)", RegexOptions.None, timeout );
		if ( tails.Count == 0 ) return null;
		string Compact( string value ) => string.Concat( value.Where( c => !char.IsWhiteSpace( c ) ) ).Trim( ',' );
		var vertices = Regex.Matches( text, @"(?m)^[ \t]*Vertices:[ \t]*(" + numbers + ")", RegexOptions.None, timeout )
			.Select( m => Compact( m.Groups[1].Value ) ).Where( s => s.Length > 0 ).ToHashSet( StringComparer.Ordinal );
		var output = new StringBuilder( text );
		var changed = false;
		foreach ( Match match in tails.Reverse() )
		{
			cancel.ThrowIfCancellationRequested();
			var tail = match.Groups["tail"];
			// Only discard an exact repeated vertex sequence at an invalid syntax position.
			// Different data is left untouched so an uncertain repair cannot alter geometry.
			if ( !vertices.Contains( Compact( tail.Value ) ) ) continue;
			output.Remove( tail.Index, tail.Length );
			changed = true;
		}
		return changed ? output.ToString() : null;
	}
}
grubs.importunitypackage / Editor/TextureChannels.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;
using SkiaSharp;

namespace ImportUnityPackage;

/// <summary>CPU image work only. Every worker owns its decoder, bitmap and pixel arrays.</summary>
public static class TextureChannels
{
	public const long MemoryBudget = 1024L * 1024 * 1024;
	public const int WorkerLimit = 2;

	public static void ExtractBatch( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel )
		=> ProcessBatch( requests, cancel );

	public sealed record BatchStats( int Decodes, int Channels, int PeakWorkers, long PeakReservedBytes, long ElapsedMilliseconds );

	public static BatchStats ProcessBatch( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel, int workers = WorkerLimit, long budget = MemoryBudget )
	{
		if ( workers < 1 || workers > WorkerLimit || budget <= 0 ) throw new ArgumentOutOfRangeException();
		var timer = System.Diagnostics.Stopwatch.StartNew();
		var gate = new object();
		long reserved = 0, peakReserved = 0;
		int active = 0, peakWorkers = 0, decodes = 0;
		var groups = requests.GroupBy( r => r.Source, StringComparer.OrdinalIgnoreCase ).Select( g => g.ToArray() ).ToArray();
		Parallel.ForEach( groups, new ParallelOptions { MaxDegreeOfParallelism = workers, CancellationToken = cancel }, group =>
		{
			// Unknown codecs and images larger than the admission budget run exclusively.
			// This is an estimated working-set budget, not a hard cap on native allocations.
			var reservation = Math.Min( budget, EstimateMemory( group[0].Source, budget ) );
			lock ( gate )
			{
				while ( reserved + reservation > budget ) { cancel.ThrowIfCancellationRequested(); Monitor.Wait( gate, 50 ); }
				cancel.ThrowIfCancellationRequested();
				reserved += reservation;
				peakReserved = Math.Max( reserved, peakReserved );
				peakWorkers = Math.Max( ++active, peakWorkers );
			}
			try
			{
				using var bitmap = Decode( group[0].Source, cancel );
				Interlocked.Increment( ref decodes );
				var sourcePixels = bitmap.GetPixels();
				var outputPixels = new Color[sourcePixels.Length];
				foreach ( var request in group )
				{
					cancel.ThrowIfCancellationRequested();
					ConvertPixels( sourcePixels, outputPixels, request.Channel, request.Scale, request.Invert, cancel );
					bitmap.SetPixels( outputPixels );
					File.WriteAllBytes( request.Destination, bitmap.ToPng() );
				}
			}
			catch ( OperationCanceledException ) { throw; }
			catch ( Exception ex ) { throw new InvalidDataException( $"Texture channel extraction failed for '{group[0].Source}': {ex.Message}", ex ); }
			finally { lock ( gate ) { reserved -= reservation; active--; Monitor.PulseAll( gate ); } }
		} );
		return new( decodes, requests.Count, peakWorkers, peakReserved, timer.ElapsedMilliseconds );
	}

	static long EstimateMemory( string source, long unknown )
	{
		using var stream = File.OpenRead( source );
		using var codec = SKCodec.Create( stream );
		if ( codec == null ) return unknown;
		// Decoded image, two float-color arrays, encoder buffers and compressed input.
		return checked( (long)codec.Info.Width * codec.Info.Height * 64 + stream.Length * 2 + 16 * 1024 * 1024 );
	}

	static Bitmap Decode( string source, CancellationToken cancel )
	{
		cancel.ThrowIfCancellationRequested();
		return Bitmap.CreateFromBytes( File.ReadAllBytes( source ) ) ?? throw new InvalidDataException( "Unable to decode packed texture: " + source );
	}

	// Retained for callers supplying a single-channel callback.
	public static byte[] Extract( string source, int channel, double scale, bool invert, CancellationToken cancel )
	{
		using var bitmap = Decode( source, cancel );
		var pixels = bitmap.GetPixels();
		ConvertPixels( pixels, pixels, channel, scale, invert, cancel );
		bitmap.SetPixels( pixels );
		return bitmap.ToPng();
	}

	static void ConvertPixels( Color[] source, Color[] output, int channel, double scale, bool invert, CancellationToken cancel )
	{
		for ( var i = 0; i < source.Length; i++ )
		{
			if ( i % 65536 == 0 ) cancel.ThrowIfCancellationRequested();
			var p = source[i];
			var value = (float)Math.Clamp( (channel switch { 0 => p.r, 1 => p.g, 2 => p.b, _ => p.a }) * scale, 0, 1 );
			if ( invert ) value = 1 - value;
			output[i] = new Color( value, value, value, 1 );
		}
	}
}
grubs.importunitypackage / Editor/Core/UnityMaterial.cs
Editor library
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>Best-effort conversion of text-serialized Standard/URP material properties.</summary>
public sealed class UnityMaterial
{
	public Dictionary<string, string> Textures { get; } = new();
	public Dictionary<string, double> Numbers { get; } = new();
	public Dictionary<string, double[]> Colors { get; } = new();
	public List<string> Warnings { get; } = new();
	public List<string> Information { get; } = new();
	readonly Dictionary<string, string> channelImages = new();
	readonly Dictionary<string, double[]> textureScale = new();
	readonly Dictionary<string, double[]> textureOffset = new();
	bool shaderAlphaTest;
	bool shaderBackfaces;
	bool shaderTranslucent;
	bool terrainLayer;
	HashSet<string> declaredProperties;
	static readonly string[] ColorProperties = { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" };
	public IEnumerable<string> ColorTextureGuids => ColorProperties.Where( Textures.ContainsKey ).Select( p => Textures[p] );
	bool AlphaTest => shaderAlphaTest || Number( "_Mode", 0 ) == 1 || Number( "_AlphaClip", 0 ) == 1;
	bool Translucent => shaderTranslucent || Number( "_Mode", 0 ) >= 2 || Number( "_Surface", 0 ) == 1;
	public string ShaderName { get; private set; }
	public string ShaderGuid { get; private set; }
	static readonly Regex GuidPattern = new( @"guid:\s*([a-fA-F0-9]{32})" );

	public static IEnumerable<string> References( string text ) => GuidPattern.Matches( text )
		.Select( m => m.Groups[1].Value ).Where( g => g.Any( c => c != '0' ) ).Distinct( StringComparer.OrdinalIgnoreCase );

	public static UnityMaterial Parse( string text )
	{
		if ( text.Contains( "TerrainLayer:" ) && !text.Contains( '\0' ) ) return ParseTerrainLayer( text );
		if ( !text.Contains( "Material:" ) || text.Contains( '\0' ) )
			throw new InvalidDataException( "Material is not Unity text YAML. Re-export it with Asset Serialization set to Force Text in Unity." );
		var result = new UnityMaterial();
		result.ShaderGuid = Regex.Match( text, @"m_Shader:[^\r\n]*guid:\s*([a-fA-F0-9]{32})" ).Groups[1].Value;
		string property = null;
		foreach ( var line in text.Split( '\n' ) )
		{
			var item = Regex.Match( line, @"^\s*-\s*(_[A-Za-z0-9_]+):\s*(.*)$" );
			if ( item.Success )
			{
				property = item.Groups[1].Value;
				var value = item.Groups[2].Value.Trim();
				if ( double.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) && double.IsFinite( number ) )
					result.Numbers[property] = number;
				if ( value.StartsWith( "{" ) )
				{
					var channels = Regex.Matches( value, @"[rgba]:\s*([-+0-9.eE]+)" );
					if ( channels.Count == 4 ) result.Colors[property] = channels.Select( m => double.Parse( m.Groups[1].Value, CultureInfo.InvariantCulture ) ).ToArray();
				}
			}
			if ( property != null && line.Contains( "m_Texture:" ) )
			{
				var guid = GuidPattern.Match( line );
				if ( guid.Success && guid.Groups[1].Value.Any( c => c != '0' ) ) result.Textures[property] = guid.Groups[1].Value;
			}
			if ( property != null && (line.Contains( "m_Scale:" ) || line.Contains( "m_Offset:" )) )
			{
				var xy = Regex.Match( line, @"x:\s*([-+0-9.eE]+),\s*y:\s*([-+0-9.eE]+)" );
				if ( xy.Success ) (line.Contains( "m_Scale:" ) ? result.textureScale : result.textureOffset)[property] =
					new[] { double.Parse( xy.Groups[1].Value, CultureInfo.InvariantCulture ), double.Parse( xy.Groups[2].Value, CultureInfo.InvariantCulture ) };
			}
			if ( line.Contains( "m_Scale:" ) && !Regex.IsMatch( line, @"x:\s*1(?:\.0+)?\s*,\s*y:\s*1(?:\.0+)?\s*}" ) ||
				line.Contains( "m_Offset:" ) && !Regex.IsMatch( line, @"x:\s*0(?:\.0+)?\s*,\s*y:\s*0(?:\.0+)?\s*}" ) )
				result.Warnings.Add( "Texture tiling/offset needs manual adjustment." );
		}
		if ( result.Textures.ContainsKey( "_MetallicGlossMap" ) || result.Textures.ContainsKey( "_MaskMap" ) )
			result.Warnings.Add( "Packed metallic/smoothness or HDRP mask maps need channel separation; scalar metallic/roughness values were used." );
		result.Information.Add( "Review the converted material: custom shaders, normal-map conventions and advanced Unity settings are not reproduced exactly." );
		return result;
	}

	public void ConfigureShader( string source )
	{
		ShaderName = Regex.Match( source, "Shader\\s+\"([^\"]+)\"" ).Groups[1].Value;
		declaredProperties = Regex.Matches( source, "(?m)^\\s*(?:\\[[^\\]\\r\\n]*\\]\\s*)*(_[A-Za-z0-9_]+)\\s*\\(\\s*\"[^\"]*\"\\s*," )
			.Select( m => m.Groups[1].Value ).ToHashSet();
		if ( declaredProperties.Count > 0 )
		{
			// Unity retains old shader properties in materials. Only use properties declared by the active shader.
			foreach ( var stale in Textures.Keys.Where( k => !declaredProperties.Contains( k ) ).ToArray() ) Textures.Remove( stale );
		}
		shaderAlphaTest = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"TransparentCutout\"" );
		shaderBackfaces = Regex.IsMatch( source, @"(?m)^\s*Cull\s+Off\s*$", RegexOptions.IgnoreCase );
		shaderTranslucent = Regex.IsMatch( source, "\"RenderType\"\\s*=\\s*\"Transparent\"" );
		if ( !string.IsNullOrEmpty( ShaderName ) ) Warnings.Add( $"Shader '{ShaderName}' is approximated using s&box's complex shader; shader code and graph behavior are not translated." );
	}

	public void PrepareChannels( Func<string, int, double, bool, string> extract )
	{
		void Channel( string target, string guid, int channel, double scale = 1, bool invert = false )
		{
			var path = extract( guid, channel, scale, invert );
			if ( path != null ) channelImages[target] = path;
		}
		if ( Textures.TryGetValue( "_MetallicGlossMap", out var packed ) )
		{
			Channel( "metal", packed, 0 );
			Channel( "rough", packed, 3, Number( "_GlossMapScale", Number( "_Smoothness", 1 ) ), true );
		}
		if ( Textures.TryGetValue( "_MaskMap", out var mask ) )
		{
			Channel( "metal", mask, 0 );
			Channel( "ao", mask, 1 );
			// TerrainLit stores height in B; HDRP Lit stores a detail mask instead.
			if ( terrainLayer ) Channel( "height", mask, 2 );
			Channel( "rough", mask, 3, 1, true );
		}
		if ( Textures.TryGetValue( "_OcclusionMap", out var ao ) ) Channel( "ao", ao, 1 );
		if ( AlphaTest || Translucent )
		{
			foreach ( var property in ColorProperties )
			{
				if ( !Textures.TryGetValue( property, out var guid ) ) continue;
				Channel( "opacity", guid, 3 );
				if ( channelImages.ContainsKey( "opacity" ) ) break;
			}
		}
		if ( channelImages.Count > 0 ) Warnings.RemoveAll( w => w.StartsWith( "Packed metallic/" ) || w.StartsWith( "Unity terrain mask channels" ) );
	}

	static UnityMaterial ParseTerrainLayer( string text )
	{
		var result = new UnityMaterial { terrainLayer = true };
		foreach ( var (source, target) in new[] { ("m_DiffuseTexture", "_MainTex"), ("m_NormalMapTexture", "_BumpMap"), ("m_MaskMapTexture", "_MaskMap") } )
		{
			var line = text.Split( '\n' ).FirstOrDefault( l => l.TrimStart().StartsWith( source + ":", StringComparison.Ordinal ) );
			if ( line != null && References( line ).FirstOrDefault() is string guid ) result.Textures[target] = guid;
		}
		foreach ( var (source, target) in new[] { ("m_Metallic", "_Metallic"), ("m_Smoothness", "_Smoothness") } )
		{
			var match = Regex.Match( text, @"(?m)^\s*" + source + @":\s*([-+0-9.eE]+)" );
			if ( match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ) result.Numbers[target] = value;
		}
		result.Warnings.Add( "Terrain layer converted; review world tiling, normal strength and remapping in the terrain material editor." );
		if ( result.Textures.ContainsKey( "_MaskMap" ) ) result.Warnings.Add( "Unity terrain mask channels need separation; packed mask was preserved as a source texture." );
		return result;
	}

	string Texture( Func<string, string> resolve, params string[] names )
	{
		foreach ( var name in names )
			if ( Textures.TryGetValue( name, out var guid ) && resolve( guid ) is string path ) return path;
		return null;
	}
	public double Number( string name, double fallback ) => Numbers.GetValueOrDefault( name, fallback );
	static string N( double value ) => double.IsFinite( value ) ? value.ToString( "0.######", CultureInfo.InvariantCulture ) : "0";
	internal static string Quote( string value ) => JsonSerializer.Serialize( value );

	public string ToVmat( Func<string, string> resolve )
	{
		var text = new StringBuilder( "Layer0\n{\n\tshader \"shaders/complex.shader_c\"\n" );
		void Set( string key, string value ) => text.AppendLine( $"\t{key} {Quote( value )}" );
		Set( "TextureColor", Texture( resolve, "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" ) ?? "materials/default/default_color.tga" );
		var colorProperty = new[] { "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" }.FirstOrDefault( p => Textures.TryGetValue( p, out var guid ) && resolve( guid ) != null );
		if ( colorProperty != null )
		{
			if ( textureScale.TryGetValue( colorProperty, out var scale ) ) Set( "g_vTexCoordScale", $"[{N( scale[0] )} {N( scale[1] )}]" );
			if ( textureOffset.TryGetValue( colorProperty, out var offset ) ) Set( "g_vTexCoordOffset", $"[{N( offset[0] )} {N( offset[1] )}]" );
		}
		Set( "TextureNormal", Texture( resolve, "_BumpMap", "_Normal", "_NormalMap" ) ?? "materials/default/default_normal.tga" );
		Set( "TextureAmbientOcclusion", channelImages.GetValueOrDefault( "ao" ) ?? Texture( resolve, "_OcclusionMap", "_Occlusion" ) ?? "materials/default/default_ao.tga" );
		Set( "TextureRoughness", channelImages.GetValueOrDefault( "rough" ) ?? "materials/default/default_rough.tga" );
		var roughness = 1 - Math.Clamp( Number( "_Smoothness", Number( "_Glossiness", 0.5 ) ), 0, 1 );
		Set( "g_flRoughnessScaleFactor", N( channelImages.ContainsKey( "rough" ) ? 1 : roughness ) );
		if ( channelImages.TryGetValue( "metal", out var metal ) )
		{
			text.AppendLine( "\tF_METALNESS_TEXTURE 1" );
			Set( "TextureMetalness", metal );
		}
		Set( "g_flMetalness", N( Math.Clamp( Number( "_Metallic", 0 ), 0, 1 ) ) );
		if ( Colors.TryGetValue( "_BaseColor", out var color ) || colorProperty == "_Diffuse" && Colors.TryGetValue( "_MainColor", out color ) || Colors.TryGetValue( "_Color", out color ) )
			Set( "g_vColorTint", $"[{N( color[0] )} {N( color[1] )} {N( color[2] )} {N( color[3] )}]" );
		if ( channelImages.TryGetValue( "opacity", out var opacity ) ) Set( "TextureTranslucency", opacity );
		if ( AlphaTest )
		{
			text.AppendLine( "\tF_ALPHA_TEST 1" );
			Set( "g_flAlphaTestReference", N( Number( "_Cutoff", 0.5 ) ) );
		}
		if ( shaderBackfaces || Number( "_Cull", 2 ) == 0 ) text.AppendLine( "\tF_RENDER_BACKFACES 1" );
		if ( Translucent ) text.AppendLine( "\tF_TRANSLUCENT 1" );
		var emission = Texture( resolve, "_EmissionMap", "_EmissiveColorMap" );
		if ( emission != null )
		{
			text.AppendLine( "\tF_SELF_ILLUM 1" );
			Set( "TextureSelfIllumMask", emission );
		}
		return text.AppendLine( "}" ).ToString();
	}

	public string ToTmat( Func<string, string> resolve )
	{
		// TerrainMaterial image fields are source image paths, not .vmat references.
		var values = new Dictionary<string, object>
		{
			["__version"] = 1,
			["AlbedoImage"] = "materials/default/default_color.tga",
			["NormalImage"] = "materials/default/default_normal.tga",
			["RoughnessImage"] = "materials/default/default_rough.tga",
			["AOImage"] = "materials/default/default_ao.tga",
			["HeightImage"] = "materials/default/default_height.tga"
		};
		void Set( string key, params string[] names )
		{
			var value = Texture( resolve, names );
			if ( value != null ) values[key] = value;
		}
		Set( "AlbedoImage", "_BaseMap", "_Diffuse", "_MainTex", "_Albedo", "_BaseColorMap" );
		Set( "NormalImage", "_BumpMap", "_Normal", "_NormalMap" );
		Set( "AOImage", "_OcclusionMap", "_Occlusion" );
		Set( "HeightImage", "_ParallaxMap", "_HeightMap" );
		foreach ( var (channel, field) in new[] { ("rough", "RoughnessImage"), ("ao", "AOImage"), ("height", "HeightImage") } )
			if ( channelImages.TryGetValue( channel, out var image ) ) values[field] = image;
		return JsonSerializer.Serialize( values, new JsonSerializerOptions { WriteIndented = true } );
	}
}
grubs.importunitypackage / Editor/ImportAssetPreparation.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace ImportUnityPackage;

public record ImportPreparationResult( string[] Warnings, bool Cancelled, int Rebuilt )
{
	public string[] Errors { get; init; } = Array.Empty<string>();
}

/// <summary>Runs on the editor context after the imported folder has been committed.</summary>
public static class ImportAssetPreparation
{
	static Task repairTask;

	/// <summary>Repair already compiled resources in an existing import without reimporting source files.</summary>
	[ConCmd( "unity_import_repair_resources" )]
	public static void Repair( string directory )
	{
		if ( repairTask is { IsCompleted: false } ) return;
		repairTask = RepairAsync( directory );
	}

	static async Task RepairAsync( string directory )
	{
		try
		{
			var assets = Project.Current.GetAssetsPath();
			var root = Path.GetFullPath( Path.Combine( assets, "imported" ) ) + Path.DirectorySeparatorChar;
			var target = Path.GetFullPath( Path.IsPathRooted( directory ) ? directory : Path.Combine( assets, directory ) );
			if ( !(target.Equals( root.TrimEnd( Path.DirectorySeparatorChar ), StringComparison.OrdinalIgnoreCase ) || target.StartsWith( root, StringComparison.OrdinalIgnoreCase )) || !Directory.Exists( target ) )
				throw new ArgumentException( "Choose an existing package folder under this project's Assets/imported." );
			var stale = Directory.EnumerateFiles( target, "*", SearchOption.AllDirectories )
				.Where( f => ResourceOrder( f ) > 0 )
				.Where( f => AssetSystem.FindByPath( f ) is { IsCompiled: true } asset && !asset.IsCompiledAndUpToDate ).ToArray();
			Log.Info( $"UNITY_RESOURCE_REPAIR: preparing {stale.Length} out-of-date resources in {target}" );
			var result = await Run( stale, null, CancellationToken.None );
			foreach ( var warning in result.Warnings ) Log.Warning( warning );
			foreach ( var error in result.Errors ) Log.Error( error );
			Log.Info( $"UNITY_RESOURCE_REPAIR complete: {stale.Length} checked, {result.Rebuilt} full rebuilds, {result.Warnings.Length} warnings, {result.Errors.Length} errors." );
		}
		catch ( Exception ex ) { Log.Error( $"UNITY_RESOURCE_REPAIR failed: {ex}" ); }
	}

	public static async Task<ImportPreparationResult> RunImport( ImportResult result, IProgress<ImportProgress> progress, CancellationToken cancel )
	{
		var files = result.Files.ToHashSet( StringComparer.OrdinalIgnoreCase );
		var warnings = new List<string>();
		foreach ( var changed in result.ChangedFiles )
		{
			if ( cancel.IsCancellationRequested ) break;
			try
			{
				var asset = AssetSystem.RegisterFile( changed );
				if ( asset == null ) continue;
				foreach ( var dependant in asset.GetDependants( true ) )
					if ( ResourceOrder( dependant.Path ) > 0 && dependant.HasSourceFile ) files.Add( dependant.GetSourceFile( true ) );
			}
			catch ( Exception ex ) { warnings.Add( $"{changed}: could not discover affected resources: {ex.Message}" ); }
		}
		var prepared = await Run( files, progress, cancel );
		return prepared with { Warnings = warnings.Concat( prepared.Warnings ).ToArray() };
	}

	public static async Task<ImportPreparationResult> Run( IEnumerable<string> files, IProgress<ImportProgress> progress, CancellationToken cancel )
	{
		var warnings = new List<string>();
		var errors = new List<string>();
		var resources = new List<Asset>();
		var rebuilt = 0;
		try
		{
			// Register all sources before compiling textures, then materials, then models.
			foreach ( var file in files.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( ResourceOrder ) )
			{
				cancel.ThrowIfCancellationRequested();
				if ( Path.GetExtension( file ).ToLowerInvariant() is ".mat" or ".json" or ".mtl" ) continue;
				try
				{
					var asset = AssetSystem.RegisterFile( file );
					if ( ResourceOrder( file ) > 0 )
					{
						if ( asset == null ) throw new InvalidOperationException( "Asset registration returned no resource" );
						resources.Add( asset );
					}
				}
				catch ( Exception ex ) { errors.Add( $"{file}: registration failed: {ex.Message}" ); }
			}
			for ( var i = 0; i < resources.Count; i++ )
			{
				cancel.ThrowIfCancellationRequested();
				var asset = resources[i];
				progress?.Report( new( (double)i / resources.Count, $"Preparing resource {i + 1}/{resources.Count}: {asset.Name}" ) );
				try
				{
					if ( !asset.IsCompiledAndUpToDate ) await asset.CompileIfNeededAsync().AsTask().WaitAsync( cancel );
					cancel.ThrowIfCancellationRequested();
					// Compilation and publishing generated children to the registry are separate steps.
					// Let the editor process completion notifications before checking dependency validity.
					await WaitForReady( asset, TimeSpan.FromMilliseconds( 250 ), cancel );
					// Incremental compilation can leave generated children absent from the asset registry,
					// even when their _c files exist. One full rebuild restores those dependencies.
					if ( !asset.IsCompileFailed && !asset.IsCompiledAndUpToDate )
					{
						var accepted = asset.Compile( true );
						rebuilt++;
						if ( accepted ) await WaitForReady( asset, TimeSpan.FromSeconds( 30 ), cancel );
					}
					if ( asset.IsCompileFailed || !asset.IsCompiledAndUpToDate )
						errors.Add( $"{asset.Path}: " + (asset.IsCompileFailed
							? "the resource compiler reported a failure; see its diagnostics in the editor console."
							: !asset.IsCompiled ? "no compiled resource became available after compilation."
							: "compiled resource dependencies did not become ready within 30 seconds. Reimport or run unity_import_repair_resources for this folder.") );
				}
				catch ( OperationCanceledException ) { throw; }
				catch ( Exception ex ) { errors.Add( $"{asset.Path}: resource preparation failed: {ex.Message}" ); }
				await Task.Delay( 1, cancel );
			}
		}
		catch ( OperationCanceledException ) when ( cancel.IsCancellationRequested )
		{
			return new( warnings.ToArray(), true, rebuilt ) { Errors = errors.ToArray() };
		}
		progress?.Report( new( 1, "Import complete" ) );
		return new( warnings.ToArray(), false, rebuilt ) { Errors = errors.ToArray() };
	}

	static async Task WaitForReady( Asset asset, TimeSpan timeout, CancellationToken cancel )
	{
		var started = System.Diagnostics.Stopwatch.StartNew();
		while ( !asset.IsCompiledAndUpToDate && started.Elapsed < timeout )
			await Task.Delay( 50, cancel );
	}

	static int ResourceOrder( string file ) => Path.GetExtension( file ).ToLowerInvariant() switch
	{
		".vtex" => 1,
		".vmat" or ".tmat" => 2,
		".vmdl" => 3,
		_ => 0
	};
}
grubs.importunitypackage / Editor/Core/UnityFbxBindings.cs
Editor library
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>FBX object hierarchy and ordered material-to-model connections; no vertex decoding needed.</summary>
internal sealed class UnityFbxBindings
{
	internal readonly Dictionary<long, (string Name, bool Mesh)> Nodes = new();
	internal readonly Dictionary<long, string> Materials = new();
	internal readonly List<(long Child, long Parent)> Connections = new();
	readonly Dictionary<string, long> legacyIds = new( StringComparer.Ordinal );
	long Id( string value )
	{
		if ( long.TryParse( value, out var id ) ) return id;
		if ( value == "Model::Scene" ) return 0;
		if ( !legacyIds.TryGetValue( value, out id ) ) legacyIds[value] = id = -legacyIds.Count - 1;
		return id;
	}
	internal void ObserveAscii( string line )
	{
		var item = Regex.Match( line, "^\\s*(Model|Material):\\s*(?:([-0-9]+),\\s*)?\"(?:Model|Material)::([^\"]+)\",\\s*\"([^\"]*)\"" );
		if ( item.Success )
		{
			var kind = item.Groups[1].Value;
			var id = Id( item.Groups[2].Success ? item.Groups[2].Value : kind + "::" + item.Groups[3].Value );
			if ( kind == "Material" ) Materials[id] = item.Groups[3].Value;
			else Nodes[id] = (item.Groups[3].Value, item.Groups[4].Value == "Mesh");
		}
		var link = Regex.Match( line, "^\\s*(?:C|Connect):\\s*\"OO\",\\s*(?:\"([^\"]+)\"|([-0-9]+)),\\s*(?:\"([^\"]+)\"|([-0-9]+))" );
		if ( link.Success ) Connections.Add( (Id( link.Groups[1].Success ? link.Groups[1].Value : link.Groups[2].Value ), Id( link.Groups[3].Success ? link.Groups[3].Value : link.Groups[4].Value )) );
	}

	internal void Apply( UnityModel model, bool preserveHierarchy )
	{
		var parents = Connections.Where( c => Nodes.ContainsKey( c.Child ) && (c.Parent == 0 || Nodes.ContainsKey( c.Parent )) )
			.GroupBy( c => c.Child ).Where( g => g.Select( c => c.Parent ).Distinct().Count() == 1 ).ToDictionary( g => g.Key, g => g.First().Parent );
		var roots = Nodes.Keys.Where( id => !parents.TryGetValue( id, out var parent ) || parent == 0 ).ToArray();
		var ambiguous = new HashSet<long>();
		foreach ( var (id, node) in Nodes.Where( p => p.Value.Mesh ) )
		{
			var names = new List<string>();
			var seen = new HashSet<long>();
			var current = id;
			while ( Nodes.TryGetValue( current, out var ancestor ) && seen.Add( current ) )
			{
				names.Add( !preserveHierarchy && roots.Length == 1 && roots[0] == current ? "root" : ancestor.Name );
				current = parents.GetValueOrDefault( current );
			}
			if ( current != 0 ) continue; // Cyclic/malformed hierarchy cannot identify a renderer.
			names.Reverse();
			if ( preserveHierarchy || roots.Length != 1 ) names.Insert( 0, "root" );
			var path = "//RootNode/" + string.Join( "/", names );
			var slots = Connections.Where( c => c.Parent == id && Materials.ContainsKey( c.Child ) ).Select( c => Materials[c.Child] ).ToArray();
			var renderer = UnityFileId.Renderer( path );
			if ( !model.RendererSlots.TryAdd( renderer, new( node.Name, slots ) ) ) ambiguous.Add( renderer );
		}
		foreach ( var id in ambiguous ) model.RendererSlots.Remove( id );
	}
}
grubs.importunitypackage / Editor/Core/UnityPrefabBindings.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>Resolves renderer/slot assignments from prefab data, independently of publisher or material filenames.</summary>
internal static class UnityPrefabBindings
{
	sealed class Candidate( string path )
	{
		internal readonly string Path = path;
		internal readonly Dictionary<string, HashSet<string>> Slots = new( StringComparer.OrdinalIgnoreCase );
		internal readonly HashSet<string> Materials = new( StringComparer.OrdinalIgnoreCase );
		internal readonly List<string> Warnings = new();
		internal bool HasOverrides;
	}
	static string GuidOf( string reference ) => Regex.Match( reference, @"\bguid:\s*([a-fA-F0-9]{32})" ).Groups[1].Value;
	static long IdOf( string reference ) => long.TryParse( Regex.Match( reference, @"\bfileID:\s*(-?[0-9]+)" ).Groups[1].Value, out var id ) ? id : 0;
	static string Field( string text, string name ) => Regex.Match( text, @"(?m)^\s*" + Regex.Escape( name ) + @":\s*\{([^}\r\n]*)\}" ).Groups[1].Value;

	internal static void Apply( UnityArchive archive )
	{
		var models = archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var materialIds = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );
		var candidates = new Dictionary<string, List<Candidate>>( StringComparer.OrdinalIgnoreCase );
		var visible = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var colliders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var prefab in archive.Assets.Where( a => a.Path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) ) )
		{
			if ( new FileInfo( prefab.Source ).Length > 16 * 1024 * 1024 ) continue;
			var text = File.ReadAllText( prefab.Source );
			var local = new Dictionary<string, Candidate>( StringComparer.OrdinalIgnoreCase );
			Candidate For( string guid )
			{
				if ( !local.TryGetValue( guid, out var candidate ) ) local[guid] = candidate = new( prefab.Path );
				return candidate;
			}
			void Assign( string guid, UnityModel.RendererBinding binding, int index, string material )
			{
				var candidate = For( guid ); candidate.HasOverrides = true;
				var model = models[guid].ModelInfo;
				if ( binding == null ) { candidate.Warnings.Add( "A prefab material override targets an unsupported or unidentified renderer; no slot was guessed." ); return; }
				// ModelDoc currently imports only LOD0 when present. Other LOD overrides must not change its slots.
				if ( model.HighestDetailMeshes.Length > 0 && !model.HighestDetailMeshes.Contains( binding.Mesh ) ) return;
				if ( index < 0 || index >= binding.Slots.Length ) { candidate.Warnings.Add( "A prefab material override references a missing FBX material slot." ); return; }
				var slot = binding.Slots[index];
				if ( !candidate.Slots.TryGetValue( slot, out var values ) ) candidate.Slots[slot] = values = new( StringComparer.OrdinalIgnoreCase );
				values.Add( material );
				if ( materialIds.Contains( material ) ) candidate.Materials.Add( material );
			}
			var blocks = Regex.Matches( text, @"(?ms)^--- !u!(?<type>[0-9]+) &[^\r\n]+\r?\n(?<body>.*?)(?=^--- !u!|\z)" ).Cast<Match>()
				.Select( m => (Type: m.Groups["type"].Value, Body: m.Groups["body"].Value) ).ToArray();
			var filters = new Dictionary<long, List<string>>();
			foreach ( var block in blocks )
			{
				var mesh = Field( block.Body, "m_Mesh" );
				var guid = GuidOf( mesh );
				if ( block.Type == "64" && models.ContainsKey( guid ) ) colliders.Add( guid );
				if ( block.Type is "33" or "137" && models.ContainsKey( guid ) )
				{
					visible.Add( guid );
					if ( block.Type == "33" )
					{
						var gameObject = IdOf( Field( block.Body, "m_GameObject" ) );
						if ( gameObject == 0 ) continue;
						if ( !filters.TryGetValue( gameObject, out var list ) ) filters[gameObject] = list = new();
						list.Add( mesh );
					}
				}
				if ( block.Type == "1001" )
				{
					var source = GuidOf( Field( block.Body, "m_SourcePrefab" ) );
					if ( models.ContainsKey( source ) ) visible.Add( source );
				}
			}
			foreach ( Match modification in Regex.Matches( text, @"(?ms)^\s*- target:\s*\{(?<target>[^}]+)\}\s*\r?\n(?<body>.*?)(?=^\s*- target:|^--- !u!|\z)" ) )
			{
				var target = modification.Groups["target"].Value;
				var guid = GuidOf( target );
				var property = Regex.Match( modification.Groups["body"].Value, @"propertyPath:\s*m_Materials\.Array\.data\[([0-9]+)\]" );
				if ( !property.Success || !models.TryGetValue( guid, out var asset ) ) continue;
				visible.Add( guid );
				if ( !int.TryParse( property.Groups[1].Value, out var index ) ) continue;
				Assign( guid, asset.ModelInfo.RendererSlots.GetValueOrDefault( IdOf( target ) ), index,
					GuidOf( Field( modification.Groups["body"].Value, "objectReference" ) ) );
			}
			// Explicit MeshRenderer + MeshFilter (or SkinnedMeshRenderer) blocks in non-variant prefabs.
			foreach ( var block in blocks.Where( b => b.Type is "23" or "137" ) )
			{
				var refs = block.Type == "137" ? new[] { Field( block.Body, "m_Mesh" ) } :
					filters.GetValueOrDefault( IdOf( Field( block.Body, "m_GameObject" ) ) )?.ToArray() ?? Array.Empty<string>();
				if ( refs.Length != 1 || !models.TryGetValue( GuidOf( refs[0] ), out var asset ) ) continue;
				var slots = Regex.Match( block.Body, @"(?m)^\s*m_Materials:[ \t]*\r?\n(?<items>(?:[ \t]*- \{[^}\r\n]*\}[ \t]*\r?\n)+)" );
				if ( !slots.Success ) continue;
				var meshId = IdOf( refs[0] );
				var bindings = asset.ModelInfo.RendererSlots.Values.Where( b => UnityFileId.Hash( "Type:Mesh->" + b.Mesh + "0" ) == meshId ).ToArray();
				var binding = bindings.Length == 1 ? bindings[0] : null;
				var index = 0;
				foreach ( Match reference in Regex.Matches( slots.Groups["items"].Value, @"\{([^}]+)\}" ) ) Assign( asset.Guid, binding, index++, GuidOf( reference.Value ) );
			}
			// Keep the prior single-model fallback for legacy prefabs without renderer-slot evidence.
			var references = UnityMaterial.References( text ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
			var modelIds = references.Where( g => models.ContainsKey( g ) && (!colliders.Contains( g ) || visible.Contains( g )) ).ToArray();
			if ( modelIds.Length == 1 && !For( modelIds[0] ).HasOverrides )
				foreach ( var material in references.Where( materialIds.Contains ) ) For( modelIds[0] ).Materials.Add( material );
			foreach ( var (guid, candidate) in local )
			{
				if ( !candidates.TryGetValue( guid, out var list ) ) candidates[guid] = list = new();
				list.Add( candidate );
			}
		}
		foreach ( var (guid, list) in candidates )
		{
			var asset = models[guid]; var model = asset.ModelInfo;
			var named = list.Where( p => Path.GetFileNameWithoutExtension( p.Path ).Equals( Path.GetFileNameWithoutExtension( asset.Path ), StringComparison.OrdinalIgnoreCase ) ).ToArray();
			var chosen = named.Length > 0 ? named : list.ToArray();
			foreach ( var slot in chosen.SelectMany( c => c.Slots.Keys ).Distinct( StringComparer.OrdinalIgnoreCase ) )
			{
				var values = chosen.SelectMany( c => c.Slots.GetValueOrDefault( slot ) ?? new HashSet<string>() ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
				if ( values.Length == 1 && materialIds.Contains( values[0] ) ) model.PrefabSlotMaterials[slot] = values[0];
				else
				{
					model.UnresolvedPrefabSlots.Add( slot );
					model.AssignmentWarnings.Add( $"Prefab assignment for slot '{slot}' is missing or conflicting; no material was guessed." );
				}
			}
			model.PrefabMaterials.AddRange( chosen.SelectMany( c => c.Materials ).Distinct( StringComparer.OrdinalIgnoreCase ) );
			model.AssignmentWarnings.AddRange( chosen.SelectMany( c => c.Warnings ).Distinct() );
		}
		foreach ( var guid in colliders.Where( g => !visible.Contains( g ) ) ) models[guid].ModelInfo.CollisionOnly = true;
	}
}
grubs.importunitypackage / Editor/UnityPackageMenu.cs
Editor library
using Editor;

namespace ImportUnityPackage;

public static class UnityPackageMenu
{
	[Menu( "Editor", "Import Unity Package/Import .unitypackage..." )]
	public static void Open()
	{
		var window = new UnityPackageWindow();
		window.Show();
		window.Browse();
	}
}
grubs.importunitypackage / Editor/Core/UnityArchive.cs
Editor library
using System;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;

namespace ImportUnityPackage;

public enum UnityAssetKind { Unsupported, Material, Texture, Model, ModelSupport }

public sealed class UnityAsset
{
	public string Guid { get; init; }
	public string Path { get; init; }
	public string Source { get; init; }
	public string Metadata { get; init; }
	public UnityAssetKind Kind { get; init; }
	public bool Selected { get; set; }
	UnityModel modelInfo;
	public UnityModel ModelInfo => modelInfo ??= UnityModel.Read( this );
}

public record ImportProgress( double Fraction, string Message, string Stage = null );

/// <summary>Reads Unity's GUID/asset, GUID/pathname, GUID/asset.meta tar layout.</summary>
public sealed class UnityArchive : IDisposable
{
	const long MaxEntryBytes = 2L * 1024 * 1024 * 1024;
	const long MaxTotalBytes = 32L * 1024 * 1024 * 1024;
	ImportWorkspace workspace;
	string scratch => workspace?.DirectoryPath;
	public bool HasExtractedFiles { get; private set; }
	public string[] CleanupWarnings => workspace?.Warnings.ToArray() ?? Array.Empty<string>();
	public string FileName { get; private set; }
	public List<UnityAsset> Assets { get; } = new();
	public List<string> Warnings { get; } = new();

	public static UnityArchive Read( string file, IProgress<ImportProgress> progress, CancellationToken cancel, string assetsDirectory )
	{
		ArgumentException.ThrowIfNullOrWhiteSpace( assetsDirectory );
		if ( !System.IO.Path.IsPathFullyQualified( assetsDirectory ) ) throw new ArgumentException( "An absolute project Assets path is required.", nameof( assetsDirectory ) );
		var package = new UnityArchive { FileName = file };
		try
		{
			package.workspace = new ImportWorkspace( assetsDirectory );
			package.HasExtractedFiles = true;
			using var input = File.OpenRead( file );
			using var gzip = new GZipStream( input, CompressionMode.Decompress );
			using var tar = new TarReader( gzip );
			var entries = new Dictionary<string, Dictionary<string, string>>( StringComparer.OrdinalIgnoreCase );
			long total = 0;
			int count = 0;
			TarEntry entry;
			while ( (entry = tar.GetNextEntry( false )) != null )
			{
				cancel.ThrowIfCancellationRequested();
				if ( ++count > 200000 ) throw new InvalidDataException( "Package has too many archive entries." );
				if ( entry.EntryType == TarEntryType.Directory ) continue;
				if ( entry.EntryType != TarEntryType.RegularFile && entry.EntryType != TarEntryType.V7RegularFile )
					throw new InvalidDataException( "Package contains unsupported links or special archive entries." );
				if ( entry.Length > MaxEntryBytes || (total += entry.Length) > MaxTotalBytes )
					throw new InvalidDataException( "Package exceeds the 2 GiB per file / 32 GiB extracted size limit." );
				var name = entry.Name.Replace( '\\', '/' );
				if ( name.StartsWith( "./", StringComparison.Ordinal ) ) name = name[2..];
				// Asset Store downloads include a package thumbnail outside the GUID records.
				if ( name == ".icon.png" ) continue;
				var parts = name.Split( '/' );
				if ( parts.Length == 2 && parts[0] == "packagemanagermanifest" && parts[1] is "asset" or "pathname" or "asset.meta" ) continue;
				if ( parts.Length != 2 || !Regex.IsMatch( parts[0], "^[0-9a-fA-F]{32}$" ) )
					throw new InvalidDataException( $"Invalid Unity archive entry: {entry.Name}" );
				if ( parts[1] is not ("asset" or "asset.meta" or "pathname" or "preview.png") ) continue;
				if ( parts[1] == "preview.png" ) continue;
				if ( parts[1] != "asset" && entry.Length > 16 * 1024 * 1024 )
					throw new InvalidDataException( "Package metadata is too large." );
				if ( !entries.TryGetValue( parts[0], out var record ) ) entries[parts[0]] = record = new();
				if ( record.ContainsKey( parts[1] ) ) throw new InvalidDataException( $"Duplicate archive entry: {name}" );
				var target = System.IO.Path.Combine( package.scratch, parts[0] + "-" + parts[1] + ".iup" );
				using ( var output = new FileStream( target, FileMode.CreateNew ) )
					Copy( entry.DataStream, output, cancel );
				record[parts[1]] = target;
				progress?.Report( new( (double)input.Position / Math.Max( 1, input.Length ), $"Reading package ({entries.Count:N0} entries)…" ) );
			}
			var paths = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
			foreach ( var (guid, record) in entries )
			{
				cancel.ThrowIfCancellationRequested();
				if ( !record.TryGetValue( "asset", out var source ) ) continue; // Unity folder metadata.
				if ( !record.TryGetValue( "pathname", out var pathname ) ) throw new InvalidDataException( $"Asset {guid} has no pathname." );
				// Unity writes the pathname on the first line, sometimes followed by a "00" trailer.
				var path = SafePath( (File.ReadLines( pathname, Encoding.UTF8 ).FirstOrDefault() ?? "").TrimEnd( '\0' ) );
				if ( !paths.Add( path ) ) throw new InvalidDataException( $"Duplicate asset path: {path}" );
				var kind = Classify( path );
				package.Assets.Add( new UnityAsset { Guid = guid, Path = path, Source = source,
					Metadata = record.GetValueOrDefault( "asset.meta" ), Kind = kind,
					Selected = kind != UnityAssetKind.Unsupported } );
			}
			package.Assets.Sort( (a, b) => StringComparer.OrdinalIgnoreCase.Compare( a.Path, b.Path ) );
			UnityModel.AssignPrefabMaterials( package );
			if ( package.Assets.Count == 0 ) throw new InvalidDataException( "This package contains no file assets." );
			return package;
		}
		catch ( Exception ex )
		{
			package.Dispose();
			if ( package.CleanupWarnings.Length > 0 ) ex.Data["StagingCleanupError"] = string.Join( "\n", package.CleanupWarnings );
			throw;
		}
	}

	public static string SafePath( string path )
	{
		path = path.Replace( '\\', '/' );
		if ( !path.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
			throw new InvalidDataException( $"Asset path must start with Assets/: {path}" );
		path = path[7..];
		foreach ( var part in path.Split( '/' ) )
		{
			var stem = part.Split( '.' )[0];
			if ( string.IsNullOrWhiteSpace( part ) || part is "." or ".." || part.EndsWith( '.' ) || part.EndsWith( ' ' ) ||
				part.Any( c => c < 32 || "<>:\"|?*".Contains( c ) ) ||
				Regex.IsMatch( stem, "^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$", RegexOptions.IgnoreCase ) )
				throw new InvalidDataException( $"Unsafe asset path: {path}" );
		}
		return path;
	}

	public static UnityAssetKind Classify( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch
	{
		".mat" or ".terrainlayer" => UnityAssetKind.Material,
		".png" or ".jpg" or ".jpeg" or ".tga" or ".tif" or ".tiff" or ".exr" or ".psd" => UnityAssetKind.Texture,
		".fbx" or ".obj" or ".smd" or ".dmx" or ".vox" => UnityAssetKind.Model,
		".mtl" => UnityAssetKind.ModelSupport,
		_ => UnityAssetKind.Unsupported
	};

	internal static void Copy( Stream source, Stream target, CancellationToken cancel )
	{
		if ( source == null ) return;
		var buffer = new byte[128 * 1024];
		int read;
		while ( (read = source.Read( buffer, 0, buffer.Length )) > 0 )
		{
			cancel.ThrowIfCancellationRequested();
			target.Write( buffer, 0, read );
		}
	}

	public void Dispose()
	{
		workspace?.Dispose();
		HasExtractedFiles = false;
	}
}
grubs.importunitypackage / Editor/Core/ImportMerge.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;

namespace ImportUnityPackage;

public sealed class ImportFileChoice
{
	public string Path { get; init; }
	public string Status { get; init; }
	public string Detail { get; init; }
	public bool Conflict { get; init; }
	public bool Replace { get; set; }
	internal string ExistingHash { get; init; }
	public string Action => ExistingHash == null ? "Add" : !Conflict ? "Reuse" : Replace ? "Replace" : "Keep existing";
}

/// <summary>One-off content comparison. No history, backups, automatic reports or rollback.</summary>
public sealed class ImportMergePlan : IDisposable
{
	readonly ImportPlan original;
	readonly ImportWorkspace workspace;
	ImportResult prepared;
	bool committed;
	public string Destination { get; }
	public List<ImportFileChoice> Outputs { get; } = new();
	public string[] CleanupWarnings => workspace.Warnings.ToArray();

	ImportMergePlan( ImportPlan plan, string assets )
	{
		original = plan;
		Destination = Path.GetFullPath( Path.Combine( assets, "Imported" ) );
		UnityImport.CheckDirectory( Destination );
		foreach ( var item in plan.Assets )
			if ( item.Asset.Path.Split( '/' )[0].Equals( ".iup-temp", StringComparison.OrdinalIgnoreCase ) )
				throw new InvalidDataException( $"Reserved importer output path: {item.Asset.Path}" );
		workspace = new ImportWorkspace( assets );
	}

	public static ImportMergePlan Create( ImportPlan plan, string assets, CancellationToken cancel = default )
	{
		cancel.ThrowIfCancellationRequested();
		return new( plan, assets );
	}

	public void Prepare( IProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extract = null, ExtractTextureChannels extractChannels = null )
	{
		if ( prepared != null ) throw new InvalidOperationException( "This import is already prepared." );
		prepared = UnityImport.PrepareFiles( original, Path.Combine( workspace.DirectoryPath, "output" ), Destination, progress, cancel, extract, extractChannels );
		progress?.Report( new( 1, "Comparing destination files…", "Checking conflicts" ) );
		foreach ( var file in prepared.Files )
		{
			cancel.ThrowIfCancellationRequested();
			var path = Path.GetRelativePath( prepared.Directory, file ).Replace( '\\', '/' );
			if ( !path.EndsWith( ".iup", StringComparison.Ordinal ) ) throw new InvalidDataException( "Invalid staging file" );
			path = path[..^4];
			var existing = HashFile( Target( path ), cancel );
			var incoming = HashFile( file, cancel );
			var conflict = existing != null && existing != incoming;
			Outputs.Add( new() { Path = path, ExistingHash = existing, Conflict = conflict,
				Status = existing == null ? "New" : conflict ? "Different" : "Identical",
				Detail = conflict ? "Keeping this file retains its current content; it may differ from the rest of the incoming package." : "" } );
		}
	}

	public ImportResult Commit( IProgress<ImportProgress> progress, CancellationToken cancel )
	{
		if ( prepared == null || committed ) throw new InvalidOperationException( "Prepare a new import before writing." );
		committed = true;
		// Recheck all reviewed files before the first write. Individual writes check again below.
		foreach ( var choice in Outputs )
			if ( HashFile( Target( choice.Path ), cancel ) != choice.ExistingHash ) throw new IOException( $"Destination changed since review: {choice.Path}. Review the import again." );
		var writes = Outputs.Where( c => c.ExistingHash == null || c.Conflict && c.Replace ).ToArray();
		var createdDirectories = new List<string>();
		try
		{
			for ( var i = 0; i < writes.Length; i++ )
			{
				cancel.ThrowIfCancellationRequested();
				var choice = writes[i];
				progress?.Report( new( (double)i / Math.Max( 1, writes.Length ), $"Writing {choice.Path}" ) );
				cancel.ThrowIfCancellationRequested();
				var target = Target( choice.Path );
				CreateOutputDirectories( Path.GetDirectoryName( target ), createdDirectories );
				// Staging is on the same filesystem. Rename a complete file; never leave a partial copy.
				ImportStorage.Retry( () =>
				{
					if ( HashFile( target, cancel ) != choice.ExistingHash ) throw new IOException( $"Destination changed while writing: {choice.Path}" );
					File.Move( Path.Combine( prepared.Directory, choice.Path + ".iup" ), target, choice.ExistingHash != null );
				}, cancel );
			}
		}
		finally { RemoveEmptyOutputDirectories( createdDirectories ); }
		var report = JsonNode.Parse( prepared.ReportJson );
		report["Destination"] = Destination;
		report["Files"] = JsonSerializer.SerializeToNode( Outputs.Select( c => c.Path ).ToArray() );
		report["Conflicts"] = JsonSerializer.SerializeToNode( Outputs.Where( c => c.Conflict ).Select( c => new { c.Path, c.Action } ).ToArray() );
		progress?.Report( new( 1, "Files imported" ) );
		return prepared with { Directory = Destination, Files = Outputs.Select( c => Target( c.Path ) ).ToArray(),
			ReportJson = report.ToJsonString( new JsonSerializerOptions { WriteIndented = true } ), ChangedFiles = writes.Select( c => Target( c.Path ) ).ToArray() };
	}

	void CreateOutputDirectories( string directory, List<string> created )
	{
		var missing = new Stack<string>();
		while ( !Directory.Exists( directory ) && directory.StartsWith( Destination + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )
		{
			missing.Push( directory );
			directory = Path.GetDirectoryName( directory );
		}
		foreach ( var path in missing )
		{
			UnityImport.CheckDirectory( path );
			if ( Directory.Exists( path ) ) continue;
			Directory.CreateDirectory( path );
			created.Add( path );
		}
	}

	void RemoveEmptyOutputDirectories( List<string> created )
	{
		// Only this run's new directories, deepest first. Never delete recursively or undo completed writes.
		foreach ( var path in created.AsEnumerable().Reverse() )
		{
			try
			{
				UnityImport.CheckDirectory( path );
				if ( Directory.Exists( path ) && !Directory.EnumerateFileSystemEntries( path ).Any() )
					ImportStorage.Retry( () => Directory.Delete( path, false ), CancellationToken.None );
			}
			catch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )
			{
				workspace.Warnings.Add( $"Empty output folder could not be removed: {path}. {ex.Message}" );
			}
		}
	}

	string Target( string path )
	{
		var safe = UnityArchive.SafePath( "Assets/" + path );
		var target = Path.GetFullPath( Path.Combine( Destination, safe ) );
		UnityImport.CheckDirectory( target );
		if ( Directory.Exists( target ) ) throw new IOException( $"A directory occupies the file destination: {path}" );
		return target;
	}

	public static string HashFile( string path, CancellationToken cancel = default )
	{
		if ( !File.Exists( path ) ) return null;
		using var input = File.OpenRead( path );
		using var hash = IncrementalHash.CreateHash( HashAlgorithmName.SHA256 );
		var buffer = new byte[128 * 1024];
		int count;
		while ( (count = input.Read( buffer, 0, buffer.Length )) > 0 ) { cancel.ThrowIfCancellationRequested(); hash.AppendData( buffer, 0, count ); }
		return Convert.ToHexString( hash.GetHashAndReset() ).ToLowerInvariant();
	}

	public void Dispose() => workspace.Dispose();
}
grubs.importunitypackage / Editor/Core/ImportPlan.cs
Editor library
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;

namespace ImportUnityPackage;

public sealed record ImportDependency( UnityAsset Asset, string Reason );
public sealed record PlannedAsset( UnityAsset Asset, bool Explicit, ImportDependency[] RequiredBy, bool Vmat, bool Tmat, bool Vmdl )
{
	public string OutputLabel => Asset.Kind switch
	{
		UnityAssetKind.Model => Asset.ModelInfo.CollisionOnly ? "Collision source (FBX)" : Vmdl ? "Model → VMDL" : "Model source",
		UnityAssetKind.Material => Vmat && Tmat ? "Material → VMAT + TMAT" : Vmat ? "Material → VMAT" : Tmat ? "Terrain material → TMAT" : "Material source",
		UnityAssetKind.Texture => UnityImport.NeedsTextureResource( Asset.Path ) ? "Texture → VTEX" : "Texture",
		_ => "Model support"
	};
}

/// <summary>A selection snapshot shared by the window, importer and import report.</summary>
public sealed class ImportPlan
{
	public UnityArchive Archive { get; }
	public ImportOptions Options { get; }
	public IReadOnlyList<PlannedAsset> Assets { get; }
	public IReadOnlyList<ImportIssue> Issues { get; }
	readonly Dictionary<UnityAsset, PlannedAsset> byAsset;
	internal ImportPlan( UnityArchive archive, ImportOptions options, PlannedAsset[] assets, ImportIssue[] issues )
	{
		Archive = archive; Options = options; Assets = Array.AsReadOnly( assets ); Issues = Array.AsReadOnly( issues );
		byAsset = assets.ToDictionary( a => a.Asset );
	}
	public PlannedAsset Find( UnityAsset asset ) => byAsset.GetValueOrDefault( asset );
}

/// <summary>Read dependency evidence once, then resolve selections without disk access.</summary>
public sealed class ImportCatalog
{
	readonly UnityArchive archive;
	readonly Dictionary<UnityAsset, List<ImportDependency>> dependencies = new();
	readonly Dictionary<UnityAsset, List<ImportIssue>> issues = new();
	ImportCatalog( UnityArchive archive ) { this.archive = archive; }

	public static ImportCatalog Read( UnityArchive archive, CancellationToken cancel = default )
	{
		var catalog = new ImportCatalog( archive );
		var byGuid = archive.Assets.ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );
		var materials = new Dictionary<UnityAsset, UnityMaterial>();
		void Issue( UnityAsset asset, string code, string message )
		{
			if ( !catalog.issues.TryGetValue( asset, out var list ) ) catalog.issues[asset] = list = new();
			list.Add( new( asset.Path, code, message ) );
		}
		void Link( UnityAsset parent, UnityAsset child, string reason )
		{
			if ( !catalog.dependencies.TryGetValue( parent, out var list ) ) catalog.dependencies[parent] = list = new();
			if ( !list.Any( d => d.Asset == child && d.Reason == reason ) ) list.Add( new( child, reason ) );
		}
		foreach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ) )
		{
			cancel.ThrowIfCancellationRequested();
			try
			{
				var material = UnityMaterial.Parse( UnityImport.ReadText( asset.Source ) );
				if ( !string.IsNullOrEmpty( material.ShaderGuid ) && byGuid.TryGetValue( material.ShaderGuid, out var shader ) && shader.Path.EndsWith( ".shader", StringComparison.OrdinalIgnoreCase ) )
					material.ConfigureShader( UnityImport.ReadText( shader.Source ) );
				materials[asset] = material;
				foreach ( var guid in material.Textures.Values.Distinct( StringComparer.OrdinalIgnoreCase ) )
				{
					if ( byGuid.TryGetValue( guid, out var texture ) && texture.Kind == UnityAssetKind.Texture ) Link( asset, texture, "Texture reference" );
					else Issue( asset, "missing-texture", $"Missing or unsupported texture {guid}." );
				}
			}
			catch ( InvalidDataException ex ) { Issue( asset, "conversion-skipped", $"Conversion skipped. {ex.Message}" ); }
		}
		var byName = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).GroupBy( a => Path.GetFileNameWithoutExtension( a.Path ), StringComparer.OrdinalIgnoreCase )
			.ToDictionary( g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase );
		var byTexture = materials.SelectMany( m => m.Value.ColorTextureGuids.Where( byGuid.ContainsKey ).Select( g => (Name: Path.GetFileNameWithoutExtension( byGuid[g].Path ), Asset: m.Key) ) )
			.GroupBy( m => m.Name, StringComparer.OrdinalIgnoreCase ).ToDictionary( g => g.Key, g => g.Select( m => m.Asset ).Distinct().ToArray(), StringComparer.OrdinalIgnoreCase );
		foreach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ) )
		{
			cancel.ThrowIfCancellationRequested();
			if ( asset.ModelInfo.CollisionOnly ) continue;
			foreach ( var warning in asset.ModelInfo.AssignmentWarnings ) Issue( asset, "prefab-material-assignment", warning );
			var referencedMaterials = new HashSet<UnityAsset>();
			void Match( UnityAsset[] candidates, string reason )
			{
				var referenced = candidates.Where( referencedMaterials.Contains ).ToArray();
				if ( referenced.Length > 0 ) candidates = referenced;
				if ( candidates.Length == 1 ) Link( asset, candidates[0], reason + " (inferred)" );
				else if ( candidates.Length > 1 ) Issue( asset, "ambiguous-material", $"{reason} matches multiple materials; no dependency was inferred." );
			}
			var refs = asset.ModelInfo.PrefabMaterials.AsEnumerable();
			if ( asset.Metadata != null ) refs = refs.Concat( UnityMaterial.References( UnityImport.ReadText( asset.Metadata ) ) );
			foreach ( var guid in refs.Distinct( StringComparer.OrdinalIgnoreCase ) )
				if ( byGuid.TryGetValue( guid, out var dependency ) && dependency.Kind is UnityAssetKind.Material or UnityAssetKind.Texture )
				{
					Link( asset, dependency, "Model / prefab reference" );
					if ( dependency.Kind == UnityAssetKind.Material ) referencedMaterials.Add( dependency );
				}
			foreach ( var filename in asset.ModelInfo.MaterialAlbedoFiles.Values ) Match( byTexture.GetValueOrDefault( Path.GetFileNameWithoutExtension( filename ), Array.Empty<UnityAsset>() ), $"Color texture {filename}" );
			foreach ( var slot in asset.ModelInfo.RenderedMaterials.Where( s => !asset.ModelInfo.PrefabSlotMaterials.ContainsKey( s ) && !asset.ModelInfo.UnresolvedPrefabSlots.Contains( s ) ) ) Match( byName.GetValueOrDefault( slot, Array.Empty<UnityAsset>() ), $"Material slot {slot}" );
			Match( byName.GetValueOrDefault( Path.GetFileNameWithoutExtension( asset.Path ), Array.Empty<UnityAsset>() ), "Model filename" );
		}
		return catalog;
	}

	public ImportPlan CreatePlan( ImportOptions options )
	{
		var explicitAssets = archive.Assets.Where( a => a.Selected && UnityImport.IsEnabled( a, options ) ).ToHashSet();
		var included = new HashSet<UnityAsset>( explicitAssets );
		var requiredBy = new Dictionary<UnityAsset, List<ImportDependency>>();
		var queue = new Queue<UnityAsset>( included );
		while ( queue.TryDequeue( out var parent ) )
		{
			if ( !options.Materials || !dependencies.TryGetValue( parent, out var children ) ) continue;
			foreach ( var child in children )
			{
				if ( !requiredBy.TryGetValue( child.Asset, out var parents ) ) requiredBy[child.Asset] = parents = new();
				parents.Add( new( parent, child.Reason ) );
				if ( included.Add( child.Asset ) ) queue.Enqueue( child.Asset );
			}
		}
		var entries = included.OrderBy( a => a.Kind == UnityAssetKind.Model ? 1 : 0 ).ThenBy( a => a.Path, StringComparer.OrdinalIgnoreCase ).Select( a =>
		{
			var parents = requiredBy.GetValueOrDefault( a )?.ToArray() ?? Array.Empty<ImportDependency>();
			var terrain = a.Path.EndsWith( ".terrainlayer", StringComparison.OrdinalIgnoreCase );
			var modelUse = parents.Any( p => p.Asset.Kind == UnityAssetKind.Model );
			return new PlannedAsset( a, explicitAssets.Contains( a ), parents,
				a.Kind == UnityAssetKind.Material && options.Vmat && (!options.Automatic || !terrain || modelUse),
				a.Kind == UnityAssetKind.Material && (options.Tmat || options.Automatic && terrain),
				a.Kind == UnityAssetKind.Model && options.Vmdl && !a.ModelInfo.CollisionOnly );
		} ).ToArray();
		return new( archive, options, entries, entries.Where( a => a.Vmat || a.Tmat || a.Vmdl && options.Materials )
			.Where( a => issues.ContainsKey( a.Asset ) ).SelectMany( a => issues[a.Asset] ).Distinct().ToArray() );
	}
}
grubs.importunitypackage / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Import Unity Package" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "importunitypackage" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "grubs" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "grubs.importunitypackage" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "29" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-09-16T05:10:22.2627195Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.112.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.112.0")]
Debug: View Raw JSON Response
{
    "TotalCount": 22,
    "Files": [
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/ImportStorage.cs",
            "FileName": "ImportStorage.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\r\nusing System.IO;\r\nusing System.Threading;\r\n\r\nnamespace ImportUnityPackage;\r\n\r\n/// <summary>Bounded retries for Windows sharing/access errors during directory finalization.</summary>\r\ninternal static class ImportStorage\r\n{\r\n\tinternal static bool IsTemporaryAccessError( Exception exception ) =>\r\n\t\texception is IOException or UnauthorizedAccessException && (exception.HResult & 0xffff) is 5 or 32 or 33;\r\n\r\n\tinternal static void Retry( Action operation, CancellationToken cancel, Action<int> retrying = null )\r\n\t{\r\n\t\tfor ( var attempt = 0; ; attempt++ )\r\n\t\t{\r\n\t\t\tcancel.ThrowIfCancellationRequested();\r\n\t\t\ttry { operation(); return; }\r\n\t\t\tcatch ( Exception ex ) when ( IsTemporaryAccessError( ex ) && attempt < 6 )\r\n\t\t\t{\r\n\t\t\t\tretrying?.Invoke( attempt + 1 );\r\n\t\t\t\t// Maximum wait is 4.05 seconds. A cancellation interrupts the wait immediately.\r\n\t\t\t\tif ( cancel.WaitHandle.WaitOne( Math.Min( 150 << attempt, 1000 ) ) ) cancel.ThrowIfCancellationRequested();\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/GeneratedAssetFiles.cs",
            "FileName": "GeneratedAssetFiles.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.RegularExpressions;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Names derived assets beside their sources, reusing matching content without an on-disk index.</summary>\ninternal sealed class GeneratedAssetFiles\n{\n\treadonly string scratch;\n\treadonly string destination;\n\treadonly HashSet<string> reserved;\n\treadonly List<(string Token, string Preferred, string File)> pending = new();\n\treadonly Dictionary<string, string> resolved = new();\n\treadonly HashSet<string> assigned = new( StringComparer.OrdinalIgnoreCase );\n\treadonly Dictionary<string, Dictionary<int, string>> existing = new( StringComparer.OrdinalIgnoreCase );\n\n\tpublic GeneratedAssetFiles( ImportPlan plan, string stage, string destination )\n\t{\n\t\tthis.destination = destination;\n\t\tscratch = Path.Combine( Path.GetDirectoryName( stage ), \"generated\" );\n\t\treserved = plan.Archive.Assets.Select( a => a.Path ).ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var item in plan.Assets )\n\t\t{\n\t\t\tif ( item.Vmat ) reserved.Add( Path.ChangeExtension( item.Asset.Path, \".vmat\" ) );\n\t\t\tif ( item.Tmat ) reserved.Add( Path.ChangeExtension( item.Asset.Path, \".tmat\" ) );\n\t\t\tif ( item.Vmdl ) reserved.Add( Path.ChangeExtension( item.Asset.Path, \".vmdl\" ) );\n\t\t\tif ( UnityImport.NeedsTextureResource( item.Asset.Path ) ) reserved.Add( item.Asset.Path + \".vtex\" );\n\t\t}\n\t}\n\n\tpublic (string Reference, string File) Add( string source, string purpose, string extension )\n\t{\n\t\tDirectory.CreateDirectory( scratch );\n\t\tvar token = $\"iup:generated:{pending.Count}\";\n\t\tvar file = Path.Combine( scratch, pending.Count + \".iup\" );\n\t\tvar preferred = Path.ChangeExtension( source, null ) + \"_iup_\" + purpose + extension;\n\t\tpending.Add( (token, preferred, file) );\n\t\treturn (token, file);\n\t}\n\n\tpublic void Complete( Func<string, string> output, CancellationToken cancel )\n\t{\n\t\tforeach ( var item in pending )\n\t\t{\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\tvar hash = ImportMergePlan.HashFile( item.File, cancel ) ?? throw new IOException( \"Generated asset is missing: \" + item.Preferred );\n\t\t\tvar candidates = Existing( item.Preferred, cancel );\n\t\t\tstring Name( int number ) => number == 0 ? item.Preferred : Path.ChangeExtension( item.Preferred, null ) + \"_\" + number + Path.GetExtension( item.Preferred );\n\t\t\t// Look for matching numbered variants even when an earlier suffix has been deleted.\n\t\t\tvar match = candidates.Where( p => p.Value == hash ).OrderBy( p => p.Key ).Select( p => Name( p.Key ) )\n\t\t\t\t.FirstOrDefault( path => !reserved.Contains( path ) );\n\t\t\tvar chosen = match;\n\t\t\tif ( chosen == null )\n\t\t\t{\n\t\t\t\tvar number = 0;\n\t\t\t\twhile ( true )\n\t\t\t\t{\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\t\tchosen = Name( number );\n\t\t\t\t\tif ( !reserved.Contains( chosen ) && !candidates.ContainsKey( number ) && !Directory.Exists( Path.Combine( destination, chosen ) ) ) break;\n\t\t\t\t\tnumber++;\n\t\t\t\t}\n\t\t\t\tcandidates[number] = hash;\n\t\t\t}\n\t\t\tresolved[item.Token] = \"Imported/\" + chosen;\n\t\t\tif ( assigned.Add( chosen ) ) File.Move( item.File, output( chosen ) );\n\t\t\telse File.Delete( item.File );\n\t\t}\n\t}\n\n\tDictionary<int, string> Existing( string preferred, CancellationToken cancel )\n\t{\n\t\tif ( existing.TryGetValue( preferred, out var result ) ) return result;\n\t\tresult = new();\n\t\tvar directory = Path.GetDirectoryName( Path.Combine( destination, preferred ) );\n\t\tUnityImport.CheckDirectory( directory );\n\t\tif ( Directory.Exists( directory ) )\n\t\t{\n\t\t\tvar pattern = new Regex( \"^\" + Regex.Escape( Path.GetFileNameWithoutExtension( preferred ) ) + @\"(?:_([1-9][0-9]*))?\" + Regex.Escape( Path.GetExtension( preferred ) ) + \"$\", RegexOptions.IgnoreCase );\n\t\t\tforeach ( var file in Directory.EnumerateFiles( directory ) )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tvar number = Number( file, pattern );\n\t\t\t\tif ( number < 0 ) continue;\n\t\t\t\tUnityImport.CheckDirectory( file );\n\t\t\t\tresult[number] = ImportMergePlan.HashFile( file, cancel );\n\t\t\t}\n\t\t}\n\t\texisting[preferred] = result;\n\t\treturn result;\n\t}\n\n\tstatic int Number( string path, Regex pattern )\n\t{\n\t\tvar match = pattern.Match( Path.GetFileName( path ) );\n\t\tif ( !match.Success ) return -1;\n\t\treturn !match.Groups[1].Success ? 0 : int.TryParse( match.Groups[1].Value, out var number ) ? number : -1;\n\t}\n\n\tpublic string ResolveDocument( string text ) => Regex.Replace( text, \"\\\"iup:generated:[0-9]+\\\"\", m => UnityMaterial.Quote( resolved[m.Value[1..^1]] ) );\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityImport.cs",
            "FileName": "UnityImport.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\npublic record ImportOptions( bool Materials, bool Models, bool Vmat, bool Tmat, bool Vmdl )\n{\n\tpublic bool Automatic { get; init; }\n\tpublic static ImportOptions Auto( bool additionalTerrainMaterials = false ) => new( true, true, true, additionalTerrainMaterials, true ) { Automatic = true };\n}\npublic record ImportIssue( string Asset, string Code, string Message )\n{\n\t// Compatibility fallbacks are warnings; failing to produce a selected resource is an error.\n\tpublic string Severity => Code == \"conversion-skipped\" ? \"Error\" : \"Warning\";\n}\npublic record ImportResult( string Directory, int AssetCount, int ConvertedCount, string[] Files, string[] Warnings )\n{\n\tpublic ImportIssue[] Unresolved { get; init; } = Array.Empty<ImportIssue>();\n\tpublic string ReportJson { get; init; }\n\tpublic void ExportReport( string path ) => File.WriteAllText( path, ReportJson );\n\tpublic string[] ChangedFiles { get; init; } = Array.Empty<string>();\n\tpublic string[] Information { get; init; } = Array.Empty<string>();\n}\npublic record TextureChannelRequest( string Source, string Destination, int Channel, double Scale, bool Invert );\npublic delegate void ExtractTextureChannels( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel );\npublic delegate byte[] ExtractTextureChannel( string source, int channel, double scale, bool invert, CancellationToken cancel );\n\npublic static class UnityImport\n{\n\tpublic static bool NeedsTextureResource( string path ) => Path.GetExtension( path ).ToLowerInvariant() is \".exr\" or \".psd\" or \".tif\" or \".tiff\";\n\tpublic static bool IsEnabled( UnityAsset asset, ImportOptions options ) => asset.Kind switch\n\t{\n\t\tUnityAssetKind.Material or UnityAssetKind.Texture => options.Materials,\n\t\tUnityAssetKind.Model or UnityAssetKind.ModelSupport => options.Models,\n\t\t_ => false\n\t};\n\n\tpublic static HashSet<UnityAsset> Selection( UnityArchive archive, ImportOptions options ) =>\n\t\tImportCatalog.Read( archive ).CreatePlan( options ).Assets.Select( a => a.Asset ).ToHashSet();\n\n\tinternal static string ReadText( string path )\n\t{\n\t\tif ( new FileInfo( path ).Length > 16 * 1024 * 1024 ) throw new InvalidDataException( \"Material or metadata exceeds the 16 MiB text limit.\" );\n\t\treturn File.ReadAllText( path );\n\t}\n\n\tpublic static ImportResult Run( UnityArchive archive, string assetsDirectory, ImportOptions options,\n\t\tIProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel = null, ExtractTextureChannels extractChannels = null )\n\t\t=> Run( ImportCatalog.Read( archive, cancel ).CreatePlan( options ), assetsDirectory, progress, cancel, extractChannel, extractChannels );\n\n\tpublic static ImportResult Run( ImportPlan plan, string assetsDirectory,\n\t\tIProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel = null, ExtractTextureChannels extractChannels = null )\n\t{\n\t\tusing var merge = ImportMergePlan.Create( plan, assetsDirectory, cancel );\n\t\tmerge.Prepare( progress, cancel, extractChannel, extractChannels );\n\t\tvar result = merge.Commit( progress, cancel );\n\t\tmerge.Dispose();\n\t\treturn result with { Warnings = result.Warnings.Concat( merge.CleanupWarnings ).Distinct().ToArray() };\n\t}\n\n\tinternal static ImportResult PrepareFiles( ImportPlan plan, string stageDirectory, string destination,\n\t\tIProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extractChannel, ExtractTextureChannels extractChannels = null )\n\t{\n\t\tcancel.ThrowIfCancellationRequested();\n\t\tvar archive = plan.Archive;\n\t\tvar options = plan.Options;\n\t\tvar selected = plan.Assets.Select( a => a.Asset ).ToArray();\n\t\tif ( selected.Length == 0 ) throw new InvalidOperationException( \"Select at least one supported asset.\" );\n\t\tvar stage = Path.GetFullPath( stageDirectory );\n\t\tCheckDirectory( stage );\n\t\tconst string prefix = \"Imported/\";\n\t\tvar warnings = new List<string>( archive.Warnings );\n\t\tvar information = new List<string>();\n\t\tvar unresolved = new List<ImportIssue>( plan.Issues );\n\t\twarnings.AddRange( plan.Issues.Select( i => $\"{i.Asset}: {i.Message}\" ) );\n\t\tvoid Issue( string asset, string code, string message )\n\t\t{\n\t\t\tunresolved.Add( new( asset, code, message ) );\n\t\t\twarnings.Add( $\"{asset}: {message}\" );\n\t\t}\n\t\tvar files = new List<string>();\n\t\tvar outputs = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar textures = selected.Where( a => a.Kind == UnityAssetKind.Texture ).ToDictionary( a => a.Guid, a => prefix + a.Path, StringComparer.OrdinalIgnoreCase );\n\t\tvar textureSources = selected.Where( a => a.Kind == UnityAssetKind.Texture ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );\n\t\tvar generatedFiles = new GeneratedAssetFiles( plan, stage, destination );\n\t\tvar documents = new List<(string File, string Text)>();\n\t\tvar generatedChannels = new Dictionary<string, string>();\n\t\tvar channelRequests = new List<TextureChannelRequest>();\n\t\tvar channelTimer = new System.Diagnostics.Stopwatch();\n\t\tvar shaders = archive.Assets.Where( a => a.Path.EndsWith( \".shader\", StringComparison.OrdinalIgnoreCase ) ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );\n\t\tvar shaderText = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar convertedMaterials = new Dictionary<string, UnityMaterial>( StringComparer.OrdinalIgnoreCase );\n\t\tvar conversions = 0;\n\t\tvar operation = \"creating the staging folder\";\n\t\tstring currentAsset = null;\n\t\tException failure = null;\n\t\tvar preserveStage = false;\n\t\ttry\n\t\t{\n\t\t\tDirectory.CreateDirectory( stage );\n\t\t\tstring Output( string relative )\n\t\t\t{\n\t\t\t\tif ( !outputs.Add( relative ) ) throw new InvalidDataException( $\"Two assets generate the same output: {relative}\" );\n\t\t\t\tvar full = Path.GetFullPath( Path.Combine( stage, relative + \".iup\" ) );\n\t\t\t\tif ( !full.StartsWith( stage + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) ) throw new InvalidDataException( \"Invalid output path.\" );\n\t\t\t\tDirectory.CreateDirectory( Path.GetDirectoryName( full ) );\n\t\t\t\tfiles.Add( relative );\n\t\t\t\treturn full;\n\t\t\t}\n\t\t\tfor ( var i = 0; i < selected.Length; i++ )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tvar asset = selected[i];\n\t\t\t\toperation = \"writing imported files\";\n\t\t\t\tcurrentAsset = asset.Path;\n\t\t\t\tvar planned = plan.Find( asset );\n\t\t\t\tif ( asset.Kind == UnityAssetKind.Model && asset.ModelInfo.CollisionOnly )\n\t\t\t\t\tinformation.Add( $\"{asset.Path}: used only by Unity MeshColliders; kept as a collision source without a visible VMDL. Physics setup is not recreated.\" );\n\t\t\t\tprogress?.Report( new( 0.8 * i / selected.Length, $\"Preparing {asset.Path}\", \"Preparing assets\" ) );\n\t\t\t\tusing ( var source = File.OpenRead( asset.Source ) )\n\t\t\t\tusing ( var target = new FileStream( Output( asset.Path ), FileMode.CreateNew ) )\n\t\t\t\t\t\tUnityArchive.Copy( source, target, cancel );\n\t\t\t\tif ( asset.Kind == UnityAssetKind.Texture && NeedsTextureResource( asset.Path ) )\n\t\t\t\t{\n\t\t\t\t\t// Keep source pixels/HDR data intact and give the editor a loadable texture resource.\n\t\t\t\t\tFile.WriteAllText( Output( asset.Path + \".vtex\" ), JsonSerializer.Serialize( new\n\t\t\t\t\t{\n\t\t\t\t\t\tImages = new[] { prefix + asset.Path }, InputColorSpace = \"Linear\", OutputColorSpace = \"Linear\",\n\t\t\t\t\t\tOutputFormat = asset.Path.EndsWith( \".exr\", StringComparison.OrdinalIgnoreCase ) ? \"RGBA16161616F\" : \"BC7\",\n\t\t\t\t\t\tOutputMipAlgorithm = \"Box\", OutputTypeString = \"2D\"\n\t\t\t\t\t}, new JsonSerializerOptions { WriteIndented = true } ) );\n\t\t\t\t\tconversions++;\n\t\t\t\t}\n\t\t\t\tif ( planned.Vmat || planned.Tmat )\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t{\n\t\t\t\t\t\tvar material = UnityMaterial.Parse( ReadText( asset.Source ) );\n\t\t\t\t\t\tif ( material.ShaderGuid != null && shaders.TryGetValue( material.ShaderGuid, out var shader ) )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif ( !shaderText.TryGetValue( shader.Guid, out var source ) ) shaderText[shader.Guid] = source = ReadText( shader.Source );\n\t\t\t\t\t\t\tmaterial.ConfigureShader( source );\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ( extractChannel != null || extractChannels != null )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmaterial.PrepareChannels( (guid, channel, scale, invert) =>\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tif ( !textureSources.TryGetValue( guid, out var source ) )\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tIssue( asset.Path, \"missing-texture\", $\"Missing or unsupported texture {guid}.\" );\n\t\t\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tvar key = $\"{guid}_{channel}_{scale.ToString( System.Globalization.CultureInfo.InvariantCulture )}_{invert}\";\n\t\t\t\t\t\t\t\tif ( !generatedChannels.TryGetValue( key, out var generated ) )\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tvar purpose = channel switch { 0 => \"metallic\", 1 => \"ao\", 2 => \"height\", _ => invert ? \"roughness\" : \"opacity\" };\n\t\t\t\t\t\t\t\t\tvar derived = generatedFiles.Add( source.Path, purpose, \".png\" );\n\t\t\t\t\t\t\t\t\tgenerated = derived.Reference;\n\t\t\t\t\t\t\t\t\tvar output = derived.File;\n\t\t\t\t\t\t\t\t\tif ( extractChannels != null ) channelRequests.Add( new( source.Source, output, channel, scale, invert ) );\n\t\t\t\t\t\t\t\t\telse File.WriteAllBytes( output, extractChannel( source.Source, channel, scale, invert, cancel ) );\n\t\t\t\t\t\t\t\t\tgeneratedChannels[key] = generated;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn generated;\n\t\t\t\t\t\t\t} );\n\t\t\t\t\t\t}\n\t\t\t\t\t\tstring Resolve( string guid )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tif ( textures.TryGetValue( guid, out var texture ) ) return texture;\n\t\t\t\t\t\t\tIssue( asset.Path, \"missing-texture\", $\"Missing or unsupported texture {guid}.\" );\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ( planned.Vmat ) { documents.Add( (Output( Path.ChangeExtension( asset.Path, \".vmat\" ) ), material.ToVmat( Resolve )) ); conversions++; }\n\t\t\t\t\t\tconvertedMaterials[asset.Guid] = material;\n\t\t\t\t\t\tif ( planned.Tmat ) { documents.Add( (Output( Path.ChangeExtension( asset.Path, \".tmat\" ) ), material.ToTmat( Resolve )) ); conversions++; }\n\t\t\t\t\t\twarnings.AddRange( material.Warnings.Select( w => $\"{asset.Path}: {w}\" ) );\n\t\t\t\t\t\tinformation.AddRange( material.Information.Select( m => $\"{asset.Path}: {m}\" ) );\n\t\t\t\t\t}\n\t\t\t\t\tcatch ( InvalidDataException ex ) { Issue( asset.Path, \"conversion-skipped\", $\"Conversion skipped. {ex.Message}\" ); }\n\t\t\t\t}\n\t\t\t\tif ( planned.Vmdl )\n\t\t\t\t{\n\t\t\t\t\tvar meshPath = prefix + asset.Path;\n\t\t\t\t\tif ( asset.Path.EndsWith( \".fbx\", StringComparison.OrdinalIgnoreCase ) && UnityFbxCompatibility.Normalize( asset.Source, cancel ) is byte[] normalized )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar repaired = generatedFiles.Add( asset.Path, \"repaired\", \".fbx\" );\n\t\t\t\t\t\tFile.WriteAllBytes( repaired.File, normalized );\n\t\t\t\t\t\tmeshPath = repaired.Reference;\n\t\t\t\t\t\twarnings.Add( $\"{asset.Path}: removed an exact duplicate vertex array after a closing brace in a derived FBX; original source preserved.\" );\n\t\t\t\t\t}\n\t\t\t\t\tvar info = asset.ModelInfo;\n\t\t\t\t\tvar scale = asset.Path.EndsWith( \".fbx\", StringComparison.OrdinalIgnoreCase ) ? info.ImportScale( asset.Metadata == null ? null : ReadText( asset.Metadata ) ) : 1;\n\t\t\t\t\tvar remaps = files.Where( f => f.EndsWith( \".vmat\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\t\t.GroupBy( Path.GetFileNameWithoutExtension, StringComparer.OrdinalIgnoreCase )\n\t\t\t\t\t\t.Where( g => g.Count() == 1 ).ToDictionary( g => g.Key.ToLowerInvariant() + \".vmat\", g => prefix + g.Single(), StringComparer.OrdinalIgnoreCase );\n\t\t\t\t\tvar meshName = Path.GetFileNameWithoutExtension( asset.Path ).ToLowerInvariant();\n\t\t\t\t\tif ( remaps.TryGetValue( meshName + \".vmat\", out var matchingMaterial ) )\n\t\t\t\t\t{\n\t\t\t\t\t\t// Some exporters append an LOD suffix to slots that share the model's base material.\n\t\t\t\t\t\tfor ( var lod = 0; lod <= 8; lod++ ) remaps.TryAdd( $\"{meshName}_lod{lod}.vmat\", matchingMaterial );\n\t\t\t\t\t}\n\t\t\t\t\tvar prefabMaterials = selected.Where( a => info.PrefabMaterials.Contains( a.Guid, StringComparer.OrdinalIgnoreCase ) &&\n\t\t\t\t\t\toutputs.Contains( Path.ChangeExtension( a.Path, \".vmat\" ) ) ).ToArray();\n\t\t\t\t\tforeach ( var slot in info.Materials )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar semantic = prefabMaterials.Where( a => UnityModel.Normalize( Path.GetFileNameWithoutExtension( a.Path ) ) == UnityModel.Normalize( slot ) ).ToArray();\n\t\t\t\t\t\tif ( semantic.Length == 1 ) remaps[slot.ToLowerInvariant() + \".vmat\"] = prefix + Path.ChangeExtension( semantic[0].Path, \".vmat\" );\n\t\t\t\t\t}\n\t\t\t\t\tforeach ( var (slot, albedoFile) in info.MaterialAlbedoFiles )\n\t\t\t\t\t{\n\t\t\t\t\t\tif ( remaps.ContainsKey( slot.ToLowerInvariant() + \".vmat\" ) ) continue;\n\t\t\t\t\t\tvar candidates = (prefabMaterials.Length > 0 ? prefabMaterials : selected.Where( a => a.Kind == UnityAssetKind.Material ).ToArray())\n\t\t\t\t\t\t\t.Where( a => convertedMaterials.TryGetValue( a.Guid, out var material ) && material.ColorTextureGuids.Any( guid =>\n\t\t\t\t\t\t\t\ttextureSources.TryGetValue( guid, out var texture ) && Path.GetFileNameWithoutExtension( texture.Path ).Equals(\n\t\t\t\t\t\t\t\t\tPath.GetFileNameWithoutExtension( albedoFile ), StringComparison.OrdinalIgnoreCase ) ) ).ToArray();\n\t\t\t\t\t\t// Unity often keeps the old diffuse filename as the material name\n\t\t\t\t\t\t// after repacking its texture. Require a unique prefab-assigned material.\n\t\t\t\t\t\tif ( candidates.Length == 0 ) candidates = prefabMaterials.Where( a =>\n\t\t\t\t\t\t\tPath.GetFileNameWithoutExtension( a.Path ).Equals( Path.GetFileNameWithoutExtension( albedoFile ), StringComparison.OrdinalIgnoreCase ) ).ToArray();\n\t\t\t\t\t\tif ( candidates.Length == 1 && outputs.Contains( Path.ChangeExtension( candidates[0].Path, \".vmat\" ) ) )\n\t\t\t\t\t\t\tremaps[slot.ToLowerInvariant() + \".vmat\"] = prefix + Path.ChangeExtension( candidates[0].Path, \".vmat\" );\n\t\t\t\t\t}\n\t\t\t\t\tforeach ( var (slot, guid) in info.PrefabSlotMaterials )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar assigned = selected.FirstOrDefault( a => a.Guid.Equals( guid, StringComparison.OrdinalIgnoreCase ) && outputs.Contains( Path.ChangeExtension( a.Path, \".vmat\" ) ) );\n\t\t\t\t\t\tif ( assigned != null ) remaps[slot.ToLowerInvariant() + \".vmat\"] = prefix + Path.ChangeExtension( assigned.Path, \".vmat\" );\n\t\t\t\t\t}\n\t\t\t\t\tforeach ( var slot in info.UnresolvedPrefabSlots ) remaps.Remove( slot.ToLowerInvariant() + \".vmat\" );\n\t\t\t\t\tvar defaultMaterial = prefabMaterials.Length == 1 && info.PrefabSlotMaterials.Count == 0 && info.AssignmentWarnings.Count == 0 ? prefix + Path.ChangeExtension( prefabMaterials[0].Path, \".vmat\" ) : null;\n\t\t\t\t\tif ( options.Materials && options.Vmat && defaultMaterial == null && info.Materials.Count == 0 && asset.Path.EndsWith( \".fbx\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar neutralPath = generatedFiles.Add( asset.Path, \"unassigned\", \".vmat\" );\n\t\t\t\t\t\tvar neutral = new UnityMaterial();\n\t\t\t\t\t\tneutral.Colors[\"_Color\"] = new[] { 0.5, 0.5, 0.5, 1.0 };\n\t\t\t\t\t\tFile.WriteAllText( neutralPath.File, neutral.ToVmat( _ => null ) );\n\t\t\t\t\t\tconversions++;\n\t\t\t\t\t\tdefaultMaterial = neutralPath.Reference;\n\t\t\t\t\t\tIssue( asset.Path, \"unassigned-material\", \"FBX contains no named material slots and no unambiguous prefab assignment was resolved; using a neutral material.\" );\n\t\t\t\t\t}\n\t\t\t\t\tif ( options.Materials && options.Vmat && defaultMaterial == null )\n\t\t\t\t\t\tforeach ( var slot in info.RenderedMaterials.Where( s => !remaps.ContainsKey( s.ToLowerInvariant() + \".vmat\" ) ) )\n\t\t\t\t\t\t\tIssue( asset.Path, \"unassigned-material\", $\"No converted material was resolved for slot '{slot}'.\" );\n\t\t\t\t\tdocuments.Add( (Output( Path.ChangeExtension( asset.Path, \".vmdl\" ) ), ModelDocument( meshPath, remaps, defaultMaterial, info.HighestDetailMeshes, scale )) );\n\t\t\t\t\tconversions++;\n\t\t\t\t\tinformation.Add( $\"{asset.Path}: review scale, orientation, material assignments, collision and animations in ModelDoc.\" );\n\t\t\t\t}\n\t\t\t}\n\t\t\toperation = \"extracting texture channels\";\n\t\t\tcurrentAsset = null;\n\t\t\tprogress?.Report( new( 0.8, $\"Preparing {channelRequests.Count} texture channels\", \"Converting textures\" ) );\n\t\t\tchannelTimer.Start();\n\t\t\tif ( channelRequests.Count > 0 ) extractChannels( channelRequests, cancel );\n\t\t\tchannelTimer.Stop();\n\t\t\tprogress?.Report( new( 0.95, \"Choosing generated filenames\u2026\", \"Naming generated files\" ) );\n\t\t\tgeneratedFiles.Complete( Output, cancel );\n\t\t\tforeach ( var document in documents )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tFile.WriteAllText( document.File, generatedFiles.ResolveDocument( document.Text ) );\n\t\t\t}\n\t\t\tvar unsupported = archive.Assets.Count( a => a.Kind == UnityAssetKind.Unsupported );\n\t\t\tif ( unsupported > 0 ) information.Add( $\"{unsupported} unsupported files (such as scripts, scenes and prefabs) were excluded.\" );\n\t\t\tvar issues = unresolved.Distinct().ToArray();\n\t\t\tvar report = new\n\t\t\t{\n\t\t\t\tPackage = Path.GetFileName( archive.FileName ), Assets = selected.Length, Converted = conversions,\n\t\t\t\tFiles = files.ToArray(), Unresolved = issues,\n\t\t\t\tTextureProcessing = new { ChannelsGenerated = channelRequests.Count, SourceDecodes = channelRequests.Select( r => r.Source ).Distinct().Count(), ElapsedMilliseconds = channelTimer.ElapsedMilliseconds },\n\t\t\t\tSelection = plan.Assets.Select( a => new { Source = a.Asset.Path, a.Explicit, a.OutputLabel,\n\t\t\t\t\tRequiredBy = a.RequiredBy.Select( d => new { Source = d.Asset.Path, d.Reason } ).ToArray() } ).ToArray(),\n\t\t\t\tMaterialConversions = selected.Where( a => convertedMaterials.ContainsKey( a.Guid ) ).Select( a => new\n\t\t\t\t{\n\t\t\t\t\tSource = a.Path, convertedMaterials[a.Guid].ShaderName, convertedMaterials[a.Guid].ShaderGuid,\n\t\t\t\t\tConversion = \"Common material properties; shader programs are not translated\",\n\t\t\t\t\tVmat = plan.Find( a ).Vmat, Tmat = plan.Find( a ).Tmat\n\t\t\t\t} ).ToArray(),\n\t\t\t\tInformation = information.ToArray(), Warnings = warnings.Distinct().ToArray()\n\t\t\t};\n\t\t\toperation = \"preparing import details\";\n\t\t\tcurrentAsset = null;\n\t\t\tvar reportJson = JsonSerializer.Serialize( report, new JsonSerializerOptions { WriteIndented = true } );\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\tvar prepared = new ImportResult( stage, selected.Length, conversions, files.Select( f => Path.Combine( stage, f + \".iup\" ) ).ToArray(), warnings.Distinct().ToArray() ) { Unresolved = issues, Information = information.ToArray(), ReportJson = reportJson };\n\t\t\tpreserveStage = true;\n\n\t\t\treturn prepared;\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tfailure = ex;\n\t\t\tif ( ex is IOException or UnauthorizedAccessException )\n\t\t\t{\n\t\t\t\tfailure = new IOException( $\"Import failed while {operation}\" + (currentAsset == null ? \"\" : $\" for '{currentAsset}'\") +\n\t\t\t\t\t$\". Staging folder: '{stage}'. Windows error {ex.HResult & 0xffff}: {ex.Message}\", ex );\n\t\t\t\tthrow failure;\n\t\t\t}\n\t\t\tthrow;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif ( !preserveStage && Directory.Exists( stage ) )\n\t\t\t{\n\t\t\t\ttry { ImportWorkspace.RemoveTree( stage ); }\n\t\t\t\tcatch ( Exception cleanup ) when ( cleanup is IOException or UnauthorizedAccessException )\n\t\t\t\t{\n\t\t\t\t\t// Preserve the original conversion/cancellation error if cleanup also fails.\n\t\t\t\t\tif ( failure == null ) throw new IOException( $\"Could not remove staging folder '{stage}'. {cleanup.Message}\", cleanup );\n\t\t\t\t\tfailure.Data[\"StagingCleanupError\"] = $\"Could not remove '{stage}': {cleanup}\";\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tinternal static void CheckDirectory( string path )\n\t{\n\t\tfor ( var current = new DirectoryInfo( Path.GetFullPath( path ) ); current != null; current = current.Parent )\n\t\t\tif ( (Directory.Exists( current.FullName ) || File.Exists( current.FullName )) && File.GetAttributes( current.FullName ).HasFlag( FileAttributes.ReparsePoint ) )\n\t\t\t\tthrow new IOException( $\"Import destination cannot pass through a symbolic link: {current.FullName}\" );\n\t}\n\n\tstatic string ModelDocument( string mesh, Dictionary<string, string> remaps, string defaultMaterial, string[] highestDetailMeshes, double scale ) => $$\"\"\"\n\t\t<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\n\t\t{\n\t\t\trootNode =\n\t\t\t{\n\t\t\t\t_class = \"RootNode\"\n\t\t\t\tchildren =\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"MaterialGroupList\"\n\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n\t\t\t\t\t\t\t\tremaps = [ {{string.Join( \", \", remaps.Select( r => \"{ from = \" + UnityMaterial.Quote( r.Key ) + \" to = \" + UnityMaterial.Quote( r.Value ) + \" }\" ) )}} ]\n\t\t\t\t\t\t\t\tuse_global_default = {{(defaultMaterial != null ? \"true\" : \"false\")}}\n\t\t\t\t\t\t\t\tglobal_default_material = {{UnityMaterial.Quote( defaultMaterial ?? \"\" )}}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\t\tfilename = {{UnityMaterial.Quote( mesh )}}\n\t\t\t\t\t\t\t\timport_filter =\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\texclude_by_default = {{(highestDetailMeshes.Length > 0 ? \"true\" : \"false\")}}\n\t\t\t\t\t\t\t\t\texception_list = [ {{string.Join( \", \", highestDetailMeshes.Select( UnityMaterial.Quote ) )}} ]\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\timport_scale = {{scale.ToString( \"0.#########\", System.Globalization.CultureInfo.InvariantCulture )}}\n\t\t\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t]\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t\tmodel_archetype = \"\"\n\t\t\t}\n\t\t}\n\t\t\"\"\";\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/ImportCompletion.cs",
            "FileName": "ImportCompletion.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Short completion text, with detailed warnings and errors retained in the report.</summary>\npublic sealed record ImportCompletion( int AssetCount, string[] Warnings, string[] Errors, bool Cancelled )\n{\n\tpublic string Title => Cancelled ? \"Import preparation cancelled\" : Errors.Length > 0 ? \"Import completed with errors\" : \"Import complete\";\n\tpublic string Counts => $\"Warnings: {Warnings.Length} \u00b7 Errors: {Errors.Length}\";\n\tpublic string Message => Cancelled ? $\"Imported files kept. Resource preparation stopped.\\n{Counts}\" : $\"{AssetCount} assets imported.\\n{Counts}\";\n\tpublic string Status => $\"{(Errors.Length == 0 && !Cancelled ? \"\u2713 \" : \"\")}{Title}\\n{Message}\";\n\n\tpublic static ImportCompletion Create( ImportResult result, IEnumerable<string> preparationWarnings, IEnumerable<string> preparationErrors, bool cancelled )\n\t{\n\t\tvar errors = result.Unresolved.Where( i => i.Severity == \"Error\" ).Select( i => $\"{i.Asset}: {i.Message}\" )\n\t\t\t.Concat( preparationErrors ?? Array.Empty<string>() ).Distinct().ToArray();\n\t\tvar warnings = result.Warnings.Concat( result.Unresolved.Where( i => i.Severity == \"Warning\" ).Select( i => $\"{i.Asset}: {i.Message}\" ) )\n\t\t\t.Concat( preparationWarnings ?? Array.Empty<string>() ).Except( errors ).Distinct().ToArray();\n\t\treturn new( result.AssetCount, warnings, errors, cancelled );\n\t}\n\n\tpublic string AppendReport( string json )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( json ) ) json = \"{}\";\n\t\tvar report = JsonNode.Parse( json );\n\t\treport[\"Completion\"] = JsonSerializer.SerializeToNode( new { Title, AssetCount, Cancelled, Warnings, Errors } );\n\t\treturn report.ToJsonString( new JsonSerializerOptions { WriteIndented = true } );\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityFileId.cs",
            "FileName": "UnityFileId.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Buffers.Binary;\nusing System.Text;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Unity's modern model subasset IDs use seed-zero XXH64 of a type and hierarchy path.</summary>\ninternal static class UnityFileId\n{\n\t// XXH64 algorithm: https://github.com/Cyan4973/xxHash/blob/dev/doc/xxhash_spec.md\n\t// Unity path format: https://discussions.unity.com/t/fbx-submesh-fileids/803882\n\tinternal static long Renderer( string path ) => Hash( \"Type:MeshRenderer->\" + path + \"/MeshRenderer0\" );\n\tinternal static long Hash( string text )\n\t{\n\t\tunchecked\n\t\t{\n\t\t\tconst ulong p1 = 0x9E3779B185EBCA87, p2 = 0xC2B2AE3D27D4EB4F, p3 = 0x165667B19E3779F9,\n\t\t\t\tp4 = 0x85EBCA77C2B2AE63, p5 = 0x27D4EB2F165667C5;\n\t\t\tstatic ulong Rotate( ulong v, int n ) => (v << n) | (v >> (64 - n));\n\t\t\tstatic ulong Round( ulong v, ulong lane ) => unchecked( Rotate( v + lane * p2, 31 ) * p1 );\n\t\t\tstatic ulong Merge( ulong h, ulong v ) => unchecked( (h ^ Round( 0, v )) * p1 + p4 );\n\t\t\tReadOnlySpan<byte> data = Encoding.UTF8.GetBytes( text );\n\t\t\tint offset = 0;\n\t\t\tulong hash = p5;\n\t\t\tif ( data.Length >= 32 )\n\t\t\t{\n\t\t\t\tulong a = p1 + p2, b = p2, c = 0, d = 0UL - p1;\n\t\t\t\twhile ( offset <= data.Length - 32 )\n\t\t\t\t{\n\t\t\t\t\ta = Round( a, BinaryPrimitives.ReadUInt64LittleEndian( data[offset..] ) );\n\t\t\t\t\tb = Round( b, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 8)..] ) );\n\t\t\t\t\tc = Round( c, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 16)..] ) );\n\t\t\t\t\td = Round( d, BinaryPrimitives.ReadUInt64LittleEndian( data[(offset + 24)..] ) );\n\t\t\t\t\toffset += 32;\n\t\t\t\t}\n\t\t\t\thash = Rotate( a, 1 ) + Rotate( b, 7 ) + Rotate( c, 12 ) + Rotate( d, 18 );\n\t\t\t\thash = Merge( Merge( Merge( Merge( hash, a ), b ), c ), d );\n\t\t\t}\n\t\t\thash += (ulong)data.Length;\n\t\t\twhile ( offset <= data.Length - 8 )\n\t\t\t{\n\t\t\t\thash = Rotate( hash ^ Round( 0, BinaryPrimitives.ReadUInt64LittleEndian( data[offset..] ) ), 27 ) * p1 + p4;\n\t\t\t\toffset += 8;\n\t\t\t}\n\t\t\tif ( offset <= data.Length - 4 )\n\t\t\t{\n\t\t\t\thash = Rotate( hash ^ BinaryPrimitives.ReadUInt32LittleEndian( data[offset..] ) * p1, 23 ) * p2 + p3;\n\t\t\t\toffset += 4;\n\t\t\t}\n\t\t\twhile ( offset < data.Length ) hash = Rotate( hash ^ data[offset++] * p5, 11 ) * p1;\n\t\t\thash ^= hash >> 33; hash *= p2; hash ^= hash >> 29; hash *= p3; hash ^= hash >> 32;\n\t\t\treturn (long)hash;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/ImportWorkspace.cs",
            "FileName": "ImportWorkspace.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Short-lived work under Assets/Imported/.iup-temp. Leases protect active runs during cleanup.</summary>\npublic sealed class ImportWorkspace : IDisposable\n{\n\tpublic string DirectoryPath { get; }\n\tpublic List<string> Warnings { get; } = new();\n\treadonly string root;\n\tFileStream lease;\n\tpublic ImportWorkspace( string assetsDirectory )\n\t{\n\t\troot = Path.GetFullPath( Path.Combine( assetsDirectory, \"Imported\", \".iup-temp\" ) );\n\t\tUnityImport.CheckDirectory( root );\n\t\tusing var gate = Enter( root );\n\t\tSweep( root, Warnings );\n\t\tDirectory.CreateDirectory( root );\n\t\tDirectoryPath = Path.Combine( root, Guid.NewGuid().ToString( \"N\" ) );\n\t\tDirectory.CreateDirectory( DirectoryPath );\n\t\ttry { lease = new FileStream( Path.Combine( DirectoryPath, \".active\" ), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None ); }\n\t\tcatch { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root ); throw; }\n\t}\n\n\tpublic static string[] CleanupAbandoned( string assetsDirectory )\n\t{\n\t\tvar root = Path.GetFullPath( Path.Combine( assetsDirectory, \"Imported\", \".iup-temp\" ) );\n\t\tUnityImport.CheckDirectory( root );\n\t\tvar warnings = new List<string>();\n\t\tusing var gate = Enter( root );\n\t\tSweep( root, warnings );\n\t\tRemoveEmptyRoot( root );\n\t\treturn warnings.ToArray();\n\t}\n\n\tstatic void Sweep( string root, List<string> warnings )\n\t{\n\t\tif ( !Directory.Exists( root ) ) return;\n\t\tforeach ( var run in Directory.EnumerateDirectories( root ) )\n\t\t{\n\t\t\tif ( !Guid.TryParseExact( Path.GetFileName( run ), \"N\", out _ ) ) continue;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tUnityImport.CheckDirectory( Path.Combine( run, \".active\" ) );\n\t\t\t\t// The exclusive lease stays open throughout an active run, including other editor processes.\n\t\t\t\tusing ( new FileStream( Path.Combine( run, \".active\" ), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None ) ) { }\n\t\t\t}\n\t\t\tcatch ( IOException ex ) when ( (ex.HResult & 0xffff) is 32 or 33 ) { continue; }\n\t\t\tcatch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n\t\t\t{\n\t\t\t\twarnings.Add( $\"Temporary folder could not be checked for cleanup: {run}. {ex.Message}\" );\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\ttry { RemoveTree( run ); }\n\t\t\tcatch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException ) { warnings.Add( $\"Temporary folder could not be removed: {run}. {ex.Message}\" ); }\n\t\t}\n\t}\n\n\t// All recursive deletion is constrained to importer work and rejects linked directories/files.\n\tinternal static void RemoveTree( string path )\n\t{\n\t\tif ( !Directory.Exists( path ) ) return;\n\t\tpath = Path.GetFullPath( path );\n\t\tvar run = new DirectoryInfo( path );\n\t\twhile ( run != null && run.Parent?.Name != \".iup-temp\" ) run = run.Parent;\n\t\tif ( run == null || !Guid.TryParseExact( run.Name, \"N\", out _ ) || !string.Equals( run.Parent.Parent?.Name, \"Imported\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new IOException( \"Refusing to clean outside Assets/Imported/.iup-temp run folders.\" );\n\t\tUnityImport.CheckDirectory( path );\n\t\tvoid Check( string directory )\n\t\t{\n\t\t\tforeach ( var item in Directory.EnumerateFileSystemEntries( directory ) )\n\t\t\t{\n\t\t\t\tvar flags = File.GetAttributes( item );\n\t\t\t\tif ( flags.HasFlag( FileAttributes.ReparsePoint ) ) throw new IOException( \"Refusing to clean linked temporary content: \" + item );\n\t\t\t\tif ( flags.HasFlag( FileAttributes.Directory ) ) Check( item );\n\t\t\t}\n\t\t}\n\t\tCheck( path );\n\t\tImportStorage.Retry( () => Directory.Delete( path, true ), CancellationToken.None );\n\t}\n\n\tstatic void RemoveEmptyRoot( string root )\n\t{\n\t\tif ( Directory.Exists( root ) && !Directory.EnumerateFileSystemEntries( root ).Any() ) Directory.Delete( root );\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tif ( lease == null ) return;\n\t\tusing var gate = Enter( root );\n\t\tlease.Dispose(); lease = null;\n\t\ttry { RemoveTree( DirectoryPath ); RemoveEmptyRoot( root ); }\n\t\tcatch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n\t\t{\n\t\t\tWarnings.Add( $\"Temporary folder could not be removed: {DirectoryPath}. Cleanup will be retried next time the importer opens. {ex.Message}\" );\n\t\t}\n\t}\n\n\tstatic IDisposable Enter( string root ) => new Gate( \"iup-temp-\" + Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( root.ToLowerInvariant() ) ) ) );\n\tsealed class Gate : IDisposable\n\t{\n\t\treadonly Mutex mutex;\n\t\tpublic Gate( string name ) { mutex = new Mutex( false, name ); try { mutex.WaitOne(); } catch ( AbandonedMutexException ) { } }\n\t\tpublic void Dispose() { mutex.ReleaseMutex(); mutex.Dispose(); }\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityModel.cs",
            "FileName": "UnityModel.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing System.Text.RegularExpressions;\n\nnamespace ImportUnityPackage;\n\npublic sealed class UnityModel\n{\n\tpublic sealed record RendererBinding( string Mesh, string[] Slots );\n\tpublic Dictionary<long, RendererBinding> RendererSlots { get; } = new();\n\tpublic Dictionary<string, string> PrefabSlotMaterials { get; } = new( StringComparer.OrdinalIgnoreCase );\n\tpublic HashSet<string> UnresolvedPrefabSlots { get; } = new( StringComparer.OrdinalIgnoreCase );\n\tpublic List<string> AssignmentWarnings { get; } = new();\n\tpublic bool CollisionOnly { get; internal set; }\n\tpublic List<string> Materials { get; } = new();\n\tpublic List<string> Meshes { get; } = new();\n\tpublic List<string> PrefabMaterials { get; } = new();\n\tpublic Dictionary<string, string> MaterialAlbedoFiles { get; } = new( StringComparer.OrdinalIgnoreCase );\n\tpublic double? UnitScaleCentimeters { get; private set; }\n\tpublic double ImportScale( string metadata )\n\t{\n\t\tdouble Setting( string key, double fallback )\n\t\t{\n\t\t\tvar match = Regex.Match( metadata ?? \"\", @\"(?m)^\\s*\" + key + @\":\\s*([-+0-9.eE]+)\" );\n\t\t\treturn match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) && double.IsFinite( value ) ? value : fallback;\n\t\t}\n\t\tvar globalScale = Setting( \"globalScale\", 1 );\n\t\tvar meters = Setting( \"useFileScale\", 1 ) == 0 ? 1 : (UnitScaleCentimeters ?? 2.54) / 100;\n\t\tvar scale = globalScale * meters / 0.0254;\n\t\tif ( !double.IsFinite( scale ) || scale <= 0 ) throw new InvalidDataException( \"Model import scale must be finite and positive.\" );\n\t\treturn scale;\n\t}\n\tpublic string[] HighestDetailMeshes => Meshes.Where( n => Regex.IsMatch( n, @\"(?i)(?:^|[_ .-])LOD0(?:$|[_ .-])\" ) ).ToArray();\n\tpublic string[] RenderedMaterials\n\t{\n\t\tget\n\t\t{\n\t\t\tvar meshes = HighestDetailMeshes;\n\t\t\tvar bindings = RendererSlots.Values.Where( b => meshes.Length == 0 || meshes.Contains( b.Mesh ) ).ToArray();\n\t\t\treturn bindings.Length > 0 && bindings.All( b => b.Slots.Length > 0 ) ? bindings.SelectMany( b => b.Slots ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray() : Materials.ToArray();\n\t\t}\n\t}\n\tpublic static string Normalize( string name ) => Regex.Replace( name.ToLowerInvariant(), \"[^a-z]\", \"\" );\n\n\tpublic static UnityModel Read( UnityAsset asset )\n\t{\n\t\tvar result = new UnityModel();\n\t\tif ( !asset.Path.EndsWith( \".fbx\", StringComparison.OrdinalIgnoreCase ) ) return result;\n\t\tusing var stream = File.OpenRead( asset.Source );\n\t\tusing var reader = new BinaryReader( stream, Encoding.UTF8 );\n\t\tvar header = Encoding.ASCII.GetString( reader.ReadBytes( 23 ) );\n\t\tif ( !header.StartsWith( \"Kaydara FBX Binary\", StringComparison.Ordinal ) )\n\t\t{\n\t\t\tstream.Position = 0;\n\t\t\tusing var textReader = new StreamReader( stream );\n\t\t\tvar links = new UnityAsciiFbxLinks();\n\t\t\tvar asciiBindings = new UnityFbxBindings();\n\t\t\tstring line;\n\t\t\twhile ( (line = textReader.ReadLine()) != null )\n\t\t\t{\n\t\t\t\tlinks.Observe( line );\n\t\t\t\tasciiBindings.ObserveAscii( line );\n\t\t\t\tvar unit = Regex.Match( line, \"^\\\\s*(?:P|Property):\\\\s*\\\"UnitScaleFactor\\\".*?,\\\\s*([-+0-9.eE]+)\\\\s*$\" );\n\t\t\t\tif ( unit.Success && double.TryParse( unit.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var centimeters ) && centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;\n\t\t\t\tvar material = Regex.Match( line, \"^\\\\s*Material:\\\\s*(?:[-0-9]+,\\\\s*)?\\\"Material::([^\\\"]+)\\\"\" );\n\t\t\t\tif ( material.Success && !result.Materials.Contains( material.Groups[1].Value ) ) result.Materials.Add( material.Groups[1].Value );\n\t\t\t\tvar mesh = Regex.Match( line, \"^\\\\s*Model:\\\\s*(?:[-0-9]+,\\\\s*)?\\\"Model::([^\\\"]+)\\\",\\\\s*\\\"Mesh\\\"\" );\n\t\t\t\tif ( mesh.Success && !result.Meshes.Contains( mesh.Groups[1].Value ) ) result.Meshes.Add( mesh.Groups[1].Value );\n\t\t\t}\n\t\t\tforeach ( var link in links.Resolve() ) result.MaterialAlbedoFiles[link.Key] = link.Value;\n\t\t\tasciiBindings.Apply( result, asset.Metadata != null && Regex.IsMatch( UnityImport.ReadText( asset.Metadata ), @\"(?m)^\\s*preserveHierarchy:\\s*1\\s*$\" ) );\n\t\t\treturn result;\n\t\t}\n\t\tvar wide = reader.ReadUInt32() >= 7500;\n\t\tvar materialNames = new Dictionary<long, string>();\n\t\tvar textureFiles = new Dictionary<long, string>();\n\t\tvar diffuseLinks = new List<(long Texture, long Material)>();\n\t\tvar bindings = new UnityFbxBindings();\n\t\tint nodes = 0;\n\t\tvoid ReadNodes( long limit, bool objects = false, bool connections = false, long textureId = 0, bool settings = false )\n\t\t{\n\t\t\twhile ( stream.Position + (wide ? 25 : 13) <= limit )\n\t\t\t{\n\t\t\t\tif ( ++nodes > 200000 ) throw new InvalidDataException( \"Too many FBX nodes.\" );\n\t\t\t\tvar end = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();\n\t\t\t\tvar properties = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();\n\t\t\t\tvar propertyBytes = wide ? (long)reader.ReadUInt64() : reader.ReadUInt32();\n\t\t\t\tvar nameBytes = reader.ReadByte();\n\t\t\t\tif ( end == 0 ) return;\n\t\t\t\tif ( end <= stream.Position || end > stream.Length ) throw new InvalidDataException( \"Invalid FBX node offset.\" );\n\t\t\t\tvar name = Encoding.UTF8.GetString( reader.ReadBytes( nameBytes ) );\n\t\t\t\tvar children = stream.Position + propertyBytes;\n\t\t\t\tif ( children > end ) throw new InvalidDataException( \"Invalid FBX property size.\" );\n\t\t\t\tobject Property()\n\t\t\t\t{\n\t\t\t\t\t\tvar type = (char)reader.ReadByte();\n\t\t\t\t\t\tif ( type == 'L' ) return reader.ReadInt64();\n\t\t\t\t\t\tif ( type == 'I' ) return reader.ReadInt32();\n\t\t\t\t\t\tif ( type == 'D' ) return reader.ReadDouble();\n\t\t\t\t\t\tif ( type == 'F' ) return reader.ReadSingle();\n\t\t\t\t\t\tif ( type == 'S' )\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tvar length = reader.ReadInt32();\n\t\t\t\t\t\t\tif ( length < 0 || length > 1024 * 1024 || stream.Position + length > children ) throw new InvalidDataException( \"Invalid FBX string.\" );\n\t\t\t\t\t\t\treturn Encoding.UTF8.GetString( reader.ReadBytes( length ) );\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow new InvalidDataException( \"Unsupported FBX object property.\" );\n\t\t\t\t}\n\t\t\t\tif ( objects && name is \"Material\" or \"Model\" or \"Texture\" && properties >= 3 )\n\t\t\t\t{\n\t\t\t\t\tvar id = Convert.ToInt64( Property() );\n\t\t\t\t\tvar objectName = (Property() as string ?? \"\").Split( '\\0' )[0];\n\t\t\t\t\tvar typeName = Property() as string;\n\t\t\t\t\tif ( name == \"Material\" ) { result.Materials.Add( objectName ); materialNames[id] = objectName; bindings.Materials[id] = objectName; }\n\t\t\t\t\tif ( name == \"Model\" )\n\t\t\t\t\t{\n\t\t\t\t\t\tbindings.Nodes[id] = (objectName, typeName == \"Mesh\");\n\t\t\t\t\t\tif ( typeName == \"Mesh\" ) result.Meshes.Add( objectName );\n\t\t\t\t\t}\n\t\t\t\t\tif ( name == \"Texture\" ) { stream.Position = children; ReadNodes( end, textureId: id ); }\n\t\t\t\t}\n\t\t\t\telse if ( textureId != 0 && name is \"FileName\" or \"RelativeFilename\" && properties > 0 ) textureFiles[textureId] = Property() as string;\n\t\t\t\telse if ( settings && name is \"P\" or \"Property\" && properties >= 4 && properties <= 8 )\n\t\t\t\t{\n\t\t\t\t\tif ( Property() as string == \"UnitScaleFactor\" )\n\t\t\t\t\t{\n\t\t\t\t\t\tobject value = null;\n\t\t\t\t\t\tfor ( var index = 1; index < properties; index++ ) value = Property();\n\t\t\t\t\t\tvar centimeters = Convert.ToDouble( value, CultureInfo.InvariantCulture );\n\t\t\t\t\t\tif ( centimeters > 0 && double.IsFinite( centimeters ) ) result.UnitScaleCentimeters = centimeters;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse if ( connections && name == \"C\" && properties >= 3 )\n\t\t\t\t{\n\t\t\t\t\tvar kind = Property() as string;\n\t\t\t\t\tvar child = Convert.ToInt64( Property() );\n\t\t\t\t\tvar parent = Convert.ToInt64( Property() );\n\t\t\t\t\tvar channel = properties >= 4 ? Property() as string ?? \"\" : \"\";\n\t\t\t\t\tif ( kind == \"OO\" ) bindings.Connections.Add( (child, parent) );\n\t\t\t\t\tif ( kind == \"OP\" && (channel == \"DiffuseColor\" || channel.EndsWith( \"|base_color_map\", StringComparison.Ordinal )) ) diffuseLinks.Add( (child, parent) );\n\t\t\t\t}\n\t\t\t\tif ( name == \"Objects\" ) { stream.Position = children; ReadNodes( end, true ); }\n\t\t\t\tif ( name == \"Connections\" ) { stream.Position = children; ReadNodes( end, connections: true ); }\n\t\t\t\tif ( name == \"GlobalSettings\" || settings && name is \"Properties70\" or \"Properties60\" ) { stream.Position = children; ReadNodes( end, settings: true ); }\n\t\t\t\tstream.Position = end;\n\t\t\t}\n\t\t}\n\t\tReadNodes( stream.Length, false );\n\t\tbindings.Apply( result, asset.Metadata != null && Regex.IsMatch( UnityImport.ReadText( asset.Metadata ), @\"(?m)^\\s*preserveHierarchy:\\s*1\\s*$\" ) );\n\t\tforeach ( var link in diffuseLinks )\n\t\t\tif ( materialNames.TryGetValue( link.Material, out var material ) && textureFiles.TryGetValue( link.Texture, out var file ) && !string.IsNullOrEmpty( file ) )\n\t\t\t\tresult.MaterialAlbedoFiles[material] = file.Replace( '\\\\', '/' ).Split( '/' ).Last();\n\t\treturn result;\n\t}\n\n\tpublic static void AssignPrefabMaterials( UnityArchive archive ) => UnityPrefabBindings.Apply( archive );\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/UnityAssetTree.cs",
            "FileName": "UnityAssetTree.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace ImportUnityPackage;\n\npublic sealed class UnityAssetTree : TreeView\n{\n\treadonly Func<ImportPlan> plan;\n\treadonly Action changed;\n\treadonly Action<UnityAsset> describe;\n\tpublic UnityAssetTree( Widget parent, Func<ImportPlan> plan, Action changed, Action<UnityAsset> describe ) : base( parent )\n\t{\n\t\tthis.plan = plan;\n\t\tthis.changed = changed;\n\t\tthis.describe = describe;\n\t\tMultiSelect = false;\n\t}\n\n\tpublic void Load( IEnumerable<UnityAsset> assets )\n\t{\n\t\tvar root = new AssetNode( \"Assets\", this );\n\t\tforeach ( var asset in assets )\n\t\t{\n\t\t\tvar parts = asset.Path.Split( '/' );\n\t\t\tvar node = root;\n\t\t\tforeach ( var part in parts[..^1] )\n\t\t\t{\n\t\t\t\tvar folder = node.Children.OfType<AssetNode>().FirstOrDefault( c => c.Asset == null && c.Name == part );\n\t\t\t\tif ( folder == null ) { folder = new AssetNode( part, this ); node.AddItem( folder ); }\n\t\t\t\tnode = folder;\n\t\t\t}\n\t\t\tnode.AddItem( new AssetNode( parts[^1], this ) { Asset = asset } );\n\t\t}\n\t\tSetItems( new[] { root } );\n\t\tOpen( root );\n\t}\n\n\tpublic void SetExpanded( bool expanded )\n\t{\n\t\tforeach ( var root in Items.OfType<TreeNode>() )\n\t\t{\n\t\t\tif ( expanded ) Open( root, recursive: true );\n\t\t\telse Close( root, recursive: true );\n\t\t}\n\t}\n\n\tpublic void ChangeExpandedLayer( bool expand )\n\t{\n\t\tvar candidates = new List<(TreeNode Node, int Depth)>();\n\t\tvoid Visit( TreeNode node, int depth )\n\t\t{\n\t\t\tvar children = node.Children.ToArray();\n\t\t\tif ( children.Length == 0 ) return;\n\t\t\t// A child's layout position reflects the actual open state, including\n\t\t\t// changes made with folder arrows or the keyboard, even offscreen.\n\t\t\tvar open = TryGetItemRect( children[0], out _ );\n\t\t\tif ( expand != open ) candidates.Add( (node, depth) );\n\t\t\tif ( open ) foreach ( var child in children ) Visit( child, depth + 1 );\n\t\t}\n\t\tforeach ( var root in Items.OfType<TreeNode>() ) Visit( root, 0 );\n\t\tif ( candidates.Count == 0 ) return;\n\t\tvar layer = expand ? candidates.Min( c => c.Depth ) : candidates.Max( c => c.Depth );\n\t\tforeach ( var (node, depth) in candidates.Where( c => c.Depth == layer ) )\n\t\t{\n\t\t\t// Clear remembered child expansion so opening one layer cannot reveal several.\n\t\t\tClose( node, recursive: true );\n\t\t\tif ( expand ) Open( node );\n\t\t}\n\t}\n\n\tprotected override bool OnItemPressed( VirtualWidget item, MouseEvent e )\n\t{\n\t\tif ( !base.OnItemPressed( item, e ) ) return false; // Leave folder expand arrows functional.\n\t\tif ( e.LeftMouseButton && item.Object is AssetNode node ) node.ToggleSelection();\n\t\treturn true;\n\t}\n\n\tsealed class AssetNode : TreeNode\n\t{\n\t\treadonly UnityAssetTree owner;\n\t\tpublic UnityAsset Asset { get; init; }\n\t\tpublic AssetNode( string name, UnityAssetTree owner ) { Name = name; this.owner = owner; }\n\t\tIEnumerable<UnityAsset> Leaves => Asset != null ? new[] { Asset } : Children.OfType<AssetNode>().SelectMany( c => c.Leaves );\n\t\tpublic void ToggleSelection()\n\t\t{\n\t\t\towner.describe( Asset );\n\t\t\t// Dependencies remain included until their last selected parent is removed.\n\t\t\tif ( Asset != null && !Asset.Selected && owner.plan()?.Find( Asset ) != null ) return;\n\t\t\tvar eligible = Leaves.Where( a => a.Kind != UnityAssetKind.Unsupported ).ToArray();\n\t\t\tvar select = !eligible.All( a => owner.plan()?.Find( a ) != null );\n\t\t\tforeach ( var asset in eligible ) asset.Selected = select;\n\t\t\towner.changed();\n\t\t}\n\t\tpublic override void OnKeyPress( KeyEvent e )\n\t\t{\n\t\t\tif ( e.Key == KeyCode.Space ) { ToggleSelection(); e.Accepted = true; }\n\t\t}\n\t\tpublic override void OnPaint( VirtualWidget item )\n\t\t{\n\t\t\tPaintSelection( item );\n\t\t\tvar plan = owner.plan();\n\t\t\tvar entry = Asset == null ? null : plan?.Find( Asset );\n\t\t\tvar eligible = Leaves.Where( a => a.Kind != UnityAssetKind.Unsupported ).ToArray();\n\t\t\tvar count = eligible.Count( a => plan?.Find( a ) != null );\n\t\t\tvar active = eligible.Length > 0;\n\t\t\tvar checkbox = !active || count == 0 ? \"check_box_outline_blank\" : count == eligible.Length ? \"check_box\" : \"indeterminate_check_box\";\n\t\t\tif ( entry is { Explicit: false } ) checkbox = \"link\";\n\t\t\tPaint.SetPen( active ? Theme.Text : Theme.Text.WithAlpha( 0.35f ) );\n\t\t\tPaint.DrawIcon( item.Rect, checkbox, 18, TextFlag.LeftCenter );\n\t\t\tPaint.DrawIcon( item.Rect.Shrink( 24, 0, 0, 0 ), Asset == null ? \"folder\" : Asset.Kind switch\n\t\t\t{\n\t\t\t\tUnityAssetKind.Material => \"palette\", UnityAssetKind.Texture => \"image\", UnityAssetKind.Model => \"view_in_ar\", _ => \"description\"\n\t\t\t}, 18, TextFlag.LeftCenter );\n\t\t\tvar annotation = Asset?.Kind == UnityAssetKind.Unsupported ? \" (unsupported)\" : entry == null ? \"\" :\n\t\t\t\t$\" \u00b7 {entry.OutputLabel}\" + (!entry.Explicit ? \" \u00b7 dependency\" : \"\") + (plan.Issues.Any( i => i.Asset == Asset.Path ) ? \" \u00b7 issue\" : \"\");\n\t\t\tPaint.DrawText( item.Rect.Shrink( 48, 0, 0, 0 ), Name + annotation, TextFlag.LeftCenter );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityAsciiFbxLinks.cs",
            "FileName": "UnityAsciiFbxLinks.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.RegularExpressions;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Legacy ASCII FBX attaches texture/material objects to the mesh by name.</summary>\ninternal sealed class UnityAsciiFbxLinks\n{\n\treadonly Dictionary<string, string> textures = new( StringComparer.Ordinal );\n\treadonly List<(string Child, string Parent)> connections = new();\n\tint depth;\n\tint objectsDepth = -1;\n\tint textureDepth = -1;\n\tstring texture;\n\n\tpublic void Observe( string line )\n\t{\n\t\tif ( Regex.IsMatch( line, @\"^\\s*Objects:\\s*\\{\" ) ) objectsDepth = depth + 1;\n\t\tif ( objectsDepth > 0 && depth == objectsDepth )\n\t\t{\n\t\t\tvar start = Regex.Match( line, \"^\\\\s*Texture:\\\\s*\\\"(Texture::[^\\\"]+)\\\"\" );\n\t\t\tif ( start.Success ) { texture = start.Groups[1].Value; textureDepth = depth + 1; }\n\t\t}\n\t\tif ( texture != null && depth >= textureDepth )\n\t\t{\n\t\t\tvar file = Regex.Match( line, \"^\\\\s*(?:FileName|Filename|RelativeFilename):\\\\s*\\\"([^\\\"]+)\\\"\" );\n\t\t\tif ( file.Success ) textures[texture] = file.Groups[1].Value.Replace( '\\\\', '/' ).Split( '/' ).Last();\n\t\t}\n\t\tvar connection = Regex.Match( line, \"^\\\\s*Connect:\\\\s*\\\"OO\\\",\\\\s*\\\"([^\\\"]+)\\\",\\\\s*\\\"([^\\\"]+)\\\"\" );\n\t\tif ( connection.Success ) connections.Add( (connection.Groups[1].Value, connection.Groups[2].Value) );\n\t\tvar structural = line.Contains( '\"' ) ? Regex.Replace( line, \"\\\"[^\\\"]*\\\"\", \"\" ) : line;\n\t\tstructural = structural.Split( ';' )[0];\n\t\tdepth += structural.Count( c => c == '{' ) - structural.Count( c => c == '}' );\n\t\tif ( depth < textureDepth ) { texture = null; textureDepth = -1; }\n\t\tif ( depth < objectsDepth ) objectsDepth = -1;\n\t}\n\n\tpublic IEnumerable<KeyValuePair<string, string>> Resolve()\n\t{\n\t\tvar candidates = new List<(string Material, string File)>();\n\t\tforeach ( var mesh in connections.Where( c => c.Parent.StartsWith( \"Model::\", StringComparison.Ordinal ) ).GroupBy( c => c.Parent ) )\n\t\t{\n\t\t\tvar materials = mesh.Select( c => c.Child ).Where( c => c.StartsWith( \"Material::\", StringComparison.Ordinal ) ).Distinct().ToArray();\n\t\t\tvar maps = mesh.Select( c => c.Child ).Where( textures.ContainsKey ).Distinct().ToArray();\n\t\t\t// Without polygon-slot information, multiple materials/textures are ambiguous.\n\t\t\tif ( materials.Length == 1 && maps.Length == 1 ) candidates.Add( (materials[0][\"Material::\".Length..], textures[maps[0]]) );\n\t\t}\n\t\tforeach ( var material in candidates.GroupBy( c => c.Material ) )\n\t\t{\n\t\t\tvar files = material.Select( c => c.File ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\t\tif ( files.Length == 1 ) yield return new( material.Key, files[0] );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityFbxCompatibility.cs",
            "FileName": "UnityFbxCompatibility.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Repairs a redundant array emitted by some legacy ASCII FBX exporters.</summary>\npublic static class UnityFbxCompatibility\n{\n\tpublic static byte[] Normalize( string source, CancellationToken cancel )\n\t{\n\t\t// Ordinary/binary models remain byte-for-byte copies. Bound optional text processing.\n\t\tusing var stream = File.OpenRead( source );\n\t\tif ( stream.Length > 32 * 1024 * 1024 ) return null;\n\t\tvar header = new byte[32];\n\t\tvar length = stream.Read( header );\n\t\tif ( !Encoding.ASCII.GetString( header, 0, length ).StartsWith( \"; FBX \", StringComparison.Ordinal ) ) return null;\n\t\tstream.Position = 0;\n\t\tusing var reader = new StreamReader( stream, new UTF8Encoding( false, true ), false );\n\t\tstring text;\n\t\ttry { text = reader.ReadToEnd(); }\n\t\tcatch ( DecoderFallbackException ) { return null; }\n\t\treturn NormalizeText( text, cancel ) is string normalized ? Encoding.UTF8.GetBytes( normalized ) : null;\n\t}\n\n\tinternal static string NormalizeText( string text, CancellationToken cancel )\n\t{\n\t\tcancel.ThrowIfCancellationRequested();\n\t\tconst string numbers = @\"[-+0-9.eE,\\s]+\";\n\t\tvar timeout = TimeSpan.FromSeconds( 2 );\n\t\tvar tails = Regex.Matches( text, @\"(?m)^[ \\t]*}(?<tail>,[-+0-9.eE, \\t]+)(?=\\r?$)\", RegexOptions.None, timeout );\n\t\tif ( tails.Count == 0 ) return null;\n\t\tstring Compact( string value ) => string.Concat( value.Where( c => !char.IsWhiteSpace( c ) ) ).Trim( ',' );\n\t\tvar vertices = Regex.Matches( text, @\"(?m)^[ \\t]*Vertices:[ \\t]*(\" + numbers + \")\", RegexOptions.None, timeout )\n\t\t\t.Select( m => Compact( m.Groups[1].Value ) ).Where( s => s.Length > 0 ).ToHashSet( StringComparer.Ordinal );\n\t\tvar output = new StringBuilder( text );\n\t\tvar changed = false;\n\t\tforeach ( Match match in tails.Reverse() )\n\t\t{\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\tvar tail = match.Groups[\"tail\"];\n\t\t\t// Only discard an exact repeated vertex sequence at an invalid syntax position.\n\t\t\t// Different data is left untouched so an uncertain repair cannot alter geometry.\n\t\t\tif ( !vertices.Contains( Compact( tail.Value ) ) ) continue;\n\t\t\toutput.Remove( tail.Index, tail.Length );\n\t\t\tchanged = true;\n\t\t}\n\t\treturn changed ? output.ToString() : null;\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/TextureChannels.cs",
            "FileName": "TextureChannels.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Sandbox;\nusing SkiaSharp;\n\nnamespace ImportUnityPackage;\n\n/// <summary>CPU image work only. Every worker owns its decoder, bitmap and pixel arrays.</summary>\npublic static class TextureChannels\n{\n\tpublic const long MemoryBudget = 1024L * 1024 * 1024;\n\tpublic const int WorkerLimit = 2;\n\n\tpublic static void ExtractBatch( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel )\n\t\t=> ProcessBatch( requests, cancel );\n\n\tpublic sealed record BatchStats( int Decodes, int Channels, int PeakWorkers, long PeakReservedBytes, long ElapsedMilliseconds );\n\n\tpublic static BatchStats ProcessBatch( IReadOnlyList<TextureChannelRequest> requests, CancellationToken cancel, int workers = WorkerLimit, long budget = MemoryBudget )\n\t{\n\t\tif ( workers < 1 || workers > WorkerLimit || budget <= 0 ) throw new ArgumentOutOfRangeException();\n\t\tvar timer = System.Diagnostics.Stopwatch.StartNew();\n\t\tvar gate = new object();\n\t\tlong reserved = 0, peakReserved = 0;\n\t\tint active = 0, peakWorkers = 0, decodes = 0;\n\t\tvar groups = requests.GroupBy( r => r.Source, StringComparer.OrdinalIgnoreCase ).Select( g => g.ToArray() ).ToArray();\n\t\tParallel.ForEach( groups, new ParallelOptions { MaxDegreeOfParallelism = workers, CancellationToken = cancel }, group =>\n\t\t{\n\t\t\t// Unknown codecs and images larger than the admission budget run exclusively.\n\t\t\t// This is an estimated working-set budget, not a hard cap on native allocations.\n\t\t\tvar reservation = Math.Min( budget, EstimateMemory( group[0].Source, budget ) );\n\t\t\tlock ( gate )\n\t\t\t{\n\t\t\t\twhile ( reserved + reservation > budget ) { cancel.ThrowIfCancellationRequested(); Monitor.Wait( gate, 50 ); }\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\treserved += reservation;\n\t\t\t\tpeakReserved = Math.Max( reserved, peakReserved );\n\t\t\t\tpeakWorkers = Math.Max( ++active, peakWorkers );\n\t\t\t}\n\t\t\ttry\n\t\t\t{\n\t\t\t\tusing var bitmap = Decode( group[0].Source, cancel );\n\t\t\t\tInterlocked.Increment( ref decodes );\n\t\t\t\tvar sourcePixels = bitmap.GetPixels();\n\t\t\t\tvar outputPixels = new Color[sourcePixels.Length];\n\t\t\t\tforeach ( var request in group )\n\t\t\t\t{\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\t\tConvertPixels( sourcePixels, outputPixels, request.Channel, request.Scale, request.Invert, cancel );\n\t\t\t\t\tbitmap.SetPixels( outputPixels );\n\t\t\t\t\tFile.WriteAllBytes( request.Destination, bitmap.ToPng() );\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( OperationCanceledException ) { throw; }\n\t\t\tcatch ( Exception ex ) { throw new InvalidDataException( $\"Texture channel extraction failed for '{group[0].Source}': {ex.Message}\", ex ); }\n\t\t\tfinally { lock ( gate ) { reserved -= reservation; active--; Monitor.PulseAll( gate ); } }\n\t\t} );\n\t\treturn new( decodes, requests.Count, peakWorkers, peakReserved, timer.ElapsedMilliseconds );\n\t}\n\n\tstatic long EstimateMemory( string source, long unknown )\n\t{\n\t\tusing var stream = File.OpenRead( source );\n\t\tusing var codec = SKCodec.Create( stream );\n\t\tif ( codec == null ) return unknown;\n\t\t// Decoded image, two float-color arrays, encoder buffers and compressed input.\n\t\treturn checked( (long)codec.Info.Width * codec.Info.Height * 64 + stream.Length * 2 + 16 * 1024 * 1024 );\n\t}\n\n\tstatic Bitmap Decode( string source, CancellationToken cancel )\n\t{\n\t\tcancel.ThrowIfCancellationRequested();\n\t\treturn Bitmap.CreateFromBytes( File.ReadAllBytes( source ) ) ?? throw new InvalidDataException( \"Unable to decode packed texture: \" + source );\n\t}\n\n\t// Retained for callers supplying a single-channel callback.\n\tpublic static byte[] Extract( string source, int channel, double scale, bool invert, CancellationToken cancel )\n\t{\n\t\tusing var bitmap = Decode( source, cancel );\n\t\tvar pixels = bitmap.GetPixels();\n\t\tConvertPixels( pixels, pixels, channel, scale, invert, cancel );\n\t\tbitmap.SetPixels( pixels );\n\t\treturn bitmap.ToPng();\n\t}\n\n\tstatic void ConvertPixels( Color[] source, Color[] output, int channel, double scale, bool invert, CancellationToken cancel )\n\t{\n\t\tfor ( var i = 0; i < source.Length; i++ )\n\t\t{\n\t\t\tif ( i % 65536 == 0 ) cancel.ThrowIfCancellationRequested();\n\t\t\tvar p = source[i];\n\t\t\tvar value = (float)Math.Clamp( (channel switch { 0 => p.r, 1 => p.g, 2 => p.b, _ => p.a }) * scale, 0, 1 );\n\t\t\tif ( invert ) value = 1 - value;\n\t\t\toutput[i] = new Color( value, value, value, 1 );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityMaterial.cs",
            "FileName": "UnityMaterial.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Best-effort conversion of text-serialized Standard/URP material properties.</summary>\npublic sealed class UnityMaterial\n{\n\tpublic Dictionary<string, string> Textures { get; } = new();\n\tpublic Dictionary<string, double> Numbers { get; } = new();\n\tpublic Dictionary<string, double[]> Colors { get; } = new();\n\tpublic List<string> Warnings { get; } = new();\n\tpublic List<string> Information { get; } = new();\n\treadonly Dictionary<string, string> channelImages = new();\n\treadonly Dictionary<string, double[]> textureScale = new();\n\treadonly Dictionary<string, double[]> textureOffset = new();\n\tbool shaderAlphaTest;\n\tbool shaderBackfaces;\n\tbool shaderTranslucent;\n\tbool terrainLayer;\n\tHashSet<string> declaredProperties;\n\tstatic readonly string[] ColorProperties = { \"_BaseMap\", \"_Diffuse\", \"_MainTex\", \"_Albedo\", \"_BaseColorMap\" };\n\tpublic IEnumerable<string> ColorTextureGuids => ColorProperties.Where( Textures.ContainsKey ).Select( p => Textures[p] );\n\tbool AlphaTest => shaderAlphaTest || Number( \"_Mode\", 0 ) == 1 || Number( \"_AlphaClip\", 0 ) == 1;\n\tbool Translucent => shaderTranslucent || Number( \"_Mode\", 0 ) >= 2 || Number( \"_Surface\", 0 ) == 1;\n\tpublic string ShaderName { get; private set; }\n\tpublic string ShaderGuid { get; private set; }\n\tstatic readonly Regex GuidPattern = new( @\"guid:\\s*([a-fA-F0-9]{32})\" );\n\n\tpublic static IEnumerable<string> References( string text ) => GuidPattern.Matches( text )\n\t\t.Select( m => m.Groups[1].Value ).Where( g => g.Any( c => c != '0' ) ).Distinct( StringComparer.OrdinalIgnoreCase );\n\n\tpublic static UnityMaterial Parse( string text )\n\t{\n\t\tif ( text.Contains( \"TerrainLayer:\" ) && !text.Contains( '\\0' ) ) return ParseTerrainLayer( text );\n\t\tif ( !text.Contains( \"Material:\" ) || text.Contains( '\\0' ) )\n\t\t\tthrow new InvalidDataException( \"Material is not Unity text YAML. Re-export it with Asset Serialization set to Force Text in Unity.\" );\n\t\tvar result = new UnityMaterial();\n\t\tresult.ShaderGuid = Regex.Match( text, @\"m_Shader:[^\\r\\n]*guid:\\s*([a-fA-F0-9]{32})\" ).Groups[1].Value;\n\t\tstring property = null;\n\t\tforeach ( var line in text.Split( '\\n' ) )\n\t\t{\n\t\t\tvar item = Regex.Match( line, @\"^\\s*-\\s*(_[A-Za-z0-9_]+):\\s*(.*)$\" );\n\t\t\tif ( item.Success )\n\t\t\t{\n\t\t\t\tproperty = item.Groups[1].Value;\n\t\t\t\tvar value = item.Groups[2].Value.Trim();\n\t\t\t\tif ( double.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) && double.IsFinite( number ) )\n\t\t\t\t\tresult.Numbers[property] = number;\n\t\t\t\tif ( value.StartsWith( \"{\" ) )\n\t\t\t\t{\n\t\t\t\t\tvar channels = Regex.Matches( value, @\"[rgba]:\\s*([-+0-9.eE]+)\" );\n\t\t\t\t\tif ( channels.Count == 4 ) result.Colors[property] = channels.Select( m => double.Parse( m.Groups[1].Value, CultureInfo.InvariantCulture ) ).ToArray();\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( property != null && line.Contains( \"m_Texture:\" ) )\n\t\t\t{\n\t\t\t\tvar guid = GuidPattern.Match( line );\n\t\t\t\tif ( guid.Success && guid.Groups[1].Value.Any( c => c != '0' ) ) result.Textures[property] = guid.Groups[1].Value;\n\t\t\t}\n\t\t\tif ( property != null && (line.Contains( \"m_Scale:\" ) || line.Contains( \"m_Offset:\" )) )\n\t\t\t{\n\t\t\t\tvar xy = Regex.Match( line, @\"x:\\s*([-+0-9.eE]+),\\s*y:\\s*([-+0-9.eE]+)\" );\n\t\t\t\tif ( xy.Success ) (line.Contains( \"m_Scale:\" ) ? result.textureScale : result.textureOffset)[property] =\n\t\t\t\t\tnew[] { double.Parse( xy.Groups[1].Value, CultureInfo.InvariantCulture ), double.Parse( xy.Groups[2].Value, CultureInfo.InvariantCulture ) };\n\t\t\t}\n\t\t\tif ( line.Contains( \"m_Scale:\" ) && !Regex.IsMatch( line, @\"x:\\s*1(?:\\.0+)?\\s*,\\s*y:\\s*1(?:\\.0+)?\\s*}\" ) ||\n\t\t\t\tline.Contains( \"m_Offset:\" ) && !Regex.IsMatch( line, @\"x:\\s*0(?:\\.0+)?\\s*,\\s*y:\\s*0(?:\\.0+)?\\s*}\" ) )\n\t\t\t\tresult.Warnings.Add( \"Texture tiling/offset needs manual adjustment.\" );\n\t\t}\n\t\tif ( result.Textures.ContainsKey( \"_MetallicGlossMap\" ) || result.Textures.ContainsKey( \"_MaskMap\" ) )\n\t\t\tresult.Warnings.Add( \"Packed metallic/smoothness or HDRP mask maps need channel separation; scalar metallic/roughness values were used.\" );\n\t\tresult.Information.Add( \"Review the converted material: custom shaders, normal-map conventions and advanced Unity settings are not reproduced exactly.\" );\n\t\treturn result;\n\t}\n\n\tpublic void ConfigureShader( string source )\n\t{\n\t\tShaderName = Regex.Match( source, \"Shader\\\\s+\\\"([^\\\"]+)\\\"\" ).Groups[1].Value;\n\t\tdeclaredProperties = Regex.Matches( source, \"(?m)^\\\\s*(?:\\\\[[^\\\\]\\\\r\\\\n]*\\\\]\\\\s*)*(_[A-Za-z0-9_]+)\\\\s*\\\\(\\\\s*\\\"[^\\\"]*\\\"\\\\s*,\" )\n\t\t\t.Select( m => m.Groups[1].Value ).ToHashSet();\n\t\tif ( declaredProperties.Count > 0 )\n\t\t{\n\t\t\t// Unity retains old shader properties in materials. Only use properties declared by the active shader.\n\t\t\tforeach ( var stale in Textures.Keys.Where( k => !declaredProperties.Contains( k ) ).ToArray() ) Textures.Remove( stale );\n\t\t}\n\t\tshaderAlphaTest = Regex.IsMatch( source, \"\\\"RenderType\\\"\\\\s*=\\\\s*\\\"TransparentCutout\\\"\" );\n\t\tshaderBackfaces = Regex.IsMatch( source, @\"(?m)^\\s*Cull\\s+Off\\s*$\", RegexOptions.IgnoreCase );\n\t\tshaderTranslucent = Regex.IsMatch( source, \"\\\"RenderType\\\"\\\\s*=\\\\s*\\\"Transparent\\\"\" );\n\t\tif ( !string.IsNullOrEmpty( ShaderName ) ) Warnings.Add( $\"Shader '{ShaderName}' is approximated using s&box's complex shader; shader code and graph behavior are not translated.\" );\n\t}\n\n\tpublic void PrepareChannels( Func<string, int, double, bool, string> extract )\n\t{\n\t\tvoid Channel( string target, string guid, int channel, double scale = 1, bool invert = false )\n\t\t{\n\t\t\tvar path = extract( guid, channel, scale, invert );\n\t\t\tif ( path != null ) channelImages[target] = path;\n\t\t}\n\t\tif ( Textures.TryGetValue( \"_MetallicGlossMap\", out var packed ) )\n\t\t{\n\t\t\tChannel( \"metal\", packed, 0 );\n\t\t\tChannel( \"rough\", packed, 3, Number( \"_GlossMapScale\", Number( \"_Smoothness\", 1 ) ), true );\n\t\t}\n\t\tif ( Textures.TryGetValue( \"_MaskMap\", out var mask ) )\n\t\t{\n\t\t\tChannel( \"metal\", mask, 0 );\n\t\t\tChannel( \"ao\", mask, 1 );\n\t\t\t// TerrainLit stores height in B; HDRP Lit stores a detail mask instead.\n\t\t\tif ( terrainLayer ) Channel( \"height\", mask, 2 );\n\t\t\tChannel( \"rough\", mask, 3, 1, true );\n\t\t}\n\t\tif ( Textures.TryGetValue( \"_OcclusionMap\", out var ao ) ) Channel( \"ao\", ao, 1 );\n\t\tif ( AlphaTest || Translucent )\n\t\t{\n\t\t\tforeach ( var property in ColorProperties )\n\t\t\t{\n\t\t\t\tif ( !Textures.TryGetValue( property, out var guid ) ) continue;\n\t\t\t\tChannel( \"opacity\", guid, 3 );\n\t\t\t\tif ( channelImages.ContainsKey( \"opacity\" ) ) break;\n\t\t\t}\n\t\t}\n\t\tif ( channelImages.Count > 0 ) Warnings.RemoveAll( w => w.StartsWith( \"Packed metallic/\" ) || w.StartsWith( \"Unity terrain mask channels\" ) );\n\t}\n\n\tstatic UnityMaterial ParseTerrainLayer( string text )\n\t{\n\t\tvar result = new UnityMaterial { terrainLayer = true };\n\t\tforeach ( var (source, target) in new[] { (\"m_DiffuseTexture\", \"_MainTex\"), (\"m_NormalMapTexture\", \"_BumpMap\"), (\"m_MaskMapTexture\", \"_MaskMap\") } )\n\t\t{\n\t\t\tvar line = text.Split( '\\n' ).FirstOrDefault( l => l.TrimStart().StartsWith( source + \":\", StringComparison.Ordinal ) );\n\t\t\tif ( line != null && References( line ).FirstOrDefault() is string guid ) result.Textures[target] = guid;\n\t\t}\n\t\tforeach ( var (source, target) in new[] { (\"m_Metallic\", \"_Metallic\"), (\"m_Smoothness\", \"_Smoothness\") } )\n\t\t{\n\t\t\tvar match = Regex.Match( text, @\"(?m)^\\s*\" + source + @\":\\s*([-+0-9.eE]+)\" );\n\t\t\tif ( match.Success && double.TryParse( match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var value ) ) result.Numbers[target] = value;\n\t\t}\n\t\tresult.Warnings.Add( \"Terrain layer converted; review world tiling, normal strength and remapping in the terrain material editor.\" );\n\t\tif ( result.Textures.ContainsKey( \"_MaskMap\" ) ) result.Warnings.Add( \"Unity terrain mask channels need separation; packed mask was preserved as a source texture.\" );\n\t\treturn result;\n\t}\n\n\tstring Texture( Func<string, string> resolve, params string[] names )\n\t{\n\t\tforeach ( var name in names )\n\t\t\tif ( Textures.TryGetValue( name, out var guid ) && resolve( guid ) is string path ) return path;\n\t\treturn null;\n\t}\n\tpublic double Number( string name, double fallback ) => Numbers.GetValueOrDefault( name, fallback );\n\tstatic string N( double value ) => double.IsFinite( value ) ? value.ToString( \"0.######\", CultureInfo.InvariantCulture ) : \"0\";\n\tinternal static string Quote( string value ) => JsonSerializer.Serialize( value );\n\n\tpublic string ToVmat( Func<string, string> resolve )\n\t{\n\t\tvar text = new StringBuilder( \"Layer0\\n{\\n\\tshader \\\"shaders/complex.shader_c\\\"\\n\" );\n\t\tvoid Set( string key, string value ) => text.AppendLine( $\"\\t{key} {Quote( value )}\" );\n\t\tSet( \"TextureColor\", Texture( resolve, \"_BaseMap\", \"_Diffuse\", \"_MainTex\", \"_Albedo\", \"_BaseColorMap\" ) ?? \"materials/default/default_color.tga\" );\n\t\tvar colorProperty = new[] { \"_BaseMap\", \"_Diffuse\", \"_MainTex\", \"_Albedo\", \"_BaseColorMap\" }.FirstOrDefault( p => Textures.TryGetValue( p, out var guid ) && resolve( guid ) != null );\n\t\tif ( colorProperty != null )\n\t\t{\n\t\t\tif ( textureScale.TryGetValue( colorProperty, out var scale ) ) Set( \"g_vTexCoordScale\", $\"[{N( scale[0] )} {N( scale[1] )}]\" );\n\t\t\tif ( textureOffset.TryGetValue( colorProperty, out var offset ) ) Set( \"g_vTexCoordOffset\", $\"[{N( offset[0] )} {N( offset[1] )}]\" );\n\t\t}\n\t\tSet( \"TextureNormal\", Texture( resolve, \"_BumpMap\", \"_Normal\", \"_NormalMap\" ) ?? \"materials/default/default_normal.tga\" );\n\t\tSet( \"TextureAmbientOcclusion\", channelImages.GetValueOrDefault( \"ao\" ) ?? Texture( resolve, \"_OcclusionMap\", \"_Occlusion\" ) ?? \"materials/default/default_ao.tga\" );\n\t\tSet( \"TextureRoughness\", channelImages.GetValueOrDefault( \"rough\" ) ?? \"materials/default/default_rough.tga\" );\n\t\tvar roughness = 1 - Math.Clamp( Number( \"_Smoothness\", Number( \"_Glossiness\", 0.5 ) ), 0, 1 );\n\t\tSet( \"g_flRoughnessScaleFactor\", N( channelImages.ContainsKey( \"rough\" ) ? 1 : roughness ) );\n\t\tif ( channelImages.TryGetValue( \"metal\", out var metal ) )\n\t\t{\n\t\t\ttext.AppendLine( \"\\tF_METALNESS_TEXTURE 1\" );\n\t\t\tSet( \"TextureMetalness\", metal );\n\t\t}\n\t\tSet( \"g_flMetalness\", N( Math.Clamp( Number( \"_Metallic\", 0 ), 0, 1 ) ) );\n\t\tif ( Colors.TryGetValue( \"_BaseColor\", out var color ) || colorProperty == \"_Diffuse\" && Colors.TryGetValue( \"_MainColor\", out color ) || Colors.TryGetValue( \"_Color\", out color ) )\n\t\t\tSet( \"g_vColorTint\", $\"[{N( color[0] )} {N( color[1] )} {N( color[2] )} {N( color[3] )}]\" );\n\t\tif ( channelImages.TryGetValue( \"opacity\", out var opacity ) ) Set( \"TextureTranslucency\", opacity );\n\t\tif ( AlphaTest )\n\t\t{\n\t\t\ttext.AppendLine( \"\\tF_ALPHA_TEST 1\" );\n\t\t\tSet( \"g_flAlphaTestReference\", N( Number( \"_Cutoff\", 0.5 ) ) );\n\t\t}\n\t\tif ( shaderBackfaces || Number( \"_Cull\", 2 ) == 0 ) text.AppendLine( \"\\tF_RENDER_BACKFACES 1\" );\n\t\tif ( Translucent ) text.AppendLine( \"\\tF_TRANSLUCENT 1\" );\n\t\tvar emission = Texture( resolve, \"_EmissionMap\", \"_EmissiveColorMap\" );\n\t\tif ( emission != null )\n\t\t{\n\t\t\ttext.AppendLine( \"\\tF_SELF_ILLUM 1\" );\n\t\t\tSet( \"TextureSelfIllumMask\", emission );\n\t\t}\n\t\treturn text.AppendLine( \"}\" ).ToString();\n\t}\n\n\tpublic string ToTmat( Func<string, string> resolve )\n\t{\n\t\t// TerrainMaterial image fields are source image paths, not .vmat references.\n\t\tvar values = new Dictionary<string, object>\n\t\t{\n\t\t\t[\"__version\"] = 1,\n\t\t\t[\"AlbedoImage\"] = \"materials/default/default_color.tga\",\n\t\t\t[\"NormalImage\"] = \"materials/default/default_normal.tga\",\n\t\t\t[\"RoughnessImage\"] = \"materials/default/default_rough.tga\",\n\t\t\t[\"AOImage\"] = \"materials/default/default_ao.tga\",\n\t\t\t[\"HeightImage\"] = \"materials/default/default_height.tga\"\n\t\t};\n\t\tvoid Set( string key, params string[] names )\n\t\t{\n\t\t\tvar value = Texture( resolve, names );\n\t\t\tif ( value != null ) values[key] = value;\n\t\t}\n\t\tSet( \"AlbedoImage\", \"_BaseMap\", \"_Diffuse\", \"_MainTex\", \"_Albedo\", \"_BaseColorMap\" );\n\t\tSet( \"NormalImage\", \"_BumpMap\", \"_Normal\", \"_NormalMap\" );\n\t\tSet( \"AOImage\", \"_OcclusionMap\", \"_Occlusion\" );\n\t\tSet( \"HeightImage\", \"_ParallaxMap\", \"_HeightMap\" );\n\t\tforeach ( var (channel, field) in new[] { (\"rough\", \"RoughnessImage\"), (\"ao\", \"AOImage\"), (\"height\", \"HeightImage\") } )\n\t\t\tif ( channelImages.TryGetValue( channel, out var image ) ) values[field] = image;\n\t\treturn JsonSerializer.Serialize( values, new JsonSerializerOptions { WriteIndented = true } );\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/ImportAssetPreparation.cs",
            "FileName": "ImportAssetPreparation.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Threading;\r\nusing System.Threading.Tasks;\r\nusing Editor;\r\nusing Sandbox;\r\n\r\nnamespace ImportUnityPackage;\r\n\r\npublic record ImportPreparationResult( string[] Warnings, bool Cancelled, int Rebuilt )\r\n{\r\n\tpublic string[] Errors { get; init; } = Array.Empty<string>();\r\n}\r\n\r\n/// <summary>Runs on the editor context after the imported folder has been committed.</summary>\r\npublic static class ImportAssetPreparation\r\n{\r\n\tstatic Task repairTask;\r\n\r\n\t/// <summary>Repair already compiled resources in an existing import without reimporting source files.</summary>\r\n\t[ConCmd( \"unity_import_repair_resources\" )]\r\n\tpublic static void Repair( string directory )\r\n\t{\r\n\t\tif ( repairTask is { IsCompleted: false } ) return;\r\n\t\trepairTask = RepairAsync( directory );\r\n\t}\r\n\r\n\tstatic async Task RepairAsync( string directory )\r\n\t{\r\n\t\ttry\r\n\t\t{\r\n\t\t\tvar assets = Project.Current.GetAssetsPath();\r\n\t\t\tvar root = Path.GetFullPath( Path.Combine( assets, \"imported\" ) ) + Path.DirectorySeparatorChar;\r\n\t\t\tvar target = Path.GetFullPath( Path.IsPathRooted( directory ) ? directory : Path.Combine( assets, directory ) );\r\n\t\t\tif ( !(target.Equals( root.TrimEnd( Path.DirectorySeparatorChar ), StringComparison.OrdinalIgnoreCase ) || target.StartsWith( root, StringComparison.OrdinalIgnoreCase )) || !Directory.Exists( target ) )\r\n\t\t\t\tthrow new ArgumentException( \"Choose an existing package folder under this project's Assets/imported.\" );\r\n\t\t\tvar stale = Directory.EnumerateFiles( target, \"*\", SearchOption.AllDirectories )\r\n\t\t\t\t.Where( f => ResourceOrder( f ) > 0 )\r\n\t\t\t\t.Where( f => AssetSystem.FindByPath( f ) is { IsCompiled: true } asset && !asset.IsCompiledAndUpToDate ).ToArray();\r\n\t\t\tLog.Info( $\"UNITY_RESOURCE_REPAIR: preparing {stale.Length} out-of-date resources in {target}\" );\r\n\t\t\tvar result = await Run( stale, null, CancellationToken.None );\r\n\t\t\tforeach ( var warning in result.Warnings ) Log.Warning( warning );\r\n\t\t\tforeach ( var error in result.Errors ) Log.Error( error );\r\n\t\t\tLog.Info( $\"UNITY_RESOURCE_REPAIR complete: {stale.Length} checked, {result.Rebuilt} full rebuilds, {result.Warnings.Length} warnings, {result.Errors.Length} errors.\" );\r\n\t\t}\r\n\t\tcatch ( Exception ex ) { Log.Error( $\"UNITY_RESOURCE_REPAIR failed: {ex}\" ); }\r\n\t}\r\n\r\n\tpublic static async Task<ImportPreparationResult> RunImport( ImportResult result, IProgress<ImportProgress> progress, CancellationToken cancel )\r\n\t{\r\n\t\tvar files = result.Files.ToHashSet( StringComparer.OrdinalIgnoreCase );\r\n\t\tvar warnings = new List<string>();\r\n\t\tforeach ( var changed in result.ChangedFiles )\r\n\t\t{\r\n\t\t\tif ( cancel.IsCancellationRequested ) break;\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar asset = AssetSystem.RegisterFile( changed );\r\n\t\t\t\tif ( asset == null ) continue;\r\n\t\t\t\tforeach ( var dependant in asset.GetDependants( true ) )\r\n\t\t\t\t\tif ( ResourceOrder( dependant.Path ) > 0 && dependant.HasSourceFile ) files.Add( dependant.GetSourceFile( true ) );\r\n\t\t\t}\r\n\t\t\tcatch ( Exception ex ) { warnings.Add( $\"{changed}: could not discover affected resources: {ex.Message}\" ); }\r\n\t\t}\r\n\t\tvar prepared = await Run( files, progress, cancel );\r\n\t\treturn prepared with { Warnings = warnings.Concat( prepared.Warnings ).ToArray() };\r\n\t}\r\n\r\n\tpublic static async Task<ImportPreparationResult> Run( IEnumerable<string> files, IProgress<ImportProgress> progress, CancellationToken cancel )\r\n\t{\r\n\t\tvar warnings = new List<string>();\r\n\t\tvar errors = new List<string>();\r\n\t\tvar resources = new List<Asset>();\r\n\t\tvar rebuilt = 0;\r\n\t\ttry\r\n\t\t{\r\n\t\t\t// Register all sources before compiling textures, then materials, then models.\r\n\t\t\tforeach ( var file in files.Distinct( StringComparer.OrdinalIgnoreCase ).OrderBy( ResourceOrder ) )\r\n\t\t\t{\r\n\t\t\t\tcancel.ThrowIfCancellationRequested();\r\n\t\t\t\tif ( Path.GetExtension( file ).ToLowerInvariant() is \".mat\" or \".json\" or \".mtl\" ) continue;\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tvar asset = AssetSystem.RegisterFile( file );\r\n\t\t\t\t\tif ( ResourceOrder( file ) > 0 )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tif ( asset == null ) throw new InvalidOperationException( \"Asset registration returned no resource\" );\r\n\t\t\t\t\t\tresources.Add( asset );\r\n\t\t\t\t\t}\r\n\t\t\t\t}\r\n\t\t\t\tcatch ( Exception ex ) { errors.Add( $\"{file}: registration failed: {ex.Message}\" ); }\r\n\t\t\t}\r\n\t\t\tfor ( var i = 0; i < resources.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tcancel.ThrowIfCancellationRequested();\r\n\t\t\t\tvar asset = resources[i];\r\n\t\t\t\tprogress?.Report( new( (double)i / resources.Count, $\"Preparing resource {i + 1}/{resources.Count}: {asset.Name}\" ) );\r\n\t\t\t\ttry\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( !asset.IsCompiledAndUpToDate ) await asset.CompileIfNeededAsync().AsTask().WaitAsync( cancel );\n\t\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\t\t// Compilation and publishing generated children to the registry are separate steps.\n\t\t\t\t\t// Let the editor process completion notifications before checking dependency validity.\n\t\t\t\t\tawait WaitForReady( asset, TimeSpan.FromMilliseconds( 250 ), cancel );\n\t\t\t\t\t// Incremental compilation can leave generated children absent from the asset registry,\r\n\t\t\t\t\t// even when their _c files exist. One full rebuild restores those dependencies.\r\n\t\t\t\t\tif ( !asset.IsCompileFailed && !asset.IsCompiledAndUpToDate )\r\n\t\t\t\t\t{\r\n\t\t\t\t\t\tvar accepted = asset.Compile( true );\n\t\t\t\t\t\trebuilt++;\n\t\t\t\t\t\tif ( accepted ) await WaitForReady( asset, TimeSpan.FromSeconds( 30 ), cancel );\n\t\t\t\t\t}\n\t\t\t\t\tif ( asset.IsCompileFailed || !asset.IsCompiledAndUpToDate )\n\t\t\t\t\t\terrors.Add( $\"{asset.Path}: \" + (asset.IsCompileFailed\n\t\t\t\t\t\t\t? \"the resource compiler reported a failure; see its diagnostics in the editor console.\"\n\t\t\t\t\t\t\t: !asset.IsCompiled ? \"no compiled resource became available after compilation.\"\n\t\t\t\t\t\t\t: \"compiled resource dependencies did not become ready within 30 seconds. Reimport or run unity_import_repair_resources for this folder.\") );\n\t\t\t\t}\r\n\t\t\t\tcatch ( OperationCanceledException ) { throw; }\r\n\t\t\t\tcatch ( Exception ex ) { errors.Add( $\"{asset.Path}: resource preparation failed: {ex.Message}\" ); }\r\n\t\t\t\tawait Task.Delay( 1, cancel );\r\n\t\t\t}\r\n\t\t}\r\n\t\tcatch ( OperationCanceledException ) when ( cancel.IsCancellationRequested )\r\n\t\t{\r\n\t\t\treturn new( warnings.ToArray(), true, rebuilt ) { Errors = errors.ToArray() };\r\n\t\t}\r\n\t\tprogress?.Report( new( 1, \"Import complete\" ) );\r\n\t\treturn new( warnings.ToArray(), false, rebuilt ) { Errors = errors.ToArray() };\r\n\t}\r\n\r\n\tstatic async Task WaitForReady( Asset asset, TimeSpan timeout, CancellationToken cancel )\n\t{\n\t\tvar started = System.Diagnostics.Stopwatch.StartNew();\n\t\twhile ( !asset.IsCompiledAndUpToDate && started.Elapsed < timeout )\n\t\t\tawait Task.Delay( 50, cancel );\n\t}\n\n\tstatic int ResourceOrder( string file ) => Path.GetExtension( file ).ToLowerInvariant() switch\n\t{\r\n\t\t\".vtex\" => 1,\r\n\t\t\".vmat\" or \".tmat\" => 2,\r\n\t\t\".vmdl\" => 3,\r\n\t\t_ => 0\r\n\t};\r\n}\r\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityFbxBindings.cs",
            "FileName": "UnityFbxBindings.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.RegularExpressions;\n\nnamespace ImportUnityPackage;\n\n/// <summary>FBX object hierarchy and ordered material-to-model connections; no vertex decoding needed.</summary>\ninternal sealed class UnityFbxBindings\n{\n\tinternal readonly Dictionary<long, (string Name, bool Mesh)> Nodes = new();\n\tinternal readonly Dictionary<long, string> Materials = new();\n\tinternal readonly List<(long Child, long Parent)> Connections = new();\n\treadonly Dictionary<string, long> legacyIds = new( StringComparer.Ordinal );\n\tlong Id( string value )\n\t{\n\t\tif ( long.TryParse( value, out var id ) ) return id;\n\t\tif ( value == \"Model::Scene\" ) return 0;\n\t\tif ( !legacyIds.TryGetValue( value, out id ) ) legacyIds[value] = id = -legacyIds.Count - 1;\n\t\treturn id;\n\t}\n\tinternal void ObserveAscii( string line )\n\t{\n\t\tvar item = Regex.Match( line, \"^\\\\s*(Model|Material):\\\\s*(?:([-0-9]+),\\\\s*)?\\\"(?:Model|Material)::([^\\\"]+)\\\",\\\\s*\\\"([^\\\"]*)\\\"\" );\n\t\tif ( item.Success )\n\t\t{\n\t\t\tvar kind = item.Groups[1].Value;\n\t\t\tvar id = Id( item.Groups[2].Success ? item.Groups[2].Value : kind + \"::\" + item.Groups[3].Value );\n\t\t\tif ( kind == \"Material\" ) Materials[id] = item.Groups[3].Value;\n\t\t\telse Nodes[id] = (item.Groups[3].Value, item.Groups[4].Value == \"Mesh\");\n\t\t}\n\t\tvar link = Regex.Match( line, \"^\\\\s*(?:C|Connect):\\\\s*\\\"OO\\\",\\\\s*(?:\\\"([^\\\"]+)\\\"|([-0-9]+)),\\\\s*(?:\\\"([^\\\"]+)\\\"|([-0-9]+))\" );\n\t\tif ( link.Success ) Connections.Add( (Id( link.Groups[1].Success ? link.Groups[1].Value : link.Groups[2].Value ), Id( link.Groups[3].Success ? link.Groups[3].Value : link.Groups[4].Value )) );\n\t}\n\n\tinternal void Apply( UnityModel model, bool preserveHierarchy )\n\t{\n\t\tvar parents = Connections.Where( c => Nodes.ContainsKey( c.Child ) && (c.Parent == 0 || Nodes.ContainsKey( c.Parent )) )\n\t\t\t.GroupBy( c => c.Child ).Where( g => g.Select( c => c.Parent ).Distinct().Count() == 1 ).ToDictionary( g => g.Key, g => g.First().Parent );\n\t\tvar roots = Nodes.Keys.Where( id => !parents.TryGetValue( id, out var parent ) || parent == 0 ).ToArray();\n\t\tvar ambiguous = new HashSet<long>();\n\t\tforeach ( var (id, node) in Nodes.Where( p => p.Value.Mesh ) )\n\t\t{\n\t\t\tvar names = new List<string>();\n\t\t\tvar seen = new HashSet<long>();\n\t\t\tvar current = id;\n\t\t\twhile ( Nodes.TryGetValue( current, out var ancestor ) && seen.Add( current ) )\n\t\t\t{\n\t\t\t\tnames.Add( !preserveHierarchy && roots.Length == 1 && roots[0] == current ? \"root\" : ancestor.Name );\n\t\t\t\tcurrent = parents.GetValueOrDefault( current );\n\t\t\t}\n\t\t\tif ( current != 0 ) continue; // Cyclic/malformed hierarchy cannot identify a renderer.\n\t\t\tnames.Reverse();\n\t\t\tif ( preserveHierarchy || roots.Length != 1 ) names.Insert( 0, \"root\" );\n\t\t\tvar path = \"//RootNode/\" + string.Join( \"/\", names );\n\t\t\tvar slots = Connections.Where( c => c.Parent == id && Materials.ContainsKey( c.Child ) ).Select( c => Materials[c.Child] ).ToArray();\n\t\t\tvar renderer = UnityFileId.Renderer( path );\n\t\t\tif ( !model.RendererSlots.TryAdd( renderer, new( node.Name, slots ) ) ) ambiguous.Add( renderer );\n\t\t}\n\t\tforeach ( var id in ambiguous ) model.RendererSlots.Remove( id );\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityPrefabBindings.cs",
            "FileName": "UnityPrefabBindings.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.RegularExpressions;\n\nnamespace ImportUnityPackage;\n\n/// <summary>Resolves renderer/slot assignments from prefab data, independently of publisher or material filenames.</summary>\ninternal static class UnityPrefabBindings\n{\n\tsealed class Candidate( string path )\n\t{\n\t\tinternal readonly string Path = path;\n\t\tinternal readonly Dictionary<string, HashSet<string>> Slots = new( StringComparer.OrdinalIgnoreCase );\n\t\tinternal readonly HashSet<string> Materials = new( StringComparer.OrdinalIgnoreCase );\n\t\tinternal readonly List<string> Warnings = new();\n\t\tinternal bool HasOverrides;\n\t}\n\tstatic string GuidOf( string reference ) => Regex.Match( reference, @\"\\bguid:\\s*([a-fA-F0-9]{32})\" ).Groups[1].Value;\n\tstatic long IdOf( string reference ) => long.TryParse( Regex.Match( reference, @\"\\bfileID:\\s*(-?[0-9]+)\" ).Groups[1].Value, out var id ) ? id : 0;\n\tstatic string Field( string text, string name ) => Regex.Match( text, @\"(?m)^\\s*\" + Regex.Escape( name ) + @\":\\s*\\{([^}\\r\\n]*)\\}\" ).Groups[1].Value;\n\n\tinternal static void Apply( UnityArchive archive )\n\t{\n\t\tvar models = archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ).ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );\n\t\tvar materialIds = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).Select( a => a.Guid ).ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar candidates = new Dictionary<string, List<Candidate>>( StringComparer.OrdinalIgnoreCase );\n\t\tvar visible = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar colliders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var prefab in archive.Assets.Where( a => a.Path.EndsWith( \".prefab\", StringComparison.OrdinalIgnoreCase ) ) )\n\t\t{\n\t\t\tif ( new FileInfo( prefab.Source ).Length > 16 * 1024 * 1024 ) continue;\n\t\t\tvar text = File.ReadAllText( prefab.Source );\n\t\t\tvar local = new Dictionary<string, Candidate>( StringComparer.OrdinalIgnoreCase );\n\t\t\tCandidate For( string guid )\n\t\t\t{\n\t\t\t\tif ( !local.TryGetValue( guid, out var candidate ) ) local[guid] = candidate = new( prefab.Path );\n\t\t\t\treturn candidate;\n\t\t\t}\n\t\t\tvoid Assign( string guid, UnityModel.RendererBinding binding, int index, string material )\n\t\t\t{\n\t\t\t\tvar candidate = For( guid ); candidate.HasOverrides = true;\n\t\t\t\tvar model = models[guid].ModelInfo;\n\t\t\t\tif ( binding == null ) { candidate.Warnings.Add( \"A prefab material override targets an unsupported or unidentified renderer; no slot was guessed.\" ); return; }\n\t\t\t\t// ModelDoc currently imports only LOD0 when present. Other LOD overrides must not change its slots.\n\t\t\t\tif ( model.HighestDetailMeshes.Length > 0 && !model.HighestDetailMeshes.Contains( binding.Mesh ) ) return;\n\t\t\t\tif ( index < 0 || index >= binding.Slots.Length ) { candidate.Warnings.Add( \"A prefab material override references a missing FBX material slot.\" ); return; }\n\t\t\t\tvar slot = binding.Slots[index];\n\t\t\t\tif ( !candidate.Slots.TryGetValue( slot, out var values ) ) candidate.Slots[slot] = values = new( StringComparer.OrdinalIgnoreCase );\n\t\t\t\tvalues.Add( material );\n\t\t\t\tif ( materialIds.Contains( material ) ) candidate.Materials.Add( material );\n\t\t\t}\n\t\t\tvar blocks = Regex.Matches( text, @\"(?ms)^--- !u!(?<type>[0-9]+) &[^\\r\\n]+\\r?\\n(?<body>.*?)(?=^--- !u!|\\z)\" ).Cast<Match>()\n\t\t\t\t.Select( m => (Type: m.Groups[\"type\"].Value, Body: m.Groups[\"body\"].Value) ).ToArray();\n\t\t\tvar filters = new Dictionary<long, List<string>>();\n\t\t\tforeach ( var block in blocks )\n\t\t\t{\n\t\t\t\tvar mesh = Field( block.Body, \"m_Mesh\" );\n\t\t\t\tvar guid = GuidOf( mesh );\n\t\t\t\tif ( block.Type == \"64\" && models.ContainsKey( guid ) ) colliders.Add( guid );\n\t\t\t\tif ( block.Type is \"33\" or \"137\" && models.ContainsKey( guid ) )\n\t\t\t\t{\n\t\t\t\t\tvisible.Add( guid );\n\t\t\t\t\tif ( block.Type == \"33\" )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar gameObject = IdOf( Field( block.Body, \"m_GameObject\" ) );\n\t\t\t\t\t\tif ( gameObject == 0 ) continue;\n\t\t\t\t\t\tif ( !filters.TryGetValue( gameObject, out var list ) ) filters[gameObject] = list = new();\n\t\t\t\t\t\tlist.Add( mesh );\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif ( block.Type == \"1001\" )\n\t\t\t\t{\n\t\t\t\t\tvar source = GuidOf( Field( block.Body, \"m_SourcePrefab\" ) );\n\t\t\t\t\tif ( models.ContainsKey( source ) ) visible.Add( source );\n\t\t\t\t}\n\t\t\t}\n\t\t\tforeach ( Match modification in Regex.Matches( text, @\"(?ms)^\\s*- target:\\s*\\{(?<target>[^}]+)\\}\\s*\\r?\\n(?<body>.*?)(?=^\\s*- target:|^--- !u!|\\z)\" ) )\n\t\t\t{\n\t\t\t\tvar target = modification.Groups[\"target\"].Value;\n\t\t\t\tvar guid = GuidOf( target );\n\t\t\t\tvar property = Regex.Match( modification.Groups[\"body\"].Value, @\"propertyPath:\\s*m_Materials\\.Array\\.data\\[([0-9]+)\\]\" );\n\t\t\t\tif ( !property.Success || !models.TryGetValue( guid, out var asset ) ) continue;\n\t\t\t\tvisible.Add( guid );\n\t\t\t\tif ( !int.TryParse( property.Groups[1].Value, out var index ) ) continue;\n\t\t\t\tAssign( guid, asset.ModelInfo.RendererSlots.GetValueOrDefault( IdOf( target ) ), index,\n\t\t\t\t\tGuidOf( Field( modification.Groups[\"body\"].Value, \"objectReference\" ) ) );\n\t\t\t}\n\t\t\t// Explicit MeshRenderer + MeshFilter (or SkinnedMeshRenderer) blocks in non-variant prefabs.\n\t\t\tforeach ( var block in blocks.Where( b => b.Type is \"23\" or \"137\" ) )\n\t\t\t{\n\t\t\t\tvar refs = block.Type == \"137\" ? new[] { Field( block.Body, \"m_Mesh\" ) } :\n\t\t\t\t\tfilters.GetValueOrDefault( IdOf( Field( block.Body, \"m_GameObject\" ) ) )?.ToArray() ?? Array.Empty<string>();\n\t\t\t\tif ( refs.Length != 1 || !models.TryGetValue( GuidOf( refs[0] ), out var asset ) ) continue;\n\t\t\t\tvar slots = Regex.Match( block.Body, @\"(?m)^\\s*m_Materials:[ \\t]*\\r?\\n(?<items>(?:[ \\t]*- \\{[^}\\r\\n]*\\}[ \\t]*\\r?\\n)+)\" );\n\t\t\t\tif ( !slots.Success ) continue;\n\t\t\t\tvar meshId = IdOf( refs[0] );\n\t\t\t\tvar bindings = asset.ModelInfo.RendererSlots.Values.Where( b => UnityFileId.Hash( \"Type:Mesh->\" + b.Mesh + \"0\" ) == meshId ).ToArray();\n\t\t\t\tvar binding = bindings.Length == 1 ? bindings[0] : null;\n\t\t\t\tvar index = 0;\n\t\t\t\tforeach ( Match reference in Regex.Matches( slots.Groups[\"items\"].Value, @\"\\{([^}]+)\\}\" ) ) Assign( asset.Guid, binding, index++, GuidOf( reference.Value ) );\n\t\t\t}\n\t\t\t// Keep the prior single-model fallback for legacy prefabs without renderer-slot evidence.\n\t\t\tvar references = UnityMaterial.References( text ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\t\tvar modelIds = references.Where( g => models.ContainsKey( g ) && (!colliders.Contains( g ) || visible.Contains( g )) ).ToArray();\n\t\t\tif ( modelIds.Length == 1 && !For( modelIds[0] ).HasOverrides )\n\t\t\t\tforeach ( var material in references.Where( materialIds.Contains ) ) For( modelIds[0] ).Materials.Add( material );\n\t\t\tforeach ( var (guid, candidate) in local )\n\t\t\t{\n\t\t\t\tif ( !candidates.TryGetValue( guid, out var list ) ) candidates[guid] = list = new();\n\t\t\t\tlist.Add( candidate );\n\t\t\t}\n\t\t}\n\t\tforeach ( var (guid, list) in candidates )\n\t\t{\n\t\t\tvar asset = models[guid]; var model = asset.ModelInfo;\n\t\t\tvar named = list.Where( p => Path.GetFileNameWithoutExtension( p.Path ).Equals( Path.GetFileNameWithoutExtension( asset.Path ), StringComparison.OrdinalIgnoreCase ) ).ToArray();\n\t\t\tvar chosen = named.Length > 0 ? named : list.ToArray();\n\t\t\tforeach ( var slot in chosen.SelectMany( c => c.Slots.Keys ).Distinct( StringComparer.OrdinalIgnoreCase ) )\n\t\t\t{\n\t\t\t\tvar values = chosen.SelectMany( c => c.Slots.GetValueOrDefault( slot ) ?? new HashSet<string>() ).Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t\t\t\tif ( values.Length == 1 && materialIds.Contains( values[0] ) ) model.PrefabSlotMaterials[slot] = values[0];\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tmodel.UnresolvedPrefabSlots.Add( slot );\n\t\t\t\t\tmodel.AssignmentWarnings.Add( $\"Prefab assignment for slot '{slot}' is missing or conflicting; no material was guessed.\" );\n\t\t\t\t}\n\t\t\t}\n\t\t\tmodel.PrefabMaterials.AddRange( chosen.SelectMany( c => c.Materials ).Distinct( StringComparer.OrdinalIgnoreCase ) );\n\t\t\tmodel.AssignmentWarnings.AddRange( chosen.SelectMany( c => c.Warnings ).Distinct() );\n\t\t}\n\t\tforeach ( var guid in colliders.Where( g => !visible.Contains( g ) ) ) models[guid].ModelInfo.CollisionOnly = true;\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/UnityPackageMenu.cs",
            "FileName": "UnityPackageMenu.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using Editor;\n\nnamespace ImportUnityPackage;\n\npublic static class UnityPackageMenu\n{\n\t[Menu( \"Editor\", \"Import Unity Package/Import .unitypackage...\" )]\n\tpublic static void Open()\n\t{\n\t\tvar window = new UnityPackageWindow();\n\t\twindow.Show();\n\t\twindow.Browse();\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/UnityArchive.cs",
            "FileName": "UnityArchive.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.Formats.Tar;\nusing System.IO;\nusing System.IO.Compression;\nusing System.Linq;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\npublic enum UnityAssetKind { Unsupported, Material, Texture, Model, ModelSupport }\n\npublic sealed class UnityAsset\n{\n\tpublic string Guid { get; init; }\n\tpublic string Path { get; init; }\n\tpublic string Source { get; init; }\n\tpublic string Metadata { get; init; }\n\tpublic UnityAssetKind Kind { get; init; }\n\tpublic bool Selected { get; set; }\n\tUnityModel modelInfo;\n\tpublic UnityModel ModelInfo => modelInfo ??= UnityModel.Read( this );\n}\n\npublic record ImportProgress( double Fraction, string Message, string Stage = null );\n\n/// <summary>Reads Unity's GUID/asset, GUID/pathname, GUID/asset.meta tar layout.</summary>\npublic sealed class UnityArchive : IDisposable\n{\n\tconst long MaxEntryBytes = 2L * 1024 * 1024 * 1024;\n\tconst long MaxTotalBytes = 32L * 1024 * 1024 * 1024;\n\tImportWorkspace workspace;\n\tstring scratch => workspace?.DirectoryPath;\n\tpublic bool HasExtractedFiles { get; private set; }\n\tpublic string[] CleanupWarnings => workspace?.Warnings.ToArray() ?? Array.Empty<string>();\n\tpublic string FileName { get; private set; }\n\tpublic List<UnityAsset> Assets { get; } = new();\n\tpublic List<string> Warnings { get; } = new();\n\n\tpublic static UnityArchive Read( string file, IProgress<ImportProgress> progress, CancellationToken cancel, string assetsDirectory )\n\t{\n\t\tArgumentException.ThrowIfNullOrWhiteSpace( assetsDirectory );\n\t\tif ( !System.IO.Path.IsPathFullyQualified( assetsDirectory ) ) throw new ArgumentException( \"An absolute project Assets path is required.\", nameof( assetsDirectory ) );\n\t\tvar package = new UnityArchive { FileName = file };\n\t\ttry\n\t\t{\n\t\t\tpackage.workspace = new ImportWorkspace( assetsDirectory );\n\t\t\tpackage.HasExtractedFiles = true;\n\t\t\tusing var input = File.OpenRead( file );\n\t\t\tusing var gzip = new GZipStream( input, CompressionMode.Decompress );\n\t\t\tusing var tar = new TarReader( gzip );\n\t\t\tvar entries = new Dictionary<string, Dictionary<string, string>>( StringComparer.OrdinalIgnoreCase );\n\t\t\tlong total = 0;\n\t\t\tint count = 0;\n\t\t\tTarEntry entry;\n\t\t\twhile ( (entry = tar.GetNextEntry( false )) != null )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tif ( ++count > 200000 ) throw new InvalidDataException( \"Package has too many archive entries.\" );\n\t\t\t\tif ( entry.EntryType == TarEntryType.Directory ) continue;\n\t\t\t\tif ( entry.EntryType != TarEntryType.RegularFile && entry.EntryType != TarEntryType.V7RegularFile )\n\t\t\t\t\tthrow new InvalidDataException( \"Package contains unsupported links or special archive entries.\" );\n\t\t\t\tif ( entry.Length > MaxEntryBytes || (total += entry.Length) > MaxTotalBytes )\n\t\t\t\t\tthrow new InvalidDataException( \"Package exceeds the 2 GiB per file / 32 GiB extracted size limit.\" );\n\t\t\t\tvar name = entry.Name.Replace( '\\\\', '/' );\n\t\t\t\tif ( name.StartsWith( \"./\", StringComparison.Ordinal ) ) name = name[2..];\n\t\t\t\t// Asset Store downloads include a package thumbnail outside the GUID records.\n\t\t\t\tif ( name == \".icon.png\" ) continue;\n\t\t\t\tvar parts = name.Split( '/' );\n\t\t\t\tif ( parts.Length == 2 && parts[0] == \"packagemanagermanifest\" && parts[1] is \"asset\" or \"pathname\" or \"asset.meta\" ) continue;\n\t\t\t\tif ( parts.Length != 2 || !Regex.IsMatch( parts[0], \"^[0-9a-fA-F]{32}$\" ) )\n\t\t\t\t\tthrow new InvalidDataException( $\"Invalid Unity archive entry: {entry.Name}\" );\n\t\t\t\tif ( parts[1] is not (\"asset\" or \"asset.meta\" or \"pathname\" or \"preview.png\") ) continue;\n\t\t\t\tif ( parts[1] == \"preview.png\" ) continue;\n\t\t\t\tif ( parts[1] != \"asset\" && entry.Length > 16 * 1024 * 1024 )\n\t\t\t\t\tthrow new InvalidDataException( \"Package metadata is too large.\" );\n\t\t\t\tif ( !entries.TryGetValue( parts[0], out var record ) ) entries[parts[0]] = record = new();\n\t\t\t\tif ( record.ContainsKey( parts[1] ) ) throw new InvalidDataException( $\"Duplicate archive entry: {name}\" );\n\t\t\t\tvar target = System.IO.Path.Combine( package.scratch, parts[0] + \"-\" + parts[1] + \".iup\" );\n\t\t\t\tusing ( var output = new FileStream( target, FileMode.CreateNew ) )\n\t\t\t\t\tCopy( entry.DataStream, output, cancel );\n\t\t\t\trecord[parts[1]] = target;\n\t\t\t\tprogress?.Report( new( (double)input.Position / Math.Max( 1, input.Length ), $\"Reading package ({entries.Count:N0} entries)\u2026\" ) );\n\t\t\t}\n\t\t\tvar paths = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\t\tforeach ( var (guid, record) in entries )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tif ( !record.TryGetValue( \"asset\", out var source ) ) continue; // Unity folder metadata.\n\t\t\t\tif ( !record.TryGetValue( \"pathname\", out var pathname ) ) throw new InvalidDataException( $\"Asset {guid} has no pathname.\" );\n\t\t\t\t// Unity writes the pathname on the first line, sometimes followed by a \"00\" trailer.\n\t\t\t\tvar path = SafePath( (File.ReadLines( pathname, Encoding.UTF8 ).FirstOrDefault() ?? \"\").TrimEnd( '\\0' ) );\n\t\t\t\tif ( !paths.Add( path ) ) throw new InvalidDataException( $\"Duplicate asset path: {path}\" );\n\t\t\t\tvar kind = Classify( path );\n\t\t\t\tpackage.Assets.Add( new UnityAsset { Guid = guid, Path = path, Source = source,\n\t\t\t\t\tMetadata = record.GetValueOrDefault( \"asset.meta\" ), Kind = kind,\n\t\t\t\t\tSelected = kind != UnityAssetKind.Unsupported } );\n\t\t\t}\n\t\t\tpackage.Assets.Sort( (a, b) => StringComparer.OrdinalIgnoreCase.Compare( a.Path, b.Path ) );\n\t\t\tUnityModel.AssignPrefabMaterials( package );\n\t\t\tif ( package.Assets.Count == 0 ) throw new InvalidDataException( \"This package contains no file assets.\" );\n\t\t\treturn package;\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tpackage.Dispose();\n\t\t\tif ( package.CleanupWarnings.Length > 0 ) ex.Data[\"StagingCleanupError\"] = string.Join( \"\\n\", package.CleanupWarnings );\n\t\t\tthrow;\n\t\t}\n\t}\n\n\tpublic static string SafePath( string path )\n\t{\n\t\tpath = path.Replace( '\\\\', '/' );\n\t\tif ( !path.StartsWith( \"Assets/\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\tthrow new InvalidDataException( $\"Asset path must start with Assets/: {path}\" );\n\t\tpath = path[7..];\n\t\tforeach ( var part in path.Split( '/' ) )\n\t\t{\n\t\t\tvar stem = part.Split( '.' )[0];\n\t\t\tif ( string.IsNullOrWhiteSpace( part ) || part is \".\" or \"..\" || part.EndsWith( '.' ) || part.EndsWith( ' ' ) ||\n\t\t\t\tpart.Any( c => c < 32 || \"<>:\\\"|?*\".Contains( c ) ) ||\n\t\t\t\tRegex.IsMatch( stem, \"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$\", RegexOptions.IgnoreCase ) )\n\t\t\t\tthrow new InvalidDataException( $\"Unsafe asset path: {path}\" );\n\t\t}\n\t\treturn path;\n\t}\n\n\tpublic static UnityAssetKind Classify( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch\n\t{\n\t\t\".mat\" or \".terrainlayer\" => UnityAssetKind.Material,\n\t\t\".png\" or \".jpg\" or \".jpeg\" or \".tga\" or \".tif\" or \".tiff\" or \".exr\" or \".psd\" => UnityAssetKind.Texture,\n\t\t\".fbx\" or \".obj\" or \".smd\" or \".dmx\" or \".vox\" => UnityAssetKind.Model,\n\t\t\".mtl\" => UnityAssetKind.ModelSupport,\n\t\t_ => UnityAssetKind.Unsupported\n\t};\n\n\tinternal static void Copy( Stream source, Stream target, CancellationToken cancel )\n\t{\n\t\tif ( source == null ) return;\n\t\tvar buffer = new byte[128 * 1024];\n\t\tint read;\n\t\twhile ( (read = source.Read( buffer, 0, buffer.Length )) > 0 )\n\t\t{\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\ttarget.Write( buffer, 0, read );\n\t\t}\n\t}\n\n\tpublic void Dispose()\n\t{\n\t\tworkspace?.Dispose();\n\t\tHasExtractedFiles = false;\n\t}\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/ImportMerge.cs",
            "FileName": "ImportMerge.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Security.Cryptography;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Threading;\n\nnamespace ImportUnityPackage;\n\npublic sealed class ImportFileChoice\n{\n\tpublic string Path { get; init; }\n\tpublic string Status { get; init; }\n\tpublic string Detail { get; init; }\n\tpublic bool Conflict { get; init; }\n\tpublic bool Replace { get; set; }\n\tinternal string ExistingHash { get; init; }\n\tpublic string Action => ExistingHash == null ? \"Add\" : !Conflict ? \"Reuse\" : Replace ? \"Replace\" : \"Keep existing\";\n}\n\n/// <summary>One-off content comparison. No history, backups, automatic reports or rollback.</summary>\npublic sealed class ImportMergePlan : IDisposable\n{\n\treadonly ImportPlan original;\n\treadonly ImportWorkspace workspace;\n\tImportResult prepared;\n\tbool committed;\n\tpublic string Destination { get; }\n\tpublic List<ImportFileChoice> Outputs { get; } = new();\n\tpublic string[] CleanupWarnings => workspace.Warnings.ToArray();\n\n\tImportMergePlan( ImportPlan plan, string assets )\n\t{\n\t\toriginal = plan;\n\t\tDestination = Path.GetFullPath( Path.Combine( assets, \"Imported\" ) );\n\t\tUnityImport.CheckDirectory( Destination );\n\t\tforeach ( var item in plan.Assets )\n\t\t\tif ( item.Asset.Path.Split( '/' )[0].Equals( \".iup-temp\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\tthrow new InvalidDataException( $\"Reserved importer output path: {item.Asset.Path}\" );\n\t\tworkspace = new ImportWorkspace( assets );\n\t}\n\n\tpublic static ImportMergePlan Create( ImportPlan plan, string assets, CancellationToken cancel = default )\n\t{\n\t\tcancel.ThrowIfCancellationRequested();\n\t\treturn new( plan, assets );\n\t}\n\n\tpublic void Prepare( IProgress<ImportProgress> progress, CancellationToken cancel, ExtractTextureChannel extract = null, ExtractTextureChannels extractChannels = null )\n\t{\n\t\tif ( prepared != null ) throw new InvalidOperationException( \"This import is already prepared.\" );\n\t\tprepared = UnityImport.PrepareFiles( original, Path.Combine( workspace.DirectoryPath, \"output\" ), Destination, progress, cancel, extract, extractChannels );\n\t\tprogress?.Report( new( 1, \"Comparing destination files\u2026\", \"Checking conflicts\" ) );\n\t\tforeach ( var file in prepared.Files )\n\t\t{\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\tvar path = Path.GetRelativePath( prepared.Directory, file ).Replace( '\\\\', '/' );\n\t\t\tif ( !path.EndsWith( \".iup\", StringComparison.Ordinal ) ) throw new InvalidDataException( \"Invalid staging file\" );\n\t\t\tpath = path[..^4];\n\t\t\tvar existing = HashFile( Target( path ), cancel );\n\t\t\tvar incoming = HashFile( file, cancel );\n\t\t\tvar conflict = existing != null && existing != incoming;\n\t\t\tOutputs.Add( new() { Path = path, ExistingHash = existing, Conflict = conflict,\n\t\t\t\tStatus = existing == null ? \"New\" : conflict ? \"Different\" : \"Identical\",\n\t\t\t\tDetail = conflict ? \"Keeping this file retains its current content; it may differ from the rest of the incoming package.\" : \"\" } );\n\t\t}\n\t}\n\n\tpublic ImportResult Commit( IProgress<ImportProgress> progress, CancellationToken cancel )\n\t{\n\t\tif ( prepared == null || committed ) throw new InvalidOperationException( \"Prepare a new import before writing.\" );\n\t\tcommitted = true;\n\t\t// Recheck all reviewed files before the first write. Individual writes check again below.\n\t\tforeach ( var choice in Outputs )\n\t\t\tif ( HashFile( Target( choice.Path ), cancel ) != choice.ExistingHash ) throw new IOException( $\"Destination changed since review: {choice.Path}. Review the import again.\" );\n\t\tvar writes = Outputs.Where( c => c.ExistingHash == null || c.Conflict && c.Replace ).ToArray();\n\t\tvar createdDirectories = new List<string>();\n\t\ttry\n\t\t{\n\t\t\tfor ( var i = 0; i < writes.Length; i++ )\n\t\t\t{\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tvar choice = writes[i];\n\t\t\t\tprogress?.Report( new( (double)i / Math.Max( 1, writes.Length ), $\"Writing {choice.Path}\" ) );\n\t\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\t\tvar target = Target( choice.Path );\n\t\t\t\tCreateOutputDirectories( Path.GetDirectoryName( target ), createdDirectories );\n\t\t\t\t// Staging is on the same filesystem. Rename a complete file; never leave a partial copy.\n\t\t\t\tImportStorage.Retry( () =>\n\t\t\t\t{\n\t\t\t\t\tif ( HashFile( target, cancel ) != choice.ExistingHash ) throw new IOException( $\"Destination changed while writing: {choice.Path}\" );\n\t\t\t\t\tFile.Move( Path.Combine( prepared.Directory, choice.Path + \".iup\" ), target, choice.ExistingHash != null );\n\t\t\t\t}, cancel );\n\t\t\t}\n\t\t}\n\t\tfinally { RemoveEmptyOutputDirectories( createdDirectories ); }\n\t\tvar report = JsonNode.Parse( prepared.ReportJson );\n\t\treport[\"Destination\"] = Destination;\n\t\treport[\"Files\"] = JsonSerializer.SerializeToNode( Outputs.Select( c => c.Path ).ToArray() );\n\t\treport[\"Conflicts\"] = JsonSerializer.SerializeToNode( Outputs.Where( c => c.Conflict ).Select( c => new { c.Path, c.Action } ).ToArray() );\n\t\tprogress?.Report( new( 1, \"Files imported\" ) );\n\t\treturn prepared with { Directory = Destination, Files = Outputs.Select( c => Target( c.Path ) ).ToArray(),\n\t\t\tReportJson = report.ToJsonString( new JsonSerializerOptions { WriteIndented = true } ), ChangedFiles = writes.Select( c => Target( c.Path ) ).ToArray() };\n\t}\n\n\tvoid CreateOutputDirectories( string directory, List<string> created )\n\t{\n\t\tvar missing = new Stack<string>();\n\t\twhile ( !Directory.Exists( directory ) && directory.StartsWith( Destination + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tmissing.Push( directory );\n\t\t\tdirectory = Path.GetDirectoryName( directory );\n\t\t}\n\t\tforeach ( var path in missing )\n\t\t{\n\t\t\tUnityImport.CheckDirectory( path );\n\t\t\tif ( Directory.Exists( path ) ) continue;\n\t\t\tDirectory.CreateDirectory( path );\n\t\t\tcreated.Add( path );\n\t\t}\n\t}\n\n\tvoid RemoveEmptyOutputDirectories( List<string> created )\n\t{\n\t\t// Only this run's new directories, deepest first. Never delete recursively or undo completed writes.\n\t\tforeach ( var path in created.AsEnumerable().Reverse() )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tUnityImport.CheckDirectory( path );\n\t\t\t\tif ( Directory.Exists( path ) && !Directory.EnumerateFileSystemEntries( path ).Any() )\n\t\t\t\t\tImportStorage.Retry( () => Directory.Delete( path, false ), CancellationToken.None );\n\t\t\t}\n\t\t\tcatch ( Exception ex ) when ( ex is IOException or UnauthorizedAccessException )\n\t\t\t{\n\t\t\t\tworkspace.Warnings.Add( $\"Empty output folder could not be removed: {path}. {ex.Message}\" );\n\t\t\t}\n\t\t}\n\t}\n\n\tstring Target( string path )\n\t{\n\t\tvar safe = UnityArchive.SafePath( \"Assets/\" + path );\n\t\tvar target = Path.GetFullPath( Path.Combine( Destination, safe ) );\n\t\tUnityImport.CheckDirectory( target );\n\t\tif ( Directory.Exists( target ) ) throw new IOException( $\"A directory occupies the file destination: {path}\" );\n\t\treturn target;\n\t}\n\n\tpublic static string HashFile( string path, CancellationToken cancel = default )\n\t{\n\t\tif ( !File.Exists( path ) ) return null;\n\t\tusing var input = File.OpenRead( path );\n\t\tusing var hash = IncrementalHash.CreateHash( HashAlgorithmName.SHA256 );\n\t\tvar buffer = new byte[128 * 1024];\n\t\tint count;\n\t\twhile ( (count = input.Read( buffer, 0, buffer.Length )) > 0 ) { cancel.ThrowIfCancellationRequested(); hash.AppendData( buffer, 0, count ); }\n\t\treturn Convert.ToHexString( hash.GetHashAndReset() ).ToLowerInvariant();\n\t}\n\n\tpublic void Dispose() => workspace.Dispose();\n}\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": "Editor/Core/ImportPlan.cs",
            "FileName": "ImportPlan.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 381664,
            "Code": "using System;\r\nusing System.Collections.Generic;\r\nusing System.IO;\r\nusing System.Linq;\r\nusing System.Threading;\r\n\r\nnamespace ImportUnityPackage;\r\n\r\npublic sealed record ImportDependency( UnityAsset Asset, string Reason );\r\npublic sealed record PlannedAsset( UnityAsset Asset, bool Explicit, ImportDependency[] RequiredBy, bool Vmat, bool Tmat, bool Vmdl )\r\n{\r\n\tpublic string OutputLabel => Asset.Kind switch\r\n\t{\r\n\t\tUnityAssetKind.Model => Asset.ModelInfo.CollisionOnly ? \"Collision source (FBX)\" : Vmdl ? \"Model \u2192 VMDL\" : \"Model source\",\n\t\tUnityAssetKind.Material => Vmat && Tmat ? \"Material \u2192 VMAT + TMAT\" : Vmat ? \"Material \u2192 VMAT\" : Tmat ? \"Terrain material \u2192 TMAT\" : \"Material source\",\r\n\t\tUnityAssetKind.Texture => UnityImport.NeedsTextureResource( Asset.Path ) ? \"Texture \u2192 VTEX\" : \"Texture\",\r\n\t\t_ => \"Model support\"\r\n\t};\r\n}\r\n\r\n/// <summary>A selection snapshot shared by the window, importer and import report.</summary>\r\npublic sealed class ImportPlan\r\n{\r\n\tpublic UnityArchive Archive { get; }\r\n\tpublic ImportOptions Options { get; }\r\n\tpublic IReadOnlyList<PlannedAsset> Assets { get; }\r\n\tpublic IReadOnlyList<ImportIssue> Issues { get; }\r\n\treadonly Dictionary<UnityAsset, PlannedAsset> byAsset;\r\n\tinternal ImportPlan( UnityArchive archive, ImportOptions options, PlannedAsset[] assets, ImportIssue[] issues )\r\n\t{\r\n\t\tArchive = archive; Options = options; Assets = Array.AsReadOnly( assets ); Issues = Array.AsReadOnly( issues );\r\n\t\tbyAsset = assets.ToDictionary( a => a.Asset );\r\n\t}\r\n\tpublic PlannedAsset Find( UnityAsset asset ) => byAsset.GetValueOrDefault( asset );\r\n}\r\n\r\n/// <summary>Read dependency evidence once, then resolve selections without disk access.</summary>\r\npublic sealed class ImportCatalog\r\n{\r\n\treadonly UnityArchive archive;\r\n\treadonly Dictionary<UnityAsset, List<ImportDependency>> dependencies = new();\r\n\treadonly Dictionary<UnityAsset, List<ImportIssue>> issues = new();\r\n\tImportCatalog( UnityArchive archive ) { this.archive = archive; }\r\n\r\n\tpublic static ImportCatalog Read( UnityArchive archive, CancellationToken cancel = default )\r\n\t{\r\n\t\tvar catalog = new ImportCatalog( archive );\r\n\t\tvar byGuid = archive.Assets.ToDictionary( a => a.Guid, StringComparer.OrdinalIgnoreCase );\r\n\t\tvar materials = new Dictionary<UnityAsset, UnityMaterial>();\r\n\t\tvoid Issue( UnityAsset asset, string code, string message )\r\n\t\t{\r\n\t\t\tif ( !catalog.issues.TryGetValue( asset, out var list ) ) catalog.issues[asset] = list = new();\r\n\t\t\tlist.Add( new( asset.Path, code, message ) );\r\n\t\t}\r\n\t\tvoid Link( UnityAsset parent, UnityAsset child, string reason )\r\n\t\t{\r\n\t\t\tif ( !catalog.dependencies.TryGetValue( parent, out var list ) ) catalog.dependencies[parent] = list = new();\r\n\t\t\tif ( !list.Any( d => d.Asset == child && d.Reason == reason ) ) list.Add( new( child, reason ) );\r\n\t\t}\r\n\t\tforeach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ) )\r\n\t\t{\r\n\t\t\tcancel.ThrowIfCancellationRequested();\r\n\t\t\ttry\r\n\t\t\t{\r\n\t\t\t\tvar material = UnityMaterial.Parse( UnityImport.ReadText( asset.Source ) );\r\n\t\t\t\tif ( !string.IsNullOrEmpty( material.ShaderGuid ) && byGuid.TryGetValue( material.ShaderGuid, out var shader ) && shader.Path.EndsWith( \".shader\", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\t\tmaterial.ConfigureShader( UnityImport.ReadText( shader.Source ) );\r\n\t\t\t\tmaterials[asset] = material;\r\n\t\t\t\tforeach ( var guid in material.Textures.Values.Distinct( StringComparer.OrdinalIgnoreCase ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( byGuid.TryGetValue( guid, out var texture ) && texture.Kind == UnityAssetKind.Texture ) Link( asset, texture, \"Texture reference\" );\r\n\t\t\t\t\telse Issue( asset, \"missing-texture\", $\"Missing or unsupported texture {guid}.\" );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\t\t\tcatch ( InvalidDataException ex ) { Issue( asset, \"conversion-skipped\", $\"Conversion skipped. {ex.Message}\" ); }\r\n\t\t}\r\n\t\tvar byName = archive.Assets.Where( a => a.Kind == UnityAssetKind.Material ).GroupBy( a => Path.GetFileNameWithoutExtension( a.Path ), StringComparer.OrdinalIgnoreCase )\r\n\t\t\t.ToDictionary( g => g.Key, g => g.ToArray(), StringComparer.OrdinalIgnoreCase );\r\n\t\tvar byTexture = materials.SelectMany( m => m.Value.ColorTextureGuids.Where( byGuid.ContainsKey ).Select( g => (Name: Path.GetFileNameWithoutExtension( byGuid[g].Path ), Asset: m.Key) ) )\r\n\t\t\t.GroupBy( m => m.Name, StringComparer.OrdinalIgnoreCase ).ToDictionary( g => g.Key, g => g.Select( m => m.Asset ).Distinct().ToArray(), StringComparer.OrdinalIgnoreCase );\r\n\t\tforeach ( var asset in archive.Assets.Where( a => a.Kind == UnityAssetKind.Model ) )\n\t\t{\n\t\t\tcancel.ThrowIfCancellationRequested();\n\t\t\tif ( asset.ModelInfo.CollisionOnly ) continue;\n\t\t\tforeach ( var warning in asset.ModelInfo.AssignmentWarnings ) Issue( asset, \"prefab-material-assignment\", warning );\n\t\t\tvar referencedMaterials = new HashSet<UnityAsset>();\r\n\t\t\tvoid Match( UnityAsset[] candidates, string reason )\r\n\t\t\t{\r\n\t\t\t\tvar referenced = candidates.Where( referencedMaterials.Contains ).ToArray();\r\n\t\t\t\tif ( referenced.Length > 0 ) candidates = referenced;\r\n\t\t\t\tif ( candidates.Length == 1 ) Link( asset, candidates[0], reason + \" (inferred)\" );\r\n\t\t\t\telse if ( candidates.Length > 1 ) Issue( asset, \"ambiguous-material\", $\"{reason} matches multiple materials; no dependency was inferred.\" );\r\n\t\t\t}\r\n\t\t\tvar refs = asset.ModelInfo.PrefabMaterials.AsEnumerable();\r\n\t\t\tif ( asset.Metadata != null ) refs = refs.Concat( UnityMaterial.References( UnityImport.ReadText( asset.Metadata ) ) );\r\n\t\t\tforeach ( var guid in refs.Distinct( StringComparer.OrdinalIgnoreCase ) )\r\n\t\t\t\tif ( byGuid.TryGetValue( guid, out var dependency ) && dependency.Kind is UnityAssetKind.Material or UnityAssetKind.Texture )\r\n\t\t\t\t{\r\n\t\t\t\t\tLink( asset, dependency, \"Model / prefab reference\" );\r\n\t\t\t\t\tif ( dependency.Kind == UnityAssetKind.Material ) referencedMaterials.Add( dependency );\r\n\t\t\t\t}\r\n\t\t\tforeach ( var filename in asset.ModelInfo.MaterialAlbedoFiles.Values ) Match( byTexture.GetValueOrDefault( Path.GetFileNameWithoutExtension( filename ), Array.Empty<UnityAsset>() ), $\"Color texture {filename}\" );\r\n\t\t\tforeach ( var slot in asset.ModelInfo.RenderedMaterials.Where( s => !asset.ModelInfo.PrefabSlotMaterials.ContainsKey( s ) && !asset.ModelInfo.UnresolvedPrefabSlots.Contains( s ) ) ) Match( byName.GetValueOrDefault( slot, Array.Empty<UnityAsset>() ), $\"Material slot {slot}\" );\n\t\t\tMatch( byName.GetValueOrDefault( Path.GetFileNameWithoutExtension( asset.Path ), Array.Empty<UnityAsset>() ), \"Model filename\" );\r\n\t\t}\r\n\t\treturn catalog;\r\n\t}\r\n\r\n\tpublic ImportPlan CreatePlan( ImportOptions options )\r\n\t{\r\n\t\tvar explicitAssets = archive.Assets.Where( a => a.Selected && UnityImport.IsEnabled( a, options ) ).ToHashSet();\r\n\t\tvar included = new HashSet<UnityAsset>( explicitAssets );\r\n\t\tvar requiredBy = new Dictionary<UnityAsset, List<ImportDependency>>();\r\n\t\tvar queue = new Queue<UnityAsset>( included );\r\n\t\twhile ( queue.TryDequeue( out var parent ) )\r\n\t\t{\r\n\t\t\tif ( !options.Materials || !dependencies.TryGetValue( parent, out var children ) ) continue;\r\n\t\t\tforeach ( var child in children )\r\n\t\t\t{\r\n\t\t\t\tif ( !requiredBy.TryGetValue( child.Asset, out var parents ) ) requiredBy[child.Asset] = parents = new();\r\n\t\t\t\tparents.Add( new( parent, child.Reason ) );\r\n\t\t\t\tif ( included.Add( child.Asset ) ) queue.Enqueue( child.Asset );\r\n\t\t\t}\r\n\t\t}\r\n\t\tvar entries = included.OrderBy( a => a.Kind == UnityAssetKind.Model ? 1 : 0 ).ThenBy( a => a.Path, StringComparer.OrdinalIgnoreCase ).Select( a =>\r\n\t\t{\r\n\t\t\tvar parents = requiredBy.GetValueOrDefault( a )?.ToArray() ?? Array.Empty<ImportDependency>();\r\n\t\t\tvar terrain = a.Path.EndsWith( \".terrainlayer\", StringComparison.OrdinalIgnoreCase );\r\n\t\t\tvar modelUse = parents.Any( p => p.Asset.Kind == UnityAssetKind.Model );\r\n\t\t\treturn new PlannedAsset( a, explicitAssets.Contains( a ), parents,\r\n\t\t\t\ta.Kind == UnityAssetKind.Material && options.Vmat && (!options.Automatic || !terrain || modelUse),\r\n\t\t\t\ta.Kind == UnityAssetKind.Material && (options.Tmat || options.Automatic && terrain),\r\n\t\t\t\ta.Kind == UnityAssetKind.Model && options.Vmdl && !a.ModelInfo.CollisionOnly );\n\t\t} ).ToArray();\r\n\t\treturn new( archive, options, entries, entries.Where( a => a.Vmat || a.Tmat || a.Vmdl && options.Materials )\r\n\t\t\t.Where( a => issues.ContainsKey( a.Asset ) ).SelectMany( a => issues[a.Asset] ).Distinct().ToArray() );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "grubs.importunitypackage",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 381664,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Import Unity Package\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"importunitypackage\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"grubs\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"grubs.importunitypackage\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"29\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-09-16T05:10:22.2627195Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.112.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.112.0\")]"
        }
    ]
}