Files
AssetUsageAudit/Source/AssetUsageAuditCore/Private/AssetUsageReportWriter.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

312 lines
11 KiB
C++

// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageReportWriter.h"
#include "AssetUsageAuditCoreModule.h"
#include "AssetUsageAuditor.h"
#include "AssetUsageAuditTypes.h"
#include "HAL/FileManager.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
namespace
{
/** Column order is shared by the CSV header and the row writer so they cannot drift. */
const TCHAR* CsvColumns[] = {
TEXT("Type"),
TEXT("GeneratedClass"),
TEXT("Name"),
TEXT("PathFromProjectRoot"),
TEXT("Verdict"),
TEXT("LevelCount"),
TEXT("Levels"),
TEXT("HardRefs"),
TEXT("SoftRefs"),
TEXT("Provenance"),
TEXT("Route"),
TEXT("ProvenanceDetail")
};
FString JoinNames(const TArray<FName>& Names, const FString& Separator)
{
TArray<FString> Strings;
Strings.Reserve(Names.Num());
for (const FName& Name : Names)
{
Strings.Add(Name.ToString());
}
return FString::Join(Strings, *Separator);
}
}
FString FAssetUsageReportWriter::EscapeCsvField(const FString& Field, TCHAR Delimiter)
{
const bool bNeedsQuoting =
Field.Contains(FString(1, &Delimiter), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\""), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\n"), ESearchCase::CaseSensitive) ||
Field.Contains(TEXT("\r"), ESearchCase::CaseSensitive);
if (!bNeedsQuoting)
{
return Field;
}
FString Escaped = Field;
Escaped.ReplaceInline(TEXT("\""), TEXT("\"\""), ESearchCase::CaseSensitive);
return FString::Printf(TEXT("\"%s\""), *Escaped);
}
FString FAssetUsageReportWriter::BuildCsv(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
{
const FAssetUsageReportHeader& Header = AuditResult.Header;
const TCHAR Delimiter = Options.CsvDelimiter;
FString Output;
Output.Reserve(AuditResult.Rows.Num() * 160 + 2048);
// Provenance of the report itself, as '#' comment lines. Excel shows them as ordinary rows,
// which is fine and is much better than a report nobody can date or attribute to a filter set.
Output += FString::Printf(TEXT("# Asset Usage Audit report\n"));
Output += FString::Printf(TEXT("# Generated: %s\n"), *Header.GeneratedAt.ToIso8601());
Output += FString::Printf(TEXT("# Project: %s\n"), *Header.ProjectName);
Output += FString::Printf(TEXT("# Engine: %s\n"), *Header.EngineVersion);
Output += FString::Printf(TEXT("# Tool: %s\n"), *Header.ToolVersion);
Output += FString::Printf(TEXT("# Levels scanned: %d\n"), Header.LevelsScanned);
Output += FString::Printf(TEXT("# Assets scanned: %d\n"), Header.AssetsScanned);
Output += FString::Printf(TEXT("# Rows reported: %d\n"), AuditResult.Rows.Num());
Output += FString::Printf(TEXT("# Scan duration: %.2f s\n"), Header.ScanDurationSeconds);
for (const FString& Filter : Header.AppliedFilters)
{
Output += FString::Printf(TEXT("# Filter: %s\n"), *Filter);
}
Output += FString::Printf(TEXT("# Verdict counts: UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n"),
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced),
AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
Output += TEXT("# Unknown means the registry cannot see this asset's references (FMOD, DataTable rows,\n");
Output += TEXT("# runtime-built paths). It does NOT mean unused. Never delete on the strength of it.\n");
for (int32 ColumnIndex = 0; ColumnIndex < UE_ARRAY_COUNT(CsvColumns); ++ColumnIndex)
{
if (ColumnIndex > 0)
{
Output.AppendChar(Delimiter);
}
Output += CsvColumns[ColumnIndex];
}
Output += TEXT("\n");
for (const FAssetUsageRow& Row : AuditResult.Rows)
{
TArray<FString, TInlineAllocator<16>> Fields;
Fields.Add(Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
Fields.Add(Row.GeneratedClassPath.IsNull() ? FString() : Row.GeneratedClassPath.ToString());
Fields.Add(Row.AssetName.ToString());
Fields.Add(Row.PathFromProjectRoot);
Fields.Add(LexToString(Row.Verdict));
Fields.Add(FString::FromInt(Row.Levels.Num()));
Fields.Add(JoinNames(Row.Levels, Options.CsvMultiValueSeparator));
Fields.Add(FString::FromInt(Row.HardReferenceCount));
Fields.Add(FString::FromInt(Row.SoftReferenceCount));
Fields.Add(ProvenanceToString(Row.Provenance));
Fields.Add(Row.Route);
Fields.Add(Row.ProvenanceDetail);
checkf(Fields.Num() == UE_ARRAY_COUNT(CsvColumns), TEXT("CSV row width must match the header."));
for (int32 FieldIndex = 0; FieldIndex < Fields.Num(); ++FieldIndex)
{
if (FieldIndex > 0)
{
Output.AppendChar(Delimiter);
}
Output += EscapeCsvField(Fields[FieldIndex], Delimiter);
}
Output += TEXT("\n");
}
return Output;
}
FString FAssetUsageReportWriter::BuildJson(const FAssetUsageAuditResult& AuditResult)
{
const FAssetUsageReportHeader& Header = AuditResult.Header;
FString Output;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Output);
Writer->WriteObjectStart();
Writer->WriteObjectStart(TEXT("header"));
Writer->WriteValue(TEXT("generatedAt"), Header.GeneratedAt.ToIso8601());
Writer->WriteValue(TEXT("project"), Header.ProjectName);
Writer->WriteValue(TEXT("engineVersion"), Header.EngineVersion);
Writer->WriteValue(TEXT("toolVersion"), Header.ToolVersion);
Writer->WriteValue(TEXT("levelsScanned"), Header.LevelsScanned);
Writer->WriteValue(TEXT("assetsScanned"), Header.AssetsScanned);
Writer->WriteValue(TEXT("rowsReported"), AuditResult.Rows.Num());
Writer->WriteValue(TEXT("scanDurationSeconds"), Header.ScanDurationSeconds);
Writer->WriteArrayStart(TEXT("appliedFilters"));
for (const FString& Filter : Header.AppliedFilters)
{
Writer->WriteValue(Filter);
}
Writer->WriteArrayEnd();
Writer->WriteObjectEnd();
Writer->WriteObjectStart(TEXT("verdictCounts"));
Writer->WriteValue(TEXT("usedOnLevel"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel));
Writer->WriteValue(TEXT("usedByAssetsOnly"), AuditResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly));
Writer->WriteValue(TEXT("referencedFromConfigOrSource"), AuditResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource));
Writer->WriteValue(TEXT("unreferenced"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unreferenced));
Writer->WriteValue(TEXT("unknown"), AuditResult.CountByVerdict(EAssetUsageVerdict::Unknown));
Writer->WriteObjectEnd();
Writer->WriteObjectStart(TEXT("graphStats"));
Writer->WriteValue(TEXT("packages"), AuditResult.GraphStats.NumPackages);
Writer->WriteValue(TEXT("edges"), AuditResult.GraphStats.NumEdges);
Writer->WriteValue(TEXT("levels"), AuditResult.GraphStats.NumLevels);
Writer->WriteValue(TEXT("externalPackages"), AuditResult.GraphStats.NumExternalPackages);
Writer->WriteValue(TEXT("enumerateSeconds"), AuditResult.GraphStats.EnumerateSeconds);
Writer->WriteValue(TEXT("dependencySeconds"), AuditResult.GraphStats.DependencySeconds);
Writer->WriteObjectEnd();
Writer->WriteArrayStart(TEXT("assets"));
for (const FAssetUsageRow& Row : AuditResult.Rows)
{
Writer->WriteObjectStart();
Writer->WriteValue(TEXT("name"), Row.AssetName.ToString());
Writer->WriteValue(TEXT("package"), Row.PackageName.ToString());
Writer->WriteValue(TEXT("path"), Row.PathFromProjectRoot);
Writer->WriteValue(TEXT("type"), Row.ClassPath.IsNull() ? FString() : Row.ClassPath.ToString());
if (!Row.GeneratedClassPath.IsNull())
{
Writer->WriteValue(TEXT("generatedClass"), Row.GeneratedClassPath.ToString());
}
Writer->WriteValue(TEXT("verdict"), LexToString(Row.Verdict));
Writer->WriteValue(TEXT("provenance"), ProvenanceToString(Row.Provenance));
Writer->WriteValue(TEXT("hardReferenceCount"), Row.HardReferenceCount);
Writer->WriteValue(TEXT("softReferenceCount"), Row.SoftReferenceCount);
Writer->WriteArrayStart(TEXT("levels"));
for (const FName& Level : Row.Levels)
{
Writer->WriteValue(Level.ToString());
}
Writer->WriteArrayEnd();
if (!Row.Route.IsEmpty())
{
Writer->WriteValue(TEXT("route"), Row.Route);
}
if (!Row.ProvenanceDetail.IsEmpty())
{
Writer->WriteValue(TEXT("provenanceDetail"), Row.ProvenanceDetail);
}
Writer->WriteObjectEnd();
}
Writer->WriteArrayEnd();
Writer->WriteObjectEnd();
Writer->Close();
return Output;
}
FAssetUsageReportWriter::FResult FAssetUsageReportWriter::Write(const FAssetUsageAuditResult& AuditResult, const FOptions& Options)
{
FResult Result;
if (Options.OutputDirectory.IsEmpty())
{
Result.ErrorMessage = TEXT("Output directory is empty.");
return Result;
}
if (!IFileManager::Get().MakeDirectory(*Options.OutputDirectory, true))
{
if (!IFileManager::Get().DirectoryExists(*Options.OutputDirectory))
{
Result.ErrorMessage = FString::Printf(TEXT("Could not create output directory '%s'."), *Options.OutputDirectory);
return Result;
}
}
const FString BaseName = Options.BaseFileName.IsEmpty() ? TEXT("AssetUsageReport") : Options.BaseFileName;
bool bAnyWritten = false;
if (Options.bWriteJson)
{
const FString JsonPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".json"));
if (FFileHelper::SaveStringToFile(BuildJson(AuditResult), *JsonPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
{
Result.JsonPath = JsonPath;
bAnyWritten = true;
}
else
{
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *JsonPath);
return Result;
}
}
if (Options.bWriteCsv)
{
const FString CsvPath = FPaths::Combine(Options.OutputDirectory, BaseName + TEXT(".csv"));
// Excel needs the BOM to read UTF-8; without it Cyrillic asset names come out mangled.
const FFileHelper::EEncodingOptions Encoding = Options.bCsvUtf8Bom
? FFileHelper::EEncodingOptions::ForceUTF8
: FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM;
if (FFileHelper::SaveStringToFile(BuildCsv(AuditResult, Options), *CsvPath, Encoding))
{
Result.CsvPath = CsvPath;
bAnyWritten = true;
}
else
{
Result.ErrorMessage = FString::Printf(TEXT("Failed to write '%s'."), *CsvPath);
return Result;
}
}
Result.bSuccess = bAnyWritten;
if (!bAnyWritten)
{
Result.ErrorMessage = TEXT("Both JSON and CSV output were disabled.");
}
else
{
UE_LOG(LogAssetUsageAudit, Log, TEXT("Report written: %d rows. JSON '%s', CSV '%s'."),
AuditResult.Rows.Num(), *Result.JsonPath, *Result.CsvPath);
}
return Result;
}