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 72bf94d142
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,297 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetDependencyClosure.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "AssetRegistry/AssetData.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/World.h"
#include "Misc/PackageName.h"
namespace AssetDependencyClosure
{
FString FStats::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d seed(s) -> %d package(s)"), SeedCount, TotalCount));
if (SkippedScript > 0)
{
Parts.Add(FString::Printf(TEXT("%d script"), SkippedScript));
}
if (SkippedEngine > 0)
{
Parts.Add(FString::Printf(TEXT("%d engine/temp"), SkippedEngine));
}
if (SkippedExcluded > 0)
{
Parts.Add(FString::Printf(TEXT("%d excluded by path"), SkippedExcluded));
}
if (ForeignLevelsSkipped > 0)
{
Parts.Add(FString::Printf(TEXT("%d other map(s) not followed"), ForeignLevelsSkipped));
}
Parts.Add(FString::Printf(TEXT("deepest hop %d"), DeepestHop));
if (bStoppedEarly)
{
Parts.Add(TEXT("stopped early"));
}
return FString::Join(Parts, TEXT(", "));
}
TArray<FSeedClosure> GatherPerSeed(IAssetRegistry& Registry, const TArray<FName>& Seeds, const FOptions& Options, FStats& OutStats)
{
OutStats = FStats();
TArray<FSeedClosure> Result;
Result.Reserve(Seeds.Num());
// Deduplicate seeds first: the same asset ticked twice would otherwise produce two identical
// folders, and the second would be silently renamed by the collision policy.
TSet<FName> SeenSeeds;
SeenSeeds.Reserve(Seeds.Num());
for (int32 Index = 0; Index < Seeds.Num(); ++Index)
{
const FName Seed = Seeds[Index];
if (Seed.IsNone() || SeenSeeds.Contains(Seed))
{
continue;
}
SeenSeeds.Add(Seed);
// Progress is reported per seed here, not per package: a caller showing a bar wants it
// to advance once per ticked asset, and the inner traversals have wildly uneven sizes.
if (Options.OnProgress && !Options.OnProgress(Index, Seeds.Num()))
{
OutStats.bStoppedEarly = true;
break;
}
// Each seed gets its own traversal and its own visited set. That is the whole point:
// sharing one set would give the first seed every shared dependency and leave the rest
// with folders that are missing their textures.
FOptions SeedOptions = Options;
SeedOptions.OnProgress = nullptr;
FStats SeedStats;
FSeedClosure& Closure = Result.AddDefaulted_GetRef();
Closure.Seed = Seed;
Closure.Packages = Gather(Registry, { Seed }, SeedOptions, SeedStats);
OutStats.SkippedScript += SeedStats.SkippedScript;
OutStats.SkippedEngine += SeedStats.SkippedEngine;
OutStats.SkippedExcluded += SeedStats.SkippedExcluded;
OutStats.ForeignLevelsSkipped += SeedStats.ForeignLevelsSkipped;
OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, SeedStats.DeepestHop);
OutStats.TotalCount += SeedStats.TotalCount;
++OutStats.SeedCount;
}
UE_LOG(LogAssetUsageAudit, Log,
TEXT("Per-seed closure: %d seed(s), %d package placement(s) counting duplicates."),
OutStats.SeedCount, OutStats.TotalCount);
return Result;
}
TArray<FName> Gather(IAssetRegistry& Registry, const TArray<FName>& Seeds, const FOptions& Options, FStats& OutStats)
{
OutStats = FStats();
TArray<FName> Result;
TSet<FName> Visited;
// Hop count per queued entry, kept alongside the queue rather than in a map: the queue is
// the only place an index is needed and a parallel array is one allocation instead of N.
TArray<int32> Hops;
Result.Reserve(Seeds.Num() * 4);
Visited.Reserve(Seeds.Num() * 4);
const auto ShouldKeep = [&Options, &OutStats](FName PackageName) -> bool
{
if (AssetUsagePaths::IsScriptPackage(PackageName))
{
++OutStats.SkippedScript;
return false;
}
const FString PackageStr = PackageName.ToString();
if (!Options.bIncludeEnginePackages && AssetUsagePaths::IsEngineOrTempPackage(PackageStr))
{
++OutStats.SkippedEngine;
return false;
}
if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths))
{
++OutStats.SkippedExcluded;
return false;
}
return true;
};
// Which packages are maps, answered once each. The registry call allocates an FAssetData
// array, and the same package is reached from many referencers, so the cache turns tens of
// thousands of lookups into one per distinct package.
TMap<FName, bool> LevelCache;
const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool
{
if (const bool* Cached = LevelCache.Find(PackageName))
{
return *Cached;
}
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
bool bIsLevel = false;
for (const FAssetData& AssetData : Assets)
{
if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
bIsLevel = true;
break;
}
}
LevelCache.Add(PackageName, bIsLevel);
return bIsLevel;
};
// Seeds are admitted without the path filters. Someone who ticked a row is entitled to the
// file for that row even if it sits under an excluded folder; the filters bound what the
// traversal drags in behind it, which is a different question.
//
// A seed that is itself a map is a level export: the user asked for that location, so its
// contents are the point. Foreign maps found later are a different matter entirely.
// Levels the closure has admitted, seeds first. A level may only be crossed into from
// another level or from an external actor package, so this set has to grow as structural
// crossings are accepted - otherwise a sublevel of a sublevel would be refused.
TSet<FName> LevelsInResult;
for (FName Seed : Seeds)
{
if (Seed.IsNone() || Visited.Contains(Seed))
{
continue;
}
if (!FPackageName::IsValidLongPackageName(Seed.ToString()))
{
continue;
}
Visited.Add(Seed);
Result.Add(Seed);
Hops.Add(0);
++OutStats.SeedCount;
if (IsLevelPackage(Seed))
{
LevelsInResult.Add(Seed);
}
}
TArray<FName> ScratchDependencies;
for (int32 Head = 0; Head < Result.Num(); ++Head)
{
if (Options.OnProgress && !Options.OnProgress(Head, Result.Num()))
{
OutStats.bStoppedEarly = true;
break;
}
const int32 CurrentHop = Hops[Head];
if (Options.MaxDepth > 0 && CurrentHop >= Options.MaxDepth)
{
continue;
}
ScratchDependencies.Reset();
// Package category, no requirements. Never Hard: in EDependencyQuery Soft is defined as
// NotHard, so Hard|Soft matches nothing, and a Hard-only query drops every soft edge -
// including every TSoftObjectPtr a Blueprint resolves at runtime and every OFPA package.
Registry.GetDependencies(
Result[Head],
ScratchDependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : ScratchDependencies)
{
if (Dependency.IsNone() || Visited.Contains(Dependency))
{
continue;
}
// Marked visited even when dropped, so a package excluded by path is not re-tested
// once per referencer. On a mesh library that is thousands of redundant string
// comparisons.
Visited.Add(Dependency);
// The map boundary. Reaching another World is only legitimate when the reference
// comes from a level or from one of its external actor packages - the shape a
// streaming sublevel or a Level Instance actually has. A World named by an ordinary
// asset is a different location that happens to be mentioned, and following it
// exports someone else's map: measured on WP_Main, the chain
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994
// packages. FLevelUsageResolver applies the same rule; the two must not disagree
// about the same graph.
if (IsLevelPackage(Dependency))
{
const FName Source = Result[Head];
const bool bStructuralCrossing =
LevelsInResult.Contains(Source) || AssetUsagePaths::IsExternalPackage(Source);
if (!bStructuralCrossing)
{
++OutStats.ForeignLevelsSkipped;
UE_LOG(LogAssetUsageAudit, Verbose,
TEXT("Not following '%s': reached from '%s', which is not a level or an external actor package."),
*Dependency.ToString(), *Source.ToString());
continue;
}
LevelsInResult.Add(Dependency);
}
if (!ShouldKeep(Dependency))
{
continue;
}
Result.Add(Dependency);
Hops.Add(CurrentHop + 1);
OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, CurrentHop + 1);
}
}
OutStats.TotalCount = Result.Num();
UE_LOG(LogAssetUsageAudit, Log, TEXT("Dependency closure: %s"), *OutStats.Summarise());
return Result;
}
}
@@ -0,0 +1,239 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportLayout.h"
#include "AssetTypeFilter.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetRegistry/AssetData.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Misc/PackageName.h"
#include "Misc/PathViews.h"
namespace AssetExportLayout
{
namespace
{
/** Folder for anything no preset covers. Never empty - see the header. */
const TCHAR* OtherFolderName = TEXT("Other");
/**
* Make a package name safe to use as a folder name.
*
* Asset names are already constrained by Unreal, but a folder name is not the same thing as
* an asset name and the target may be any filesystem. FPaths::MakeValidFileName replaces
* what Windows rejects; the empty check catches a name that was nothing but invalid
* characters, which would otherwise produce a group writing into the export root.
*/
FString MakeFolderName(FName PackageName)
{
FString Name = FPackageName::GetShortName(PackageName.ToString());
Name = FPaths::MakeValidFileName(Name, TEXT('_'));
return Name.IsEmpty() ? FString(TEXT("Asset")) : Name;
}
}
FString FStats::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d group(s)"), GroupCount));
Parts.Add(FString::Printf(TEXT("%d distinct package(s)"), DistinctPackages));
// Always stated, even when equal to DistinctPackages: under FolderPerAsset the gap between
// the two is the surprise, and a reader should not have to infer that they match.
Parts.Add(FString::Printf(TEXT("%d file placement(s)"), FilePlacements));
if (RenamedFolders > 0)
{
Parts.Add(FString::Printf(TEXT("%d folder name(s) made unique"), RenamedFolders));
}
return FString::Join(Parts, TEXT(", "));
}
TMap<FTopLevelAssetPath, FString> BuildTypeFolderMap(IAssetRegistry& Registry)
{
TMap<FTopLevelAssetPath, FString> FolderByClass;
for (EAssetTypePreset Preset : GetAllAssetTypePresets())
{
const TArray<FTopLevelAssetPath> Seeds = GetPresetClassPaths(Preset);
if (Seeds.IsEmpty())
{
continue;
}
const FString FolderName = LexToString(Preset);
TSet<FTopLevelAssetPath> Derived;
Registry.GetDerivedClassNames(Seeds, TSet<FTopLevelAssetPath>(), Derived);
// Seeds added explicitly: GetDerivedClassNames returns strict descendants in some
// builds and includes the seeds in others. The same reason FAssetTypeFilter::Compile
// does it, and the same consequence if it is forgotten - Material would match every
// material instance but not Material itself.
for (const FTopLevelAssetPath& ClassPath : Seeds)
{
FolderByClass.FindOrAdd(ClassPath, FolderName);
}
for (const FTopLevelAssetPath& ClassPath : Derived)
{
// FindOrAdd, not Add: presets overlap (a Niagara system is also a UObject subclass
// reachable from more than one seed) and the first preset to claim a class wins.
// Overwriting would make the folder depend on preset iteration order.
FolderByClass.FindOrAdd(ClassPath, FolderName);
}
}
return FolderByClass;
}
FString TypeFolderForPackage(IAssetRegistry& Registry, FName PackageName, const TMap<FTopLevelAssetPath, FString>& FolderByClass)
{
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
for (const FAssetData& AssetData : Assets)
{
if (const FString* Folder = FolderByClass.Find(AssetData.AssetClassPath))
{
return *Folder;
}
}
return OtherFolderName;
}
TArray<FGroup> BuildGroups(IAssetRegistry& Registry, const TArray<AssetDependencyClosure::FSeedClosure>& SeedClosures, const FOptions& Options, FStats& OutStats)
{
OutStats = FStats();
TArray<FGroup> Groups;
TSet<FName> DistinctPackages;
// Flat and MirrorTree do not care which seed pulled a package in, so they collapse to one
// group and let the exporter's own bFlatten decide the path. FolderPerAsset is the only
// layout that needs the per-seed structure, and it is the only one that pays for it.
if (Options.Layout != EExportLayout::FolderPerAsset)
{
FGroup& Group = Groups.AddDefaulted_GetRef();
for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures)
{
for (FName Package : Closure.Packages)
{
if (!DistinctPackages.Contains(Package))
{
DistinctPackages.Add(Package);
Group.Packages.Add(Package);
}
}
}
OutStats.GroupCount = Groups.Num();
OutStats.DistinctPackages = DistinctPackages.Num();
OutStats.FilePlacements = Group.Packages.Num();
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise());
return Groups;
}
// Folder names must be unique across the export, and asset names are not: SM_Rock can exist
// in three different folders in /Game. Two seeds sharing a short name would otherwise write
// into one folder and interleave their dependencies.
TSet<FString> UsedFolderNames;
const TMap<FTopLevelAssetPath, FString> FolderByClass = Options.bGroupDependenciesByType
? BuildTypeFolderMap(Registry)
: TMap<FTopLevelAssetPath, FString>();
for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures)
{
if (Closure.Packages.IsEmpty())
{
continue;
}
FString FolderName = MakeFolderName(Closure.Seed);
if (UsedFolderNames.Contains(FolderName))
{
const FString Base = FolderName;
int32 Suffix = 1;
while (UsedFolderNames.Contains(FolderName))
{
FolderName = FString::Printf(TEXT("%s_%d"), *Base, Suffix++);
}
++OutStats.RenamedFolders;
}
UsedFolderNames.Add(FolderName);
// The seed sits at the root of its own folder; dependencies go beside it, or into
// per-type subfolders when asked. Grouping the seed too would bury the subject of the
// folder among the things it needs.
FGroup& SeedGroup = Groups.AddDefaulted_GetRef();
SeedGroup.RelativeDir = FolderName;
SeedGroup.Packages.Add(Closure.Seed);
DistinctPackages.Add(Closure.Seed);
++OutStats.FilePlacements;
// One group per type subfolder, built lazily so a folder is only created for a type
// that actually has files in it.
TMap<FString, int32> GroupIndexByDir;
for (FName Package : Closure.Packages)
{
if (Package == Closure.Seed)
{
continue;
}
DistinctPackages.Add(Package);
++OutStats.FilePlacements;
FString RelativeDir = FolderName;
if (Options.bGroupDependenciesByType)
{
RelativeDir = FPaths::Combine(FolderName, TypeFolderForPackage(Registry, Package, FolderByClass));
}
if (int32* ExistingIndex = GroupIndexByDir.Find(RelativeDir))
{
Groups[*ExistingIndex].Packages.Add(Package);
continue;
}
// The dependency group for the seed's own folder is the one already created above;
// reuse it rather than adding a second group with the same RelativeDir, which the
// exporter would treat as a separate destination.
if (RelativeDir == FolderName)
{
SeedGroup.Packages.Add(Package);
GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1);
continue;
}
FGroup& TypeGroup = Groups.AddDefaulted_GetRef();
TypeGroup.RelativeDir = RelativeDir;
TypeGroup.Packages.Add(Package);
GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1);
}
}
OutStats.GroupCount = Groups.Num();
OutStats.DistinctPackages = DistinctPackages.Num();
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise());
return Groups;
}
}
@@ -0,0 +1,171 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportManifest.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Dom/JsonObject.h"
#include "Misc/App.h"
#include "Misc/EngineVersion.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
namespace AssetExportManifest
{
FResult Write(IAssetRegistry& Registry, const TArray<FAssetUsageExporter::FWrittenFile>& WrittenFiles, const FOptions& Options)
{
FResult Result;
if (Options.TargetDirectory.IsEmpty())
{
Result.ErrorMessage = TEXT("No target directory was given.");
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: no target directory."));
return Result;
}
if (WrittenFiles.IsEmpty())
{
// Deliberately an error. The usual cause is a caller that forgot bRecordWrittenFiles,
// and a manifest listing nothing beside a folder full of assets would be taken at face
// value by whatever reads it next.
Result.ErrorMessage = TEXT("No written files were recorded; the exporter must run with bRecordWrittenFiles.");
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Manifest not written: the exporter recorded no files. Set FOptions::bRecordWrittenFiles."));
return Result;
}
if (Options.Layout == EExportLayout::Migrate)
{
Result.ErrorMessage = TEXT("Migrate preserves package paths itself; a manifest would be misleading.");
UE_LOG(LogAssetUsageAudit, Warning,
TEXT("Manifest not written: the Migrate layout keeps references intact, so there is nothing to restore."));
return Result;
}
// Where each package ended up, so dependency entries can point at a file in this folder
// rather than at a package path the reader has no way to resolve.
TMap<FName, FString> PathByPackage;
PathByPackage.Reserve(WrittenFiles.Num());
for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles)
{
// First writer wins. Under FolderPerAsset the same package is written into several
// folders; the per-entry "file" field below is the authoritative one for each copy, and
// this map only serves the "present in this export" test.
if (!PathByPackage.Contains(Written.PackageName))
{
PathByPackage.Add(Written.PackageName, Written.RelativePath);
}
}
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetNumberField(TEXT("schemaVersion"), SchemaVersion);
Root->SetStringField(TEXT("tool"), TEXT("AssetUsageAudit"));
Root->SetStringField(TEXT("generatedAt"), FDateTime::UtcNow().ToIso8601());
Root->SetStringField(TEXT("project"), FApp::GetProjectName());
Root->SetStringField(TEXT("engineVersion"), FEngineVersion::Current().ToString());
Root->SetStringField(TEXT("layout"), LexToString(Options.Layout));
Root->SetStringField(TEXT("collisionPolicy"),
Options.CollisionPolicy == EExportCollisionPolicy::Overwrite ? TEXT("Overwrite") : TEXT("Index"));
Root->SetStringField(TEXT("note"),
TEXT("Paths are relative to this file's folder. 'package' is the original Unreal package name; ")
TEXT("restoring an asset means putting its file back at that package path. 'dependencies' lists ")
TEXT("the packages each asset referenced at export time, whether or not they are in this folder."));
TArray<TSharedPtr<FJsonValue>> Entries;
Entries.Reserve(WrittenFiles.Num());
TArray<FName> ScratchDependencies;
for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles)
{
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
Entry->SetStringField(TEXT("package"), Written.PackageName.ToString());
Entry->SetStringField(TEXT("file"), Written.RelativePath);
Entry->SetBoolField(TEXT("seed"), Options.SeedPackages.Contains(Written.PackageName));
// Asset name and class, when the registry knows the package. A package can hold several
// assets; the first is representative and the class is only a hint for a reader.
TArray<FAssetData> AssetsInPackage;
Registry.GetAssetsByPackageName(Written.PackageName, AssetsInPackage, /*bIncludeOnlyOnDiskAssets=*/true);
if (!AssetsInPackage.IsEmpty())
{
Entry->SetStringField(TEXT("asset"), AssetsInPackage[0].AssetName.ToString());
Entry->SetStringField(TEXT("class"), AssetsInPackage[0].AssetClassPath.ToString());
}
// Direct dependencies only. The full closure is recoverable by walking these entries,
// and storing it per asset would repeat most of the file for no extra information.
ScratchDependencies.Reset();
Registry.GetDependencies(
Written.PackageName,
ScratchDependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
TArray<TSharedPtr<FJsonValue>> Dependencies;
Dependencies.Reserve(ScratchDependencies.Num());
for (FName Dependency : ScratchDependencies)
{
if (Dependency.IsNone())
{
continue;
}
TSharedRef<FJsonObject> DependencyEntry = MakeShared<FJsonObject>();
DependencyEntry->SetStringField(TEXT("package"), Dependency.ToString());
// Whether the dependency travelled with the export. A reader needs this to tell
// "restore this link from a file here" from "this link points outside the folder".
if (const FString* DependencyPath = PathByPackage.Find(Dependency))
{
DependencyEntry->SetStringField(TEXT("file"), *DependencyPath);
}
Dependencies.Add(MakeShared<FJsonValueObject>(DependencyEntry));
++Result.EdgesRecorded;
}
Entry->SetArrayField(TEXT("dependencies"), Dependencies);
Entries.Add(MakeShared<FJsonValueObject>(Entry));
}
Root->SetArrayField(TEXT("assets"), Entries);
FString Output;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
if (!FJsonSerializer::Serialize(Root, Writer))
{
Result.ErrorMessage = TEXT("Could not serialise the manifest.");
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: serialisation failed."));
return Result;
}
const FString FilePath = FPaths::Combine(Options.TargetDirectory, FileName);
if (!FFileHelper::SaveStringToFile(Output, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
Result.ErrorMessage = FString::Printf(TEXT("Could not write '%s'."), *FilePath);
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: could not save '%s'."), *FilePath);
return Result;
}
Result.bSuccess = true;
Result.FilePath = FilePath;
Result.EntriesWritten = WrittenFiles.Num();
UE_LOG(LogAssetUsageAudit, Log,
TEXT("Manifest written: %d entries, %d dependency edges, '%s'."),
Result.EntriesWritten, Result.EdgesRecorded, *FilePath);
return Result;
}
}
@@ -0,0 +1,132 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportNaming.h"
#include "AssetUsageAuditCoreModule.h"
namespace AssetExportNaming
{
namespace
{
/** Guard against a pathological folder where every candidate is taken. */
constexpr int32 MaxCollisionAttempts = 100000;
bool IsAllDigits(const FString& Value)
{
if (Value.IsEmpty())
{
return false;
}
for (const TCHAR Char : Value)
{
if (!FChar::IsDigit(Char))
{
return false;
}
}
return true;
}
}
void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth)
{
OutStem = BaseName;
OutIndex = INDEX_NONE;
OutPadWidth = 0;
int32 SeparatorPos = INDEX_NONE;
if (!BaseName.FindLastChar(TEXT('_'), SeparatorPos))
{
return;
}
// A leading underscore is part of the name, not a separator: "_1" has no stem.
if (SeparatorPos <= 0)
{
return;
}
const FString Suffix = BaseName.Mid(SeparatorPos + 1);
// "Foo_" has an empty suffix, "SM_Rock_02_v3" has a non-numeric one. Neither is an index.
if (!IsAllDigits(Suffix))
{
return;
}
OutStem = BaseName.Left(SeparatorPos);
OutIndex = FCString::Atoi(*Suffix);
OutPadWidth = Suffix.Len();
}
FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth)
{
if (Index == INDEX_NONE)
{
return Stem;
}
FString Digits = FString::FromInt(Index);
// Preserve the original zero padding, but never truncate: Foo_09 -> Foo_10, Foo_99 -> Foo_100.
while (Digits.Len() < PadWidth)
{
Digits.InsertAt(0, TEXT('0'));
}
return FString::Printf(TEXT("%s_%s"), *Stem, *Digits);
}
FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists)
{
if (Policy == EExportCollisionPolicy::Overwrite)
{
return DesiredFileName;
}
if (!Exists(DesiredFileName))
{
return DesiredFileName;
}
// Split off the extension so the index lands before it: SM_Rock.uasset -> SM_Rock_1.uasset.
// Note this splits on the LAST dot, so a name like "M_Glass.v2.uasset" keeps "M_Glass.v2" as the base.
FString BaseName = DesiredFileName;
FString Extension;
int32 DotPos = INDEX_NONE;
if (DesiredFileName.FindLastChar(TEXT('.'), DotPos) && DotPos > 0)
{
BaseName = DesiredFileName.Left(DotPos);
Extension = DesiredFileName.Mid(DotPos); // includes the dot
}
FString Stem;
int32 Index = INDEX_NONE;
int32 PadWidth = 0;
SplitTrailingIndex(BaseName, Stem, Index, PadWidth);
// No trailing index yet: start at _1. Otherwise increment what is already there,
// rather than appending a second suffix and producing Foo_1_1.
int32 NextIndex = (Index == INDEX_NONE) ? 1 : Index + 1;
const int32 EffectivePadWidth = (Index == INDEX_NONE) ? 1 : PadWidth;
for (int32 Attempt = 0; Attempt < MaxCollisionAttempts; ++Attempt, ++NextIndex)
{
const FString Candidate = ComposeIndexedName(Stem, NextIndex, EffectivePadWidth) + Extension;
if (!Exists(Candidate))
{
return Candidate;
}
}
// Every candidate taken. Returning the desired name would silently overwrite, which is the
// one outcome the Index policy exists to prevent - so fail loudly instead.
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Could not find a free filename for '%s' after %d attempts. Export of this asset will be skipped."),
*DesiredFileName, MaxCollisionAttempts);
return FString();
}
}
@@ -0,0 +1,216 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetTypeFilter.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsageGraph.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "UObject/Class.h"
#include "UObject/UObjectGlobals.h"
const TCHAR* LexToString(EAssetTypePreset Preset)
{
switch (Preset)
{
case EAssetTypePreset::StaticMesh: return TEXT("StaticMesh");
case EAssetTypePreset::SkeletalMesh: return TEXT("SkeletalMesh");
case EAssetTypePreset::Material: return TEXT("Material");
case EAssetTypePreset::Texture: return TEXT("Texture");
case EAssetTypePreset::VFX: return TEXT("VFX");
case EAssetTypePreset::Sound: return TEXT("Sound");
case EAssetTypePreset::Blueprint: return TEXT("Blueprint");
case EAssetTypePreset::Level: return TEXT("Level");
case EAssetTypePreset::DataAsset: return TEXT("DataAsset");
case EAssetTypePreset::Animation: return TEXT("Animation");
}
return TEXT("Unknown");
}
TArray<EAssetTypePreset> GetAllAssetTypePresets()
{
return {
EAssetTypePreset::StaticMesh,
EAssetTypePreset::SkeletalMesh,
EAssetTypePreset::Material,
EAssetTypePreset::Texture,
EAssetTypePreset::VFX,
EAssetTypePreset::Sound,
EAssetTypePreset::Blueprint,
EAssetTypePreset::Level,
EAssetTypePreset::DataAsset,
EAssetTypePreset::Animation
};
}
TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset)
{
const auto Path = [](const TCHAR* InPath)
{
return FTopLevelAssetPath(InPath);
};
switch (Preset)
{
case EAssetTypePreset::StaticMesh:
return { Path(TEXT("/Script/Engine.StaticMesh")) };
case EAssetTypePreset::SkeletalMesh:
return { Path(TEXT("/Script/Engine.SkeletalMesh")) };
case EAssetTypePreset::Material:
// MaterialInterface covers Material, MaterialInstanceConstant and MaterialInstanceDynamic;
// material functions are a separate hierarchy and must be named explicitly.
return {
Path(TEXT("/Script/Engine.MaterialInterface")),
Path(TEXT("/Script/Engine.MaterialFunctionInterface"))
};
case EAssetTypePreset::Texture:
return { Path(TEXT("/Script/Engine.Texture")) };
case EAssetTypePreset::VFX:
// Niagara classes live in the Niagara module. They resolve only when the plugin is
// enabled; unresolved entries are reported rather than silently dropped.
return {
Path(TEXT("/Script/Niagara.NiagaraSystem")),
Path(TEXT("/Script/Niagara.NiagaraEmitter")),
Path(TEXT("/Script/Engine.ParticleSystem"))
};
case EAssetTypePreset::Sound:
// FMOD is this project's audio system. FMOD assets are included for completeness, but
// FMOD resolves events by string path outside the UObject graph, so their usage verdict
// is unreliable by construction - the report marks them Unknown.
return {
Path(TEXT("/Script/Engine.SoundBase")),
Path(TEXT("/Script/FMODStudio.FMODAsset"))
};
case EAssetTypePreset::Blueprint:
return { Path(TEXT("/Script/Engine.Blueprint")) };
case EAssetTypePreset::Level:
return { Path(TEXT("/Script/Engine.World")) };
case EAssetTypePreset::DataAsset:
return {
Path(TEXT("/Script/Engine.DataAsset")),
Path(TEXT("/Script/Engine.DataTable"))
};
case EAssetTypePreset::Animation:
return {
Path(TEXT("/Script/Engine.AnimationAsset")),
Path(TEXT("/Script/Engine.AnimBlueprint"))
};
}
return {};
}
void FAssetTypeFilter::AddPreset(EAssetTypePreset Preset)
{
for (const FTopLevelAssetPath& ClassPath : GetPresetClassPaths(Preset))
{
RequestedClasses.AddUnique(ClassPath);
}
RequestedDescriptions.AddUnique(LexToString(Preset));
bCompiled = false;
}
void FAssetTypeFilter::AddRawClass(const FString& ClassPathOrName)
{
const FString Trimmed = ClassPathOrName.TrimStartAndEnd();
if (Trimmed.IsEmpty())
{
return;
}
FTopLevelAssetPath Resolved;
if (Trimmed.StartsWith(TEXT("/")))
{
Resolved.TrySetPath(Trimmed);
}
else
{
// A short name like "StaticMesh". TryFindType walks loaded classes, which is the only way
// to accept the shorthand the UI offers without making the user type /Script/Module.Class.
if (const UClass* Found = UClass::TryFindTypeSlow<UClass>(Trimmed, EFindFirstObjectOptions::ExactClass))
{
Resolved = Found->GetClassPathName();
}
}
if (Resolved.IsNull())
{
UnresolvedClasses.AddUnique(Trimmed);
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Class filter '%s' did not resolve to a class; ignoring it."), *Trimmed);
return;
}
RequestedClasses.AddUnique(Resolved);
RequestedDescriptions.AddUnique(Trimmed);
bCompiled = false;
}
void FAssetTypeFilter::Compile(IAssetRegistry& AssetRegistry)
{
ExpandedClasses.Reset();
if (RequestedClasses.IsEmpty())
{
bCompiled = true;
return;
}
TSet<FTopLevelAssetPath> Derived;
AssetRegistry.GetDerivedClassNames(RequestedClasses, TSet<FTopLevelAssetPath>(), Derived);
// GetDerivedClassNames returns strict descendants in some builds and includes the seeds in
// others. Adding the seeds explicitly makes the result the same either way.
for (const FTopLevelAssetPath& ClassPath : RequestedClasses)
{
ExpandedClasses.Add(ClassPath);
}
ExpandedClasses.Append(Derived);
bCompiled = true;
}
bool FAssetTypeFilter::Matches(const FAssetUsageNode& Node) const
{
if (RequestedClasses.IsEmpty())
{
return true;
}
ensureMsgf(bCompiled, TEXT("FAssetTypeFilter::Matches called before Compile; subclasses will be missed."));
if (!Node.ClassPath.IsNull() && ExpandedClasses.Contains(Node.ClassPath))
{
return true;
}
// The Blueprint case. Asking "is this a StaticMeshActor" of a BP asset only works through the
// generated class, because the asset's own class is /Script/Engine.Blueprint.
if (!Node.GeneratedClassPath.IsNull() && ExpandedClasses.Contains(Node.GeneratedClassPath))
{
return true;
}
return false;
}
FString FAssetTypeFilter::Describe() const
{
if (RequestedDescriptions.IsEmpty())
{
return TEXT("All types");
}
return FString::Join(RequestedDescriptions, TEXT(", "));
}
@@ -0,0 +1,17 @@
#include "AssetUsageAuditCoreModule.h"
DEFINE_LOG_CATEGORY(LogAssetUsageAudit);
#define LOCTEXT_NAMESPACE "FAssetUsageAuditCoreModule"
void FAssetUsageAuditCoreModule::StartupModule()
{
}
void FAssetUsageAuditCoreModule::ShutdownModule()
{
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FAssetUsageAuditCoreModule, AssetUsageAuditCore)
@@ -0,0 +1,77 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditTypes.h"
const TCHAR* LexToString(EAssetUsageVerdict Verdict)
{
switch (Verdict)
{
case EAssetUsageVerdict::UsedOnLevel:
return TEXT("UsedOnLevel");
case EAssetUsageVerdict::UsedByAssetsOnly:
return TEXT("UsedByAssetsOnly");
case EAssetUsageVerdict::ReferencedFromConfigOrSource:
return TEXT("ReferencedFromConfigOrSource");
case EAssetUsageVerdict::Unreferenced:
return TEXT("Unreferenced");
case EAssetUsageVerdict::Unknown:
return TEXT("Unknown");
}
return TEXT("Unknown");
}
TArray<EAssetUsageVerdict> GetAllAssetUsageVerdicts()
{
// Most actionable first: this order is what the filter menu shows, and "used on a level" is the
// question people open the tool with.
return {
EAssetUsageVerdict::UsedOnLevel,
EAssetUsageVerdict::UsedByAssetsOnly,
EAssetUsageVerdict::ReferencedFromConfigOrSource,
EAssetUsageVerdict::Unreferenced,
EAssetUsageVerdict::Unknown
};
}
const TCHAR* LexToString(EExportLayout Layout)
{
switch (Layout)
{
case EExportLayout::Flat: return TEXT("Flat");
case EExportLayout::MirrorTree: return TEXT("MirrorTree");
case EExportLayout::FolderPerAsset: return TEXT("FolderPerAsset");
case EExportLayout::Migrate: return TEXT("Migrate");
}
return TEXT("Flat");
}
FString ProvenanceToString(EAssetUsageProvenance Provenance)
{
if (Provenance == EAssetUsageProvenance::None)
{
return TEXT("None");
}
TArray<FString> Parts;
auto Append = [&Parts, Provenance](EAssetUsageProvenance Flag, const TCHAR* Label)
{
if (EnumHasAnyFlags(Provenance, Flag))
{
Parts.Add(Label);
}
};
Append(EAssetUsageProvenance::HardReference, TEXT("Hard"));
Append(EAssetUsageProvenance::SoftReference, TEXT("Soft"));
Append(EAssetUsageProvenance::ExternalActor, TEXT("ExternalActor"));
Append(EAssetUsageProvenance::Sublevel, TEXT("Sublevel"));
Append(EAssetUsageProvenance::ConfigFile, TEXT("Config"));
Append(EAssetUsageProvenance::SourceFile, TEXT("Source"));
Append(EAssetUsageProvenance::EditorOnly, TEXT("EditorOnly"));
Append(EAssetUsageProvenance::Redirector, TEXT("Redirector"));
return FString::Join(Parts, TEXT("|"));
}
@@ -0,0 +1,274 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditor.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsagePaths.h"
#include "IndirectReferenceScanner.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "HAL/PlatformTime.h"
#include "Misc/App.h"
#include "Misc/EngineVersion.h"
namespace
{
/** Package roots whose assets the registry cannot meaningfully track. */
bool IsBlindSpotPackage(FName PackageName)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
const FStringView Path = Builder.ToView();
// FMOD banks and events are addressed by string path through the FMOD Studio runtime.
// Nothing in the UObject graph references them, so "no referencers" means nothing here.
return UE::String::FindFirst(Path, TEXTVIEW("/FMOD/"), ESearchCase::IgnoreCase) != INDEX_NONE;
}
bool IsBlindSpotClass(const FTopLevelAssetPath& ClassPath)
{
if (ClassPath.IsNull())
{
return false;
}
// Anything from the FMOD module: events and banks are addressed by string, never referenced.
if (ClassPath.GetPackageName().ToString().StartsWith(TEXT("/Script/FMODStudio"), ESearchCase::IgnoreCase))
{
return true;
}
// Data tables and curve tables are a subtler blind spot, and it cuts both ways. A consumer
// depends on the whole table, so every row inside it looks used even when nothing reads it;
// and rows holding paths as plain strings rather than TSoftObjectPtr produce no edge at all.
// Neither "used" nor "unused" is defensible for the table's contents.
static const TSet<FTopLevelAssetPath> BlindSpotClasses = {
FTopLevelAssetPath(TEXT("/Script/Engine.DataTable")),
FTopLevelAssetPath(TEXT("/Script/Engine.CurveTable"))
};
return BlindSpotClasses.Contains(ClassPath);
}
}
int32 FAssetUsageAuditResult::CountByVerdict(EAssetUsageVerdict Verdict) const
{
int32 Count = 0;
for (const FAssetUsageRow& Row : Rows)
{
if (Row.Verdict == Verdict)
{
++Count;
}
}
return Count;
}
bool FAssetUsageAuditor::IsRegistryBlindSpot(const FAssetUsageNode& Node)
{
return IsBlindSpotPackage(Node.PackageName) || IsBlindSpotClass(Node.ClassPath);
}
EAssetUsageVerdict FAssetUsageAuditor::ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot)
{
// Order matters, and it is chosen so the tool never overstates.
//
// Positive evidence wins over blind-spot status: if a level demonstrably reaches an FMOD bank,
// saying "Unknown" would throw away a fact we actually have.
if (bReachableFromLevel)
{
return EAssetUsageVerdict::UsedOnLevel;
}
if (bFoundInConfigOrSource)
{
return EAssetUsageVerdict::ReferencedFromConfigOrSource;
}
if (bHasReferencers)
{
return EAssetUsageVerdict::UsedByAssetsOnly;
}
// Absence of evidence only becomes Unreferenced when absence is actually informative.
// For a blind spot it is not, so the honest answer is Unknown.
if (bIsBlindSpot)
{
return EAssetUsageVerdict::Unknown;
}
return EAssetUsageVerdict::Unreferenced;
}
FAssetUsageAuditResult FAssetUsageAuditor::Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request)
{
FAssetUsageAuditResult Result;
const double StartTime = FPlatformTime::Seconds();
// --- 1. Graph -----------------------------------------------------------------------------
FAssetUsageGraphBuildOptions GraphOptions;
GraphOptions.IncludePackagePaths = Request.IncludePackagePaths;
GraphOptions.ExcludePackagePaths = Request.ExcludePackagePaths;
GraphOptions.bGatherDependencies = true;
FAssetUsageGraph Graph;
Graph.Build(AssetRegistry, GraphOptions);
Result.GraphStats = Graph.GetStats();
if (Graph.Num() == 0)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Asset graph is empty. Is the Asset Registry still scanning?"));
return Result;
}
// --- 2. Level reachability ----------------------------------------------------------------
FLevelUsageResolveOptions ResolveOptions = Request.ResolveOptions;
ResolveOptions.LevelPackages = Request.LevelPackages;
FLevelUsageResolver Resolver(Graph, AssetRegistry);
const FLevelUsageResult LevelResult = Resolver.Resolve(ResolveOptions);
Result.LevelStats = LevelResult.Stats;
// --- 3. Indirect references ---------------------------------------------------------------
TMap<FName, FString> IndirectProvenance;
if (Request.bScanIndirectReferences)
{
for (const FIndirectReference& Reference : FIndirectReferenceScanner::Scan(FIndirectReferenceScanner::MakeDefaultOptions()))
{
FString& Existing = IndirectProvenance.FindOrAdd(Reference.PackageName);
if (Existing.IsEmpty())
{
Existing = Reference.ToProvenanceString();
}
}
}
// --- 4. Rows ------------------------------------------------------------------------------
Request.TypeFilter.Compile(AssetRegistry);
// Levels-per-asset the cheap way: invert once, rather than calling GetLevelsForAsset per row,
// which would rescan every level bitset for every asset.
TArray<TArray<FName>> LevelsPerAsset;
LevelsPerAsset.SetNum(Graph.Num());
for (int32 LevelOrdinal = 0; LevelOrdinal < LevelResult.LevelReachability.Num(); ++LevelOrdinal)
{
const TBitArray<>& Bits = LevelResult.LevelReachability[LevelOrdinal];
const FName LevelName = LevelResult.LevelPackageNames[LevelOrdinal];
for (TConstSetBitIterator<> It(Bits); It; ++It)
{
LevelsPerAsset[It.GetIndex()].Add(LevelName);
}
}
Result.Rows.Reserve(Graph.Num() / 2);
for (int32 Index = 0; Index < Graph.Num(); ++Index)
{
const FAssetUsageNode& Node = Graph.GetNode(Index);
if (Request.bHideExternalPackages && Node.bIsExternalPackage)
{
continue;
}
if (Request.bHideRedirectors && Node.bIsRedirector)
{
continue;
}
// Only project content can be exported or acted on; engine and plugin packages are noise.
const FString RelativePath = AssetUsagePaths::ToProjectRelativePath(Node.PackageName, Node.bIsLevel);
if (RelativePath.IsEmpty())
{
continue;
}
if (!Request.TypeFilter.Matches(Node))
{
continue;
}
const bool bReachable = LevelResult.IsReachableFromAnyLevel(Index);
const bool bHasReferencers = Graph.GetReferencers(Index).Num() > 0;
const FString* IndirectDetail = IndirectProvenance.Find(Node.PackageName);
const bool bIsBlindSpot = IsRegistryBlindSpot(Node);
const EAssetUsageVerdict Verdict = ClassifyVerdict(bReachable, bHasReferencers, IndirectDetail != nullptr, bIsBlindSpot);
if (Request.bOnlyUnusedAssets && Verdict == EAssetUsageVerdict::UsedOnLevel)
{
continue;
}
FAssetUsageRow& Row = Result.Rows.AddDefaulted_GetRef();
Row.PackageName = Node.PackageName;
Row.AssetName = Node.AssetName;
Row.ClassPath = Node.ClassPath;
Row.GeneratedClassPath = Node.GeneratedClassPath;
Row.PathFromProjectRoot = RelativePath;
Row.Verdict = Verdict;
Row.Levels = MoveTemp(LevelsPerAsset[Index]);
if (LevelResult.Provenance.IsValidIndex(Index))
{
Row.Provenance = LevelResult.Provenance[Index];
}
if (LevelResult.HardReferenceCounts.IsValidIndex(Index))
{
Row.HardReferenceCount = LevelResult.HardReferenceCounts[Index];
Row.SoftReferenceCount = LevelResult.SoftReferenceCounts[Index];
}
if (const FString* Route = LevelResult.Routes.Find(Index))
{
Row.Route = *Route;
}
if (IndirectDetail)
{
Row.ProvenanceDetail = *IndirectDetail;
Row.Provenance |= EAssetUsageProvenance::ConfigFile;
}
}
// --- 5. Header ----------------------------------------------------------------------------
Result.Header.GeneratedAt = FDateTime::Now();
Result.Header.EngineVersion = FEngineVersion::Current().ToString();
Result.Header.ToolVersion = TEXT("0.1");
Result.Header.ProjectName = FApp::GetProjectName();
Result.Header.LevelsScanned = LevelResult.Stats.LevelsScanned;
Result.Header.AssetsScanned = Graph.Num();
Result.Header.ScanDurationSeconds = FPlatformTime::Seconds() - StartTime;
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Types: %s"), *Request.TypeFilter.Describe()));
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Include paths: %s"),
Request.IncludePackagePaths.IsEmpty() ? TEXT("/Game") : *FString::Join(Request.IncludePackagePaths, TEXT(", "))));
if (!Request.ExcludePackagePaths.IsEmpty())
{
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Excluded paths: %s"), *FString::Join(Request.ExcludePackagePaths, TEXT(", "))));
}
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Levels: %s"),
Request.LevelPackages.IsEmpty() ? TEXT("all") : *FString::Printf(TEXT("%d selected"), Request.LevelPackages.Num())));
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Indirect scan: %s"), Request.bScanIndirectReferences ? TEXT("on") : TEXT("off")));
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Only unused: %s"), Request.bOnlyUnusedAssets ? TEXT("yes") : TEXT("no")));
UE_LOG(LogAssetUsageAudit, Log,
TEXT("Audit complete in %.2fs: %d rows (%d on level, %d assets-only, %d config/source, %d unreferenced, %d unknown) across %d levels."),
Result.Header.ScanDurationSeconds,
Result.Rows.Num(),
Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
Result.CountByVerdict(EAssetUsageVerdict::Unreferenced),
Result.CountByVerdict(EAssetUsageVerdict::Unknown),
Result.Header.LevelsScanned);
return Result;
}
@@ -0,0 +1,601 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageExporter.h"
#include "AssetExportNaming.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsagePaths.h"
#include "AssetExportTask.h"
#include "Engine/Level.h"
#include "Exporters/Exporter.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "UObject/Class.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/UObjectGlobals.h"
FString FAssetUsageExporter::FResult::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d copied"), FilesCopied));
if (FilesRenamed > 0)
{
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
}
if (FilesOverwritten > 0)
{
Parts.Add(FString::Printf(TEXT("%d overwritten"), FilesOverwritten));
}
if (ExternalPackagesCopied > 0)
{
Parts.Add(FString::Printf(TEXT("%d external actor packages"), ExternalPackagesCopied));
}
if (FilesMissingOnDisk > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no file on disk"), FilesMissingOnDisk));
}
if (Errors.Num() > 0)
{
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
}
if (bCancelled)
{
Parts.Add(TEXT("cancelled before finishing"));
}
return FString::Join(Parts, TEXT(", "));
}
FString FAssetUsageExporter::FExchangeResult::Summarise() const
{
TArray<FString> Parts;
Parts.Add(FString::Printf(TEXT("%d written"), FilesWritten));
if (FilesRenamed > 0)
{
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
}
// Each skip reason is reported separately on purpose. "17 skipped" tells a user nothing, while
// "17 have no configured format" points straight at the settings they need to change.
if (SkippedNoFormat > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no configured format"), SkippedNoFormat));
}
if (SkippedNoExporter > 0)
{
Parts.Add(FString::Printf(TEXT("%d had no exporter for that format"), SkippedNoExporter));
}
if (SkippedNotLoaded > 0)
{
Parts.Add(FString::Printf(TEXT("%d could not be loaded"), SkippedNotLoaded));
}
if (Errors.Num() > 0)
{
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
}
if (bCancelled)
{
Parts.Add(TEXT("cancelled before finishing"));
}
return FString::Join(Parts, TEXT(", "));
}
TMap<FString, FString> FAssetUsageExporter::GetDefaultFormatByClass()
{
// Only classes the engine actually ships a UExporter for. A plausible-looking entry with no
// exporter behind it turns "nothing came out" into a mystery, so anything unverified is left
// out and reported as SkippedNoFormat instead.
//
// Keys are class short names and lookup walks up the hierarchy, so MaterialInterface covers
// material instances and Texture covers every 2D texture subclass.
return {
{ TEXT("StaticMesh"), TEXT("fbx") },
{ TEXT("SkeletalMesh"), TEXT("fbx") },
{ TEXT("AnimSequence"), TEXT("fbx") },
{ TEXT("Texture2D"), TEXT("png") },
{ TEXT("TextureCube"), TEXT("hdr") },
{ TEXT("SoundWave"), TEXT("wav") },
{ TEXT("DataTable"), TEXT("csv") },
{ TEXT("CurveTable"), TEXT("csv") },
{ TEXT("FontFace"), TEXT("ttf") },
{ TEXT("World"), TEXT("fbx") }
};
}
FString FAssetUsageExporter::FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass)
{
if (!ClassPath.IsValid())
{
return FString();
}
const FString ShortName = ClassPath.GetAssetName().ToString();
if (const FString* Direct = FormatByClass.Find(ShortName))
{
return *Direct;
}
// Walk the hierarchy so one entry covers a family. FindObject rather than LoadObject: a native
// class is already loaded, and a Blueprint class we cannot find has no exporter anyway.
UClass* Class = FindObject<UClass>(ClassPath);
for (UClass* Super = Class ? Class->GetSuperClass() : nullptr; Super; Super = Super->GetSuperClass())
{
if (const FString* Found = FormatByClass.Find(Super->GetName()))
{
return *Found;
}
}
return FString();
}
FAssetUsageExporter::FExchangeResult FAssetUsageExporter::ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options)
{
FExchangeResult Result;
if (Options.TargetDirectory.IsEmpty())
{
Result.Errors.Add(TEXT("No target directory was given."));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: no target directory was given."));
return Result;
}
IFileManager& FileManager = IFileManager::Get();
if (!FileManager.DirectoryExists(*Options.TargetDirectory)
&& !FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: could not create target directory '%s'."), *Options.TargetDirectory);
return Result;
}
const TMap<FString, FString>& FormatByClass = Options.FormatByClass.Num() > 0
? Options.FormatByClass
: GetDefaultFormatByClass();
const int32 Total = Items.Num();
int32 LoadedSinceCollect = 0;
for (int32 Index = 0; Index < Total; ++Index)
{
if (Options.OnProgress && !Options.OnProgress(Index, Total))
{
Result.bCancelled = true;
break;
}
const FExportItem& Item = Items[Index];
const FString Extension = FindFormatForClass(Item.ClassPath, FormatByClass);
if (Extension.IsEmpty())
{
// Decided before loading anything: there is no point paying to load a Blueprint just to
// discover the engine cannot write it to a file.
++Result.SkippedNoFormat;
continue;
}
const FString ObjectPath = FString::Printf(TEXT("%s.%s"), *Item.PackageName.ToString(), *Item.AssetName.ToString());
UObject* Asset = LoadObject<UObject>(nullptr, *ObjectPath);
if (!Asset)
{
++Result.SkippedNotLoaded;
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Could not load '%s' for conversion."), *ObjectPath);
continue;
}
FString DestinationDir = Options.TargetDirectory;
if (!Options.bFlatten)
{
FString Relative = FPaths::GetPath(Item.PackageName.ToString());
Relative.RemoveFromStart(TEXT("/"));
DestinationDir = FPaths::Combine(Options.TargetDirectory, Relative);
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
continue;
}
}
const FString DesiredFileName = FString::Printf(TEXT("%s.%s"), *Item.AssetName.ToString(), *Extension);
const FString FinalFileName = AssetExportNaming::ResolveCollision(
DesiredFileName,
Options.CollisionPolicy,
[&FileManager, &DestinationDir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
});
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
if (FileManager.FileExists(*DestinationPath) && FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
// TStrongObjectPtr, not a bare NewObject: this loop runs garbage collection, and a task
// rooted nowhere would be destroyed underneath the exporter.
TStrongObjectPtr<UAssetExportTask> Task(NewObject<UAssetExportTask>());
Task->Object = Asset;
Task->Exporter = nullptr; // let the engine match SupportedClass against the extension
Task->Filename = DestinationPath;
Task->bSelected = false;
Task->bReplaceIdentical = true;
Task->bPrompt = false; // a modal dialog per asset would hang an unattended run
Task->bAutomated = true;
Task->bUseFileArchive = false;
Task->bWriteEmptyFiles = false;
const bool bRan = UExporter::RunAssetExportTask(Task.Get());
// Both halves matter. RunAssetExportTask returns false when no exporter matched, and it can
// also return true having filled Errors - reading only the bool reports a success that
// produced nothing.
if (!bRan)
{
if (Task->Errors.Num() > 0)
{
for (const FString& TaskError : Task->Errors)
{
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
}
}
else
{
++Result.SkippedNoExporter;
}
}
else if (Task->Errors.Num() > 0)
{
for (const FString& TaskError : Task->Errors)
{
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
}
}
else if (!FileManager.FileExists(*DestinationPath))
{
// An exporter that reports success without producing a file is the one failure mode a
// caller would never notice. Say so rather than counting a write that did not happen.
Result.Errors.Add(FString::Printf(
TEXT("%s: the exporter reported success but wrote no file at '%s'."),
*Item.AssetName.ToString(), *DestinationPath));
}
else
{
++Result.FilesWritten;
if (FinalFileName != DesiredFileName)
{
++Result.FilesRenamed;
}
}
// Loading thousands of meshes without collecting exhausts memory long before the export
// finishes. The task is released first so it is not what keeps the asset alive.
Task.Reset();
if (Options.CollectGarbageEvery > 0 && ++LoadedSinceCollect >= Options.CollectGarbageEvery)
{
LoadedSinceCollect = 0;
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
}
}
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
UE_LOG(LogAssetUsageAudit, Log, TEXT("Conversion to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
for (const FString& Error : Result.Errors)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
}
return Result;
}
FString FAssetUsageExporter::ResolvePackageFilePath(FName PackageName)
{
const FString PackageNameStr = PackageName.ToString();
if (!FPackageName::IsValidLongPackageName(PackageNameStr))
{
return FString();
}
// A package is either an asset or a map and we do not know which from the name alone.
// DoesPackageExist resolves the extension for us and also copes with mount points other
// than /Game, which matters for plugin content.
FString ResolvedFileName;
if (FPackageName::DoesPackageExist(PackageNameStr, &ResolvedFileName))
{
return ResolvedFileName;
}
return FString();
}
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options)
{
// One unnamed group: everything straight into the target, which is what this function has
// always done. Kept as its own entry point because most callers and every existing spec want
// exactly this and should not have to build a group to say so.
FExportGroup Group;
Group.Packages = PackageNames;
return ExportPackageGroups({ MoveTemp(Group) }, Options);
}
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options)
{
FResult Result;
// Setup failures return before the summary log at the bottom of this function, so they must
// log for themselves. These two are the most likely things to go wrong and were previously
// the only failures that left no trace at all - the UI would say "see the Output Log" and the
// Output Log would be empty.
if (Options.TargetDirectory.IsEmpty())
{
Result.Errors.Add(TEXT("No target directory was given."));
UE_LOG(LogAssetUsageAudit, Error, TEXT("Export aborted: no target directory was given."));
return Result;
}
IFileManager& FileManager = IFileManager::Get();
if (!FileManager.DirectoryExists(*Options.TargetDirectory))
{
if (!FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Export aborted: could not create target directory '%s'. Check the path is valid and writable ")
TEXT("- a Perforce-read-only parent or a non-existent drive letter both land here."),
*Options.TargetDirectory);
return Result;
}
}
// Flatten the groups into one work list, remembering each entry's destination folder. Doing it
// up front keeps the progress total honest: a caller showing a bar must not watch the maximum
// creep upward as levels expand mid-copy.
//
// Deduplication is per group, not global. The same texture legitimately appears in several
// groups under the folder-per-asset layout - that is the whole point of that layout - so a
// global Seen set would silently give the first folder the texture and leave the rest without.
TArray<FName> Expanded;
TArray<FString> DestinationDirs;
// Parallel to Expanded: true for entries this function added by expanding a level, rather than
// entries the caller asked for. Counted at copy time, not here - reporting "N external actor
// packages" for packages that were only queued would overstate a cancelled or failing run.
TBitArray<> IsExternalExpansion;
for (const FExportGroup& Group : Groups)
{
const FString GroupDir = Group.RelativeDir.IsEmpty()
? Options.TargetDirectory
: FPaths::Combine(Options.TargetDirectory, Group.RelativeDir);
const int32 FirstIndexInGroup = Expanded.Num();
TSet<FName> Seen;
Seen.Reserve(Group.Packages.Num());
for (FName PackageName : Group.Packages)
{
if (!Seen.Contains(PackageName))
{
Seen.Add(PackageName);
Expanded.Add(PackageName);
DestinationDirs.Add(GroupDir);
IsExternalExpansion.Add(false);
}
}
if (!Options.bIncludeExternalPackages)
{
continue;
}
const int32 LastIndexInGroup = Expanded.Num();
for (int32 Index = FirstIndexInGroup; Index < LastIndexInGroup; ++Index)
{
const FString PackageNameStr = Expanded[Index].ToString();
// Only a World owns external packages. Asking for the paths of a non-level is harmless
// but pointless, and on 45k rows it is not free.
const FString FilePath = ResolvePackageFilePath(Expanded[Index]);
if (!FilePath.EndsWith(FPackageName::GetMapPackageExtension()))
{
continue;
}
// Plural form: plugins register extra roots through delegates, and Content Bundles and
// External Data Layers inject path segments a hand-built string would miss.
TArray<FString> ExternalRoots = ULevel::GetExternalActorsPaths(PackageNameStr);
ExternalRoots.Append(ULevel::GetExternalObjectsPaths(PackageNameStr));
for (const FString& Root : ExternalRoots)
{
FString RootDir;
if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir))
{
continue;
}
TArray<FString> Found;
FileManager.FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), /*Files=*/true, /*Directories=*/false);
for (const FString& AbsFile : Found)
{
FString ExternalPackageName;
if (FPackageName::TryConvertFilenameToLongPackageName(AbsFile, ExternalPackageName))
{
const FName AsName(*ExternalPackageName);
if (!Seen.Contains(AsName))
{
Seen.Add(AsName);
Expanded.Add(AsName);
DestinationDirs.Add(GroupDir);
IsExternalExpansion.Add(true);
}
}
}
}
}
}
return CopyExpandedPackages(Expanded, DestinationDirs, IsExternalExpansion, Options, Result);
}
FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TArray<FName>& Expanded, const TArray<FString>& DestinationDirs, const TBitArray<>& IsExternalExpansion, const FOptions& Options, FResult& Result)
{
IFileManager& FileManager = IFileManager::Get();
const int32 Total = Expanded.Num();
for (int32 Index = 0; Index < Total; ++Index)
{
if (Options.OnProgress && !Options.OnProgress(Index, Total))
{
Result.bCancelled = true;
break;
}
const FName PackageName = Expanded[Index];
const FString SourcePath = ResolvePackageFilePath(PackageName);
if (SourcePath.IsEmpty())
{
// Normal for script packages and for assets that exist only in memory. Counted, not
// reported as an error, so a real failure stays visible in the Errors list.
++Result.FilesMissingOnDisk;
continue;
}
// The group decided the folder; bFlatten only chooses whether to mirror the package path
// beneath it. The two compose: a folder-per-asset export with mirroring off writes straight
// into the asset's folder, which is what makes that layout readable.
FString DestinationDir = DestinationDirs.IsValidIndex(Index)
? DestinationDirs[Index]
: Options.TargetDirectory;
if (!Options.bFlatten)
{
// Mirror the package path under the target, minus the leading mount point slash.
FString Relative = FPaths::GetPath(PackageName.ToString());
Relative.RemoveFromStart(TEXT("/"));
DestinationDir = FPaths::Combine(DestinationDir, Relative);
}
if (DestinationDir != Options.TargetDirectory)
{
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
{
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
continue;
}
}
const FString DesiredFileName = FPaths::GetCleanFilename(SourcePath);
const FString FinalFileName = AssetExportNaming::ResolveCollision(
DesiredFileName,
Options.CollisionPolicy,
[&FileManager, &DestinationDir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
});
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
const bool bWasPresent = FileManager.FileExists(*DestinationPath);
// This repository is Perforce-primary, so the source .uasset is very often read-only on
// disk. Windows CopyFile carries that attribute across, which would make the export folder
// read-only too - and then an Overwrite-policy re-export fails on its own previous output.
// Clear the flag on both ends of the problem: before writing over an existing file, and
// after writing a new one. The destination is outside source control by definition.
if (bWasPresent && FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
if (FileManager.Copy(*DestinationPath, *SourcePath, /*bReplace=*/true) != COPY_OK)
{
Result.Errors.Add(FString::Printf(TEXT("Failed to copy '%s' to '%s'."), *PackageName.ToString(), *DestinationPath));
continue;
}
if (FileManager.IsReadOnly(*DestinationPath))
{
// IFileManager can report the flag but not change it; only IPlatformFile can.
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
}
++Result.FilesCopied;
if (Options.bRecordWrittenFiles)
{
// Relative to the export root, so the manifest stays valid if the folder is moved or
// renamed - which is the normal fate of an export folder.
FString Relative = DestinationPath;
FPaths::MakePathRelativeTo(Relative, *(Options.TargetDirectory / TEXT("")));
FWrittenFile& Written = Result.WrittenFiles.AddDefaulted_GetRef();
Written.PackageName = PackageName;
Written.RelativePath = MoveTemp(Relative);
}
if (IsExternalExpansion.IsValidIndex(Index) && IsExternalExpansion[Index])
{
++Result.ExternalPackagesCopied;
}
if (FinalFileName != DesiredFileName)
{
++Result.FilesRenamed;
}
else if (bWasPresent)
{
++Result.FilesOverwritten;
}
}
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
for (const FString& Error : Result.Errors)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
}
return Result;
}
@@ -0,0 +1,369 @@
// 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;
}
@@ -0,0 +1,186 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsagePaths.h"
#include "HAL/FileManager.h"
#include "Misc/Paths.h"
#include "Misc/StringBuilder.h"
#include "String/Find.h"
namespace AssetUsagePaths
{
namespace Private
{
// These two literals match FPackagePath's folder names (CoreUObject PackagePath.cpp).
// They are used only to CLASSIFY an existing path. Producing an external-actor path is
// ULevel::GetExternalActorsPaths's job, because that form has /CB/ and /EDL/ variants and
// is extensible by plugin delegates - string building it here would be silently wrong.
static const TCHAR* ExternalActorsFolder = TEXT("__ExternalActors__");
static const TCHAR* ExternalObjectsFolder = TEXT("__ExternalObjects__");
static const TCHAR* GameRoot = TEXT("/Game");
static const TCHAR* ContentPrefix = TEXT("Content");
/** Prefix match that only accepts a whole path segment, so /Game/Art misses /Game/ArtSource. */
static bool StartsWithSegment(FStringView Path, FStringView Prefix)
{
if (Prefix.IsEmpty() || Path.Len() < Prefix.Len())
{
return false;
}
if (!Path.Left(Prefix.Len()).Equals(Prefix, ESearchCase::IgnoreCase))
{
return false;
}
return Path.Len() == Prefix.Len() || Path[Prefix.Len()] == TEXT('/');
}
}
bool IsExternalPackage(FStringView PackagePath)
{
return UE::String::FindFirst(PackagePath, Private::ExternalActorsFolder, ESearchCase::IgnoreCase) != INDEX_NONE
|| UE::String::FindFirst(PackagePath, Private::ExternalObjectsFolder, ESearchCase::IgnoreCase) != INDEX_NONE;
}
bool IsExternalPackage(FName PackageName)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsExternalPackage(Builder.ToView());
}
bool IsScriptPackage(FStringView PackagePath)
{
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Script"));
}
bool IsScriptPackage(FName PackageName)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsScriptPackage(Builder.ToView());
}
bool IsEngineOrTempPackage(FStringView PackagePath)
{
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Engine"))
|| Private::StartsWithSegment(PackagePath, TEXTVIEW("/Temp"));
}
FString NormalizeExclusionPrefix(const FString& Prefix)
{
FString Working = Prefix;
Working.TrimStartAndEndInline();
Working.ReplaceInline(TEXT("\\"), TEXT("/"), ESearchCase::CaseSensitive);
while (Working.EndsWith(TEXT("/"), ESearchCase::CaseSensitive))
{
Working.LeftChopInline(1, EAllowShrinking::No);
}
if (Working.IsEmpty())
{
return FString();
}
// Content-relative form, which is what the UI shows and what settings files accumulate.
if (Private::StartsWithSegment(Working, Private::ContentPrefix))
{
const FString Remainder = Working.RightChop(FCString::Strlen(Private::ContentPrefix));
return FString(Private::GameRoot) + Remainder;
}
if (!Working.StartsWith(TEXT("/"), ESearchCase::CaseSensitive))
{
Working.InsertAt(0, TEXT("/"));
}
return Working;
}
bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes)
{
for (const FString& RawPrefix : ExcludedPrefixes)
{
const FString Normalized = NormalizeExclusionPrefix(RawPrefix);
if (!Normalized.IsEmpty() && Private::StartsWithSegment(PackagePath, Normalized))
{
return true;
}
}
return false;
}
bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes)
{
if (ExcludedPrefixes.IsEmpty())
{
return false;
}
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsPathExcluded(Builder.ToView(), ExcludedPrefixes);
}
FString ToProjectRelativePath(FName PackageName, bool bIsLevel)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
const FStringView PackagePath = Builder.ToView();
if (!Private::StartsWithSegment(PackagePath, Private::GameRoot))
{
// /Script, /Engine and plugin mounts have no project-relative form. Reporting an
// invented path for them would send an artist looking for a file that is not there.
return FString();
}
const FStringView Remainder = PackagePath.RightChop(FCString::Strlen(Private::GameRoot));
const TCHAR* Extension = bIsLevel ? TEXT(".umap") : TEXT(".uasset");
return FString::Printf(TEXT("Content%.*s%s"), Remainder.Len(), Remainder.GetData(), Extension);
}
FString ValidateMigrateDestination(const FString& Directory)
{
if (Directory.IsEmpty())
{
return TEXT("Choose the destination project's Content folder.");
}
FString Normalised = Directory;
FPaths::NormalizeDirectoryName(Normalised);
Normalised += TEXT("/");
if (!Normalised.EndsWith(TEXT("/Content/")))
{
return TEXT("Migrate needs a folder named Content - pick the destination project's Content folder itself, not the project root.");
}
// Second rule. A folder merely named Content passes the first check and Migrate still
// aborts: the destination mount point is derived from what sits beside it.
const FString Parent = Normalised + TEXT("../");
TArray<FString> Found;
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uproject"));
if (!Found.IsEmpty())
{
return FString();
}
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uplugin"));
if (Found.Num() == 1)
{
return FString();
}
return Found.Num() > 1
? TEXT("That folder's parent holds several .uplugin files, so Unreal cannot tell which mount point to migrate into.")
: TEXT("No .uproject or .uplugin next to that Content folder, so Unreal has no mount point to migrate into.");
}
}
@@ -0,0 +1,311 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageReportWriter.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsageAuditor.h"
#include "AssetUsageAuditTypes.h"
#include "HAL/FileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
namespace
{
/** Column order is shared by the CSV header and the row writer so they cannot drift. */
const TCHAR* CsvColumns[] = {
TEXT("Type"),
TEXT("GeneratedClass"),
TEXT("Name"),
TEXT("PathFromProjectRoot"),
TEXT("Verdict"),
TEXT("LevelCount"),
TEXT("Levels"),
TEXT("HardRefs"),
TEXT("SoftRefs"),
TEXT("Provenance"),
TEXT("Route"),
TEXT("ProvenanceDetail")
};
FString JoinNames(const TArray<FName>& Names, const FString& Separator)
{
TArray<FString> Strings;
Strings.Reserve(Names.Num());
for (const FName& Name : Names)
{
Strings.Add(Name.ToString());
}
return FString::Join(Strings, *Separator);
}
}
FString FAssetUsageReportWriter::EscapeCsvField(const FString& Field, TCHAR Delimiter)
{
const bool bNeedsQuoting =
Field.Contains(FString(1, &Delimiter), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\""), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\n"), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\r"), ESearchCase::CaseSensitive);
if (!bNeedsQuoting)
{
return Field;
}
FString Escaped = Field;
Escaped.ReplaceInline(TEXT("\""), TEXT("\"\""), ESearchCase::CaseSensitive);
return FString::Printf(TEXT("\"%s\""), *Escaped);
}
FString FAssetUsageReportWriter::BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
{
const FAssetUsageReportHeader& Header = AuditResult.Header;
const TCHAR Delimiter = Options.CsvDelimiter;
FString Output;
Output.Reserve(AuditResult.Rows.Num() * 160 + 2048);
// Provenance of the report itself, as '#' comment lines. Excel shows them as ordinary rows,
// which is fine and is much better than a report nobody can date or attribute to a filter set.
Output += FString::Printf(TEXT("# Asset Usage Audit report\n"));
Output += FString::Printf(TEXT("# Generated: %s\n"), *Header.GeneratedAt.ToIso8601());
Output += FString::Printf(TEXT("# Project: %s\n"), *Header.ProjectName);
Output += FString::Printf(TEXT("# Engine: %s\n"), *Header.EngineVersion);
Output += FString::Printf(TEXT("# Tool: %s\n"), *Header.ToolVersion);
Output += FString::Printf(TEXT("# Levels scanned: %d\n"), Header.LevelsScanned);
Output += FString::Printf(TEXT("# Assets scanned: %d\n"), Header.AssetsScanned);
Output += FString::Printf(TEXT("# Rows reported: %d\n"), AuditResult.Rows.Num());
Output += FString::Printf(TEXT("# Scan duration: %.2f s\n"), Header.ScanDurationSeconds);
for (const FString& Filter : Header.AppliedFilters)
{
Output += FString::Printf(TEXT("# Filter: %s\n"), *Filter);
}
Output += FString::Printf(TEXT("# Verdict counts: UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n"),
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced),
AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
Output += TEXT("# Unknown means the registry cannot see this asset's references (FMOD, DataTable rows,\n");
Output += TEXT("# runtime-built paths). It does NOT mean unused. Never delete on the strength of it.\n");
for (int32 ColumnIndex = 0; ColumnIndex < UE_ARRAY_COUNT(CsvColumns); ++ColumnIndex)
{
if (ColumnIndex > 0)
{
Output.AppendChar(Delimiter);
}
Output += CsvColumns[ColumnIndex];
}
Output += TEXT("\n");
for (const FAssetUsageRow& Row : AuditResult.Rows)
{
TArray<FString, TInlineAllocator<16>> Fields;
Fields.Add(Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
Fields.Add(Row.GeneratedClassPath.IsNull() ? FString() : Row.GeneratedClassPath.ToString());
Fields.Add(Row.AssetName.ToString());
Fields.Add(Row.PathFromProjectRoot);
Fields.Add(LexToString(Row.Verdict));
Fields.Add(FString::FromInt(Row.Levels.Num()));
Fields.Add(JoinNames(Row.Levels, Options.CsvMultiValueSeparator));
Fields.Add(FString::FromInt(Row.HardReferenceCount));
Fields.Add(FString::FromInt(Row.SoftReferenceCount));
Fields.Add(ProvenanceToString(Row.Provenance));
Fields.Add(Row.Route);
Fields.Add(Row.ProvenanceDetail);
checkf(Fields.Num() == UE_ARRAY_COUNT(CsvColumns), TEXT("CSV row width must match the header."));
for (int32 FieldIndex = 0; FieldIndex < Fields.Num(); ++FieldIndex)
{
if (FieldIndex > 0)
{
Output.AppendChar(Delimiter);
}
Output += EscapeCsvField(Fields[FieldIndex], Delimiter);
}
Output += TEXT("\n");
}
return Output;
}
FString FAssetUsageReportWriter::BuildJson(const FAssetUsageAuditResult& AuditResult)
{
const FAssetUsageReportHeader& Header = AuditResult.Header;
FString Output;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
Writer->WriteObjectStart();
Writer->WriteObjectStart(TEXT("header"));
Writer->WriteValue(TEXT("generatedAt"), Header.GeneratedAt.ToIso8601());
Writer->WriteValue(TEXT("project"), Header.ProjectName);
Writer->WriteValue(TEXT("engineVersion"), Header.EngineVersion);
Writer->WriteValue(TEXT("toolVersion"), Header.ToolVersion);
Writer->WriteValue(TEXT("levelsScanned"), Header.LevelsScanned);
Writer->WriteValue(TEXT("assetsScanned"), Header.AssetsScanned);
Writer->WriteValue(TEXT("rowsReported"), AuditResult.Rows.Num());
Writer->WriteValue(TEXT("scanDurationSeconds"), Header.ScanDurationSeconds);
Writer->WriteArrayStart(TEXT("appliedFilters"));
for (const FString& Filter : Header.AppliedFilters)
{
Writer->WriteValue(Filter);
}
Writer->WriteArrayEnd();
Writer->WriteObjectEnd();
Writer->WriteObjectStart(TEXT("verdictCounts"));
Writer->WriteValue(TEXT("usedOnLevel"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel));
Writer->WriteValue(TEXT("usedByAssetsOnly"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly));
Writer->WriteValue(TEXT("referencedFromConfigOrSource"), AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource));
Writer->WriteValue(TEXT("unreferenced"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced));
Writer->WriteValue(TEXT("unknown"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
Writer->WriteObjectEnd();
Writer->WriteObjectStart(TEXT("graphStats"));
Writer->WriteValue(TEXT("packages"), AuditResult.GraphStats.NumPackages);
Writer->WriteValue(TEXT("edges"), AuditResult.GraphStats.NumEdges);
Writer->WriteValue(TEXT("levels"), AuditResult.GraphStats.NumLevels);
Writer->WriteValue(TEXT("externalPackages"), AuditResult.GraphStats.NumExternalPackages);
Writer->WriteValue(TEXT("enumerateSeconds"), AuditResult.GraphStats.EnumerateSeconds);
Writer->WriteValue(TEXT("dependencySeconds"), AuditResult.GraphStats.DependencySeconds);
Writer->WriteObjectEnd();
Writer->WriteArrayStart(TEXT("assets"));
for (const FAssetUsageRow& Row : AuditResult.Rows)
{
Writer->WriteObjectStart();
Writer->WriteValue(TEXT("name"), Row.AssetName.ToString());
Writer->WriteValue(TEXT("package"), Row.PackageName.ToString());
Writer->WriteValue(TEXT("path"), Row.PathFromProjectRoot);
Writer->WriteValue(TEXT("type"), Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
if (!Row.GeneratedClassPath.IsNull())
{
Writer->WriteValue(TEXT("generatedClass"), Row.GeneratedClassPath.ToString());
}
Writer->WriteValue(TEXT("verdict"), LexToString(Row.Verdict));
Writer->WriteValue(TEXT("provenance"), ProvenanceToString(Row.Provenance));
Writer->WriteValue(TEXT("hardReferenceCount"), Row.HardReferenceCount);
Writer->WriteValue(TEXT("softReferenceCount"), Row.SoftReferenceCount);
Writer->WriteArrayStart(TEXT("levels"));
for (const FName& Level : Row.Levels)
{
Writer->WriteValue(Level.ToString());
}
Writer->WriteArrayEnd();
if (!Row.Route.IsEmpty())
{
Writer->WriteValue(TEXT("route"), Row.Route);
}
if (!Row.ProvenanceDetail.IsEmpty())
{
Writer->WriteValue(TEXT("provenanceDetail"), Row.ProvenanceDetail);
}
Writer->WriteObjectEnd();
}
Writer->WriteArrayEnd();
Writer->WriteObjectEnd();
Writer->Close();
return Output;
}
FAssetUsageReportWriter::FResult FAssetUsageReportWriter::Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
{
FResult Result;
if (Options.OutputDirectory.IsEmpty())
{
Result.ErrorMessage = TEXT("Output directory is empty.");
return Result;
}
if (!IFileManager::Get().MakeDirectory(*Options.OutputDirectory, true))
{
if (!IFileManager::Get().DirectoryExists(*Options.OutputDirectory))
{
Result.ErrorMessage = FString::Printf(TEXT("Could not create output directory '%s'."), *Options.OutputDirectory);
return Result;
}
}
const FString BaseName = Options.BaseFileName.IsEmpty() ? TEXT("AssetUsageReport") : Options.BaseFileName;
bool bAnyWritten = false;
if (Options.bWriteJson)
{
const FString JsonPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".json"));
if (FFileHelper::SaveStringToFile(BuildJson(AuditResult), *JsonPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
Result.JsonPath = JsonPath;
bAnyWritten = true;
}
else
{
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *JsonPath);
return Result;
}
}
if (Options.bWriteCsv)
{
const FString CsvPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".csv"));
// Excel needs the BOM to read UTF-8; without it Cyrillic asset names come out mangled.
const FFileHelper::EEncodingOptions Encoding = Options.bCsvUtf8Bom
? FFileHelper::EEncodingOptions::ForceUTF8
: FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM;
if (FFileHelper::SaveStringToFile(BuildCsv(AuditResult, Options), *CsvPath, Encoding))
{
Result.CsvPath = CsvPath;
bAnyWritten = true;
}
else
{
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *CsvPath);
return Result;
}
}
Result.bSuccess = bAnyWritten;
if (!bAnyWritten)
{
Result.ErrorMessage = TEXT("Both JSON and CSV output were disabled.");
}
else
{
UE_LOG(LogAssetUsageAudit, Log, TEXT("Report written: %d rows. JSON '%s', CSV '%s'."),
AuditResult.Rows.Num(), *Result.JsonPath, *Result.CsvPath);
}
return Result;
}
@@ -0,0 +1,161 @@
// NextGenium 2026. Asset Usage Audit.
#include "IndirectReferenceScanner.h"
#include "AssetUsageAuditCoreModule.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/StringBuilder.h"
namespace
{
const TCHAR* GameRootLiteral = TEXT("/Game/");
/**
* Characters that can legally continue a package path. Everything else ends the literal.
*
* Note '.' is included so that an object path like /Game/X/BP_Y.BP_Y_C is captured whole and
* then trimmed below; stopping at the dot would work too but would silently accept the
* malformed tail of a truncated literal.
*/
bool IsPathChar(TCHAR Char)
{
return FChar::IsAlnum(Char) || Char == TEXT('_') || Char == TEXT('/') || Char == TEXT('.') || Char == TEXT('-');
}
}
FString FIndirectReference::ToProvenanceString() const
{
return FString::Printf(TEXT("%s:%d"), *FPaths::GetCleanFilename(SourceFile), LineNumber);
}
void FIndirectReferenceScanner::ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames)
{
const int32 RootLen = FCString::Strlen(GameRootLiteral);
int32 SearchStart = 0;
while (SearchStart < Line.Len())
{
const FStringView Remaining = Line.RightChop(SearchStart);
const int32 Found = UE::String::FindFirst(Remaining, GameRootLiteral, ESearchCase::IgnoreCase);
if (Found == INDEX_NONE)
{
return;
}
const int32 Start = SearchStart + Found;
int32 End = Start + RootLen;
while (End < Line.Len() && IsPathChar(Line[End]))
{
++End;
}
FString Candidate(Line.Mid(Start, End - Start));
SearchStart = End > Start ? End : Start + RootLen;
// "/Game/" alone, or a trailing slash, is not a reference.
if (Candidate.Len() <= RootLen)
{
continue;
}
while (Candidate.EndsWith(TEXT("/"), ESearchCase::CaseSensitive) || Candidate.EndsWith(TEXT("."), ESearchCase::CaseSensitive))
{
Candidate.LeftChopInline(1, EAllowShrinking::No);
}
// Object path -> package path. "/Game/X/BP_Y.BP_Y_C" and "/Game/X/T.T" both name package
// "/Game/X/...". Take everything before the first dot after the last slash.
int32 LastSlash = INDEX_NONE;
Candidate.FindLastChar(TEXT('/'), LastSlash);
int32 DotAfterSlash = INDEX_NONE;
if (Candidate.FindChar(TEXT('.'), DotAfterSlash) && DotAfterSlash > LastSlash)
{
Candidate.LeftInline(DotAfterSlash, EAllowShrinking::No);
}
if (Candidate.Len() <= RootLen)
{
continue;
}
OutPackageNames.AddUnique(Candidate);
}
}
FIndirectReferenceScanner::FOptions FIndirectReferenceScanner::MakeDefaultOptions()
{
FOptions Options;
const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
Options.Directories.Add(ProjectDir / TEXT("Config"));
Options.Directories.Add(ProjectDir / TEXT("Source"));
return Options;
}
TArray<FIndirectReference> FIndirectReferenceScanner::Scan(const FOptions& Options)
{
TArray<FIndirectReference> Results;
IFileManager& FileManager = IFileManager::Get();
for (const FString& Directory : Options.Directories)
{
if (!FileManager.DirectoryExists(*Directory))
{
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: directory '%s' does not exist, skipping."), *Directory);
continue;
}
const bool bIsConfigDir = Directory.EndsWith(TEXT("Config"), ESearchCase::IgnoreCase);
TArray<FString> FoundFiles;
FileManager.FindFilesRecursive(FoundFiles, *Directory, TEXT("*.*"), true, false);
for (const FString& FilePath : FoundFiles)
{
const FString Extension = FPaths::GetExtension(FilePath, true).ToLower();
if (!Options.Extensions.Contains(Extension))
{
continue;
}
if (FileManager.FileSize(*FilePath) > Options.MaxFileSizeBytes)
{
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: skipping oversized file '%s'."), *FilePath);
continue;
}
TArray<FString> Lines;
if (!FFileHelper::LoadFileToStringArray(Lines, *FilePath))
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Indirect scan: could not read '%s'."), *FilePath);
continue;
}
TArray<FString> PackageNames;
for (int32 LineIndex = 0; LineIndex < Lines.Num(); ++LineIndex)
{
PackageNames.Reset();
ExtractGamePathsFromLine(Lines[LineIndex], PackageNames);
for (const FString& PackageName : PackageNames)
{
FIndirectReference& Reference = Results.AddDefaulted_GetRef();
Reference.PackageName = FName(*PackageName);
Reference.SourceFile = FilePath;
Reference.LineNumber = LineIndex + 1;
Reference.bFromConfig = bIsConfigDir;
}
}
}
}
UE_LOG(LogAssetUsageAudit, Log, TEXT("Indirect scan found %d /Game path literals in Config and Source."), Results.Num());
return Results;
}
@@ -0,0 +1,419 @@
// NextGenium 2026. Asset Usage Audit.
#include "LevelUsageResolver.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsageGraph.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Algo/Reverse.h"
#include "Engine/Level.h"
#include "HAL/PlatformTime.h"
#include "Misc/PackageName.h"
#include "Misc/StringBuilder.h"
FLevelUsageResolver::FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry)
: Graph(InGraph)
, AssetRegistry(InAssetRegistry)
{
}
TArray<FName> FLevelUsageResult::GetLevelsForAsset(int32 AssetIndex) const
{
TArray<FName> Out;
for (int32 LevelIdx = 0; LevelIdx < LevelReachability.Num(); ++LevelIdx)
{
const TBitArray<>& Bits = LevelReachability[LevelIdx];
if (Bits.IsValidIndex(AssetIndex) && Bits[AssetIndex])
{
Out.Add(LevelPackageNames[LevelIdx]);
}
}
return Out;
}
FLevelUsageResult FLevelUsageResolver::Resolve(const FLevelUsageResolveOptions& Options)
{
FLevelUsageResult Result;
const int32 NumPackages = Graph.Num();
if (NumPackages == 0)
{
return Result;
}
// Decide which levels to walk. An explicit list is resolved against the graph so a typo
// surfaces as a warning rather than as a silently empty report.
TArray<int32> LevelIndices;
if (Options.LevelPackages.Num() > 0)
{
LevelIndices.Reserve(Options.LevelPackages.Num());
for (FName LevelPackage : Options.LevelPackages)
{
const int32 Index = Graph.FindPackageIndex(LevelPackage);
if (Index == INDEX_NONE)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level '%s' is not in the graph; skipping."), *LevelPackage.ToString());
continue;
}
if (!Graph.GetNode(Index).bIsLevel)
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Package '%s' is not a World asset; skipping."), *LevelPackage.ToString());
continue;
}
LevelIndices.Add(Index);
}
}
else
{
LevelIndices = Graph.GetLevelIndices();
}
const int32 NumLevels = LevelIndices.Num();
UE_LOG(LogAssetUsageAudit, Log, TEXT("Resolving %d level(s); %d requested explicitly, %d present in the graph."),
NumLevels, Options.LevelPackages.Num(), Graph.GetLevelIndices().Num());
Result.LevelPackageNames.Reserve(NumLevels);
Result.LevelReachability.Reserve(NumLevels);
Result.ReachableFromAnyLevel.Init(false, NumPackages);
Result.Provenance.Init(EAssetUsageProvenance::None, NumPackages);
Result.HardReferenceCounts.Init(0, NumPackages);
Result.SoftReferenceCounts.Init(0, NumPackages);
// Scratch buffers reused across every level so the BFS allocates nothing per level.
TArray<int32> ScratchPredecessor;
TArray<int32> ScratchQueue;
ScratchPredecessor.SetNumUninitialized(NumPackages);
ScratchQueue.Reserve(FMath::Min(NumPackages, 4096));
const double TraversalStart = FPlatformTime::Seconds();
for (int32 Ordinal = 0; Ordinal < NumLevels; ++Ordinal)
{
if (Options.ShouldAbort && Options.ShouldAbort())
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level sweep aborted after %d of %d levels."), Ordinal, NumLevels);
break;
}
if (Options.OnLevelProgress)
{
Options.OnLevelProgress(Ordinal, NumLevels);
}
const int32 LevelIndex = LevelIndices[Ordinal];
// The gatherer only reports external packages the registry has already seen. Without this
// a level nobody opened this session can report zero actors, which is indistinguishable
// from a level that genuinely has none.
if (Options.bScanLevelAssetsFirst)
{
const double ScanStart = FPlatformTime::Seconds();
ULevel::ScanLevelAssets(Graph.GetNode(LevelIndex).PackageName.ToString());
Result.Stats.ScanLevelAssetsSeconds += FPlatformTime::Seconds() - ScanStart;
}
TBitArray<> Reachable;
Reachable.Init(false, NumPackages);
TraverseLevel(LevelIndex, Options, Reachable, Result, ScratchPredecessor, ScratchQueue);
Result.LevelPackageNames.Add(Graph.GetNode(LevelIndex).PackageName);
Result.LevelReachability.Add(MoveTemp(Reachable));
++Result.Stats.LevelsScanned;
}
Result.Stats.TraversalSeconds = FPlatformTime::Seconds() - TraversalStart - Result.Stats.ScanLevelAssetsSeconds;
for (int32 Index = 0; Index < NumPackages; ++Index)
{
if (Result.ReachableFromAnyLevel[Index])
{
++Result.Stats.AssetsReachable;
}
}
return Result;
}
void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray<int32>& ScratchPredecessor, TArray<int32>& ScratchQueue)
{
const int32 NumPackages = Graph.Num();
if (Options.bRecordRoutes)
{
for (int32& Pred : ScratchPredecessor)
{
Pred = INDEX_NONE;
}
}
ScratchQueue.Reset();
const auto Enqueue = [&](int32 Index, int32 FromIndex, EAssetUsageProvenance EdgeProvenance)
{
if (!Graph.IsValidIndex(Index) || OutReachable[Index])
{
return;
}
OutReachable[Index] = true;
InOutResult.ReachableFromAnyLevel[Index] = true;
InOutResult.Provenance[Index] |= EdgeProvenance;
if (Options.bRecordRoutes)
{
ScratchPredecessor[Index] = FromIndex;
}
ScratchQueue.Add(Index);
};
Enqueue(LevelIndex, INDEX_NONE, EAssetUsageProvenance::None);
// Explicit OFPA seeds. Normally redundant with the gatherer's edges, but it costs one registry
// filter per level and converts a stale-registry failure from wrong into merely slow.
if (Options.bSeedExternalPackages)
{
TArray<int32> Seeds;
GatherExternalPackageSeeds(LevelIndex, Seeds, InOutResult);
for (int32 SeedIndex : Seeds)
{
Enqueue(SeedIndex, LevelIndex, EAssetUsageProvenance::ExternalActor | EAssetUsageProvenance::SoftReference);
}
}
int32 Head = 0;
while (Head < ScratchQueue.Num())
{
const int32 Current = ScratchQueue[Head++];
for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Current))
{
int32 Target = Edge.TargetIndex;
if (!Graph.IsValidIndex(Target))
{
continue;
}
// A redirector reached on the way to a real asset must be followed through, or the
// redirector is reported used and the asset behind it is reported garbage.
EAssetUsageProvenance EdgeProvenance = EAssetUsageProvenance::None;
if (Graph.GetNode(Target).bIsRedirector)
{
const int32 Resolved = Graph.ResolveRedirector(Target);
if (Resolved != Target)
{
// Mark the redirector itself reachable too - it is a real package on disk that
// the level does depend on, and hiding it would make the report disagree with
// what a migrate or a copy actually pulls.
Enqueue(Target, Current, EAssetUsageProvenance::Redirector);
++InOutResult.Stats.RedirectorsResolved;
Target = Resolved;
EdgeProvenance |= EAssetUsageProvenance::Redirector;
}
}
if (Edge.IsHard())
{
++InOutResult.HardReferenceCounts[Target];
EdgeProvenance |= EAssetUsageProvenance::HardReference;
}
else
{
++InOutResult.SoftReferenceCounts[Target];
EdgeProvenance |= EAssetUsageProvenance::SoftReference;
}
if (Edge.IsEditorOnly())
{
EdgeProvenance |= EAssetUsageProvenance::EditorOnly;
}
const FAssetUsageNode& TargetNode = Graph.GetNode(Target);
if (TargetNode.bIsExternalPackage)
{
EdgeProvenance |= EAssetUsageProvenance::ExternalActor;
}
// Reaching another World is NOT automatically a sublevel. An earlier version assumed it
// was, and on WP_Main that assumption pulled the whole of L_MainLevel in through
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a
// different map. A crossing only counts when the reference comes from the level package
// itself or from one of its external actor packages, which is the shape a streaming
// sublevel or a Level Instance actually has.
bool bStopAtForeignLevel = false;
if (TargetNode.bIsLevel && Target != LevelIndex)
{
const FAssetUsageNode& SourceNode = Graph.GetNode(Current);
const bool bCrossingIsStructural = SourceNode.bIsLevel || SourceNode.bIsExternalPackage;
if (bCrossingIsStructural)
{
EdgeProvenance |= EAssetUsageProvenance::Sublevel;
}
else if (!Options.bTraverseIntoOtherLevels)
{
// Record the foreign map as referenced - it genuinely is - but attribute its
// contents to itself. Its own sweep reports them.
bStopAtForeignLevel = true;
}
}
// Accumulate provenance even when already visited: an asset reached by both a hard and
// a soft path should report both, otherwise the columns depend on BFS visit order.
if (OutReachable[Target])
{
InOutResult.Provenance[Target] |= EdgeProvenance;
continue;
}
if (bStopAtForeignLevel)
{
// Mark it reachable without queueing it, so the BFS never expands its dependencies.
OutReachable[Target] = true;
InOutResult.ReachableFromAnyLevel[Target] = true;
InOutResult.Provenance[Target] |= EdgeProvenance;
if (Options.bRecordRoutes)
{
ScratchPredecessor[Target] = Current;
InOutResult.Routes.FindOrAdd(Target, BuildRouteString(LevelIndex, Target, ScratchPredecessor, Options.MaxRouteHops));
}
++InOutResult.Stats.ForeignLevelsNotExpanded;
continue;
}
Enqueue(Target, Current, EdgeProvenance);
}
}
if (Options.bRecordRoutes)
{
// Walk the queue, not the whole index space: the queue already holds exactly the nodes this
// level reached. Scanning all packages per level would be 80k x 1207 iterations for nothing.
for (int32 Index : ScratchQueue)
{
if (InOutResult.Routes.Contains(Index))
{
continue;
}
InOutResult.Routes.Add(Index, BuildRouteString(LevelIndex, Index, ScratchPredecessor, Options.MaxRouteHops));
}
}
}
void FLevelUsageResolver::GatherExternalPackageSeeds(int32 LevelIndex, TArray<int32>& OutSeeds, FLevelUsageResult& InOutResult) const
{
OutSeeds.Reset();
const FString LevelPackageName = Graph.GetNode(LevelIndex).PackageName.ToString();
// The plural form runs registered path-provider delegates, so plugins that mount their own
// external-actor roots are covered. Never hardcode "__ExternalActors__" here.
TArray<FString> ExternalPaths = ULevel::GetExternalActorsPaths(LevelPackageName);
ExternalPaths.Append(ULevel::GetExternalObjectsPaths(LevelPackageName));
if (ExternalPaths.Num() == 0)
{
return;
}
FARFilter Filter;
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
Filter.PackagePaths.Reserve(ExternalPaths.Num());
for (const FString& Path : ExternalPaths)
{
Filter.PackagePaths.Add(FName(*Path));
}
AssetRegistry.EnumerateAssets(Filter, [this, &OutSeeds, &InOutResult](const FAssetData& AssetData)
{
const int32 Index = Graph.FindPackageIndex(AssetData.PackageName);
if (Index != INDEX_NONE)
{
OutSeeds.AddUnique(Index);
++InOutResult.Stats.ExternalPackagesSeeded;
}
return true;
});
}
FString FLevelUsageResolver::BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray<int32>& Predecessor, int32 MaxHops) const
{
TArray<int32> Chain;
int32 Current = AssetIndex;
while (Current != INDEX_NONE && Chain.Num() < 1024)
{
Chain.Add(Current);
if (Current == LevelIndex)
{
break;
}
Current = Predecessor.IsValidIndex(Current) ? Predecessor[Current] : INDEX_NONE;
}
Algo::Reverse(Chain);
const auto NameAt = [this](int32 Index)
{
return Graph.GetNode(Index).AssetName.IsNone()
? Graph.GetNode(Index).PackageName.ToString()
: Graph.GetNode(Index).AssetName.ToString();
};
if (MaxHops > 0 && Chain.Num() > MaxHops)
{
// Keep both ends: the level explains scope, the tail explains the asset. The middle is
// where an artist stops reading anyway.
const int32 HeadCount = MaxHops / 2;
const int32 TailCount = MaxHops - HeadCount;
TStringBuilder<512> Builder;
for (int32 i = 0; i < HeadCount; ++i)
{
if (i > 0)
{
Builder << TEXT(" -> ");
}
Builder << NameAt(Chain[i]);
}
Builder << TEXT(" -> ... (") << (Chain.Num() - MaxHops) << TEXT(" more) -> ");
for (int32 i = Chain.Num() - TailCount; i < Chain.Num(); ++i)
{
if (i > Chain.Num() - TailCount)
{
Builder << TEXT(" -> ");
}
Builder << NameAt(Chain[i]);
}
return Builder.ToString();
}
TStringBuilder<512> Builder;
for (int32 i = 0; i < Chain.Num(); ++i)
{
if (i > 0)
{
Builder << TEXT(" -> ");
}
Builder << NameAt(Chain[i]);
}
return Builder.ToString();
}