72bf94d142
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>
147 lines
6.3 KiB
C++
147 lines
6.3 KiB
C++
// 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
|