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,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
|
||||
Reference in New Issue
Block a user