b7f5343a73
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>
290 lines
11 KiB
C++
290 lines
11 KiB
C++
// NextGenium 2026. Asset Usage Audit.
|
|
|
|
#include "AssetExportNaming.h"
|
|
#include "AssetUsageAuditTypes.h"
|
|
#include "Misc/AutomationTest.h"
|
|
|
|
#if WITH_DEV_AUTOMATION_TESTS
|
|
|
|
namespace
|
|
{
|
|
/** Builds an Exists predicate over a fixed set of filenames, so no filesystem is involved. */
|
|
struct FFakeFolder
|
|
{
|
|
TSet<FString> Files;
|
|
|
|
explicit FFakeFolder(std::initializer_list<const TCHAR*> InFiles)
|
|
{
|
|
for (const TCHAR* File : InFiles)
|
|
{
|
|
Files.Add(FString(File));
|
|
}
|
|
}
|
|
|
|
bool Contains(const FString& FileName) const
|
|
{
|
|
return Files.Contains(FileName);
|
|
}
|
|
};
|
|
}
|
|
|
|
BEGIN_DEFINE_SPEC(AssetExportNamingSpec,
|
|
"AssetUsageAudit.ExportNaming",
|
|
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
|
|
END_DEFINE_SPEC(AssetExportNamingSpec)
|
|
|
|
void AssetExportNamingSpec::Define()
|
|
{
|
|
Describe("SplitTrailingIndex", [this]()
|
|
{
|
|
It("reports no index for a plain name", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
|
|
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
|
|
TestEqual(TEXT("PadWidth"), PadWidth, 0);
|
|
});
|
|
|
|
It("splits a single-digit trailing index", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_7"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
|
|
TestEqual(TEXT("Index"), Index, 7);
|
|
TestEqual(TEXT("PadWidth"), PadWidth, 1);
|
|
});
|
|
|
|
It("preserves zero padding width", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_007"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
|
|
TestEqual(TEXT("Index"), Index, 7);
|
|
TestEqual(TEXT("PadWidth"), PadWidth, 3);
|
|
});
|
|
|
|
It("treats a non-numeric suffix as part of the name", [this]()
|
|
{
|
|
// The trap this whole function exists for: _v3 must not be read as an index,
|
|
// and the _02 in the middle must not be touched.
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02_v3"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_02_v3")));
|
|
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
|
|
});
|
|
|
|
It("splits a mid-name numeric group only when it is trailing", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_02"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock")));
|
|
TestEqual(TEXT("Index"), Index, 2);
|
|
TestEqual(TEXT("PadWidth"), PadWidth, 2);
|
|
});
|
|
|
|
It("treats an empty suffix as no index", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("SM_Rock_"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("SM_Rock_")));
|
|
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
|
|
});
|
|
|
|
It("does not treat a leading underscore as a separator", [this]()
|
|
{
|
|
FString Stem;
|
|
int32 Index = 0;
|
|
int32 PadWidth = 0;
|
|
AssetExportNaming::SplitTrailingIndex(TEXT("_1"), Stem, Index, PadWidth);
|
|
|
|
TestEqual(TEXT("Stem"), Stem, FString(TEXT("_1")));
|
|
TestEqual(TEXT("Index"), Index, (int32)INDEX_NONE);
|
|
});
|
|
});
|
|
|
|
Describe("ComposeIndexedName", [this]()
|
|
{
|
|
It("returns the bare stem for INDEX_NONE", [this]()
|
|
{
|
|
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), INDEX_NONE, 0), FString(TEXT("Foo")));
|
|
});
|
|
|
|
It("appends an unpadded index", [this]()
|
|
{
|
|
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 1, 1), FString(TEXT("Foo_1")));
|
|
});
|
|
|
|
It("restores zero padding", [this]()
|
|
{
|
|
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 8, 3), FString(TEXT("Foo_008")));
|
|
});
|
|
|
|
It("widens rather than truncates when the number outgrows the padding", [this]()
|
|
{
|
|
TestEqual(TEXT("Composed"), AssetExportNaming::ComposeIndexedName(TEXT("Foo"), 100, 2), FString(TEXT("Foo_100")));
|
|
});
|
|
});
|
|
|
|
Describe("ResolveCollision under Overwrite", [this]()
|
|
{
|
|
It("returns the desired name even when it is taken", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock.uasset"),
|
|
EExportCollisionPolicy::Overwrite,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset")));
|
|
});
|
|
});
|
|
|
|
Describe("ResolveCollision under Index", [this]()
|
|
{
|
|
It("returns the desired name when the folder is empty", [this]()
|
|
{
|
|
const FFakeFolder Folder({});
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock.uasset")));
|
|
});
|
|
|
|
It("appends _1 on the first collision", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.uasset")));
|
|
});
|
|
|
|
It("advances to _2 when _1 is also taken", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock.uasset"), TEXT("SM_Rock_1.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_2.uasset")));
|
|
});
|
|
|
|
It("increments an existing index instead of appending a second one", [this]()
|
|
{
|
|
// The explicit requirement: Foo_7 must become Foo_8, never Foo_7_1.
|
|
const FFakeFolder Folder({ TEXT("SM_Rock_7.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock_7.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_8.uasset")));
|
|
TestFalse(TEXT("Must not contain a doubled index"), Result.Contains(TEXT("_7_")));
|
|
});
|
|
|
|
It("keeps zero padding when incrementing", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock_007.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock_007.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_008.uasset")));
|
|
});
|
|
|
|
It("widens padding when the increment carries", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock_99.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock_99.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_100.uasset")));
|
|
});
|
|
|
|
It("does not mistake a version suffix for an index", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock_02_v3.uasset") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock_02_v3.uasset"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_02_v3_1.uasset")));
|
|
});
|
|
|
|
It("preserves the extension", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock.fbx") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock.fbx"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1.fbx")));
|
|
});
|
|
|
|
It("handles a name with no extension", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("SM_Rock") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("SM_Rock"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("SM_Rock_1")));
|
|
});
|
|
|
|
It("skips over a gap-free run of taken indices", [this]()
|
|
{
|
|
const FFakeFolder Folder({
|
|
TEXT("T_Wall.png"), TEXT("T_Wall_1.png"), TEXT("T_Wall_2.png"), TEXT("T_Wall_3.png") });
|
|
const FString Result = AssetExportNaming::ResolveCollision(
|
|
TEXT("T_Wall.png"),
|
|
EExportCollisionPolicy::Index,
|
|
[&Folder](const FString& Name) { return Folder.Contains(Name); });
|
|
|
|
TestEqual(TEXT("Result"), Result, FString(TEXT("T_Wall_4.png")));
|
|
});
|
|
|
|
It("is idempotent for a name that does not collide", [this]()
|
|
{
|
|
const FFakeFolder Folder({ TEXT("Other.uasset") });
|
|
const auto Predicate = [&Folder](const FString& Name) { return Folder.Contains(Name); };
|
|
|
|
const FString First = AssetExportNaming::ResolveCollision(TEXT("SM_Rock.uasset"), EExportCollisionPolicy::Index, Predicate);
|
|
const FString Second = AssetExportNaming::ResolveCollision(First, EExportCollisionPolicy::Index, Predicate);
|
|
|
|
TestEqual(TEXT("Stable"), Second, First);
|
|
});
|
|
});
|
|
}
|
|
|
|
#endif // WITH_DEV_AUTOMATION_TESTS
|