You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
CrowdControl/Assets/Plugins/AssetUsageDetector/Editor/AssetUsageDetector.cs

1398 lines
49 KiB
C#

// Asset Usage Detector - by Suleyman Yasir KULA (yasirkula@gmail.com)
using UnityEngine;
using UnityEditor;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;
using System.Collections.Generic;
using System.Reflection;
using System;
using System.IO;
using System.Text;
using Object = UnityEngine.Object;
#if UNITY_2018_3_OR_NEWER && !UNITY_2021_2_OR_NEWER
using PrefabStage = UnityEditor.Experimental.SceneManagement.PrefabStage;
using PrefabStageUtility = UnityEditor.Experimental.SceneManagement.PrefabStageUtility;
#endif
namespace AssetUsageDetectorNamespace
{
[Flags]
public enum SceneSearchMode { None = 0, OpenScenes = 1, ScenesInBuildSettingsAll = 2, ScenesInBuildSettingsTickedOnly = 4, AllScenes = 8 };
public partial class AssetUsageDetector
{
#region Helper Classes
[Serializable]
public class Parameters
{
public Object[] objectsToSearch = null;
public SceneSearchMode searchInScenes = SceneSearchMode.AllScenes;
public Object[] searchInScenesSubset = null;
public Object[] excludedScenesFromSearch = null;
public bool searchInSceneLightingSettings = true;
public bool searchInAssetsFolder = true;
public Object[] searchInAssetsSubset = null;
public Object[] excludedAssetsFromSearch = null;
public bool dontSearchInSourceAssets = true;
public bool searchInProjectSettings = true;
public int searchDepthLimit = 4;
public BindingFlags fieldModifiers = BindingFlags.Public | BindingFlags.NonPublic;
public BindingFlags propertyModifiers = BindingFlags.Public | BindingFlags.NonPublic;
public bool searchNonSerializableVariables = true;
public bool searchUnusedMaterialProperties = true;
public SearchRefactoring searchRefactoring = null;
public bool lazySceneSearch = true;
#if ASSET_USAGE_ADDRESSABLES
public bool addressablesSupport = false;
#endif
public bool calculateUnusedObjects = false;
public bool hideDuplicateRows = true;
public bool hideReduntantPrefabVariantLinks = true;
public bool noAssetDatabaseChanges = false;
public bool showDetailedProgressBar = true;
}
#endregion
private Parameters searchParameters;
// A set that contains the searched scene object(s), asset(s) and their sub-assets (if any)
private readonly HashSet<Object> objectsToSearchSet = new HashSet<Object>();
// Scenes of scene object(s) in objectsToSearchSet
private readonly HashSet<string> sceneObjectsToSearchScenesSet = new HashSet<string>();
// Project asset(s) in objectsToSearchSet
private readonly HashSet<Object> assetsToSearchSet = new HashSet<Object>();
// assetsToSearchSet's path(s)
private readonly HashSet<string> assetsToSearchPathsSet = new HashSet<string>();
// The root prefab objects in assetsToSearchSet that will be used to search for prefab references
private readonly List<GameObject> assetsToSearchRootPrefabs = new List<GameObject>( 4 );
// Path(s) of the assets that should be excluded from the search
private readonly HashSet<string> excludedAssetsPathsSet = new HashSet<string>();
// Extension(s) of assets that will always be searched in detail
private readonly HashSet<string> alwaysSearchedExtensionsSet = new HashSet<string>();
// Results for the currently searched scene
private SearchResultGroup currentSearchResultGroup;
// An optimization to search an object only once (key is a hash of the searched object)
private readonly Dictionary<string, ReferenceNode> searchedObjects = new Dictionary<string, ReferenceNode>( 4096 );
private readonly Dictionary<int, ReferenceNode> searchedUnityObjects = new Dictionary<int, ReferenceNode>( 32768 ); // Unity objects use their instanceIDs as key which is more performant
// Stack of SearchObject function parameters to avoid infinite loops (which happens when same object is passed as parameter to function)
private readonly List<object> callStack = new List<object>( 64 );
private Object currentSearchedObject;
private int currentDepth;
private bool searchingSourceAssets;
private bool isInPlayMode;
#if UNITY_2018_3_OR_NEWER
private PrefabStage openPrefabStage;
private GameObject openPrefabStagePrefabAsset;
#if UNITY_2020_1_OR_NEWER
private GameObject openPrefabStageContextObject;
#endif
#endif
private int searchedObjectsCount; // Number of searched objects
private double searchStartTime;
private readonly List<ReferenceNode> nodesPool = new List<ReferenceNode>( 32 );
// Search for references!
public SearchResult Run( Parameters searchParameters )
{
if( searchParameters == null )
{
Debug.LogError( "'searchParameters' mustn't be null!" );
return new SearchResult( false, null, null, null, this, searchParameters );
}
if( searchParameters.objectsToSearch == null )
{
Debug.LogError( "'objectsToSearch' list (\"SEARCHED OBJECTS\") is empty!" );
return new SearchResult( false, null, null, null, this, searchParameters );
}
#if UNITY_2018_3_OR_NEWER
openPrefabStagePrefabAsset = null;
string openPrefabStageAssetPath = null;
openPrefabStage = PrefabStageUtility.GetCurrentPrefabStage();
if( openPrefabStage != null )
{
if( !openPrefabStage.stageHandle.IsValid() )
openPrefabStage = null;
else
{
if( openPrefabStage.scene.isDirty )
{
// Don't start the search if a prefab stage is currently open and dirty (not saved)
Debug.LogError( "Save open prefab first!" );
return new SearchResult( false, null, null, null, this, searchParameters );
}
#if UNITY_2020_1_OR_NEWER
string prefabAssetPath = openPrefabStage.assetPath;
#else
string prefabAssetPath = openPrefabStage.prefabAssetPath;
#endif
openPrefabStagePrefabAsset = AssetDatabase.LoadAssetAtPath<GameObject>( prefabAssetPath );
openPrefabStageAssetPath = prefabAssetPath;
#if UNITY_2020_1_OR_NEWER
openPrefabStageContextObject = openPrefabStage.openedFromInstanceRoot;
#endif
}
}
#endif
List<SearchResultGroup> searchResult = null;
isInPlayMode = EditorApplication.isPlaying;
if( !isInPlayMode && !Utilities.AreScenesSaved() && !EditorUtility.DisplayDialog( "Asset Usage Detector", "Some scene(s) aren't saved. This may result in incorrect search results in those scene(s). Proceed?", "Yes", "Cancel" ) )
{
Debug.LogError( "Save open scene(s) first!" );
return new SearchResult( false, null, null, null, this, searchParameters );
}
// Get the scenes that are open right now
SceneSetup[] initialSceneSetup = !isInPlayMode ? EditorSceneManager.GetSceneManagerSetup() : null;
Scene activeScene = EditorSceneManager.GetActiveScene();
// Make sure that the AssetDatabase is up-to-date
AssetDatabase.SaveAssets();
try
{
this.searchParameters = searchParameters;
// Initialize commonly used variables
searchResult = new List<SearchResultGroup>(); // Overall search results
currentSearchedObject = null;
currentDepth = 0;
searchedObjectsCount = 0;
searchStartTime = EditorApplication.timeSinceStartup;
searchedObjects.Clear();
searchedUnityObjects.Clear();
animationClipUniqueBindings.Clear();
callStack.Clear();
objectsToSearchSet.Clear();
sceneObjectsToSearchScenesSet.Clear();
assetsToSearchSet.Clear();
assetsToSearchPathsSet.Clear();
assetsToSearchRootPrefabs.Clear();
excludedAssetsPathsSet.Clear();
alwaysSearchedExtensionsSet.Clear();
shaderIncludesToSearchSet.Clear();
#if UNITY_2017_3_OR_NEWER
assemblyDefinitionFilesToSearch.Clear();
#endif
if( assetDependencyCache == null )
{
LoadCache();
searchStartTime = EditorApplication.timeSinceStartup;
}
else if( !searchParameters.noAssetDatabaseChanges )
{
foreach( var cacheEntry in assetDependencyCache.Values )
cacheEntry.verified = false;
}
foreach( var cacheEntry in assetDependencyCache.Values )
cacheEntry.searchResult = CacheEntry.Result.Unknown;
lastRefreshedCacheEntry = null;
// Store the searched objects(s) in HashSets
HashSet<string> folderContentsSet = new HashSet<string>();
foreach( Object obj in searchParameters.objectsToSearch )
{
if( obj == null || obj.Equals( null ) )
continue;
if( obj.IsFolder() )
folderContentsSet.UnionWith( Utilities.EnumerateFolderContents( obj ) );
else
AddSearchedObjectToFilteredSets( obj, true );
}
foreach( string filePath in folderContentsSet )
{
// Skip scene assets
if( filePath.EndsWithFast( ".unity" ) )
continue;
Object[] assets = AssetDatabase.LoadAllAssetsAtPath( filePath );
if( assets == null || assets.Length == 0 )
continue;
for( int i = 0; i < assets.Length; i++ )
AddSearchedObjectToFilteredSets( assets[i], true );
}
// Find Project Settings to search for references. Don't search Project Settings if searched object(s) are all scene objects
// as Project Settings can't hold references to scene objects
string[] projectSettingsToSearch = new string[0];
if( searchParameters.searchInProjectSettings && assetsToSearchSet.Count > 0 )
{
string[] projectSettingsAssets = Directory.GetFiles( "ProjectSettings" );
projectSettingsToSearch = new string[projectSettingsAssets.Length];
for( int i = 0; i < projectSettingsAssets.Length; i++ )
projectSettingsToSearch[i] = "ProjectSettings/" + Path.GetFileName( projectSettingsAssets[i] );
// AssetDatabase.GetDependencies doesn't work with Project Settings assets. By adding these assets to assetsToSearchPathsSet,
// we make sure that AssetHasAnyReference returns true for these assets and they don't get excluded from the search
assetsToSearchPathsSet.UnionWith( projectSettingsToSearch );
}
// Find the scenes to search for references
HashSet<string> scenesToSearch = new HashSet<string>();
if( searchParameters.searchInScenesSubset != null && searchParameters.searchInScenesSubset.Length > 0 )
{
foreach( Object obj in searchParameters.searchInScenesSubset )
{
if( obj == null || obj.Equals( null ) )
continue;
if( !obj.IsAsset() )
continue;
if( obj.IsFolder() )
{
string[] folderContents = AssetDatabase.FindAssets( "t:SceneAsset", new string[] { AssetDatabase.GetAssetPath( obj ) } );
if( folderContents == null )
continue;
for( int i = 0; i < folderContents.Length; i++ )
scenesToSearch.Add( AssetDatabase.GUIDToAssetPath( folderContents[i] ) );
}
else if( obj is SceneAsset )
scenesToSearch.Add( AssetDatabase.GetAssetPath( obj ) );
}
}
else if( ( searchParameters.searchInScenes & SceneSearchMode.AllScenes ) == SceneSearchMode.AllScenes )
{
// Get all scenes from the Assets folder
string[] sceneGuids = AssetDatabase.FindAssets( "t:SceneAsset" );
for( int i = 0; i < sceneGuids.Length; i++ )
scenesToSearch.Add( AssetDatabase.GUIDToAssetPath( sceneGuids[i] ) );
}
else
{
if( ( searchParameters.searchInScenes & SceneSearchMode.OpenScenes ) == SceneSearchMode.OpenScenes )
{
// Get all open (and loaded) scenes
for( int i = 0; i < SceneManager.sceneCount; i++ )
{
Scene scene = SceneManager.GetSceneAt( i );
if( scene.IsValid() && scene.isLoaded )
scenesToSearch.Add( scene.path );
}
}
bool searchInScenesInBuildTickedAll = ( searchParameters.searchInScenes & SceneSearchMode.ScenesInBuildSettingsAll ) == SceneSearchMode.ScenesInBuildSettingsAll;
if( searchInScenesInBuildTickedAll || ( searchParameters.searchInScenes & SceneSearchMode.ScenesInBuildSettingsTickedOnly ) == SceneSearchMode.ScenesInBuildSettingsTickedOnly )
{
// Get all scenes in build settings
EditorBuildSettingsScene[] scenesTemp = EditorBuildSettings.scenes;
for( int i = 0; i < scenesTemp.Length; i++ )
{
if( ( searchInScenesInBuildTickedAll || scenesTemp[i].enabled ) )
scenesToSearch.Add( scenesTemp[i].path );
}
}
}
// In Play mode, only open scenes can be searched
if( isInPlayMode )
{
HashSet<string> openScenes = new HashSet<string>();
for( int i = 0; i < SceneManager.sceneCount; i++ )
{
Scene scene = SceneManager.GetSceneAt( i );
if( scene.IsValid() && scene.isLoaded )
openScenes.Add( scene.path );
}
List<string> skippedScenes = new List<string>( scenesToSearch.Count );
scenesToSearch.RemoveWhere( ( path ) =>
{
if( !openScenes.Contains( path ) )
{
skippedScenes.Add( path );
return true;
}
return false;
} );
if( skippedScenes.Count > 0 )
{
StringBuilder sb = Utilities.stringBuilder;
sb.Length = 0;
sb.Append( "Can't search unloaded scenes while in play mode, skipped " ).Append( skippedScenes.Count ).AppendLine( " scene(s):" );
for( int i = 0; i < skippedScenes.Count; i++ )
sb.Append( "- " ).AppendLine( skippedScenes[i] );
Debug.Log( sb.ToString() );
}
}
// Initialize data used by search functions
InitializeSearchFunctionsData( searchParameters );
// Initialize the nodes of searched asset(s)
foreach( Object obj in objectsToSearchSet )
searchedUnityObjects.Add( obj.GetInstanceID(), PopReferenceNode( obj ) );
// Progressbar values
int searchProgress = 0;
int searchTotalProgress = scenesToSearch.Count;
if( isInPlayMode && searchParameters.searchInScenes != SceneSearchMode.None )
searchTotalProgress++; // DontDestroyOnLoad scene
if( searchParameters.showDetailedProgressBar )
searchTotalProgress += projectSettingsToSearch.Length;
// Don't search assets if searched object(s) are all scene objects as assets can't hold references to scene objects
if( searchParameters.searchInAssetsFolder && assetsToSearchSet.Count > 0 )
{
currentSearchResultGroup = new SearchResultGroup( "Project Window (Assets)", SearchResultGroup.GroupType.Assets );
// Get the paths of all assets that are to be searched
IEnumerable<string> assetPaths;
if( searchParameters.searchInAssetsSubset == null || searchParameters.searchInAssetsSubset.Length == 0 )
{
string[] allAssetPaths = AssetDatabase.GetAllAssetPaths();
assetPaths = allAssetPaths;
if( searchParameters.showDetailedProgressBar )
searchTotalProgress += allAssetPaths.Length;
}
else
{
folderContentsSet.Clear();
foreach( Object obj in searchParameters.searchInAssetsSubset )
{
if( obj == null || obj.Equals( null ) )
continue;
if( !obj.IsAsset() )
continue;
if( obj.IsFolder() )
folderContentsSet.UnionWith( Utilities.EnumerateFolderContents( obj ) );
else
folderContentsSet.Add( AssetDatabase.GetAssetPath( obj ) );
}
assetPaths = folderContentsSet;
if( searchParameters.showDetailedProgressBar )
searchTotalProgress += folderContentsSet.Count;
}
// Calculate the path(s) of the assets that won't be searched for references
if( searchParameters.excludedAssetsFromSearch != null )
{
foreach( Object obj in searchParameters.excludedAssetsFromSearch )
{
if( obj == null || obj.Equals( null ) )
continue;
if( !obj.IsAsset() )
continue;
if( obj.IsFolder() )
excludedAssetsPathsSet.UnionWith( Utilities.EnumerateFolderContents( obj ) );
else
excludedAssetsPathsSet.Add( AssetDatabase.GetAssetPath( obj ) );
}
}
if( EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching assets", 0f ) )
throw new Exception( "Search aborted" );
foreach( string path in assetPaths )
{
if( searchParameters.showDetailedProgressBar && ++searchProgress % 30 == 1 && EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching assets", (float) searchProgress / searchTotalProgress ) )
throw new Exception( "Search aborted" );
if( excludedAssetsPathsSet.Contains( path ) )
continue;
// If asset resides inside the Assets directory and is not a scene asset
if( path.StartsWithFast( "Assets/" ) && !path.EndsWithFast( ".unity" ) )
{
if( !AssetHasAnyReference( path ) )
continue;
Object[] assets = AssetDatabase.LoadAllAssetsAtPath( path );
if( assets == null || assets.Length == 0 )
continue;
for( int i = 0; i < assets.Length; i++ )
{
// Components are already searched while searching the GameObject
if( assets[i] is Component )
continue;
BeginSearchObject( assets[i] );
}
}
}
// If a reference is found in the Project view, save the results
if( currentSearchResultGroup.NumberOfReferences > 0 )
searchResult.Add( currentSearchResultGroup );
}
// Search all assets inside the ProjectSettings folder
if( projectSettingsToSearch.Length > 0 )
{
currentSearchResultGroup = new SearchResultGroup( "Project Settings", SearchResultGroup.GroupType.ProjectSettings );
if( EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching Project Settings", (float) searchProgress / searchTotalProgress ) )
throw new Exception( "Search aborted" );
for( int i = 0; i < projectSettingsToSearch.Length; i++ )
{
if( searchParameters.showDetailedProgressBar && ++searchProgress % 30 == 1 && EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching Project Settings", (float) searchProgress / searchTotalProgress ) )
throw new Exception( "Search aborted" );
Object[] assets = AssetDatabase.LoadAllAssetsAtPath( projectSettingsToSearch[i] );
if( assets != null && assets.Length > 0 )
{
for( int j = 0; j < assets.Length; j++ )
BeginSearchObject( assets[j] );
}
}
if( currentSearchResultGroup.NumberOfReferences > 0 )
searchResult.Add( currentSearchResultGroup );
}
// Search non-serializable variables for references while searching a scene in play mode
if( isInPlayMode )
searchSerializableVariablesOnly = false;
if( scenesToSearch.Count > 0 )
{
// Calculate the path(s) of the scenes that won't be searched for references
HashSet<string> excludedScenesPathsSet = new HashSet<string>();
if( searchParameters.excludedScenesFromSearch != null )
{
foreach( Object obj in searchParameters.excludedScenesFromSearch )
{
if( obj == null || obj.Equals( null ) )
continue;
if( !obj.IsAsset() )
continue;
if( obj.IsFolder() )
{
string[] folderContents = AssetDatabase.FindAssets( "t:SceneAsset", new string[] { AssetDatabase.GetAssetPath( obj ) } );
if( folderContents == null )
continue;
for( int i = 0; i < folderContents.Length; i++ )
excludedScenesPathsSet.Add( AssetDatabase.GUIDToAssetPath( folderContents[i] ) );
}
else if( obj is SceneAsset )
excludedScenesPathsSet.Add( AssetDatabase.GetAssetPath( obj ) );
}
}
// Search scenes for references
foreach( string scenePath in scenesToSearch )
{
if( EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching scene: " + scenePath, (float) ++searchProgress / searchTotalProgress ) )
throw new Exception( "Search aborted" );
if( string.IsNullOrEmpty( scenePath ) )
continue;
if( excludedScenesPathsSet.Contains( scenePath ) )
continue;
#if UNITY_2019_2_OR_NEWER
// Skip scenes in read-only packages (Issue #36)
// Credit: https://forum.unity.com/threads/check-if-asset-inside-package-is-readonly.900902/#post-5990822
if( !scenePath.StartsWithFast( "Assets/" ) )
{
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath( scenePath );
if( packageInfo != null && packageInfo.source != UnityEditor.PackageManager.PackageSource.Embedded && packageInfo.source != UnityEditor.PackageManager.PackageSource.Local )
continue;
}
#endif
SearchScene( scenePath, searchResult, searchParameters, initialSceneSetup );
}
}
// Search through all the GameObjects under the DontDestroyOnLoad scene (if exists)
if( isInPlayMode && searchParameters.searchInScenes != SceneSearchMode.None )
{
if( EditorUtility.DisplayCancelableProgressBar( "Please wait...", "Searching scene: DontDestroyOnLoad", 1f ) )
throw new Exception( "Search aborted" );
currentSearchResultGroup = new SearchResultGroup( "DontDestroyOnLoad", SearchResultGroup.GroupType.DontDestroyOnLoad );
GameObject[] rootGameObjects = GetDontDestroyOnLoadObjects();
for( int i = 0; i < rootGameObjects.Length; i++ )
SearchGameObjectRecursively( rootGameObjects[i] );
if( currentSearchResultGroup.NumberOfReferences > 0 )
searchResult.Add( currentSearchResultGroup );
}
// Searching source assets last prevents some references from being excluded due to callStack.ContainsFast
if( !searchParameters.dontSearchInSourceAssets )
{
searchingSourceAssets = true;
foreach( Object obj in objectsToSearchSet )
{
currentSearchedObject = obj;
SearchObject( obj );
}
searchingSourceAssets = false;
}
EditorUtility.DisplayProgressBar( "Please wait...", "Post-processing search results", 1f );
InitializeSearchResultNodes( searchResult );
HashSet<Object> usedObjects = null;
if( searchResult.Count > 0 && searchParameters.calculateUnusedObjects )
CalculateUnusedObjects( searchResult, out usedObjects );
// Log some c00l stuff to console
Debug.Log( "Searched " + searchedObjectsCount + " objects in " + ( EditorApplication.timeSinceStartup - searchStartTime ).ToString( "F2" ) + " seconds" );
return new SearchResult( true, searchResult, usedObjects, initialSceneSetup, this, searchParameters );
}
catch( Exception e )
{
StringBuilder sb = Utilities.stringBuilder;
sb.Length = 0;
sb.EnsureCapacity( objectsToSearchSet.Count * 50 + callStack.Count * 50 + 500 );
sb.AppendLine( "<b>AssetUsageDetector Error:</b> The following Exception is thrown during the search. Details:" );
Object latestUnityObjectInCallStack = AppendCallStackToStringBuilder( sb );
sb.AppendLine( "Searching references of: " );
foreach( Object obj in objectsToSearchSet )
{
if( obj )
sb.Append( obj.name ).Append( " (" ).Append( obj.GetType() ).AppendLine( ")" );
}
Debug.LogError( sb.ToString(), latestUnityObjectInCallStack );
Debug.LogException( e, latestUnityObjectInCallStack );
try
{
InitializeSearchResultNodes( searchResult );
}
catch
{ }
return new SearchResult( false, searchResult, null, initialSceneSetup, this, searchParameters );
}
finally
{
currentSearchResultGroup = null;
currentSearchedObject = null;
EditorUtility.ClearProgressBar();
// If the active scene was changed during search, reset it
if( EditorSceneManager.GetActiveScene() != activeScene )
EditorSceneManager.SetActiveScene( activeScene );
#if UNITY_2018_3_OR_NEWER
// If a prefab stage was open when the search was triggered, try reopening the prefab stage after the search is completed
if( !string.IsNullOrEmpty( openPrefabStageAssetPath ) )
{
#if UNITY_2020_1_OR_NEWER
bool shouldOpenPrefabStageWithoutContext = true;
if( openPrefabStageContextObject != null && !openPrefabStageContextObject.Equals( null ) )
{
try
{
// Try to access this method: https://github.com/Unity-Technologies/UnityCsReference/blob/73925b1711847c067e607ec8371f8e9ffe7ab65d/Editor/Mono/SceneManagement/StageManager/PrefabStage/PrefabStageUtility.cs#L61-L65
MethodInfo prefabStageOpenerWithContext = typeof( PrefabStageUtility ).GetMethod( "OpenPrefab", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, null, new Type[2] { typeof( string ), typeof( GameObject ) }, null );
if( prefabStageOpenerWithContext != null )
{
prefabStageOpenerWithContext.Invoke( null, new object[2] { openPrefabStageAssetPath, openPrefabStageContextObject } );
shouldOpenPrefabStageWithoutContext = false;
}
}
catch { }
}
if( shouldOpenPrefabStageWithoutContext )
#endif
{
AssetDatabase.OpenAsset( AssetDatabase.LoadAssetAtPath<GameObject>( openPrefabStageAssetPath ) );
}
}
#endif
}
}
private void InitializeSearchResultNodes( List<SearchResultGroup> searchResult )
{
for( int i = 0; i < searchResult.Count; i++ )
{
searchResult[i].InitializeNodes( objectsToSearchSet );
// Remove empty search result groups
if( !searchResult[i].PendingSearch && searchResult[i].NumberOfReferences == 0 )
searchResult.RemoveAt( i-- );
}
}
private void CalculateUnusedObjects( List<SearchResultGroup> searchResult, out HashSet<Object> usedObjectsSet )
{
currentSearchResultGroup = new SearchResultGroup( "Unused Objects", SearchResultGroup.GroupType.UnusedObjects, false, false );
usedObjectsSet = new HashSet<Object>();
HashSet<string> usedObjectPathsSet = new HashSet<string>(); // For assets: stores the filepaths, For scene objects: stores the topmost GameObject's instanceID
foreach( SearchResultGroup searchResultGroup in searchResult )
{
for( int j = 0; j < searchResultGroup.NumberOfReferences; j++ )
{
Object obj = searchResultGroup[j].UnityObject;
if( obj is Component )
obj = ( (Component) obj ).gameObject;
if( usedObjectsSet.Add( obj ) )
{
string assetPath = AssetDatabase.GetAssetPath( obj );
if( !string.IsNullOrEmpty( assetPath ) )
usedObjectPathsSet.Add( assetPath );
else
{
for( Transform parent = ( (GameObject) obj ).transform.parent; parent != null; parent = parent.parent )
usedObjectPathsSet.Add( parent.gameObject.GetInstanceID().ToString() );
}
}
}
}
Dictionary<string, ReferenceNode> unusedMainObjectNodes = new Dictionary<string, ReferenceNode>( objectsToSearchSet.Count - usedObjectsSet.Count );
Dictionary<string, List<ReferenceNode>> unusedSubObjectNodes = new Dictionary<string, List<ReferenceNode>>( objectsToSearchSet.Count - usedObjectsSet.Count );
foreach( Object obj in objectsToSearchSet )
{
// Omit components, their GameObjects are already included in search
if( obj is Component )
continue;
// Omit assets that are invisible in Hierarchy/Inspector
if( ( obj.hideFlags & ( HideFlags.HideInInspector | HideFlags.HideInHierarchy ) ) != HideFlags.None )
continue;
if( usedObjectsSet.Contains( obj ) )
continue;
string assetPath = AssetDatabase.GetAssetPath( obj );
// Omit unused sub-assets whose parent assets are used (configurable via Settings)
if( AssetUsageDetectorSettings.MarkUsedAssetsSubAssetsAsUsed && AssetDatabase.IsSubAsset( obj ) && usedObjectsSet.Contains( AssetDatabase.LoadMainAssetAtPath( assetPath ) ) )
continue;
// Omit meshes of an imported model asset
if( obj is Mesh && !string.IsNullOrEmpty( assetPath ) && AssetDatabase.GetMainAssetTypeAtPath( assetPath ) == typeof( GameObject ) && objectsToSearchSet.Contains( AssetDatabase.LoadMainAssetAtPath( assetPath ) ) )
continue;
// Omit MonoScripts whose types can't be determined
if( obj is MonoScript && ( (MonoScript) obj ).GetClass() == null )
continue;
GameObject searchedTopmostGameObject = null;
if( obj is GameObject )
{
if( string.IsNullOrEmpty( assetPath ) )
{
for( Transform parent = ( (GameObject) obj ).transform.parent; parent != null; parent = parent.parent )
{
if( objectsToSearchSet.Contains( parent ) && !usedObjectsSet.Contains( parent.gameObject ) )
searchedTopmostGameObject = parent.gameObject;
}
}
else
{
for( Transform parent = ( (GameObject) obj ).transform.parent; parent != null; parent = parent.parent )
{
if( objectsToSearchSet.Contains( parent ) )
{
searchedTopmostGameObject = parent.gameObject;
break;
}
}
}
if( searchedTopmostGameObject && !string.IsNullOrEmpty( assetPath ) ) // Omit GameObject assets if their parent objects are already included in search
continue;
}
// Use new ReferenceNodes in UnusedObjects search result group because we don't want these nodes to be linked to the actual ReferenceNodes in any way
// (i.e. we don't use actual ReferenceNodes of these objects (GetReferenceNode) because these may have links to other nodes in unknown circumstances)
ReferenceNode node = PopReferenceNode( obj );
node.usedState = ReferenceNode.UsedState.Unused;
if( string.IsNullOrEmpty( assetPath ) )
{
if( !searchedTopmostGameObject )
{
if( obj is GameObject )
unusedMainObjectNodes[obj.GetInstanceID().ToString()] = node;
else
currentSearchResultGroup.AddReference( node );
}
else // List child GameObject scene objects under their parent GameObject
{
string dictionaryKey = searchedTopmostGameObject.GetInstanceID().ToString();
List<ReferenceNode> unusedSubObjectNodesAtPath;
if( !unusedSubObjectNodes.TryGetValue( dictionaryKey, out unusedSubObjectNodesAtPath ) )
unusedSubObjectNodes[dictionaryKey] = unusedSubObjectNodesAtPath = new List<ReferenceNode>( 2 );
unusedSubObjectNodesAtPath.Add( node );
}
}
else
{
if( AssetDatabase.IsMainAsset( obj ) )
unusedMainObjectNodes[assetPath] = node;
else
{
List<ReferenceNode> unusedSubObjectNodesAtPath;
if( !unusedSubObjectNodes.TryGetValue( assetPath, out unusedSubObjectNodesAtPath ) )
unusedSubObjectNodes[assetPath] = unusedSubObjectNodesAtPath = new List<ReferenceNode>( 2 );
unusedSubObjectNodesAtPath.Add( node );
}
}
}
foreach( KeyValuePair<string, ReferenceNode> kvPair in unusedMainObjectNodes )
{
List<ReferenceNode> unusedSubAssetNodesAtPath;
if( unusedSubObjectNodes.TryGetValue( kvPair.Key, out unusedSubAssetNodesAtPath ) )
{
currentSearchResultGroup.AddReference( kvPair.Value );
for( int i = 0; i < unusedSubAssetNodesAtPath.Count; i++ )
kvPair.Value.AddLinkTo( unusedSubAssetNodesAtPath[i] );
if( usedObjectPathsSet.Contains( kvPair.Key ) )
kvPair.Value.usedState = ReferenceNode.UsedState.MixedCollapsed;
unusedSubObjectNodes.Remove( kvPair.Key );
}
else if( !usedObjectPathsSet.Contains( kvPair.Key ) ) // If a main asset has sub-assets and all of them are used, consider the main asset as used, as well (especially useful for Sprite assets)
currentSearchResultGroup.AddReference( kvPair.Value );
else if( !AssetDatabase.Contains( (Object) kvPair.Value.nodeObject ) )
{
currentSearchResultGroup.AddReference( kvPair.Value );
kvPair.Value.usedState = ReferenceNode.UsedState.MixedCollapsed;
}
}
foreach( KeyValuePair<string, List<ReferenceNode>> kvPair in unusedSubObjectNodes ) // These aren't linked to any unusedMainObjectNodes, add them as root nodes to the search result group
{
foreach( ReferenceNode node in kvPair.Value )
currentSearchResultGroup.AddReference( node );
}
if( currentSearchResultGroup.NumberOfReferences > 0 )
{
for( int i = 0; i < currentSearchResultGroup.NumberOfReferences; i++ )
currentSearchResultGroup[i].InitializeRecursively();
searchResult.Insert( 0, currentSearchResultGroup );
}
}
// Checks if object is asset or scene object and adds it to the corresponding HashSet(s)
private void AddSearchedObjectToFilteredSets( Object obj, bool expandGameObjects )
{
if( obj == null || obj.Equals( null ) )
return;
objectsToSearchSet.Add( obj );
#if UNITY_2018_3_OR_NEWER
// When searching for references of a prefab stage object, try adding its corresponding prefab asset to the searched assets, as well
if( openPrefabStage != null && openPrefabStagePrefabAsset != null && obj is GameObject && openPrefabStage.IsPartOfPrefabContents( (GameObject) obj ) )
{
GameObject prefabStageObjectSource = ( (GameObject) obj ).FollowSymmetricHierarchy( openPrefabStage.prefabContentsRoot, openPrefabStagePrefabAsset );
if( prefabStageObjectSource != null )
AddSearchedObjectToFilteredSets( prefabStageObjectSource, expandGameObjects );
}
#endif
bool isAsset = obj.IsAsset();
if( isAsset )
{
assetsToSearchSet.Add( obj );
string assetPath = AssetDatabase.GetAssetPath( obj );
if( !string.IsNullOrEmpty( assetPath ) )
{
assetsToSearchPathsSet.Add( assetPath );
if( searchParameters.dontSearchInSourceAssets && AssetDatabase.IsMainAsset( obj ) )
excludedAssetsPathsSet.Add( assetPath );
}
GameObject go = null;
if( obj is GameObject )
go = (GameObject) obj;
else if( obj is Component )
go = ( (Component) obj ).gameObject;
if( go != null )
{
Transform transform = go.transform;
bool shouldAddRootPrefabEntry = true;
for( int i = assetsToSearchRootPrefabs.Count - 1; i >= 0; i-- )
{
Transform rootTransform = assetsToSearchRootPrefabs[i].transform;
if( transform.IsChildOf( rootTransform ) )
{
shouldAddRootPrefabEntry = false;
break;
}
if( rootTransform.IsChildOf( transform ) )
assetsToSearchRootPrefabs.RemoveAt( i );
}
if( shouldAddRootPrefabEntry )
assetsToSearchRootPrefabs.Add( go );
}
}
else
{
if( obj is GameObject )
sceneObjectsToSearchScenesSet.Add( ( (GameObject) obj ).scene.path );
else if( obj is Component )
sceneObjectsToSearchScenesSet.Add( ( (Component) obj ).gameObject.scene.path );
}
if( expandGameObjects && obj is GameObject )
{
// If searched asset is a GameObject, include its components in the search
Component[] components = ( (GameObject) obj ).GetComponents<Component>();
for( int i = 0; i < components.Length; i++ )
{
if( components[i] == null || components[i].Equals( null ) )
continue;
objectsToSearchSet.Add( components[i] );
if( isAsset )
assetsToSearchSet.Add( components[i] );
}
}
else if( obj is Component )
{
// Include searched components' GameObjects in the search, as well
AddSearchedObjectToFilteredSets( ( (Component) obj ).gameObject, false );
}
}
// Search a scene for references
private void SearchScene( string scenePath, List<SearchResultGroup> searchResult, Parameters searchParameters, SceneSetup[] initialSceneSetup )
{
Scene scene = EditorSceneManager.GetSceneByPath( scenePath );
if( isInPlayMode && !scene.isLoaded )
return;
bool canContainSceneObjectReference = scene.isLoaded && ( !EditorSceneManager.preventCrossSceneReferences || sceneObjectsToSearchScenesSet.Contains( scenePath ) );
if( !canContainSceneObjectReference )
{
bool canContainAssetReference = assetsToSearchSet.Count > 0 && ( isInPlayMode || AssetHasAnyReference( scenePath ) );
if( !canContainAssetReference )
return;
}
if( !scene.isLoaded )
{
if( searchParameters.lazySceneSearch )
{
searchResult.Add( new SearchResultGroup( scenePath, SearchResultGroup.GroupType.Scene, true, true ) );
return;
}
scene = EditorSceneManager.OpenScene( scenePath, OpenSceneMode.Additive );
}
currentSearchResultGroup = new SearchResultGroup( scenePath, SearchResultGroup.GroupType.Scene );
// Search through all the GameObjects in the scene
GameObject[] rootGameObjects = scene.GetRootGameObjects();
for( int i = 0; i < rootGameObjects.Length; i++ )
SearchGameObjectRecursively( rootGameObjects[i] );
// Search through Lighting Settings (it requires changing the active scene but don't do that in play mode)
if( searchParameters.searchInSceneLightingSettings && ( !isInPlayMode || SceneManager.GetActiveScene() == scene ) )
{
if( !isInPlayMode && EditorSceneManager.GetActiveScene() != scene )
EditorSceneManager.SetActiveScene( scene );
BeginSearchObject( lightmapSettingsGetter() );
BeginSearchObject( renderSettingsGetter() );
}
// If no references are found in the scene and if the scene is not part of the initial scene setup, close it
if( currentSearchResultGroup.NumberOfReferences == 0 )
{
if( !isInPlayMode )
{
bool sceneIsOneOfInitials = false;
for( int i = 0; i < initialSceneSetup.Length; i++ )
{
if( initialSceneSetup[i].path == scenePath )
{
if( !initialSceneSetup[i].isLoaded )
EditorSceneManager.CloseScene( scene, false );
sceneIsOneOfInitials = true;
break;
}
}
if( !sceneIsOneOfInitials )
EditorSceneManager.CloseScene( scene, true );
}
}
else
{
// Some references are found in this scene, save the results
searchResult.Add( currentSearchResultGroup );
}
}
// Search a GameObject and its children for references recursively
private void SearchGameObjectRecursively( GameObject go )
{
BeginSearchObject( go );
Transform tr = go.transform;
for( int i = 0; i < tr.childCount; i++ )
SearchGameObjectRecursively( tr.GetChild( i ).gameObject );
}
// Begin searching a root object (like a GameObject or an asset)
private void BeginSearchObject( Object obj )
{
if( obj is SceneAsset )
return;
currentSearchedObject = obj;
ReferenceNode searchResult = SearchObject( obj );
if( searchResult != null )
currentSearchResultGroup.AddReference( searchResult );
}
// Search an object for references
private ReferenceNode SearchObject( object obj )
{
if( obj == null || obj.Equals( null ) )
return null;
// Avoid recursion (which leads to stackoverflow exception) using a stack (initially, I was using callStack.ContainsFast
// here but it returned false for objects that do exist in the call stack if VFX Graph window was open)
for( int i = callStack.Count - 1; i >= 0; i-- )
{
if( callStack[i].Equals( obj ) )
return null;
}
bool searchingSourceAsset = searchingSourceAssets && ReferenceEquals( currentSearchedObject, obj );
// Hashing does not work well with structs all the time, don't cache search results for structs
if( !( obj is ValueType ) && !searchingSourceAsset )
{
// If object was searched before, return the cached result
ReferenceNode cachedResult;
if( TryGetReferenceNode( obj, out cachedResult ) )
return cachedResult;
}
searchedObjectsCount++;
ReferenceNode result;
Object unityObject = obj as Object;
if( unityObject != null )
{
// If the Object is an asset, search it in detail only if its dependencies contain at least one of the searched asset(s)
string assetPath = null;
if( unityObject.IsAsset() )
{
if( assetsToSearchSet.Count == 0 )
{
searchedUnityObjects.Add( unityObject.GetInstanceID(), null );
return null;
}
assetPath = AssetDatabase.GetAssetPath( unityObject );
if( excludedAssetsPathsSet.Contains( assetPath ) || !AssetHasAnyReference( assetPath ) )
{
searchedUnityObjects.Add( unityObject.GetInstanceID(), null );
return null;
}
}
callStack.Add( unityObject );
// Search the Object in detail
Func<object, ReferenceNode> searchFunction;
if( assetPath != null && extensionToSearchFunction.TryGetValue( Utilities.GetFileExtension( assetPath ), out searchFunction ) && AssetDatabase.IsMainAsset( unityObject ) )
result = searchFunction( unityObject );
else if( typeToSearchFunction.TryGetValue( unityObject.GetType(), out searchFunction ) )
result = searchFunction( unityObject );
else if( unityObject is Component )
result = SearchComponent( unityObject );
else
{
result = PopReferenceNode( unityObject );
SearchVariablesWithSerializedObject( result );
}
// A prefab asset should have a link to its children because when a scene object uses a prefab and a child of that prefab uses
// a searched object, the scene object needs to appear in the search results. Since prefab assets aren't automatically linked to
// their children, we need to create that link manually
if( assetPath != null && unityObject is GameObject && AssetDatabase.IsMainAsset( unityObject ) )
{
if( result == null )
result = PopReferenceNode( unityObject );
GameObject prefabGameObject = (GameObject) unityObject;
Transform[] prefabChildren = prefabGameObject.GetComponentsInChildren<Transform>( true );
for( int i = 0; i < prefabChildren.Length; i++ )
{
if( prefabChildren[i].gameObject != prefabGameObject )
result.AddLinkTo( SearchObject( prefabChildren[i].gameObject ), isWeakLink: true );
}
}
callStack.RemoveAt( callStack.Count - 1 );
}
else
{
// Comply with the recursive search limit
if( currentDepth >= searchParameters.searchDepthLimit )
return null;
callStack.Add( obj );
currentDepth++;
result = PopReferenceNode( obj );
SearchVariablesWithReflection( result );
currentDepth--;
callStack.RemoveAt( callStack.Count - 1 );
}
if( result != null && result.NumberOfOutgoingLinks == 0 )
{
PoolReferenceNode( result );
result = null;
}
// Cache the search result if we are skimming through a class (not a struct; i.e. objHash != null)
// and if the object is a UnityEngine.Object (if not, cache the result only if we have actually found something
// or we are at the root of the search; i.e. currentDepth == 0)
if( !( obj is ValueType ) && ( result != null || unityObject != null || currentDepth == 0 ) )
{
if( !searchingSourceAsset )
{
if( obj is Object )
searchedUnityObjects.Add( unityObject.GetInstanceID(), result );
else
searchedObjects.Add( GetNodeObjectHash( obj ), result );
}
else if( result != null )
{
result.CopyReferencesTo( searchedUnityObjects[unityObject.GetInstanceID()] );
PoolReferenceNode( result );
}
}
return result;
}
// Check if the asset at specified path depends on any of the references
private bool AssetHasAnyReference( string assetPath )
{
#if ASSET_USAGE_ADDRESSABLES
if( searchParameters.addressablesSupport )
return true;
#endif
if( assetsToSearchPathsSet.Contains( assetPath ) )
return true;
if( alwaysSearchedExtensionsSet.Count > 0 && alwaysSearchedExtensionsSet.Contains( Utilities.GetFileExtension( assetPath ) ) )
return true;
return AssetHasAnyReferenceInternal( assetPath );
}
// Recursively check if the asset at specified path depends on any of the references
private bool AssetHasAnyReferenceInternal( string assetPath )
{
CacheEntry cacheEntry;
if( !assetDependencyCache.TryGetValue( assetPath, out cacheEntry ) )
{
cacheEntry = new CacheEntry( assetPath );
assetDependencyCache[assetPath] = cacheEntry;
}
else if( !cacheEntry.verified )
cacheEntry.Verify( assetPath );
if( cacheEntry.searchResult != CacheEntry.Result.Unknown )
return cacheEntry.searchResult == CacheEntry.Result.Yes;
cacheEntry.searchResult = CacheEntry.Result.No;
string[] dependencies = cacheEntry.dependencies;
long[] fileSizes = cacheEntry.fileSizes;
for( int i = 0; i < dependencies.Length; i++ )
{
// If a dependency was renamed (which doesn't affect the verified hash, unfortunately),
// force refresh the asset's dependencies and search it again
if( !Directory.Exists( dependencies[i] ) ) // Calling FileInfo.Length on a directory throws FileNotFoundException
{
FileInfo assetFile = new FileInfo( dependencies[i] );
if( !assetFile.Exists || assetFile.Length != fileSizes[i] )
{
// Although not reproduced, it is reported that this section caused StackOverflowException due to infinite loop,
// if that happens, log useful information to help reproduce the issue
if( lastRefreshedCacheEntry == cacheEntry )
{
StringBuilder sb = Utilities.stringBuilder;
sb.Length = 0;
sb.EnsureCapacity( 1000 );
sb.AppendLine( "<b>Infinite loop while refreshing a cache entry, please report it to the author.</b>" ).AppendLine();
sb.Append( "Asset path: " ).AppendLine( assetPath );
for( int j = 0; j < 2; j++ )
{
if( j == 1 )
{
cacheEntry.Refresh( assetPath );
dependencies = cacheEntry.dependencies;
fileSizes = cacheEntry.fileSizes;
}
sb.AppendLine().AppendLine( j == 0 ? "Old Dependencies:" : "New Dependencies" );
for( int k = 0; k < dependencies.Length; k++ )
{
sb.Append( "- " ).Append( dependencies[k] );
if( Directory.Exists( dependencies[k] ) )
{
sb.Append( " (Dir)" );
if( fileSizes[k] != 0L )
sb.Append( " WasCachedAsFile: " ).Append( fileSizes[k] );
}
else
{
assetFile = new FileInfo( dependencies[k] );
sb.Append( " (File) " ).Append( "CachedSize: " ).Append( fileSizes[k] );
if( assetFile.Exists )
sb.Append( " RealSize: " ).Append( assetFile.Length );
else
sb.Append( " NoLongerExists" );
}
sb.AppendLine();
}
}
Debug.LogError( sb.ToString() );
return false;
}
cacheEntry.Refresh( assetPath );
cacheEntry.searchResult = CacheEntry.Result.Unknown;
lastRefreshedCacheEntry = cacheEntry;
return AssetHasAnyReferenceInternal( assetPath );
}
}
if( assetsToSearchPathsSet.Contains( dependencies[i] ) )
{
cacheEntry.searchResult = CacheEntry.Result.Yes;
return true;
}
}
for( int i = 0; i < dependencies.Length; i++ )
{
if( AssetHasAnyReferenceInternal( dependencies[i] ) )
{
cacheEntry.searchResult = CacheEntry.Result.Yes;
return true;
}
}
return false;
}
// If object was already searched, return its ReferenceNode
private bool TryGetReferenceNode( object nodeObject, out ReferenceNode referenceNode )
{
if( nodeObject is Object )
{
if( searchedUnityObjects.TryGetValue( ( (Object) nodeObject ).GetInstanceID(), out referenceNode ) )
return true;
}
else if( searchedObjects.TryGetValue( GetNodeObjectHash( nodeObject ), out referenceNode ) )
return true;
referenceNode = null;
return false;
}
// Get reference node for object
private ReferenceNode GetReferenceNode( object nodeObject )
{
ReferenceNode result;
if( nodeObject is Object )
{
int hash = ( (Object) nodeObject ).GetInstanceID();
if( !searchedUnityObjects.TryGetValue( hash, out result ) || result == null )
{
result = PopReferenceNode( nodeObject );
searchedUnityObjects[hash] = result;
}
}
else
{
string hash = GetNodeObjectHash( nodeObject );
if( !searchedObjects.TryGetValue( hash, out result ) || result == null )
{
result = PopReferenceNode( nodeObject );
searchedObjects[hash] = result;
}
}
return result;
}
// Fetch a reference node from pool
private ReferenceNode PopReferenceNode( object nodeObject )
{
ReferenceNode node;
if( nodesPool.Count == 0 )
node = new ReferenceNode();
else
{
int index = nodesPool.Count - 1;
node = nodesPool[index];
nodesPool.RemoveAt( index );
}
node.nodeObject = nodeObject;
return node;
}
// Pool a reference node
private void PoolReferenceNode( ReferenceNode node )
{
node.Clear();
nodesPool.Add( node );
}
// Get a unique-ish string hash code for a plain C# object (i.e. non-UnityEngine.Object object)
private string GetNodeObjectHash( object nodeObject )
{
return nodeObject.GetHashCode() + nodeObject.GetType().Name;
}
// Retrieve the game objects listed under the DontDestroyOnLoad scene
private GameObject[] GetDontDestroyOnLoadObjects()
{
GameObject temp = null;
try
{
temp = new GameObject();
Object.DontDestroyOnLoad( temp );
Scene dontDestroyOnLoad = temp.scene;
Object.DestroyImmediate( temp );
temp = null;
return dontDestroyOnLoad.GetRootGameObjects();
}
finally
{
if( temp != null )
Object.DestroyImmediate( temp );
}
}
// Appends contents of callStack to StringBuilder and returns the most recent Unity object in callStack
private Object AppendCallStackToStringBuilder( StringBuilder sb )
{
Object latestUnityObjectInCallStack = null;
if( callStack.Count > 0 )
{
sb.AppendLine().AppendLine( "Stack contents: " );
for( int i = callStack.Count - 1; i >= 0; i-- )
{
latestUnityObjectInCallStack = callStack[i] as Object;
if( latestUnityObjectInCallStack )
{
if( !AssetDatabase.Contains( latestUnityObjectInCallStack ) )
{
string scenePath = AssetDatabase.GetAssetOrScenePath( latestUnityObjectInCallStack );
if( !string.IsNullOrEmpty( scenePath ) && SceneManager.GetSceneByPath( scenePath ).IsValid() )
sb.Append( "Scene: " ).AppendLine( scenePath );
}
break;
}
}
for( int i = callStack.Count - 1; i >= 0; i-- )
{
sb.Append( i ).Append( ": " );
Object unityObject = callStack[i] as Object;
if( unityObject )
sb.Append( unityObject.name ).Append( " (" ).Append( unityObject.GetType() ).AppendLine( ")" );
else if( callStack[i] != null )
sb.Append( callStack[i].GetType() ).AppendLine( " object" );
else
sb.AppendLine( "<<destroyed>>" );
}
sb.AppendLine();
}
return latestUnityObjectInCallStack;
}
}
}