s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=grubs.importunitypackage&take=20
Showing code results for query:
*
(22 total matches found)
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();
}
}
}
}
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]] ) );
}
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 = ""
}
}
""";
}
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 } );
}
}
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;
}
}
}
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(); }
}
}
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 );
}
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 );
}
}
}
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] );
}
}
}
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;
}
}
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 );
}
}
}
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 } );
}
}
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
};
}
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 );
}
}
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;
}
}
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();
}
}
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;
}
}
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();
}
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() );
}
}
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\")]"
}
]
}