000cc5735c
The plugin was authored outside studio work but carried NextGenium attribution in the .uplugin descriptor and in every source header. Co-Authored-By: Claude Code <noreply@anthropic.com>
358 lines
14 KiB
C++
358 lines
14 KiB
C++
// MagentaDolphin 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
|