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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user