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>
327 lines
12 KiB
C++
327 lines
12 KiB
C++
// MagentaDolphin 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
|