Files
AssetUsageAudit/Source/AssetUsageAuditCore/Private/IndirectReferenceScanner.cpp
T
MagentaDolphin b7f5343a73 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>
2026-09-03 17:07:59 +07:00

162 lines
5.3 KiB
C++

// NextGenium 2026. Asset Usage Audit.
#include "IndirectReferenceScanner.h"
#include "AssetUsageAuditCoreModule.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformFileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Misc/StringBuilder.h"
namespace
{
const TCHAR* GameRootLiteral = TEXT("/Game/");
/**
* Characters that can legally continue a package path. Everything else ends the literal.
*
* Note '.' is included so that an object path like /Game/X/BP_Y.BP_Y_C is captured whole and
* then trimmed below; stopping at the dot would work too but would silently accept the
* malformed tail of a truncated literal.
*/
bool IsPathChar(TCHAR Char)
{
return FChar::IsAlnum(Char) || Char == TEXT('_') || Char == TEXT('/') || Char == TEXT('.') || Char == TEXT('-');
}
}
FString FIndirectReference::ToProvenanceString() const
{
return FString::Printf(TEXT("%s:%d"), *FPaths::GetCleanFilename(SourceFile), LineNumber);
}
void FIndirectReferenceScanner::ExtractGamePathsFromLine(FStringView Line, TArray<FString>& OutPackageNames)
{
const int32 RootLen = FCString::Strlen(GameRootLiteral);
int32 SearchStart = 0;
while (SearchStart < Line.Len())
{
const FStringView Remaining = Line.RightChop(SearchStart);
const int32 Found = UE::String::FindFirst(Remaining, GameRootLiteral, ESearchCase::IgnoreCase);
if (Found == INDEX_NONE)
{
return;
}
const int32 Start = SearchStart + Found;
int32 End = Start + RootLen;
while (End < Line.Len() && IsPathChar(Line[End]))
{
++End;
}
FString Candidate(Line.Mid(Start, End - Start));
SearchStart = End > Start ? End : Start + RootLen;
// "/Game/" alone, or a trailing slash, is not a reference.
if (Candidate.Len() <= RootLen)
{
continue;
}
while (Candidate.EndsWith(TEXT("/"), ESearchCase::CaseSensitive) || Candidate.EndsWith(TEXT("."), ESearchCase::CaseSensitive))
{
Candidate.LeftChopInline(1, EAllowShrinking::No);
}
// Object path -> package path. "/Game/X/BP_Y.BP_Y_C" and "/Game/X/T.T" both name package
// "/Game/X/...". Take everything before the first dot after the last slash.
int32 LastSlash = INDEX_NONE;
Candidate.FindLastChar(TEXT('/'), LastSlash);
int32 DotAfterSlash = INDEX_NONE;
if (Candidate.FindChar(TEXT('.'), DotAfterSlash) && DotAfterSlash > LastSlash)
{
Candidate.LeftInline(DotAfterSlash, EAllowShrinking::No);
}
if (Candidate.Len() <= RootLen)
{
continue;
}
OutPackageNames.AddUnique(Candidate);
}
}
FIndirectReferenceScanner::FOptions FIndirectReferenceScanner::MakeDefaultOptions()
{
FOptions Options;
const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
Options.Directories.Add(ProjectDir / TEXT("Config"));
Options.Directories.Add(ProjectDir / TEXT("Source"));
return Options;
}
TArray<FIndirectReference> FIndirectReferenceScanner::Scan(const FOptions& Options)
{
TArray<FIndirectReference> Results;
IFileManager& FileManager = IFileManager::Get();
for (const FString& Directory : Options.Directories)
{
if (!FileManager.DirectoryExists(*Directory))
{
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: directory '%s' does not exist, skipping."), *Directory);
continue;
}
const bool bIsConfigDir = Directory.EndsWith(TEXT("Config"), ESearchCase::IgnoreCase);
TArray<FString> FoundFiles;
FileManager.FindFilesRecursive(FoundFiles, *Directory, TEXT("*.*"), true, false);
for (const FString& FilePath : FoundFiles)
{
const FString Extension = FPaths::GetExtension(FilePath, true).ToLower();
if (!Options.Extensions.Contains(Extension))
{
continue;
}
if (FileManager.FileSize(*FilePath) > Options.MaxFileSizeBytes)
{
UE_LOG(LogAssetUsageAudit, Verbose, TEXT("Indirect scan: skipping oversized file '%s'."), *FilePath);
continue;
}
TArray<FString> Lines;
if (!FFileHelper::LoadFileToStringArray(Lines, *FilePath))
{
UE_LOG(LogAssetUsageAudit, Warning, TEXT("Indirect scan: could not read '%s'."), *FilePath);
continue;
}
TArray<FString> PackageNames;
for (int32 LineIndex = 0; LineIndex < Lines.Num(); ++LineIndex)
{
PackageNames.Reset();
ExtractGamePathsFromLine(Lines[LineIndex], PackageNames);
for (const FString& PackageName : PackageNames)
{
FIndirectReference& Reference = Results.AddDefaulted_GetRef();
Reference.PackageName = FName(*PackageName);
Reference.SourceFile = FilePath;
Reference.LineNumber = LineIndex + 1;
Reference.bFromConfig = bIsConfigDir;
}
}
}
}
UE_LOG(LogAssetUsageAudit, Log, TEXT("Indirect scan found %d /Game path literals in Config and Source."), Results.Num());
return Results;
}