b7f5343a73
Editor tool for the LA and 3D departments: which assets each level uses, which are used nowhere, and export of a chosen set. Three modules. AssetUsageAuditCore holds the whole analysis and links no UI and no editor-only asset pipeline, so it stays runnable from a commandlet; AssetUsageAuditEditor holds the Slate panel and everything that needs UnrealEd or AssetTools; AssetUsageAuditTests holds 152 specs. Load-bearing decisions, each of which produces a wrong answer if undone: - Dependency queries are always Package + NoRequirements, never Hard. The map-to-external-actor edges the OFPA gatherer emits carry Game|Build without Hard, so a Hard query drops all 16117 external actor packages in this project. There is deliberately no Hard constant in the code. - Crossing into another map is allowed only from a level or its external actor package. Without that rule WP_Main reported 18136 assets, of which 9994 belonged to L_MainLevel, reached through the GameMode. - The verdict has five states, never a bool. The registry cannot see FMOD events, DataTable rows or string-built paths; those are Unknown, and the tool never proposes a deletion. - Copying .uasset files does not preserve references - they are stored as full package paths. Only the Migrate layout produces something Unreal can open; the others write a manifest so the graph can be rebuilt. Co-Authored-By: Claude Code <noreply@anthropic.com>
370 lines
12 KiB
C++
370 lines
12 KiB
C++
// NextGenium 2026. Asset Usage Audit.
|
|
|
|
#include "AssetUsageGraph.h"
|
|
#include "AssetUsageAuditCoreModule.h"
|
|
#include "AssetUsagePaths.h"
|
|
#include "AssetRegistry/ARFilter.h"
|
|
#include "AssetRegistry/IAssetRegistry.h"
|
|
#include "Engine/World.h"
|
|
#include "HAL/PlatformTime.h"
|
|
#include "Misc/PackageName.h"
|
|
#include "Misc/ScopeExit.h"
|
|
#include "UObject/ObjectRedirector.h"
|
|
|
|
namespace
|
|
{
|
|
/** Asset registry tag carrying a Blueprint's generated gameplay class. */
|
|
const FName GeneratedClassTagName(TEXT("GeneratedClass"));
|
|
|
|
/** Redirector chains are shallow in practice; this only guards against a malformed cycle. */
|
|
constexpr int32 MaxRedirectorHops = 16;
|
|
}
|
|
|
|
void FAssetUsageGraph::Reset()
|
|
{
|
|
Nodes.Reset();
|
|
PackageToIndex.Reset();
|
|
Dependencies.Reset();
|
|
DependencyOffsets.Reset();
|
|
Referencers.Reset();
|
|
ReferencerOffsets.Reset();
|
|
LevelIndices.Reset();
|
|
Stats = FAssetUsageGraphStats();
|
|
}
|
|
|
|
int32 FAssetUsageGraph::AddOrFindPackage(FName PackageName)
|
|
{
|
|
if (const int32* Existing = PackageToIndex.Find(PackageName))
|
|
{
|
|
return *Existing;
|
|
}
|
|
|
|
const int32 NewIndex = Nodes.Num();
|
|
|
|
FAssetUsageNode& Node = Nodes.AddDefaulted_GetRef();
|
|
Node.PackageName = PackageName;
|
|
Node.bIsExternalPackage = AssetUsagePaths::IsExternalPackage(PackageName);
|
|
|
|
PackageToIndex.Add(PackageName, NewIndex);
|
|
|
|
return NewIndex;
|
|
}
|
|
|
|
void FAssetUsageGraph::Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options)
|
|
{
|
|
Reset();
|
|
|
|
// Temporary caching makes repeated class-hierarchy and tag lookups cheap for the duration of
|
|
// the sweep. GetDerivedClassNames in particular is documented as slow without it.
|
|
AssetRegistry.SetTemporaryCachingMode(true);
|
|
ON_SCOPE_EXIT
|
|
{
|
|
AssetRegistry.SetTemporaryCachingMode(false);
|
|
};
|
|
|
|
FARFilter Filter;
|
|
Filter.bRecursivePaths = true;
|
|
|
|
// On-disk only: unsaved in-memory assets have no dependency data yet, and including them
|
|
// makes the result depend on which windows the user happens to have open.
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
if (Options.IncludePackagePaths.IsEmpty())
|
|
{
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
}
|
|
else
|
|
{
|
|
for (const FString& Path : Options.IncludePackagePaths)
|
|
{
|
|
Filter.PackagePaths.Add(FName(*Path));
|
|
}
|
|
}
|
|
|
|
FARCompiledFilter CompiledFilter;
|
|
AssetRegistry.CompileFilter(Filter, CompiledFilter);
|
|
|
|
const FTopLevelAssetPath WorldClassPath = UWorld::StaticClass()->GetClassPathName();
|
|
const FTopLevelAssetPath RedirectorClassPath = UObjectRedirector::StaticClass()->GetClassPathName();
|
|
|
|
// --- Pass 1: inventory -------------------------------------------------------------------
|
|
// EnumerateAssets rather than GetAssets: at 80k assets the latter materialises one giant
|
|
// TArray<FAssetData> before we have looked at a single element.
|
|
{
|
|
const double StartTime = FPlatformTime::Seconds();
|
|
|
|
AssetRegistry.EnumerateAssets(CompiledFilter, [this, &Options, &WorldClassPath, &RedirectorClassPath](const FAssetData& AssetData)
|
|
{
|
|
const FName PackageName = AssetData.PackageName;
|
|
|
|
if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
const int32 Index = AddOrFindPackage(PackageName);
|
|
FAssetUsageNode& Node = Nodes[Index];
|
|
|
|
// A package can hold several assets; keep the first as representative but never let a
|
|
// secondary asset downgrade a class we already recorded.
|
|
if (Node.AssetName.IsNone())
|
|
{
|
|
Node.AssetName = AssetData.AssetName;
|
|
Node.ClassPath = AssetData.AssetClassPath;
|
|
}
|
|
|
|
if (AssetData.AssetClassPath == WorldClassPath)
|
|
{
|
|
Node.bIsLevel = true;
|
|
Node.ClassPath = WorldClassPath;
|
|
}
|
|
|
|
if (AssetData.AssetClassPath == RedirectorClassPath)
|
|
{
|
|
Node.bIsRedirector = true;
|
|
}
|
|
|
|
// The GeneratedClass tag is the only route from a Blueprint asset to its gameplay
|
|
// class; the asset's own class is always /Script/Engine.Blueprint.
|
|
FString GeneratedClass;
|
|
if (AssetData.GetTagValue(GeneratedClassTagName, GeneratedClass) && !GeneratedClass.IsEmpty())
|
|
{
|
|
// The tag is stored in export-text form, e.g. BlueprintGeneratedClass'/Game/X/BP_Y.BP_Y_C'.
|
|
// TrySetPath rather than the FStringView constructor: a malformed tag then leaves the
|
|
// path empty instead of asserting mid-sweep.
|
|
Node.GeneratedClassPath.TrySetPath(FPackageName::ExportTextPathToObjectPath(GeneratedClass));
|
|
}
|
|
|
|
return true;
|
|
}, UE::AssetRegistry::EEnumerateAssetsFlags::OnlyOnDiskAssets);
|
|
|
|
Stats.EnumerateSeconds = FPlatformTime::Seconds() - StartTime;
|
|
}
|
|
|
|
Stats.NumPackages = Nodes.Num();
|
|
|
|
// --- Pass 2: dependency edges ------------------------------------------------------------
|
|
DependencyOffsets.SetNumZeroed(Nodes.Num() + 1);
|
|
|
|
if (Options.bGatherDependencies)
|
|
{
|
|
const double StartTime = FPlatformTime::Seconds();
|
|
|
|
TArray<FAssetDependency> EdgeBuffer;
|
|
|
|
// Node count grows as we discover dependency targets outside the swept paths (engine
|
|
// content, plugin content). Index by position rather than caching Num().
|
|
for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex)
|
|
{
|
|
DependencyOffsets[NodeIndex] = Dependencies.Num();
|
|
|
|
EdgeBuffer.Reset();
|
|
|
|
// The query that makes this tool correct. Package category, no requirement flags.
|
|
// Never EDependencyQuery::Hard: map -> external actor edges carry Game|Build without
|
|
// Hard, so a Hard query silently drops every OFPA actor in the project.
|
|
AssetRegistry.GetDependencies(
|
|
Nodes[NodeIndex].PackageName,
|
|
EdgeBuffer,
|
|
AssetUsageAudit::MakeTraversalCategory(),
|
|
AssetUsageAudit::MakeTraversalQuery());
|
|
|
|
for (const FAssetDependency& Dependency : EdgeBuffer)
|
|
{
|
|
const FName TargetPackage = Dependency.AssetId.PackageName;
|
|
|
|
if (TargetPackage.IsNone() || TargetPackage == Nodes[NodeIndex].PackageName)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (AssetUsagePaths::IsPathExcluded(TargetPackage, Options.ExcludePackagePaths))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Script packages (/Script/Engine) are code, not assets. They inflate the graph
|
|
// and can never be exported or reported as unused.
|
|
if (AssetUsagePaths::IsScriptPackage(TargetPackage))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FAssetUsageEdge& Edge = Dependencies.AddDefaulted_GetRef();
|
|
Edge.TargetIndex = AddOrFindPackage(TargetPackage);
|
|
Edge.Properties = Dependency.Properties;
|
|
}
|
|
|
|
// AddOrFindPackage above may have appended nodes; keep the offset table in step.
|
|
if (DependencyOffsets.Num() < Nodes.Num() + 1)
|
|
{
|
|
DependencyOffsets.SetNumZeroed(Nodes.Num() + 1, EAllowShrinking::No);
|
|
}
|
|
}
|
|
|
|
DependencyOffsets[Nodes.Num()] = Dependencies.Num();
|
|
|
|
Stats.DependencySeconds = FPlatformTime::Seconds() - StartTime;
|
|
}
|
|
else
|
|
{
|
|
DependencyOffsets[Nodes.Num()] = 0;
|
|
}
|
|
|
|
// Nodes discovered during pass 2 have no inventory data. Fill in what we can from the path so
|
|
// they are never reported as an unnamed blank row.
|
|
for (FAssetUsageNode& Node : Nodes)
|
|
{
|
|
if (Node.AssetName.IsNone())
|
|
{
|
|
Node.AssetName = FName(*FPackageName::GetShortName(Node.PackageName));
|
|
}
|
|
}
|
|
|
|
for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex)
|
|
{
|
|
if (Nodes[NodeIndex].bIsLevel)
|
|
{
|
|
LevelIndices.Add(NodeIndex);
|
|
}
|
|
|
|
if (Nodes[NodeIndex].bIsExternalPackage)
|
|
{
|
|
++Stats.NumExternalPackages;
|
|
}
|
|
}
|
|
|
|
BuildReverseEdges();
|
|
|
|
Stats.NumPackages = Nodes.Num();
|
|
Stats.NumEdges = Dependencies.Num();
|
|
Stats.NumLevels = LevelIndices.Num();
|
|
|
|
UE_LOG(LogAssetUsageAudit, Log,
|
|
TEXT("Graph built: %d packages, %d edges, %d levels, %d external packages. Enumerate %.2fs, dependencies %.2fs, total %.2fs."),
|
|
Stats.NumPackages, Stats.NumEdges, Stats.NumLevels, Stats.NumExternalPackages,
|
|
Stats.EnumerateSeconds, Stats.DependencySeconds, Stats.TotalSeconds());
|
|
}
|
|
|
|
void FAssetUsageGraph::BuildReverseEdges()
|
|
{
|
|
Referencers.Reset();
|
|
ReferencerOffsets.Reset();
|
|
ReferencerOffsets.SetNumZeroed(Nodes.Num() + 1);
|
|
|
|
if (Dependencies.IsEmpty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Counting sort: one pass to size each bucket, one to fill. Avoids per-node TArray growth.
|
|
for (const FAssetUsageEdge& Edge : Dependencies)
|
|
{
|
|
if (Nodes.IsValidIndex(Edge.TargetIndex))
|
|
{
|
|
++ReferencerOffsets[Edge.TargetIndex + 1];
|
|
}
|
|
}
|
|
|
|
for (int32 Index = 1; Index <= Nodes.Num(); ++Index)
|
|
{
|
|
ReferencerOffsets[Index] += ReferencerOffsets[Index - 1];
|
|
}
|
|
|
|
Referencers.SetNumUninitialized(Dependencies.Num());
|
|
|
|
TArray<int32> FillCursor = ReferencerOffsets;
|
|
|
|
for (int32 SourceIndex = 0; SourceIndex < Nodes.Num(); ++SourceIndex)
|
|
{
|
|
for (const FAssetUsageEdge& Edge : GetDependencies(SourceIndex))
|
|
{
|
|
if (!Nodes.IsValidIndex(Edge.TargetIndex))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
FAssetUsageEdge& Reverse = Referencers[FillCursor[Edge.TargetIndex]++];
|
|
Reverse.TargetIndex = SourceIndex;
|
|
Reverse.Properties = Edge.Properties;
|
|
}
|
|
}
|
|
}
|
|
|
|
TConstArrayView<FAssetUsageEdge> FAssetUsageGraph::GetDependencies(int32 Index) const
|
|
{
|
|
if (!Nodes.IsValidIndex(Index) || DependencyOffsets.Num() < Nodes.Num() + 1)
|
|
{
|
|
return TConstArrayView<FAssetUsageEdge>();
|
|
}
|
|
|
|
const int32 Begin = DependencyOffsets[Index];
|
|
const int32 End = DependencyOffsets[Index + 1];
|
|
|
|
if (Begin >= End)
|
|
{
|
|
return TConstArrayView<FAssetUsageEdge>();
|
|
}
|
|
|
|
return TConstArrayView<FAssetUsageEdge>(Dependencies.GetData() + Begin, End - Begin);
|
|
}
|
|
|
|
TConstArrayView<FAssetUsageEdge> FAssetUsageGraph::GetReferencers(int32 Index) const
|
|
{
|
|
if (!Nodes.IsValidIndex(Index) || ReferencerOffsets.Num() < Nodes.Num() + 1)
|
|
{
|
|
return TConstArrayView<FAssetUsageEdge>();
|
|
}
|
|
|
|
const int32 Begin = ReferencerOffsets[Index];
|
|
const int32 End = ReferencerOffsets[Index + 1];
|
|
|
|
if (Begin >= End)
|
|
{
|
|
return TConstArrayView<FAssetUsageEdge>();
|
|
}
|
|
|
|
return TConstArrayView<FAssetUsageEdge>(Referencers.GetData() + Begin, End - Begin);
|
|
}
|
|
|
|
int32 FAssetUsageGraph::ResolveRedirector(int32 Index) const
|
|
{
|
|
int32 Current = Index;
|
|
|
|
for (int32 Hop = 0; Hop < MaxRedirectorHops; ++Hop)
|
|
{
|
|
if (!Nodes.IsValidIndex(Current) || !Nodes[Current].bIsRedirector)
|
|
{
|
|
return Current;
|
|
}
|
|
|
|
// A redirector package depends on exactly the asset it forwards to. Take the first
|
|
// non-self dependency; there is no second candidate in a well-formed redirector.
|
|
const TConstArrayView<FAssetUsageEdge> Forward = GetDependencies(Current);
|
|
|
|
int32 Next = INDEX_NONE;
|
|
for (const FAssetUsageEdge& Edge : Forward)
|
|
{
|
|
if (Edge.TargetIndex != Current && Nodes.IsValidIndex(Edge.TargetIndex))
|
|
{
|
|
Next = Edge.TargetIndex;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (Next == INDEX_NONE)
|
|
{
|
|
// Dangling redirector: points at something outside the sweep, or at nothing.
|
|
return Current;
|
|
}
|
|
|
|
Current = Next;
|
|
}
|
|
|
|
UE_LOG(LogAssetUsageAudit, Warning,
|
|
TEXT("Redirector chain from '%s' exceeded %d hops; treating it as unresolvable."),
|
|
*Nodes[Index].PackageName.ToString(), MaxRedirectorHops);
|
|
|
|
return Current;
|
|
}
|