feat: asset usage audit plugin

Editor tool for the LA and 3D departments: which assets each level uses,
which are used nowhere, and export of a chosen set.

Three modules. AssetUsageAuditCore holds the whole analysis and links no
UI and no editor-only asset pipeline, so it stays runnable from a
commandlet; AssetUsageAuditEditor holds the Slate panel and everything
that needs UnrealEd or AssetTools; AssetUsageAuditTests holds 152 specs.

Load-bearing decisions, each of which produces a wrong answer if undone:

- Dependency queries are always Package + NoRequirements, never Hard. The
  map-to-external-actor edges the OFPA gatherer emits carry Game|Build
  without Hard, so a Hard query drops all 16117 external actor packages
  in this project. There is deliberately no Hard constant in the code.
- Crossing into another map is allowed only from a level or its external
  actor package. Without that rule WP_Main reported 18136 assets, of
  which 9994 belonged to L_MainLevel, reached through the GameMode.
- The verdict has five states, never a bool. The registry cannot see
  FMOD events, DataTable rows or string-built paths; those are Unknown,
  and the tool never proposes a deletion.
- Copying .uasset files does not preserve references - they are stored as
  full package paths. Only the Migrate layout produces something Unreal
  can open; the others write a manifest so the graph can be rebuilt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:07:59 +07:00
