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,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