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,32 @@
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class AssetUsageAuditCore : ModuleRules
|
||||
{
|
||||
public AssetUsageAuditCore(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
// INVARIANT: this module must stay free of UI and of the editor-only asset pipeline.
|
||||
// Do NOT add: UnrealEd, AssetTools, ToolMenus, Slate, SlateCore, EditorSubsystem.
|
||||
// AssetTools is editor-only transitively (it puts UnrealEd in PublicDependencyModuleNames),
|
||||
// so pulling it in here would silently make the whole analysis undrivable from a commandlet.
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"AssetRegistry"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
// Engine is a Runtime module and is available in commandlets, so it does not
|
||||
// compromise the headless goal. It is needed for ULevel::GetExternalActorsPaths and
|
||||
// ScanLevelAssets - the OFPA folder layout must never be string-built, because
|
||||
// Content Bundles inject /CB/<Guid>/, External Data Layers inject /EDL/<UID>/, and
|
||||
// plugins can register further roots through delegates.
|
||||
"Engine",
|
||||
"Json",
|
||||
"Projects"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetDependencyClosure.h"
|
||||
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "AssetUsagePaths.h"
|
||||
#include "AssetRegistry/AssetData.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "Engine/World.h"
|
||||
#include "Misc/PackageName.h"
|
||||
|
||||
namespace AssetDependencyClosure
|
||||
{
|
||||
FString FStats::Summarise() const
|
||||
{
|
||||
TArray<FString> Parts;
|
||||
|
||||
Parts.Add(FString::Printf(TEXT("%d seed(s) -> %d package(s)"), SeedCount, TotalCount));
|
||||
|
||||
if (SkippedScript > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d script"), SkippedScript));
|
||||
}
|
||||
|
||||
if (SkippedEngine > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d engine/temp"), SkippedEngine));
|
||||
}
|
||||
|
||||
if (SkippedExcluded > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d excluded by path"), SkippedExcluded));
|
||||
}
|
||||
|
||||
if (ForeignLevelsSkipped > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d other map(s) not followed"), ForeignLevelsSkipped));
|
||||
}
|
||||
|
||||
Parts.Add(FString::Printf(TEXT("deepest hop %d"), DeepestHop));
|
||||
|
||||
if (bStoppedEarly)
|
||||
{
|
||||
Parts.Add(TEXT("stopped early"));
|
||||
}
|
||||
|
||||
return FString::Join(Parts, TEXT(", "));
|
||||
}
|
||||
|
||||
TArray<FSeedClosure> GatherPerSeed(IAssetRegistry& Registry, const TArray<FName>& Seeds, const FOptions& Options, FStats& OutStats)
|
||||
{
|
||||
OutStats = FStats();
|
||||
|
||||
TArray<FSeedClosure> Result;
|
||||
Result.Reserve(Seeds.Num());
|
||||
|
||||
// Deduplicate seeds first: the same asset ticked twice would otherwise produce two identical
|
||||
// folders, and the second would be silently renamed by the collision policy.
|
||||
TSet<FName> SeenSeeds;
|
||||
SeenSeeds.Reserve(Seeds.Num());
|
||||
|
||||
for (int32 Index = 0; Index < Seeds.Num(); ++Index)
|
||||
{
|
||||
const FName Seed = Seeds[Index];
|
||||
|
||||
if (Seed.IsNone() || SeenSeeds.Contains(Seed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SeenSeeds.Add(Seed);
|
||||
|
||||
// Progress is reported per seed here, not per package: a caller showing a bar wants it
|
||||
// to advance once per ticked asset, and the inner traversals have wildly uneven sizes.
|
||||
if (Options.OnProgress && !Options.OnProgress(Index, Seeds.Num()))
|
||||
{
|
||||
OutStats.bStoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Each seed gets its own traversal and its own visited set. That is the whole point:
|
||||
// sharing one set would give the first seed every shared dependency and leave the rest
|
||||
// with folders that are missing their textures.
|
||||
FOptions SeedOptions = Options;
|
||||
SeedOptions.OnProgress = nullptr;
|
||||
|
||||
FStats SeedStats;
|
||||
FSeedClosure& Closure = Result.AddDefaulted_GetRef();
|
||||
Closure.Seed = Seed;
|
||||
Closure.Packages = Gather(Registry, { Seed }, SeedOptions, SeedStats);
|
||||
|
||||
OutStats.SkippedScript += SeedStats.SkippedScript;
|
||||
OutStats.SkippedEngine += SeedStats.SkippedEngine;
|
||||
OutStats.SkippedExcluded += SeedStats.SkippedExcluded;
|
||||
OutStats.ForeignLevelsSkipped += SeedStats.ForeignLevelsSkipped;
|
||||
OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, SeedStats.DeepestHop);
|
||||
OutStats.TotalCount += SeedStats.TotalCount;
|
||||
++OutStats.SeedCount;
|
||||
}
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log,
|
||||
TEXT("Per-seed closure: %d seed(s), %d package placement(s) counting duplicates."),
|
||||
OutStats.SeedCount, OutStats.TotalCount);
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
TArray<FName> Gather(IAssetRegistry& Registry, const TArray<FName>& Seeds, const FOptions& Options, FStats& OutStats)
|
||||
{
|
||||
OutStats = FStats();
|
||||
|
||||
TArray<FName> Result;
|
||||
TSet<FName> Visited;
|
||||
|
||||
// Hop count per queued entry, kept alongside the queue rather than in a map: the queue is
|
||||
// the only place an index is needed and a parallel array is one allocation instead of N.
|
||||
TArray<int32> Hops;
|
||||
|
||||
Result.Reserve(Seeds.Num() * 4);
|
||||
Visited.Reserve(Seeds.Num() * 4);
|
||||
|
||||
const auto ShouldKeep = [&Options, &OutStats](FName PackageName) -> bool
|
||||
{
|
||||
if (AssetUsagePaths::IsScriptPackage(PackageName))
|
||||
{
|
||||
++OutStats.SkippedScript;
|
||||
return false;
|
||||
}
|
||||
|
||||
const FString PackageStr = PackageName.ToString();
|
||||
|
||||
if (!Options.bIncludeEnginePackages && AssetUsagePaths::IsEngineOrTempPackage(PackageStr))
|
||||
{
|
||||
++OutStats.SkippedEngine;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths))
|
||||
{
|
||||
++OutStats.SkippedExcluded;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Which packages are maps, answered once each. The registry call allocates an FAssetData
|
||||
// array, and the same package is reached from many referencers, so the cache turns tens of
|
||||
// thousands of lookups into one per distinct package.
|
||||
TMap<FName, bool> LevelCache;
|
||||
|
||||
const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool
|
||||
{
|
||||
if (const bool* Cached = LevelCache.Find(PackageName))
|
||||
{
|
||||
return *Cached;
|
||||
}
|
||||
|
||||
TArray<FAssetData> Assets;
|
||||
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
|
||||
|
||||
bool bIsLevel = false;
|
||||
for (const FAssetData& AssetData : Assets)
|
||||
{
|
||||
if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
|
||||
{
|
||||
bIsLevel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LevelCache.Add(PackageName, bIsLevel);
|
||||
return bIsLevel;
|
||||
};
|
||||
|
||||
// Seeds are admitted without the path filters. Someone who ticked a row is entitled to the
|
||||
// file for that row even if it sits under an excluded folder; the filters bound what the
|
||||
// traversal drags in behind it, which is a different question.
|
||||
//
|
||||
// A seed that is itself a map is a level export: the user asked for that location, so its
|
||||
// contents are the point. Foreign maps found later are a different matter entirely.
|
||||
// Levels the closure has admitted, seeds first. A level may only be crossed into from
|
||||
// another level or from an external actor package, so this set has to grow as structural
|
||||
// crossings are accepted - otherwise a sublevel of a sublevel would be refused.
|
||||
TSet<FName> LevelsInResult;
|
||||
|
||||
for (FName Seed : Seeds)
|
||||
{
|
||||
if (Seed.IsNone() || Visited.Contains(Seed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!FPackageName::IsValidLongPackageName(Seed.ToString()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Visited.Add(Seed);
|
||||
Result.Add(Seed);
|
||||
Hops.Add(0);
|
||||
++OutStats.SeedCount;
|
||||
|
||||
if (IsLevelPackage(Seed))
|
||||
{
|
||||
LevelsInResult.Add(Seed);
|
||||
}
|
||||
}
|
||||
|
||||
TArray<FName> ScratchDependencies;
|
||||
|
||||
for (int32 Head = 0; Head < Result.Num(); ++Head)
|
||||
{
|
||||
if (Options.OnProgress && !Options.OnProgress(Head, Result.Num()))
|
||||
{
|
||||
OutStats.bStoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const int32 CurrentHop = Hops[Head];
|
||||
|
||||
if (Options.MaxDepth > 0 && CurrentHop >= Options.MaxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ScratchDependencies.Reset();
|
||||
|
||||
// Package category, no requirements. Never Hard: in EDependencyQuery Soft is defined as
|
||||
// NotHard, so Hard|Soft matches nothing, and a Hard-only query drops every soft edge -
|
||||
// including every TSoftObjectPtr a Blueprint resolves at runtime and every OFPA package.
|
||||
Registry.GetDependencies(
|
||||
Result[Head],
|
||||
ScratchDependencies,
|
||||
AssetUsageAudit::MakeTraversalCategory(),
|
||||
AssetUsageAudit::MakeTraversalQuery());
|
||||
|
||||
for (FName Dependency : ScratchDependencies)
|
||||
{
|
||||
if (Dependency.IsNone() || Visited.Contains(Dependency))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Marked visited even when dropped, so a package excluded by path is not re-tested
|
||||
// once per referencer. On a mesh library that is thousands of redundant string
|
||||
// comparisons.
|
||||
Visited.Add(Dependency);
|
||||
|
||||
// The map boundary. Reaching another World is only legitimate when the reference
|
||||
// comes from a level or from one of its external actor packages - the shape a
|
||||
// streaming sublevel or a Level Instance actually has. A World named by an ordinary
|
||||
// asset is a different location that happens to be mentioned, and following it
|
||||
// exports someone else's map: measured on WP_Main, the chain
|
||||
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994
|
||||
// packages. FLevelUsageResolver applies the same rule; the two must not disagree
|
||||
// about the same graph.
|
||||
if (IsLevelPackage(Dependency))
|
||||
{
|
||||
const FName Source = Result[Head];
|
||||
const bool bStructuralCrossing =
|
||||
LevelsInResult.Contains(Source) || AssetUsagePaths::IsExternalPackage(Source);
|
||||
|
||||
if (!bStructuralCrossing)
|
||||
{
|
||||
++OutStats.ForeignLevelsSkipped;
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Verbose,
|
||||
TEXT("Not following '%s': reached from '%s', which is not a level or an external actor package."),
|
||||
*Dependency.ToString(), *Source.ToString());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
LevelsInResult.Add(Dependency);
|
||||
}
|
||||
|
||||
if (!ShouldKeep(Dependency))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result.Add(Dependency);
|
||||
Hops.Add(CurrentHop + 1);
|
||||
|
||||
OutStats.DeepestHop = FMath::Max(OutStats.DeepestHop, CurrentHop + 1);
|
||||
}
|
||||
}
|
||||
|
||||
OutStats.TotalCount = Result.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Dependency closure: %s"), *OutStats.Summarise());
|
||||
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetExportLayout.h"
|
||||
|
||||
#include "AssetTypeFilter.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetRegistry/AssetData.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "Misc/PackageName.h"
|
||||
#include "Misc/PathViews.h"
|
||||
|
||||
namespace AssetExportLayout
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/** Folder for anything no preset covers. Never empty - see the header. */
|
||||
const TCHAR* OtherFolderName = TEXT("Other");
|
||||
|
||||
/**
|
||||
* Make a package name safe to use as a folder name.
|
||||
*
|
||||
* Asset names are already constrained by Unreal, but a folder name is not the same thing as
|
||||
* an asset name and the target may be any filesystem. FPaths::MakeValidFileName replaces
|
||||
* what Windows rejects; the empty check catches a name that was nothing but invalid
|
||||
* characters, which would otherwise produce a group writing into the export root.
|
||||
*/
|
||||
FString MakeFolderName(FName PackageName)
|
||||
{
|
||||
FString Name = FPackageName::GetShortName(PackageName.ToString());
|
||||
Name = FPaths::MakeValidFileName(Name, TEXT('_'));
|
||||
|
||||
return Name.IsEmpty() ? FString(TEXT("Asset")) : Name;
|
||||
}
|
||||
}
|
||||
|
||||
FString FStats::Summarise() const
|
||||
{
|
||||
TArray<FString> Parts;
|
||||
|
||||
Parts.Add(FString::Printf(TEXT("%d group(s)"), GroupCount));
|
||||
Parts.Add(FString::Printf(TEXT("%d distinct package(s)"), DistinctPackages));
|
||||
|
||||
// Always stated, even when equal to DistinctPackages: under FolderPerAsset the gap between
|
||||
// the two is the surprise, and a reader should not have to infer that they match.
|
||||
Parts.Add(FString::Printf(TEXT("%d file placement(s)"), FilePlacements));
|
||||
|
||||
if (RenamedFolders > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d folder name(s) made unique"), RenamedFolders));
|
||||
}
|
||||
|
||||
return FString::Join(Parts, TEXT(", "));
|
||||
}
|
||||
|
||||
TMap<FTopLevelAssetPath, FString> BuildTypeFolderMap(IAssetRegistry& Registry)
|
||||
{
|
||||
TMap<FTopLevelAssetPath, FString> FolderByClass;
|
||||
|
||||
for (EAssetTypePreset Preset : GetAllAssetTypePresets())
|
||||
{
|
||||
const TArray<FTopLevelAssetPath> Seeds = GetPresetClassPaths(Preset);
|
||||
if (Seeds.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const FString FolderName = LexToString(Preset);
|
||||
|
||||
TSet<FTopLevelAssetPath> Derived;
|
||||
Registry.GetDerivedClassNames(Seeds, TSet<FTopLevelAssetPath>(), Derived);
|
||||
|
||||
// Seeds added explicitly: GetDerivedClassNames returns strict descendants in some
|
||||
// builds and includes the seeds in others. The same reason FAssetTypeFilter::Compile
|
||||
// does it, and the same consequence if it is forgotten - Material would match every
|
||||
// material instance but not Material itself.
|
||||
for (const FTopLevelAssetPath& ClassPath : Seeds)
|
||||
{
|
||||
FolderByClass.FindOrAdd(ClassPath, FolderName);
|
||||
}
|
||||
|
||||
for (const FTopLevelAssetPath& ClassPath : Derived)
|
||||
{
|
||||
// FindOrAdd, not Add: presets overlap (a Niagara system is also a UObject subclass
|
||||
// reachable from more than one seed) and the first preset to claim a class wins.
|
||||
// Overwriting would make the folder depend on preset iteration order.
|
||||
FolderByClass.FindOrAdd(ClassPath, FolderName);
|
||||
}
|
||||
}
|
||||
|
||||
return FolderByClass;
|
||||
}
|
||||
|
||||
FString TypeFolderForPackage(IAssetRegistry& Registry, FName PackageName, const TMap<FTopLevelAssetPath, FString>& FolderByClass)
|
||||
{
|
||||
TArray<FAssetData> Assets;
|
||||
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
|
||||
|
||||
for (const FAssetData& AssetData : Assets)
|
||||
{
|
||||
if (const FString* Folder = FolderByClass.Find(AssetData.AssetClassPath))
|
||||
{
|
||||
return *Folder;
|
||||
}
|
||||
}
|
||||
|
||||
return OtherFolderName;
|
||||
}
|
||||
|
||||
TArray<FGroup> BuildGroups(IAssetRegistry& Registry, const TArray<AssetDependencyClosure::FSeedClosure>& SeedClosures, const FOptions& Options, FStats& OutStats)
|
||||
{
|
||||
OutStats = FStats();
|
||||
|
||||
TArray<FGroup> Groups;
|
||||
TSet<FName> DistinctPackages;
|
||||
|
||||
// Flat and MirrorTree do not care which seed pulled a package in, so they collapse to one
|
||||
// group and let the exporter's own bFlatten decide the path. FolderPerAsset is the only
|
||||
// layout that needs the per-seed structure, and it is the only one that pays for it.
|
||||
if (Options.Layout != EExportLayout::FolderPerAsset)
|
||||
{
|
||||
FGroup& Group = Groups.AddDefaulted_GetRef();
|
||||
|
||||
for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures)
|
||||
{
|
||||
for (FName Package : Closure.Packages)
|
||||
{
|
||||
if (!DistinctPackages.Contains(Package))
|
||||
{
|
||||
DistinctPackages.Add(Package);
|
||||
Group.Packages.Add(Package);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutStats.GroupCount = Groups.Num();
|
||||
OutStats.DistinctPackages = DistinctPackages.Num();
|
||||
OutStats.FilePlacements = Group.Packages.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise());
|
||||
|
||||
return Groups;
|
||||
}
|
||||
|
||||
// Folder names must be unique across the export, and asset names are not: SM_Rock can exist
|
||||
// in three different folders in /Game. Two seeds sharing a short name would otherwise write
|
||||
// into one folder and interleave their dependencies.
|
||||
TSet<FString> UsedFolderNames;
|
||||
|
||||
const TMap<FTopLevelAssetPath, FString> FolderByClass = Options.bGroupDependenciesByType
|
||||
? BuildTypeFolderMap(Registry)
|
||||
: TMap<FTopLevelAssetPath, FString>();
|
||||
|
||||
for (const AssetDependencyClosure::FSeedClosure& Closure : SeedClosures)
|
||||
{
|
||||
if (Closure.Packages.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FString FolderName = MakeFolderName(Closure.Seed);
|
||||
|
||||
if (UsedFolderNames.Contains(FolderName))
|
||||
{
|
||||
const FString Base = FolderName;
|
||||
int32 Suffix = 1;
|
||||
|
||||
while (UsedFolderNames.Contains(FolderName))
|
||||
{
|
||||
FolderName = FString::Printf(TEXT("%s_%d"), *Base, Suffix++);
|
||||
}
|
||||
|
||||
++OutStats.RenamedFolders;
|
||||
}
|
||||
|
||||
UsedFolderNames.Add(FolderName);
|
||||
|
||||
// The seed sits at the root of its own folder; dependencies go beside it, or into
|
||||
// per-type subfolders when asked. Grouping the seed too would bury the subject of the
|
||||
// folder among the things it needs.
|
||||
FGroup& SeedGroup = Groups.AddDefaulted_GetRef();
|
||||
SeedGroup.RelativeDir = FolderName;
|
||||
SeedGroup.Packages.Add(Closure.Seed);
|
||||
|
||||
DistinctPackages.Add(Closure.Seed);
|
||||
++OutStats.FilePlacements;
|
||||
|
||||
// One group per type subfolder, built lazily so a folder is only created for a type
|
||||
// that actually has files in it.
|
||||
TMap<FString, int32> GroupIndexByDir;
|
||||
|
||||
for (FName Package : Closure.Packages)
|
||||
{
|
||||
if (Package == Closure.Seed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DistinctPackages.Add(Package);
|
||||
++OutStats.FilePlacements;
|
||||
|
||||
FString RelativeDir = FolderName;
|
||||
|
||||
if (Options.bGroupDependenciesByType)
|
||||
{
|
||||
RelativeDir = FPaths::Combine(FolderName, TypeFolderForPackage(Registry, Package, FolderByClass));
|
||||
}
|
||||
|
||||
if (int32* ExistingIndex = GroupIndexByDir.Find(RelativeDir))
|
||||
{
|
||||
Groups[*ExistingIndex].Packages.Add(Package);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The dependency group for the seed's own folder is the one already created above;
|
||||
// reuse it rather than adding a second group with the same RelativeDir, which the
|
||||
// exporter would treat as a separate destination.
|
||||
if (RelativeDir == FolderName)
|
||||
{
|
||||
SeedGroup.Packages.Add(Package);
|
||||
GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
FGroup& TypeGroup = Groups.AddDefaulted_GetRef();
|
||||
TypeGroup.RelativeDir = RelativeDir;
|
||||
TypeGroup.Packages.Add(Package);
|
||||
|
||||
GroupIndexByDir.Add(RelativeDir, Groups.Num() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
OutStats.GroupCount = Groups.Num();
|
||||
OutStats.DistinctPackages = DistinctPackages.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export layout %s: %s"), LexToString(Options.Layout), *OutStats.Summarise());
|
||||
|
||||
return Groups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetExportManifest.h"
|
||||
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "Dom/JsonObject.h"
|
||||
#include "Misc/App.h"
|
||||
#include "Misc/EngineVersion.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
|
||||
namespace AssetExportManifest
|
||||
{
|
||||
FResult Write(IAssetRegistry& Registry, const TArray<FAssetUsageExporter::FWrittenFile>& WrittenFiles, const FOptions& Options)
|
||||
{
|
||||
FResult Result;
|
||||
|
||||
if (Options.TargetDirectory.IsEmpty())
|
||||
{
|
||||
Result.ErrorMessage = TEXT("No target directory was given.");
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: no target directory."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
if (WrittenFiles.IsEmpty())
|
||||
{
|
||||
// Deliberately an error. The usual cause is a caller that forgot bRecordWrittenFiles,
|
||||
// and a manifest listing nothing beside a folder full of assets would be taken at face
|
||||
// value by whatever reads it next.
|
||||
Result.ErrorMessage = TEXT("No written files were recorded; the exporter must run with bRecordWrittenFiles.");
|
||||
UE_LOG(LogAssetUsageAudit, Error,
|
||||
TEXT("Manifest not written: the exporter recorded no files. Set FOptions::bRecordWrittenFiles."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
if (Options.Layout == EExportLayout::Migrate)
|
||||
{
|
||||
Result.ErrorMessage = TEXT("Migrate preserves package paths itself; a manifest would be misleading.");
|
||||
UE_LOG(LogAssetUsageAudit, Warning,
|
||||
TEXT("Manifest not written: the Migrate layout keeps references intact, so there is nothing to restore."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
// Where each package ended up, so dependency entries can point at a file in this folder
|
||||
// rather than at a package path the reader has no way to resolve.
|
||||
TMap<FName, FString> PathByPackage;
|
||||
PathByPackage.Reserve(WrittenFiles.Num());
|
||||
|
||||
for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles)
|
||||
{
|
||||
// First writer wins. Under FolderPerAsset the same package is written into several
|
||||
// folders; the per-entry "file" field below is the authoritative one for each copy, and
|
||||
// this map only serves the "present in this export" test.
|
||||
if (!PathByPackage.Contains(Written.PackageName))
|
||||
{
|
||||
PathByPackage.Add(Written.PackageName, Written.RelativePath);
|
||||
}
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
|
||||
|
||||
Root->SetNumberField(TEXT("schemaVersion"), SchemaVersion);
|
||||
Root->SetStringField(TEXT("tool"), TEXT("AssetUsageAudit"));
|
||||
Root->SetStringField(TEXT("generatedAt"), FDateTime::UtcNow().ToIso8601());
|
||||
Root->SetStringField(TEXT("project"), FApp::GetProjectName());
|
||||
Root->SetStringField(TEXT("engineVersion"), FEngineVersion::Current().ToString());
|
||||
Root->SetStringField(TEXT("layout"), LexToString(Options.Layout));
|
||||
Root->SetStringField(TEXT("collisionPolicy"),
|
||||
Options.CollisionPolicy == EExportCollisionPolicy::Overwrite ? TEXT("Overwrite") : TEXT("Index"));
|
||||
|
||||
Root->SetStringField(TEXT("note"),
|
||||
TEXT("Paths are relative to this file's folder. 'package' is the original Unreal package name; ")
|
||||
TEXT("restoring an asset means putting its file back at that package path. 'dependencies' lists ")
|
||||
TEXT("the packages each asset referenced at export time, whether or not they are in this folder."));
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Entries;
|
||||
Entries.Reserve(WrittenFiles.Num());
|
||||
|
||||
TArray<FName> ScratchDependencies;
|
||||
|
||||
for (const FAssetUsageExporter::FWrittenFile& Written : WrittenFiles)
|
||||
{
|
||||
TSharedRef<FJsonObject> Entry = MakeShared<FJsonObject>();
|
||||
|
||||
Entry->SetStringField(TEXT("package"), Written.PackageName.ToString());
|
||||
Entry->SetStringField(TEXT("file"), Written.RelativePath);
|
||||
Entry->SetBoolField(TEXT("seed"), Options.SeedPackages.Contains(Written.PackageName));
|
||||
|
||||
// Asset name and class, when the registry knows the package. A package can hold several
|
||||
// assets; the first is representative and the class is only a hint for a reader.
|
||||
TArray<FAssetData> AssetsInPackage;
|
||||
Registry.GetAssetsByPackageName(Written.PackageName, AssetsInPackage, /*bIncludeOnlyOnDiskAssets=*/true);
|
||||
|
||||
if (!AssetsInPackage.IsEmpty())
|
||||
{
|
||||
Entry->SetStringField(TEXT("asset"), AssetsInPackage[0].AssetName.ToString());
|
||||
Entry->SetStringField(TEXT("class"), AssetsInPackage[0].AssetClassPath.ToString());
|
||||
}
|
||||
|
||||
// Direct dependencies only. The full closure is recoverable by walking these entries,
|
||||
// and storing it per asset would repeat most of the file for no extra information.
|
||||
ScratchDependencies.Reset();
|
||||
Registry.GetDependencies(
|
||||
Written.PackageName,
|
||||
ScratchDependencies,
|
||||
AssetUsageAudit::MakeTraversalCategory(),
|
||||
AssetUsageAudit::MakeTraversalQuery());
|
||||
|
||||
TArray<TSharedPtr<FJsonValue>> Dependencies;
|
||||
Dependencies.Reserve(ScratchDependencies.Num());
|
||||
|
||||
for (FName Dependency : ScratchDependencies)
|
||||
{
|
||||
if (Dependency.IsNone())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TSharedRef<FJsonObject> DependencyEntry = MakeShared<FJsonObject>();
|
||||
DependencyEntry->SetStringField(TEXT("package"), Dependency.ToString());
|
||||
|
||||
// Whether the dependency travelled with the export. A reader needs this to tell
|
||||
// "restore this link from a file here" from "this link points outside the folder".
|
||||
if (const FString* DependencyPath = PathByPackage.Find(Dependency))
|
||||
{
|
||||
DependencyEntry->SetStringField(TEXT("file"), *DependencyPath);
|
||||
}
|
||||
|
||||
Dependencies.Add(MakeShared<FJsonValueObject>(DependencyEntry));
|
||||
++Result.EdgesRecorded;
|
||||
}
|
||||
|
||||
Entry->SetArrayField(TEXT("dependencies"), Dependencies);
|
||||
Entries.Add(MakeShared<FJsonValueObject>(Entry));
|
||||
}
|
||||
|
||||
Root->SetArrayField(TEXT("assets"), Entries);
|
||||
|
||||
FString Output;
|
||||
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
|
||||
|
||||
if (!FJsonSerializer::Serialize(Root, Writer))
|
||||
{
|
||||
Result.ErrorMessage = TEXT("Could not serialise the manifest.");
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: serialisation failed."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
const FString FilePath = FPaths::Combine(Options.TargetDirectory, FileName);
|
||||
|
||||
if (!FFileHelper::SaveStringToFile(Output, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
||||
{
|
||||
Result.ErrorMessage = FString::Printf(TEXT("Could not write '%s'."), *FilePath);
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Manifest not written: could not save '%s'."), *FilePath);
|
||||
return Result;
|
||||
}
|
||||
|
||||
Result.bSuccess = true;
|
||||
Result.FilePath = FilePath;
|
||||
Result.EntriesWritten = WrittenFiles.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log,
|
||||
TEXT("Manifest written: %d entries, %d dependency edges, '%s'."),
|
||||
Result.EntriesWritten, Result.EdgesRecorded, *FilePath);
|
||||
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetExportNaming.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
|
||||
namespace AssetExportNaming
|
||||
{
|
||||
namespace
|
||||
{
|
||||
/** Guard against a pathological folder where every candidate is taken. */
|
||||
constexpr int32 MaxCollisionAttempts = 100000;
|
||||
|
||||
bool IsAllDigits(const FString& Value)
|
||||
{
|
||||
if (Value.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const TCHAR Char : Value)
|
||||
{
|
||||
if (!FChar::IsDigit(Char))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth)
|
||||
{
|
||||
OutStem = BaseName;
|
||||
OutIndex = INDEX_NONE;
|
||||
OutPadWidth = 0;
|
||||
|
||||
int32 SeparatorPos = INDEX_NONE;
|
||||
if (!BaseName.FindLastChar(TEXT('_'), SeparatorPos))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A leading underscore is part of the name, not a separator: "_1" has no stem.
|
||||
if (SeparatorPos <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const FString Suffix = BaseName.Mid(SeparatorPos + 1);
|
||||
|
||||
// "Foo_" has an empty suffix, "SM_Rock_02_v3" has a non-numeric one. Neither is an index.
|
||||
if (!IsAllDigits(Suffix))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OutStem = BaseName.Left(SeparatorPos);
|
||||
OutIndex = FCString::Atoi(*Suffix);
|
||||
OutPadWidth = Suffix.Len();
|
||||
}
|
||||
|
||||
FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth)
|
||||
{
|
||||
if (Index == INDEX_NONE)
|
||||
{
|
||||
return Stem;
|
||||
}
|
||||
|
||||
FString Digits = FString::FromInt(Index);
|
||||
|
||||
// Preserve the original zero padding, but never truncate: Foo_09 -> Foo_10, Foo_99 -> Foo_100.
|
||||
while (Digits.Len() < PadWidth)
|
||||
{
|
||||
Digits.InsertAt(0, TEXT('0'));
|
||||
}
|
||||
|
||||
return FString::Printf(TEXT("%s_%s"), *Stem, *Digits);
|
||||
}
|
||||
|
||||
FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists)
|
||||
{
|
||||
if (Policy == EExportCollisionPolicy::Overwrite)
|
||||
{
|
||||
return DesiredFileName;
|
||||
}
|
||||
|
||||
if (!Exists(DesiredFileName))
|
||||
{
|
||||
return DesiredFileName;
|
||||
}
|
||||
|
||||
// Split off the extension so the index lands before it: SM_Rock.uasset -> SM_Rock_1.uasset.
|
||||
// Note this splits on the LAST dot, so a name like "M_Glass.v2.uasset" keeps "M_Glass.v2" as the base.
|
||||
FString BaseName = DesiredFileName;
|
||||
FString Extension;
|
||||
|
||||
int32 DotPos = INDEX_NONE;
|
||||
if (DesiredFileName.FindLastChar(TEXT('.'), DotPos) && DotPos > 0)
|
||||
{
|
||||
BaseName = DesiredFileName.Left(DotPos);
|
||||
Extension = DesiredFileName.Mid(DotPos); // includes the dot
|
||||
}
|
||||
|
||||
FString Stem;
|
||||
int32 Index = INDEX_NONE;
|
||||
int32 PadWidth = 0;
|
||||
SplitTrailingIndex(BaseName, Stem, Index, PadWidth);
|
||||
|
||||
// No trailing index yet: start at _1. Otherwise increment what is already there,
|
||||
// rather than appending a second suffix and producing Foo_1_1.
|
||||
int32 NextIndex = (Index == INDEX_NONE) ? 1 : Index + 1;
|
||||
const int32 EffectivePadWidth = (Index == INDEX_NONE) ? 1 : PadWidth;
|
||||
|
||||
for (int32 Attempt = 0; Attempt < MaxCollisionAttempts; ++Attempt, ++NextIndex)
|
||||
{
|
||||
const FString Candidate = ComposeIndexedName(Stem, NextIndex, EffectivePadWidth) + Extension;
|
||||
if (!Exists(Candidate))
|
||||
{
|
||||
return Candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Every candidate taken. Returning the desired name would silently overwrite, which is the
|
||||
// one outcome the Index policy exists to prevent - so fail loudly instead.
|
||||
UE_LOG(LogAssetUsageAudit, Error,
|
||||
TEXT("Could not find a free filename for '%s' after %d attempts. Export of this asset will be skipped."),
|
||||
*DesiredFileName, MaxCollisionAttempts);
|
||||
|
||||
return FString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetTypeFilter.h"
|
||||
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsageGraph.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "UObject/Class.h"
|
||||
#include "UObject/UObjectGlobals.h"
|
||||
|
||||
const TCHAR* LexToString(EAssetTypePreset Preset)
|
||||
{
|
||||
switch (Preset)
|
||||
{
|
||||
case EAssetTypePreset::StaticMesh: return TEXT("StaticMesh");
|
||||
case EAssetTypePreset::SkeletalMesh: return TEXT("SkeletalMesh");
|
||||
case EAssetTypePreset::Material: return TEXT("Material");
|
||||
case EAssetTypePreset::Texture: return TEXT("Texture");
|
||||
case EAssetTypePreset::VFX: return TEXT("VFX");
|
||||
case EAssetTypePreset::Sound: return TEXT("Sound");
|
||||
case EAssetTypePreset::Blueprint: return TEXT("Blueprint");
|
||||
case EAssetTypePreset::Level: return TEXT("Level");
|
||||
case EAssetTypePreset::DataAsset: return TEXT("DataAsset");
|
||||
case EAssetTypePreset::Animation: return TEXT("Animation");
|
||||
}
|
||||
|
||||
return TEXT("Unknown");
|
||||
}
|
||||
|
||||
TArray<EAssetTypePreset> GetAllAssetTypePresets()
|
||||
{
|
||||
return {
|
||||
EAssetTypePreset::StaticMesh,
|
||||
EAssetTypePreset::SkeletalMesh,
|
||||
EAssetTypePreset::Material,
|
||||
EAssetTypePreset::Texture,
|
||||
EAssetTypePreset::VFX,
|
||||
EAssetTypePreset::Sound,
|
||||
EAssetTypePreset::Blueprint,
|
||||
EAssetTypePreset::Level,
|
||||
EAssetTypePreset::DataAsset,
|
||||
EAssetTypePreset::Animation
|
||||
};
|
||||
}
|
||||
|
||||
TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset)
|
||||
{
|
||||
const auto Path = [](const TCHAR* InPath)
|
||||
{
|
||||
return FTopLevelAssetPath(InPath);
|
||||
};
|
||||
|
||||
switch (Preset)
|
||||
{
|
||||
case EAssetTypePreset::StaticMesh:
|
||||
return { Path(TEXT("/Script/Engine.StaticMesh")) };
|
||||
|
||||
case EAssetTypePreset::SkeletalMesh:
|
||||
return { Path(TEXT("/Script/Engine.SkeletalMesh")) };
|
||||
|
||||
case EAssetTypePreset::Material:
|
||||
// MaterialInterface covers Material, MaterialInstanceConstant and MaterialInstanceDynamic;
|
||||
// material functions are a separate hierarchy and must be named explicitly.
|
||||
return {
|
||||
Path(TEXT("/Script/Engine.MaterialInterface")),
|
||||
Path(TEXT("/Script/Engine.MaterialFunctionInterface"))
|
||||
};
|
||||
|
||||
case EAssetTypePreset::Texture:
|
||||
return { Path(TEXT("/Script/Engine.Texture")) };
|
||||
|
||||
case EAssetTypePreset::VFX:
|
||||
// Niagara classes live in the Niagara module. They resolve only when the plugin is
|
||||
// enabled; unresolved entries are reported rather than silently dropped.
|
||||
return {
|
||||
Path(TEXT("/Script/Niagara.NiagaraSystem")),
|
||||
Path(TEXT("/Script/Niagara.NiagaraEmitter")),
|
||||
Path(TEXT("/Script/Engine.ParticleSystem"))
|
||||
};
|
||||
|
||||
case EAssetTypePreset::Sound:
|
||||
// FMOD is this project's audio system. FMOD assets are included for completeness, but
|
||||
// FMOD resolves events by string path outside the UObject graph, so their usage verdict
|
||||
// is unreliable by construction - the report marks them Unknown.
|
||||
return {
|
||||
Path(TEXT("/Script/Engine.SoundBase")),
|
||||
Path(TEXT("/Script/FMODStudio.FMODAsset"))
|
||||
};
|
||||
|
||||
case EAssetTypePreset::Blueprint:
|
||||
return { Path(TEXT("/Script/Engine.Blueprint")) };
|
||||
|
||||
case EAssetTypePreset::Level:
|
||||
return { Path(TEXT("/Script/Engine.World")) };
|
||||
|
||||
case EAssetTypePreset::DataAsset:
|
||||
return {
|
||||
Path(TEXT("/Script/Engine.DataAsset")),
|
||||
Path(TEXT("/Script/Engine.DataTable"))
|
||||
};
|
||||
|
||||
case EAssetTypePreset::Animation:
|
||||
return {
|
||||
Path(TEXT("/Script/Engine.AnimationAsset")),
|
||||
Path(TEXT("/Script/Engine.AnimBlueprint"))
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
void FAssetTypeFilter::AddPreset(EAssetTypePreset Preset)
|
||||
{
|
||||
for (const FTopLevelAssetPath& ClassPath : GetPresetClassPaths(Preset))
|
||||
{
|
||||
RequestedClasses.AddUnique(ClassPath);
|
||||
}
|
||||
|
||||
RequestedDescriptions.AddUnique(LexToString(Preset));
|
||||
bCompiled = false;
|
||||
}
|
||||
|
||||
void FAssetTypeFilter::AddRawClass(const FString& ClassPathOrName)
|
||||
{
|
||||
const FString Trimmed = ClassPathOrName.TrimStartAndEnd();
|
||||
if (Trimmed.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FTopLevelAssetPath Resolved;
|
||||
|
||||
if (Trimmed.StartsWith(TEXT("/")))
|
||||
{
|
||||
Resolved.TrySetPath(Trimmed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// A short name like "StaticMesh". TryFindType walks loaded classes, which is the only way
|
||||
// to accept the shorthand the UI offers without making the user type /Script/Module.Class.
|
||||
if (const UClass* Found = UClass::TryFindTypeSlow<UClass>(Trimmed, EFindFirstObjectOptions::ExactClass))
|
||||
{
|
||||
Resolved = Found->GetClassPathName();
|
||||
}
|
||||
}
|
||||
|
||||
if (Resolved.IsNull())
|
||||
{
|
||||
UnresolvedClasses.AddUnique(Trimmed);
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Class filter '%s' did not resolve to a class; ignoring it."), *Trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
RequestedClasses.AddUnique(Resolved);
|
||||
RequestedDescriptions.AddUnique(Trimmed);
|
||||
bCompiled = false;
|
||||
}
|
||||
|
||||
void FAssetTypeFilter::Compile(IAssetRegistry& AssetRegistry)
|
||||
{
|
||||
ExpandedClasses.Reset();
|
||||
|
||||
if (RequestedClasses.IsEmpty())
|
||||
{
|
||||
bCompiled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
TSet<FTopLevelAssetPath> Derived;
|
||||
AssetRegistry.GetDerivedClassNames(RequestedClasses, TSet<FTopLevelAssetPath>(), Derived);
|
||||
|
||||
// GetDerivedClassNames returns strict descendants in some builds and includes the seeds in
|
||||
// others. Adding the seeds explicitly makes the result the same either way.
|
||||
for (const FTopLevelAssetPath& ClassPath : RequestedClasses)
|
||||
{
|
||||
ExpandedClasses.Add(ClassPath);
|
||||
}
|
||||
|
||||
ExpandedClasses.Append(Derived);
|
||||
|
||||
bCompiled = true;
|
||||
}
|
||||
|
||||
bool FAssetTypeFilter::Matches(const FAssetUsageNode& Node) const
|
||||
{
|
||||
if (RequestedClasses.IsEmpty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureMsgf(bCompiled, TEXT("FAssetTypeFilter::Matches called before Compile; subclasses will be missed."));
|
||||
|
||||
if (!Node.ClassPath.IsNull() && ExpandedClasses.Contains(Node.ClassPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// The Blueprint case. Asking "is this a StaticMeshActor" of a BP asset only works through the
|
||||
// generated class, because the asset's own class is /Script/Engine.Blueprint.
|
||||
if (!Node.GeneratedClassPath.IsNull() && ExpandedClasses.Contains(Node.GeneratedClassPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
FString FAssetTypeFilter::Describe() const
|
||||
{
|
||||
if (RequestedDescriptions.IsEmpty())
|
||||
{
|
||||
return TEXT("All types");
|
||||
}
|
||||
|
||||
return FString::Join(RequestedDescriptions, TEXT(", "));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
|
||||
DEFINE_LOG_CATEGORY(LogAssetUsageAudit);
|
||||
|
||||
#define LOCTEXT_NAMESPACE "FAssetUsageAuditCoreModule"
|
||||
|
||||
void FAssetUsageAuditCoreModule::StartupModule()
|
||||
{
|
||||
}
|
||||
|
||||
void FAssetUsageAuditCoreModule::ShutdownModule()
|
||||
{
|
||||
}
|
||||
|
||||
#undef LOCTEXT_NAMESPACE
|
||||
|
||||
IMPLEMENT_MODULE(FAssetUsageAuditCoreModule, AssetUsageAuditCore)
|
||||
@@ -0,0 +1,77 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
|
||||
const TCHAR* LexToString(EAssetUsageVerdict Verdict)
|
||||
{
|
||||
switch (Verdict)
|
||||
{
|
||||
case EAssetUsageVerdict::UsedOnLevel:
|
||||
return TEXT("UsedOnLevel");
|
||||
case EAssetUsageVerdict::UsedByAssetsOnly:
|
||||
return TEXT("UsedByAssetsOnly");
|
||||
case EAssetUsageVerdict::ReferencedFromConfigOrSource:
|
||||
return TEXT("ReferencedFromConfigOrSource");
|
||||
case EAssetUsageVerdict::Unreferenced:
|
||||
return TEXT("Unreferenced");
|
||||
case EAssetUsageVerdict::Unknown:
|
||||
return TEXT("Unknown");
|
||||
}
|
||||
|
||||
return TEXT("Unknown");
|
||||
}
|
||||
|
||||
TArray<EAssetUsageVerdict> GetAllAssetUsageVerdicts()
|
||||
{
|
||||
// Most actionable first: this order is what the filter menu shows, and "used on a level" is the
|
||||
// question people open the tool with.
|
||||
return {
|
||||
EAssetUsageVerdict::UsedOnLevel,
|
||||
EAssetUsageVerdict::UsedByAssetsOnly,
|
||||
EAssetUsageVerdict::ReferencedFromConfigOrSource,
|
||||
EAssetUsageVerdict::Unreferenced,
|
||||
EAssetUsageVerdict::Unknown
|
||||
};
|
||||
}
|
||||
|
||||
const TCHAR* LexToString(EExportLayout Layout)
|
||||
{
|
||||
switch (Layout)
|
||||
{
|
||||
case EExportLayout::Flat: return TEXT("Flat");
|
||||
case EExportLayout::MirrorTree: return TEXT("MirrorTree");
|
||||
case EExportLayout::FolderPerAsset: return TEXT("FolderPerAsset");
|
||||
case EExportLayout::Migrate: return TEXT("Migrate");
|
||||
}
|
||||
|
||||
return TEXT("Flat");
|
||||
}
|
||||
|
||||
FString ProvenanceToString(EAssetUsageProvenance Provenance)
|
||||
{
|
||||
if (Provenance == EAssetUsageProvenance::None)
|
||||
{
|
||||
return TEXT("None");
|
||||
}
|
||||
|
||||
TArray<FString> Parts;
|
||||
|
||||
auto Append = [&Parts, Provenance](EAssetUsageProvenance Flag, const TCHAR* Label)
|
||||
{
|
||||
if (EnumHasAnyFlags(Provenance, Flag))
|
||||
{
|
||||
Parts.Add(Label);
|
||||
}
|
||||
};
|
||||
|
||||
Append(EAssetUsageProvenance::HardReference, TEXT("Hard"));
|
||||
Append(EAssetUsageProvenance::SoftReference, TEXT("Soft"));
|
||||
Append(EAssetUsageProvenance::ExternalActor, TEXT("ExternalActor"));
|
||||
Append(EAssetUsageProvenance::Sublevel, TEXT("Sublevel"));
|
||||
Append(EAssetUsageProvenance::ConfigFile, TEXT("Config"));
|
||||
Append(EAssetUsageProvenance::SourceFile, TEXT("Source"));
|
||||
Append(EAssetUsageProvenance::EditorOnly, TEXT("EditorOnly"));
|
||||
Append(EAssetUsageProvenance::Redirector, TEXT("Redirector"));
|
||||
|
||||
return FString::Join(Parts, TEXT("|"));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsageAuditor.h"
|
||||
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsagePaths.h"
|
||||
#include "IndirectReferenceScanner.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "HAL/PlatformTime.h"
|
||||
#include "Misc/App.h"
|
||||
#include "Misc/EngineVersion.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Package roots whose assets the registry cannot meaningfully track. */
|
||||
bool IsBlindSpotPackage(FName PackageName)
|
||||
{
|
||||
TStringBuilder<256> Builder;
|
||||
PackageName.ToString(Builder);
|
||||
const FStringView Path = Builder.ToView();
|
||||
|
||||
// FMOD banks and events are addressed by string path through the FMOD Studio runtime.
|
||||
// Nothing in the UObject graph references them, so "no referencers" means nothing here.
|
||||
return UE::String::FindFirst(Path, TEXTVIEW("/FMOD/"), ESearchCase::IgnoreCase) != INDEX_NONE;
|
||||
}
|
||||
|
||||
bool IsBlindSpotClass(const FTopLevelAssetPath& ClassPath)
|
||||
{
|
||||
if (ClassPath.IsNull())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Anything from the FMOD module: events and banks are addressed by string, never referenced.
|
||||
if (ClassPath.GetPackageName().ToString().StartsWith(TEXT("/Script/FMODStudio"), ESearchCase::IgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Data tables and curve tables are a subtler blind spot, and it cuts both ways. A consumer
|
||||
// depends on the whole table, so every row inside it looks used even when nothing reads it;
|
||||
// and rows holding paths as plain strings rather than TSoftObjectPtr produce no edge at all.
|
||||
// Neither "used" nor "unused" is defensible for the table's contents.
|
||||
static const TSet<FTopLevelAssetPath> BlindSpotClasses = {
|
||||
FTopLevelAssetPath(TEXT("/Script/Engine.DataTable")),
|
||||
FTopLevelAssetPath(TEXT("/Script/Engine.CurveTable"))
|
||||
};
|
||||
|
||||
return BlindSpotClasses.Contains(ClassPath);
|
||||
}
|
||||
}
|
||||
|
||||
int32 FAssetUsageAuditResult::CountByVerdict(EAssetUsageVerdict Verdict) const
|
||||
{
|
||||
int32 Count = 0;
|
||||
for (const FAssetUsageRow& Row : Rows)
|
||||
{
|
||||
if (Row.Verdict == Verdict)
|
||||
{
|
||||
++Count;
|
||||
}
|
||||
}
|
||||
return Count;
|
||||
}
|
||||
|
||||
bool FAssetUsageAuditor::IsRegistryBlindSpot(const FAssetUsageNode& Node)
|
||||
{
|
||||
return IsBlindSpotPackage(Node.PackageName) || IsBlindSpotClass(Node.ClassPath);
|
||||
}
|
||||
|
||||
EAssetUsageVerdict FAssetUsageAuditor::ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot)
|
||||
{
|
||||
// Order matters, and it is chosen so the tool never overstates.
|
||||
//
|
||||
// Positive evidence wins over blind-spot status: if a level demonstrably reaches an FMOD bank,
|
||||
// saying "Unknown" would throw away a fact we actually have.
|
||||
if (bReachableFromLevel)
|
||||
{
|
||||
return EAssetUsageVerdict::UsedOnLevel;
|
||||
}
|
||||
|
||||
if (bFoundInConfigOrSource)
|
||||
{
|
||||
return EAssetUsageVerdict::ReferencedFromConfigOrSource;
|
||||
}
|
||||
|
||||
if (bHasReferencers)
|
||||
{
|
||||
return EAssetUsageVerdict::UsedByAssetsOnly;
|
||||
}
|
||||
|
||||
// Absence of evidence only becomes Unreferenced when absence is actually informative.
|
||||
// For a blind spot it is not, so the honest answer is Unknown.
|
||||
if (bIsBlindSpot)
|
||||
{
|
||||
return EAssetUsageVerdict::Unknown;
|
||||
}
|
||||
|
||||
return EAssetUsageVerdict::Unreferenced;
|
||||
}
|
||||
|
||||
FAssetUsageAuditResult FAssetUsageAuditor::Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request)
|
||||
{
|
||||
FAssetUsageAuditResult Result;
|
||||
|
||||
const double StartTime = FPlatformTime::Seconds();
|
||||
|
||||
// --- 1. Graph -----------------------------------------------------------------------------
|
||||
FAssetUsageGraphBuildOptions GraphOptions;
|
||||
GraphOptions.IncludePackagePaths = Request.IncludePackagePaths;
|
||||
GraphOptions.ExcludePackagePaths = Request.ExcludePackagePaths;
|
||||
GraphOptions.bGatherDependencies = true;
|
||||
|
||||
FAssetUsageGraph Graph;
|
||||
Graph.Build(AssetRegistry, GraphOptions);
|
||||
|
||||
Result.GraphStats = Graph.GetStats();
|
||||
|
||||
if (Graph.Num() == 0)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Asset graph is empty. Is the Asset Registry still scanning?"));
|
||||
return Result;
|
||||
}
|
||||
|
||||
// --- 2. Level reachability ----------------------------------------------------------------
|
||||
FLevelUsageResolveOptions ResolveOptions = Request.ResolveOptions;
|
||||
ResolveOptions.LevelPackages = Request.LevelPackages;
|
||||
|
||||
FLevelUsageResolver Resolver(Graph, AssetRegistry);
|
||||
const FLevelUsageResult LevelResult = Resolver.Resolve(ResolveOptions);
|
||||
|
||||
Result.LevelStats = LevelResult.Stats;
|
||||
|
||||
// --- 3. Indirect references ---------------------------------------------------------------
|
||||
TMap<FName, FString> IndirectProvenance;
|
||||
if (Request.bScanIndirectReferences)
|
||||
{
|
||||
for (const FIndirectReference& Reference : FIndirectReferenceScanner::Scan(FIndirectReferenceScanner::MakeDefaultOptions()))
|
||||
{
|
||||
FString& Existing = IndirectProvenance.FindOrAdd(Reference.PackageName);
|
||||
if (Existing.IsEmpty())
|
||||
{
|
||||
Existing = Reference.ToProvenanceString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Rows ------------------------------------------------------------------------------
|
||||
Request.TypeFilter.Compile(AssetRegistry);
|
||||
|
||||
// Levels-per-asset the cheap way: invert once, rather than calling GetLevelsForAsset per row,
|
||||
// which would rescan every level bitset for every asset.
|
||||
TArray<TArray<FName>> LevelsPerAsset;
|
||||
LevelsPerAsset.SetNum(Graph.Num());
|
||||
|
||||
for (int32 LevelOrdinal = 0; LevelOrdinal < LevelResult.LevelReachability.Num(); ++LevelOrdinal)
|
||||
{
|
||||
const TBitArray<>& Bits = LevelResult.LevelReachability[LevelOrdinal];
|
||||
const FName LevelName = LevelResult.LevelPackageNames[LevelOrdinal];
|
||||
|
||||
for (TConstSetBitIterator<> It(Bits); It; ++It)
|
||||
{
|
||||
LevelsPerAsset[It.GetIndex()].Add(LevelName);
|
||||
}
|
||||
}
|
||||
|
||||
Result.Rows.Reserve(Graph.Num() / 2);
|
||||
|
||||
for (int32 Index = 0; Index < Graph.Num(); ++Index)
|
||||
{
|
||||
const FAssetUsageNode& Node = Graph.GetNode(Index);
|
||||
|
||||
if (Request.bHideExternalPackages && Node.bIsExternalPackage)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Request.bHideRedirectors && Node.bIsRedirector)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only project content can be exported or acted on; engine and plugin packages are noise.
|
||||
const FString RelativePath = AssetUsagePaths::ToProjectRelativePath(Node.PackageName, Node.bIsLevel);
|
||||
if (RelativePath.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Request.TypeFilter.Matches(Node))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool bReachable = LevelResult.IsReachableFromAnyLevel(Index);
|
||||
const bool bHasReferencers = Graph.GetReferencers(Index).Num() > 0;
|
||||
const FString* IndirectDetail = IndirectProvenance.Find(Node.PackageName);
|
||||
const bool bIsBlindSpot = IsRegistryBlindSpot(Node);
|
||||
|
||||
const EAssetUsageVerdict Verdict = ClassifyVerdict(bReachable, bHasReferencers, IndirectDetail != nullptr, bIsBlindSpot);
|
||||
|
||||
if (Request.bOnlyUnusedAssets && Verdict == EAssetUsageVerdict::UsedOnLevel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FAssetUsageRow& Row = Result.Rows.AddDefaulted_GetRef();
|
||||
Row.PackageName = Node.PackageName;
|
||||
Row.AssetName = Node.AssetName;
|
||||
Row.ClassPath = Node.ClassPath;
|
||||
Row.GeneratedClassPath = Node.GeneratedClassPath;
|
||||
Row.PathFromProjectRoot = RelativePath;
|
||||
Row.Verdict = Verdict;
|
||||
Row.Levels = MoveTemp(LevelsPerAsset[Index]);
|
||||
|
||||
if (LevelResult.Provenance.IsValidIndex(Index))
|
||||
{
|
||||
Row.Provenance = LevelResult.Provenance[Index];
|
||||
}
|
||||
|
||||
if (LevelResult.HardReferenceCounts.IsValidIndex(Index))
|
||||
{
|
||||
Row.HardReferenceCount = LevelResult.HardReferenceCounts[Index];
|
||||
Row.SoftReferenceCount = LevelResult.SoftReferenceCounts[Index];
|
||||
}
|
||||
|
||||
if (const FString* Route = LevelResult.Routes.Find(Index))
|
||||
{
|
||||
Row.Route = *Route;
|
||||
}
|
||||
|
||||
if (IndirectDetail)
|
||||
{
|
||||
Row.ProvenanceDetail = *IndirectDetail;
|
||||
Row.Provenance |= EAssetUsageProvenance::ConfigFile;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 5. Header ----------------------------------------------------------------------------
|
||||
Result.Header.GeneratedAt = FDateTime::Now();
|
||||
Result.Header.EngineVersion = FEngineVersion::Current().ToString();
|
||||
Result.Header.ToolVersion = TEXT("0.1");
|
||||
Result.Header.ProjectName = FApp::GetProjectName();
|
||||
Result.Header.LevelsScanned = LevelResult.Stats.LevelsScanned;
|
||||
Result.Header.AssetsScanned = Graph.Num();
|
||||
Result.Header.ScanDurationSeconds = FPlatformTime::Seconds() - StartTime;
|
||||
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Types: %s"), *Request.TypeFilter.Describe()));
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Include paths: %s"),
|
||||
Request.IncludePackagePaths.IsEmpty() ? TEXT("/Game") : *FString::Join(Request.IncludePackagePaths, TEXT(", "))));
|
||||
|
||||
if (!Request.ExcludePackagePaths.IsEmpty())
|
||||
{
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Excluded paths: %s"), *FString::Join(Request.ExcludePackagePaths, TEXT(", "))));
|
||||
}
|
||||
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Levels: %s"),
|
||||
Request.LevelPackages.IsEmpty() ? TEXT("all") : *FString::Printf(TEXT("%d selected"), Request.LevelPackages.Num())));
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Indirect scan: %s"), Request.bScanIndirectReferences ? TEXT("on") : TEXT("off")));
|
||||
Result.Header.AppliedFilters.Add(FString::Printf(TEXT("Only unused: %s"), Request.bOnlyUnusedAssets ? TEXT("yes") : TEXT("no")));
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log,
|
||||
TEXT("Audit complete in %.2fs: %d rows (%d on level, %d assets-only, %d config/source, %d unreferenced, %d unknown) across %d levels."),
|
||||
Result.Header.ScanDurationSeconds,
|
||||
Result.Rows.Num(),
|
||||
Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
|
||||
Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
|
||||
Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
|
||||
Result.CountByVerdict(EAssetUsageVerdict::Unreferenced),
|
||||
Result.CountByVerdict(EAssetUsageVerdict::Unknown),
|
||||
Result.Header.LevelsScanned);
|
||||
|
||||
return Result;
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsageExporter.h"
|
||||
|
||||
#include "AssetExportNaming.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsagePaths.h"
|
||||
#include "AssetExportTask.h"
|
||||
#include "Engine/Level.h"
|
||||
#include "Exporters/Exporter.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "HAL/PlatformFileManager.h"
|
||||
#include "Misc/PackageName.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "UObject/Class.h"
|
||||
#include "UObject/StrongObjectPtr.h"
|
||||
#include "UObject/UObjectGlobals.h"
|
||||
|
||||
FString FAssetUsageExporter::FResult::Summarise() const
|
||||
{
|
||||
TArray<FString> Parts;
|
||||
|
||||
Parts.Add(FString::Printf(TEXT("%d copied"), FilesCopied));
|
||||
|
||||
if (FilesRenamed > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
|
||||
}
|
||||
|
||||
if (FilesOverwritten > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d overwritten"), FilesOverwritten));
|
||||
}
|
||||
|
||||
if (ExternalPackagesCopied > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d external actor packages"), ExternalPackagesCopied));
|
||||
}
|
||||
|
||||
if (FilesMissingOnDisk > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d had no file on disk"), FilesMissingOnDisk));
|
||||
}
|
||||
|
||||
if (Errors.Num() > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
|
||||
}
|
||||
|
||||
if (bCancelled)
|
||||
{
|
||||
Parts.Add(TEXT("cancelled before finishing"));
|
||||
}
|
||||
|
||||
return FString::Join(Parts, TEXT(", "));
|
||||
}
|
||||
|
||||
FString FAssetUsageExporter::FExchangeResult::Summarise() const
|
||||
{
|
||||
TArray<FString> Parts;
|
||||
|
||||
Parts.Add(FString::Printf(TEXT("%d written"), FilesWritten));
|
||||
|
||||
if (FilesRenamed > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d renamed to avoid a collision"), FilesRenamed));
|
||||
}
|
||||
|
||||
// Each skip reason is reported separately on purpose. "17 skipped" tells a user nothing, while
|
||||
// "17 have no configured format" points straight at the settings they need to change.
|
||||
if (SkippedNoFormat > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d had no configured format"), SkippedNoFormat));
|
||||
}
|
||||
|
||||
if (SkippedNoExporter > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d had no exporter for that format"), SkippedNoExporter));
|
||||
}
|
||||
|
||||
if (SkippedNotLoaded > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d could not be loaded"), SkippedNotLoaded));
|
||||
}
|
||||
|
||||
if (Errors.Num() > 0)
|
||||
{
|
||||
Parts.Add(FString::Printf(TEXT("%d failed"), Errors.Num()));
|
||||
}
|
||||
|
||||
if (bCancelled)
|
||||
{
|
||||
Parts.Add(TEXT("cancelled before finishing"));
|
||||
}
|
||||
|
||||
return FString::Join(Parts, TEXT(", "));
|
||||
}
|
||||
|
||||
TMap<FString, FString> FAssetUsageExporter::GetDefaultFormatByClass()
|
||||
{
|
||||
// Only classes the engine actually ships a UExporter for. A plausible-looking entry with no
|
||||
// exporter behind it turns "nothing came out" into a mystery, so anything unverified is left
|
||||
// out and reported as SkippedNoFormat instead.
|
||||
//
|
||||
// Keys are class short names and lookup walks up the hierarchy, so MaterialInterface covers
|
||||
// material instances and Texture covers every 2D texture subclass.
|
||||
return {
|
||||
{ TEXT("StaticMesh"), TEXT("fbx") },
|
||||
{ TEXT("SkeletalMesh"), TEXT("fbx") },
|
||||
{ TEXT("AnimSequence"), TEXT("fbx") },
|
||||
{ TEXT("Texture2D"), TEXT("png") },
|
||||
{ TEXT("TextureCube"), TEXT("hdr") },
|
||||
{ TEXT("SoundWave"), TEXT("wav") },
|
||||
{ TEXT("DataTable"), TEXT("csv") },
|
||||
{ TEXT("CurveTable"), TEXT("csv") },
|
||||
{ TEXT("FontFace"), TEXT("ttf") },
|
||||
{ TEXT("World"), TEXT("fbx") }
|
||||
};
|
||||
}
|
||||
|
||||
FString FAssetUsageExporter::FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass)
|
||||
{
|
||||
if (!ClassPath.IsValid())
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
const FString ShortName = ClassPath.GetAssetName().ToString();
|
||||
|
||||
if (const FString* Direct = FormatByClass.Find(ShortName))
|
||||
{
|
||||
return *Direct;
|
||||
}
|
||||
|
||||
// Walk the hierarchy so one entry covers a family. FindObject rather than LoadObject: a native
|
||||
// class is already loaded, and a Blueprint class we cannot find has no exporter anyway.
|
||||
UClass* Class = FindObject<UClass>(ClassPath);
|
||||
|
||||
for (UClass* Super = Class ? Class->GetSuperClass() : nullptr; Super; Super = Super->GetSuperClass())
|
||||
{
|
||||
if (const FString* Found = FormatByClass.Find(Super->GetName()))
|
||||
{
|
||||
return *Found;
|
||||
}
|
||||
}
|
||||
|
||||
return FString();
|
||||
}
|
||||
|
||||
FAssetUsageExporter::FExchangeResult FAssetUsageExporter::ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options)
|
||||
{
|
||||
FExchangeResult Result;
|
||||
|
||||
if (Options.TargetDirectory.IsEmpty())
|
||||
{
|
||||
Result.Errors.Add(TEXT("No target directory was given."));
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: no target directory was given."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
IFileManager& FileManager = IFileManager::Get();
|
||||
|
||||
if (!FileManager.DirectoryExists(*Options.TargetDirectory)
|
||||
&& !FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Conversion aborted: could not create target directory '%s'."), *Options.TargetDirectory);
|
||||
return Result;
|
||||
}
|
||||
|
||||
const TMap<FString, FString>& FormatByClass = Options.FormatByClass.Num() > 0
|
||||
? Options.FormatByClass
|
||||
: GetDefaultFormatByClass();
|
||||
|
||||
const int32 Total = Items.Num();
|
||||
int32 LoadedSinceCollect = 0;
|
||||
|
||||
for (int32 Index = 0; Index < Total; ++Index)
|
||||
{
|
||||
if (Options.OnProgress && !Options.OnProgress(Index, Total))
|
||||
{
|
||||
Result.bCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const FExportItem& Item = Items[Index];
|
||||
|
||||
const FString Extension = FindFormatForClass(Item.ClassPath, FormatByClass);
|
||||
if (Extension.IsEmpty())
|
||||
{
|
||||
// Decided before loading anything: there is no point paying to load a Blueprint just to
|
||||
// discover the engine cannot write it to a file.
|
||||
++Result.SkippedNoFormat;
|
||||
continue;
|
||||
}
|
||||
|
||||
const FString ObjectPath = FString::Printf(TEXT("%s.%s"), *Item.PackageName.ToString(), *Item.AssetName.ToString());
|
||||
|
||||
UObject* Asset = LoadObject<UObject>(nullptr, *ObjectPath);
|
||||
if (!Asset)
|
||||
{
|
||||
++Result.SkippedNotLoaded;
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Could not load '%s' for conversion."), *ObjectPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
FString DestinationDir = Options.TargetDirectory;
|
||||
|
||||
if (!Options.bFlatten)
|
||||
{
|
||||
FString Relative = FPaths::GetPath(Item.PackageName.ToString());
|
||||
Relative.RemoveFromStart(TEXT("/"));
|
||||
DestinationDir = FPaths::Combine(Options.TargetDirectory, Relative);
|
||||
|
||||
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const FString DesiredFileName = FString::Printf(TEXT("%s.%s"), *Item.AssetName.ToString(), *Extension);
|
||||
|
||||
const FString FinalFileName = AssetExportNaming::ResolveCollision(
|
||||
DesiredFileName,
|
||||
Options.CollisionPolicy,
|
||||
[&FileManager, &DestinationDir](const FString& Candidate)
|
||||
{
|
||||
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
|
||||
});
|
||||
|
||||
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
|
||||
|
||||
if (FileManager.FileExists(*DestinationPath) && FileManager.IsReadOnly(*DestinationPath))
|
||||
{
|
||||
// IFileManager can report the flag but not change it; only IPlatformFile can.
|
||||
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
|
||||
}
|
||||
|
||||
// TStrongObjectPtr, not a bare NewObject: this loop runs garbage collection, and a task
|
||||
// rooted nowhere would be destroyed underneath the exporter.
|
||||
TStrongObjectPtr<UAssetExportTask> Task(NewObject<UAssetExportTask>());
|
||||
Task->Object = Asset;
|
||||
Task->Exporter = nullptr; // let the engine match SupportedClass against the extension
|
||||
Task->Filename = DestinationPath;
|
||||
Task->bSelected = false;
|
||||
Task->bReplaceIdentical = true;
|
||||
Task->bPrompt = false; // a modal dialog per asset would hang an unattended run
|
||||
Task->bAutomated = true;
|
||||
Task->bUseFileArchive = false;
|
||||
Task->bWriteEmptyFiles = false;
|
||||
|
||||
const bool bRan = UExporter::RunAssetExportTask(Task.Get());
|
||||
|
||||
// Both halves matter. RunAssetExportTask returns false when no exporter matched, and it can
|
||||
// also return true having filled Errors - reading only the bool reports a success that
|
||||
// produced nothing.
|
||||
if (!bRan)
|
||||
{
|
||||
if (Task->Errors.Num() > 0)
|
||||
{
|
||||
for (const FString& TaskError : Task->Errors)
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
++Result.SkippedNoExporter;
|
||||
}
|
||||
}
|
||||
else if (Task->Errors.Num() > 0)
|
||||
{
|
||||
for (const FString& TaskError : Task->Errors)
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("%s: %s"), *Item.AssetName.ToString(), *TaskError));
|
||||
}
|
||||
}
|
||||
else if (!FileManager.FileExists(*DestinationPath))
|
||||
{
|
||||
// An exporter that reports success without producing a file is the one failure mode a
|
||||
// caller would never notice. Say so rather than counting a write that did not happen.
|
||||
Result.Errors.Add(FString::Printf(
|
||||
TEXT("%s: the exporter reported success but wrote no file at '%s'."),
|
||||
*Item.AssetName.ToString(), *DestinationPath));
|
||||
}
|
||||
else
|
||||
{
|
||||
++Result.FilesWritten;
|
||||
|
||||
if (FinalFileName != DesiredFileName)
|
||||
{
|
||||
++Result.FilesRenamed;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading thousands of meshes without collecting exhausts memory long before the export
|
||||
// finishes. The task is released first so it is not what keeps the asset alive.
|
||||
Task.Reset();
|
||||
|
||||
if (Options.CollectGarbageEvery > 0 && ++LoadedSinceCollect >= Options.CollectGarbageEvery)
|
||||
{
|
||||
LoadedSinceCollect = 0;
|
||||
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
|
||||
}
|
||||
}
|
||||
|
||||
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Conversion to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
|
||||
|
||||
for (const FString& Error : Result.Errors)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
FString FAssetUsageExporter::ResolvePackageFilePath(FName PackageName)
|
||||
{
|
||||
const FString PackageNameStr = PackageName.ToString();
|
||||
|
||||
if (!FPackageName::IsValidLongPackageName(PackageNameStr))
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
// A package is either an asset or a map and we do not know which from the name alone.
|
||||
// DoesPackageExist resolves the extension for us and also copes with mount points other
|
||||
// than /Game, which matters for plugin content.
|
||||
FString ResolvedFileName;
|
||||
if (FPackageName::DoesPackageExist(PackageNameStr, &ResolvedFileName))
|
||||
{
|
||||
return ResolvedFileName;
|
||||
}
|
||||
|
||||
return FString();
|
||||
}
|
||||
|
||||
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options)
|
||||
{
|
||||
// One unnamed group: everything straight into the target, which is what this function has
|
||||
// always done. Kept as its own entry point because most callers and every existing spec want
|
||||
// exactly this and should not have to build a group to say so.
|
||||
FExportGroup Group;
|
||||
Group.Packages = PackageNames;
|
||||
|
||||
return ExportPackageGroups({ MoveTemp(Group) }, Options);
|
||||
}
|
||||
|
||||
FAssetUsageExporter::FResult FAssetUsageExporter::ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options)
|
||||
{
|
||||
FResult Result;
|
||||
|
||||
// Setup failures return before the summary log at the bottom of this function, so they must
|
||||
// log for themselves. These two are the most likely things to go wrong and were previously
|
||||
// the only failures that left no trace at all - the UI would say "see the Output Log" and the
|
||||
// Output Log would be empty.
|
||||
if (Options.TargetDirectory.IsEmpty())
|
||||
{
|
||||
Result.Errors.Add(TEXT("No target directory was given."));
|
||||
UE_LOG(LogAssetUsageAudit, Error, TEXT("Export aborted: no target directory was given."));
|
||||
return Result;
|
||||
}
|
||||
|
||||
IFileManager& FileManager = IFileManager::Get();
|
||||
|
||||
if (!FileManager.DirectoryExists(*Options.TargetDirectory))
|
||||
{
|
||||
if (!FileManager.MakeDirectory(*Options.TargetDirectory, /*Tree=*/true))
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *Options.TargetDirectory));
|
||||
UE_LOG(LogAssetUsageAudit, Error,
|
||||
TEXT("Export aborted: could not create target directory '%s'. Check the path is valid and writable ")
|
||||
TEXT("- a Perforce-read-only parent or a non-existent drive letter both land here."),
|
||||
*Options.TargetDirectory);
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the groups into one work list, remembering each entry's destination folder. Doing it
|
||||
// up front keeps the progress total honest: a caller showing a bar must not watch the maximum
|
||||
// creep upward as levels expand mid-copy.
|
||||
//
|
||||
// Deduplication is per group, not global. The same texture legitimately appears in several
|
||||
// groups under the folder-per-asset layout - that is the whole point of that layout - so a
|
||||
// global Seen set would silently give the first folder the texture and leave the rest without.
|
||||
TArray<FName> Expanded;
|
||||
TArray<FString> DestinationDirs;
|
||||
|
||||
// Parallel to Expanded: true for entries this function added by expanding a level, rather than
|
||||
// entries the caller asked for. Counted at copy time, not here - reporting "N external actor
|
||||
// packages" for packages that were only queued would overstate a cancelled or failing run.
|
||||
TBitArray<> IsExternalExpansion;
|
||||
|
||||
for (const FExportGroup& Group : Groups)
|
||||
{
|
||||
const FString GroupDir = Group.RelativeDir.IsEmpty()
|
||||
? Options.TargetDirectory
|
||||
: FPaths::Combine(Options.TargetDirectory, Group.RelativeDir);
|
||||
|
||||
const int32 FirstIndexInGroup = Expanded.Num();
|
||||
|
||||
TSet<FName> Seen;
|
||||
Seen.Reserve(Group.Packages.Num());
|
||||
|
||||
for (FName PackageName : Group.Packages)
|
||||
{
|
||||
if (!Seen.Contains(PackageName))
|
||||
{
|
||||
Seen.Add(PackageName);
|
||||
Expanded.Add(PackageName);
|
||||
DestinationDirs.Add(GroupDir);
|
||||
IsExternalExpansion.Add(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Options.bIncludeExternalPackages)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const int32 LastIndexInGroup = Expanded.Num();
|
||||
|
||||
for (int32 Index = FirstIndexInGroup; Index < LastIndexInGroup; ++Index)
|
||||
{
|
||||
const FString PackageNameStr = Expanded[Index].ToString();
|
||||
|
||||
// Only a World owns external packages. Asking for the paths of a non-level is harmless
|
||||
// but pointless, and on 45k rows it is not free.
|
||||
const FString FilePath = ResolvePackageFilePath(Expanded[Index]);
|
||||
if (!FilePath.EndsWith(FPackageName::GetMapPackageExtension()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plural form: plugins register extra roots through delegates, and Content Bundles and
|
||||
// External Data Layers inject path segments a hand-built string would miss.
|
||||
TArray<FString> ExternalRoots = ULevel::GetExternalActorsPaths(PackageNameStr);
|
||||
ExternalRoots.Append(ULevel::GetExternalObjectsPaths(PackageNameStr));
|
||||
|
||||
for (const FString& Root : ExternalRoots)
|
||||
{
|
||||
FString RootDir;
|
||||
if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TArray<FString> Found;
|
||||
FileManager.FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), /*Files=*/true, /*Directories=*/false);
|
||||
|
||||
for (const FString& AbsFile : Found)
|
||||
{
|
||||
FString ExternalPackageName;
|
||||
if (FPackageName::TryConvertFilenameToLongPackageName(AbsFile, ExternalPackageName))
|
||||
{
|
||||
const FName AsName(*ExternalPackageName);
|
||||
if (!Seen.Contains(AsName))
|
||||
{
|
||||
Seen.Add(AsName);
|
||||
Expanded.Add(AsName);
|
||||
DestinationDirs.Add(GroupDir);
|
||||
IsExternalExpansion.Add(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CopyExpandedPackages(Expanded, DestinationDirs, IsExternalExpansion, Options, Result);
|
||||
}
|
||||
|
||||
FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TArray<FName>& Expanded, const TArray<FString>& DestinationDirs, const TBitArray<>& IsExternalExpansion, const FOptions& Options, FResult& Result)
|
||||
{
|
||||
IFileManager& FileManager = IFileManager::Get();
|
||||
|
||||
const int32 Total = Expanded.Num();
|
||||
|
||||
for (int32 Index = 0; Index < Total; ++Index)
|
||||
{
|
||||
if (Options.OnProgress && !Options.OnProgress(Index, Total))
|
||||
{
|
||||
Result.bCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const FName PackageName = Expanded[Index];
|
||||
const FString SourcePath = ResolvePackageFilePath(PackageName);
|
||||
|
||||
if (SourcePath.IsEmpty())
|
||||
{
|
||||
// Normal for script packages and for assets that exist only in memory. Counted, not
|
||||
// reported as an error, so a real failure stays visible in the Errors list.
|
||||
++Result.FilesMissingOnDisk;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The group decided the folder; bFlatten only chooses whether to mirror the package path
|
||||
// beneath it. The two compose: a folder-per-asset export with mirroring off writes straight
|
||||
// into the asset's folder, which is what makes that layout readable.
|
||||
FString DestinationDir = DestinationDirs.IsValidIndex(Index)
|
||||
? DestinationDirs[Index]
|
||||
: Options.TargetDirectory;
|
||||
|
||||
if (!Options.bFlatten)
|
||||
{
|
||||
// Mirror the package path under the target, minus the leading mount point slash.
|
||||
FString Relative = FPaths::GetPath(PackageName.ToString());
|
||||
Relative.RemoveFromStart(TEXT("/"));
|
||||
DestinationDir = FPaths::Combine(DestinationDir, Relative);
|
||||
}
|
||||
|
||||
if (DestinationDir != Options.TargetDirectory)
|
||||
{
|
||||
if (!FileManager.DirectoryExists(*DestinationDir) && !FileManager.MakeDirectory(*DestinationDir, true))
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("Could not create '%s'."), *DestinationDir));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const FString DesiredFileName = FPaths::GetCleanFilename(SourcePath);
|
||||
|
||||
const FString FinalFileName = AssetExportNaming::ResolveCollision(
|
||||
DesiredFileName,
|
||||
Options.CollisionPolicy,
|
||||
[&FileManager, &DestinationDir](const FString& Candidate)
|
||||
{
|
||||
return FileManager.FileExists(*FPaths::Combine(DestinationDir, Candidate));
|
||||
});
|
||||
|
||||
const FString DestinationPath = FPaths::Combine(DestinationDir, FinalFileName);
|
||||
|
||||
const bool bWasPresent = FileManager.FileExists(*DestinationPath);
|
||||
|
||||
// This repository is Perforce-primary, so the source .uasset is very often read-only on
|
||||
// disk. Windows CopyFile carries that attribute across, which would make the export folder
|
||||
// read-only too - and then an Overwrite-policy re-export fails on its own previous output.
|
||||
// Clear the flag on both ends of the problem: before writing over an existing file, and
|
||||
// after writing a new one. The destination is outside source control by definition.
|
||||
if (bWasPresent && FileManager.IsReadOnly(*DestinationPath))
|
||||
{
|
||||
// IFileManager can report the flag but not change it; only IPlatformFile can.
|
||||
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
|
||||
}
|
||||
|
||||
if (FileManager.Copy(*DestinationPath, *SourcePath, /*bReplace=*/true) != COPY_OK)
|
||||
{
|
||||
Result.Errors.Add(FString::Printf(TEXT("Failed to copy '%s' to '%s'."), *PackageName.ToString(), *DestinationPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FileManager.IsReadOnly(*DestinationPath))
|
||||
{
|
||||
// IFileManager can report the flag but not change it; only IPlatformFile can.
|
||||
FPlatformFileManager::Get().GetPlatformFile().SetReadOnly(*DestinationPath, false);
|
||||
}
|
||||
|
||||
++Result.FilesCopied;
|
||||
|
||||
if (Options.bRecordWrittenFiles)
|
||||
{
|
||||
// Relative to the export root, so the manifest stays valid if the folder is moved or
|
||||
// renamed - which is the normal fate of an export folder.
|
||||
FString Relative = DestinationPath;
|
||||
FPaths::MakePathRelativeTo(Relative, *(Options.TargetDirectory / TEXT("")));
|
||||
|
||||
FWrittenFile& Written = Result.WrittenFiles.AddDefaulted_GetRef();
|
||||
Written.PackageName = PackageName;
|
||||
Written.RelativePath = MoveTemp(Relative);
|
||||
}
|
||||
|
||||
if (IsExternalExpansion.IsValidIndex(Index) && IsExternalExpansion[Index])
|
||||
{
|
||||
++Result.ExternalPackagesCopied;
|
||||
}
|
||||
|
||||
if (FinalFileName != DesiredFileName)
|
||||
{
|
||||
++Result.FilesRenamed;
|
||||
}
|
||||
else if (bWasPresent)
|
||||
{
|
||||
++Result.FilesOverwritten;
|
||||
}
|
||||
}
|
||||
|
||||
Result.bSuccess = Result.Errors.IsEmpty() && !Result.bCancelled;
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Export to '%s': %s"), *Options.TargetDirectory, *Result.Summarise());
|
||||
|
||||
for (const FString& Error : Result.Errors)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT(" %s"), *Error);
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsageGraph.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsagePaths.h"
|
||||
#include "AssetRegistry/ARFilter.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "Engine/World.h"
|
||||
#include "HAL/PlatformTime.h"
|
||||
#include "Misc/PackageName.h"
|
||||
#include "Misc/ScopeExit.h"
|
||||
#include "UObject/ObjectRedirector.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Asset registry tag carrying a Blueprint's generated gameplay class. */
|
||||
const FName GeneratedClassTagName(TEXT("GeneratedClass"));
|
||||
|
||||
/** Redirector chains are shallow in practice; this only guards against a malformed cycle. */
|
||||
constexpr int32 MaxRedirectorHops = 16;
|
||||
}
|
||||
|
||||
void FAssetUsageGraph::Reset()
|
||||
{
|
||||
Nodes.Reset();
|
||||
PackageToIndex.Reset();
|
||||
Dependencies.Reset();
|
||||
DependencyOffsets.Reset();
|
||||
Referencers.Reset();
|
||||
ReferencerOffsets.Reset();
|
||||
LevelIndices.Reset();
|
||||
Stats = FAssetUsageGraphStats();
|
||||
}
|
||||
|
||||
int32 FAssetUsageGraph::AddOrFindPackage(FName PackageName)
|
||||
{
|
||||
if (const int32* Existing = PackageToIndex.Find(PackageName))
|
||||
{
|
||||
return *Existing;
|
||||
}
|
||||
|
||||
const int32 NewIndex = Nodes.Num();
|
||||
|
||||
FAssetUsageNode& Node = Nodes.AddDefaulted_GetRef();
|
||||
Node.PackageName = PackageName;
|
||||
Node.bIsExternalPackage = AssetUsagePaths::IsExternalPackage(PackageName);
|
||||
|
||||
PackageToIndex.Add(PackageName, NewIndex);
|
||||
|
||||
return NewIndex;
|
||||
}
|
||||
|
||||
void FAssetUsageGraph::Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options)
|
||||
{
|
||||
Reset();
|
||||
|
||||
// Temporary caching makes repeated class-hierarchy and tag lookups cheap for the duration of
|
||||
// the sweep. GetDerivedClassNames in particular is documented as slow without it.
|
||||
AssetRegistry.SetTemporaryCachingMode(true);
|
||||
ON_SCOPE_EXIT
|
||||
{
|
||||
AssetRegistry.SetTemporaryCachingMode(false);
|
||||
};
|
||||
|
||||
FARFilter Filter;
|
||||
Filter.bRecursivePaths = true;
|
||||
|
||||
// On-disk only: unsaved in-memory assets have no dependency data yet, and including them
|
||||
// makes the result depend on which windows the user happens to have open.
|
||||
Filter.bIncludeOnlyOnDiskAssets = true;
|
||||
|
||||
if (Options.IncludePackagePaths.IsEmpty())
|
||||
{
|
||||
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const FString& Path : Options.IncludePackagePaths)
|
||||
{
|
||||
Filter.PackagePaths.Add(FName(*Path));
|
||||
}
|
||||
}
|
||||
|
||||
FARCompiledFilter CompiledFilter;
|
||||
AssetRegistry.CompileFilter(Filter, CompiledFilter);
|
||||
|
||||
const FTopLevelAssetPath WorldClassPath = UWorld::StaticClass()->GetClassPathName();
|
||||
const FTopLevelAssetPath RedirectorClassPath = UObjectRedirector::StaticClass()->GetClassPathName();
|
||||
|
||||
// --- Pass 1: inventory -------------------------------------------------------------------
|
||||
// EnumerateAssets rather than GetAssets: at 80k assets the latter materialises one giant
|
||||
// TArray<FAssetData> before we have looked at a single element.
|
||||
{
|
||||
const double StartTime = FPlatformTime::Seconds();
|
||||
|
||||
AssetRegistry.EnumerateAssets(CompiledFilter, [this, &Options, &WorldClassPath, &RedirectorClassPath](const FAssetData& AssetData)
|
||||
{
|
||||
const FName PackageName = AssetData.PackageName;
|
||||
|
||||
if (AssetUsagePaths::IsPathExcluded(PackageName, Options.ExcludePackagePaths))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
const int32 Index = AddOrFindPackage(PackageName);
|
||||
FAssetUsageNode& Node = Nodes[Index];
|
||||
|
||||
// A package can hold several assets; keep the first as representative but never let a
|
||||
// secondary asset downgrade a class we already recorded.
|
||||
if (Node.AssetName.IsNone())
|
||||
{
|
||||
Node.AssetName = AssetData.AssetName;
|
||||
Node.ClassPath = AssetData.AssetClassPath;
|
||||
}
|
||||
|
||||
if (AssetData.AssetClassPath == WorldClassPath)
|
||||
{
|
||||
Node.bIsLevel = true;
|
||||
Node.ClassPath = WorldClassPath;
|
||||
}
|
||||
|
||||
if (AssetData.AssetClassPath == RedirectorClassPath)
|
||||
{
|
||||
Node.bIsRedirector = true;
|
||||
}
|
||||
|
||||
// The GeneratedClass tag is the only route from a Blueprint asset to its gameplay
|
||||
// class; the asset's own class is always /Script/Engine.Blueprint.
|
||||
FString GeneratedClass;
|
||||
if (AssetData.GetTagValue(GeneratedClassTagName, GeneratedClass) && !GeneratedClass.IsEmpty())
|
||||
{
|
||||
// The tag is stored in export-text form, e.g. BlueprintGeneratedClass'/Game/X/BP_Y.BP_Y_C'.
|
||||
// TrySetPath rather than the FStringView constructor: a malformed tag then leaves the
|
||||
// path empty instead of asserting mid-sweep.
|
||||
Node.GeneratedClassPath.TrySetPath(FPackageName::ExportTextPathToObjectPath(GeneratedClass));
|
||||
}
|
||||
|
||||
return true;
|
||||
}, UE::AssetRegistry::EEnumerateAssetsFlags::OnlyOnDiskAssets);
|
||||
|
||||
Stats.EnumerateSeconds = FPlatformTime::Seconds() - StartTime;
|
||||
}
|
||||
|
||||
Stats.NumPackages = Nodes.Num();
|
||||
|
||||
// --- Pass 2: dependency edges ------------------------------------------------------------
|
||||
DependencyOffsets.SetNumZeroed(Nodes.Num() + 1);
|
||||
|
||||
if (Options.bGatherDependencies)
|
||||
{
|
||||
const double StartTime = FPlatformTime::Seconds();
|
||||
|
||||
TArray<FAssetDependency> EdgeBuffer;
|
||||
|
||||
// Node count grows as we discover dependency targets outside the swept paths (engine
|
||||
// content, plugin content). Index by position rather than caching Num().
|
||||
for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex)
|
||||
{
|
||||
DependencyOffsets[NodeIndex] = Dependencies.Num();
|
||||
|
||||
EdgeBuffer.Reset();
|
||||
|
||||
// The query that makes this tool correct. Package category, no requirement flags.
|
||||
// Never EDependencyQuery::Hard: map -> external actor edges carry Game|Build without
|
||||
// Hard, so a Hard query silently drops every OFPA actor in the project.
|
||||
AssetRegistry.GetDependencies(
|
||||
Nodes[NodeIndex].PackageName,
|
||||
EdgeBuffer,
|
||||
AssetUsageAudit::MakeTraversalCategory(),
|
||||
AssetUsageAudit::MakeTraversalQuery());
|
||||
|
||||
for (const FAssetDependency& Dependency : EdgeBuffer)
|
||||
{
|
||||
const FName TargetPackage = Dependency.AssetId.PackageName;
|
||||
|
||||
if (TargetPackage.IsNone() || TargetPackage == Nodes[NodeIndex].PackageName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AssetUsagePaths::IsPathExcluded(TargetPackage, Options.ExcludePackagePaths))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Script packages (/Script/Engine) are code, not assets. They inflate the graph
|
||||
// and can never be exported or reported as unused.
|
||||
if (AssetUsagePaths::IsScriptPackage(TargetPackage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FAssetUsageEdge& Edge = Dependencies.AddDefaulted_GetRef();
|
||||
Edge.TargetIndex = AddOrFindPackage(TargetPackage);
|
||||
Edge.Properties = Dependency.Properties;
|
||||
}
|
||||
|
||||
// AddOrFindPackage above may have appended nodes; keep the offset table in step.
|
||||
if (DependencyOffsets.Num() < Nodes.Num() + 1)
|
||||
{
|
||||
DependencyOffsets.SetNumZeroed(Nodes.Num() + 1, EAllowShrinking::No);
|
||||
}
|
||||
}
|
||||
|
||||
DependencyOffsets[Nodes.Num()] = Dependencies.Num();
|
||||
|
||||
Stats.DependencySeconds = FPlatformTime::Seconds() - StartTime;
|
||||
}
|
||||
else
|
||||
{
|
||||
DependencyOffsets[Nodes.Num()] = 0;
|
||||
}
|
||||
|
||||
// Nodes discovered during pass 2 have no inventory data. Fill in what we can from the path so
|
||||
// they are never reported as an unnamed blank row.
|
||||
for (FAssetUsageNode& Node : Nodes)
|
||||
{
|
||||
if (Node.AssetName.IsNone())
|
||||
{
|
||||
Node.AssetName = FName(*FPackageName::GetShortName(Node.PackageName));
|
||||
}
|
||||
}
|
||||
|
||||
for (int32 NodeIndex = 0; NodeIndex < Nodes.Num(); ++NodeIndex)
|
||||
{
|
||||
if (Nodes[NodeIndex].bIsLevel)
|
||||
{
|
||||
LevelIndices.Add(NodeIndex);
|
||||
}
|
||||
|
||||
if (Nodes[NodeIndex].bIsExternalPackage)
|
||||
{
|
||||
++Stats.NumExternalPackages;
|
||||
}
|
||||
}
|
||||
|
||||
BuildReverseEdges();
|
||||
|
||||
Stats.NumPackages = Nodes.Num();
|
||||
Stats.NumEdges = Dependencies.Num();
|
||||
Stats.NumLevels = LevelIndices.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log,
|
||||
TEXT("Graph built: %d packages, %d edges, %d levels, %d external packages. Enumerate %.2fs, dependencies %.2fs, total %.2fs."),
|
||||
Stats.NumPackages, Stats.NumEdges, Stats.NumLevels, Stats.NumExternalPackages,
|
||||
Stats.EnumerateSeconds, Stats.DependencySeconds, Stats.TotalSeconds());
|
||||
}
|
||||
|
||||
void FAssetUsageGraph::BuildReverseEdges()
|
||||
{
|
||||
Referencers.Reset();
|
||||
ReferencerOffsets.Reset();
|
||||
ReferencerOffsets.SetNumZeroed(Nodes.Num() + 1);
|
||||
|
||||
if (Dependencies.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Counting sort: one pass to size each bucket, one to fill. Avoids per-node TArray growth.
|
||||
for (const FAssetUsageEdge& Edge : Dependencies)
|
||||
{
|
||||
if (Nodes.IsValidIndex(Edge.TargetIndex))
|
||||
{
|
||||
++ReferencerOffsets[Edge.TargetIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
for (int32 Index = 1; Index <= Nodes.Num(); ++Index)
|
||||
{
|
||||
ReferencerOffsets[Index] += ReferencerOffsets[Index - 1];
|
||||
}
|
||||
|
||||
Referencers.SetNumUninitialized(Dependencies.Num());
|
||||
|
||||
TArray<int32> FillCursor = ReferencerOffsets;
|
||||
|
||||
for (int32 SourceIndex = 0; SourceIndex < Nodes.Num(); ++SourceIndex)
|
||||
{
|
||||
for (const FAssetUsageEdge& Edge : GetDependencies(SourceIndex))
|
||||
{
|
||||
if (!Nodes.IsValidIndex(Edge.TargetIndex))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
FAssetUsageEdge& Reverse = Referencers[FillCursor[Edge.TargetIndex]++];
|
||||
Reverse.TargetIndex = SourceIndex;
|
||||
Reverse.Properties = Edge.Properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TConstArrayView<FAssetUsageEdge> FAssetUsageGraph::GetDependencies(int32 Index) const
|
||||
{
|
||||
if (!Nodes.IsValidIndex(Index) || DependencyOffsets.Num() < Nodes.Num() + 1)
|
||||
{
|
||||
return TConstArrayView<FAssetUsageEdge>();
|
||||
}
|
||||
|
||||
const int32 Begin = DependencyOffsets[Index];
|
||||
const int32 End = DependencyOffsets[Index + 1];
|
||||
|
||||
if (Begin >= End)
|
||||
{
|
||||
return TConstArrayView<FAssetUsageEdge>();
|
||||
}
|
||||
|
||||
return TConstArrayView<FAssetUsageEdge>(Dependencies.GetData() + Begin, End - Begin);
|
||||
}
|
||||
|
||||
TConstArrayView<FAssetUsageEdge> FAssetUsageGraph::GetReferencers(int32 Index) const
|
||||
{
|
||||
if (!Nodes.IsValidIndex(Index) || ReferencerOffsets.Num() < Nodes.Num() + 1)
|
||||
{
|
||||
return TConstArrayView<FAssetUsageEdge>();
|
||||
}
|
||||
|
||||
const int32 Begin = ReferencerOffsets[Index];
|
||||
const int32 End = ReferencerOffsets[Index + 1];
|
||||
|
||||
if (Begin >= End)
|
||||
{
|
||||
return TConstArrayView<FAssetUsageEdge>();
|
||||
}
|
||||
|
||||
return TConstArrayView<FAssetUsageEdge>(Referencers.GetData() + Begin, End - Begin);
|
||||
}
|
||||
|
||||
int32 FAssetUsageGraph::ResolveRedirector(int32 Index) const
|
||||
{
|
||||
int32 Current = Index;
|
||||
|
||||
for (int32 Hop = 0; Hop < MaxRedirectorHops; ++Hop)
|
||||
{
|
||||
if (!Nodes.IsValidIndex(Current) || !Nodes[Current].bIsRedirector)
|
||||
{
|
||||
return Current;
|
||||
}
|
||||
|
||||
// A redirector package depends on exactly the asset it forwards to. Take the first
|
||||
// non-self dependency; there is no second candidate in a well-formed redirector.
|
||||
const TConstArrayView<FAssetUsageEdge> Forward = GetDependencies(Current);
|
||||
|
||||
int32 Next = INDEX_NONE;
|
||||
for (const FAssetUsageEdge& Edge : Forward)
|
||||
{
|
||||
if (Edge.TargetIndex != Current && Nodes.IsValidIndex(Edge.TargetIndex))
|
||||
{
|
||||
Next = Edge.TargetIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (Next == INDEX_NONE)
|
||||
{
|
||||
// Dangling redirector: points at something outside the sweep, or at nothing.
|
||||
return Current;
|
||||
}
|
||||
|
||||
Current = Next;
|
||||
}
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Warning,
|
||||
TEXT("Redirector chain from '%s' exceeded %d hops; treating it as unresolvable."),
|
||||
*Nodes[Index].PackageName.ToString(), MaxRedirectorHops);
|
||||
|
||||
return Current;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsagePaths.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Misc/StringBuilder.h"
|
||||
#include "String/Find.h"
|
||||
|
||||
namespace AssetUsagePaths
|
||||
{
|
||||
namespace Private
|
||||
{
|
||||
// These two literals match FPackagePath's folder names (CoreUObject PackagePath.cpp).
|
||||
// They are used only to CLASSIFY an existing path. Producing an external-actor path is
|
||||
// ULevel::GetExternalActorsPaths's job, because that form has /CB/ and /EDL/ variants and
|
||||
// is extensible by plugin delegates - string building it here would be silently wrong.
|
||||
static const TCHAR* ExternalActorsFolder = TEXT("__ExternalActors__");
|
||||
static const TCHAR* ExternalObjectsFolder = TEXT("__ExternalObjects__");
|
||||
|
||||
static const TCHAR* GameRoot = TEXT("/Game");
|
||||
static const TCHAR* ContentPrefix = TEXT("Content");
|
||||
|
||||
/** Prefix match that only accepts a whole path segment, so /Game/Art misses /Game/ArtSource. */
|
||||
static bool StartsWithSegment(FStringView Path, FStringView Prefix)
|
||||
{
|
||||
if (Prefix.IsEmpty() || Path.Len() < Prefix.Len())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Path.Left(Prefix.Len()).Equals(Prefix, ESearchCase::IgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Path.Len() == Prefix.Len() || Path[Prefix.Len()] == TEXT('/');
|
||||
}
|
||||
}
|
||||
|
||||
bool IsExternalPackage(FStringView PackagePath)
|
||||
{
|
||||
return UE::String::FindFirst(PackagePath, Private::ExternalActorsFolder, ESearchCase::IgnoreCase) != INDEX_NONE
|
||||
|| UE::String::FindFirst(PackagePath, Private::ExternalObjectsFolder, ESearchCase::IgnoreCase) != INDEX_NONE;
|
||||
}
|
||||
|
||||
bool IsExternalPackage(FName PackageName)
|
||||
{
|
||||
TStringBuilder<256> Builder;
|
||||
PackageName.ToString(Builder);
|
||||
return IsExternalPackage(Builder.ToView());
|
||||
}
|
||||
|
||||
bool IsScriptPackage(FStringView PackagePath)
|
||||
{
|
||||
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Script"));
|
||||
}
|
||||
|
||||
bool IsScriptPackage(FName PackageName)
|
||||
{
|
||||
TStringBuilder<256> Builder;
|
||||
PackageName.ToString(Builder);
|
||||
return IsScriptPackage(Builder.ToView());
|
||||
}
|
||||
|
||||
bool IsEngineOrTempPackage(FStringView PackagePath)
|
||||
{
|
||||
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Engine"))
|
||||
|| Private::StartsWithSegment(PackagePath, TEXTVIEW("/Temp"));
|
||||
}
|
||||
|
||||
FString NormalizeExclusionPrefix(const FString& Prefix)
|
||||
{
|
||||
FString Working = Prefix;
|
||||
Working.TrimStartAndEndInline();
|
||||
Working.ReplaceInline(TEXT("\\"), TEXT("/"), ESearchCase::CaseSensitive);
|
||||
|
||||
while (Working.EndsWith(TEXT("/"), ESearchCase::CaseSensitive))
|
||||
{
|
||||
Working.LeftChopInline(1, EAllowShrinking::No);
|
||||
}
|
||||
|
||||
if (Working.IsEmpty())
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
// Content-relative form, which is what the UI shows and what settings files accumulate.
|
||||
if (Private::StartsWithSegment(Working, Private::ContentPrefix))
|
||||
{
|
||||
const FString Remainder = Working.RightChop(FCString::Strlen(Private::ContentPrefix));
|
||||
return FString(Private::GameRoot) + Remainder;
|
||||
}
|
||||
|
||||
if (!Working.StartsWith(TEXT("/"), ESearchCase::CaseSensitive))
|
||||
{
|
||||
Working.InsertAt(0, TEXT("/"));
|
||||
}
|
||||
|
||||
return Working;
|
||||
}
|
||||
|
||||
bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes)
|
||||
{
|
||||
for (const FString& RawPrefix : ExcludedPrefixes)
|
||||
{
|
||||
const FString Normalized = NormalizeExclusionPrefix(RawPrefix);
|
||||
if (!Normalized.IsEmpty() && Private::StartsWithSegment(PackagePath, Normalized))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes)
|
||||
{
|
||||
if (ExcludedPrefixes.IsEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
TStringBuilder<256> Builder;
|
||||
PackageName.ToString(Builder);
|
||||
return IsPathExcluded(Builder.ToView(), ExcludedPrefixes);
|
||||
}
|
||||
|
||||
FString ToProjectRelativePath(FName PackageName, bool bIsLevel)
|
||||
{
|
||||
TStringBuilder<256> Builder;
|
||||
PackageName.ToString(Builder);
|
||||
const FStringView PackagePath = Builder.ToView();
|
||||
|
||||
if (!Private::StartsWithSegment(PackagePath, Private::GameRoot))
|
||||
{
|
||||
// /Script, /Engine and plugin mounts have no project-relative form. Reporting an
|
||||
// invented path for them would send an artist looking for a file that is not there.
|
||||
return FString();
|
||||
}
|
||||
|
||||
const FStringView Remainder = PackagePath.RightChop(FCString::Strlen(Private::GameRoot));
|
||||
const TCHAR* Extension = bIsLevel ? TEXT(".umap") : TEXT(".uasset");
|
||||
|
||||
return FString::Printf(TEXT("Content%.*s%s"), Remainder.Len(), Remainder.GetData(), Extension);
|
||||
}
|
||||
|
||||
FString ValidateMigrateDestination(const FString& Directory)
|
||||
{
|
||||
if (Directory.IsEmpty())
|
||||
{
|
||||
return TEXT("Choose the destination project's Content folder.");
|
||||
}
|
||||
|
||||
FString Normalised = Directory;
|
||||
FPaths::NormalizeDirectoryName(Normalised);
|
||||
Normalised += TEXT("/");
|
||||
|
||||
if (!Normalised.EndsWith(TEXT("/Content/")))
|
||||
{
|
||||
return TEXT("Migrate needs a folder named Content - pick the destination project's Content folder itself, not the project root.");
|
||||
}
|
||||
|
||||
// Second rule. A folder merely named Content passes the first check and Migrate still
|
||||
// aborts: the destination mount point is derived from what sits beside it.
|
||||
const FString Parent = Normalised + TEXT("../");
|
||||
|
||||
TArray<FString> Found;
|
||||
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uproject"));
|
||||
|
||||
if (!Found.IsEmpty())
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uplugin"));
|
||||
|
||||
if (Found.Num() == 1)
|
||||
{
|
||||
return FString();
|
||||
}
|
||||
|
||||
return Found.Num() > 1
|
||||
? TEXT("That folder's parent holds several .uplugin files, so Unreal cannot tell which mount point to migrate into.")
|
||||
: TEXT("No .uproject or .uplugin next to that Content folder, so Unreal has no mount point to migrate into.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "AssetUsageReportWriter.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsageAuditor.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Serialization/JsonSerializer.h"
|
||||
#include "Serialization/JsonWriter.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Column order is shared by the CSV header and the row writer so they cannot drift. */
|
||||
const TCHAR* CsvColumns[] = {
|
||||
TEXT("Type"),
|
||||
TEXT("GeneratedClass"),
|
||||
TEXT("Name"),
|
||||
TEXT("PathFromProjectRoot"),
|
||||
TEXT("Verdict"),
|
||||
TEXT("LevelCount"),
|
||||
TEXT("Levels"),
|
||||
TEXT("HardRefs"),
|
||||
TEXT("SoftRefs"),
|
||||
TEXT("Provenance"),
|
||||
TEXT("Route"),
|
||||
TEXT("ProvenanceDetail")
|
||||
};
|
||||
|
||||
FString JoinNames(const TArray<FName>& Names, const FString& Separator)
|
||||
{
|
||||
TArray<FString> Strings;
|
||||
Strings.Reserve(Names.Num());
|
||||
|
||||
for (const FName& Name : Names)
|
||||
{
|
||||
Strings.Add(Name.ToString());
|
||||
}
|
||||
|
||||
return FString::Join(Strings, *Separator);
|
||||
}
|
||||
}
|
||||
|
||||
FString FAssetUsageReportWriter::EscapeCsvField(const FString& Field, TCHAR Delimiter)
|
||||
{
|
||||
const bool bNeedsQuoting =
|
||||
Field.Contains(FString(1, &Delimiter), ESearchCase::CaseSensitive) ||
|
||||
Field.Contains(TEXT("\""), ESearchCase::CaseSensitive) ||
|
||||
Field.Contains(TEXT("\n"), ESearchCase::CaseSensitive) ||
|
||||
Field.Contains(TEXT("\r"), ESearchCase::CaseSensitive);
|
||||
|
||||
if (!bNeedsQuoting)
|
||||
{
|
||||
return Field;
|
||||
}
|
||||
|
||||
FString Escaped = Field;
|
||||
Escaped.ReplaceInline(TEXT("\""), TEXT("\"\""), ESearchCase::CaseSensitive);
|
||||
|
||||
return FString::Printf(TEXT("\"%s\""), *Escaped);
|
||||
}
|
||||
|
||||
FString FAssetUsageReportWriter::BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
|
||||
{
|
||||
const FAssetUsageReportHeader& Header = AuditResult.Header;
|
||||
const TCHAR Delimiter = Options.CsvDelimiter;
|
||||
|
||||
FString Output;
|
||||
Output.Reserve(AuditResult.Rows.Num() * 160 + 2048);
|
||||
|
||||
// Provenance of the report itself, as '#' comment lines. Excel shows them as ordinary rows,
|
||||
// which is fine and is much better than a report nobody can date or attribute to a filter set.
|
||||
Output += FString::Printf(TEXT("# Asset Usage Audit report\n"));
|
||||
Output += FString::Printf(TEXT("# Generated: %s\n"), *Header.GeneratedAt.ToIso8601());
|
||||
Output += FString::Printf(TEXT("# Project: %s\n"), *Header.ProjectName);
|
||||
Output += FString::Printf(TEXT("# Engine: %s\n"), *Header.EngineVersion);
|
||||
Output += FString::Printf(TEXT("# Tool: %s\n"), *Header.ToolVersion);
|
||||
Output += FString::Printf(TEXT("# Levels scanned: %d\n"), Header.LevelsScanned);
|
||||
Output += FString::Printf(TEXT("# Assets scanned: %d\n"), Header.AssetsScanned);
|
||||
Output += FString::Printf(TEXT("# Rows reported: %d\n"), AuditResult.Rows.Num());
|
||||
Output += FString::Printf(TEXT("# Scan duration: %.2f s\n"), Header.ScanDurationSeconds);
|
||||
|
||||
for (const FString& Filter : Header.AppliedFilters)
|
||||
{
|
||||
Output += FString::Printf(TEXT("# Filter: %s\n"), *Filter);
|
||||
}
|
||||
|
||||
Output += FString::Printf(TEXT("# Verdict counts: UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n"),
|
||||
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
|
||||
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
|
||||
AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
|
||||
AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced),
|
||||
AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
|
||||
|
||||
Output += TEXT("# Unknown means the registry cannot see this asset's references (FMOD, DataTable rows,\n");
|
||||
Output += TEXT("# runtime-built paths). It does NOT mean unused. Never delete on the strength of it.\n");
|
||||
|
||||
for (int32 ColumnIndex = 0; ColumnIndex < UE_ARRAY_COUNT(CsvColumns); ++ColumnIndex)
|
||||
{
|
||||
if (ColumnIndex > 0)
|
||||
{
|
||||
Output.AppendChar(Delimiter);
|
||||
}
|
||||
|
||||
Output += CsvColumns[ColumnIndex];
|
||||
}
|
||||
|
||||
Output += TEXT("\n");
|
||||
|
||||
for (const FAssetUsageRow& Row : AuditResult.Rows)
|
||||
{
|
||||
TArray<FString, TInlineAllocator<16>> Fields;
|
||||
|
||||
Fields.Add(Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
|
||||
Fields.Add(Row.GeneratedClassPath.IsNull() ? FString() : Row.GeneratedClassPath.ToString());
|
||||
Fields.Add(Row.AssetName.ToString());
|
||||
Fields.Add(Row.PathFromProjectRoot);
|
||||
Fields.Add(LexToString(Row.Verdict));
|
||||
Fields.Add(FString::FromInt(Row.Levels.Num()));
|
||||
Fields.Add(JoinNames(Row.Levels, Options.CsvMultiValueSeparator));
|
||||
Fields.Add(FString::FromInt(Row.HardReferenceCount));
|
||||
Fields.Add(FString::FromInt(Row.SoftReferenceCount));
|
||||
Fields.Add(ProvenanceToString(Row.Provenance));
|
||||
Fields.Add(Row.Route);
|
||||
Fields.Add(Row.ProvenanceDetail);
|
||||
|
||||
checkf(Fields.Num() == UE_ARRAY_COUNT(CsvColumns), TEXT("CSV row width must match the header."));
|
||||
|
||||
for (int32 FieldIndex = 0; FieldIndex < Fields.Num(); ++FieldIndex)
|
||||
{
|
||||
if (FieldIndex > 0)
|
||||
{
|
||||
Output.AppendChar(Delimiter);
|
||||
}
|
||||
|
||||
Output += EscapeCsvField(Fields[FieldIndex], Delimiter);
|
||||
}
|
||||
|
||||
Output += TEXT("\n");
|
||||
}
|
||||
|
||||
return Output;
|
||||
}
|
||||
|
||||
FString FAssetUsageReportWriter::BuildJson(const FAssetUsageAuditResult& AuditResult)
|
||||
{
|
||||
const FAssetUsageReportHeader& Header = AuditResult.Header;
|
||||
|
||||
FString Output;
|
||||
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
|
||||
|
||||
Writer->WriteObjectStart();
|
||||
|
||||
Writer->WriteObjectStart(TEXT("header"));
|
||||
Writer->WriteValue(TEXT("generatedAt"), Header.GeneratedAt.ToIso8601());
|
||||
Writer->WriteValue(TEXT("project"), Header.ProjectName);
|
||||
Writer->WriteValue(TEXT("engineVersion"), Header.EngineVersion);
|
||||
Writer->WriteValue(TEXT("toolVersion"), Header.ToolVersion);
|
||||
Writer->WriteValue(TEXT("levelsScanned"), Header.LevelsScanned);
|
||||
Writer->WriteValue(TEXT("assetsScanned"), Header.AssetsScanned);
|
||||
Writer->WriteValue(TEXT("rowsReported"), AuditResult.Rows.Num());
|
||||
Writer->WriteValue(TEXT("scanDurationSeconds"), Header.ScanDurationSeconds);
|
||||
|
||||
Writer->WriteArrayStart(TEXT("appliedFilters"));
|
||||
for (const FString& Filter : Header.AppliedFilters)
|
||||
{
|
||||
Writer->WriteValue(Filter);
|
||||
}
|
||||
Writer->WriteArrayEnd();
|
||||
|
||||
Writer->WriteObjectEnd();
|
||||
|
||||
Writer->WriteObjectStart(TEXT("verdictCounts"));
|
||||
Writer->WriteValue(TEXT("usedOnLevel"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel));
|
||||
Writer->WriteValue(TEXT("usedByAssetsOnly"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly));
|
||||
Writer->WriteValue(TEXT("referencedFromConfigOrSource"), AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource));
|
||||
Writer->WriteValue(TEXT("unreferenced"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced));
|
||||
Writer->WriteValue(TEXT("unknown"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
|
||||
Writer->WriteObjectEnd();
|
||||
|
||||
Writer->WriteObjectStart(TEXT("graphStats"));
|
||||
Writer->WriteValue(TEXT("packages"), AuditResult.GraphStats.NumPackages);
|
||||
Writer->WriteValue(TEXT("edges"), AuditResult.GraphStats.NumEdges);
|
||||
Writer->WriteValue(TEXT("levels"), AuditResult.GraphStats.NumLevels);
|
||||
Writer->WriteValue(TEXT("externalPackages"), AuditResult.GraphStats.NumExternalPackages);
|
||||
Writer->WriteValue(TEXT("enumerateSeconds"), AuditResult.GraphStats.EnumerateSeconds);
|
||||
Writer->WriteValue(TEXT("dependencySeconds"), AuditResult.GraphStats.DependencySeconds);
|
||||
Writer->WriteObjectEnd();
|
||||
|
||||
Writer->WriteArrayStart(TEXT("assets"));
|
||||
|
||||
for (const FAssetUsageRow& Row : AuditResult.Rows)
|
||||
{
|
||||
Writer->WriteObjectStart();
|
||||
Writer->WriteValue(TEXT("name"), Row.AssetName.ToString());
|
||||
Writer->WriteValue(TEXT("package"), Row.PackageName.ToString());
|
||||
Writer->WriteValue(TEXT("path"), Row.PathFromProjectRoot);
|
||||
Writer->WriteValue(TEXT("type"), Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
|
||||
|
||||
if (!Row.GeneratedClassPath.IsNull())
|
||||
{
|
||||
Writer->WriteValue(TEXT("generatedClass"), Row.GeneratedClassPath.ToString());
|
||||
}
|
||||
|
||||
Writer->WriteValue(TEXT("verdict"), LexToString(Row.Verdict));
|
||||
Writer->WriteValue(TEXT("provenance"), ProvenanceToString(Row.Provenance));
|
||||
Writer->WriteValue(TEXT("hardReferenceCount"), Row.HardReferenceCount);
|
||||
Writer->WriteValue(TEXT("softReferenceCount"), Row.SoftReferenceCount);
|
||||
|
||||
Writer->WriteArrayStart(TEXT("levels"));
|
||||
for (const FName& Level : Row.Levels)
|
||||
{
|
||||
Writer->WriteValue(Level.ToString());
|
||||
}
|
||||
Writer->WriteArrayEnd();
|
||||
|
||||
if (!Row.Route.IsEmpty())
|
||||
{
|
||||
Writer->WriteValue(TEXT("route"), Row.Route);
|
||||
}
|
||||
|
||||
if (!Row.ProvenanceDetail.IsEmpty())
|
||||
{
|
||||
Writer->WriteValue(TEXT("provenanceDetail"), Row.ProvenanceDetail);
|
||||
}
|
||||
|
||||
Writer->WriteObjectEnd();
|
||||
}
|
||||
|
||||
Writer->WriteArrayEnd();
|
||||
Writer->WriteObjectEnd();
|
||||
Writer->Close();
|
||||
|
||||
return Output;
|
||||
}
|
||||
|
||||
FAssetUsageReportWriter::FResult FAssetUsageReportWriter::Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
|
||||
{
|
||||
FResult Result;
|
||||
|
||||
if (Options.OutputDirectory.IsEmpty())
|
||||
{
|
||||
Result.ErrorMessage = TEXT("Output directory is empty.");
|
||||
return Result;
|
||||
}
|
||||
|
||||
if (!IFileManager::Get().MakeDirectory(*Options.OutputDirectory, true))
|
||||
{
|
||||
if (!IFileManager::Get().DirectoryExists(*Options.OutputDirectory))
|
||||
{
|
||||
Result.ErrorMessage = FString::Printf(TEXT("Could not create output directory '%s'."), *Options.OutputDirectory);
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
|
||||
const FString BaseName = Options.BaseFileName.IsEmpty() ? TEXT("AssetUsageReport") : Options.BaseFileName;
|
||||
|
||||
bool bAnyWritten = false;
|
||||
|
||||
if (Options.bWriteJson)
|
||||
{
|
||||
const FString JsonPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".json"));
|
||||
|
||||
if (FFileHelper::SaveStringToFile(BuildJson(AuditResult), *JsonPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
||||
{
|
||||
Result.JsonPath = JsonPath;
|
||||
bAnyWritten = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *JsonPath);
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
|
||||
if (Options.bWriteCsv)
|
||||
{
|
||||
const FString CsvPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".csv"));
|
||||
|
||||
// Excel needs the BOM to read UTF-8; without it Cyrillic asset names come out mangled.
|
||||
const FFileHelper::EEncodingOptions Encoding = Options.bCsvUtf8Bom
|
||||
? FFileHelper::EEncodingOptions::ForceUTF8
|
||||
: FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM;
|
||||
|
||||
if (FFileHelper::SaveStringToFile(BuildCsv(AuditResult, Options), *CsvPath, Encoding))
|
||||
{
|
||||
Result.CsvPath = CsvPath;
|
||||
bAnyWritten = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *CsvPath);
|
||||
return Result;
|
||||
}
|
||||
}
|
||||
|
||||
Result.bSuccess = bAnyWritten;
|
||||
|
||||
if (!bAnyWritten)
|
||||
{
|
||||
Result.ErrorMessage = TEXT("Both JSON and CSV output were disabled.");
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Report written: %d rows. JSON '%s', CSV '%s'."),
|
||||
AuditResult.Rows.Num(), *Result.JsonPath, *Result.CsvPath);
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "IndirectReferenceScanner.h"
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "HAL/FileManager.h"
|
||||
#include "HAL/PlatformFileManager.h"
|
||||
#include "Misc/FileHelper.h"
|
||||
#include "Misc/Paths.h"
|
||||
#include "Misc/StringBuilder.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
const TCHAR* GameRootLiteral = TEXT("/Game/");
|
||||
|
||||
/**
|
||||
* Characters that can legally continue a package path. Everything else ends the literal.
|
||||
*
|
||||
* Note '.' is included so that an object path like /Game/X/BP_Y.BP_Y_C is captured whole and
|
||||
* then trimmed below; stopping at the dot would work too but would silently accept the
|
||||
* malformed tail of a truncated literal.
|
||||
*/
|
||||
bool IsPathChar(TCHAR Char)
|
||||
{
|
||||
return FChar::IsAlnum(Char) || Char == TEXT('_') || Char == TEXT('/') || Char == TEXT('.') || Char == TEXT('-');
|
||||
}
|
||||
}
|
||||
|
||||
FString FIndirectReference::ToProvenanceString() const
|
||||
{
|
||||
return FString::Printf(TEXT("%s:%d"), *FPaths::GetCleanFilename(SourceFile), LineNumber);
|
||||
}
|
||||
|
||||
void FIndirectReferenceScanner::ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames)
|
||||
{
|
||||
const int32 RootLen = FCString::Strlen(GameRootLiteral);
|
||||
|
||||
int32 SearchStart = 0;
|
||||
while (SearchStart < Line.Len())
|
||||
{
|
||||
const FStringView Remaining = Line.RightChop(SearchStart);
|
||||
const int32 Found = UE::String::FindFirst(Remaining, GameRootLiteral, ESearchCase::IgnoreCase);
|
||||
if (Found == INDEX_NONE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int32 Start = SearchStart + Found;
|
||||
|
||||
int32 End = Start + RootLen;
|
||||
while (End < Line.Len() && IsPathChar(Line[End]))
|
||||
{
|
||||
++End;
|
||||
}
|
||||
|
||||
FString Candidate(Line.Mid(Start, End - Start));
|
||||
SearchStart = End > Start ? End : Start + RootLen;
|
||||
|
||||
// "/Game/" alone, or a trailing slash, is not a reference.
|
||||
if (Candidate.Len() <= RootLen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
while (Candidate.EndsWith(TEXT("/"), ESearchCase::CaseSensitive) || Candidate.EndsWith(TEXT("."), ESearchCase::CaseSensitive))
|
||||
{
|
||||
Candidate.LeftChopInline(1, EAllowShrinking::No);
|
||||
}
|
||||
|
||||
// Object path -> package path. "/Game/X/BP_Y.BP_Y_C" and "/Game/X/T.T" both name package
|
||||
// "/Game/X/...". Take everything before the first dot after the last slash.
|
||||
int32 LastSlash = INDEX_NONE;
|
||||
Candidate.FindLastChar(TEXT('/'), LastSlash);
|
||||
|
||||
int32 DotAfterSlash = INDEX_NONE;
|
||||
if (Candidate.FindChar(TEXT('.'), DotAfterSlash) && DotAfterSlash > LastSlash)
|
||||
{
|
||||
Candidate.LeftInline(DotAfterSlash, EAllowShrinking::No);
|
||||
}
|
||||
|
||||
if (Candidate.Len() <= RootLen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
OutPackageNames.AddUnique(Candidate);
|
||||
}
|
||||
}
|
||||
|
||||
FIndirectReferenceScanner::FOptions FIndirectReferenceScanner::MakeDefaultOptions()
|
||||
{
|
||||
FOptions Options;
|
||||
|
||||
const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
|
||||
Options.Directories.Add(ProjectDir / TEXT("Config"));
|
||||
Options.Directories.Add(ProjectDir / TEXT("Source"));
|
||||
|
||||
return Options;
|
||||
}
|
||||
|
||||
TArray<FIndirectReference> FIndirectReferenceScanner::Scan(const FOptions& Options)
|
||||
{
|
||||
TArray<FIndirectReference> Results;
|
||||
|
||||
IFileManager& FileManager = IFileManager::Get();
|
||||
|
||||
for (const FString& Directory : Options.Directories)
|
||||
{
|
||||
if (!FileManager.DirectoryExists(*Directory))
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: directory '%s' does not exist, skipping."), *Directory);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bool bIsConfigDir = Directory.EndsWith(TEXT("Config"), ESearchCase::IgnoreCase);
|
||||
|
||||
TArray<FString> FoundFiles;
|
||||
FileManager.FindFilesRecursive(FoundFiles, *Directory, TEXT("*.*"), true, false);
|
||||
|
||||
for (const FString& FilePath : FoundFiles)
|
||||
{
|
||||
const FString Extension = FPaths::GetExtension(FilePath, true).ToLower();
|
||||
if (!Options.Extensions.Contains(Extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (FileManager.FileSize(*FilePath) > Options.MaxFileSizeBytes)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: skipping oversized file '%s'."), *FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
TArray<FString> Lines;
|
||||
if (!FFileHelper::LoadFileToStringArray(Lines, *FilePath))
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Indirect scan: could not read '%s'."), *FilePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
TArray<FString> PackageNames;
|
||||
for (int32 LineIndex = 0; LineIndex < Lines.Num(); ++LineIndex)
|
||||
{
|
||||
PackageNames.Reset();
|
||||
ExtractGamePathsFromLine(Lines[LineIndex], PackageNames);
|
||||
|
||||
for (const FString& PackageName : PackageNames)
|
||||
{
|
||||
FIndirectReference& Reference = Results.AddDefaulted_GetRef();
|
||||
Reference.PackageName = FName(*PackageName);
|
||||
Reference.SourceFile = FilePath;
|
||||
Reference.LineNumber = LineIndex + 1;
|
||||
Reference.bFromConfig = bIsConfigDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Indirect scan found %d /Game path literals in Config and Source."), Results.Num());
|
||||
|
||||
return Results;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "LevelUsageResolver.h"
|
||||
|
||||
#include "AssetUsageAuditCoreModule.h"
|
||||
#include "AssetUsageGraph.h"
|
||||
#include "AssetRegistry/ARFilter.h"
|
||||
#include "AssetRegistry/IAssetRegistry.h"
|
||||
#include "Algo/Reverse.h"
|
||||
#include "Engine/Level.h"
|
||||
#include "HAL/PlatformTime.h"
|
||||
#include "Misc/PackageName.h"
|
||||
#include "Misc/StringBuilder.h"
|
||||
|
||||
FLevelUsageResolver::FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry)
|
||||
: Graph(InGraph)
|
||||
, AssetRegistry(InAssetRegistry)
|
||||
{
|
||||
}
|
||||
|
||||
TArray<FName> FLevelUsageResult::GetLevelsForAsset(int32 AssetIndex) const
|
||||
{
|
||||
TArray<FName> Out;
|
||||
|
||||
for (int32 LevelIdx = 0; LevelIdx < LevelReachability.Num(); ++LevelIdx)
|
||||
{
|
||||
const TBitArray<>& Bits = LevelReachability[LevelIdx];
|
||||
if (Bits.IsValidIndex(AssetIndex) && Bits[AssetIndex])
|
||||
{
|
||||
Out.Add(LevelPackageNames[LevelIdx]);
|
||||
}
|
||||
}
|
||||
|
||||
return Out;
|
||||
}
|
||||
|
||||
FLevelUsageResult FLevelUsageResolver::Resolve(const FLevelUsageResolveOptions& Options)
|
||||
{
|
||||
FLevelUsageResult Result;
|
||||
|
||||
const int32 NumPackages = Graph.Num();
|
||||
if (NumPackages == 0)
|
||||
{
|
||||
return Result;
|
||||
}
|
||||
|
||||
// Decide which levels to walk. An explicit list is resolved against the graph so a typo
|
||||
// surfaces as a warning rather than as a silently empty report.
|
||||
TArray<int32> LevelIndices;
|
||||
if (Options.LevelPackages.Num() > 0)
|
||||
{
|
||||
LevelIndices.Reserve(Options.LevelPackages.Num());
|
||||
for (FName LevelPackage : Options.LevelPackages)
|
||||
{
|
||||
const int32 Index = Graph.FindPackageIndex(LevelPackage);
|
||||
if (Index == INDEX_NONE)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level '%s' is not in the graph; skipping."), *LevelPackage.ToString());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Graph.GetNode(Index).bIsLevel)
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Package '%s' is not a World asset; skipping."), *LevelPackage.ToString());
|
||||
continue;
|
||||
}
|
||||
|
||||
LevelIndices.Add(Index);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
LevelIndices = Graph.GetLevelIndices();
|
||||
}
|
||||
|
||||
const int32 NumLevels = LevelIndices.Num();
|
||||
|
||||
UE_LOG(LogAssetUsageAudit, Log, TEXT("Resolving %d level(s); %d requested explicitly, %d present in the graph."),
|
||||
NumLevels, Options.LevelPackages.Num(), Graph.GetLevelIndices().Num());
|
||||
|
||||
Result.LevelPackageNames.Reserve(NumLevels);
|
||||
Result.LevelReachability.Reserve(NumLevels);
|
||||
Result.ReachableFromAnyLevel.Init(false, NumPackages);
|
||||
Result.Provenance.Init(EAssetUsageProvenance::None, NumPackages);
|
||||
Result.HardReferenceCounts.Init(0, NumPackages);
|
||||
Result.SoftReferenceCounts.Init(0, NumPackages);
|
||||
|
||||
// Scratch buffers reused across every level so the BFS allocates nothing per level.
|
||||
TArray<int32> ScratchPredecessor;
|
||||
TArray<int32> ScratchQueue;
|
||||
ScratchPredecessor.SetNumUninitialized(NumPackages);
|
||||
ScratchQueue.Reserve(FMath::Min(NumPackages, 4096));
|
||||
|
||||
const double TraversalStart = FPlatformTime::Seconds();
|
||||
|
||||
for (int32 Ordinal = 0; Ordinal < NumLevels; ++Ordinal)
|
||||
{
|
||||
if (Options.ShouldAbort && Options.ShouldAbort())
|
||||
{
|
||||
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Level sweep aborted after %d of %d levels."), Ordinal, NumLevels);
|
||||
break;
|
||||
}
|
||||
|
||||
if (Options.OnLevelProgress)
|
||||
{
|
||||
Options.OnLevelProgress(Ordinal, NumLevels);
|
||||
}
|
||||
|
||||
const int32 LevelIndex = LevelIndices[Ordinal];
|
||||
|
||||
// The gatherer only reports external packages the registry has already seen. Without this
|
||||
// a level nobody opened this session can report zero actors, which is indistinguishable
|
||||
// from a level that genuinely has none.
|
||||
if (Options.bScanLevelAssetsFirst)
|
||||
{
|
||||
const double ScanStart = FPlatformTime::Seconds();
|
||||
ULevel::ScanLevelAssets(Graph.GetNode(LevelIndex).PackageName.ToString());
|
||||
Result.Stats.ScanLevelAssetsSeconds += FPlatformTime::Seconds() - ScanStart;
|
||||
}
|
||||
|
||||
TBitArray<> Reachable;
|
||||
Reachable.Init(false, NumPackages);
|
||||
|
||||
TraverseLevel(LevelIndex, Options, Reachable, Result, ScratchPredecessor, ScratchQueue);
|
||||
|
||||
Result.LevelPackageNames.Add(Graph.GetNode(LevelIndex).PackageName);
|
||||
Result.LevelReachability.Add(MoveTemp(Reachable));
|
||||
++Result.Stats.LevelsScanned;
|
||||
}
|
||||
|
||||
Result.Stats.TraversalSeconds = FPlatformTime::Seconds() - TraversalStart - Result.Stats.ScanLevelAssetsSeconds;
|
||||
|
||||
for (int32 Index = 0; Index < NumPackages; ++Index)
|
||||
{
|
||||
if (Result.ReachableFromAnyLevel[Index])
|
||||
{
|
||||
++Result.Stats.AssetsReachable;
|
||||
}
|
||||
}
|
||||
|
||||
return Result;
|
||||
}
|
||||
|
||||
void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray<int32>& ScratchPredecessor, TArray<int32>& ScratchQueue)
|
||||
{
|
||||
const int32 NumPackages = Graph.Num();
|
||||
|
||||
if (Options.bRecordRoutes)
|
||||
{
|
||||
for (int32& Pred : ScratchPredecessor)
|
||||
{
|
||||
Pred = INDEX_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
ScratchQueue.Reset();
|
||||
|
||||
const auto Enqueue = [&](int32 Index, int32 FromIndex, EAssetUsageProvenance EdgeProvenance)
|
||||
{
|
||||
if (!Graph.IsValidIndex(Index) || OutReachable[Index])
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
OutReachable[Index] = true;
|
||||
InOutResult.ReachableFromAnyLevel[Index] = true;
|
||||
InOutResult.Provenance[Index] |= EdgeProvenance;
|
||||
|
||||
if (Options.bRecordRoutes)
|
||||
{
|
||||
ScratchPredecessor[Index] = FromIndex;
|
||||
}
|
||||
|
||||
ScratchQueue.Add(Index);
|
||||
};
|
||||
|
||||
Enqueue(LevelIndex, INDEX_NONE, EAssetUsageProvenance::None);
|
||||
|
||||
// Explicit OFPA seeds. Normally redundant with the gatherer's edges, but it costs one registry
|
||||
// filter per level and converts a stale-registry failure from wrong into merely slow.
|
||||
if (Options.bSeedExternalPackages)
|
||||
{
|
||||
TArray<int32> Seeds;
|
||||
GatherExternalPackageSeeds(LevelIndex, Seeds, InOutResult);
|
||||
|
||||
for (int32 SeedIndex : Seeds)
|
||||
{
|
||||
Enqueue(SeedIndex, LevelIndex, EAssetUsageProvenance::ExternalActor | EAssetUsageProvenance::SoftReference);
|
||||
}
|
||||
}
|
||||
|
||||
int32 Head = 0;
|
||||
while (Head < ScratchQueue.Num())
|
||||
{
|
||||
const int32 Current = ScratchQueue[Head++];
|
||||
|
||||
for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Current))
|
||||
{
|
||||
int32 Target = Edge.TargetIndex;
|
||||
if (!Graph.IsValidIndex(Target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A redirector reached on the way to a real asset must be followed through, or the
|
||||
// redirector is reported used and the asset behind it is reported garbage.
|
||||
EAssetUsageProvenance EdgeProvenance = EAssetUsageProvenance::None;
|
||||
if (Graph.GetNode(Target).bIsRedirector)
|
||||
{
|
||||
const int32 Resolved = Graph.ResolveRedirector(Target);
|
||||
if (Resolved != Target)
|
||||
{
|
||||
// Mark the redirector itself reachable too - it is a real package on disk that
|
||||
// the level does depend on, and hiding it would make the report disagree with
|
||||
// what a migrate or a copy actually pulls.
|
||||
Enqueue(Target, Current, EAssetUsageProvenance::Redirector);
|
||||
++InOutResult.Stats.RedirectorsResolved;
|
||||
Target = Resolved;
|
||||
EdgeProvenance |= EAssetUsageProvenance::Redirector;
|
||||
}
|
||||
}
|
||||
|
||||
if (Edge.IsHard())
|
||||
{
|
||||
++InOutResult.HardReferenceCounts[Target];
|
||||
EdgeProvenance |= EAssetUsageProvenance::HardReference;
|
||||
}
|
||||
else
|
||||
{
|
||||
++InOutResult.SoftReferenceCounts[Target];
|
||||
EdgeProvenance |= EAssetUsageProvenance::SoftReference;
|
||||
}
|
||||
|
||||
if (Edge.IsEditorOnly())
|
||||
{
|
||||
EdgeProvenance |= EAssetUsageProvenance::EditorOnly;
|
||||
}
|
||||
|
||||
const FAssetUsageNode& TargetNode = Graph.GetNode(Target);
|
||||
if (TargetNode.bIsExternalPackage)
|
||||
{
|
||||
EdgeProvenance |= EAssetUsageProvenance::ExternalActor;
|
||||
}
|
||||
|
||||
// Reaching another World is NOT automatically a sublevel. An earlier version assumed it
|
||||
// was, and on WP_Main that assumption pulled the whole of L_MainLevel in through
|
||||
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a
|
||||
// different map. A crossing only counts when the reference comes from the level package
|
||||
// itself or from one of its external actor packages, which is the shape a streaming
|
||||
// sublevel or a Level Instance actually has.
|
||||
bool bStopAtForeignLevel = false;
|
||||
|
||||
if (TargetNode.bIsLevel && Target != LevelIndex)
|
||||
{
|
||||
const FAssetUsageNode& SourceNode = Graph.GetNode(Current);
|
||||
const bool bCrossingIsStructural = SourceNode.bIsLevel || SourceNode.bIsExternalPackage;
|
||||
|
||||
if (bCrossingIsStructural)
|
||||
{
|
||||
EdgeProvenance |= EAssetUsageProvenance::Sublevel;
|
||||
}
|
||||
else if (!Options.bTraverseIntoOtherLevels)
|
||||
{
|
||||
// Record the foreign map as referenced - it genuinely is - but attribute its
|
||||
// contents to itself. Its own sweep reports them.
|
||||
bStopAtForeignLevel = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate provenance even when already visited: an asset reached by both a hard and
|
||||
// a soft path should report both, otherwise the columns depend on BFS visit order.
|
||||
if (OutReachable[Target])
|
||||
{
|
||||
InOutResult.Provenance[Target] |= EdgeProvenance;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bStopAtForeignLevel)
|
||||
{
|
||||
// Mark it reachable without queueing it, so the BFS never expands its dependencies.
|
||||
OutReachable[Target] = true;
|
||||
InOutResult.ReachableFromAnyLevel[Target] = true;
|
||||
InOutResult.Provenance[Target] |= EdgeProvenance;
|
||||
|
||||
if (Options.bRecordRoutes)
|
||||
{
|
||||
ScratchPredecessor[Target] = Current;
|
||||
InOutResult.Routes.FindOrAdd(Target, BuildRouteString(LevelIndex, Target, ScratchPredecessor, Options.MaxRouteHops));
|
||||
}
|
||||
|
||||
++InOutResult.Stats.ForeignLevelsNotExpanded;
|
||||
continue;
|
||||
}
|
||||
|
||||
Enqueue(Target, Current, EdgeProvenance);
|
||||
}
|
||||
}
|
||||
|
||||
if (Options.bRecordRoutes)
|
||||
{
|
||||
// Walk the queue, not the whole index space: the queue already holds exactly the nodes this
|
||||
// level reached. Scanning all packages per level would be 80k x 1207 iterations for nothing.
|
||||
for (int32 Index : ScratchQueue)
|
||||
{
|
||||
if (InOutResult.Routes.Contains(Index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
InOutResult.Routes.Add(Index, BuildRouteString(LevelIndex, Index, ScratchPredecessor, Options.MaxRouteHops));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FLevelUsageResolver::GatherExternalPackageSeeds(int32 LevelIndex, TArray<int32>& OutSeeds, FLevelUsageResult& InOutResult) const
|
||||
{
|
||||
OutSeeds.Reset();
|
||||
|
||||
const FString LevelPackageName = Graph.GetNode(LevelIndex).PackageName.ToString();
|
||||
|
||||
// The plural form runs registered path-provider delegates, so plugins that mount their own
|
||||
// external-actor roots are covered. Never hardcode "__ExternalActors__" here.
|
||||
TArray<FString> ExternalPaths = ULevel::GetExternalActorsPaths(LevelPackageName);
|
||||
ExternalPaths.Append(ULevel::GetExternalObjectsPaths(LevelPackageName));
|
||||
|
||||
if (ExternalPaths.Num() == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FARFilter Filter;
|
||||
Filter.bRecursivePaths = true;
|
||||
Filter.bIncludeOnlyOnDiskAssets = true;
|
||||
Filter.PackagePaths.Reserve(ExternalPaths.Num());
|
||||
|
||||
for (const FString& Path : ExternalPaths)
|
||||
{
|
||||
Filter.PackagePaths.Add(FName(*Path));
|
||||
}
|
||||
|
||||
AssetRegistry.EnumerateAssets(Filter, [this, &OutSeeds, &InOutResult](const FAssetData& AssetData)
|
||||
{
|
||||
const int32 Index = Graph.FindPackageIndex(AssetData.PackageName);
|
||||
if (Index != INDEX_NONE)
|
||||
{
|
||||
OutSeeds.AddUnique(Index);
|
||||
++InOutResult.Stats.ExternalPackagesSeeded;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
FString FLevelUsageResolver::BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray<int32>& Predecessor, int32 MaxHops) const
|
||||
{
|
||||
TArray<int32> Chain;
|
||||
|
||||
int32 Current = AssetIndex;
|
||||
while (Current != INDEX_NONE && Chain.Num() < 1024)
|
||||
{
|
||||
Chain.Add(Current);
|
||||
if (Current == LevelIndex)
|
||||
{
|
||||
break;
|
||||
}
|
||||
Current = Predecessor.IsValidIndex(Current) ? Predecessor[Current] : INDEX_NONE;
|
||||
}
|
||||
|
||||
Algo::Reverse(Chain);
|
||||
|
||||
const auto NameAt = [this](int32 Index)
|
||||
{
|
||||
return Graph.GetNode(Index).AssetName.IsNone()
|
||||
? Graph.GetNode(Index).PackageName.ToString()
|
||||
: Graph.GetNode(Index).AssetName.ToString();
|
||||
};
|
||||
|
||||
if (MaxHops > 0 && Chain.Num() > MaxHops)
|
||||
{
|
||||
// Keep both ends: the level explains scope, the tail explains the asset. The middle is
|
||||
// where an artist stops reading anyway.
|
||||
const int32 HeadCount = MaxHops / 2;
|
||||
const int32 TailCount = MaxHops - HeadCount;
|
||||
|
||||
TStringBuilder<512> Builder;
|
||||
for (int32 i = 0; i < HeadCount; ++i)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
Builder << TEXT(" -> ");
|
||||
}
|
||||
Builder << NameAt(Chain[i]);
|
||||
}
|
||||
|
||||
Builder << TEXT(" -> ... (") << (Chain.Num() - MaxHops) << TEXT(" more) -> ");
|
||||
|
||||
for (int32 i = Chain.Num() - TailCount; i < Chain.Num(); ++i)
|
||||
{
|
||||
if (i > Chain.Num() - TailCount)
|
||||
{
|
||||
Builder << TEXT(" -> ");
|
||||
}
|
||||
Builder << NameAt(Chain[i]);
|
||||
}
|
||||
|
||||
return Builder.ToString();
|
||||
}
|
||||
|
||||
TStringBuilder<512> Builder;
|
||||
for (int32 i = 0; i < Chain.Num(); ++i)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
Builder << TEXT(" -> ");
|
||||
}
|
||||
Builder << NameAt(Chain[i]);
|
||||
}
|
||||
|
||||
return Builder.ToString();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
|
||||
/**
|
||||
* Everything a set of packages needs in order to be usable somewhere else.
|
||||
*
|
||||
* Exists because "export the ticked meshes" without this produces meshes that open pink: the
|
||||
* package file of a StaticMesh holds no materials and no textures, only references to them. The
|
||||
* same applies to a Niagara system and its sprites, and to a Blueprint and everything it spawns.
|
||||
*
|
||||
* Deliberately a separate step rather than something FAssetUsageExporter does internally. The
|
||||
* exporter stays a dumb file copier that needs no Asset Registry and is testable without one, and
|
||||
* the caller keeps the chance to show "40 ticked, 380 will be written" before anything is copied -
|
||||
* which matters, because that ratio surprises people.
|
||||
*
|
||||
* This is NOT IAssetTools::MigratePackages. Migrate walks the same closure but also decides where
|
||||
* files go, prompts, and cannot be told to stop at a folder boundary. Here the traversal is bounded
|
||||
* by the same exclusion list the audit uses, and engine content is left out by default because the
|
||||
* destination project already has it.
|
||||
*/
|
||||
namespace AssetDependencyClosure
|
||||
{
|
||||
struct FOptions
|
||||
{
|
||||
/**
|
||||
* Package prefixes not to descend into, in either /Game/... or Content/... form.
|
||||
* Normally the audit's own exclusion list, so an export cannot pull in content the report
|
||||
* deliberately ignores.
|
||||
*/
|
||||
TArray<FString> ExcludePackagePaths;
|
||||
|
||||
/**
|
||||
* Include /Engine and /Temp packages in the result.
|
||||
*
|
||||
* Off by default. A mesh using DefaultMaterial genuinely depends on /Engine content, but
|
||||
* copying it into the export folder is almost never what someone wants - the destination
|
||||
* project ships the same file, and overwriting it there is worse than useless.
|
||||
*/
|
||||
bool bIncludeEnginePackages = false;
|
||||
|
||||
/**
|
||||
* Stop after this many hops from a seed. Zero means no limit.
|
||||
*
|
||||
* A limit is a blunt instrument and changes the answer rather than just shortening it, so it
|
||||
* is off by default. It exists for the "just the materials, not the whole graph" case.
|
||||
*/
|
||||
int32 MaxDepth = 0;
|
||||
|
||||
/** Called as (Visited, Queued). Return false to stop; the partial result is still returned. */
|
||||
TFunction<bool(int32, int32)> OnProgress;
|
||||
};
|
||||
|
||||
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
|
||||
struct ASSETUSAGEAUDITCORE_API FStats
|
||||
{
|
||||
/** Seeds that were valid package names to begin with. */
|
||||
int32 SeedCount = 0;
|
||||
|
||||
/** Size of the returned array, seeds included. */
|
||||
int32 TotalCount = 0;
|
||||
|
||||
/** Reached but dropped, per reason. Kept apart so a surprising result can be explained. */
|
||||
int32 SkippedScript = 0;
|
||||
int32 SkippedEngine = 0;
|
||||
int32 SkippedExcluded = 0;
|
||||
|
||||
/**
|
||||
* Maps reached through an ordinary asset rather than through a level, and therefore not
|
||||
* followed - nor included.
|
||||
*
|
||||
* The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main
|
||||
* the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994
|
||||
* packages belonging to a different map. Without this the export dialog would quote an
|
||||
* honest number for a wrong set.
|
||||
*
|
||||
* Excluded rather than merely not expanded, unlike in the audit. The exporter expands any
|
||||
* .umap it is handed into its One File Per Actor packages, so including the map file would
|
||||
* pull the foreign level's contents back in through the exporter instead of the closure.
|
||||
*/
|
||||
int32 ForeignLevelsSkipped = 0;
|
||||
|
||||
/** Deepest hop count actually reached. Tells you whether MaxDepth did anything. */
|
||||
int32 DeepestHop = 0;
|
||||
|
||||
bool bStoppedEarly = false;
|
||||
|
||||
FString Summarise() const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeds plus everything they depend on, transitively.
|
||||
*
|
||||
* Seeds come first and in their original order, so a caller can still tell what was asked for.
|
||||
* The traversal uses AssetUsageAudit::MakeTraversalQuery() - package category, no requirements -
|
||||
* for the same reason the audit does: a Hard-only query silently drops every soft edge, which
|
||||
* includes every One File Per Actor package and every TSoftObjectPtr a Blueprint resolves.
|
||||
*/
|
||||
/** One ticked asset and everything it pulls in, the seed first. */
|
||||
struct FSeedClosure
|
||||
{
|
||||
FName Seed;
|
||||
TArray<FName> Packages;
|
||||
};
|
||||
|
||||
/**
|
||||
* Closure per seed, rather than one closure over all of them.
|
||||
*
|
||||
* Needed by the folder-per-asset layout: a texture shared by forty meshes has to appear in all
|
||||
* forty folders, and the flat Gather deliberately returns it once. Costs one traversal per
|
||||
* seed, so it is the slower call by construction - use Gather when a single list will do.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API TArray<FSeedClosure> GatherPerSeed(
|
||||
IAssetRegistry& Registry,
|
||||
const TArray<FName>& Seeds,
|
||||
const FOptions& Options,
|
||||
FStats& OutStats);
|
||||
|
||||
ASSETUSAGEAUDITCORE_API TArray<FName> Gather(
|
||||
IAssetRegistry& Registry,
|
||||
const TArray<FName>& Seeds,
|
||||
const FOptions& Options,
|
||||
FStats& OutStats);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetDependencyClosure.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "AssetUsageExporter.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
|
||||
/**
|
||||
* Decides where each exported file goes.
|
||||
*
|
||||
* Deliberately separate from FAssetUsageExporter. The exporter's job is to copy a package to a
|
||||
* path and nothing else - it has no idea what a preset or a dependency is, and keeping it that way
|
||||
* is why its tests can run against invented package names. Everything that needs the Asset
|
||||
* Registry, the class hierarchy or the dependency closure lives here instead, and hands the
|
||||
* exporter a finished list of "these packages, into this subfolder".
|
||||
*/
|
||||
namespace AssetExportLayout
|
||||
{
|
||||
/**
|
||||
* A set of packages destined for one subfolder of the export target.
|
||||
*
|
||||
* An alias, not a second struct: this namespace exists to produce exactly what the exporter
|
||||
* consumes, and two identical types would mean a conversion loop whose only job is to prove
|
||||
* they stayed identical.
|
||||
*/
|
||||
using FGroup = FAssetUsageExporter::FExportGroup;
|
||||
|
||||
struct FOptions
|
||||
{
|
||||
EExportLayout Layout = EExportLayout::Flat;
|
||||
|
||||
/**
|
||||
* Sort each asset's dependencies into per-type subfolders: Texture/, Material/, and so on.
|
||||
*
|
||||
* Only meaningful with FolderPerAsset - there is no "each asset's dependencies" to sort in
|
||||
* the other layouts. BuildGroups ignores it rather than inventing a meaning.
|
||||
*
|
||||
* The seed itself stays at the root of its own folder. It is the subject of that folder;
|
||||
* filing it under StaticMesh/ next to its own dependencies would bury it.
|
||||
*/
|
||||
bool bGroupDependenciesByType = false;
|
||||
};
|
||||
|
||||
struct ASSETUSAGEAUDITCORE_API FStats
|
||||
{
|
||||
int32 GroupCount = 0;
|
||||
|
||||
/** Distinct packages across every group. */
|
||||
int32 DistinctPackages = 0;
|
||||
|
||||
/**
|
||||
* Total placements, counting a shared dependency once per folder it lands in.
|
||||
*
|
||||
* This, not DistinctPackages, is the number of files that will be written. Under
|
||||
* FolderPerAsset the two differ sharply and the user has to be told which one they are
|
||||
* looking at before the copy starts.
|
||||
*/
|
||||
int32 FilePlacements = 0;
|
||||
|
||||
/** Folder names that collided and were given a numeric suffix. */
|
||||
int32 RenamedFolders = 0;
|
||||
|
||||
FString Summarise() const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the export groups.
|
||||
*
|
||||
* @param SeedClosures For FolderPerAsset, one entry per ticked asset from GatherPerSeed. For
|
||||
* the other layouts only the union of Packages is used, so a single
|
||||
* closure covering everything is enough.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API TArray<FGroup> BuildGroups(
|
||||
IAssetRegistry& Registry,
|
||||
const TArray<AssetDependencyClosure::FSeedClosure>& SeedClosures,
|
||||
const FOptions& Options,
|
||||
FStats& OutStats);
|
||||
|
||||
/**
|
||||
* Map every class the type presets cover, and their subclasses, to a folder name.
|
||||
*
|
||||
* Folder names are the preset names verbatim - Texture, Material, StaticMesh - rather than a
|
||||
* second set of invented labels. The Types filter in the panel already shows those words, so
|
||||
* the folders a user gets match the words they filtered by.
|
||||
*
|
||||
* Built once per export: expanding the class hierarchy costs a registry call per preset.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API TMap<FTopLevelAssetPath, FString> BuildTypeFolderMap(IAssetRegistry& Registry);
|
||||
|
||||
/**
|
||||
* Folder name for one package, using a map from BuildTypeFolderMap.
|
||||
* Returns "Other" for anything no preset covers, never an empty string - an empty name would
|
||||
* silently put the file in the parent folder and look like the grouping had failed.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString TypeFolderForPackage(
|
||||
IAssetRegistry& Registry,
|
||||
FName PackageName,
|
||||
const TMap<FTopLevelAssetPath, FString>& FolderByClass);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "AssetUsageExporter.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
|
||||
/**
|
||||
* The JSON written beside a reference-breaking export.
|
||||
*
|
||||
* Why it exists: references inside a .uasset are stored as full package names, so a copied file
|
||||
* only resolves when it sits at exactly that package path in the destination. The Flat and
|
||||
* FolderPerAsset layouts deliberately do not put it there - they arrange files for a person to read
|
||||
* and hand around - so the reference graph is lost the moment the files leave the project.
|
||||
*
|
||||
* The manifest records that graph next to the files, so a later import can rebuild it rather than
|
||||
* guess. It is written from what the exporter actually wrote, never from what it intended to write:
|
||||
* the two differ exactly when the collision policy renamed something, which is the case a manifest
|
||||
* has to get right.
|
||||
*
|
||||
* Migrate needs none of this - the engine preserves the paths itself - and asking for a manifest
|
||||
* there is treated as a caller mistake rather than silently producing a misleading file.
|
||||
*/
|
||||
namespace AssetExportManifest
|
||||
{
|
||||
/** Bumped when the schema changes in a way a reader must notice. */
|
||||
inline constexpr int32 SchemaVersion = 1;
|
||||
|
||||
/** Manifest filename, written into the export root. */
|
||||
inline const TCHAR* FileName = TEXT("AssetUsageAudit.manifest.json");
|
||||
|
||||
struct FOptions
|
||||
{
|
||||
/** Export root. The manifest is written here and paths are relative to it. */
|
||||
FString TargetDirectory;
|
||||
|
||||
/** Recorded so a reader knows why the paths look the way they do. */
|
||||
EExportLayout Layout = EExportLayout::Flat;
|
||||
|
||||
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
|
||||
|
||||
/**
|
||||
* Packages the user ticked, as opposed to those pulled in as dependencies.
|
||||
*
|
||||
* Kept apart because an importer needs to know which assets were the point and which came
|
||||
* along to make them work - "restore what I exported" and "restore everything in this
|
||||
* folder" are different requests.
|
||||
*/
|
||||
TSet<FName> SeedPackages;
|
||||
};
|
||||
|
||||
struct ASSETUSAGEAUDITCORE_API FResult
|
||||
{
|
||||
bool bSuccess = false;
|
||||
|
||||
/** Absolute path of the manifest, empty on failure. */
|
||||
FString FilePath;
|
||||
|
||||
int32 EntriesWritten = 0;
|
||||
|
||||
/** Dependency edges recorded across all entries. */
|
||||
int32 EdgesRecorded = 0;
|
||||
|
||||
FString ErrorMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Write the manifest.
|
||||
*
|
||||
* @param WrittenFiles From FAssetUsageExporter::FResult::WrittenFiles, which requires the
|
||||
* exporter to have been run with bRecordWrittenFiles. An empty array is a
|
||||
* failure rather than an empty manifest: it almost always means the flag
|
||||
* was forgotten, and an empty manifest beside a full folder is worse than
|
||||
* no manifest at all.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FResult Write(
|
||||
IAssetRegistry& Registry,
|
||||
const TArray<FAssetUsageExporter::FWrittenFile>& WrittenFiles,
|
||||
const FOptions& Options);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
|
||||
/**
|
||||
* Collision-handling for export filenames.
|
||||
*
|
||||
* Deliberately a free function over strings with an injectable existence predicate: the rule
|
||||
* ("increment an existing index, do not append a second one") is fiddly on real asset names like
|
||||
* SM_Rock_02_v3, and it must be unit-testable without touching a filesystem or an editor.
|
||||
*/
|
||||
namespace AssetExportNaming
|
||||
{
|
||||
/** Predicate answering "does this filename already exist in the target folder". */
|
||||
using FExistsPredicate = TFunctionRef<bool(const FString& /*FileName*/)>;
|
||||
|
||||
/**
|
||||
* Split a base name into stem and trailing numeric index.
|
||||
*
|
||||
* "Foo" -> {"Foo", INDEX_NONE, 0}
|
||||
* "Foo_1" -> {"Foo", 1, 1}
|
||||
* "Foo_007" -> {"Foo", 7, 3} <- padding width preserved
|
||||
* "SM_Rock_02_v3"-> {"SM_Rock_02_v3", INDEX_NONE, 0} <- v3 is not a numeric suffix
|
||||
* "SM_Rock_02" -> {"SM_Rock", 2, 2}
|
||||
* "Foo_" -> {"Foo_", INDEX_NONE, 0} <- empty suffix is not an index
|
||||
*
|
||||
* @param BaseName Filename without extension.
|
||||
* @param OutStem Portion before the trailing _N, or the whole name when there is none.
|
||||
* @param OutIndex Parsed index, or INDEX_NONE.
|
||||
* @param OutPadWidth Digit count of the parsed index, so Foo_007 -> Foo_008 not Foo_8.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth);
|
||||
|
||||
/**
|
||||
* Compose a filename from stem, index and padding.
|
||||
* (Foo, INDEX_NONE, 0) -> "Foo"
|
||||
* (Foo, 1, 1) -> "Foo_1"
|
||||
* (Foo, 8, 3) -> "Foo_008"
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth);
|
||||
|
||||
/**
|
||||
* Resolve a target filename under the given collision policy.
|
||||
*
|
||||
* Overwrite -> returns DesiredFileName unchanged.
|
||||
* Index -> returns the first non-colliding name, incrementing any existing trailing index.
|
||||
*
|
||||
* @param DesiredFileName Filename with extension, e.g. "SM_Rock.uasset".
|
||||
* @param Policy Overwrite or Index.
|
||||
* @param Exists Predicate over filenames (with extension) in the target folder.
|
||||
* @return Filename with extension that does not collide, or DesiredFileName under Overwrite.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
struct FAssetUsageNode;
|
||||
|
||||
/**
|
||||
* Named groups of asset classes, so an artist picks "VFX" rather than typing a class path.
|
||||
*/
|
||||
enum class EAssetTypePreset : uint8
|
||||
{
|
||||
StaticMesh,
|
||||
SkeletalMesh,
|
||||
Material,
|
||||
Texture,
|
||||
VFX,
|
||||
Sound,
|
||||
Blueprint,
|
||||
Level,
|
||||
DataAsset,
|
||||
Animation
|
||||
};
|
||||
|
||||
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetTypePreset Preset);
|
||||
|
||||
/** Every preset, in the order the UI should list them. */
|
||||
ASSETUSAGEAUDITCORE_API TArray<EAssetTypePreset> GetAllAssetTypePresets();
|
||||
|
||||
/** Class paths a preset stands for, before subclass expansion. */
|
||||
ASSETUSAGEAUDITCORE_API TArray<FTopLevelAssetPath> GetPresetClassPaths(EAssetTypePreset Preset);
|
||||
|
||||
/**
|
||||
* Matches assets by class, with the two things that make class filtering work in this project.
|
||||
*
|
||||
* 1. Subclass expansion via GetDerivedClassNames, so "Material" also matches MaterialInstanceConstant.
|
||||
*
|
||||
* 2. Blueprint awareness. A BP asset's own class is always /Script/Engine.Blueprint, so filtering on
|
||||
* class alone finds no Blueprints at all - fatal in a project whose gameplay is entirely Blueprints.
|
||||
* The gameplay class lives in the GeneratedClass registry tag, captured per node during the sweep,
|
||||
* and a node matches when EITHER its class or its generated class is in the expanded set.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FAssetTypeFilter
|
||||
{
|
||||
public:
|
||||
/** Empty filter matches everything. */
|
||||
FAssetTypeFilter() = default;
|
||||
|
||||
void AddPreset(EAssetTypePreset Preset);
|
||||
|
||||
/** Raw class path, e.g. "/Script/Engine.StaticMesh" or a short name like "StaticMesh". */
|
||||
void AddRawClass(const FString& ClassPathOrName);
|
||||
|
||||
/**
|
||||
* Expand every added class to include its subclasses.
|
||||
* Must be called after the Add* calls and before Matches.
|
||||
*/
|
||||
void Compile(IAssetRegistry& AssetRegistry);
|
||||
|
||||
bool IsEmpty() const
|
||||
{
|
||||
return RequestedClasses.IsEmpty();
|
||||
}
|
||||
|
||||
bool Matches(const FAssetUsageNode& Node) const;
|
||||
|
||||
/** Human-readable description for the report header. */
|
||||
FString Describe() const;
|
||||
|
||||
/** Raw class strings that could not be resolved to a real class. */
|
||||
TConstArrayView<FString> GetUnresolvedClasses() const
|
||||
{
|
||||
return UnresolvedClasses;
|
||||
}
|
||||
|
||||
private:
|
||||
TArray<FTopLevelAssetPath> RequestedClasses;
|
||||
TArray<FString> RequestedDescriptions;
|
||||
TArray<FString> UnresolvedClasses;
|
||||
|
||||
TSet<FTopLevelAssetPath> ExpandedClasses;
|
||||
bool bCompiled = false;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
ASSETUSAGEAUDITCORE_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAudit, Log, All);
|
||||
|
||||
/**
|
||||
* Analysis-only module. Deliberately links no UI and no editor framework:
|
||||
* every dependency here is available in a commandlet and in a standalone Program,
|
||||
* which is what keeps a future headless mode a packaging question rather than a rewrite.
|
||||
*/
|
||||
class FAssetUsageAuditCoreModule : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
virtual void StartupModule() override;
|
||||
virtual void ShutdownModule() override;
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Misc/AssetRegistryInterface.h"
|
||||
|
||||
// EExportLayout is a UENUM so the editor settings can expose it as a dropdown. It is the only
|
||||
// reflected type in this module; everything else here stays plain C++ so the analysis has no
|
||||
// reflection cost. Must be the last include, as UHT requires.
|
||||
#include "AssetUsageAuditTypes.generated.h"
|
||||
|
||||
/**
|
||||
* Why an asset is considered used - or why we cannot tell.
|
||||
*
|
||||
* Deliberately five states, never a bool. The Asset Registry cannot see string-built paths,
|
||||
* OpenLevel(FName), DataTable row contents or FMOD event references; reporting those as
|
||||
* "unused" is how an audit tool causes a deletion incident. Unknown is a legitimate answer.
|
||||
*/
|
||||
enum class EAssetUsageVerdict : uint8
|
||||
{
|
||||
/** Reachable from at least one level package. Columns say through which, and hard or soft. */
|
||||
UsedOnLevel,
|
||||
|
||||
/** Has referencers, but no chain from any level reaches it. */
|
||||
UsedByAssetsOnly,
|
||||
|
||||
/** Found by scanning Config/ and Source/ for /Game paths. Provenance carries file and line. */
|
||||
ReferencedFromConfigOrSource,
|
||||
|
||||
/** GetReferencers returned nothing and no level reaches it. */
|
||||
Unreferenced,
|
||||
|
||||
/** Falls into a known registry blind spot. Never present this as "safe to delete". */
|
||||
Unknown
|
||||
};
|
||||
|
||||
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EAssetUsageVerdict Verdict);
|
||||
|
||||
/**
|
||||
* Every verdict, in the order the UI should list them.
|
||||
*
|
||||
* Single source of truth: the menu, the report and the settings restore all need this list, and
|
||||
* three hand-written copies would drift the moment a sixth verdict is added.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API TArray<EAssetUsageVerdict> GetAllAssetUsageVerdicts();
|
||||
|
||||
/** How an asset reference was discovered. Bitmask - an asset can be reached several ways. */
|
||||
enum class EAssetUsageProvenance : uint8
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/** Direct hard dependency edge (EDependencyProperty::Hard present). */
|
||||
HardReference = 1 << 0,
|
||||
|
||||
/** Soft dependency edge - the lack of Hard. Includes every external actor edge. */
|
||||
SoftReference = 1 << 1,
|
||||
|
||||
/** Reached through an OFPA __ExternalActors__ / __ExternalObjects__ package. */
|
||||
ExternalActor = 1 << 2,
|
||||
|
||||
/** Reached through ULevelStreaming::WorldAsset or ALevelInstance::WorldAsset. */
|
||||
Sublevel = 1 << 3,
|
||||
|
||||
/** Path literal found in Config/*.ini. */
|
||||
ConfigFile = 1 << 4,
|
||||
|
||||
/** Path literal found in Source/**.cpp|h. */
|
||||
SourceFile = 1 << 5,
|
||||
|
||||
/** Edge is editor-only (EDependencyProperty::Game absent) - not shipped, but still a use. */
|
||||
EditorOnly = 1 << 6,
|
||||
|
||||
/** Reached through a redirector that we resolved. */
|
||||
Redirector = 1 << 7
|
||||
};
|
||||
|
||||
ENUM_CLASS_FLAGS(EAssetUsageProvenance);
|
||||
|
||||
ASSETUSAGEAUDITCORE_API FString ProvenanceToString(EAssetUsageProvenance Provenance);
|
||||
|
||||
/**
|
||||
* How exported files are arranged under the target folder.
|
||||
*
|
||||
* Deliberately an enum rather than a set of booleans. These are mutually exclusive: "mirror the
|
||||
* /Game tree" and "one folder per ticked asset" cannot both be true, and a pair of booleans would
|
||||
* make that contradiction expressible and then silently resolve it one way.
|
||||
*/
|
||||
UENUM()
|
||||
enum class EExportLayout : uint8
|
||||
{
|
||||
/** Everything straight into the target folder. This is why the collision policy exists. */
|
||||
Flat UMETA(DisplayName = "Flat - everything in one folder"),
|
||||
|
||||
/** Mirror the package path, so /Game/Art/SM_Rock lands in Game/Art/SM_Rock.uasset. */
|
||||
MirrorTree UMETA(DisplayName = "Mirror the /Game folder tree"),
|
||||
|
||||
/**
|
||||
* A subfolder per ticked asset, with that asset's dependencies beside it.
|
||||
*
|
||||
* Note the consequence: a texture shared by forty meshes is copied forty times, once into each
|
||||
* mesh's folder. That is the point - each folder is self-contained and can be handed over on
|
||||
* its own - but it means the file count exceeds the number of distinct assets, sometimes by a
|
||||
* lot. The export dialog states the real figure before anything is written.
|
||||
*/
|
||||
FolderPerAsset,
|
||||
|
||||
/**
|
||||
* Hand the packages to IAssetTools::MigratePackages, aimed at another project's Content folder.
|
||||
*
|
||||
* The only layout whose output opens in Unreal with its references intact. References inside a
|
||||
* .uasset are stored as full package names, so a file only resolves when it sits at exactly the
|
||||
* same package path in the destination; Migrate is the engine's own code for arranging that,
|
||||
* including the OFPA actor packages of a level.
|
||||
*
|
||||
* Not implemented in this module. AssetTools is editor-only, and the analysis here has to stay
|
||||
* runnable from a commandlet - the editor module dispatches this value to its own path. Anything
|
||||
* in Core that switches on the layout must therefore treat Migrate as "not mine".
|
||||
*/
|
||||
Migrate
|
||||
};
|
||||
|
||||
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(EExportLayout Layout);
|
||||
|
||||
/**
|
||||
* What to do when an exported file already exists in the target folder.
|
||||
*/
|
||||
enum class EExportCollisionPolicy : uint8
|
||||
{
|
||||
/** Replace the existing file. */
|
||||
Overwrite,
|
||||
|
||||
/** Append or increment a numeric suffix: Foo -> Foo_1, Foo_7 -> Foo_8. */
|
||||
Index
|
||||
};
|
||||
|
||||
/**
|
||||
* One row of the audit result.
|
||||
*/
|
||||
struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
|
||||
{
|
||||
/** Package name, e.g. /Game/Space/Art/SM_Rock. */
|
||||
FName PackageName;
|
||||
|
||||
/** Asset name without path. */
|
||||
FName AssetName;
|
||||
|
||||
/** Class path of the asset, e.g. /Script/Engine.StaticMesh. */
|
||||
FTopLevelAssetPath ClassPath;
|
||||
|
||||
/**
|
||||
* For Blueprints, the generated gameplay class from the GeneratedClass tag.
|
||||
* A BP asset's ClassPath is always /Script/Engine.Blueprint, which is useless for filtering.
|
||||
*/
|
||||
FTopLevelAssetPath GeneratedClassPath;
|
||||
|
||||
/** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */
|
||||
FString PathFromProjectRoot;
|
||||
|
||||
EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown;
|
||||
|
||||
EAssetUsageProvenance Provenance = EAssetUsageProvenance::None;
|
||||
|
||||
/** Levels this asset is reachable from. Includes both the sublevel and its parent map. */
|
||||
TArray<FName> Levels;
|
||||
|
||||
/** How many incoming edges carried EDependencyProperty::Hard. */
|
||||
int32 HardReferenceCount = 0;
|
||||
|
||||
/** How many incoming edges lacked Hard. */
|
||||
int32 SoftReferenceCount = 0;
|
||||
|
||||
/**
|
||||
* Human-readable chain explaining the verdict, e.g.
|
||||
* "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
|
||||
* A verdict without a route is unactionable for an artist.
|
||||
*/
|
||||
FString Route;
|
||||
|
||||
/** For ReferencedFromConfigOrSource - which file and line named this asset. */
|
||||
FString ProvenanceDetail;
|
||||
|
||||
bool IsUsedOnAnyLevel() const
|
||||
{
|
||||
return Verdict == EAssetUsageVerdict::UsedOnLevel;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata written into the report header so a stale report can never be mistaken for a fresh one.
|
||||
*/
|
||||
struct ASSETUSAGEAUDITCORE_API FAssetUsageReportHeader
|
||||
{
|
||||
FDateTime GeneratedAt;
|
||||
FString EngineVersion;
|
||||
FString ToolVersion;
|
||||
FString ProjectName;
|
||||
|
||||
/** Human-readable description of the filters that produced this result. */
|
||||
TArray<FString> AppliedFilters;
|
||||
|
||||
int32 LevelsScanned = 0;
|
||||
int32 AssetsScanned = 0;
|
||||
double ScanDurationSeconds = 0.0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dependency query this tool must always use.
|
||||
*
|
||||
* Package category, NoRequirements flags. NOT Hard.
|
||||
*
|
||||
* Two engine facts make this non-negotiable:
|
||||
*
|
||||
* 1. FExternalObjectAndActorDependencyGatherer (ExternalObjectAndActorDependencyGatherer.cpp:22)
|
||||
* emits map -> external actor edges with property mask Game|Build. Hard is absent, and
|
||||
* AssetRegistryInterface.h:95 states the lack of Hard *is* a soft dependency. Querying with
|
||||
* Hard therefore drops every external actor - 16126 packages in this project.
|
||||
*
|
||||
* 2. In EDependencyQuery, Soft is literally defined as NotHard. So Hard|Soft means
|
||||
* "require Hard AND require not-Hard" and matches nothing at all.
|
||||
*
|
||||
* Hard vs soft is a column in the report, never a filter on the query.
|
||||
*/
|
||||
namespace AssetUsageAudit
|
||||
{
|
||||
inline UE::AssetRegistry::FDependencyQuery MakeTraversalQuery()
|
||||
{
|
||||
return UE::AssetRegistry::FDependencyQuery();
|
||||
}
|
||||
|
||||
inline UE::AssetRegistry::EDependencyCategory MakeTraversalCategory()
|
||||
{
|
||||
return UE::AssetRegistry::EDependencyCategory::Package;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "AssetTypeFilter.h"
|
||||
#include "AssetUsageGraph.h"
|
||||
#include "LevelUsageResolver.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
|
||||
struct FAssetUsageAuditRequest
|
||||
{
|
||||
/** Roots to sweep. Empty means /Game. */
|
||||
TArray<FString> IncludePackagePaths;
|
||||
|
||||
/** Package or content-relative prefixes to skip, e.g. "Content/3rdParty". */
|
||||
TArray<FString> ExcludePackagePaths;
|
||||
|
||||
/** Levels to consider. Empty means every level found. */
|
||||
TArray<FName> LevelPackages;
|
||||
|
||||
/** Type filter applied to the reported rows, not to the traversal. */
|
||||
FAssetTypeFilter TypeFilter;
|
||||
|
||||
/** Scan Config/ and Source/ for path literals the registry cannot see. */
|
||||
bool bScanIndirectReferences = true;
|
||||
|
||||
/** Drop rows for __ExternalActors__ / __ExternalObjects__ packages. */
|
||||
bool bHideExternalPackages = true;
|
||||
|
||||
/** Drop redirector rows; they are plumbing, not content an artist acts on. */
|
||||
bool bHideRedirectors = true;
|
||||
|
||||
/** Report only assets that no level reaches. */
|
||||
bool bOnlyUnusedAssets = false;
|
||||
|
||||
FLevelUsageResolveOptions ResolveOptions;
|
||||
};
|
||||
|
||||
// Exported: CountByVerdict is defined out of line, so the Editor module cannot link without this.
|
||||
struct ASSETUSAGEAUDITCORE_API FAssetUsageAuditResult
|
||||
{
|
||||
TArray<FAssetUsageRow> Rows;
|
||||
FAssetUsageReportHeader Header;
|
||||
FAssetUsageGraphStats GraphStats;
|
||||
FLevelUsageStats LevelStats;
|
||||
|
||||
int32 CountByVerdict(EAssetUsageVerdict Verdict) const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the whole analysis: graph, level reachability, indirect scan, verdicts.
|
||||
*
|
||||
* Free of UI and of the editor asset pipeline, so the Slate panel and a future commandlet can
|
||||
* call exactly the same code and cannot drift apart.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FAssetUsageAuditor
|
||||
{
|
||||
public:
|
||||
static FAssetUsageAuditResult Run(IAssetRegistry& AssetRegistry, FAssetUsageAuditRequest& Request);
|
||||
|
||||
/**
|
||||
* Classify one asset.
|
||||
*
|
||||
* Pure and exposed for testing: the ordering of these rules is the difference between a tool
|
||||
* people trust and one that tells an artist to delete the GameMode.
|
||||
*
|
||||
* @param bReachableFromLevel A level's traversal reached this asset.
|
||||
* @param bHasReferencers Something in the graph depends on it.
|
||||
* @param bFoundInConfigOrSource A path literal in Config/ or Source/ named it.
|
||||
* @param bIsBlindSpot Its type is one the registry cannot track reliably, e.g. FMOD.
|
||||
*/
|
||||
static EAssetUsageVerdict ClassifyVerdict(bool bReachableFromLevel, bool bHasReferencers, bool bFoundInConfigOrSource, bool bIsBlindSpot);
|
||||
|
||||
/**
|
||||
* True for asset types whose real usage the Asset Registry cannot see.
|
||||
*
|
||||
* FMOD is the concrete case on this project: it resolves events by string path through the
|
||||
* FMOD Studio runtime, entirely outside the UObject reference graph, so "no referencers"
|
||||
* carries no information at all for an FMOD asset.
|
||||
*/
|
||||
static bool IsRegistryBlindSpot(const FAssetUsageNode& Node);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
|
||||
/**
|
||||
* Copies package files out of the project.
|
||||
*
|
||||
* Deliberately a plain file copy rather than IAssetTools::MigratePackages. Migrate pulls the whole
|
||||
* dependency closure, which is emphatically not what someone asked for when they ticked eleven
|
||||
* meshes, and it lives in an editor-only module. A byte copy loads no UObject, needs no editor,
|
||||
* and gives exactly the files that were ticked.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FAssetUsageExporter
|
||||
{
|
||||
public:
|
||||
struct FOptions
|
||||
{
|
||||
/** Absolute destination directory. Created if missing. */
|
||||
FString TargetDirectory;
|
||||
|
||||
/** What to do when a file of that name is already there. */
|
||||
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
|
||||
|
||||
/**
|
||||
* Put every file directly in the target folder rather than mirroring the /Game tree.
|
||||
*
|
||||
* Flat is what people expect from "export to a folder" and is why the collision policy
|
||||
* exists at all - mirroring the tree makes collisions nearly impossible but hands back a
|
||||
* deep folder structure nobody asked for.
|
||||
*/
|
||||
bool bFlatten = true;
|
||||
|
||||
/**
|
||||
* Also copy the __ExternalActors__ / __ExternalObjects__ packages belonging to any exported
|
||||
* level. Without these an exported OFPA map opens empty in the destination project.
|
||||
*/
|
||||
bool bIncludeExternalPackages = true;
|
||||
|
||||
/**
|
||||
* Record where each package actually ended up, in FResult::WrittenFiles.
|
||||
*
|
||||
* Off by default because it costs a string per file. Required by the dependency manifest:
|
||||
* a manifest built from intended paths rather than written ones is wrong precisely when the
|
||||
* collision policy renamed something, which is when someone needs it most.
|
||||
*/
|
||||
bool bRecordWrittenFiles = false;
|
||||
|
||||
/** Called as (Done, Total). Return false to cancel; already-copied files are kept. */
|
||||
TFunction<bool(int32, int32)> OnProgress;
|
||||
};
|
||||
|
||||
/** One copied file, as it was actually written. */
|
||||
struct FWrittenFile
|
||||
{
|
||||
FName PackageName;
|
||||
|
||||
/** Path relative to FOptions::TargetDirectory, with the final on-disk filename. */
|
||||
FString RelativePath;
|
||||
};
|
||||
|
||||
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
|
||||
struct ASSETUSAGEAUDITCORE_API FResult
|
||||
{
|
||||
/** Filled only when FOptions::bRecordWrittenFiles was set. */
|
||||
TArray<FWrittenFile> WrittenFiles;
|
||||
|
||||
bool bSuccess = false;
|
||||
int32 FilesCopied = 0;
|
||||
int32 FilesRenamed = 0;
|
||||
int32 FilesOverwritten = 0;
|
||||
int32 FilesMissingOnDisk = 0;
|
||||
int32 ExternalPackagesCopied = 0;
|
||||
bool bCancelled = false;
|
||||
|
||||
/** One line per failure, safe to show in a dialog. */
|
||||
TArray<FString> Errors;
|
||||
|
||||
FString Summarise() const;
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of packages destined for one subfolder of the target.
|
||||
*
|
||||
* The exporter does not decide what the subfolder means - AssetExportLayout does, and hands
|
||||
* the result over already named. Keeping the split means this class still knows nothing about
|
||||
* presets, dependencies or the Asset Registry, which is why its specs can run on package names
|
||||
* that do not exist.
|
||||
*/
|
||||
struct FExportGroup
|
||||
{
|
||||
/** Relative to FOptions::TargetDirectory. Empty writes straight into it. */
|
||||
FString RelativeDir;
|
||||
|
||||
TArray<FName> Packages;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy packages, each group into its own subfolder.
|
||||
*
|
||||
* Collision handling is per destination folder, not global: two groups may each hold a file
|
||||
* called SM_Rock.uasset without either being renamed, because they land in different folders.
|
||||
* That is the point of the folder-per-asset layout.
|
||||
*/
|
||||
static FResult ExportPackageGroups(const TArray<FExportGroup>& Groups, const FOptions& Options);
|
||||
|
||||
/** Single-group convenience: everything straight into the target. */
|
||||
static FResult ExportPackageFiles(const TArray<FName>& PackageNames, const FOptions& Options);
|
||||
|
||||
// --- Exchange formats ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One asset to convert. Deliberately not FAssetUsageRow: conversion needs three fields and
|
||||
* taking the whole row would make this callable only from a finished audit.
|
||||
*/
|
||||
struct FExportItem
|
||||
{
|
||||
FName PackageName;
|
||||
FName AssetName;
|
||||
|
||||
/** Asset class, e.g. /Script/Engine.StaticMesh. Chooses the output format. */
|
||||
FTopLevelAssetPath ClassPath;
|
||||
};
|
||||
|
||||
struct FExchangeOptions
|
||||
{
|
||||
FString TargetDirectory;
|
||||
|
||||
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
|
||||
|
||||
bool bFlatten = true;
|
||||
|
||||
/**
|
||||
* Asset class short name -> file extension without the dot, e.g. {"StaticMesh", "fbx"}.
|
||||
*
|
||||
* Passed in rather than hardcoded so the Core module stays free of the settings object, and
|
||||
* so a class with no sensible exchange format is a configuration fact rather than a silent
|
||||
* omission. Lookup walks up the class hierarchy, so mapping MaterialInterface also covers
|
||||
* MaterialInstanceConstant.
|
||||
*/
|
||||
TMap<FString, FString> FormatByClass;
|
||||
|
||||
/**
|
||||
* Run garbage collection every N assets.
|
||||
*
|
||||
* Unlike a file copy, this path loads every UObject it touches. Exporting a few thousand
|
||||
* meshes without collecting will exhaust memory long before the export finishes. Zero
|
||||
* disables it.
|
||||
*/
|
||||
int32 CollectGarbageEvery = 64;
|
||||
|
||||
/** Called as (Done, Total). Return false to cancel; files already written are kept. */
|
||||
TFunction<bool(int32, int32)> OnProgress;
|
||||
};
|
||||
|
||||
struct ASSETUSAGEAUDITCORE_API FExchangeResult
|
||||
{
|
||||
bool bSuccess = false;
|
||||
int32 FilesWritten = 0;
|
||||
int32 FilesRenamed = 0;
|
||||
|
||||
/** No extension configured for the asset's class. Not an error - a deliberate omission. */
|
||||
int32 SkippedNoFormat = 0;
|
||||
|
||||
/** The asset would not load. Counted separately from an exporter refusing to run. */
|
||||
int32 SkippedNotLoaded = 0;
|
||||
|
||||
/** Loaded fine, but no UExporter is registered for that class and extension. */
|
||||
int32 SkippedNoExporter = 0;
|
||||
|
||||
bool bCancelled = false;
|
||||
|
||||
TArray<FString> Errors;
|
||||
|
||||
FString Summarise() const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert assets to interchange formats (FBX, PNG, WAV, ...) via UAssetExportTask.
|
||||
*
|
||||
* Loads every asset, so it is orders of magnitude slower than ExportPackageFiles and must be
|
||||
* driven with a progress callback. bPrompt is forced false and bAutomated true: a modal file
|
||||
* dialog per asset would hang an unattended run, and this is the whole reason the engine has
|
||||
* those flags.
|
||||
*/
|
||||
static FExchangeResult ExportConvertedAssets(const TArray<FExportItem>& Items, const FExchangeOptions& Options);
|
||||
|
||||
/**
|
||||
* Extension for an asset class, following the class hierarchy upward.
|
||||
* Returns an empty string when nothing in the chain is mapped.
|
||||
*/
|
||||
static FString FindFormatForClass(const FTopLevelAssetPath& ClassPath, const TMap<FString, FString>& FormatByClass);
|
||||
|
||||
/** The mapping the tool ships with. Editor settings seed themselves from this. */
|
||||
static TMap<FString, FString> GetDefaultFormatByClass();
|
||||
|
||||
/**
|
||||
* Resolve a package name to its file on disk, trying both asset and map extensions.
|
||||
* Returns an empty string when the package has no file (script packages, unsaved assets).
|
||||
*/
|
||||
static FString ResolvePackageFilePath(FName PackageName);
|
||||
|
||||
private:
|
||||
/**
|
||||
* The copy loop, shared by every entry point.
|
||||
*
|
||||
* Takes the destination folder per entry rather than deriving it, so that deciding where a file
|
||||
* goes and actually writing it stay separate concerns. Result is passed by reference because
|
||||
* the caller has already recorded setup failures into it.
|
||||
*/
|
||||
static FResult CopyExpandedPackages(
|
||||
const TArray<FName>& Expanded,
|
||||
const TArray<FString>& DestinationDirs,
|
||||
const TBitArray<>& IsExternalExpansion,
|
||||
const FOptions& Options,
|
||||
FResult& Result);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "AssetRegistry/AssetData.h"
|
||||
|
||||
class IAssetRegistry;
|
||||
|
||||
/**
|
||||
* One dependency edge, stored by dense index rather than by FName.
|
||||
*
|
||||
* 8 bytes. At this project's scale (80k packages, several hundred thousand edges) the difference
|
||||
* between this and a TMap<FName, TArray<FName>> is the difference between a tool that answers in
|
||||
* seconds and one nobody waits for.
|
||||
*/
|
||||
struct FAssetUsageEdge
|
||||
{
|
||||
/** Dense index of the target package in FAssetUsageGraph. */
|
||||
int32 TargetIndex = INDEX_NONE;
|
||||
|
||||
/** Edge properties as reported by the registry: Hard / Game / Build. */
|
||||
UE::AssetRegistry::EDependencyProperty Properties = UE::AssetRegistry::EDependencyProperty::None;
|
||||
|
||||
bool IsHard() const
|
||||
{
|
||||
return EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Hard);
|
||||
}
|
||||
|
||||
/** Lack of Hard is what the engine calls a soft dependency (AssetRegistryInterface.h:95). */
|
||||
bool IsSoft() const
|
||||
{
|
||||
return !IsHard();
|
||||
}
|
||||
|
||||
/** Lack of Game means the edge is editor-only and will not survive a cook. */
|
||||
bool IsEditorOnly() const
|
||||
{
|
||||
return !EnumHasAnyFlags(Properties, UE::AssetRegistry::EDependencyProperty::Game);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-package facts captured once during the sweep, so later passes never re-query the registry.
|
||||
*/
|
||||
struct FAssetUsageNode
|
||||
{
|
||||
FName PackageName;
|
||||
FName AssetName;
|
||||
FTopLevelAssetPath ClassPath;
|
||||
|
||||
/**
|
||||
* Gameplay class behind a Blueprint, read from the GeneratedClass asset-registry tag.
|
||||
*
|
||||
* Needed because a BP asset's own ClassPath is always /Script/Engine.Blueprint. Filtering
|
||||
* ClassPaths alone finds zero Blueprints, which in this project means finding almost nothing.
|
||||
*/
|
||||
FTopLevelAssetPath GeneratedClassPath;
|
||||
|
||||
/** ClassPath is /Script/Engine.World. */
|
||||
bool bIsLevel = false;
|
||||
|
||||
/** Package lives under __ExternalActors__ or __ExternalObjects__. */
|
||||
bool bIsExternalPackage = false;
|
||||
|
||||
/** Asset is an ObjectRedirector and must be followed through, not reported. */
|
||||
bool bIsRedirector = false;
|
||||
};
|
||||
|
||||
struct FAssetUsageGraphBuildOptions
|
||||
{
|
||||
/** Roots to sweep. Defaults to /Game. */
|
||||
TArray<FString> IncludePackagePaths;
|
||||
|
||||
/** Package path prefixes to skip entirely, e.g. Content/3rdParty. */
|
||||
TArray<FString> ExcludePackagePaths;
|
||||
|
||||
/** Collect dependency edges. Off gives a much faster inventory-only pass. */
|
||||
bool bGatherDependencies = true;
|
||||
};
|
||||
|
||||
struct FAssetUsageGraphStats
|
||||
{
|
||||
int32 NumPackages = 0;
|
||||
int32 NumEdges = 0;
|
||||
int32 NumLevels = 0;
|
||||
int32 NumExternalPackages = 0;
|
||||
double EnumerateSeconds = 0.0;
|
||||
double DependencySeconds = 0.0;
|
||||
|
||||
double TotalSeconds() const
|
||||
{
|
||||
return EnumerateSeconds + DependencySeconds;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dense, immutable-after-build dependency graph over the project's packages.
|
||||
*
|
||||
* Edges are stored CSR-style: one flat FAssetUsageEdge array plus an offset table, so a node's
|
||||
* dependency list is a contiguous view with no per-node allocation.
|
||||
*
|
||||
* The graph deliberately stores dependencies in BOTH directions. Forward edges answer "what does
|
||||
* this level use"; reverse edges answer "is this asset referenced by anything at all", which is
|
||||
* what separates the Unreferenced verdict from UsedByAssetsOnly.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FAssetUsageGraph
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* Sweep the registry and build the graph.
|
||||
*
|
||||
* The registry must already be populated. In a commandlet the AssetRegistry module gathers
|
||||
* synchronously on load; in the editor callers must wait for OnFilesLoaded first.
|
||||
*/
|
||||
void Build(IAssetRegistry& AssetRegistry, const FAssetUsageGraphBuildOptions& Options);
|
||||
|
||||
void Reset();
|
||||
|
||||
int32 Num() const
|
||||
{
|
||||
return Nodes.Num();
|
||||
}
|
||||
|
||||
bool IsValidIndex(int32 Index) const
|
||||
{
|
||||
return Nodes.IsValidIndex(Index);
|
||||
}
|
||||
|
||||
/** INDEX_NONE when the package was not part of the sweep. */
|
||||
int32 FindPackageIndex(FName PackageName) const
|
||||
{
|
||||
const int32* Found = PackageToIndex.Find(PackageName);
|
||||
return Found ? *Found : INDEX_NONE;
|
||||
}
|
||||
|
||||
const FAssetUsageNode& GetNode(int32 Index) const
|
||||
{
|
||||
return Nodes[Index];
|
||||
}
|
||||
|
||||
TConstArrayView<FAssetUsageNode> GetNodes() const
|
||||
{
|
||||
return Nodes;
|
||||
}
|
||||
|
||||
/** Packages this one depends on. */
|
||||
TConstArrayView<FAssetUsageEdge> GetDependencies(int32 Index) const;
|
||||
|
||||
/** Packages that depend on this one. */
|
||||
TConstArrayView<FAssetUsageEdge> GetReferencers(int32 Index) const;
|
||||
|
||||
/** Dense indices of every /Script/Engine.World package in the sweep. */
|
||||
TConstArrayView<int32> GetLevelIndices() const
|
||||
{
|
||||
return LevelIndices;
|
||||
}
|
||||
|
||||
const FAssetUsageGraphStats& GetStats() const
|
||||
{
|
||||
return Stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a redirector chain to the asset it ultimately points at.
|
||||
* Returns Index unchanged when it is not a redirector. Cycle-safe.
|
||||
*/
|
||||
int32 ResolveRedirector(int32 Index) const;
|
||||
|
||||
private:
|
||||
int32 AddOrFindPackage(FName PackageName);
|
||||
void BuildReverseEdges();
|
||||
|
||||
TArray<FAssetUsageNode> Nodes;
|
||||
TMap<FName, int32> PackageToIndex;
|
||||
|
||||
/** CSR forward edges: Dependencies[DependencyOffsets[i] .. DependencyOffsets[i+1]). */
|
||||
TArray<FAssetUsageEdge> Dependencies;
|
||||
TArray<int32> DependencyOffsets;
|
||||
|
||||
TArray<FAssetUsageEdge> Referencers;
|
||||
TArray<int32> ReferencerOffsets;
|
||||
|
||||
TArray<int32> LevelIndices;
|
||||
|
||||
FAssetUsageGraphStats Stats;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
/**
|
||||
* Package-path predicates and conversions.
|
||||
*
|
||||
* Pure string work, no registry and no engine state, so it is unit-testable and cheap enough to
|
||||
* call inside the per-asset sweep.
|
||||
*
|
||||
* Note on OFPA: these helpers only ever *classify* a path. They never try to derive a level's
|
||||
* external-actor folder by string building, and they never try to recover the owning level from
|
||||
* an external actor path. Both are real traps - Content Bundles inject /CB/<Guid>/ and External
|
||||
* Data Layers inject /EDL/<UID>/ between the folder and the level path, and plugins can register
|
||||
* additional roots through delegates. Producing those paths is ULevel::GetExternalActorsPaths's
|
||||
* job; recovering ownership is avoided entirely by traversing forward from the level.
|
||||
*/
|
||||
namespace AssetUsagePaths
|
||||
{
|
||||
/** True for a package under an __ExternalActors__ or __ExternalObjects__ root. */
|
||||
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FName PackageName);
|
||||
ASSETUSAGEAUDITCORE_API bool IsExternalPackage(FStringView PackagePath);
|
||||
|
||||
/** True for /Script/... - code, not an asset. Cannot be exported or reported unused. */
|
||||
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FName PackageName);
|
||||
ASSETUSAGEAUDITCORE_API bool IsScriptPackage(FStringView PackagePath);
|
||||
|
||||
/** True for /Engine/... or /Temp/... - not project content. */
|
||||
ASSETUSAGEAUDITCORE_API bool IsEngineOrTempPackage(FStringView PackagePath);
|
||||
|
||||
/**
|
||||
* True when the package sits under any of the given exclusion prefixes.
|
||||
*
|
||||
* Prefixes may be given in either package form ("/Game/3rdParty") or content-relative form
|
||||
* ("Content/3rdParty"), because the UI shows users the latter and settings files tend to
|
||||
* accumulate both. Matching is case-insensitive and boundary-aware, so "/Game/Art" does not
|
||||
* exclude "/Game/ArtSource".
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes);
|
||||
ASSETUSAGEAUDITCORE_API bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes);
|
||||
|
||||
/**
|
||||
* Normalise an exclusion prefix to package form with no trailing slash.
|
||||
* "Content/3rdParty/" -> "/Game/3rdParty"
|
||||
* "/Game/3rdParty" -> "/Game/3rdParty"
|
||||
* "Content" -> "/Game"
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString NormalizeExclusionPrefix(const FString& Prefix);
|
||||
|
||||
/**
|
||||
* Package name to a path relative to the project root, as the report requires.
|
||||
* "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset"
|
||||
*
|
||||
* @param bIsLevel Chooses the .umap extension over .uasset.
|
||||
* @return Empty for packages with no project-relative form, e.g. /Script or /Engine.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString ToProjectRelativePath(FName PackageName, bool bIsLevel);
|
||||
|
||||
/**
|
||||
* Can this folder be used as a Migrate destination?
|
||||
*
|
||||
* Mirrors the two checks UAssetToolsImpl makes, and exists because it makes them *after* being
|
||||
* called and reports the refusal to the Output Log alone - so an impossible export otherwise
|
||||
* presents as a button that does nothing.
|
||||
*
|
||||
* The engine's rules:
|
||||
* 1. the path must end in /Content/ (MigratePackages_ReportConfirmed);
|
||||
* 2. the folder above it must hold a .uproject, or exactly one .uplugin
|
||||
* (FPackageMigrationImpl::GetMountPointRootPath) - that is where the destination mount
|
||||
* point comes from, and without it Migrate aborts.
|
||||
*
|
||||
* Lives in Core despite serving an editor-only feature: it is filesystem and string work with
|
||||
* no AssetTools involved, so it belongs with the other path predicates and can be tested
|
||||
* without an editor module.
|
||||
*
|
||||
* @return Reason the folder is unusable, or an empty string when it is fine.
|
||||
*/
|
||||
ASSETUSAGEAUDITCORE_API FString ValidateMigrateDestination(const FString& Directory);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
struct FAssetUsageAuditResult;
|
||||
|
||||
/**
|
||||
* Writes the audit result as JSON and CSV.
|
||||
*
|
||||
* Both come from one in-memory result in a single call, so the two files can never disagree
|
||||
* about the same run. JSON is the machine-readable form (nested level lists, filter metadata);
|
||||
* CSV is the flat form a human opens in Excel.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FAssetUsageReportWriter
|
||||
{
|
||||
public:
|
||||
struct FOptions
|
||||
{
|
||||
/** Absolute directory to write into. Created if missing. */
|
||||
FString OutputDirectory;
|
||||
|
||||
/** Base filename without extension; ".json" and ".csv" are appended. */
|
||||
FString BaseFileName = TEXT("AssetUsageReport");
|
||||
|
||||
bool bWriteJson = true;
|
||||
bool bWriteCsv = true;
|
||||
|
||||
/**
|
||||
* Write a UTF-8 BOM at the start of the CSV.
|
||||
*
|
||||
* Excel misreads UTF-8 without it and mangles every non-ASCII asset name, which on this
|
||||
* project means the Cyrillic folder and asset names come out as garbage.
|
||||
*/
|
||||
bool bCsvUtf8Bom = true;
|
||||
|
||||
/** CSV field separator. Semicolon suits locales where the comma is a decimal separator. */
|
||||
TCHAR CsvDelimiter = TEXT(';');
|
||||
|
||||
/** Separator for multi-valued fields inside one CSV cell, e.g. the level list. */
|
||||
FString CsvMultiValueSeparator = TEXT("|");
|
||||
};
|
||||
|
||||
struct FResult
|
||||
{
|
||||
bool bSuccess = false;
|
||||
FString JsonPath;
|
||||
FString CsvPath;
|
||||
FString ErrorMessage;
|
||||
};
|
||||
|
||||
static FResult Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
|
||||
|
||||
/** Serialize to a JSON string without touching disk. Exposed for tests. */
|
||||
static FString BuildJson(const FAssetUsageAuditResult& AuditResult);
|
||||
|
||||
/** Serialize to a CSV string without touching disk. Exposed for tests. */
|
||||
static FString BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options);
|
||||
|
||||
/**
|
||||
* Quote and escape one CSV field per RFC 4180.
|
||||
*
|
||||
* Exposed because this is where CSV writers usually break: a field containing the delimiter,
|
||||
* a quote or a newline must be quoted, and embedded quotes doubled.
|
||||
*/
|
||||
static FString EscapeCsvField(const FString& Field, TCHAR Delimiter);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
/**
|
||||
* One /Game path literal found in a text file, with enough context to check it by hand.
|
||||
*/
|
||||
struct FIndirectReference
|
||||
{
|
||||
/** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */
|
||||
FName PackageName;
|
||||
|
||||
/** Absolute path of the file the literal was found in. */
|
||||
FString SourceFile;
|
||||
|
||||
/** 1-based line number. */
|
||||
int32 LineNumber = 0;
|
||||
|
||||
/** True when the hit came from Config/, false when from Source/. */
|
||||
bool bFromConfig = false;
|
||||
|
||||
FString ToProvenanceString() const;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds asset paths referenced from text rather than from a package.
|
||||
*
|
||||
* This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini
|
||||
* names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain
|
||||
* strings. No asset references them, so the Asset Registry reports zero referencers and a naive
|
||||
* audit calls the project's GameMode unused.
|
||||
*
|
||||
* It is a mitigation, not a solution. It cannot see a path assembled at runtime by concatenation,
|
||||
* an OpenLevel(FName) call, or an FMOD event path - those stay Unknown, deliberately.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FIndirectReferenceScanner
|
||||
{
|
||||
public:
|
||||
struct FOptions
|
||||
{
|
||||
/** Absolute directories to scan. Defaults to <Project>/Config and <Project>/Source. */
|
||||
TArray<FString> Directories;
|
||||
|
||||
/** File extensions to read, lowercase, with the dot. */
|
||||
TArray<FString> Extensions = { TEXT(".ini"), TEXT(".cpp"), TEXT(".h"), TEXT(".cs") };
|
||||
|
||||
/** Skip files larger than this; a multi-megabyte generated file is never a reference site. */
|
||||
int64 MaxFileSizeBytes = 8 * 1024 * 1024;
|
||||
};
|
||||
|
||||
/** Scan and return every distinct package path found, with provenance. */
|
||||
static TArray<FIndirectReference> Scan(const FOptions& Options);
|
||||
|
||||
/** Default options: <Project>/Config and <Project>/Source. */
|
||||
static FOptions MakeDefaultOptions();
|
||||
|
||||
/**
|
||||
* Extract /Game path literals from one line of text.
|
||||
*
|
||||
* Exposed for testing: the trailing-_C strip and the delimiter set are where this goes wrong.
|
||||
* "/Game/X/BP_Y.BP_Y_C" and "/Game/X/BP_Y" must both yield the package /Game/X/BP_Y.
|
||||
*/
|
||||
static void ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "AssetUsageAuditTypes.h"
|
||||
#include "Containers/BitArray.h"
|
||||
|
||||
class FAssetUsageGraph;
|
||||
class IAssetRegistry;
|
||||
|
||||
struct FLevelUsageResolveOptions
|
||||
{
|
||||
/** Levels to analyse. Empty means every /Script/Engine.World package in the graph. */
|
||||
TArray<FName> LevelPackages;
|
||||
|
||||
/**
|
||||
* Call ULevel::ScanLevelAssets before traversing each level.
|
||||
*
|
||||
* The external-actor dependency gatherer only reports packages the registry has already
|
||||
* scanned. Skipping this can silently yield an empty external-actor set on a level nobody
|
||||
* has opened this session - which looks exactly like a level that genuinely has no actors.
|
||||
*/
|
||||
bool bScanLevelAssetsFirst = true;
|
||||
|
||||
/**
|
||||
* Additionally seed the BFS from ULevel::GetExternalActorsPaths / GetExternalObjectsPaths.
|
||||
*
|
||||
* Belt and braces: the gatherer normally puts these edges in the graph already. Keeping the
|
||||
* explicit seed means a stale or partially-scanned registry degrades to a slower correct
|
||||
* answer instead of a fast wrong one, and it lets us tag ExternalActor provenance precisely.
|
||||
*/
|
||||
bool bSeedExternalPackages = true;
|
||||
|
||||
/**
|
||||
* Follow a reference into another map's contents.
|
||||
*
|
||||
* Off by default, and that default is load-bearing. A map does not only reference its own
|
||||
* sublevels: anything it can reach may name an unrelated map, and on this project it does.
|
||||
* WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole
|
||||
* of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer
|
||||
* was another level's content.
|
||||
*
|
||||
* With this off, a foreign map is still reported as referenced, but its contents are attributed
|
||||
* to that map alone. Genuine sublevels and Level Instances are unaffected: they are reached
|
||||
* from the level package itself or from one of its external actor packages, and that crossing
|
||||
* is always allowed. See ShouldCrossIntoLevel in the .cpp.
|
||||
*/
|
||||
bool bTraverseIntoOtherLevels = false;
|
||||
|
||||
/** Record a human-readable route for each asset. Costs one int32 array per BFS. */
|
||||
bool bRecordRoutes = true;
|
||||
|
||||
/** Route strings longer than this many hops are elided in the middle. */
|
||||
int32 MaxRouteHops = 12;
|
||||
|
||||
/** Optional progress sink, called once per level with (LevelIndex, TotalLevels). */
|
||||
TFunction<void(int32, int32)> OnLevelProgress;
|
||||
|
||||
/** Return true to abort the sweep between levels. */
|
||||
TFunction<bool()> ShouldAbort;
|
||||
};
|
||||
|
||||
struct FLevelUsageStats
|
||||
{
|
||||
int32 LevelsScanned = 0;
|
||||
int32 AssetsReachable = 0;
|
||||
int32 ExternalPackagesSeeded = 0;
|
||||
int32 RedirectorsResolved = 0;
|
||||
|
||||
/**
|
||||
* Foreign maps referenced but deliberately not expanded into.
|
||||
*
|
||||
* A non-zero value here is the amount of another level's content that would otherwise have
|
||||
* been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report.
|
||||
*/
|
||||
int32 ForeignLevelsNotExpanded = 0;
|
||||
double ScanLevelAssetsSeconds = 0.0;
|
||||
double TraversalSeconds = 0.0;
|
||||
|
||||
double TotalSeconds() const
|
||||
{
|
||||
return ScanLevelAssetsSeconds + TraversalSeconds;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Which levels reach which assets.
|
||||
*
|
||||
* Reachability is one TBitArray per level over dense package indices. At this project's scale
|
||||
* that is 1207 levels x 80608 bits, roughly 12 MB - cheap enough to hold both query directions
|
||||
* ("assets on this level" and "levels using this asset") without ever re-walking the graph.
|
||||
*/
|
||||
struct ASSETUSAGEAUDITCORE_API FLevelUsageResult
|
||||
{
|
||||
/** Level package names, parallel to LevelReachability. */
|
||||
TArray<FName> LevelPackageNames;
|
||||
|
||||
/** LevelReachability[L][A] - level L reaches asset A. */
|
||||
TArray<TBitArray<>> LevelReachability;
|
||||
|
||||
/** Union across every level. The primary input to the UsedOnLevel verdict. */
|
||||
TBitArray<> ReachableFromAnyLevel;
|
||||
|
||||
/** Accumulated provenance flags per asset index. */
|
||||
TArray<EAssetUsageProvenance> Provenance;
|
||||
|
||||
/** Incoming edge counts per asset index, split by the Hard property. */
|
||||
TArray<int32> HardReferenceCounts;
|
||||
TArray<int32> SoftReferenceCounts;
|
||||
|
||||
/** Route string per asset index, from the first level that reached it. Empty when not recorded. */
|
||||
TMap<int32, FString> Routes;
|
||||
|
||||
FLevelUsageStats Stats;
|
||||
|
||||
/** Levels that reach the given asset index. */
|
||||
TArray<FName> GetLevelsForAsset(int32 AssetIndex) const;
|
||||
|
||||
bool IsReachableFromAnyLevel(int32 AssetIndex) const
|
||||
{
|
||||
return ReachableFromAnyLevel.IsValidIndex(AssetIndex) && ReachableFromAnyLevel[AssetIndex];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Forward traversal from level packages to everything they pull in.
|
||||
*
|
||||
* Direction matters. Going forward from the map is correct; going backward from an external actor
|
||||
* package to its owning map is not, because PackageDependencyData.cpp:57-96 deliberately strips
|
||||
* the UsedInGame flag off that reverse import by naming convention, so the AssetManager will not
|
||||
* drag a whole map in when something references one actor. Reconstructing ownership from the path
|
||||
* instead is possible but fragile - Content Bundles inject /CB/<Guid>/ and External Data Layers
|
||||
* inject /EDL/<UID>/ between the folder and the level path. Forward traversal sidesteps all of it.
|
||||
*/
|
||||
class ASSETUSAGEAUDITCORE_API FLevelUsageResolver
|
||||
{
|
||||
public:
|
||||
FLevelUsageResolver(const FAssetUsageGraph& InGraph, IAssetRegistry& InAssetRegistry);
|
||||
|
||||
FLevelUsageResult Resolve(const FLevelUsageResolveOptions& Options);
|
||||
|
||||
private:
|
||||
/** BFS from one level. Marks OutReachable and accumulates provenance and counts. */
|
||||
void TraverseLevel(int32 LevelIndex, const FLevelUsageResolveOptions& Options, TBitArray<>& OutReachable, FLevelUsageResult& InOutResult, TArray<int32>& ScratchPredecessor, TArray<int32>& ScratchQueue);
|
||||
|
||||
/** Dense indices of the __ExternalActors__ / __ExternalObjects__ packages owned by a level. */
|
||||
void GatherExternalPackageSeeds(int32 LevelIndex, TArray<int32>& OutSeeds, FLevelUsageResult& InOutResult) const;
|
||||
|
||||
FString BuildRouteString(int32 LevelIndex, int32 AssetIndex, const TArray<int32>& Predecessor, int32 MaxHops) const;
|
||||
|
||||
const FAssetUsageGraph& Graph;
|
||||
IAssetRegistry& AssetRegistry;
|
||||
};
|
||||
Reference in New Issue
Block a user