feat: asset usage audit plugin
Editor tool for the LA and 3D departments: which assets each level uses, which are used nowhere, and export of a chosen set. Three modules. AssetUsageAuditCore holds the whole analysis and links no UI and no editor-only asset pipeline, so it stays runnable from a commandlet; AssetUsageAuditEditor holds the Slate panel and everything that needs UnrealEd or AssetTools; AssetUsageAuditTests holds 152 specs. Load-bearing decisions, each of which produces a wrong answer if undone: - Dependency queries are always Package + NoRequirements, never Hard. The map-to-external-actor edges the OFPA gatherer emits carry Game|Build without Hard, so a Hard query drops all 16117 external actor packages in this project. There is deliberately no Hard constant in the code. - Crossing into another map is allowed only from a level or its external actor package. Without that rule WP_Main reported 18136 assets, of which 9994 belonged to L_MainLevel, reached through the GameMode. - The verdict has five states, never a bool. The registry cannot see FMOD events, DataTable rows or string-built paths; those are Unknown, and the tool never proposes a deletion. - Copying .uasset files does not preserve references - they are stored as full package paths. Only the Migrate layout produces something Unreal can open; the others write a manifest so the graph can be rebuilt. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
using UnrealBuildTool;
|
||||
|
||||
public class AssetUsageAuditTests : ModuleRules
|
||||
{
|
||||
public AssetUsageAuditTests(ReadOnlyTargetRules Target) : base(Target)
|
||||
{
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
PublicDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"Core",
|
||||
"CoreUObject",
|
||||
"Engine"
|
||||
});
|
||||
|
||||
PrivateDependencyModuleNames.AddRange(new string[]
|
||||
{
|
||||
"AssetRegistry",
|
||||
"AssetUsageAuditCore",
|
||||
"Json",
|
||||
"Projects"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// NextGenium 2026. Asset Usage Audit.
|
||||
|
||||
#include "Modules/ModuleManager.h"
|
||||
|
||||
IMPLEMENT_MODULE(FDefaultModuleImpl, AssetUsageAuditTests)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user