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 b7f5343a73
60 changed files with 13673 additions and 0 deletions
@@ -0,0 +1,186 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsagePaths.h"
#include "HAL/FileManager.h"
#include "Misc/Paths.h"
#include "Misc/StringBuilder.h"
#include "String/Find.h"
namespace AssetUsagePaths
{
namespace Private
{
// These two literals match FPackagePath's folder names (CoreUObject PackagePath.cpp).
// They are used only to CLASSIFY an existing path. Producing an external-actor path is
// ULevel::GetExternalActorsPaths's job, because that form has /CB/ and /EDL/ variants and
// is extensible by plugin delegates - string building it here would be silently wrong.
static const TCHAR* ExternalActorsFolder = TEXT("__ExternalActors__");
static const TCHAR* ExternalObjectsFolder = TEXT("__ExternalObjects__");
static const TCHAR* GameRoot = TEXT("/Game");
static const TCHAR* ContentPrefix = TEXT("Content");
/** Prefix match that only accepts a whole path segment, so /Game/Art misses /Game/ArtSource. */
static bool StartsWithSegment(FStringView Path, FStringView Prefix)
{
if (Prefix.IsEmpty() || Path.Len() < Prefix.Len())
{
return false;
}
if (!Path.Left(Prefix.Len()).Equals(Prefix, ESearchCase::IgnoreCase))
{
return false;
}
return Path.Len() == Prefix.Len() || Path[Prefix.Len()] == TEXT('/');
}
}
bool IsExternalPackage(FStringView PackagePath)
{
return UE::String::FindFirst(PackagePath, Private::ExternalActorsFolder, ESearchCase::IgnoreCase) != INDEX_NONE
|| UE::String::FindFirst(PackagePath, Private::ExternalObjectsFolder, ESearchCase::IgnoreCase) != INDEX_NONE;
}
bool IsExternalPackage(FName PackageName)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsExternalPackage(Builder.ToView());
}
bool IsScriptPackage(FStringView PackagePath)
{
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Script"));
}
bool IsScriptPackage(FName PackageName)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsScriptPackage(Builder.ToView());
}
bool IsEngineOrTempPackage(FStringView PackagePath)
{
return Private::StartsWithSegment(PackagePath, TEXTVIEW("/Engine"))
|| Private::StartsWithSegment(PackagePath, TEXTVIEW("/Temp"));
}
FString NormalizeExclusionPrefix(const FString& Prefix)
{
FString Working = Prefix;
Working.TrimStartAndEndInline();
Working.ReplaceInline(TEXT("\\"), TEXT("/"), ESearchCase::CaseSensitive);
while (Working.EndsWith(TEXT("/"), ESearchCase::CaseSensitive))
{
Working.LeftChopInline(1, EAllowShrinking::No);
}
if (Working.IsEmpty())
{
return FString();
}
// Content-relative form, which is what the UI shows and what settings files accumulate.
if (Private::StartsWithSegment(Working, Private::ContentPrefix))
{
const FString Remainder = Working.RightChop(FCString::Strlen(Private::ContentPrefix));
return FString(Private::GameRoot) + Remainder;
}
if (!Working.StartsWith(TEXT("/"), ESearchCase::CaseSensitive))
{
Working.InsertAt(0, TEXT("/"));
}
return Working;
}
bool IsPathExcluded(FStringView PackagePath, const TArray<FString>& ExcludedPrefixes)
{
for (const FString& RawPrefix : ExcludedPrefixes)
{
const FString Normalized = NormalizeExclusionPrefix(RawPrefix);
if (!Normalized.IsEmpty() && Private::StartsWithSegment(PackagePath, Normalized))
{
return true;
}
}
return false;
}
bool IsPathExcluded(FName PackageName, const TArray<FString>& ExcludedPrefixes)
{
if (ExcludedPrefixes.IsEmpty())
{
return false;
}
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
return IsPathExcluded(Builder.ToView(), ExcludedPrefixes);
}
FString ToProjectRelativePath(FName PackageName, bool bIsLevel)
{
TStringBuilder<256> Builder;
PackageName.ToString(Builder);
const FStringView PackagePath = Builder.ToView();
if (!Private::StartsWithSegment(PackagePath, Private::GameRoot))
{
// /Script, /Engine and plugin mounts have no project-relative form. Reporting an
// invented path for them would send an artist looking for a file that is not there.
return FString();
}
const FStringView Remainder = PackagePath.RightChop(FCString::Strlen(Private::GameRoot));
const TCHAR* Extension = bIsLevel ? TEXT(".umap") : TEXT(".uasset");
return FString::Printf(TEXT("Content%.*s%s"), Remainder.Len(), Remainder.GetData(), Extension);
}
FString ValidateMigrateDestination(const FString& Directory)
{
if (Directory.IsEmpty())
{
return TEXT("Choose the destination project's Content folder.");
}
FString Normalised = Directory;
FPaths::NormalizeDirectoryName(Normalised);
Normalised += TEXT("/");
if (!Normalised.EndsWith(TEXT("/Content/")))
{
return TEXT("Migrate needs a folder named Content - pick the destination project's Content folder itself, not the project root.");
}
// Second rule. A folder merely named Content passes the first check and Migrate still
// aborts: the destination mount point is derived from what sits beside it.
const FString Parent = Normalised + TEXT("../");
TArray<FString> Found;
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uproject"));
if (!Found.IsEmpty())
{
return FString();
}
IFileManager::Get().FindFiles(Found, *Parent, TEXT("uplugin"));
if (Found.Num() == 1)
{
return FString();
}
return Found.Num() > 1
? TEXT("That folder's parent holds several .uplugin files, so Unreal cannot tell which mount point to migrate into.")
: TEXT("No .uproject or .uplugin next to that Content folder, so Unreal has no mount point to migrate into.");
}
}