640e940ab4
Measured figures are kept; only the studio-specific level and asset names are generalised, and the Perforce rationale is reworded to describe the class of setups rather than this one project. Co-Authored-By: Claude Code <noreply@anthropic.com>
377 lines
14 KiB
C++
377 lines
14 KiB
C++
// MagentaDolphin 2026. Asset Usage Audit.
|
|
|
|
#include "AssetDependencyClosure.h"
|
|
|
|
#include "AssetUsageAuditTypes.h"
|
|
#include "AssetUsagePaths.h"
|
|
#include "AssetRegistry/ARFilter.h"
|
|
#include "AssetRegistry/IAssetRegistry.h"
|
|
#include "Engine/StaticMesh.h"
|
|
#include "Misc/AutomationTest.h"
|
|
|
|
#if WITH_DEV_AUTOMATION_TESTS
|
|
|
|
/**
|
|
* The dependency closure behind "export a mesh and get its materials too".
|
|
*
|
|
* Runs against the real registry, because the thing worth testing is whether the traversal finds
|
|
* the edges the engine actually stores - a mock would only prove that a BFS visits nodes.
|
|
*/
|
|
BEGIN_DEFINE_SPEC(AssetDependencyClosureSpec,
|
|
"AssetUsageAudit.DependencyClosure",
|
|
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
|
|
|
|
static IAssetRegistry& ReadyRegistry()
|
|
{
|
|
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
|
|
if (Registry.IsLoadingAssets())
|
|
{
|
|
Registry.WaitForCompletion();
|
|
}
|
|
return Registry;
|
|
}
|
|
|
|
/**
|
|
* A /Game static mesh that actually references something, so the closure has work to do.
|
|
*
|
|
* Picking the first mesh found is not enough: a mesh with no material assigned would make the
|
|
* "brings materials along" test pass vacuously.
|
|
*/
|
|
static FName FindMeshWithDependencies(int32& OutDirectCount)
|
|
{
|
|
FARFilter Filter;
|
|
Filter.ClassPaths.Add(UStaticMesh::StaticClass()->GetClassPathName());
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
Filter.bRecursivePaths = true;
|
|
Filter.bRecursiveClasses = true;
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
IAssetRegistry& Registry = ReadyRegistry();
|
|
|
|
FName Best = NAME_None;
|
|
int32 BestCount = 0;
|
|
|
|
Registry.EnumerateAssets(Filter, [&Registry, &Best, &BestCount](const FAssetData& AssetData)
|
|
{
|
|
TArray<FName> Dependencies;
|
|
Registry.GetDependencies(
|
|
AssetData.PackageName,
|
|
Dependencies,
|
|
AssetUsageAudit::MakeTraversalCategory(),
|
|
AssetUsageAudit::MakeTraversalQuery());
|
|
|
|
int32 GameDependencies = 0;
|
|
for (FName Dependency : Dependencies)
|
|
{
|
|
if (Dependency.ToString().StartsWith(TEXT("/Game/")))
|
|
{
|
|
++GameDependencies;
|
|
}
|
|
}
|
|
|
|
if (GameDependencies > BestCount)
|
|
{
|
|
BestCount = GameDependencies;
|
|
Best = AssetData.PackageName;
|
|
}
|
|
|
|
// Two /Game dependencies is enough to be a meaningful subject; stop hunting.
|
|
return BestCount < 2;
|
|
});
|
|
|
|
OutDirectCount = BestCount;
|
|
return Best;
|
|
}
|
|
|
|
END_DEFINE_SPEC(AssetDependencyClosureSpec)
|
|
|
|
void AssetDependencyClosureSpec::Define()
|
|
{
|
|
Describe("Degenerate input", [this]()
|
|
{
|
|
It("returns nothing for no seeds", [this]()
|
|
{
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), {}, {}, Stats);
|
|
|
|
TestEqual(TEXT("empty"), Result.Num(), 0);
|
|
TestEqual(TEXT("no seeds counted"), Stats.SeedCount, 0);
|
|
});
|
|
|
|
It("drops a malformed seed instead of passing it on", [this]()
|
|
{
|
|
// A name that is not a long package name has no file and no dependencies. Keeping it
|
|
// would push the failure down into the exporter, which would report it as missing.
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(
|
|
ReadyRegistry(), { FName(TEXT("NotAPackage")), FName() }, {}, Stats);
|
|
|
|
TestEqual(TEXT("nothing kept"), Result.Num(), 0);
|
|
});
|
|
|
|
It("keeps a seed that has no dependencies at all", [this]()
|
|
{
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(
|
|
ReadyRegistry(), { FName(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest")) }, {}, Stats);
|
|
|
|
TestEqual(TEXT("the seed survives"), Result.Num(), 1);
|
|
TestEqual(TEXT("counted as a seed"), Stats.SeedCount, 1);
|
|
});
|
|
|
|
It("de-duplicates a seed listed twice", [this]()
|
|
{
|
|
const FName Same(TEXT("/Game/NoSuchAsset_AssetUsageAuditTest"));
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Same, Same, Same }, {}, Stats);
|
|
|
|
TestEqual(TEXT("once"), Result.Num(), 1);
|
|
});
|
|
});
|
|
|
|
Describe("Walking real dependencies", [this]()
|
|
{
|
|
It("brings a mesh's materials and textures along", [this]()
|
|
{
|
|
int32 DirectCount = 0;
|
|
const FName Mesh = FindMeshWithDependencies(DirectCount);
|
|
|
|
if (Mesh.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AddInfo(FString::Printf(TEXT("Using '%s' with %d direct /Game dependencies."), *Mesh.ToString(), DirectCount));
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats);
|
|
|
|
// This is the whole point of the feature: exporting the seed alone produces a file that
|
|
// opens with no material.
|
|
TestTrue(TEXT("more than the seed came back"), Result.Num() > 1);
|
|
TestEqual(TEXT("the seed is first"), Result[0], Mesh);
|
|
TestEqual(TEXT("stats agree with the array"), Stats.TotalCount, Result.Num());
|
|
TestTrue(TEXT("walked at least one hop"), Stats.DeepestHop >= 1);
|
|
});
|
|
|
|
It("leaves engine content out by default", [this]()
|
|
{
|
|
int32 DirectCount = 0;
|
|
const FName Mesh = FindMeshWithDependencies(DirectCount);
|
|
|
|
if (Mesh.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, Stats);
|
|
|
|
// The destination project ships /Engine itself; copying it there is at best redundant
|
|
// and at worst overwrites engine files.
|
|
for (FName Package : Result)
|
|
{
|
|
const FString AsString = Package.ToString();
|
|
TestFalse(*FString::Printf(TEXT("no engine package: %s"), *AsString), AsString.StartsWith(TEXT("/Engine/")));
|
|
TestFalse(*FString::Printf(TEXT("no script package: %s"), *AsString), AsString.StartsWith(TEXT("/Script/")));
|
|
}
|
|
});
|
|
|
|
It("respects the exclusion list for dependencies but not for seeds", [this]()
|
|
{
|
|
int32 DirectCount = 0;
|
|
const FName Mesh = FindMeshWithDependencies(DirectCount);
|
|
|
|
if (Mesh.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
// Excluding all of /Game must still return the ticked asset: the user asked for that
|
|
// file by name. It is the traversal behind it that the exclusion list bounds.
|
|
AssetDependencyClosure::FOptions Options;
|
|
Options.ExcludePackagePaths.Add(TEXT("/Game"));
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
|
|
|
|
TestEqual(TEXT("only the seed survives"), Result.Num(), 1);
|
|
TestEqual(TEXT("and it is the seed"), Result[0], Mesh);
|
|
TestTrue(TEXT("exclusions were counted"), Stats.SkippedExcluded > 0);
|
|
});
|
|
|
|
It("stops at the requested depth", [this]()
|
|
{
|
|
int32 DirectCount = 0;
|
|
const FName Mesh = FindMeshWithDependencies(DirectCount);
|
|
|
|
if (Mesh.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AssetDependencyClosure::FOptions Options;
|
|
Options.MaxDepth = 1;
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Limited = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
|
|
|
|
TestTrue(TEXT("never went past one hop"), Stats.DeepestHop <= 1);
|
|
|
|
AssetDependencyClosure::FStats FullStats;
|
|
const TArray<FName> Full = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, {}, FullStats);
|
|
|
|
TestTrue(TEXT("the limit removed something or the graph was one hop deep"),
|
|
Limited.Num() <= Full.Num());
|
|
});
|
|
|
|
It("does not follow a map that an ordinary asset merely mentions", [this]()
|
|
{
|
|
// The defect this guards against: exporting WP_Example pulled in the whole of L_Other
|
|
// through BP_GameMode -> PDA_MenuConfig, 9994 packages belonging to a
|
|
// different location. FLevelUsageResolver already refused that crossing; the closure
|
|
// did not, so the two disagreed about the same graph.
|
|
IAssetRegistry& Registry = ReadyRegistry();
|
|
|
|
FARFilter Filter;
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
Filter.bRecursivePaths = true;
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
const FTopLevelAssetPath WorldClass = UWorld::StaticClass()->GetClassPathName();
|
|
|
|
FName Culprit = NAME_None;
|
|
FName ReferencedMap = NAME_None;
|
|
|
|
Registry.EnumerateAssets(Filter, [&](const FAssetData& AssetData)
|
|
{
|
|
// The source must not itself be a level or an external actor package, or the
|
|
// crossing would be structural and legitimately followed.
|
|
if (AssetData.AssetClassPath == WorldClass
|
|
|| AssetUsagePaths::IsExternalPackage(AssetData.PackageName))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
TArray<FName> Dependencies;
|
|
Registry.GetDependencies(
|
|
AssetData.PackageName,
|
|
Dependencies,
|
|
AssetUsageAudit::MakeTraversalCategory(),
|
|
AssetUsageAudit::MakeTraversalQuery());
|
|
|
|
for (FName Dependency : Dependencies)
|
|
{
|
|
TArray<FAssetData> InPackage;
|
|
Registry.GetAssetsByPackageName(Dependency, InPackage, true);
|
|
|
|
for (const FAssetData& Inner : InPackage)
|
|
{
|
|
if (Inner.AssetClassPath == WorldClass)
|
|
{
|
|
Culprit = AssetData.PackageName;
|
|
ReferencedMap = Dependency;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (Culprit.IsNone())
|
|
{
|
|
AddWarning(TEXT("No non-level asset referencing a map was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AddInfo(FString::Printf(TEXT("'%s' references the map '%s'."), *Culprit.ToString(), *ReferencedMap.ToString()));
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Culprit }, {}, Stats);
|
|
|
|
TestFalse(TEXT("the foreign map is not in the closure"), Result.Contains(ReferencedMap));
|
|
TestTrue(TEXT("and the refusal was counted"), Stats.ForeignLevelsSkipped > 0);
|
|
});
|
|
|
|
It("still expands a level that was ticked directly", [this]()
|
|
{
|
|
// The rule bounds foreign maps, not the seed. Someone who ticks a location is asking
|
|
// for its contents - that is the task this tool exists for.
|
|
IAssetRegistry& Registry = ReadyRegistry();
|
|
|
|
FARFilter Filter;
|
|
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
|
|
Filter.PackagePaths.Add(FName(TEXT("/Game")));
|
|
Filter.bRecursivePaths = true;
|
|
Filter.bIncludeOnlyOnDiskAssets = true;
|
|
|
|
FName Level = NAME_None;
|
|
|
|
Registry.EnumerateAssets(Filter, [&Registry, &Level](const FAssetData& AssetData)
|
|
{
|
|
TArray<FName> Dependencies;
|
|
Registry.GetDependencies(
|
|
AssetData.PackageName,
|
|
Dependencies,
|
|
AssetUsageAudit::MakeTraversalCategory(),
|
|
AssetUsageAudit::MakeTraversalQuery());
|
|
|
|
for (FName Dependency : Dependencies)
|
|
{
|
|
if (Dependency.ToString().StartsWith(TEXT("/Game/")))
|
|
{
|
|
Level = AssetData.PackageName;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (Level.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game level with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Level }, {}, Stats);
|
|
|
|
TestTrue(TEXT("the level brought its content"), Result.Num() > 1);
|
|
TestEqual(TEXT("the level is the seed"), Result[0], Level);
|
|
});
|
|
|
|
It("stops when the progress callback asks it to", [this]()
|
|
{
|
|
int32 DirectCount = 0;
|
|
const FName Mesh = FindMeshWithDependencies(DirectCount);
|
|
|
|
if (Mesh.IsNone())
|
|
{
|
|
AddWarning(TEXT("No /Game static mesh with dependencies was found; skipping."));
|
|
return;
|
|
}
|
|
|
|
AssetDependencyClosure::FOptions Options;
|
|
Options.OnProgress = [](int32, int32) { return false; };
|
|
|
|
AssetDependencyClosure::FStats Stats;
|
|
const TArray<FName> Result = AssetDependencyClosure::Gather(ReadyRegistry(), { Mesh }, Options, Stats);
|
|
|
|
TestTrue(TEXT("flagged as stopped"), Stats.bStoppedEarly);
|
|
|
|
// A cancelled walk still returns the seeds, so a caller that ignores the flag exports
|
|
// what was ticked rather than nothing at all.
|
|
TestEqual(TEXT("seeds survive cancellation"), Result.Num(), 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
#endif // WITH_DEV_AUTOMATION_TESTS
|