commit b7f5343a73
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,32 @@
using UnrealBuildTool;
public class AssetUsageAuditCore : ModuleRules
{
public AssetUsageAuditCore(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// INVARIANT: this module must stay free of UI and of the editor-only asset pipeline.
// Do NOT add: UnrealEd, AssetTools, ToolMenus, Slate, SlateCore, EditorSubsystem.
// AssetTools is editor-only transitively (it puts UnrealEd in PublicDependencyModuleNames),
// so pulling it in here would silently make the whole analysis undrivable from a commandlet.
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"AssetRegistry"
});
PrivateDependencyModuleNames.AddRange(new string[]
{
// Engine is a Runtime module and is available in commandlets, so it does not
// compromise the headless goal. It is needed for ULevel::GetExternalActorsPaths and
// ScanLevelAssets - the OFPA folder layout must never be string-built, because
// Content Bundles inject /CB/<Guid>/, External Data Layers inject /EDL/<UID>/, and
// plugins can register further roots through delegates.
"Engine",
"Json",
"Projects"
});
}
}
@@ -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();
}
@@ -0,0 +1,128 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
class IAssetRegistry;
/**
* Everything a set of packages needs in order to be usable somewhere else.
*
* Exists because "export the ticked meshes" without this produces meshes that open pink: the
* package file of a StaticMesh holds no materials and no textures, only references to them. The
* same applies to a Niagara system and its sprites, and to a Blueprint and everything it spawns.
*
* Deliberately a separate step rather than something FAssetUsageExporter does internally. The
* exporter stays a dumb file copier that needs no Asset Registry and is testable without one, and
* the caller keeps the chance to show "40 ticked, 380 will be written" before anything is copied -
* which matters, because that ratio surprises people.
*
* This is NOT IAssetTools::MigratePackages. Migrate walks the same closure but also decides where
* files go, prompts, and cannot be told to stop at a folder boundary. Here the traversal is bounded
* by the same exclusion list the audit uses, and engine content is left out by default because the
* destination project already has it.
*/
namespace AssetDependencyClosure
{
struct FOptions
{
/**
* Package prefixes not to descend into, in either /Game/... or Content/... form.
* Normally the audit's own exclusion list, so an export cannot pull in content the report
* deliberately ignores.
*/
TArray<FString> ExcludePackagePaths;
/**
* Include /Engine and /Temp packages in the result.
*
* Off by default. A mesh using DefaultMaterial genuinely depends on /Engine content, but
* copying it into the export folder is almost never what someone wants - the destination
* project ships the same file, and overwriting it there is worse than useless.
*/
bool bIncludeEnginePackages = false;
/**
* Stop after this many hops from a seed. Zero means no limit.
*
* A limit is a blunt instrument and changes the answer rather than just shortening it, so it
* is off by default. It exists for the "just the materials, not the whole graph" case.
*/
int32 MaxDepth = 0;
/** Called as (Visited, Queued). Return false to stop; the partial result is still returned. */
TFunction<bool(int32, int32)> OnProgress;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FStats
{
/** Seeds that were valid package names to begin with. */
int32 SeedCount = 0;
/** Size of the returned array, seeds included. */
int32 TotalCount = 0;
/** Reached but dropped, per reason. Kept apart so a surprising result can be explained. */
int32 SkippedScript = 0;
int32 SkippedEngine = 0;
int32 SkippedExcluded = 0;
/**
* Maps reached through an ordinary asset rather than through a level, and therefore not
* followed - nor included.
*
* The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main
* the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994
* packages belonging to a different map. Without this the export dialog would quote an
* honest number for a wrong set.
*
* Excluded rather than merely not expanded, unlike in the audit. The exporter expands any
* .umap it is handed into its One File Per Actor packages, so including the map file would
* pull the foreign level's contents back in through the exporter instead of the closure.
*/
int32 ForeignLevelsSkipped = 0;
/** Deepest hop count actually reached. Tells you whether MaxDepth did anything. */
int32 DeepestHop = 0;
bool bStoppedEarly = false;
FString Summarise() const;
};
/**
* Seeds plus everything they depend on, transitively.
*
* Seeds come first and in their original order, so a caller can still tell what was asked for.
* The traversal uses AssetUsageAudit::MakeTraversalQuery() - package category, no requirements -
* for the same reason the audit does: a Hard-only query silently drops every soft edge, which
* includes every One File Per Actor package and every TSoftObjectPtr a Blueprint resolves.
*/
/** One ticked asset and everything it pulls in, the seed first. */
struct FSeedClosure
{
FName Seed;
TArray<FName> Packages;
};
/**
* Closure per seed, rather than one closure over all of them.
*
* Needed by the folder-per-asset layout: a texture shared by forty meshes has to appear in all
* forty folders, and the flat Gather deliberately returns it once. Costs one traversal per
* seed, so it is the slower call by construction - use Gather when a single list will do.
*/
ASSETUSAGEAUDITCORE_API TArray<FSeedClosure> GatherPerSeed(
IAssetRegistry& Registry,
const TArray<FName>& Seeds,
const FOptions& Options,
FStats& OutStats);
ASSETUSAGEAUDITCORE_API TArray<FName> Gather(
IAssetRegistry& Registry,
const TArray<FName>& Seeds,
const FOptions& Options,
FStats& OutStats);
}
@@ -0,0 +1,103 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetDependencyClosure.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsageExporter.h"
class IAssetRegistry;
/**
* Decides where each exported file goes.
*
* Deliberately separate from FAssetUsageExporter. The exporter's job is to copy a package to a
* path and nothing else - it has no idea what a preset or a dependency is, and keeping it that way
* is why its tests can run against invented package names. Everything that needs the Asset
* Registry, the class hierarchy or the dependency closure lives here instead, and hands the
* exporter a finished list of "these packages, into this subfolder".
*/
namespace AssetExportLayout
{
/**
* A set of packages destined for one subfolder of the export target.
*
* An alias, not a second struct: this namespace exists to produce exactly what the exporter
* consumes, and two identical types would mean a conversion loop whose only job is to prove
* they stayed identical.
*/
using FGroup = FAssetUsageExporter::FExportGroup;
struct FOptions
{
EExportLayout Layout = EExportLayout::Flat;
/**
* Sort each asset's dependencies into per-type subfolders: Texture/, Material/, and so on.
*
* Only meaningful with FolderPerAsset - there is no "each asset's dependencies" to sort in
* the other layouts. BuildGroups ignores it rather than inventing a meaning.
*
* The seed itself stays at the root of its own folder. It is the subject of that folder;
* filing it under StaticMesh/ next to its own dependencies would bury it.
*/
bool bGroupDependenciesByType = false;
};
struct ASSETUSAGEAUDITCORE_API FStats
{
int32 GroupCount = 0;
/** Distinct packages across every group. */
int32 DistinctPackages = 0;
/**
* Total placements, counting a shared dependency once per folder it lands in.
*
* This, not DistinctPackages, is the number of files that will be written. Under
* FolderPerAsset the two differ sharply and the user has to be told which one they are
* looking at before the copy starts.
*/
int32 FilePlacements = 0;
/** Folder names that collided and were given a numeric suffix. */
int32 RenamedFolders = 0;
FString Summarise() const;
};
/**
* Build the export groups.
*
* @param SeedClosures For FolderPerAsset, one entry per ticked asset from GatherPerSeed. For
* the other layouts only the union of Packages is used, so a single
* closure covering everything is enough.
*/
ASSETUSAGEAUDITCORE_API TArray<FGroup> BuildGroups(
IAssetRegistry& Registry,
const TArray<AssetDependencyClosure::FSeedClosure>& SeedClosures,
const FOptions& Options,
FStats& OutStats);
/**
* Map every class the type presets cover, and their subclasses, to a folder name.
*
* Folder names are the preset names verbatim - Texture, Material, StaticMesh - rather than a
* second set of invented labels. The Types filter in the panel already shows those words, so
* the folders a user gets match the words they filtered by.
*
* Built once per export: expanding the class hierarchy costs a registry call per preset.
*/
ASSETUSAGEAUDITCORE_API TMap<FTopLevelAssetPath, FString> BuildTypeFolderMap(IAssetRegistry& Registry);
/**
* Folder name for one package, using a map from BuildTypeFolderMap.
* Returns "Other" for anything no preset covers, never an empty string - an empty name would
* silently put the file in the parent folder and look like the grouping had failed.
*/
ASSETUSAGEAUDITCORE_API FString TypeFolderForPackage(
IAssetRegistry& Registry,
FName PackageName,
const TMap<FTopLevelAssetPath, FString>& FolderByClass);
}
@@ -0,0 +1,83 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsageExporter.h"
class IAssetRegistry;
/**
* The JSON written beside a reference-breaking export.
*
* Why it exists: references inside a .uasset are stored as full package names, so a copied file
* only resolves when it sits at exactly that package path in the destination. The Flat and
* FolderPerAsset layouts deliberately do not put it there - they arrange files for a person to read
* and hand around - so the reference graph is lost the moment the files leave the project.
*
* The manifest records that graph next to the files, so a later import can rebuild it rather than
* guess. It is written from what the exporter actually wrote, never from what it intended to write:
* the two differ exactly when the collision policy renamed something, which is the case a manifest
* has to get right.
*
* Migrate needs none of this - the engine preserves the paths itself - and asking for a manifest
* there is treated as a caller mistake rather than silently producing a misleading file.
*/
namespace AssetExportManifest
{
/** Bumped when the schema changes in a way a reader must notice. */
inline constexpr int32 SchemaVersion = 1;
/** Manifest filename, written into the export root. */
inline const TCHAR* FileName = TEXT("AssetUsageAudit.manifest.json");
struct FOptions
{
/** Export root. The manifest is written here and paths are relative to it. */
FString TargetDirectory;
/** Recorded so a reader knows why the paths look the way they do. */
EExportLayout Layout = EExportLayout::Flat;
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
/**
* Packages the user ticked, as opposed to those pulled in as dependencies.
*
* Kept apart because an importer needs to know which assets were the point and which came
* along to make them work - "restore what I exported" and "restore everything in this
* folder" are different requests.
*/
TSet<FName> SeedPackages;
};
struct ASSETUSAGEAUDITCORE_API FResult
{
bool bSuccess = false;
/** Absolute path of the manifest, empty on failure. */
FString FilePath;
int32 EntriesWritten = 0;
/** Dependency edges recorded across all entries. */
int32 EdgesRecorded = 0;
FString ErrorMessage;
};
/**
* Write the manifest.
*
* @param WrittenFiles From FAssetUsageExporter::FResult::WrittenFiles, which requires the
* exporter to have been run with bRecordWrittenFiles. An empty array is a
* failure rather than an empty manifest: it almost always means the flag
* was forgotten, and an empty manifest beside a full folder is worse than
* no manifest at all.
*/
ASSETUSAGEAUDITCORE_API FResult Write(
IAssetRegistry& Registry,
const TArray<FAssetUsageExporter::FWrittenFile>& WrittenFiles,
const FOptions& Options);
}
@@ -0,0 +1,57 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
/**
* Collision-handling for export filenames.
*
* Deliberately a free function over strings with an injectable existence predicate: the rule
* ("increment an existing index, do not append a second one") is fiddly on real asset names like
* SM_Rock_02_v3, and it must be unit-testable without touching a filesystem or an editor.
*/
namespace AssetExportNaming
{
/** Predicate answering "does this filename already exist in the target folder". */
using FExistsPredicate = TFunctionRef<bool(const FString& /*FileName*/)>;
/**
* Split a base name into stem and trailing numeric index.
*
* "Foo" -> {"Foo", INDEX_NONE, 0}
* "Foo_1" -> {"Foo", 1, 1}
* "Foo_007" -> {"Foo", 7, 3} <- padding width preserved
* "SM_Rock_02_v3"-> {"SM_Rock_02_v3", INDEX_NONE, 0} <- v3 is not a numeric suffix
* "SM_Rock_02" -> {"SM_Rock", 2, 2}
* "Foo_" -> {"Foo_", INDEX_NONE, 0} <- empty suffix is not an index
*
* @param BaseName Filename without extension.
* @param OutStem Portion before the trailing _N, or the whole name when there is none.
* @param OutIndex Parsed index, or INDEX_NONE.
* @param OutPadWidth Digit count of the parsed index, so Foo_007 -> Foo_008 not Foo_8.
*/
ASSETUSAGEAUDITCORE_API void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth);
/**
* Compose a filename from stem, index and padding.
* (Foo, INDEX_NONE, 0) -> "Foo"
* (Foo, 1, 1) -> "Foo_1"
* (Foo, 8, 3) -> "Foo_008"
*/
ASSETUSAGEAUDITCORE_API FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth);
/**
* Resolve a target filename under the given collision policy.
*
* Overwrite -> returns DesiredFileName unchanged.
* Index -> returns the first non-colliding name, incrementing any existing trailing index.
*
* @param DesiredFileName Filename with extension, e.g. "SM_Rock.uasset".
* @param Policy Overwrite or Index.
* @param Exists Predicate over filenames (with extension) in the target folder.
* @return Filename with extension that does not collide, or DesiredFileName under Overwrite.
*/
ASSETUSAGEAUDITCORE_API FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists);
}
@@ -0,0 +1,85 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
class IAssetRegistry;
struct FAssetUsageNode;
/**
* Named groups of asset classes, so an artist picks "VFX" rather than typing a class path.
*/
enum class EAssetTypePreset : uint8
{
StaticMesh,
SkeletalMesh,
Material,
Texture,
VFX,
Sound,
Blueprint,
Level,
DataAsset,
Animation
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetTypePreset Preset);
/** Every preset, in the order the UI should list them. */
ASSETUSAGEAUDITCORE_API TArray<EAssetTypePreset> GetAllAssetTypePresets();
/** Class paths a preset stands for, before subclass expansion. */
ASSETUSAGEAUDITCORE_API TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset);
/**
* Matches assets by class, with the two things that make class filtering work in this project.
*
* 1. Subclass expansion via GetDerivedClassNames, so "Material" also matches MaterialInstanceConstant.
*
* 2. Blueprint awareness. A BP asset's own class is always /Script/Engine.Blueprint, so filtering on
* class alone finds no Blueprints at all - fatal in a project whose gameplay is entirely Blueprints.
* The gameplay class lives in the GeneratedClass registry tag, captured per node during the sweep,
* and a node matches when EITHER its class or its generated class is in the expanded set.
*/
class ASSETUSAGEAUDITCORE_API FAssetTypeFilter
{
public:
/** Empty filter matches everything. */
FAssetTypeFilter() = default;
void AddPreset(EAssetTypePreset Preset);
/** Raw class path, e.g. "/Script/Engine.StaticMesh" or a short name like "StaticMesh". */
void AddRawClass(const FString& ClassPathOrName);
/**
* Expand every added class to include its subclasses.
* Must be called after the Add* calls and before Matches.
*/
void Compile(IAssetRegistry& AssetRegistry);
bool IsEmpty() const
{
return RequestedClasses.IsEmpty();
}
bool Matches(const FAssetUsageNode& Node) const;
/** Human-readable description for the report header. */
FString Describe() const;
/** Raw class strings that could not be resolved to a real class. */
TConstArrayView<FString> GetUnresolvedClasses() const
{
return UnresolvedClasses;
}
private:
TArray<FTopLevelAssetPath> RequestedClasses;
TArray<FString> RequestedDescriptions;
TArray<FString> UnresolvedClasses;
TSet<FTopLevelAssetPath> ExpandedClasses;
bool bCompiled = false;
};
@@ -0,0 +1,18 @@
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
ASSETUSAGEAUDITCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAudit, Log, All);
/**
* Analysis-only module. Deliberately links no UI and no editor framework:
* every dependency here is available in a commandlet and in a standalone Program,
* which is what keeps a future headless mode a packaging question rather than a rewrite.
*/
class FAssetUsageAuditCoreModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};
@@ -0,0 +1,235 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "Misc/AssetRegistryInterface.h"
// EExportLayout is a UENUM so the editor settings can expose it as a dropdown. It is the only
// reflected type in this module; everything else here stays plain C++ so the analysis has no
// reflection cost. Must be the last include, as UHT requires.
#include "AssetUsageAuditTypes.generated.h"
/**
* Why an asset is considered used - or why we cannot tell.
*
* Deliberately five states, never a bool. The Asset Registry cannot see string-built paths,
* OpenLevel(FName), DataTable row contents or FMOD event references; reporting those as
* "unused" is how an audit tool causes a deletion incident. Unknown is a legitimate answer.
*/
enum class EAssetUsageVerdict : uint8
{
/** Reachable from at least one level package. Columns say through which, and hard or soft. */
UsedOnLevel,
/** Has referencers, but no chain from any level reaches it. */
UsedByAssetsOnly,
/** Found by scanning Config/ and Source/ for /Game paths. Provenance carries file and line. */
ReferencedFromConfigOrSource,
/** GetReferencers returned nothing and no level reaches it. */
Unreferenced,
/** Falls into a known registry blind spot. Never present this as "safe to delete". */
Unknown
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetUsageVerdict Verdict);
/**
* Every verdict, in the order the UI should list them.
*
* Single source of truth: the menu, the report and the settings restore all need this list, and
* three hand-written copies would drift the moment a sixth verdict is added.
*/
ASSETUSAGEAUDITCORE_API TArray<EAssetUsageVerdict> GetAllAssetUsageVerdicts();
/** How an asset reference was discovered. Bitmask - an asset can be reached several ways. */
enum class EAssetUsageProvenance : uint8
{
None = 0,
/** Direct hard dependency edge (EDependencyProperty::Hard present). */
HardReference = 1 << 0,
/** Soft dependency edge - the lack of Hard. Includes every external actor edge. */
SoftReference = 1 << 1,
/** Reached through an OFPA __ExternalActors__ / __ExternalObjects__ package. */
ExternalActor = 1 << 2,
/** Reached through ULevelStreaming::WorldAsset or ALevelInstance::WorldAsset. */
Sublevel = 1 << 3,
/** Path literal found in Config/*.ini. */
ConfigFile = 1 << 4,
/** Path literal found in Source/**.cpp|h. */
SourceFile = 1 << 5,
/** Edge is editor-only (EDependencyProperty::Game absent) - not shipped, but still a use. */
EditorOnly = 1 << 6,
/** Reached through a redirector that we resolved. */
Redirector = 1 << 7
};
ENUM_CLASS_FLAGS(EAssetUsageProvenance);
ASSETUSAGEAUDITCORE_API FString ProvenanceToString(EAssetUsageProvenance Provenance);
/**
* How exported files are arranged under the target folder.
*
* Deliberately an enum rather than a set of booleans. These are mutually exclusive: "mirror the
* /Game tree" and "one folder per ticked asset" cannot both be true, and a pair of booleans would
* make that contradiction expressible and then silently resolve it one way.
*/
UENUM()
enum class EExportLayout : uint8
{
/** Everything straight into the target folder. This is why the collision policy exists. */
Flat UMETA(DisplayName = "Flat - everything in one folder"),
/** Mirror the package path, so /Game/Art/SM_Rock lands in Game/Art/SM_Rock.uasset. */
MirrorTree UMETA(DisplayName = "Mirror the /Game folder tree"),
/**
* A subfolder per ticked asset, with that asset's dependencies beside it.
*
* Note the consequence: a texture shared by forty meshes is copied forty times, once into each
* mesh's folder. That is the point - each folder is self-contained and can be handed over on
* its own - but it means the file count exceeds the number of distinct assets, sometimes by a
* lot. The export dialog states the real figure before anything is written.
*/
FolderPerAsset,
/**
* Hand the packages to IAssetTools::MigratePackages, aimed at another project's Content folder.
*
* The only layout whose output opens in Unreal with its references intact. References inside a
* .uasset are stored as full package names, so a file only resolves when it sits at exactly the
* same package path in the destination; Migrate is the engine's own code for arranging that,
* including the OFPA actor packages of a level.
*
* Not implemented in this module. AssetTools is editor-only, and the analysis here has to stay
* runnable from a commandlet - the editor module dispatches this value to its own path. Anything
* in Core that switches on the layout must therefore treat Migrate as "not mine".
*/
Migrate
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EExportLayout Layout);
/**
* What to do when an exported file already exists in the target folder.
*/
enum class EExportCollisionPolicy : uint8
{
/** Replace the existing file. */
Overwrite,
/** Append or increment a numeric suffix: Foo -> Foo_1, Foo_7 -> Foo_8. */
Index
};
/**
* One row of the audit result.
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
{
/** Package name, e.g. /Game/Space/Art/SM_Rock. */
FName PackageName;
/** Asset name without path. */
FName AssetName;
/** Class path of the asset, e.g. /Script/Engine.StaticMesh. */
FTopLevelAssetPath ClassPath;
/**
* For Blueprints, the generated gameplay class from the GeneratedClass tag.
* A BP asset's ClassPath is always /Script/Engine.Blueprint, which is useless for filtering.
*/
FTopLevelAssetPath GeneratedClassPath;
/** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */
FString PathFromProjectRoot;
EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown;
EAssetUsageProvenance Provenance = EAssetUsageProvenance::None;
/** Levels this asset is reachable from. Includes both the sublevel and its parent map. */
TArray<FName> Levels;
/** How many incoming edges carried EDependencyProperty::Hard. */
int32 HardReferenceCount = 0;
/** How many incoming edges lacked Hard. */
int32 SoftReferenceCount = 0;
/**
* Human-readable chain explaining the verdict, e.g.
* "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* A verdict without a route is unactionable for an artist.
*/
FString Route;
/** For ReferencedFromConfigOrSource - which file and line named this asset. */
FString ProvenanceDetail;
bool IsUsedOnAnyLevel() const
{
return Verdict == EAssetUsageVerdict::UsedOnLevel;
}
};
/**
* Metadata written into the report header so a stale report can never be mistaken for a fresh one.
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageReportHeader
{
FDateTime GeneratedAt;
FString EngineVersion;
FString ToolVersion;
FString ProjectName;
/** Human-readable description of the filters that produced this result. */
TArray<FString> AppliedFilters;
int32 LevelsScanned = 0;
int32 AssetsScanned = 0;
double ScanDurationSeconds = 0.0;
};
/**
* The dependency query this tool must always use.
*
* Package category, NoRequirements flags. NOT Hard.
*
* Two engine facts make this non-negotiable:
*
* 1. FExternalObjectAndActorDependencyGatherer (ExternalObjectAndActorDependencyGatherer.cpp:22)
* emits map -> external actor edges with property mask Game|Build. Hard is absent, and
* AssetRegistryInterface.h:95 states the lack of Hard *is* a soft dependency. Querying with
* Hard therefore drops every external actor - 16126 packages in this project.
*
* 2. In EDependencyQuery, Soft is literally defined as NotHard. So Hard|Soft means
* "require Hard AND require not-Hard" and matches nothing at all.
*
* Hard vs soft is a column in the report, never a filter on the query.
*/
namespace AssetUsageAudit
{
inline UE::AssetRegistry::FDependencyQuery MakeTraversalQuery()
{
return UE::AssetRegistry::FDependencyQuery();
}
inline UE::AssetRegistry::EDependencyCategory MakeTraversalCategory()
{
return UE::AssetRegistry::EDependencyCategory::Package;
}
}
@@ -0,0 +1,85 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetTypeFilter.h"
#include "AssetUsageGraph.h"
#include "LevelUsageResolver.h"
class IAssetRegistry;
struct FAssetUsageAuditRequest
{
/** Roots to sweep. Empty means /Game. */
TArray<FString> IncludePackagePaths;
/** Package or content-relative prefixes to skip, e.g. "Content/3rdParty". */
TArray<FString> ExcludePackagePaths;
/** Levels to consider. Empty means every level found. */
TArray<FName> LevelPackages;
/** Type filter applied to the reported rows, not to the traversal. */
FAssetTypeFilter TypeFilter;
/** Scan Config/ and Source/ for path literals the registry cannot see. */
bool bScanIndirectReferences = true;
/** Drop rows for __ExternalActors__ / __ExternalObjects__ packages. */
bool bHideExternalPackages = true;
/** Drop redirector rows; they are plumbing, not content an artist acts on. */
bool bHideRedirectors = true;
/** Report only assets that no level reaches. */
bool bOnlyUnusedAssets = false;
FLevelUsageResolveOptions ResolveOptions;
};
// Exported: CountByVerdict is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FAssetUsageAuditResult
{
TArray<FAssetUsageRow> Rows;
FAssetUsageReportHeader Header;
FAssetUsageGraphStats GraphStats;
FLevelUsageStats LevelStats;
int32 CountByVerdict(EAssetUsageVerdict Verdict) const;
};
/**
* Runs the whole analysis: graph, level reachability, indirect scan, verdicts.
*
* Free of UI and of the editor asset pipeline, so the Slate panel and a future commandlet can
* call exactly the same code and cannot drift apart.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageAuditor
{
public:
static FAssetUsageAuditResult Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request);
/**
* Classify one asset.
*
* Pure and exposed for testing: the ordering of these rules is the difference between a tool
* people trust and one that tells an artist to delete the GameMode.
*
* @param bReachableFromLevel A level's traversal reached this asset.
* @param bHasReferencers Something in the graph depends on it.
* @param bFoundInConfigOrSource A path literal in Config/ or Source/ named it.
* @param bIsBlindSpot Its type is one the registry cannot track reliably, e.g. FMOD.
*/
static EAssetUsageVerdict ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot);
/**
* True for asset types whose real usage the Asset Registry cannot see.
*
* FMOD is the concrete case on this project: it resolves events by string path through the
* FMOD Studio runtime, entirely outside the UObject reference graph, so "no referencers"
* carries no information at all for an FMOD asset.
*/
static bool IsRegistryBlindSpot(const FAssetUsageNode& Node);
};
@@ -0,0 +1,219 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
/**
* Copies package files out of the project.
*
* Deliberately a plain file copy rather than IAssetTools::MigratePackages. Migrate pulls the whole
* dependency closure, which is emphatically not what someone asked for when they ticked eleven
* meshes, and it lives in an editor-only module. A byte copy loads no UObject, needs no editor,
* and gives exactly the files that were ticked.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageExporter
{
public:
struct FOptions
{
/** Absolute destination directory. Created if missing. */
FString TargetDirectory;
/** What to do when a file of that name is already there. */
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
/**
* Put every file directly in the target folder rather than mirroring the /Game tree.
*
* Flat is what people expect from "export to a folder" and is why the collision policy
* exists at all - mirroring the tree makes collisions nearly impossible but hands back a
* deep folder structure nobody asked for.
*/
bool bFlatten = true;
/**
* Also copy the __ExternalActors__ / __ExternalObjects__ packages belonging to any exported
* level. Without these an exported OFPA map opens empty in the destination project.
*/
bool bIncludeExternalPackages = true;
/**
* Record where each package actually ended up, in FResult::WrittenFiles.
*
* Off by default because it costs a string per file. Required by the dependency manifest:
* a manifest built from intended paths rather than written ones is wrong precisely when the
* collision policy renamed something, which is when someone needs it most.
*/
bool bRecordWrittenFiles = false;
/** Called as (Done, Total). Return false to cancel; already-copied files are kept. */
TFunction<bool(int32, int32)> OnProgress;
};
/** One copied file, as it was actually written. */
struct FWrittenFile
{
FName PackageName;
/** Path relative to FOptions::TargetDirectory, with the final on-disk filename. */
FString RelativePath;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FResult
{
/** Filled only when FOptions::bRecordWrittenFiles was set. */
TArray<FWrittenFile> WrittenFiles;
bool bSuccess = false;
int32 FilesCopied = 0;
int32 FilesRenamed = 0;
int32 FilesOverwritten = 0;
int32 FilesMissingOnDisk = 0;
int32 ExternalPackagesCopied = 0;
bool bCancelled = false;
/** One line per failure, safe to show in a dialog. */
TArray<FString> Errors;
FString Summarise() const;
};
/**
* A set of packages destined for one subfolder of the target.
*
* The exporter does not decide what the subfolder means - AssetExportLayout does, and hands
* the result over already named. Keeping the split means this class still knows nothing about
* presets, dependencies or the Asset Registry, which is why its specs can run on package names
* that do not exist.
*/
struct FExportGroup
{
/** Relative to FOptions::TargetDirectory. Empty writes straight into it. */
FString RelativeDir;
TArray<FName> Packages;
};
/**
* Copy packages, each group into its own subfolder.
*
* Collision handling is per destination folder, not global: two groups may each hold a file
* called SM_Rock.uasset without either being renamed, because they land in different folders.
* That is the point of the folder-per-asset layout.
*/
static FResult ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options);
/** Single-group convenience: everything straight into the target. */
static FResult ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options);
// --- Exchange formats ------------------------------------------------------------------------
/**
* One asset to convert. Deliberately not FAssetUsageRow: conversion needs three fields and
* taking the whole row would make this callable only from a finished audit.
*/
struct FExportItem
{
FName PackageName;
FName AssetName;
/** Asset class, e.g. /Script/Engine.StaticMesh. Chooses the output format. */
FTopLevelAssetPath ClassPath;
};
struct FExchangeOptions
{
FString TargetDirectory;
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
bool bFlatten = true;
/**
* Asset class short name -> file extension without the dot, e.g. {"StaticMesh", "fbx"}.
*
* Passed in rather than hardcoded so the Core module stays free of the settings object, and
* so a class with no sensible exchange format is a configuration fact rather than a silent
* omission. Lookup walks up the class hierarchy, so mapping MaterialInterface also covers
* MaterialInstanceConstant.
*/
TMap<FString, FString> FormatByClass;
/**
* Run garbage collection every N assets.
*
* Unlike a file copy, this path loads every UObject it touches. Exporting a few thousand
* meshes without collecting will exhaust memory long before the export finishes. Zero
* disables it.
*/
int32 CollectGarbageEvery = 64;
/** Called as (Done, Total). Return false to cancel; files already written are kept. */
TFunction<bool(int32, int32)> OnProgress;
};
struct ASSETUSAGEAUDITCORE_API FExchangeResult
{
bool bSuccess = false;
int32 FilesWritten = 0;
int32 FilesRenamed = 0;
/** No extension configured for the asset's class. Not an error - a deliberate omission. */
int32 SkippedNoFormat = 0;
/** The asset would not load. Counted separately from an exporter refusing to run. */
int32 SkippedNotLoaded = 0;
/** Loaded fine, but no UExporter is registered for that class and extension. */
int32 SkippedNoExporter = 0;
bool bCancelled = false;
TArray<FString> Errors;
FString Summarise() const;
};
/**
* Convert assets to interchange formats (FBX, PNG, WAV, ...) via UAssetExportTask.
*
* Loads every asset, so it is orders of magnitude slower than ExportPackageFiles and must be
* driven with a progress callback. bPrompt is forced false and bAutomated true: a modal file
* dialog per asset would hang an unattended run, and this is the whole reason the engine has
* those flags.
*/
static FExchangeResult ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options);
/**
* Extension for an asset class, following the class hierarchy upward.
* Returns an empty string when nothing in the chain is mapped.
*/
static FString FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass);
/** The mapping the tool ships with. Editor settings seed themselves from this. */
static TMap<FString, FString> GetDefaultFormatByClass();
/**
* Resolve a package name to its file on disk, trying both asset and map extensions.
* Returns an empty string when the package has no file (script packages, unsaved assets).
*/
static FString ResolvePackageFilePath(FName PackageName);
private:
/**
* The copy loop, shared by every entry point.
*
* Takes the destination folder per entry rather than deriving it, so that deciding where a file
* goes and actually writing it stay separate concerns. Result is passed by reference because
* the caller has already recorded setup failures into it.
*/
static FResult CopyExpandedPackages(
const TArray<FName>& Expanded,
const TArray<FString>& DestinationDirs,
const TBitArray<>& IsExternalExpansion,
const FOptions& Options,
FResult& Result);
};
@@ -0,0 +1,188 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "AssetRegistry/AssetData.h"
class IAssetRegistry;
/**
* One dependency edge, stored by dense index rather than by FName.
*
* 8 bytes. At this project's scale (80k packages, several hundred thousand edges) the difference
* between this and a TMap<FName, TArray<FName>> is the difference between a tool that answers in
* seconds and one nobody waits for.
*/
struct FAssetUsageEdge
{
/** Dense index of the target package in FAssetUsageGraph. */
int32 TargetIndex = INDEX_NONE;
/** Edge properties as reported by the registry: Hard / Game / Build. */
UE::AssetRegistry::EDependencyProperty Properties = UE::AssetRegistry::EDependencyProperty::None;
bool IsHard() const
{
return EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Hard);
}
/** Lack of Hard is what the engine calls a soft dependency (AssetRegistryInterface.h:95). */
bool IsSoft() const
{
return !IsHard();
}
/** Lack of Game means the edge is editor-only and will not survive a cook. */
bool IsEditorOnly() const
{
return !EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Game);
}
};
/**
* Per-package facts captured once during the sweep, so later passes never re-query the registry.
*/
struct FAssetUsageNode
{
FName PackageName;
FName AssetName;
FTopLevelAssetPath ClassPath;
/**
* Gameplay class behind a Blueprint, read from the GeneratedClass asset-registry tag.
*
* Needed because a BP asset's own ClassPath is always /Script/Engine.Blueprint. Filtering
* ClassPaths alone finds zero Blueprints, which in this project means finding almost nothing.
*/
FTopLevelAssetPath GeneratedClassPath;
/** ClassPath is /Script/Engine.World. */
bool bIsLevel = false;
/** Package lives under __ExternalActors__ or __ExternalObjects__. */
bool bIsExternalPackage = false;
/** Asset is an ObjectRedirector and must be followed through, not reported. */
bool bIsRedirector = false;
};
struct FAssetUsageGraphBuildOptions
{
/** Roots to sweep. Defaults to /Game. */
TArray<FString> IncludePackagePaths;
/** Package path prefixes to skip entirely, e.g. Content/3rdParty. */
TArray<FString> ExcludePackagePaths;
/** Collect dependency edges. Off gives a much faster inventory-only pass. */
bool bGatherDependencies = true;
};
struct FAssetUsageGraphStats
{
int32 NumPackages = 0;
int32 NumEdges = 0;
int32 NumLevels = 0;
int32 NumExternalPackages = 0;
double EnumerateSeconds = 0.0;
double DependencySeconds = 0.0;
double TotalSeconds() const
{
return EnumerateSeconds + DependencySeconds;
}
};
/**
* Dense, immutable-after-build dependency graph over the project's packages.
*
* Edges are stored CSR-style: one flat FAssetUsageEdge array plus an offset table, so a node's
* dependency list is a contiguous view with no per-node allocation.
*
* The graph deliberately stores dependencies in BOTH directions. Forward edges answer "what does
* this level use"; reverse edges answer "is this asset referenced by anything at all", which is
* what separates the Unreferenced verdict from UsedByAssetsOnly.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageGraph
{
public:
/**
* Sweep the registry and build the graph.
*
* The registry must already be populated. In a commandlet the AssetRegistry module gathers
* synchronously on load; in the editor callers must wait for OnFilesLoaded first.
*/
void Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options);
void Reset();
int32 Num() const
{
return Nodes.Num();
}
bool IsValidIndex(int32 Index) const
{
return Nodes.IsValidIndex(Index);
}
/** INDEX_NONE when the package was not part of the sweep. */
int32 FindPackageIndex(FName PackageName) const
{
const int32* Found = PackageToIndex.Find(PackageName);
return Found ? *Found : INDEX_NONE;
}
const FAssetUsageNode& GetNode(int32 Index) const
{
return Nodes[Index];
}
TConstArrayView<FAssetUsageNode> GetNodes() const
{
return Nodes;
}
/** Packages this one depends on. */
TConstArrayView<FAssetUsageEdge> GetDependencies(int32 Index) const;
/** Packages that depend on this one. */
TConstArrayView<FAssetUsageEdge> GetReferencers(int32 Index) const;
/** Dense indices of every /Script/Engine.World package in the sweep. */
TConstArrayView<int32> GetLevelIndices() const
{
return LevelIndices;
}
const FAssetUsageGraphStats& GetStats() const
{
return Stats;
}
/**
* Follow a redirector chain to the asset it ultimately points at.
* Returns Index unchanged when it is not a redirector. Cycle-safe.
*/
int32 ResolveRedirector(int32 Index) const;
private:
int32 AddOrFindPackage(FName PackageName);
void BuildReverseEdges();
TArray<FAssetUsageNode> Nodes;
TMap<FName, int32> PackageToIndex;
/** CSR forward edges: Dependencies[DependencyOffsets[i] .. DependencyOffsets[i+1]). */
TArray<FAssetUsageEdge> Dependencies;
TArray<int32> DependencyOffsets;
TArray<FAssetUsageEdge> Referencers;
TArray<int32> ReferencerOffsets;
TArray<int32> LevelIndices;
FAssetUsageGraphStats Stats;
};
@@ -0,0 +1,81 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/**
* Package-path predicates and conversions.
*
* Pure string work, no registry and no engine state, so it is unit-testable and cheap enough to
* call inside the per-asset sweep.
*
* Note on OFPA: these helpers only ever *classify* a path. They never try to derive a level's
* external-actor folder by string building, and they never try to recover the owning level from
* an external actor path. Both are real traps - Content Bundles inject /CB/<Guid>/ and External
* Data Layers inject /EDL/<UID>/ between the folder and the level path, and plugins can register
* additional roots through delegates. Producing those paths is ULevel::GetExternalActorsPaths's
* job; recovering ownership is avoided entirely by traversing forward from the level.
*/
namespace AssetUsagePaths
{
/** True for a package under an __ExternalActors__ or __ExternalObjects__ root. */
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FName PackageName);
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FStringView PackagePath);
/** True for /Script/... - code, not an asset. Cannot be exported or reported unused. */
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FName PackageName);
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FStringView PackagePath);
/** True for /Engine/... or /Temp/... - not project content. */
ASSETUSAGEAUDITCORE_API bool IsEngineOrTempPackage(FStringView PackagePath);
/**
* True when the package sits under any of the given exclusion prefixes.
*
* Prefixes may be given in either package form ("/Game/3rdParty") or content-relative form
* ("Content/3rdParty"), because the UI shows users the latter and settings files tend to
* accumulate both. Matching is case-insensitive and boundary-aware, so "/Game/Art" does not
* exclude "/Game/ArtSource".
*/
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes);
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes);
/**
* Normalise an exclusion prefix to package form with no trailing slash.
* "Content/3rdParty/" -> "/Game/3rdParty"
* "/Game/3rdParty" -> "/Game/3rdParty"
* "Content" -> "/Game"
*/
ASSETUSAGEAUDITCORE_API FString NormalizeExclusionPrefix(const FString& Prefix);
/**
* Package name to a path relative to the project root, as the report requires.
* "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset"
*
* @param bIsLevel Chooses the .umap extension over .uasset.
* @return Empty for packages with no project-relative form, e.g. /Script or /Engine.
*/
ASSETUSAGEAUDITCORE_API FString ToProjectRelativePath(FName PackageName, bool bIsLevel);
/**
* Can this folder be used as a Migrate destination?
*
* Mirrors the two checks UAssetToolsImpl makes, and exists because it makes them *after* being
* called and reports the refusal to the Output Log alone - so an impossible export otherwise
* presents as a button that does nothing.
*
* The engine's rules:
* 1. the path must end in /Content/ (MigratePackages_ReportConfirmed);
* 2. the folder above it must hold a .uproject, or exactly one .uplugin
* (FPackageMigrationImpl::GetMountPointRootPath) - that is where the destination mount
* point comes from, and without it Migrate aborts.
*
* Lives in Core despite serving an editor-only feature: it is filesystem and string work with
* no AssetTools involved, so it belongs with the other path predicates and can be tested
* without an editor module.
*
* @return Reason the folder is unusable, or an empty string when it is fine.
*/
ASSETUSAGEAUDITCORE_API FString ValidateMigrateDestination(const FString& Directory);
}
@@ -0,0 +1,68 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
struct FAssetUsageAuditResult;
/**
* Writes the audit result as JSON and CSV.
*
* Both come from one in-memory result in a single call, so the two files can never disagree
* about the same run. JSON is the machine-readable form (nested level lists, filter metadata);
* CSV is the flat form a human opens in Excel.
*/
class ASSETUSAGEAUDITCORE_API FAssetUsageReportWriter
{
public:
struct FOptions
{
/** Absolute directory to write into. Created if missing. */
FString OutputDirectory;
/** Base filename without extension; ".json" and ".csv" are appended. */
FString BaseFileName = TEXT("AssetUsageReport");
bool bWriteJson = true;
bool bWriteCsv = true;
/**
* Write a UTF-8 BOM at the start of the CSV.
*
* Excel misreads UTF-8 without it and mangles every non-ASCII asset name, which on this
* project means the Cyrillic folder and asset names come out as garbage.
*/
bool bCsvUtf8Bom = true;
/** CSV field separator. Semicolon suits locales where the comma is a decimal separator. */
TCHAR CsvDelimiter = TEXT(';');
/** Separator for multi-valued fields inside one CSV cell, e.g. the level list. */
FString CsvMultiValueSeparator = TEXT("|");
};
struct FResult
{
bool bSuccess = false;
FString JsonPath;
FString CsvPath;
FString ErrorMessage;
};
static FResult Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
/** Serialize to a JSON string without touching disk. Exposed for tests. */
static FString BuildJson(const FAssetUsageAuditResult& AuditResult);
/** Serialize to a CSV string without touching disk. Exposed for tests. */
static FString BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
/**
* Quote and escape one CSV field per RFC 4180.
*
* Exposed because this is where CSV writers usually break: a field containing the delimiter,
* a quote or a newline must be quoted, and embedded quotes doubled.
*/
static FString EscapeCsvField(const FString& Field, TCHAR Delimiter);
};
@@ -0,0 +1,66 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/**
* One /Game path literal found in a text file, with enough context to check it by hand.
*/
struct FIndirectReference
{
/** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */
FName PackageName;
/** Absolute path of the file the literal was found in. */
FString SourceFile;
/** 1-based line number. */
int32 LineNumber = 0;
/** True when the hit came from Config/, false when from Source/. */
bool bFromConfig = false;
FString ToProvenanceString() const;
};
/**
* Finds asset paths referenced from text rather than from a package.
*
* This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini
* names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain
* strings. No asset references them, so the Asset Registry reports zero referencers and a naive
* audit calls the project's GameMode unused.
*
* It is a mitigation, not a solution. It cannot see a path assembled at runtime by concatenation,
* an OpenLevel(FName) call, or an FMOD event path - those stay Unknown, deliberately.
*/
class ASSETUSAGEAUDITCORE_API FIndirectReferenceScanner
{
public:
struct FOptions
{
/** Absolute directories to scan. Defaults to <Project>/Config and <Project>/Source. */
TArray<FString> Directories;
/** File extensions to read, lowercase, with the dot. */
TArray<FString> Extensions = { TEXT(".ini"), TEXT(".cpp"), TEXT(".h"), TEXT(".cs") };
/** Skip files larger than this; a multi-megabyte generated file is never a reference site. */
int64 MaxFileSizeBytes = 8 * 1024 * 1024;
};
/** Scan and return every distinct package path found, with provenance. */
static TArray<FIndirectReference> Scan(const FOptions& Options);
/** Default options: <Project>/Config and <Project>/Source. */
static FOptions MakeDefaultOptions();
/**
* Extract /Game path literals from one line of text.
*
* Exposed for testing: the trailing-_C strip and the delimiter set are where this goes wrong.
* "/Game/X/BP_Y.BP_Y_C" and "/Game/X/BP_Y" must both yield the package /Game/X/BP_Y.
*/
static void ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames);
};
@@ -0,0 +1,154 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "Containers/BitArray.h"
class FAssetUsageGraph;
class IAssetRegistry;
struct FLevelUsageResolveOptions
{
/** Levels to analyse. Empty means every /Script/Engine.World package in the graph. */
TArray<FName> LevelPackages;
/**
* Call ULevel::ScanLevelAssets before traversing each level.
*
* The external-actor dependency gatherer only reports packages the registry has already
* scanned. Skipping this can silently yield an empty external-actor set on a level nobody
* has opened this session - which looks exactly like a level that genuinely has no actors.
*/
bool bScanLevelAssetsFirst = true;
/**
* Additionally seed the BFS from ULevel::GetExternalActorsPaths / GetExternalObjectsPaths.
*
* Belt and braces: the gatherer normally puts these edges in the graph already. Keeping the
* explicit seed means a stale or partially-scanned registry degrades to a slower correct
* answer instead of a fast wrong one, and it lets us tag ExternalActor provenance precisely.
*/
bool bSeedExternalPackages = true;
/**
* Follow a reference into another map's contents.
*
* Off by default, and that default is load-bearing. A map does not only reference its own
* sublevels: anything it can reach may name an unrelated map, and on this project it does.
* WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole
* of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer
* was another level's content.
*
* With this off, a foreign map is still reported as referenced, but its contents are attributed
* to that map alone. Genuine sublevels and Level Instances are unaffected: they are reached
* from the level package itself or from one of its external actor packages, and that crossing
* is always allowed. See ShouldCrossIntoLevel in the .cpp.
*/
bool bTraverseIntoOtherLevels = false;
/** Record a human-readable route for each asset. Costs one int32 array per BFS. */
bool bRecordRoutes = true;
/** Route strings longer than this many hops are elided in the middle. */
int32 MaxRouteHops = 12;
/** Optional progress sink, called once per level with (LevelIndex, TotalLevels). */
TFunction<void(int32, int32)> OnLevelProgress;
/** Return true to abort the sweep between levels. */
TFunction<bool()> ShouldAbort;
};
struct FLevelUsageStats
{
int32 LevelsScanned = 0;
int32 AssetsReachable = 0;
int32 ExternalPackagesSeeded = 0;
int32 RedirectorsResolved = 0;
/**
* Foreign maps referenced but deliberately not expanded into.
*
* A non-zero value here is the amount of another level's content that would otherwise have
* been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report.
*/
int32 ForeignLevelsNotExpanded = 0;
double ScanLevelAssetsSeconds = 0.0;
double TraversalSeconds = 0.0;
double TotalSeconds() const
{
return ScanLevelAssetsSeconds + TraversalSeconds;
}
};
/**
* Which levels reach which assets.
*
* Reachability is one TBitArray per level over dense package indices. At this project's scale
* that is 1207 levels x 80608 bits, roughly 12 MB - cheap enough to hold both query directions
* ("assets on this level" and "levels using this asset") without ever re-walking the graph.
*/
struct ASSETUSAGEAUDITCORE_API FLevelUsageResult
{
/** Level package names, parallel to LevelReachability. */
TArray<FName> LevelPackageNames;
/** LevelReachability[L][A] - level L reaches asset A. */
TArray<TBitArray<>> LevelReachability;
/** Union across every level. The primary input to the UsedOnLevel verdict. */
TBitArray<> ReachableFromAnyLevel;
/** Accumulated provenance flags per asset index. */
TArray<EAssetUsageProvenance> Provenance;
/** Incoming edge counts per asset index, split by the Hard property. */
TArray<int32> HardReferenceCounts;
TArray<int32> SoftReferenceCounts;
/** Route string per asset index, from the first level that reached it. Empty when not recorded. */
TMap<int32, FString> Routes;
FLevelUsageStats Stats;
/** Levels that reach the given asset index. */
TArray<FName> GetLevelsForAsset(int32 AssetIndex) const;
bool IsReachableFromAnyLevel(int32 AssetIndex) const
{
return ReachableFromAnyLevel.IsValidIndex(AssetIndex) && ReachableFromAnyLevel[AssetIndex];
}
};
/**
* Forward traversal from level packages to everything they pull in.
*
* Direction matters. Going forward from the map is correct; going backward from an external actor
* package to its owning map is not, because PackageDependencyData.cpp:57-96 deliberately strips
* the UsedInGame flag off that reverse import by naming convention, so the AssetManager will not
* drag a whole map in when something references one actor. Reconstructing ownership from the path
* instead is possible but fragile - Content Bundles inject /CB/<Guid>/ and External Data Layers
* inject /EDL/<UID>/ between the folder and the level path. Forward traversal sidesteps all of it.
*/
class ASSETUSAGEAUDITCORE_API FLevelUsageResolver
{
public:
FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry);
FLevelUsageResult Resolve(const FLevelUsageResolveOptions& Options);
private:
/** BFS from one level. Marks OutReachable and accumulates provenance and counts. */
void TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray<int32>& ScratchPredecessor, TArray<int32>& ScratchQueue);
/** Dense indices of the __ExternalActors__ / __ExternalObjects__ packages owned by a level. */
void GatherExternalPackageSeeds(int32 LevelIndex, TArray<int32>& OutSeeds, FLevelUsageResult& InOutResult) const;
FString BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray<int32>& Predecessor, int32 MaxHops) const;
const FAssetUsageGraph& Graph;
IAssetRegistry& AssetRegistry;
};