feat: asset usage audit plugin

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>
This commit is contained in:
2026-09-03 17:07:59 +07:00
commit b7f5343a73
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,188 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetRegistry/AssetData.h"
class IAssetRegistry;
/**
* One dependency edge, stored by dense index rather than by FName.
*
* 8 bytes. At this project's scale (80k packages, several hundred thousand edges) the difference
* between this and a TMap<FName, TArray<FName>> is the difference between a tool that answers in
* seconds and one nobody waits for.
*/
struct FAssetUsageEdge
{
/** Dense index of the target package in FAssetUsageGraph. */
int32 TargetIndex = INDEX_NONE;
/** Edge properties as reported by the registry: Hard / Game / Build. */
UE::AssetRegistry::EDependencyProperty Properties = UE::AssetRegistry::EDependencyProperty::None;
bool IsHard() const
{
return EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Hard);
}
/** Lack of Hard is what the engine calls a soft dependency (AssetRegistryInterface.h:95). */
bool IsSoft() const
{
return !IsHard();
}
/** Lack of Game means the edge is editor-only and will not survive a cook. */
bool IsEditorOnly() const
{
return !EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Game);
}
};
/**
* Per-package facts captured once during the sweep, so later passes never re-query the registry.
*/
struct FAssetUsageNode
{
FName PackageName;
FName AssetName;
FTopLevelAssetPath ClassPath;
/**
* Gameplay class behind a Blueprint, read from the GeneratedClass asset-registry tag.
*
* Needed because a BP asset's own ClassPath is always /Script/Engine.Blueprint. Filtering
* ClassPaths alone finds zero Blueprints, which in this project means finding almost nothing.
*/
FTopLevelAssetPath GeneratedClassPath;
/** ClassPath is /Script/Engine.World. */
bool bIsLevel = false;
/** Package lives under __ExternalActors__ or __ExternalObjects__. */
bool bIsExternalPackage = false;
/** Asset is an ObjectRedirector and must be followed through, not reported. */
bool bIsRedirector = false;
};
struct FAssetUsageGraphBuildOptions
{
/** Roots to sweep. Defaults to /Game. */
TArray<FString> IncludePackagePaths;
/** Package path prefixes to skip entirely, e.g. Content/3rdParty. */
TArray<FString> ExcludePackagePaths;
/** Collect dependency edges. Off gives a much faster inventory-only pass. */
bool bGatherDependencies = true;
};
struct FAssetUsageGraphStats
{
int32 NumPackages = 0;
int32 NumEdges = 0;
int32 NumLevels = 0;
int32 NumExternalPackages = 0;
double EnumerateSeconds = 0.0;
double DependencySeconds = 0.0;
double TotalSeconds() const
{
return EnumerateSeconds + DependencySeconds;
}
};
/**
* Dense, immutable-after-build dependency graph over the project's packages.
*
* Edges are stored CSR-style: one flat FAssetUsageEdge array plus an offset table, so a node's
* dependency list is a contiguous view with no per-node allocation.
*
* The graph deliberately stores dependencies in BOTH directions. Forward edges answer "what does
* this level use"; reverse edges answer "is this asset referenced by anything at all", which is
* what separates the Unreferenced verdict from UsedByAssetsOnly.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageGraph
{
public:
/**
* Sweep the registry and build the graph.
*
* The registry must already be populated. In a commandlet the AssetRegistry module gathers
* synchronously on load; in the editor callers must wait for OnFilesLoaded first.
*/
void Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options);
void Reset();
int32 Num() const
{
return Nodes.Num();
}
bool IsValidIndex(int32 Index) const
{
return Nodes.IsValidIndex(Index);
}
/** INDEX_NONE when the package was not part of the sweep. */
int32 FindPackageIndex(FName PackageName) const
{
const int32* Found = PackageToIndex.Find(PackageName);
return Found ? *Found : INDEX_NONE;
}
const FAssetUsageNode& GetNode(int32 Index) const
{
return Nodes[Index];
}
TConstArrayView<FAssetUsageNode> GetNodes() const
{
return Nodes;
}
/** Packages this one depends on. */
TConstArrayView<FAssetUsageEdge> GetDependencies(int32 Index) const;
/** Packages that depend on this one. */
TConstArrayView<FAssetUsageEdge> GetReferencers(int32 Index) const;
/** Dense indices of every /Script/Engine.World package in the sweep. */
TConstArrayView<int32> GetLevelIndices() const
{
return LevelIndices;
}
const FAssetUsageGraphStats& GetStats() const
{
return Stats;
}
/**
* Follow a redirector chain to the asset it ultimately points at.
* Returns Index unchanged when it is not a redirector. Cycle-safe.
*/
int32 ResolveRedirector(int32 Index) const;
private:
int32 AddOrFindPackage(FName PackageName);
void BuildReverseEdges();
TArray<FAssetUsageNode> Nodes;
TMap<FName, int32> PackageToIndex;
/** CSR forward edges: Dependencies[DependencyOffsets[i] .. DependencyOffsets[i+1]). */
TArray<FAssetUsageEdge> Dependencies;
TArray<int32> DependencyOffsets;
TArray<FAssetUsageEdge> Referencers;
TArray<int32> ReferencerOffsets;
TArray<int32> LevelIndices;
FAssetUsageGraphStats Stats;
};