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:
2026-09-03 17:07:59 +07:00
commit 72bf94d142
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,132 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetExportNaming.h"
#include "AssetUsageAuditCoreModule.h"
namespace AssetExportNaming
{
namespace
{
/** Guard against a pathological folder where every candidate is taken. */
constexpr int32 MaxCollisionAttempts = 100000;
bool IsAllDigits(const FString& Value)
{
if (Value.IsEmpty())
{
return false;
}
for (const TCHAR Char : Value)
{
if (!FChar::IsDigit(Char))
{
return false;
}
}
return true;
}
}
void SplitTrailingIndex(const FString& BaseName, FString& OutStem, int32& OutIndex, int32& OutPadWidth)
{
OutStem = BaseName;
OutIndex = INDEX_NONE;
OutPadWidth = 0;
int32 SeparatorPos = INDEX_NONE;
if (!BaseName.FindLastChar(TEXT('_'), SeparatorPos))
{
return;
}
// A leading underscore is part of the name, not a separator: "_1" has no stem.
if (SeparatorPos <= 0)
{
return;
}
const FString Suffix = BaseName.Mid(SeparatorPos + 1);
// "Foo_" has an empty suffix, "SM_Rock_02_v3" has a non-numeric one. Neither is an index.
if (!IsAllDigits(Suffix))
{
return;
}
OutStem = BaseName.Left(SeparatorPos);
OutIndex = FCString::Atoi(*Suffix);
OutPadWidth = Suffix.Len();
}
FString ComposeIndexedName(const FString& Stem, int32 Index, int32 PadWidth)
{
if (Index == INDEX_NONE)
{
return Stem;
}
FString Digits = FString::FromInt(Index);
// Preserve the original zero padding, but never truncate: Foo_09 -> Foo_10, Foo_99 -> Foo_100.
while (Digits.Len() < PadWidth)
{
Digits.InsertAt(0, TEXT('0'));
}
return FString::Printf(TEXT("%s_%s"), *Stem, *Digits);
}
FString ResolveCollision(const FString& DesiredFileName, EExportCollisionPolicy Policy, FExistsPredicate Exists)
{
if (Policy == EExportCollisionPolicy::Overwrite)
{
return DesiredFileName;
}
if (!Exists(DesiredFileName))
{
return DesiredFileName;
}
// Split off the extension so the index lands before it: SM_Rock.uasset -> SM_Rock_1.uasset.
// Note this splits on the LAST dot, so a name like "M_Glass.v2.uasset" keeps "M_Glass.v2" as the base.
FString BaseName = DesiredFileName;
FString Extension;
int32 DotPos = INDEX_NONE;
if (DesiredFileName.FindLastChar(TEXT('.'), DotPos) && DotPos > 0)
{
BaseName = DesiredFileName.Left(DotPos);
Extension = DesiredFileName.Mid(DotPos); // includes the dot
}
FString Stem;
int32 Index = INDEX_NONE;
int32 PadWidth = 0;
SplitTrailingIndex(BaseName, Stem, Index, PadWidth);
// No trailing index yet: start at _1. Otherwise increment what is already there,
// rather than appending a second suffix and producing Foo_1_1.
int32 NextIndex = (Index == INDEX_NONE) ? 1 : Index + 1;
const int32 EffectivePadWidth = (Index == INDEX_NONE) ? 1 : PadWidth;
for (int32 Attempt = 0; Attempt < MaxCollisionAttempts; ++Attempt, ++NextIndex)
{
const FString Candidate = ComposeIndexedName(Stem, NextIndex, EffectivePadWidth) + Extension;
if (!Exists(Candidate))
{
return Candidate;
}
}
// Every candidate taken. Returning the desired name would silently overwrite, which is the
// one outcome the Index policy exists to prevent - so fail loudly instead.
UE_LOG(LogAssetUsageAudit, Error,
TEXT("Could not find a free filename for '%s' after %d attempts. Export of this asset will be skipped."),
*DesiredFileName, MaxCollisionAttempts);
return FString();
}
}