feat: asset usage audit plugin

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

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

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:07:59 +07:00
commit 72bf94d142
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,376 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetDependencyClosure.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/StaticMesh.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* The dependency closure behind "export a mesh and get its materials too".
*
* Runs against the real registry, because the thing worth testing is whether the traversal finds
* the edges the engine actually stores - a mock would only prove that a BFS visits nodes.
*/
BEGIN_DEFINE_SPEC(AssetDependencyClosureSpec,
"AssetUsageAudit.DependencyClosure",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/**
* A /Game static mesh that actually references something, so the closure has work to do.
*
* Picking the first mesh found is not enough: a mesh with no material assigned would make the
* "brings materials along" test pass vacuously.
*/
static FName FindMeshWithDependencies(int32& OutDirectCount)
{
FARFilter Filter;
Filter.ClassPaths.Add(UStaticMesh::StaticClass()->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bRecursiveClasses = true;
Filter.bIncludeOnlyOnDiskAssets = true;
IAssetRegistry& Registry = ReadyRegistry();
FName Best = NAME_None;
int32 BestCount = 0;
Registry.EnumerateAssets(Filter, [&Registry, &Best, &BestCount](const FAssetData& AssetData)
{
TArray<FName> Dependencies;
Registry.GetDependencies(
AssetData.PackageName,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
int32 GameDependencies = 0;
for (FName Dependency : Dependencies)
{
if (Dependency.ToString().StartsWith(TEXT("/Game/")))
{
++GameDependencies;
}
}
if (GameDependencies > BestCount)
{
BestCount = GameDependencies;
Best = AssetData.PackageName;
}
// Two /Game dependencies is enough to be a meaningful subject; stop hunting.
return BestCount < 2;
});
OutDirectCount = BestCount;
return Best;
}
END_DEFINE_SPEC(AssetDependencyClosureSpec)
void AssetDependencyClosureSpec::Define()
{
Describe("Degenerate input", [this]()
{
It("returns nothing for no seeds", [this]()
{
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), {}, {}, Stats);
TestEqual(TEXT("empty"), Result.Num(), 0);
TestEqual(TEXT("no seeds counted"), Stats.SeedCount, 0);
});
It("drops a malformed seed instead of passing it on", [this]()
{
// A name that is not a long package name has no file and no dependencies. Keeping it
// would push the failure down into the exporter, which would report it as missing.
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(
ReadyRegistry(), { FName(TEXT("NotAPackage")), FName() }, {}, Stats);
TestEqual(TEXT("nothing kept"), Result.Num(), 0);
});
It("keeps a seed that has no dependencies at all", [this]()
{
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(
ReadyRegistry(), { FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")) }, {}, Stats);
TestEqual(TEXT("the seed survives"), Result.Num(), 1);
TestEqual(TEXT("counted as a seed"), Stats.SeedCount, 1);
});
It("de-duplicates a seed listed twice", [this]()
{
const FName Same(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest"));
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Same, Same, Same }, {}, Stats);
TestEqual(TEXT("once"), Result.Num(), 1);
});
});
Describe("Walking real dependencies", [this]()
{
It("brings a mesh's materials and textures along", [this]()
{
int32 DirectCount = 0;
const FName Mesh = FindMeshWithDependencies(DirectCount);
if (Mesh.IsNone())
{
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("Using '%s' with %d direct /Game dependencies."), *Mesh.ToString(), DirectCount));
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats);
// This is the whole point of the feature: exporting the seed alone produces a file that
// opens with no material.
TestTrue(TEXT("more than the seed came back"), Result.Num() > 1);
TestEqual(TEXT("the seed is first"), Result[0], Mesh);
TestEqual(TEXT("stats agree with the array"), Stats.TotalCount, Result.Num());
TestTrue(TEXT("walked at least one hop"), Stats.DeepestHop >= 1);
});
It("leaves engine content out by default", [this]()
{
int32 DirectCount = 0;
const FName Mesh = FindMeshWithDependencies(DirectCount);
if (Mesh.IsNone())
{
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
return;
}
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats);
// The destination project ships /Engine itself; copying it there is at best redundant
// and at worst overwrites engine files.
for (FName Package : Result)
{
const FString AsString = Package.ToString();
TestFalse(*FString::Printf(TEXT("no engine package: %s"), *AsString), AsString.StartsWith(TEXT("/Engine/")));
TestFalse(*FString::Printf(TEXT("no script package: %s"), *AsString), AsString.StartsWith(TEXT("/Script/")));
}
});
It("respects the exclusion list for dependencies but not for seeds", [this]()
{
int32 DirectCount = 0;
const FName Mesh = FindMeshWithDependencies(DirectCount);
if (Mesh.IsNone())
{
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
return;
}
// Excluding all of /Game must still return the ticked asset: the user asked for that
// file by name. It is the traversal behind it that the exclusion list bounds.
AssetDependencyClosure::FOptions Options;
Options.ExcludePackagePaths.Add(TEXT("/Game"));
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
TestEqual(TEXT("only the seed survives"), Result.Num(), 1);
TestEqual(TEXT("and it is the seed"), Result[0], Mesh);
TestTrue(TEXT("exclusions were counted"), Stats.SkippedExcluded > 0);
});
It("stops at the requested depth", [this]()
{
int32 DirectCount = 0;
const FName Mesh = FindMeshWithDependencies(DirectCount);
if (Mesh.IsNone())
{
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
return;
}
AssetDependencyClosure::FOptions Options;
Options.MaxDepth = 1;
AssetDependencyClosure::FStats Stats;
const TArray<FName> Limited = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
TestTrue(TEXT("never went past one hop"), Stats.DeepestHop <= 1);
AssetDependencyClosure::FStats FullStats;
const TArray<FName> Full = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, FullStats);
TestTrue(TEXT("the limit removed something or the graph was one hop deep"),
Limited.Num() <= Full.Num());
});
It("does not follow a map that an ordinary asset merely mentions", [this]()
{
// The defect this guards against: exporting WP_Main pulled in the whole of L_MainLevel
// through BP_FirstPersonGameMode -> PDA_MenuSystemConfig, 9994 packages belonging to a
// different location. FLevelUsageResolver already refused that crossing; the closure
// did not, so the two disagreed about the same graph.
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
const FTopLevelAssetPath WorldClass = UWorld::StaticClass()->GetClassPathName();
FName Culprit = NAME_None;
FName ReferencedMap = NAME_None;
Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData)
{
// The source must not itself be a level or an external actor package, or the
// crossing would be structural and legitimately followed.
if (AssetData.AssetClassPath == WorldClass
|| AssetUsagePaths::IsExternalPackage(AssetData.PackageName))
{
return true;
}
TArray<FName> Dependencies;
Registry.GetDependencies(
AssetData.PackageName,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : Dependencies)
{
TArray<FAssetData> InPackage;
Registry.GetAssetsByPackageName(Dependency, InPackage, true);
for (const FAssetData& Inner : InPackage)
{
if (Inner.AssetClassPath == WorldClass)
{
Culprit = AssetData.PackageName;
ReferencedMap = Dependency;
return false;
}
}
}
return true;
});
if (Culprit.IsNone())
{
AddWarning(TEXT("No non-level asset referencing a map was found; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("'%s' references the map '%s'."), *Culprit.ToString(), *ReferencedMap.ToString()));
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Culprit }, {}, Stats);
TestFalse(TEXT("the foreign map is not in the closure"), Result.Contains(ReferencedMap));
TestTrue(TEXT("and the refusal was counted"), Stats.ForeignLevelsSkipped > 0);
});
It("still expands a level that was ticked directly", [this]()
{
// The rule bounds foreign maps, not the seed. Someone who ticks a location is asking
// for its contents - that is the task this tool exists for.
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
FName Level = NAME_None;
Registry.EnumerateAssets(Filter, [&Registry, &Level](const FAssetData& AssetData)
{
TArray<FName> Dependencies;
Registry.GetDependencies(
AssetData.PackageName,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : Dependencies)
{
if (Dependency.ToString().StartsWith(TEXT("/Game/")))
{
Level = AssetData.PackageName;
return false;
}
}
return true;
});
if (Level.IsNone())
{
AddWarning(TEXT("No /Game level with dependencies was found; skipping."));
return;
}
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Level }, {}, Stats);
TestTrue(TEXT("the level brought its content"), Result.Num() > 1);
TestEqual(TEXT("the level is the seed"), Result[0], Level);
});
It("stops when the progress callback asks it to", [this]()
{
int32 DirectCount = 0;
const FName Mesh = FindMeshWithDependencies(DirectCount);
if (Mesh.IsNone())
{
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
return;
}
AssetDependencyClosure::FOptions Options;
Options.OnProgress = [](int32, int32) { return false; };
AssetDependencyClosure::FStats Stats;
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
TestTrue(TEXT("flagged as stopped"), Stats.bStoppedEarly);
// A cancelled walk still returns the seeds, so a caller that ignores the flag exports
// what was ticked rather than nothing at all.
TestEqual(TEXT("seeds survive cancellation"), Result.Num(), 1);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,326 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageExporter.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/StaticMesh.h"
#include "Engine/Texture2D.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* The second export mode: convert assets to interchange formats instead of copying .uasset files.
*
* Format selection is a pure function over a class path and a map, so it is tested directly. The
* conversion itself needs a loaded UObject and a registered UExporter, so those cases run against
* real project content and warn rather than fail when the content is not there.
*/
BEGIN_DEFINE_SPEC(AssetExchangeExportSpec,
"AssetUsageAudit.ExchangeExport",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** First /Game asset of the given class, as an export item. Sorted for reproducibility. */
static bool FindItemOfClass(UClass* Class, FAssetUsageExporter::FExportItem& OutItem)
{
FARFilter Filter;
Filter.ClassPaths.Add(Class->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bRecursiveClasses = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FAssetData> Found;
ReadyRegistry().EnumerateAssets(Filter, [&Found](const FAssetData& AssetData)
{
Found.Add(AssetData);
return Found.Num() < 64;
});
if (Found.IsEmpty())
{
return false;
}
Found.Sort([](const FAssetData& A, const FAssetData& B)
{
return A.PackageName.LexicalLess(B.PackageName);
});
OutItem.PackageName = Found[0].PackageName;
OutItem.AssetName = Found[0].AssetName;
OutItem.ClassPath = Found[0].AssetClassPath;
return true;
}
int32 CountFilesInScratch(const TCHAR* Wildcard = TEXT("*.*")) const
{
TArray<FString> Found;
IFileManager::Get().FindFilesRecursive(Found, *ScratchDir, Wildcard, true, false);
return Found.Num();
}
END_DEFINE_SPEC(AssetExchangeExportSpec)
void AssetExchangeExportSpec::Define()
{
Describe("FindFormatForClass", [this]()
{
It("matches a class named directly in the map", [this]()
{
const TMap<FString, FString> Map = { { TEXT("StaticMesh"), TEXT("fbx") } };
TestEqual(TEXT("direct hit"),
FAssetUsageExporter::FindFormatForClass(FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("StaticMesh")), Map),
FString(TEXT("fbx")));
});
It("returns nothing for a class with no entry", [this]()
{
const TMap<FString, FString> Map = { { TEXT("StaticMesh"), TEXT("fbx") } };
TestTrue(TEXT("no format"),
FAssetUsageExporter::FindFormatForClass(
FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("Blueprint")), Map).IsEmpty());
});
It("returns nothing for an invalid class path rather than guessing", [this]()
{
TestTrue(TEXT("no format"),
FAssetUsageExporter::FindFormatForClass(FTopLevelAssetPath(), FAssetUsageExporter::GetDefaultFormatByClass()).IsEmpty());
});
It("inherits a format from a base class", [this]()
{
// One "Texture" entry has to cover Texture2D, or every texture subclass needs its own
// line in the settings. Requires the class to be findable, which Engine classes are.
const TMap<FString, FString> Map = { { TEXT("Texture"), TEXT("png") } };
TestEqual(TEXT("Texture2D inherits from Texture"),
FAssetUsageExporter::FindFormatForClass(UTexture2D::StaticClass()->GetClassPathName(), Map),
FString(TEXT("png")));
});
It("prefers the exact entry over an inherited one", [this]()
{
const TMap<FString, FString> Map = {
{ TEXT("Texture"), TEXT("hdr") },
{ TEXT("Texture2D"), TEXT("png") }
};
TestEqual(TEXT("exact wins"),
FAssetUsageExporter::FindFormatForClass(UTexture2D::StaticClass()->GetClassPathName(), Map),
FString(TEXT("png")));
});
});
Describe("The default format map", [this]()
{
It("covers the asset types artists actually export", [this]()
{
const TMap<FString, FString> Defaults = FAssetUsageExporter::GetDefaultFormatByClass();
TestEqual(TEXT("static meshes as fbx"), Defaults.FindRef(TEXT("StaticMesh")), FString(TEXT("fbx")));
TestEqual(TEXT("textures as png"), Defaults.FindRef(TEXT("Texture2D")), FString(TEXT("png")));
TestEqual(TEXT("sounds as wav"), Defaults.FindRef(TEXT("SoundWave")), FString(TEXT("wav")));
});
It("leaves out Blueprint, which has no interchange format", [this]()
{
// A Blueprint entry would promise a conversion the engine cannot perform. Skipping it
// with a counted reason is honest; producing a .t3d text dump labelled "exported" is not.
const TMap<FString, FString> Defaults = FAssetUsageExporter::GetDefaultFormatByClass();
TestFalse(TEXT("no Blueprint entry"), Defaults.Contains(TEXT("Blueprint")));
});
});
Describe("Converting without a usable target", [this]()
{
It("fails cleanly when no target directory is given", [this]()
{
AddExpectedError(TEXT("Conversion aborted: no target directory was given"), EAutomationExpectedErrorFlags::Contains, 1);
FAssetUsageExporter::FExchangeOptions Options;
FAssetUsageExporter::FExportItem Item;
Item.PackageName = FName(TEXT("/Game/Whatever"));
Item.AssetName = FName(TEXT("Whatever"));
const FAssetUsageExporter::FExchangeResult Result =
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestEqual(TEXT("wrote nothing"), Result.FilesWritten, 0);
});
It("counts an asset with no configured format without loading it", [this]()
{
MakeScratchDir(TEXT("NoFormat"));
FAssetUsageExporter::FExchangeOptions Options;
Options.TargetDirectory = ScratchDir;
FAssetUsageExporter::FExportItem Item;
Item.PackageName = FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest"));
Item.AssetName = FName(TEXT("NoSuchAsset_AssetUsageAuditTest"));
Item.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine"), TEXT("Blueprint"));
const FAssetUsageExporter::FExchangeResult Result =
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
TestEqual(TEXT("skipped for want of a format"), Result.SkippedNoFormat, 1);
TestEqual(TEXT("never attempted a load"), Result.SkippedNotLoaded, 0);
TestEqual(TEXT("no errors"), Result.Errors.Num(), 0);
RemoveScratchDir();
});
It("stops when the progress callback asks it to", [this]()
{
MakeScratchDir(TEXT("ConvertCancel"));
FAssetUsageExporter::FExchangeOptions Options;
Options.TargetDirectory = ScratchDir;
int32 Calls = 0;
Options.OnProgress = [&Calls](int32, int32) { ++Calls; return false; };
FAssetUsageExporter::FExportItem Item;
Item.PackageName = FName(TEXT("/Game/A_AssetUsageAuditTest"));
Item.AssetName = FName(TEXT("A_AssetUsageAuditTest"));
const FAssetUsageExporter::FExchangeResult Result =
FAssetUsageExporter::ExportConvertedAssets({ Item, Item }, Options);
TestTrue(TEXT("flagged as cancelled"), Result.bCancelled);
TestFalse(TEXT("not reported successful"), Result.bSuccess);
TestEqual(TEXT("stopped at the first item"), Calls, 1);
RemoveScratchDir();
});
});
Describe("Converting real project content", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("writes a PNG for a texture", [this]()
{
FAssetUsageExporter::FExportItem Item;
if (!FindItemOfClass(UTexture2D::StaticClass(), Item))
{
AddWarning(TEXT("No Texture2D found under /Game; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("Converting '%s'."), *Item.PackageName.ToString()));
MakeScratchDir(TEXT("ConvertTexture"));
FAssetUsageExporter::FExchangeOptions Options;
Options.TargetDirectory = ScratchDir;
const FAssetUsageExporter::FExchangeResult Result =
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
TestTrue(TEXT("succeeded"), Result.bSuccess);
TestEqual(TEXT("one file written"), Result.FilesWritten, 1);
const FString Expected = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT(".png"));
TestTrue(TEXT("the png exists"), IFileManager::Get().FileExists(*Expected));
// An exporter can create the file and write nothing into it; a zero-byte PNG would pass
// an existence check and be useless to whoever opens the folder.
TestTrue(TEXT("the png is not empty"), IFileManager::Get().FileSize(*Expected) > 0);
});
It("writes an FBX for a static mesh", [this]()
{
FAssetUsageExporter::FExportItem Item;
if (!FindItemOfClass(UStaticMesh::StaticClass(), Item))
{
AddWarning(TEXT("No StaticMesh found under /Game; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("Converting '%s'."), *Item.PackageName.ToString()));
MakeScratchDir(TEXT("ConvertMesh"));
FAssetUsageExporter::FExchangeOptions Options;
Options.TargetDirectory = ScratchDir;
const FAssetUsageExporter::FExchangeResult Result =
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
TestTrue(TEXT("succeeded"), Result.bSuccess);
TestEqual(TEXT("one file written"), Result.FilesWritten, 1);
const FString Expected = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT(".fbx"));
TestTrue(TEXT("the fbx exists"), IFileManager::Get().FileExists(*Expected));
TestTrue(TEXT("the fbx is not empty"), IFileManager::Get().FileSize(*Expected) > 0);
});
It("indexes the second conversion of the same asset", [this]()
{
FAssetUsageExporter::FExportItem Item;
if (!FindItemOfClass(UTexture2D::StaticClass(), Item))
{
AddWarning(TEXT("No Texture2D found under /Game; skipping."));
return;
}
MakeScratchDir(TEXT("ConvertIndex"));
FAssetUsageExporter::FExchangeOptions Options;
Options.TargetDirectory = ScratchDir;
Options.CollisionPolicy = EExportCollisionPolicy::Index;
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
const FAssetUsageExporter::FExchangeResult Second =
FAssetUsageExporter::ExportConvertedAssets({ Item }, Options);
TestEqual(TEXT("second run renamed"), Second.FilesRenamed, 1);
TestEqual(TEXT("two files on disk"), CountFilesInScratch(TEXT("*.png")), 2);
const FString Indexed = FPaths::Combine(ScratchDir, Item.AssetName.ToString() + TEXT("_1.png"));
TestTrue(TEXT("_1 present"), IFileManager::Get().FileExists(*Indexed));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,357 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportLayout.h"
#include "AssetDependencyClosure.h"
#include "AssetUsageExporter.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/StaticMesh.h"
#include "Engine/Texture2D.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* Where exported files land.
*
* The grouping logic is pure enough to test on invented package names; the type-folder lookup and
* the end-to-end copy need real content and say so when it is missing.
*/
BEGIN_DEFINE_SPEC(AssetExportLayoutSpec,
"AssetUsageAudit.ExportLayout",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** A closure with a made-up seed and dependencies, for testing grouping alone. */
static AssetDependencyClosure::FSeedClosure MakeClosure(const TCHAR* Seed, std::initializer_list<const TCHAR*> Dependencies)
{
AssetDependencyClosure::FSeedClosure Closure;
Closure.Seed = FName(Seed);
Closure.Packages.Add(Closure.Seed);
for (const TCHAR* Dependency : Dependencies)
{
Closure.Packages.Add(FName(Dependency));
}
return Closure;
}
/** First /Game asset of a class, or NAME_None. */
static FName FindPackageOfClass(UClass* Class)
{
FARFilter Filter;
Filter.ClassPaths.Add(Class->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bRecursiveClasses = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Found;
ReadyRegistry().EnumerateAssets(Filter, [&Found](const FAssetData& AssetData)
{
Found.Add(AssetData.PackageName);
return Found.Num() < 64;
});
if (Found.IsEmpty())
{
return NAME_None;
}
Found.Sort(FNameLexicalLess());
return Found[0];
}
END_DEFINE_SPEC(AssetExportLayoutSpec)
void AssetExportLayoutSpec::Define()
{
Describe("Flat and mirrored layouts", [this]()
{
It("collapses every closure into a single unnamed group", [this]()
{
const TArray<AssetDependencyClosure::FSeedClosure> Closures = {
MakeClosure(TEXT("/Game/A"), { TEXT("/Game/Shared") }),
MakeClosure(TEXT("/Game/B"), { TEXT("/Game/Shared") })
};
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::Flat;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats);
TestEqual(TEXT("one group"), Groups.Num(), 1);
TestTrue(TEXT("writing into the target itself"), Groups[0].RelativeDir.IsEmpty());
// The shared dependency appears once: a flat folder cannot hold it twice, and pretending
// otherwise would make the file count disagree with what gets written.
TestEqual(TEXT("three distinct packages"), Groups[0].Packages.Num(), 3);
TestEqual(TEXT("stats agree"), Stats.FilePlacements, 3);
TestEqual(TEXT("distinct count matches"), Stats.DistinctPackages, 3);
});
It("treats MirrorTree the same at this level", [this]()
{
// Mirroring is the exporter's job, applied per file inside whichever folder a group
// names. The layout planner has nothing extra to do for it.
const TArray<AssetDependencyClosure::FSeedClosure> Closures = {
MakeClosure(TEXT("/Game/A"), { TEXT("/Game/Shared") })
};
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::MirrorTree;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats);
TestEqual(TEXT("one group"), Groups.Num(), 1);
TestTrue(TEXT("no subfolder from the planner"), Groups[0].RelativeDir.IsEmpty());
});
});
Describe("Folder per asset", [this]()
{
It("gives each seed its own folder named after the asset", [this]()
{
const TArray<AssetDependencyClosure::FSeedClosure> Closures = {
MakeClosure(TEXT("/Game/Art/SM_Rock"), {}),
MakeClosure(TEXT("/Game/Art/SM_Tree"), {})
};
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::FolderPerAsset;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats);
TestEqual(TEXT("one group per seed"), Groups.Num(), 2);
TestEqual(TEXT("named after the asset, not the path"), Groups[0].RelativeDir, FString(TEXT("SM_Rock")));
TestEqual(TEXT("second folder"), Groups[1].RelativeDir, FString(TEXT("SM_Tree")));
});
It("copies a shared dependency into every folder that needs it", [this]()
{
// The defining property of this layout: each folder must be self-contained, so a texture
// used by two meshes is written twice. Deduplicating it would leave the second folder
// opening with missing content.
const TArray<AssetDependencyClosure::FSeedClosure> Closures = {
MakeClosure(TEXT("/Game/Art/SM_Rock"), { TEXT("/Game/Art/T_Shared") }),
MakeClosure(TEXT("/Game/Art/SM_Tree"), { TEXT("/Game/Art/T_Shared") })
};
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::FolderPerAsset;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats);
int32 SharedPlacements = 0;
for (const AssetExportLayout::FGroup& Group : Groups)
{
SharedPlacements += Group.Packages.Contains(FName(TEXT("/Game/Art/T_Shared"))) ? 1 : 0;
}
TestEqual(TEXT("the texture lands in both folders"), SharedPlacements, 2);
// Three distinct packages, four files written. This gap is what the export dialog has
// to show, and the stats have to keep the two numbers apart to make that possible.
TestEqual(TEXT("distinct packages"), Stats.DistinctPackages, 3);
TestEqual(TEXT("file placements"), Stats.FilePlacements, 4);
});
It("makes folder names unique when two assets share a short name", [this]()
{
// /Game/A/SM_Rock and /Game/B/SM_Rock are different assets with the same name. Without
// a suffix they would share one folder and interleave their dependencies.
const TArray<AssetDependencyClosure::FSeedClosure> Closures = {
MakeClosure(TEXT("/Game/A/SM_Rock"), {}),
MakeClosure(TEXT("/Game/B/SM_Rock"), {})
};
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::FolderPerAsset;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closures, Options, Stats);
TestEqual(TEXT("two groups"), Groups.Num(), 2);
TestNotEqual(TEXT("folders differ"), Groups[0].RelativeDir, Groups[1].RelativeDir);
TestEqual(TEXT("the rename was counted"), Stats.RenamedFolders, 1);
});
It("keeps the seed at the root of its own folder when grouping by type", [this]()
{
const FName MeshPackage = FindPackageOfClass(UStaticMesh::StaticClass());
if (MeshPackage.IsNone())
{
AddWarning(TEXT("No /Game StaticMesh found; skipping."));
return;
}
AssetDependencyClosure::FSeedClosure Closure;
Closure.Seed = MeshPackage;
Closure.Packages.Add(MeshPackage);
AssetExportLayout::FOptions Options;
Options.Layout = EExportLayout::FolderPerAsset;
Options.bGroupDependenciesByType = true;
AssetExportLayout::FStats Stats;
const TArray<AssetExportLayout::FGroup> Groups =
AssetExportLayout::BuildGroups(ReadyRegistry(), Closure.Packages.IsEmpty()
? TArray<AssetDependencyClosure::FSeedClosure>()
: TArray<AssetDependencyClosure::FSeedClosure>{ Closure },
Options, Stats);
TestEqual(TEXT("one group"), Groups.Num(), 1);
// The seed is the subject of its folder. Filing it under StaticMesh/ beside its own
// dependencies would bury the thing the folder is named after.
const FString Expected = FPackageName::GetShortName(MeshPackage.ToString());
TestEqual(TEXT("seed sits at the folder root"), Groups[0].RelativeDir, Expected);
});
});
Describe("Type folders", [this]()
{
It("names folders after the type presets, not invented labels", [this]()
{
const TMap<FTopLevelAssetPath, FString> FolderByClass = AssetExportLayout::BuildTypeFolderMap(ReadyRegistry());
const FString* TextureFolder = FolderByClass.Find(UTexture2D::StaticClass()->GetClassPathName());
if (!TextureFolder)
{
AddWarning(TEXT("Texture2D missing from the type folder map; skipping."));
return;
}
// The Types filter in the panel shows "Texture"; the folder a user gets must match the
// word they filtered by, or the two halves of the tool disagree.
TestEqual(TEXT("Texture2D lands under Texture"), *TextureFolder, FString(TEXT("Texture")));
});
It("returns Other rather than an empty name for an unmapped package", [this]()
{
// An empty folder name would silently write into the parent folder and look like the
// grouping had failed rather than like a type nobody mapped.
const TMap<FTopLevelAssetPath, FString> FolderByClass = AssetExportLayout::BuildTypeFolderMap(ReadyRegistry());
const FString Folder = AssetExportLayout::TypeFolderForPackage(
ReadyRegistry(), FName(TEXT("/Game/NoSuchPackage_AssetUsageAuditTest")), FolderByClass);
TestEqual(TEXT("falls back to Other"), Folder, FString(TEXT("Other")));
});
});
Describe("Groups reaching the exporter", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("writes each group into its own subfolder", [this]()
{
const FName Package = FindPackageOfClass(UTexture2D::StaticClass());
if (Package.IsNone())
{
AddWarning(TEXT("No /Game Texture2D found; skipping."));
return;
}
MakeScratchDir(TEXT("LayoutGroups"));
// The same package in two groups: it must be written twice, once per folder, and
// neither copy renamed - they do not collide because they are in different folders.
FAssetUsageExporter::FExportGroup First;
First.RelativeDir = TEXT("FolderOne");
First.Packages.Add(Package);
FAssetUsageExporter::FExportGroup Second;
Second.RelativeDir = TEXT("FolderTwo");
Second.Packages.Add(Package);
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageGroups({ First, Second }, Options);
TestTrue(TEXT("succeeded"), Result.bSuccess);
TestEqual(TEXT("copied once per group"), Result.FilesCopied, 2);
TestEqual(TEXT("nothing renamed"), Result.FilesRenamed, 0);
const FString FileName = FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package));
TestTrue(TEXT("first folder"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, TEXT("FolderOne"), FileName)));
TestTrue(TEXT("second folder"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, TEXT("FolderTwo"), FileName)));
});
It("still writes into the target when a group has no subfolder", [this]()
{
const FName Package = FindPackageOfClass(UTexture2D::StaticClass());
if (Package.IsNone())
{
AddWarning(TEXT("No /Game Texture2D found; skipping."));
return;
}
MakeScratchDir(TEXT("LayoutRoot"));
FAssetUsageExporter::FExportGroup Group;
Group.Packages.Add(Package);
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageGroups({ Group }, Options);
TestEqual(TEXT("one file"), Result.FilesCopied, 1);
const FString FileName = FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package));
TestTrue(TEXT("straight into the target"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, FileName)));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,266 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportManifest.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Dom/JsonObject.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(AssetExportManifestSpec,
"AssetUsageAudit.ExportManifest",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** Parse the manifest the writer just produced. Null when it is missing or malformed. */
TSharedPtr<FJsonObject> ReadManifest() const
{
FString Text;
if (!FFileHelper::LoadFileToString(Text, *FPaths::Combine(ScratchDir, AssetExportManifest::FileName)))
{
return nullptr;
}
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Text);
return FJsonSerializer::Deserialize(Reader, Root) ? Root : nullptr;
}
END_DEFINE_SPEC(AssetExportManifestSpec)
void AssetExportManifestSpec::Define()
{
Describe("Refusing to write a misleading manifest", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("fails when the exporter recorded nothing", [this]()
{
// The usual cause is a caller that forgot bRecordWrittenFiles. An empty manifest beside
// a folder full of assets would be believed by whatever reads it next.
MakeScratchDir(TEXT("ManifestEmpty"));
AddExpectedError(TEXT("the exporter recorded no files"), EAutomationExpectedErrorFlags::Contains, 1);
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), {}, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestTrue(TEXT("says why"), !Result.ErrorMessage.IsEmpty());
TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists(
*FPaths::Combine(ScratchDir, AssetExportManifest::FileName)));
});
It("fails when no target directory is given", [this]()
{
AddExpectedError(TEXT("no target directory"), EAutomationExpectedErrorFlags::Contains, 1);
AssetExportManifest::FOptions Options;
TArray<FAssetUsageExporter::FWrittenFile> Written;
Written.AddDefaulted_GetRef().PackageName = FName(TEXT("/Game/Whatever"));
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
});
It("refuses under the Migrate layout", [this]()
{
// Migrate keeps package paths intact, so there is nothing to restore. A manifest there
// would imply the references had been broken.
MakeScratchDir(TEXT("ManifestMigrate"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.Layout = EExportLayout::Migrate;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/Whatever"));
Entry.RelativePath = TEXT("Whatever.uasset");
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestFalse(TEXT("wrote no file"), IFileManager::Get().FileExists(
*FPaths::Combine(ScratchDir, AssetExportManifest::FileName)));
});
});
Describe("The manifest it writes", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("is valid JSON carrying the schema version and the layout", [this]()
{
MakeScratchDir(TEXT("ManifestBasic"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.Layout = EExportLayout::FolderPerAsset;
Options.CollisionPolicy = EExportCollisionPolicy::Index;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/SomeAsset_AssetUsageAuditTest"));
Entry.RelativePath = TEXT("SomeAsset/SomeAsset.uasset");
const AssetExportManifest::FResult Result =
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
TestTrue(TEXT("successful"), Result.bSuccess);
const TSharedPtr<FJsonObject> Root = ReadManifest();
TestTrue(TEXT("parses as JSON"), Root.IsValid());
if (!Root.IsValid())
{
return;
}
TestEqual(TEXT("schema version"),
static_cast<int32>(Root->GetNumberField(TEXT("schemaVersion"))),
AssetExportManifest::SchemaVersion);
TestEqual(TEXT("records the layout"), Root->GetStringField(TEXT("layout")), FString(TEXT("FolderPerAsset")));
TestEqual(TEXT("records the naming policy"), Root->GetStringField(TEXT("collisionPolicy")), FString(TEXT("Index")));
TestTrue(TEXT("has an assets array"), Root->HasField(TEXT("assets")));
});
It("records the path that was actually written, not the intended one", [this]()
{
// The case the manifest exists for: the collision policy renamed the file, so a manifest
// built from the package name alone would point at a file that is not there.
MakeScratchDir(TEXT("ManifestRenamed"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& Entry = Written.AddDefaulted_GetRef();
Entry.PackageName = FName(TEXT("/Game/SM_Rock_AssetUsageAuditTest"));
Entry.RelativePath = TEXT("SM_Rock_AssetUsageAuditTest_2.uasset");
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
const TSharedPtr<FJsonObject> Root = ReadManifest();
TestTrue(TEXT("parses"), Root.IsValid());
if (!Root.IsValid())
{
return;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
TestTrue(TEXT("has assets"), Root->TryGetArrayField(TEXT("assets"), Assets));
if (!Assets || Assets->Num() != 1)
{
AddError(TEXT("expected exactly one asset entry"));
return;
}
const TSharedPtr<FJsonObject> First = (*Assets)[0]->AsObject();
TestEqual(TEXT("original package name"), First->GetStringField(TEXT("package")),
FString(TEXT("/Game/SM_Rock_AssetUsageAuditTest")));
TestEqual(TEXT("the renamed file"), First->GetStringField(TEXT("file")),
FString(TEXT("SM_Rock_AssetUsageAuditTest_2.uasset")));
});
It("separates the assets that were ticked from those pulled in", [this]()
{
// "Restore what I exported" and "restore everything in this folder" are different
// requests, and only the manifest can tell them apart after the fact.
MakeScratchDir(TEXT("ManifestSeeds"));
const FName SeedPackage(TEXT("/Game/Seed_AssetUsageAuditTest"));
const FName DependencyPackage(TEXT("/Game/Dependency_AssetUsageAuditTest"));
AssetExportManifest::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.SeedPackages = { SeedPackage };
TArray<FAssetUsageExporter::FWrittenFile> Written;
FAssetUsageExporter::FWrittenFile& SeedEntry = Written.AddDefaulted_GetRef();
SeedEntry.PackageName = SeedPackage;
SeedEntry.RelativePath = TEXT("Seed.uasset");
FAssetUsageExporter::FWrittenFile& DependencyEntry = Written.AddDefaulted_GetRef();
DependencyEntry.PackageName = DependencyPackage;
DependencyEntry.RelativePath = TEXT("Dependency.uasset");
AssetExportManifest::Write(ReadyRegistry(), Written, Options);
const TSharedPtr<FJsonObject> Root = ReadManifest();
if (!Root.IsValid())
{
AddError(TEXT("manifest did not parse"));
return;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
Root->TryGetArrayField(TEXT("assets"), Assets);
if (!Assets || Assets->Num() != 2)
{
AddError(TEXT("expected two asset entries"));
return;
}
int32 SeedCount = 0;
for (const TSharedPtr<FJsonValue>& Value : *Assets)
{
if (Value->AsObject()->GetBoolField(TEXT("seed")))
{
++SeedCount;
}
}
TestEqual(TEXT("exactly one entry is a seed"), SeedCount, 1);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,289 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportNaming.h"
#include "AssetUsageAuditTypes.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
namespace
{
/** Builds an Exists predicate over a fixed set of filenames, so no filesystem is involved. */
struct FFakeFolder
{
TSet<FString> Files;
explicit FFakeFolder(std::initializer_list<const TCHAR*> InFiles)
{
for (const TCHAR* File : InFiles)
{
Files.Add(FString(File));
}
}
bool Contains(const FString& FileName) const
{
return Files.Contains(FileName);
}
};
}
BEGIN_DEFINE_SPEC(AssetExportNamingSpec,
"AssetUsageAudit.ExportNaming",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
END_DEFINE_SPEC(AssetExportNamingSpec)
void AssetExportNamingSpec::Define()
{
Describe("SplitTrailingIndex", [this]()
{
It("reports no index for a plain name", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
TestEqual(TEXT("PadWidth"), PadWidth, 0);
});
It("splits a single-digit trailing index", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_7"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
TestEqual(TEXT("Index"), Index, 7);
TestEqual(TEXT("PadWidth"), PadWidth, 1);
});
It("preserves zero padding width", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_007"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
TestEqual(TEXT("Index"), Index, 7);
TestEqual(TEXT("PadWidth"), PadWidth, 3);
});
It("treats a non-numeric suffix as part of the name", [this]()
{
// The trap this whole function exists for: _v3 must not be read as an index,
// and the _02 in the middle must not be touched.
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02_v3"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_02_v3")));
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
});
It("splits a mid-name numeric group only when it is trailing", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
TestEqual(TEXT("Index"), Index, 2);
TestEqual(TEXT("PadWidth"), PadWidth, 2);
});
It("treats an empty suffix as no index", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_")));
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
});
It("does not treat a leading underscore as a separator", [this]()
{
FString Stem;
int32 Index = 0;
int32 PadWidth = 0;
AssetExportNaming::SplitTrailingIndex(TEXT("_1"), Stem, Index, PadWidth);
TestEqual(TEXT("Stem"), Stem, FString(TEXT("_1")));
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
});
});
Describe("ComposeIndexedName", [this]()
{
It("returns the bare stem for INDEX_NONE", [this]()
{
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), INDEX_NONE, 0), FString(TEXT("Foo")));
});
It("appends an unpadded index", [this]()
{
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 1, 1), FString(TEXT("Foo_1")));
});
It("restores zero padding", [this]()
{
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 8, 3), FString(TEXT("Foo_008")));
});
It("widens rather than truncates when the number outgrows the padding", [this]()
{
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 100, 2), FString(TEXT("Foo_100")));
});
});
Describe("ResolveCollision under Overwrite", [this]()
{
It("returns the desired name even when it is taken", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Overwrite,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset")));
});
});
Describe("ResolveCollision under Index", [this]()
{
It("returns the desired name when the folder is empty", [this]()
{
const FFakeFolder Folder({});
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset")));
});
It("appends _1 on the first collision", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.uasset")));
});
It("advances to _2 when _1 is also taken", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock.uasset"), TEXT("SM_Rock_1.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_2.uasset")));
});
It("increments an existing index instead of appending a second one", [this]()
{
// The explicit requirement: Foo_7 must become Foo_8, never Foo_7_1.
const FFakeFolder Folder({ TEXT("SM_Rock_7.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock_7.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_8.uasset")));
TestFalse(TEXT("Must not contain a doubled index"), Result.Contains(TEXT("_7_")));
});
It("keeps zero padding when incrementing", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock_007.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock_007.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_008.uasset")));
});
It("widens padding when the increment carries", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock_99.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock_99.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_100.uasset")));
});
It("does not mistake a version suffix for an index", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock_02_v3.uasset") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock_02_v3.uasset"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_02_v3_1.uasset")));
});
It("preserves the extension", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock.fbx") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.fbx"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.fbx")));
});
It("handles a name with no extension", [this]()
{
const FFakeFolder Folder({ TEXT("SM_Rock") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1")));
});
It("skips over a gap-free run of taken indices", [this]()
{
const FFakeFolder Folder({
TEXT("T_Wall.png"), TEXT("T_Wall_1.png"), TEXT("T_Wall_2.png"), TEXT("T_Wall_3.png") });
const FString Result = AssetExportNaming::ResolveCollision(
TEXT("T_Wall.png"),
EExportCollisionPolicy::Index,
[&Folder](const FString& Name) { return Folder.Contains(Name); });
TestEqual(TEXT("Result"), Result, FString(TEXT("T_Wall_4.png")));
});
It("is idempotent for a name that does not collide", [this]()
{
const FFakeFolder Folder({ TEXT("Other.uasset") });
const auto Predicate = [&Folder](const FString& Name) { return Folder.Contains(Name); };
const FString First = AssetExportNaming::ResolveCollision(TEXT("SM_Rock.uasset"), EExportCollisionPolicy::Index, Predicate);
const FString Second = AssetExportNaming::ResolveCollision(First, EExportCollisionPolicy::Index, Predicate);
TestEqual(TEXT("Stable"), Second, First);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,296 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageExporter.h"
#include "AssetExportNaming.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(AssetUsageExporterSpec,
"AssetUsageAudit.Exporter",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
/** Create a throwaway folder under Saved/ so a failed run never touches project content. */
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
void WriteStubFile(const FString& FileName)
{
FFileHelper::SaveStringToFile(TEXT("stub"), *FPaths::Combine(ScratchDir, FileName));
}
bool FileExistsInScratch(const FString& FileName) const
{
return IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, FileName));
}
END_DEFINE_SPEC(AssetUsageExporterSpec)
void AssetUsageExporterSpec::Define()
{
Describe("ResolvePackageFilePath", [this]()
{
It("returns empty for a package that does not exist", [this]()
{
const FString Path = FAssetUsageExporter::ResolvePackageFilePath(
FName(TEXT("/Game/ThisPackageDoesNotExist_AssetUsageAuditTest")));
TestTrue(TEXT("no file for a missing package"), Path.IsEmpty());
});
It("returns empty for a script package rather than inventing a path", [this]()
{
// Script packages are code. Handing back a plausible path would make the exporter
// report a copy it never performed.
const FString Path = FAssetUsageExporter::ResolvePackageFilePath(FName(TEXT("/Script/Engine")));
TestTrue(TEXT("no file for a script package"), Path.IsEmpty());
});
It("returns empty for a malformed package name", [this]()
{
TestTrue(TEXT("empty name"), FAssetUsageExporter::ResolvePackageFilePath(FName()).IsEmpty());
TestTrue(TEXT("not a long package name"), FAssetUsageExporter::ResolvePackageFilePath(FName(TEXT("NotAPackage"))).IsEmpty());
});
});
Describe("ExportPackageFiles", [this]()
{
AfterEach([this]()
{
RemoveScratchDir();
});
It("fails cleanly when no target directory is given", [this]()
{
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory.Reset();
// The exporter logs this at Error level on purpose - it is a setup mistake the user
// must see in the Output Log. The automation framework treats any logged Error during
// a test as a failure, so the expected message has to be declared.
AddExpectedError(TEXT("Export aborted: no target directory was given"), EAutomationExpectedErrorFlags::Contains, 1);
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageFiles({ FName(TEXT("/Game/Whatever")) }, Options);
TestFalse(TEXT("not successful"), Result.bSuccess);
TestTrue(TEXT("reports why"), Result.Errors.Num() > 0);
TestEqual(TEXT("copied nothing"), Result.FilesCopied, 0);
});
It("creates the target directory when it is missing", [this]()
{
MakeScratchDir(TEXT("CreatesDir"));
const FString Nested = FPaths::Combine(ScratchDir, TEXT("Nested/Deeper"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = Nested;
FAssetUsageExporter::ExportPackageFiles({}, Options);
TestTrue(TEXT("directory created"), IFileManager::Get().DirectoryExists(*Nested));
});
It("counts a package with no file on disk without calling it an error", [this]()
{
// A script package or an unsaved asset is an ordinary outcome, not a failure. Folding
// it into Errors would bury real copy failures in noise.
MakeScratchDir(TEXT("MissingOnDisk"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result = FAssetUsageExporter::ExportPackageFiles(
{ FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")) }, Options);
TestEqual(TEXT("counted as missing"), Result.FilesMissingOnDisk, 1);
TestEqual(TEXT("no errors"), Result.Errors.Num(), 0);
TestEqual(TEXT("nothing copied"), Result.FilesCopied, 0);
});
It("reports progress with a total that matches the request", [this]()
{
MakeScratchDir(TEXT("Progress"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
int32 SeenTotal = INDEX_NONE;
int32 Calls = 0;
Options.OnProgress = [&SeenTotal, &Calls](int32 Done, int32 Total)
{
SeenTotal = Total;
++Calls;
return true;
};
const TArray<FName> Packages = {
FName(TEXT("/Game/A_AssetUsageAuditTest")),
FName(TEXT("/Game/B_AssetUsageAuditTest")),
FName(TEXT("/Game/C_AssetUsageAuditTest"))
};
FAssetUsageExporter::ExportPackageFiles(Packages, Options);
TestEqual(TEXT("total matches"), SeenTotal, 3);
TestEqual(TEXT("one call per package"), Calls, 3);
});
It("stops when the progress callback asks it to", [this]()
{
MakeScratchDir(TEXT("Cancel"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
int32 Calls = 0;
Options.OnProgress = [&Calls](int32, int32)
{
++Calls;
return false; // cancel immediately
};
const TArray<FName> Packages = {
FName(TEXT("/Game/A_AssetUsageAuditTest")),
FName(TEXT("/Game/B_AssetUsageAuditTest")),
FName(TEXT("/Game/C_AssetUsageAuditTest"))
};
const FAssetUsageExporter::FResult Result = FAssetUsageExporter::ExportPackageFiles(Packages, Options);
TestTrue(TEXT("flagged as cancelled"), Result.bCancelled);
TestFalse(TEXT("not reported successful"), Result.bSuccess);
TestEqual(TEXT("stopped at the first item"), Calls, 1);
});
It("de-duplicates a package listed twice", [this]()
{
MakeScratchDir(TEXT("Dedup"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
int32 Calls = 0;
Options.OnProgress = [&Calls](int32, int32) { ++Calls; return true; };
const FName Same(TEXT("/Game/Duplicate_AssetUsageAuditTest"));
FAssetUsageExporter::ExportPackageFiles({ Same, Same, Same }, Options);
TestEqual(TEXT("processed once"), Calls, 1);
});
});
Describe("Collision policy as the exporter applies it", [this]()
{
AfterEach([this]()
{
RemoveScratchDir();
});
It("picks an indexed name against real files on disk", [this]()
{
// The exporter feeds a filesystem predicate into ResolveCollision. This checks the two
// sides agree: a file that exists on disk must be treated as taken.
MakeScratchDir(TEXT("Collision"));
WriteStubFile(TEXT("SM_Rock.uasset"));
WriteStubFile(TEXT("SM_Rock_1.uasset"));
IFileManager& FileManager = IFileManager::Get();
const FString Dir = ScratchDir;
const FString Resolved = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Index,
[&FileManager, &Dir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(Dir, Candidate));
});
TestEqual(TEXT("skips both taken names"), Resolved, FString(TEXT("SM_Rock_2.uasset")));
});
It("keeps the original name when the folder is empty", [this]()
{
MakeScratchDir(TEXT("NoCollision"));
IFileManager& FileManager = IFileManager::Get();
const FString Dir = ScratchDir;
const FString Resolved = AssetExportNaming::ResolveCollision(
TEXT("SM_Rock.uasset"),
EExportCollisionPolicy::Index,
[&FileManager, &Dir](const FString& Candidate)
{
return FileManager.FileExists(*FPaths::Combine(Dir, Candidate));
});
TestEqual(TEXT("unchanged"), Resolved, FString(TEXT("SM_Rock.uasset")));
});
});
Describe("FResult::Summarise", [this]()
{
It("always states how many files were copied", [this]()
{
FAssetUsageExporter::FResult Result;
Result.FilesCopied = 7;
TestTrue(TEXT("mentions the count"), Result.Summarise().Contains(TEXT("7 copied")));
});
It("mentions renames, overwrites and cancellation when they happened", [this]()
{
FAssetUsageExporter::FResult Result;
Result.FilesCopied = 3;
Result.FilesRenamed = 2;
Result.FilesOverwritten = 1;
Result.bCancelled = true;
const FString Summary = Result.Summarise();
TestTrue(TEXT("renames"), Summary.Contains(TEXT("renamed")));
TestTrue(TEXT("overwrites"), Summary.Contains(TEXT("overwritten")));
TestTrue(TEXT("cancellation"), Summary.Contains(TEXT("cancelled")));
});
It("stays quiet about categories that did not occur", [this]()
{
FAssetUsageExporter::FResult Result;
Result.FilesCopied = 1;
const FString Summary = Result.Summarise();
TestFalse(TEXT("no rename noise"), Summary.Contains(TEXT("renamed")));
TestFalse(TEXT("no failure noise"), Summary.Contains(TEXT("failed")));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,446 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageExporter.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/Level.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "Misc/SecureHash.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* Exporter tests against the project's real content.
*
* The sibling spec (AssetUsageAudit.Exporter) covers the exporter's logic with names that
* deliberately do not exist, so it proves the bookkeeping and nothing else: until this file existed
* the exporter had never copied a single real .uasset. The interesting failures - a Perforce
* read-only source, an OFPA level whose external actors live under a stale duplicate folder, a
* package whose file extension is .umap rather than .uasset - only appear on real files.
*
* Everything here discovers its subject from the Asset Registry rather than hardcoding a path, so
* the spec keeps working after content moves, and warns instead of failing on a clone that has no
* suitable asset.
*/
BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec,
"AssetUsageAudit.ExporterLive",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
/**
* A partially scanned registry answers "no such asset" indistinguishably from "no such asset
* exists", which would turn a real failure into a silent skip.
*/
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** Files on disk under a package's file, or 0 when the package has no file. */
static int64 FileSizeOf(FName PackageName)
{
const FString Path = FAssetUsageExporter::ResolvePackageFilePath(PackageName);
return Path.IsEmpty() ? 0 : IFileManager::Get().FileSize(*Path);
}
static FString HashOf(const FString& FilePath)
{
FMD5Hash Hash = FMD5Hash::HashFile(*FilePath);
return Hash.IsValid() ? LexToString(Hash) : FString();
}
/**
* Any ordinary /Game asset that has a file on disk.
*
* Excludes levels (a .umap drags external actors in), OFPA packages, and redirectors. Sorted so
* two runs on the same content pick the same asset and a failure is reproducible.
*/
static FName FindOrdinaryAssetPackage()
{
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Candidates;
ReadyRegistry().EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData)
{
// Compare class paths rather than calling GetClass(): GetClass() resolves the UClass and
// returns null for anything not loaded, which would let levels through.
if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
return true;
}
const FString PackageName = AssetData.PackageName.ToString();
if (PackageName.Contains(TEXT("__External")))
{
return true;
}
Candidates.Add(AssetData.PackageName);
// A few hundred is plenty to sort; enumerating all 80k just to pick one is waste.
return Candidates.Num() < 512;
});
if (Candidates.IsEmpty())
{
return NAME_None;
}
Candidates.Sort(FNameLexicalLess());
for (FName Candidate : Candidates)
{
if (!FAssetUsageExporter::ResolvePackageFilePath(Candidate).IsEmpty())
{
return Candidate;
}
}
return NAME_None;
}
/**
* How many .uasset files live under a level's registered external roots.
*
* Uses the plural GetExternalActorsPaths / GetExternalObjectsPaths rather than a hand-built
* "__ExternalActors__/..." string. This project contains a stale duplicate of exactly that
* shape - Content/__ExternalActors__/levelinterior_station alongside the live
* Content/__ExternalActors__/Interior_station/levelinterior_station - so a string-built path
* would silently count the wrong folder.
*/
static int32 CountExternalPackages(FName LevelPackage)
{
const FString LevelPackageStr = LevelPackage.ToString();
TArray<FString> Roots = ULevel::GetExternalActorsPaths(LevelPackageStr);
Roots.Append(ULevel::GetExternalObjectsPaths(LevelPackageStr));
// The returned roots overlap: measured on this project, summing the per-root file counts
// gives exactly twice the number of packages that exist. Deduplicate by absolute file path
// rather than by package name, so this stays an independent filesystem measurement instead
// of a restatement of the exporter's own bookkeeping.
TSet<FString> UniqueFiles;
for (const FString& Root : Roots)
{
FString RootDir;
if (!FPackageName::TryConvertLongPackageNameToFilename(Root, RootDir))
{
continue;
}
TArray<FString> Found;
IFileManager::Get().FindFilesRecursive(Found, *RootDir, TEXT("*.uasset"), true, false);
for (const FString& File : Found)
{
UniqueFiles.Add(FPaths::ConvertRelativePathToFull(File));
}
}
return UniqueFiles.Num();
}
/**
* The OFPA level with the fewest external actors.
*
* Smallest on purpose: WP_Main owns roughly 16000 external packages and copying those in a test
* would move gigabytes. Search stops as soon as something small enough turns up.
*/
static FName FindSmallestOfpaLevel(int32& OutExternalCount)
{
FARFilter Filter;
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
FName Best = NAME_None;
int32 BestCount = MAX_int32;
ReadyRegistry().EnumerateAssets(Filter, [&Best, &BestCount](const FAssetData& AssetData)
{
const int32 Count = CountExternalPackages(AssetData.PackageName);
if (Count > 0 && Count < BestCount)
{
BestCount = Count;
Best = AssetData.PackageName;
}
// Good enough to prove the expansion works without a long copy.
return BestCount > 40;
});
OutExternalCount = (Best.IsNone()) ? 0 : BestCount;
return Best;
}
int32 CountFilesInScratch() const
{
TArray<FString> Found;
IFileManager::Get().FindFilesRecursive(Found, *ScratchDir, TEXT("*.*"), true, false);
return Found.Num();
}
END_DEFINE_SPEC(AssetUsageExporterLiveSpec)
void AssetUsageExporterLiveSpec::Define()
{
Describe("Copying a real asset", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("reproduces the source file byte for byte", [this]()
{
const FName Package = FindOrdinaryAssetPackage();
if (Package.IsNone())
{
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveCopy"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
TestTrue(TEXT("succeeded"), Result.bSuccess);
TestEqual(TEXT("one file copied"), Result.FilesCopied, 1);
TestEqual(TEXT("nothing missing"), Result.FilesMissingOnDisk, 0);
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
const FString DestPath = FPaths::Combine(ScratchDir, FPaths::GetCleanFilename(SourcePath));
TestTrue(TEXT("destination exists"), IFileManager::Get().FileExists(*DestPath));
TestEqual(TEXT("same size"), IFileManager::Get().FileSize(*DestPath), FileSizeOf(Package));
TestEqual(TEXT("same content"), HashOf(DestPath), HashOf(SourcePath));
});
It("leaves the exported file writable even when the source is read-only", [this]()
{
// This project is Perforce-primary, so unopened files are read-only on disk and Windows
// CopyFile carries the attribute across. A read-only export folder is useless to an
// artist and breaks the exporter's own Overwrite policy on the next run.
const FName Package = FindOrdinaryAssetPackage();
if (Package.IsNone())
{
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveReadOnly"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
const FString DestPath = FPaths::Combine(
ScratchDir, FPaths::GetCleanFilename(FAssetUsageExporter::ResolvePackageFilePath(Package)));
TestFalse(TEXT("destination is writable"), IFileManager::Get().IsReadOnly(*DestPath));
});
});
Describe("Collision policy on real files", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("writes a second copy beside the first under the Index policy", [this]()
{
const FName Package = FindOrdinaryAssetPackage();
if (Package.IsNone())
{
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveIndex"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.CollisionPolicy = EExportCollisionPolicy::Index;
Options.bIncludeExternalPackages = false;
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
const FAssetUsageExporter::FResult Second =
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
const FAssetUsageExporter::FResult Third =
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
TestEqual(TEXT("second run renamed"), Second.FilesRenamed, 1);
TestEqual(TEXT("third run renamed"), Third.FilesRenamed, 1);
TestEqual(TEXT("three files on disk"), CountFilesInScratch(), 3);
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
const FString Base = FPaths::GetBaseFilename(SourcePath);
const FString Ext = FPaths::GetExtension(SourcePath, true);
TestTrue(TEXT("_1 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_1") + Ext)));
TestTrue(TEXT("_2 present"), IFileManager::Get().FileExists(*FPaths::Combine(ScratchDir, Base + TEXT("_2") + Ext)));
});
It("replaces the previous file under the Overwrite policy", [this]()
{
const FName Package = FindOrdinaryAssetPackage();
if (Package.IsNone())
{
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveOverwrite"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.CollisionPolicy = EExportCollisionPolicy::Overwrite;
Options.bIncludeExternalPackages = false;
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
const FAssetUsageExporter::FResult Second =
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
TestTrue(TEXT("second run succeeded"), Second.bSuccess);
TestEqual(TEXT("counted as an overwrite"), Second.FilesOverwritten, 1);
TestEqual(TEXT("nothing renamed"), Second.FilesRenamed, 0);
TestEqual(TEXT("still one file"), CountFilesInScratch(), 1);
});
});
Describe("Mirroring the package tree", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("places the file under its /Game path when flattening is off", [this]()
{
const FName Package = FindOrdinaryAssetPackage();
if (Package.IsNone())
{
AddWarning(TEXT("No ordinary /Game asset with a file on disk was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveTree"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bFlatten = false;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageFiles({ Package }, Options);
TestEqual(TEXT("one file copied"), Result.FilesCopied, 1);
FString Relative = FPaths::GetPath(Package.ToString());
Relative.RemoveFromStart(TEXT("/"));
const FString SourcePath = FAssetUsageExporter::ResolvePackageFilePath(Package);
const FString Expected = FPaths::Combine(ScratchDir, Relative, FPaths::GetCleanFilename(SourcePath));
TestTrue(TEXT("mirrored path exists"), IFileManager::Get().FileExists(*Expected));
});
});
Describe("Exporting an OFPA level", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("brings every external actor package along with the map", [this]()
{
// The whole point of the tool: an OFPA map copied on its own opens empty, because its
// actors are separate packages joined to it by a soft dependency edge.
int32 ExpectedExternal = 0;
const FName Level = FindSmallestOfpaLevel(ExpectedExternal);
if (Level.IsNone())
{
AddWarning(TEXT("No level with external actor packages was found; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("Using level '%s' with %d external packages."), *Level.ToString(), ExpectedExternal));
MakeScratchDir(TEXT("LiveOfpa"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bFlatten = false; // keep the tree: external actor names are GUID-ish and long
Options.bIncludeExternalPackages = true;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageFiles({ Level }, Options);
TestTrue(TEXT("succeeded"), Result.bSuccess);
TestEqual(TEXT("expanded to every external package"), Result.ExternalPackagesCopied, ExpectedExternal);
TestEqual(TEXT("copied the map plus its externals"), Result.FilesCopied, ExpectedExternal + 1);
TestEqual(TEXT("files on disk match"), CountFilesInScratch(), ExpectedExternal + 1);
const FString MapPath = FAssetUsageExporter::ResolvePackageFilePath(Level);
TestTrue(TEXT("the map itself is a .umap"), MapPath.EndsWith(TEXT(".umap")));
});
It("copies only the map when external packages are switched off", [this]()
{
int32 ExpectedExternal = 0;
const FName Level = FindSmallestOfpaLevel(ExpectedExternal);
if (Level.IsNone())
{
AddWarning(TEXT("No level with external actor packages was found; skipping."));
return;
}
MakeScratchDir(TEXT("LiveOfpaOff"));
FAssetUsageExporter::FOptions Options;
Options.TargetDirectory = ScratchDir;
Options.bIncludeExternalPackages = false;
const FAssetUsageExporter::FResult Result =
FAssetUsageExporter::ExportPackageFiles({ Level }, Options);
TestEqual(TEXT("just the map"), Result.FilesCopied, 1);
TestEqual(TEXT("no externals expanded"), Result.ExternalPackagesCopied, 0);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,513 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageGraph.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "LevelUsageResolver.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Engine/World.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* Does the graph agree with the Asset Registry it was built from?
*
* This is the automated form of the plan's "open Reference Viewer on ten assets and compare the
* edges by eye". Reference Viewer is a drawing of IAssetRegistry::GetDependencies, so comparing
* against the registry directly checks the same thing, on hundreds of assets instead of ten, and
* keeps checking it after every future change.
*
* What it cannot check: whether the registry itself is right. A reference the registry never
* recorded - an FMOD event resolved by string, a path built by concatenation - is invisible here
* exactly as it is invisible in Reference Viewer. That limitation is the Unknown verdict's whole
* reason for existing, and no test can close it.
*/
BEGIN_DEFINE_SPEC(AssetUsageGraphFidelitySpec,
"AssetUsageAudit.GraphFidelity",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/**
* Build the graph the way the audit does.
*
* Restricted to a subtree so the spec costs a fraction of a second rather than sweeping 80k
* packages; the invariants under test are per-edge and do not depend on scale.
*/
static void BuildGraph(FAssetUsageGraph& OutGraph, const TCHAR* Root = TEXT("/Game"))
{
FAssetUsageGraphBuildOptions Options;
Options.IncludePackagePaths.Add(Root);
OutGraph.Build(ReadyRegistry(), Options);
}
/**
* A stable sample of graph indices.
*
* Sampling by raw index is not reproducible: node indices are assigned in Asset Registry
* enumeration order, which varies between runs even though the graph itself does not. Measured
* on this project the graph is identical run to run - 84505 packages, 370722 edges - while an
* index-strided sample compared 3879, 3847 and 3870 targets on three consecutive runs, because
* index 97 was a different package each time.
*
* Sorting by package name first makes the sample the same every run, so a failure here can be
* reproduced and a pass actually means something.
*/
static TArray<int32> StableSample(const FAssetUsageGraph& Graph, int32 Stride = 97)
{
TArray<FName> Names;
Names.Reserve(Graph.Num());
for (int32 Index = 0; Index < Graph.Num(); ++Index)
{
Names.Add(Graph.GetNode(Index).PackageName);
}
Names.Sort(FNameLexicalLess());
TArray<int32> Sample;
Sample.Reserve(Names.Num() / FMath::Max(1, Stride) + 1);
for (int32 Position = 0; Position < Names.Num(); Position += Stride)
{
const int32 Index = Graph.FindPackageIndex(Names[Position]);
if (Index != INDEX_NONE)
{
Sample.Add(Index);
}
}
return Sample;
}
/** Raw registry dependencies for a package, using the tool's mandated query. */
static TSet<FName> RegistryDependencies(FName PackageName)
{
TArray<FName> Dependencies;
ReadyRegistry().GetDependencies(
PackageName,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
return TSet<FName>(Dependencies);
}
END_DEFINE_SPEC(AssetUsageGraphFidelitySpec)
void AssetUsageGraphFidelitySpec::Define()
{
Describe("Edges against the registry", [this]()
{
It("stores every /Game dependency the registry reports", [this]()
{
FAssetUsageGraph Graph;
BuildGraph(Graph);
if (Graph.Num() == 0)
{
AddWarning(TEXT("The graph came back empty; skipping."));
return;
}
// A sample rather than all 84k: this compares against a live registry call per package,
// and the point is to catch a systematic edge-handling mistake, which shows up on the
// first dozen or not at all.
int32 Checked = 0;
int32 Missing = 0;
FString FirstMissing;
for (int32 Index : StableSample(Graph))
{
const FAssetUsageNode& Node = Graph.GetNode(Index);
const TSet<FName> Expected = RegistryDependencies(Node.PackageName);
TSet<FName> InGraph;
for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index))
{
if (Graph.IsValidIndex(Edge.TargetIndex))
{
InGraph.Add(Graph.GetNode(Edge.TargetIndex).PackageName);
}
}
for (FName Dependency : Expected)
{
// The graph only holds packages inside the swept roots, so a dependency it
// legitimately does not know about is not a defect. Anything under /Game is.
if (!Dependency.ToString().StartsWith(TEXT("/Game/")))
{
continue;
}
if (!InGraph.Contains(Dependency))
{
++Missing;
if (FirstMissing.IsEmpty())
{
FirstMissing = FString::Printf(TEXT("%s -> %s"),
*Node.PackageName.ToString(), *Dependency.ToString());
}
}
}
++Checked;
}
AddInfo(FString::Printf(TEXT("Compared %d packages against the registry."), Checked));
TestTrue(TEXT("something was actually compared"), Checked > 0);
TestEqual(*FString::Printf(TEXT("no /Game edge is missing (first: %s)"), *FirstMissing), Missing, 0);
});
It("invents no edge the registry does not report", [this]()
{
// The opposite direction, and the more dangerous one: a fabricated edge would make an
// unused asset look used, which is the failure mode this tool must never have.
FAssetUsageGraph Graph;
BuildGraph(Graph);
if (Graph.Num() == 0)
{
AddWarning(TEXT("The graph came back empty; skipping."));
return;
}
int32 Extra = 0;
FString FirstExtra;
for (int32 Index : StableSample(Graph))
{
const FAssetUsageNode& Node = Graph.GetNode(Index);
const TSet<FName> Expected = RegistryDependencies(Node.PackageName);
for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index))
{
if (!Graph.IsValidIndex(Edge.TargetIndex))
{
continue;
}
const FName Target = Graph.GetNode(Edge.TargetIndex).PackageName;
if (!Expected.Contains(Target))
{
++Extra;
if (FirstExtra.IsEmpty())
{
FirstExtra = FString::Printf(TEXT("%s -> %s"),
*Node.PackageName.ToString(), *Target.ToString());
}
}
}
}
TestEqual(*FString::Printf(TEXT("no invented edge (first: %s)"), *FirstExtra), Extra, 0);
});
It("agrees with the registry on which edges are hard", [this]()
{
// Hard versus soft is a report column, and the columns are what someone decides on.
// A mislabelled edge is worse than a missing one: it reads as information.
FAssetUsageGraph Graph;
BuildGraph(Graph);
if (Graph.Num() == 0)
{
AddWarning(TEXT("The graph came back empty; skipping."));
return;
}
IAssetRegistry& Registry = ReadyRegistry();
// Compare per TARGET, not per edge. The registry may report the same target twice under
// different property masks - a hard reference and a soft one to the same package - and
// the graph faithfully keeps both. Asking "is this edge hard" against a set that only
// answers "is any edge to this target hard" then flags the soft twin as a mismatch.
//
// An earlier version of this spec did exactly that and failed intermittently: whether a
// package with such a pair fell into the sample depended on the sample, so the same code
// passed or failed run to run. The defect was here, not in the graph.
int32 Compared = 0;
int32 Disagreements = 0;
TArray<FString> Details;
for (int32 Index : StableSample(Graph))
{
const FAssetUsageNode& Node = Graph.GetNode(Index);
TArray<FName> HardOnly;
Registry.GetDependencies(
Node.PackageName,
HardOnly,
AssetUsageAudit::MakeTraversalCategory(),
UE::AssetRegistry::FDependencyQuery(UE::AssetRegistry::EDependencyQuery::Hard));
const TSet<FName> RegistryHardTargets(HardOnly);
// Targets the graph considers hard by at least one edge - the same question the
// registry's hard-only query answers.
TSet<FName> GraphHardTargets;
TSet<FName> GraphAllTargets;
for (const FAssetUsageEdge& Edge : Graph.GetDependencies(Index))
{
if (!Graph.IsValidIndex(Edge.TargetIndex))
{
continue;
}
const FName Target = Graph.GetNode(Edge.TargetIndex).PackageName;
GraphAllTargets.Add(Target);
if (Edge.IsHard())
{
GraphHardTargets.Add(Target);
}
}
for (FName Target : GraphAllTargets)
{
const bool bGraphSaysHard = GraphHardTargets.Contains(Target);
const bool bRegistrySaysHard = RegistryHardTargets.Contains(Target);
if (bGraphSaysHard != bRegistrySaysHard)
{
++Disagreements;
if (Details.Num() < 5)
{
Details.Add(FString::Printf(
TEXT("%s -> %s: graph says %s, registry says %s"),
*Node.PackageName.ToString(),
*Target.ToString(),
bGraphSaysHard ? TEXT("hard") : TEXT("not hard"),
bRegistrySaysHard ? TEXT("hard") : TEXT("not hard")));
}
}
++Compared;
}
}
AddInfo(FString::Printf(TEXT("Compared the hard flag on %d dependency targets."), Compared));
for (const FString& Detail : Details)
{
AddInfo(Detail);
}
TestTrue(TEXT("something was actually compared"), Compared > 0);
TestEqual(TEXT("hard flags match the registry"), Disagreements, 0);
});
});
Describe("The OFPA invariant, stated as a measurement", [this]()
{
It("finds external actor packages that a hard-only query would lose", [this]()
{
// The regression this guards is silent: a Hard-only query still returns a plausible
// graph, just without any One File Per Actor package in it. Rather than trusting the
// comment, measure the difference the query makes.
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
FName LevelWithExternals = NAME_None;
int32 ExternalsUnderCorrectQuery = 0;
Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData)
{
TArray<FName> All;
Registry.GetDependencies(
AssetData.PackageName,
All,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
int32 Externals = 0;
for (FName Dependency : All)
{
if (AssetUsagePaths::IsExternalPackage(Dependency))
{
++Externals;
}
}
if (Externals > 0)
{
LevelWithExternals = AssetData.PackageName;
ExternalsUnderCorrectQuery = Externals;
return false;
}
return true;
});
if (LevelWithExternals.IsNone())
{
AddWarning(TEXT("No level with external actor packages was found; skipping."));
return;
}
TArray<FName> HardOnly;
Registry.GetDependencies(
LevelWithExternals,
HardOnly,
AssetUsageAudit::MakeTraversalCategory(),
UE::AssetRegistry::FDependencyQuery(UE::AssetRegistry::EDependencyQuery::Hard));
int32 ExternalsUnderHardQuery = 0;
for (FName Dependency : HardOnly)
{
if (AssetUsagePaths::IsExternalPackage(Dependency))
{
++ExternalsUnderHardQuery;
}
}
AddInfo(FString::Printf(
TEXT("'%s': %d external packages with the tool's query, %d with a Hard-only query."),
*LevelWithExternals.ToString(), ExternalsUnderCorrectQuery, ExternalsUnderHardQuery));
TestTrue(TEXT("the tool's query finds external packages"), ExternalsUnderCorrectQuery > 0);
// The engine emits these edges as Game|Build without Hard. If this ever becomes
// non-zero the engine has changed and the surrounding comments need revisiting.
TestEqual(TEXT("a Hard-only query finds none of them"), ExternalsUnderHardQuery, 0);
});
});
Describe("Sublevels and their parent map", [this]()
{
It("attributes a sublevel's assets to the parent map as well", [this]()
{
// Plan item 4. A streaming sublevel is reached from the parent's own package, which is
// a structural crossing, so the parent's sweep should see through it. Asserted rather
// than assumed: the map-boundary rule refuses most crossings, and getting it slightly
// wrong would silently drop every sublevel's contents from the parent.
IAssetRegistry& Registry = ReadyRegistry();
const FTopLevelAssetPath WorldClass = UWorld::StaticClass()->GetClassPathName();
FARFilter Filter;
Filter.ClassPaths.Add(WorldClass);
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
FName Parent = NAME_None;
FName Sublevel = NAME_None;
Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData)
{
TArray<FName> Dependencies;
Registry.GetDependencies(
AssetData.PackageName,
Dependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
for (FName Dependency : Dependencies)
{
TArray<FAssetData> InPackage;
Registry.GetAssetsByPackageName(Dependency, InPackage, true);
for (const FAssetData& Inner : InPackage)
{
if (Inner.AssetClassPath == WorldClass && Dependency != AssetData.PackageName)
{
Parent = AssetData.PackageName;
Sublevel = Dependency;
return false;
}
}
}
return true;
});
if (Parent.IsNone())
{
AddWarning(TEXT("No map referencing another map directly was found; skipping."));
return;
}
AddInfo(FString::Printf(TEXT("Parent '%s' references '%s'."), *Parent.ToString(), *Sublevel.ToString()));
FAssetUsageGraph Graph;
BuildGraph(Graph);
const int32 SublevelIndex = Graph.FindPackageIndex(Sublevel);
if (SublevelIndex == INDEX_NONE)
{
AddWarning(TEXT("The sublevel is outside the built graph; skipping."));
return;
}
FLevelUsageResolver Resolver(Graph, Registry);
FLevelUsageResolveOptions Options;
Options.LevelPackages = { Parent };
Options.bRecordRoutes = false;
const FLevelUsageResult Result = Resolver.Resolve(Options);
TestTrue(TEXT("the parent's sweep reaches the sublevel"), Result.IsReachableFromAnyLevel(SublevelIndex));
// Reaching the sublevel package is necessary but not sufficient - a foreign map is also
// "reached", just not expanded. What matters is that its contents came too.
TArray<FName> SublevelDependencies;
Registry.GetDependencies(
Sublevel,
SublevelDependencies,
AssetUsageAudit::MakeTraversalCategory(),
AssetUsageAudit::MakeTraversalQuery());
int32 ContentsChecked = 0;
int32 ContentsReached = 0;
for (FName Dependency : SublevelDependencies)
{
const int32 Index = Graph.FindPackageIndex(Dependency);
if (Index == INDEX_NONE)
{
continue;
}
++ContentsChecked;
if (Result.IsReachableFromAnyLevel(Index))
{
++ContentsReached;
}
}
if (ContentsChecked == 0)
{
AddWarning(TEXT("The sublevel has no dependencies inside the graph; nothing to check."));
return;
}
AddInfo(FString::Printf(TEXT("%d of %d sublevel dependencies reached from the parent."),
ContentsReached, ContentsChecked));
TestEqual(TEXT("the parent sees the sublevel's contents"), ContentsReached, ContentsChecked);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,163 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsagePaths.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(AssetUsagePathsSpec,
"AssetUsageAudit.Paths",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
END_DEFINE_SPEC(AssetUsagePathsSpec)
void AssetUsagePathsSpec::Define()
{
Describe("IsExternalPackage", [this]()
{
It("detects an OFPA external actor package", [this]()
{
// Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/<22 chars>
TestTrue(TEXT("external actor"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/QWERTYUIOPASDFGHJKLZXC"))));
});
It("detects an external objects package", [this]()
{
TestTrue(TEXT("external object"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Space/Maps/L_3i_Hub/1/2A/ABC"))));
});
It("does not flag an ordinary content package", [this]()
{
TestFalse(TEXT("ordinary asset"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Art/SM_Rock"))));
});
It("does not flag a level that merely lives next to external actors", [this]()
{
TestFalse(TEXT("the level itself is not external"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Maps/WP_Main"))));
});
});
Describe("IsScriptPackage", [this]()
{
It("flags /Script packages", [this]()
{
TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/Space"))));
});
It("does not flag content that starts with the same letters", [this]()
{
TestFalse(TEXT("not script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Scripted/Foo"))));
});
});
Describe("NormalizeExclusionPrefix", [this]()
{
It("converts content-relative form to package form", [this]()
{
TestEqual(TEXT("Content/3rdParty"),
AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content/3rdParty")), FString(TEXT("/Game/3rdParty")));
});
It("strips a trailing slash", [this]()
{
TestEqual(TEXT("trailing slash"),
AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content/3rdParty/")), FString(TEXT("/Game/3rdParty")));
});
It("leaves package form untouched", [this]()
{
TestEqual(TEXT("already package form"),
AssetUsagePaths::NormalizeExclusionPrefix(TEXT("/Game/Zombie")), FString(TEXT("/Game/Zombie")));
});
It("maps bare Content to the game root", [this]()
{
TestEqual(TEXT("bare Content"),
AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content")), FString(TEXT("/Game")));
});
It("normalizes backslashes pasted from Explorer", [this]()
{
TestEqual(TEXT("backslashes"),
AssetUsagePaths::NormalizeExclusionPrefix(TEXT("Content\\3rdParty\\Fab")), FString(TEXT("/Game/3rdParty/Fab")));
});
It("returns empty for an empty or slash-only prefix", [this]()
{
TestTrue(TEXT("empty"), AssetUsagePaths::NormalizeExclusionPrefix(TEXT("")).IsEmpty());
TestTrue(TEXT("slashes only"), AssetUsagePaths::NormalizeExclusionPrefix(TEXT("///")).IsEmpty());
});
});
Describe("IsPathExcluded", [this]()
{
It("excludes a package under the prefix", [this]()
{
const TArray<FString> Excluded = { TEXT("Content/3rdParty") };
TestTrue(TEXT("under prefix"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/3rdParty/Galaxy/SM_Star")), Excluded));
});
It("does not exclude a sibling folder sharing a name prefix", [this]()
{
// The bug this guards: a naive StartsWith would exclude /Game/ArtSource when the user
// only asked to exclude /Game/Art.
const TArray<FString> Excluded = { TEXT("/Game/Art") };
TestFalse(TEXT("ArtSource must survive"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/ArtSource/SM_Rock")), Excluded));
TestTrue(TEXT("Art itself is excluded"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Art/SM_Rock")), Excluded));
});
It("matches case-insensitively", [this]()
{
const TArray<FString> Excluded = { TEXT("content/ZOMBIE") };
TestTrue(TEXT("case insensitive"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Zombie/SK_Walker")), Excluded));
});
It("returns false for an empty exclusion list", [this]()
{
const TArray<FString> Excluded;
TestFalse(TEXT("nothing excluded"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Space/Art/SM_Rock")), Excluded));
});
});
Describe("ToProjectRelativePath", [this]()
{
It("converts an asset package", [this]()
{
TestEqual(TEXT("asset"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Art/SM_Rock")), false),
FString(TEXT("Content/Space/Art/SM_Rock.uasset")));
});
It("uses the umap extension for levels", [this]()
{
TestEqual(TEXT("level"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Maps/WP_Main")), true),
FString(TEXT("Content/Space/Maps/WP_Main.umap")));
});
It("returns empty for script and engine packages rather than inventing a path", [this]()
{
TestTrue(TEXT("script"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/Space")), false).IsEmpty());
TestTrue(TEXT("engine"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Engine/BasicShapes/Cube")), false).IsEmpty());
});
It("converts an external actor package, which is still under /Game", [this]()
{
TestEqual(TEXT("external actor"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC")), false),
FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC.uasset")));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,228 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageReportWriter.h"
#include "AssetUsageAuditor.h"
#include "AssetUsageAuditTypes.h"
#include "Dom/JsonObject.h"
#include "Misc/AutomationTest.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#if WITH_DEV_AUTOMATION_TESTS
namespace
{
FAssetUsageAuditResult MakeSampleResult()
{
FAssetUsageAuditResult Result;
Result.Header.GeneratedAt = FDateTime(2026, 9, 2, 12, 0, 0);
Result.Header.ProjectName = TEXT("Space");
Result.Header.EngineVersion = TEXT("5.6.1");
Result.Header.ToolVersion = TEXT("0.1");
Result.Header.LevelsScanned = 2;
Result.Header.AssetsScanned = 3;
Result.Header.AppliedFilters.Add(TEXT("Types: StaticMesh"));
FAssetUsageRow& Used = Result.Rows.AddDefaulted_GetRef();
Used.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Used.AssetName = FName(TEXT("SM_Rock"));
Used.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Used.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Rock.uasset");
Used.Verdict = EAssetUsageVerdict::UsedOnLevel;
Used.Levels = { FName(TEXT("/Game/Space/Maps/WP_Main")), FName(TEXT("/Game/Space/Maps/L_3i_Hub")) };
Used.HardReferenceCount = 3;
Used.SoftReferenceCount = 1;
Used.Provenance = EAssetUsageProvenance::HardReference | EAssetUsageProvenance::ExternalActor;
Used.Route = TEXT("WP_Main -> BP_Rock -> SM_Rock");
FAssetUsageRow& Unused = Result.Rows.AddDefaulted_GetRef();
Unused.PackageName = FName(TEXT("/Game/Space/Art/SM_Orphan"));
Unused.AssetName = FName(TEXT("SM_Orphan"));
Unused.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Unused.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Orphan.uasset");
Unused.Verdict = EAssetUsageVerdict::Unreferenced;
return Result;
}
}
BEGIN_DEFINE_SPEC(AssetUsageReportWriterSpec,
"AssetUsageAudit.ReportWriter",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
END_DEFINE_SPEC(AssetUsageReportWriterSpec)
void AssetUsageReportWriterSpec::Define()
{
Describe("EscapeCsvField", [this]()
{
It("leaves a plain field untouched", [this]()
{
TestEqual(TEXT("plain"), FAssetUsageReportWriter::EscapeCsvField(TEXT("SM_Rock"), TEXT(';')), FString(TEXT("SM_Rock")));
});
It("quotes a field containing the delimiter", [this]()
{
TestEqual(TEXT("delimiter"),
FAssetUsageReportWriter::EscapeCsvField(TEXT("A;B"), TEXT(';')), FString(TEXT("\"A;B\"")));
});
It("does not quote a field containing a different delimiter", [this]()
{
TestEqual(TEXT("other delimiter"),
FAssetUsageReportWriter::EscapeCsvField(TEXT("A,B"), TEXT(';')), FString(TEXT("A,B")));
});
It("doubles embedded quotes", [this]()
{
TestEqual(TEXT("quotes"),
FAssetUsageReportWriter::EscapeCsvField(TEXT("say \"hi\""), TEXT(';')), FString(TEXT("\"say \"\"hi\"\"\"")));
});
It("quotes a field containing a newline", [this]()
{
// Route strings are built from node titles, and Blueprint node titles really do
// contain newlines. An unquoted one would split the row and corrupt the whole file.
TestEqual(TEXT("newline"),
FAssetUsageReportWriter::EscapeCsvField(TEXT("A\nB"), TEXT(';')), FString(TEXT("\"A\nB\"")));
});
});
Describe("BuildCsv", [this]()
{
It("emits one header row and one row per asset", [this]()
{
const FAssetUsageAuditResult Sample = MakeSampleResult();
FAssetUsageReportWriter::FOptions Options;
const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options);
TArray<FString> Lines;
Csv.ParseIntoArrayLines(Lines);
const int32 CommentLines = Lines.FilterByPredicate([](const FString& Line) { return Line.StartsWith(TEXT("#")); }).Num();
TestTrue(TEXT("has comment header"), CommentLines > 0);
TestEqual(TEXT("comments + header + 2 rows"), Lines.Num(), CommentLines + 1 + 2);
});
It("records the filters that produced the report", [this]()
{
const FAssetUsageAuditResult Sample = MakeSampleResult();
const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, FAssetUsageReportWriter::FOptions());
TestTrue(TEXT("filter recorded"), Csv.Contains(TEXT("# Filter: Types: StaticMesh")));
});
It("joins multiple levels into one cell with the configured separator", [this]()
{
const FAssetUsageAuditResult Sample = MakeSampleResult();
FAssetUsageReportWriter::FOptions Options;
Options.CsvMultiValueSeparator = TEXT("|");
const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options);
TestTrue(TEXT("levels joined"),
Csv.Contains(TEXT("/Game/Space/Maps/WP_Main|/Game/Space/Maps/L_3i_Hub")));
});
It("carries the verdict verbatim", [this]()
{
const FString Csv = FAssetUsageReportWriter::BuildCsv(MakeSampleResult(), FAssetUsageReportWriter::FOptions());
TestTrue(TEXT("UsedOnLevel present"), Csv.Contains(TEXT("UsedOnLevel")));
TestTrue(TEXT("Unreferenced present"), Csv.Contains(TEXT("Unreferenced")));
});
It("warns in the header that Unknown does not mean unused", [this]()
{
// The single most dangerous misreading of this report. It must be stated in the file
// itself, because the file outlives the UI that explained it.
const FString Csv = FAssetUsageReportWriter::BuildCsv(MakeSampleResult(), FAssetUsageReportWriter::FOptions());
TestTrue(TEXT("warning present"), Csv.Contains(TEXT("does NOT mean unused")));
});
});
Describe("BuildJson", [this]()
{
It("produces valid, parseable JSON", [this]()
{
const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult());
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Json);
TestTrue(TEXT("parses"), FJsonSerializer::Deserialize(Reader, Root) && Root.IsValid());
});
It("carries the header, verdict counts and one entry per asset", [this]()
{
const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult());
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Json);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
AddError(TEXT("JSON did not parse"));
return;
}
const TSharedPtr<FJsonObject>* Header = nullptr;
TestTrue(TEXT("header object"), Root->TryGetObjectField(TEXT("header"), Header));
if (Header)
{
TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("Space")));
TestEqual(TEXT("levelsScanned"), (*Header)->GetIntegerField(TEXT("levelsScanned")), 2);
}
const TSharedPtr<FJsonObject>* Counts = nullptr;
TestTrue(TEXT("verdictCounts object"), Root->TryGetObjectField(TEXT("verdictCounts"), Counts));
if (Counts)
{
TestEqual(TEXT("usedOnLevel"), (*Counts)->GetIntegerField(TEXT("usedOnLevel")), 1);
TestEqual(TEXT("unreferenced"), (*Counts)->GetIntegerField(TEXT("unreferenced")), 1);
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
TestTrue(TEXT("assets array"), Root->TryGetArrayField(TEXT("assets"), Assets));
if (Assets)
{
TestEqual(TEXT("two assets"), Assets->Num(), 2);
}
});
It("keeps the level list nested rather than flattened to a string", [this]()
{
// The whole point of shipping JSON alongside CSV: a consumer should not have to
// re-split a delimited cell to learn which levels use an asset.
const FString Json = FAssetUsageReportWriter::BuildJson(MakeSampleResult());
TSharedPtr<FJsonObject> Root;
const TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Json);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
AddError(TEXT("JSON did not parse"));
return;
}
const TArray<TSharedPtr<FJsonValue>>* Assets = nullptr;
if (!Root->TryGetArrayField(TEXT("assets"), Assets) || Assets->Num() == 0)
{
AddError(TEXT("assets array missing"));
return;
}
const TSharedPtr<FJsonObject> First = (*Assets)[0]->AsObject();
const TArray<TSharedPtr<FJsonValue>>* Levels = nullptr;
TestTrue(TEXT("levels is an array"), First->TryGetArrayField(TEXT("levels"), Levels));
if (Levels)
{
TestEqual(TEXT("two levels"), Levels->Num(), 2);
}
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,146 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditor.h"
#include "AssetUsageGraph.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(AssetUsageVerdictSpec,
"AssetUsageAudit.Verdict",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
END_DEFINE_SPEC(AssetUsageVerdictSpec)
void AssetUsageVerdictSpec::Define()
{
Describe("ClassifyVerdict", [this]()
{
It("reports UsedOnLevel when a level reaches the asset", [this]()
{
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(
/*bReachableFromLevel=*/true,
/*bHasReferencers=*/true,
/*bFoundInConfigOrSource=*/false,
/*bIsBlindSpot=*/false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel);
});
It("reports UsedOnLevel even when the asset is also named in config", [this]()
{
// Level reachability is the strongest signal and must win. Otherwise a mesh that is
// both placed on a level and mentioned in an ini would be filed under the weaker
// config verdict and vanish from a per-level report.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(true, true, true, false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel);
});
It("reports UsedByAssetsOnly when referenced but no level reaches it", [this]()
{
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, true, false, false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedByAssetsOnly);
});
It("reports ReferencedFromConfigOrSource when only a text literal names it", [this]()
{
// This is the BP_FirstPersonGameMode case: no asset references it, only
// Config/DefaultEngine.ini does. Reporting it Unreferenced would be a real defect.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, true, false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::ReferencedFromConfigOrSource);
});
It("prefers the config verdict over UsedByAssetsOnly when both apply", [this]()
{
// Config provenance is more actionable than "some asset points at it" - it tells the
// user the reference is invisible to the registry and where to look for it.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, true, true, false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::ReferencedFromConfigOrSource);
});
It("reports Unreferenced only when nothing at all points at it", [this]()
{
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, false, false);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::Unreferenced);
});
It("never reports Unreferenced for a registry blind spot", [this]()
{
// An FMOD asset with zero referencers tells us nothing, because FMOD resolves events
// by string path outside the UObject graph. Unknown is the honest answer, and the one
// that stops someone deleting the audio bank.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, false, true);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::Unknown);
TestNotEqual(TEXT("Must not be Unreferenced"), Verdict, EAssetUsageVerdict::Unreferenced);
});
It("still reports UsedOnLevel for a blind-spot asset that a level does reach", [this]()
{
// A positive result is trustworthy even for a blind spot: we found a real edge.
// Only the absence of evidence is uninformative.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(true, false, false, true);
TestEqual(TEXT("Verdict"), Verdict, EAssetUsageVerdict::UsedOnLevel);
});
It("produces a distinct string for every verdict", [this]()
{
// The report is parsed by other tools; two verdicts sharing a label would silently
// merge distinct populations.
TSet<FString> Seen;
const TArray<EAssetUsageVerdict> All = {
EAssetUsageVerdict::UsedOnLevel,
EAssetUsageVerdict::UsedByAssetsOnly,
EAssetUsageVerdict::ReferencedFromConfigOrSource,
EAssetUsageVerdict::Unreferenced,
EAssetUsageVerdict::Unknown
};
for (EAssetUsageVerdict Verdict : All)
{
const FString Label = LexToString(Verdict);
TestFalse(FString::Printf(TEXT("Label '%s' must be unique"), *Label), Seen.Contains(Label));
TestFalse(TEXT("Label must not be empty"), Label.IsEmpty());
Seen.Add(Label);
}
});
});
Describe("IsRegistryBlindSpot", [this]()
{
It("flags FMOD assets", [this]()
{
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/FMOD/Events/Explosion"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/FMODStudio.FMODEvent"));
TestTrue(TEXT("FMOD asset is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
});
It("flags DataTables, whose rows the registry cannot attribute", [this]()
{
// A level depends on the whole table, so every row looks used even when none is.
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Space/Data/DT_Weapons"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.DataTable"));
TestTrue(TEXT("DataTable is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
});
It("does not flag an ordinary static mesh", [this]()
{
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
TestFalse(TEXT("StaticMesh is not a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,131 @@
// NextGenium 2026. Asset Usage Audit.
#include "IndirectReferenceScanner.h"
#include "Misc/AutomationTest.h"
#if WITH_DEV_AUTOMATION_TESTS
BEGIN_DEFINE_SPEC(IndirectReferenceScannerSpec,
"AssetUsageAudit.IndirectReferences",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
TArray<FString> Extract(const TCHAR* Line)
{
TArray<FString> Result;
FIndirectReferenceScanner::ExtractGamePathsFromLine(Line, Result);
return Result;
}
END_DEFINE_SPEC(IndirectReferenceScannerSpec)
void IndirectReferenceScannerSpec::Define()
{
Describe("ExtractGamePathsFromLine", [this]()
{
// These four lines are copied from this project's Config/DefaultEngine.ini. They are the
// whole reason this scanner exists: nothing in Content references them, so without this
// pass the project's GameMode and GameInstance are reported as unused assets.
It("finds the GameMode from DefaultEngine.ini and strips the _C class suffix", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GlobalDefaultGameMode=/Game/Space/Core/GameModes/BP_FirstPersonGameMode.BP_FirstPersonGameMode_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Core/GameModes/BP_FirstPersonGameMode")));
}
});
It("finds the GameInstance from MenuSystemPro", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GameInstanceClass=/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance.BP_MenuSystemGameInstance_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0],
FString(TEXT("/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance")));
}
});
It("finds a map reference with an object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("EditorStartupMap=/Game/Space/Maps/Gyms/Gyms_Geoda.Gyms_Geoda"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/Gyms/Gyms_Geoda")));
}
});
It("finds a bare package path with no object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("GameDefaultMap=/Game/Space/Maps/RefinedMenuMap"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/RefinedMenuMap")));
}
});
It("strips surrounding quotes and parentheses from an ini struct value", [this]()
{
const TArray<FString> Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Space/Art\")"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art")));
}
});
It("handles a C++ literal wrapped in TEXT()", [this]()
{
const TArray<FString> Found = Extract(
TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Space/Art/SM_Rock\"));"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art/SM_Rock")));
}
});
It("finds several distinct paths on one line", [this]()
{
const TArray<FString> Found = Extract(
TEXT("Redirect=(From=\"/Game/Old/SM_A\", To=\"/Game/New/SM_B\")"));
TestEqual(TEXT("two hits"), Found.Num(), 2);
if (Found.Num() == 2)
{
TestEqual(TEXT("first"), Found[0], FString(TEXT("/Game/Old/SM_A")));
TestEqual(TEXT("second"), Found[1], FString(TEXT("/Game/New/SM_B")));
}
});
It("returns nothing for a line with no /Game path", [this]()
{
TestEqual(TEXT("no hits"), Extract(TEXT("bDynamicLevelStreaming=False")).Num(), 0);
TestEqual(TEXT("no hits"), Extract(TEXT("")).Num(), 0);
});
It("ignores a bare /Game root with no asset under it", [this]()
{
// "/Game" alone is a mount point, not a reference. Recording it would mark the entire
// project as referenced-from-config and make the verdict column meaningless.
TestEqual(TEXT("no hits"), Extract(TEXT("PackagePath=/Game")).Num(), 0);
});
It("does not match a path that merely contains the word Game", [this]()
{
TestEqual(TEXT("no hits"), Extract(TEXT("Path=/MiniGames2/Foo/SM_Bar")).Num(), 0);
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -0,0 +1,158 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsagePaths.h"
#include "HAL/FileManager.h"
#include "Misc/App.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* The Migrate destination check.
*
* Exists because the engine validates the destination only after MigratePackages has been called,
* and reports the refusal to the Output Log alone - so a bad folder presented as a button that did
* nothing at all. These specs pin the two rules to real folders on disk rather than to a reading of
* the engine source.
*/
BEGIN_DEFINE_SPEC(MigrateDestinationSpec,
"AssetUsageAudit.MigrateDestination",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
FString ScratchDir;
void MakeScratchDir(const TCHAR* Suffix)
{
ScratchDir = FPaths::ConvertRelativePathToFull(
FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAuditTests"), Suffix));
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
IFileManager::Get().MakeDirectory(*ScratchDir, true);
}
void RemoveScratchDir()
{
if (!ScratchDir.IsEmpty())
{
IFileManager::Get().DeleteDirectory(*ScratchDir, false, true);
}
}
/** Build <Scratch>/<ProjectName>/Content plus a marker file beside it. */
FString MakeFakeProject(const TCHAR* MarkerFileName)
{
const FString ProjectRoot = FPaths::Combine(ScratchDir, TEXT("FakeProject"));
const FString ContentDir = FPaths::Combine(ProjectRoot, TEXT("Content"));
IFileManager::Get().MakeDirectory(*ContentDir, true);
if (MarkerFileName)
{
FFileHelper::SaveStringToFile(TEXT("{}"), *FPaths::Combine(ProjectRoot, MarkerFileName));
}
return ContentDir;
}
END_DEFINE_SPEC(MigrateDestinationSpec)
void MigrateDestinationSpec::Define()
{
Describe("Rejecting a destination Migrate would refuse", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("rejects an empty path", [this]()
{
TestFalse(TEXT("has a reason"), AssetUsagePaths::ValidateMigrateDestination(FString()).IsEmpty());
});
It("rejects a folder that is not called Content", [this]()
{
// The project's own Saved/AssetUsageAudit is the default for every other layout, so
// this is the exact path a user arrives with after switching to Migrate.
MakeScratchDir(TEXT("MigrateNotContent"));
const FString Reason = AssetUsagePaths::ValidateMigrateDestination(ScratchDir);
TestFalse(TEXT("rejected"), Reason.IsEmpty());
TestTrue(TEXT("says the folder must be Content"), Reason.Contains(TEXT("Content")));
});
It("rejects a Content folder with no project or plugin beside it", [this]()
{
// Passes the name check and still aborts: Migrate derives the destination mount point
// from what sits next to the Content folder.
MakeScratchDir(TEXT("MigrateOrphanContent"));
const FString ContentDir = MakeFakeProject(nullptr);
TestFalse(TEXT("rejected"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty());
});
It("rejects a Content folder whose parent holds several plugins", [this]()
{
MakeScratchDir(TEXT("MigrateManyPlugins"));
const FString ContentDir = MakeFakeProject(TEXT("First.uplugin"));
FFileHelper::SaveStringToFile(TEXT("{}"),
*FPaths::Combine(FPaths::GetPath(ContentDir), TEXT("Second.uplugin")));
const FString Reason = AssetUsagePaths::ValidateMigrateDestination(ContentDir);
TestFalse(TEXT("rejected"), Reason.IsEmpty());
TestTrue(TEXT("explains the ambiguity"), Reason.Contains(TEXT("several")));
});
});
Describe("Accepting a destination Migrate would use", [this]()
{
AfterEach([this]() { RemoveScratchDir(); });
It("accepts a Content folder next to a .uproject", [this]()
{
MakeScratchDir(TEXT("MigrateGoodProject"));
const FString ContentDir = MakeFakeProject(TEXT("FakeProject.uproject"));
TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty());
});
It("accepts a Content folder next to exactly one .uplugin", [this]()
{
// Plugin content is a legitimate migrate target; the engine derives the mount point
// from the plugin name.
MakeScratchDir(TEXT("MigrateGoodPlugin"));
const FString ContentDir = MakeFakeProject(TEXT("Only.uplugin"));
TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ContentDir).IsEmpty());
});
It("accepts a trailing slash and backslashes alike", [this]()
{
// A path pasted from Explorer arrives with backslashes; one typed by hand often ends
// in a slash. Neither should read as "not a Content folder".
MakeScratchDir(TEXT("MigrateSlashes"));
const FString ContentDir = MakeFakeProject(TEXT("FakeProject.uproject"));
TestTrue(TEXT("trailing slash"), AssetUsagePaths::ValidateMigrateDestination(ContentDir + TEXT("/")).IsEmpty());
TestTrue(TEXT("backslashes"), AssetUsagePaths::ValidateMigrateDestination(ContentDir.Replace(TEXT("/"), TEXT("\\"))).IsEmpty());
});
It("accepts this project's own Content folder", [this]()
{
// The strongest available check that the rule matches reality: the running project is
// a real, valid migrate target by construction.
const FString ProjectContent = FPaths::ConvertRelativePathToFull(FPaths::ProjectContentDir());
TestTrue(TEXT("accepted"), AssetUsagePaths::ValidateMigrateDestination(ProjectContent).IsEmpty());
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS