72bf94d142
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>
162 lines
5.3 KiB
C++
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;
|
|
}
|