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,38 @@
// NextGenium 2026. Asset Usage Audit.
using UnrealBuildTool;
public class AssetUsageAuditEditor : ModuleRules
{
public AssetUsageAuditEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"AssetUsageAuditCore"
});
// Everything editor-only and UI-shaped belongs here and nowhere else. Keeping this list
// out of AssetUsageAuditCore is what lets the same analysis run from a commandlet later.
PrivateDependencyModuleNames.AddRange(new string[]
{
"CoreUObject",
"Engine",
"AssetRegistry",
"AssetTools", // MigratePackages - the only path that keeps references working
"DeveloperSettings",
"DesktopPlatform", // native folder picker for the export destination
"EditorFramework",
"InputCore",
"Projects",
"Slate",
"SlateCore",
"ToolMenus",
"ToolWidgets", // SSearchBox, SSegmentedControl
"UnrealEd",
"WorkspaceMenuStructure" // places the tab under Tools in the Window menu
});
}
}
@@ -0,0 +1,257 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditEditorModule.h"
#include "AssetUsageAuditSettings.h"
#include "AssetUsageAuditor.h"
#include "AssetUsageReportWriter.h"
#include "SAssetUsageAuditPanel.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Framework/Docking/TabManager.h"
#include "HAL/IConsoleManager.h"
#include "Misc/ScopedSlowTask.h"
#include "Styling/AppStyle.h"
#include "ToolMenus.h"
#include "Widgets/Docking/SDockTab.h"
#include "WorkspaceMenuStructure.h"
#include "WorkspaceMenuStructureModule.h"
DEFINE_LOG_CATEGORY(LogAssetUsageAuditEditor);
#define LOCTEXT_NAMESPACE "FAssetUsageAuditEditorModule"
const FName FAssetUsageAuditEditorModule::PanelTabId(TEXT("AssetUsageAudit"));
void FAssetUsageAuditEditorModule::StartupModule()
{
RegisterConsoleCommands();
RegisterTabSpawner();
// ToolMenus may not exist yet at PostEngineInit; the callback fires once it does. Registering
// directly as well would double up the entry.
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FAssetUsageAuditEditorModule::RegisterMenus));
}
void FAssetUsageAuditEditorModule::ShutdownModule()
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
UnregisterTabSpawner();
UnregisterConsoleCommands();
}
void FAssetUsageAuditEditorModule::RegisterTabSpawner()
{
FGlobalTabmanager::Get()
->RegisterNomadTabSpawner(
PanelTabId,
FOnSpawnTab::CreateRaw(this, &FAssetUsageAuditEditorModule::SpawnPanelTab))
.SetDisplayName(LOCTEXT("PanelTabTitle", "Asset Usage Audit"))
.SetTooltipText(LOCTEXT("PanelTabTooltip", "Find which assets each level uses, and which are used nowhere."))
.SetGroup(WorkspaceMenu::GetMenuStructure().GetToolsCategory())
.SetIcon(FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Search"));
}
void FAssetUsageAuditEditorModule::UnregisterTabSpawner()
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(PanelTabId);
}
TSharedRef<SDockTab> FAssetUsageAuditEditorModule::SpawnPanelTab(const FSpawnTabArgs& Args)
{
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(SAssetUsageAuditPanel)
];
}
void FAssetUsageAuditEditorModule::RegisterMenus()
{
FToolMenuOwnerScoped OwnerScoped(this);
UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu(TEXT("LevelEditor.MainMenu.Tools"));
if (!ToolsMenu)
{
return;
}
FToolMenuSection& Section = ToolsMenu->FindOrAddSection(TEXT("Audit"), LOCTEXT("AuditSection", "Audit"));
Section.AddMenuEntry(
TEXT("OpenAssetUsageAudit"),
LOCTEXT("OpenPanel", "Asset Usage Audit"),
LOCTEXT("OpenPanelTip", "Find which assets each level uses, and which are used nowhere."),
FSlateIcon(FAppStyle::GetAppStyleSetName(), "Icons.Search"),
FUIAction(FExecuteAction::CreateLambda([]()
{
FGlobalTabmanager::Get()->TryInvokeTab(FAssetUsageAuditEditorModule::PanelTabId);
})));
}
void FAssetUsageAuditEditorModule::RegisterConsoleCommands()
{
IConsoleManager& ConsoleManager = IConsoleManager::Get();
ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand(
TEXT("AssetUsageAudit.Run"),
TEXT("Audit asset usage across levels and write a JSON + CSV report. Optional args: level package names to restrict the sweep."),
FConsoleCommandWithArgsDelegate::CreateLambda([this](const TArray<FString>& Args)
{
ExecuteAudit(Args, /*bOnlyUnused=*/false);
}),
ECVF_Default));
ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand(
TEXT("AssetUsageAudit.OpenPanel"),
TEXT("Open the Asset Usage Audit panel. Same as Tools > Asset Usage Audit."),
FConsoleCommandDelegate::CreateLambda([]()
{
if (FGlobalTabmanager::Get()->TryInvokeTab(PanelTabId).IsValid())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Usage Audit panel opened."));
}
else
{
UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Could not open the Asset Usage Audit panel."));
}
}),
ECVF_Default));
ConsoleCommands.Add(ConsoleManager.RegisterConsoleCommand(
TEXT("AssetUsageAudit.FindUnused"),
TEXT("Report only assets that no level reaches. Read the Verdict column: Unknown is not the same as unused."),
FConsoleCommandWithArgsDelegate::CreateLambda([this](const TArray<FString>& Args)
{
ExecuteAudit(Args, /*bOnlyUnused=*/true);
}),
ECVF_Default));
}
void FAssetUsageAuditEditorModule::UnregisterConsoleCommands()
{
IConsoleManager& ConsoleManager = IConsoleManager::Get();
for (IConsoleObject* Command : ConsoleCommands)
{
if (Command)
{
ConsoleManager.UnregisterConsoleObject(Command);
}
}
ConsoleCommands.Reset();
}
void FAssetUsageAuditEditorModule::ExecuteAudit(const TArray<FString>& Args, bool bOnlyUnused)
{
FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& AssetRegistry = AssetRegistryModule.Get();
// A sweep started mid-scan silently reports a fraction of the project, so the scan must finish
// first. Block rather than refuse: on a cold start the registry is nearly always still
// scanning, and refusing made the command unusable from -ExecCmds, where there is no human to
// retry. WaitForCompletion is synchronous and behaves the same in the editor and a commandlet.
if (AssetRegistry.IsLoadingAssets())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry is still scanning; waiting for it to finish before auditing."));
AssetRegistry.WaitForCompletion();
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry scan complete; starting the audit."));
}
const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get();
check(Settings);
FAssetUsageAuditRequest Request;
Request.IncludePackagePaths = Settings->IncludedPackagePaths;
Request.ExcludePackagePaths = Settings->ExcludedPackagePaths;
Request.bScanIndirectReferences = Settings->bScanIndirectReferences;
Request.bHideExternalPackages = Settings->bHideExternalPackages;
Request.bOnlyUnusedAssets = bOnlyUnused;
for (const FString& Arg : Args)
{
Request.LevelPackages.Add(FName(*Arg));
}
FScopedSlowTask SlowTask(100.0f, LOCTEXT("AuditingAssets", "Auditing asset usage..."));
SlowTask.MakeDialog(/*bShowCancelButton=*/true);
bool bCancelled = false;
Request.ResolveOptions.ShouldAbort = [&SlowTask, &bCancelled]()
{
if (SlowTask.ShouldCancel())
{
bCancelled = true;
return true;
}
return false;
};
Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 LevelOrdinal, int32 TotalLevels)
{
if (TotalLevels > 0 && LevelOrdinal % 16 == 0)
{
SlowTask.EnterProgressFrame(
16.0f * 100.0f / static_cast<float>(TotalLevels),
FText::Format(LOCTEXT("AuditingLevel", "Level {0} of {1}"), LevelOrdinal, TotalLevels));
}
};
const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request);
if (bCancelled)
{
UE_LOG(LogAssetUsageAuditEditor, Warning,
TEXT("Audit cancelled. Partial results were discarded - a partial sweep understates usage and must not be reported."));
return;
}
FAssetUsageReportWriter::FOptions WriteOptions;
WriteOptions.OutputDirectory = Settings->GetResolvedExportDirectory();
WriteOptions.BaseFileName = FString::Printf(TEXT("AssetUsageReport_%s"), *Result.Header.GeneratedAt.ToString(TEXT("%Y%m%d_%H%M%S")));
const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Result, WriteOptions);
if (!WriteResult.bSuccess)
{
UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Report was not written: %s"), *WriteResult.ErrorMessage);
return;
}
UE_LOG(LogAssetUsageAuditEditor, Display,
TEXT("Audit finished in %.2fs. %d rows across %d levels (%d packages, %d edges, %d OFPA packages).\n")
TEXT(" UsedOnLevel=%d UsedByAssetsOnly=%d ReferencedFromConfigOrSource=%d Unreferenced=%d Unknown=%d\n")
TEXT(" JSON: %s\n CSV: %s"),
Result.Header.ScanDurationSeconds,
Result.Rows.Num(),
Result.Header.LevelsScanned,
Result.GraphStats.NumPackages,
Result.GraphStats.NumEdges,
Result.GraphStats.NumExternalPackages,
Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
Result.CountByVerdict(EAssetUsageVerdict::Unreferenced),
Result.CountByVerdict(EAssetUsageVerdict::Unknown),
*WriteResult.JsonPath,
*WriteResult.CsvPath);
// The canary. If a sweep of an OFPA level yields almost nothing, the dependency query has
// regressed to Hard-only and every external actor has silently vanished from the result.
if (Result.GraphStats.NumExternalPackages > 0 && Result.LevelStats.AssetsReachable < Result.GraphStats.NumExternalPackages / 4)
{
UE_LOG(LogAssetUsageAuditEditor, Warning,
TEXT("Only %d assets were reachable although the project has %d One File Per Actor packages. ")
TEXT("This is the signature of a Hard-only dependency query dropping every external actor. Check the traversal query."),
Result.LevelStats.AssetsReachable, Result.GraphStats.NumExternalPackages);
}
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FAssetUsageAuditEditorModule, AssetUsageAuditEditor)
@@ -0,0 +1,213 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditLibrary.h"
#include "AssetUsageAuditEditorModule.h"
#include "AssetUsageAuditSettings.h"
#include "AssetUsageAuditor.h"
#include "AssetUsageReportWriter.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "HAL/PlatformTime.h"
#include "Misc/ScopedSlowTask.h"
namespace
{
/**
* The registry gathers asynchronously in the editor. Reading it mid-gather yields a partial
* graph and therefore a confidently wrong "unused" list, so block until it is complete.
* In a commandlet the module already gathers synchronously and this returns immediately.
*/
IAssetRegistry& GetReadyAssetRegistry()
{
FAssetRegistryModule& Module = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& AssetRegistry = Module.Get();
if (AssetRegistry.IsLoadingAssets())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Asset Registry is still scanning; waiting before the audit."));
AssetRegistry.WaitForCompletion();
}
return AssetRegistry;
}
/** Populate a request from the shared settings, so every entry point behaves the same. */
FAssetUsageAuditRequest MakeRequestFromSettings()
{
FAssetUsageAuditRequest Request;
if (const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get())
{
Request.IncludePackagePaths = Settings->IncludedPackagePaths;
Request.ExcludePackagePaths = Settings->ExcludedPackagePaths;
Request.bScanIndirectReferences = Settings->bScanIndirectReferences;
Request.bHideExternalPackages = Settings->bHideExternalPackages;
}
return Request;
}
}
bool UAssetUsageAuditLibrary::RunAudit(const TArray<FString>& LevelPackageNames, const FString& OutputDirectory, FString& OutReportPath)
{
OutReportPath.Reset();
IAssetRegistry& AssetRegistry = GetReadyAssetRegistry();
FAssetUsageAuditRequest Request = MakeRequestFromSettings();
Request.LevelPackages.Reserve(LevelPackageNames.Num());
for (const FString& LevelName : LevelPackageNames)
{
Request.LevelPackages.Add(FName(*LevelName));
}
FScopedSlowTask SlowTask(100.0f, NSLOCTEXT("AssetUsageAudit", "RunningAudit", "Auditing asset usage..."));
SlowTask.MakeDialog(true);
Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 Current, int32 Total)
{
if (Total > 0)
{
SlowTask.EnterProgressFrame(100.0f / static_cast<float>(Total));
}
};
Request.ResolveOptions.ShouldAbort = [&SlowTask]()
{
return SlowTask.ShouldCancel();
};
const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request);
FAssetUsageReportWriter::FOptions WriteOptions;
WriteOptions.OutputDirectory = OutputDirectory;
if (WriteOptions.OutputDirectory.IsEmpty())
{
if (const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get())
{
WriteOptions.OutputDirectory = Settings->GetResolvedExportDirectory();
}
}
const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Result, WriteOptions);
if (!WriteResult.bSuccess)
{
UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Failed to write the audit report: %s"), *WriteResult.ErrorMessage);
return false;
}
OutReportPath = WriteResult.JsonPath;
UE_LOG(LogAssetUsageAuditEditor, Log,
TEXT("Audit complete: %d rows across %d levels. UsedOnLevel %d, UsedByAssetsOnly %d, Config/Source %d, Unreferenced %d, Unknown %d. Reports: %s | %s"),
Result.Rows.Num(),
Result.LevelStats.LevelsScanned,
Result.CountByVerdict(EAssetUsageVerdict::UsedOnLevel),
Result.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly),
Result.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource),
Result.CountByVerdict(EAssetUsageVerdict::Unreferenced),
Result.CountByVerdict(EAssetUsageVerdict::Unknown),
*WriteResult.JsonPath,
*WriteResult.CsvPath);
return true;
}
TArray<FString> UAssetUsageAuditLibrary::GetAssetsUsedOnLevel(const FString& LevelPackageName)
{
TArray<FString> Out;
if (LevelPackageName.IsEmpty())
{
UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("GetAssetsUsedOnLevel called with an empty level name."));
return Out;
}
IAssetRegistry& AssetRegistry = GetReadyAssetRegistry();
FAssetUsageAuditRequest Request = MakeRequestFromSettings();
Request.LevelPackages.Add(FName(*LevelPackageName));
// Routes are the expensive part of the sweep and nothing here consumes them.
Request.ResolveOptions.bRecordRoutes = false;
const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request);
Out.Reserve(Result.Rows.Num());
for (const FAssetUsageRow& Row : Result.Rows)
{
if (Row.IsUsedOnAnyLevel())
{
Out.Add(Row.PackageName.ToString());
}
}
return Out;
}
TArray<FString> UAssetUsageAuditLibrary::GetLevelsUsingAsset(const FString& AssetPackageName)
{
TArray<FString> Out;
if (AssetPackageName.IsEmpty())
{
UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("GetLevelsUsingAsset called with an empty asset name."));
return Out;
}
IAssetRegistry& AssetRegistry = GetReadyAssetRegistry();
// Every level must be swept: the question is which of them reach this asset, and that cannot
// be answered from a subset.
FAssetUsageAuditRequest Request = MakeRequestFromSettings();
Request.ResolveOptions.bRecordRoutes = false;
const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request);
const FName Target(*AssetPackageName);
for (const FAssetUsageRow& Row : Result.Rows)
{
if (Row.PackageName == Target)
{
Out.Reserve(Row.Levels.Num());
for (FName Level : Row.Levels)
{
Out.Add(Level.ToString());
}
break;
}
}
return Out;
}
float UAssetUsageAuditLibrary::MeasureFullSweepSeconds()
{
IAssetRegistry& AssetRegistry = GetReadyAssetRegistry();
FAssetUsageAuditRequest Request = MakeRequestFromSettings();
const double StartTime = FPlatformTime::Seconds();
const FAssetUsageAuditResult Result = FAssetUsageAuditor::Run(AssetRegistry, Request);
const double Elapsed = FPlatformTime::Seconds() - StartTime;
UE_LOG(LogAssetUsageAuditEditor, Log,
TEXT("Full sweep timing: %.2fs total. Graph: enumerate %.2fs + dependencies %.2fs over %d packages and %d edges. ")
TEXT("Levels: %d scanned, ScanLevelAssets %.2fs + traversal %.2fs, %d assets reachable, %d external packages seeded."),
Elapsed,
Result.GraphStats.EnumerateSeconds,
Result.GraphStats.DependencySeconds,
Result.GraphStats.NumPackages,
Result.GraphStats.NumEdges,
Result.LevelStats.LevelsScanned,
Result.LevelStats.ScanLevelAssetsSeconds,
Result.LevelStats.TraversalSeconds,
Result.LevelStats.AssetsReachable,
Result.LevelStats.ExternalPackagesSeeded);
return static_cast<float>(Elapsed);
}
@@ -0,0 +1,47 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditSettings.h"
#include "Misc/App.h"
#include "Misc/Paths.h"
UAssetUsageAuditSettings::UAssetUsageAuditSettings()
{
// Bought content packs. Measured on this project: roughly two thirds of the 80k assets are
// third-party, so leaving these in makes the first run unreadable for an artist looking for
// their own content.
//
// Deliberately NOT excluded, despite being a bought pack: Content/MenuSystemPro. It supplies
// BP_MenuSystemGameInstance, which DefaultEngine.ini names as the project's GameInstance.
// Excluding it would hide a load-bearing asset from every report.
ExcludedPackagePaths = {
TEXT("Content/3rdParty"),
TEXT("Content/StarterContent"),
TEXT("Content/StarterBundle"),
TEXT("Content/Megascans"),
TEXT("Content/MSPresets")
};
}
const UAssetUsageAuditSettings* UAssetUsageAuditSettings::Get()
{
return GetDefault<UAssetUsageAuditSettings>();
}
FString UAssetUsageAuditSettings::GetResolvedExportDirectory() const
{
if (!DefaultExportDirectory.IsEmpty())
{
return DefaultExportDirectory;
}
return FPaths::ConvertRelativePathToFull(FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("AssetUsageAudit")));
}
FName UAssetUsageAuditSettings::GetCategoryName() const
{
// House convention, matching ConventionKeeper: project tools group under the project's own
// name in Project Settings rather than scattering across Plugins.
// GetProjectName already returns const TCHAR*; dereferencing it would pass a single character.
return FName(FApp::GetProjectName());
}
@@ -0,0 +1,8 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetUsageAuditUserSettings.h"
UAssetUsageAuditUserSettings* UAssetUsageAuditUserSettings::Get()
{
return GetMutableDefault<UAssetUsageAuditUserSettings>();
}
@@ -0,0 +1,532 @@
// NextGenium 2026. Asset Usage Audit.
#include "SAssetExportDialog.h"
#include "AssetUsageAuditEditorModule.h"
#include "AssetUsagePaths.h"
#include "DesktopPlatformModule.h"
#include "Framework/Application/SlateApplication.h"
#include "IDesktopPlatform.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Input/SComboBox.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/Input/SSegmentedControl.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SGridPanel.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/SWindow.h"
#include "Widgets/Text/STextBlock.h"
#define LOCTEXT_NAMESPACE "AssetExportDialog"
namespace
{
FText LayoutLabel(EExportLayout Layout)
{
switch (Layout)
{
case EExportLayout::Flat: return LOCTEXT("LayoutFlat", "Flat - everything in one folder");
case EExportLayout::MirrorTree: return LOCTEXT("LayoutMirror", "Mirror the content tree");
case EExportLayout::FolderPerAsset: return LOCTEXT("LayoutPerAsset", "One folder per asset");
case EExportLayout::Migrate: return LOCTEXT("LayoutMigrate", "Migrate into another Unreal project");
}
return FText::GetEmpty();
}
FText LayoutTooltip(EExportLayout Layout)
{
switch (Layout)
{
case EExportLayout::Flat:
return LOCTEXT("LayoutFlatTip", "Every file side by side in the destination. Two assets with the same name collide, which is what the naming policy below is for.");
case EExportLayout::MirrorTree:
return LOCTEXT("LayoutMirrorTip", "Recreate the /Game folder structure under the destination. Collisions become almost impossible, but you get a deep tree.");
case EExportLayout::FolderPerAsset:
return LOCTEXT("LayoutPerAssetTip", "Each ticked asset gets its own folder with the assets it references beside it. Anything shared is copied into every folder that needs it, so the file count is higher than the number of assets.");
case EExportLayout::Migrate:
return LOCTEXT("LayoutMigrateTip", "Hand the transfer to Unreal's own Migrate. Choose the destination project's Content folder. This is the only option whose result opens in Unreal with references intact - the others produce files for people, not for the engine.");
}
return FText::GetEmpty();
}
}
FText ValidateMigrateDestination(const FString& Directory)
{
// The rule itself lives in Core with the other path predicates, where it can be tested without
// an editor module. This is only the wrapper that turns it into displayable text.
const FString Reason = AssetUsagePaths::ValidateMigrateDestination(Directory);
return Reason.IsEmpty() ? FText::GetEmpty() : FText::FromString(Reason);
}
/**
* The dialog body.
*
* Holds its own copy of the request and writes it back only when Export is pressed, so cancelling
* genuinely changes nothing - including the persisted defaults.
*/
class SAssetExportDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetExportDialog) {}
SLATE_ARGUMENT(FAssetExportRequest, InitialRequest)
SLATE_ARGUMENT(int32, TickedCount)
SLATE_ARGUMENT(TSharedPtr<SWindow>, ParentWindow)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
Request = InArgs._InitialRequest;
TickedCount = InArgs._TickedCount;
ParentWindow = InArgs._ParentWindow;
for (EExportLayout Layout : { EExportLayout::Flat, EExportLayout::MirrorTree, EExportLayout::FolderPerAsset, EExportLayout::Migrate })
{
LayoutOptions.Add(MakeShared<EExportLayout>(Layout));
}
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(12.0f)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.Text(FText::Format(
LOCTEXT("TickedHeader", "{0} asset(s) ticked."),
FText::AsNumber(TickedCount)))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SSegmentedControl<EFileExportMode>)
.Value_Lambda([this]() { return Request.Mode; })
.OnValueChanged_Lambda([this](EFileExportMode NewMode)
{
Request.Mode = NewMode;
// Migrate transfers packages; there is nothing to convert. Rather than
// ignore one of the two settings at export time, drop back to Flat here so
// the window always shows what will actually happen.
if (NewMode == EFileExportMode::ConvertToExchangeFormat && Request.Layout == EExportLayout::Migrate)
{
Request.Layout = EExportLayout::Flat;
}
})
+ SSegmentedControl<EFileExportMode>::Slot(EFileExportMode::CopyPackages)
.Text(LOCTEXT("ModeCopy", "Copy .uasset"))
.ToolTip(LOCTEXT("ModeCopyTip", "Byte-exact copies of the package files. Fast; loads nothing. Levels bring their One File Per Actor packages along."))
+ SSegmentedControl<EFileExportMode>::Slot(EFileExportMode::ConvertToExchangeFormat)
.Text(LOCTEXT("ModeConvert", "Convert (FBX/PNG/WAV)"))
.ToolTip(LOCTEXT("ModeConvertTip", "Through the engine's exporters. Loads every asset, so expect minutes rather than seconds. Classes with no configured format are skipped and counted."))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SSeparator)
]
+ SVerticalBox::Slot()
.AutoHeight()
[
MakeOptionsGrid()
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 12.0f, 0.0f, 0.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor::UseSubduedForeground())
.Text_Lambda([this]() { return GetFootnoteText(); })
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(8.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Export", "Export"))
// Disabled rather than allowed-and-then-failed. Migrate reports a bad
// destination only to the Output Log, so pressing Export would look like a
// button that does nothing at all.
.IsEnabled_Lambda([this]() { return GetBlockingReason().IsEmpty(); })
.ToolTipText_Lambda([this]()
{
const FText Reason = GetBlockingReason();
return Reason.IsEmpty()
? LOCTEXT("ExportTip", "Start the export. The exact file count is confirmed once more before anything is written.")
: Reason;
})
.OnClicked(this, &SAssetExportDialog::OnAccept)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Cancel", "Cancel"))
.OnClicked(this, &SAssetExportDialog::OnCancel)
]
]
]
];
}
bool WasAccepted() const
{
return bAccepted;
}
const FAssetExportRequest& GetRequest() const
{
return Request;
}
private:
TSharedRef<SWidget> MakeOptionsGrid()
{
TSharedRef<SGridPanel> Grid = SNew(SGridPanel).FillColumn(1, 1.0f);
int32 Row = 0;
// --- Destination ---------------------------------------------------------------------
Grid->AddSlot(0, Row).Padding(0.0f, 4.0f)
[
SNew(STextBlock).Text(LOCTEXT("Destination", "Destination"))
];
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.0f)
[
SNew(SEditableTextBox)
.Text_Lambda([this]() { return FText::FromString(Request.TargetDirectory); })
.OnTextCommitted_Lambda([this](const FText& NewText, ETextCommit::Type)
{
Request.TargetDirectory = NewText.ToString();
})
.HintText(LOCTEXT("DestHint", "Choose a folder..."))
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f, 0.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Browse", "Browse..."))
.OnClicked(this, &SAssetExportDialog::OnBrowse)
]
];
++Row;
// --- Layout --------------------------------------------------------------------------
Grid->AddSlot(0, Row).Padding(0.0f, 4.0f)
[
SNew(STextBlock).Text(LOCTEXT("Layout", "Folder layout"))
];
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SComboBox<TSharedPtr<EExportLayout>>)
.OptionsSource(&LayoutOptions)
.OnGenerateWidget_Lambda([](TSharedPtr<EExportLayout> Item)
{
return SNew(STextBlock)
.Text(LayoutLabel(*Item))
.ToolTipText(LayoutTooltip(*Item));
})
.OnSelectionChanged_Lambda([this](TSharedPtr<EExportLayout> Item, ESelectInfo::Type)
{
if (!Item.IsValid())
{
return;
}
Request.Layout = *Item;
// The mirror of the rule in the mode switch above: picking Migrate while Convert is
// selected would leave two settings that contradict each other.
if (Request.Layout == EExportLayout::Migrate)
{
Request.Mode = EFileExportMode::CopyPackages;
// The destination carried over from the other layouts is this project's
// Saved/AssetUsageAudit, which can never be a valid Migrate target. Leaving it
// in place would present a filled-in path that is guaranteed to fail; clearing
// it makes the next step obvious.
if (!ValidateMigrateDestination(Request.TargetDirectory).IsEmpty())
{
Request.TargetDirectory.Reset();
}
}
})
[
SNew(STextBlock)
.Text_Lambda([this]() { return LayoutLabel(Request.Layout); })
.ToolTipText_Lambda([this]() { return LayoutTooltip(Request.Layout); })
]
];
++Row;
// --- Group by type -------------------------------------------------------------------
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SCheckBox)
// Disabled rather than hidden: a checkbox that vanishes reads as a bug, whereas a
// greyed one with this tooltip explains which layout to pick to use it.
.IsEnabled_Lambda([this]() { return Request.Layout == EExportLayout::FolderPerAsset; })
.IsChecked_Lambda([this]()
{
return Request.bGroupDependenciesByType ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Request.bGroupDependenciesByType = (NewState == ECheckBoxState::Checked);
})
.ToolTipText(LOCTEXT("GroupByTypeTip", "Inside each asset's folder, put its dependencies into subfolders by type - Texture, Material, StaticMesh. The asset itself stays at the top of its folder. Only available with the one-folder-per-asset layout."))
[
SNew(STextBlock).Text(LOCTEXT("GroupByType", "Sort dependencies into type subfolders"))
]
];
++Row;
// --- Dependencies --------------------------------------------------------------------
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]()
{
return Request.bIncludeDependencies ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Request.bIncludeDependencies = (NewState == ECheckBoxState::Checked);
})
.ToolTipText(LOCTEXT("DepsTip", "Also export the materials, textures and other assets the ticked ones reference. Without this a mesh arrives without its materials and opens grey."))
[
SNew(STextBlock).Text(LOCTEXT("Deps", "Include referenced assets"))
]
];
++Row;
// --- Manifest ------------------------------------------------------------------------
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SCheckBox)
.IsEnabled_Lambda([this]() { return Request.Layout != EExportLayout::Migrate; })
.IsChecked_Lambda([this]()
{
return Request.bWriteManifest ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Request.bWriteManifest = (NewState == ECheckBoxState::Checked);
})
.ToolTipText(LOCTEXT("ManifestTip", "Write AssetUsageAudit.manifest.json beside the files: which package each file came from, and what it referenced. These layouts lose that information, and the manifest is what an importer would need to put it back. Not applicable to Migrate, which keeps the references itself."))
[
SNew(STextBlock).Text(LOCTEXT("Manifest", "Write dependency manifest"))
]
];
++Row;
// --- Collision -----------------------------------------------------------------------
Grid->AddSlot(0, Row).Padding(0.0f, 4.0f)
[
SNew(STextBlock).Text(LOCTEXT("Collision", "If a file exists"))
];
Grid->AddSlot(1, Row).Padding(8.0f, 4.0f)
[
SNew(SSegmentedControl<EExportCollisionPolicy>)
.Value_Lambda([this]() { return Request.CollisionPolicy; })
.OnValueChanged_Lambda([this](EExportCollisionPolicy NewPolicy) { Request.CollisionPolicy = NewPolicy; })
+ SSegmentedControl<EExportCollisionPolicy>::Slot(EExportCollisionPolicy::Index)
.Text(LOCTEXT("PolicyIndex", "Keep both"))
.ToolTip(LOCTEXT("PolicyIndexTip", "Write Foo_1, Foo_2 and so on. An existing index is incremented rather than having a second one appended: Foo_7 becomes Foo_8."))
+ SSegmentedControl<EExportCollisionPolicy>::Slot(EExportCollisionPolicy::Overwrite)
.Text(LOCTEXT("PolicyOverwrite", "Overwrite"))
.ToolTip(LOCTEXT("PolicyOverwriteTip", "Replace the existing file. Cannot be undone."))
];
return Grid;
}
/** Why Export is unavailable, or empty when it is available. */
FText GetBlockingReason() const
{
if (Request.TargetDirectory.IsEmpty())
{
return LOCTEXT("NeedDir", "Choose a destination folder first.");
}
if (Request.Layout == EExportLayout::Migrate)
{
return ValidateMigrateDestination(Request.TargetDirectory);
}
return FText::GetEmpty();
}
FText GetFootnoteText() const
{
// A blocking reason outranks the explanatory footnote: the user needs to know why they
// cannot proceed before they need to know how the layout behaves.
const FText Blocking = GetBlockingReason();
if (!Blocking.IsEmpty())
{
return Blocking;
}
if (Request.Layout == EExportLayout::Migrate)
{
return LOCTEXT("FootnoteMigrate",
"Choose the destination project's Content folder. Unreal performs the transfer and reports the result itself.");
}
// The single most useful thing this window can say. References inside a .uasset are full
// package paths, so a file only resolves when it sits at the same path in the destination -
// and neither of these two layouts puts it there.
if (Request.Layout == EExportLayout::FolderPerAsset)
{
return LOCTEXT("FootnotePerAsset",
"Shared assets are copied into every folder that needs them, so more files are written than there are assets. "
"These files are for people, not for Unreal: copied into a project they open with broken references. Use Migrate for that.");
}
if (Request.Layout == EExportLayout::Flat)
{
return LOCTEXT("FootnoteFlat",
"These files are for people, not for Unreal: copied into a project they open with broken references. Use Migrate for that.");
}
if (Request.Mode == EFileExportMode::ConvertToExchangeFormat)
{
return LOCTEXT("FootnoteConvert",
"Conversion loads every asset. Blueprints and materials have no exchange format and are skipped.");
}
return FText::GetEmpty();
}
FReply OnBrowse()
{
IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get();
if (!DesktopPlatform)
{
UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Export dialog: DesktopPlatform unavailable; cannot open a folder picker."));
return FReply::Handled();
}
const void* ParentHandle = ParentWindow.IsValid() && ParentWindow->GetNativeWindow().IsValid()
? ParentWindow->GetNativeWindow()->GetOSWindowHandle()
: nullptr;
FString ChosenDir;
if (DesktopPlatform->OpenDirectoryDialog(
ParentHandle,
LOCTEXT("PickDir", "Choose a destination folder").ToString(),
Request.TargetDirectory,
ChosenDir))
{
Request.TargetDirectory = ChosenDir;
}
return FReply::Handled();
}
FReply OnAccept()
{
bAccepted = true;
CloseWindow();
return FReply::Handled();
}
FReply OnCancel()
{
bAccepted = false;
CloseWindow();
return FReply::Handled();
}
void CloseWindow()
{
if (ParentWindow.IsValid())
{
ParentWindow->RequestDestroyWindow();
}
}
FAssetExportRequest Request;
int32 TickedCount = 0;
bool bAccepted = false;
TSharedPtr<SWindow> ParentWindow;
TArray<TSharedPtr<EExportLayout>> LayoutOptions;
};
bool ShowAssetExportDialog(FAssetExportRequest& InOutRequest, int32 TickedCount)
{
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(LOCTEXT("WindowTitle", "Export ticked assets"))
.SizingRule(ESizingRule::Autosized)
.SupportsMinimize(false)
.SupportsMaximize(false);
TSharedRef<SAssetExportDialog> Dialog = SNew(SAssetExportDialog)
.InitialRequest(InOutRequest)
.TickedCount(TickedCount)
.ParentWindow(Window);
Window->SetContent(Dialog);
FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow());
if (!Dialog->WasAccepted())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Export cancelled in the options dialog."));
return false;
}
InOutRequest = Dialog->GetRequest();
return true;
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,75 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
/** What "export the ticked files" does. */
enum class EFileExportMode : uint8
{
/** Byte-exact copies of the package files. Loads nothing. */
CopyPackages,
/** Meshes to FBX, textures to PNG and so on, through the engine's exporters. */
ConvertToExchangeFormat
};
/**
* Everything the export dialog collects.
*
* Lives here rather than in the panel so the two headers do not have to include each other: the
* panel needs the mode for its persisted state, and the dialog needs the whole request.
*/
struct FAssetExportRequest
{
EFileExportMode Mode = EFileExportMode::CopyPackages;
/** Absolute destination. Pre-filled from settings, editable in the dialog. */
FString TargetDirectory;
EExportLayout Layout = EExportLayout::Flat;
/** Only meaningful with FolderPerAsset; the dialog disables it otherwise. */
bool bGroupDependenciesByType = false;
bool bIncludeDependencies = true;
/**
* Write AssetUsageAudit.manifest.json beside the exported files.
*
* On by default: the Flat and FolderPerAsset layouts destroy the reference graph by design, and
* the manifest is the only record of what pointed at what. Meaningless under Migrate, where the
* engine keeps the paths intact.
*/
bool bWriteManifest = true;
EExportCollisionPolicy CollisionPolicy = EExportCollisionPolicy::Index;
};
/**
* Show the modal export dialog.
*
* Modal on purpose. The alternative - a dockable options panel - lets the ticked set change while
* the options are open, so the figures on screen would describe a selection that no longer exists.
*
* @param InOutRequest Seeded with the current defaults; overwritten with the user's choices.
* @param TickedCount Shown in the dialog so the user can see what they are about to act on.
* @return true if the user pressed Export, false on Cancel or on closing the window.
*/
bool ShowAssetExportDialog(FAssetExportRequest& InOutRequest, int32 TickedCount);
/**
* Why a folder cannot be used as a Migrate destination, or empty text when it can.
*
* Mirrors the two checks UAssetToolsImpl performs, because it performs them *after* being called
* and reports the failure only to the Output Log - so an export that cannot possibly work would
* otherwise look like a button that does nothing.
*
* The rules, from the engine:
* 1. the path must end in /Content/ (MigratePackages_ReportConfirmed);
* 2. the folder one level up must hold a .uproject, or exactly one .uplugin
* (FPackageMigrationImpl::GetMountPointRootPath) - that is how the destination's mount point
* is derived, and without it Migrate aborts.
*/
FText ValidateMigrateDestination(const FString& Directory);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,377 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditor.h"
#include "SAssetExportDialog.h"
#include "Widgets/SCompoundWidget.h"
#include "Widgets/Views/SHeaderRow.h"
#include "Widgets/Views/SListView.h"
#include "Widgets/Views/STreeView.h"
/** One row as the list view sees it. Shared so the list can hold it without copying. */
using FAssetUsageRowPtr = TSharedPtr<FAssetUsageRow>;
/**
* A node in the results tree: either a level heading or an asset under one.
*
* The tree exists because "by level" needs to answer two questions at once - what is on this
* location, and how do two locations compare - and a flat list filtered to one level can only
* answer the first. In by-asset mode every node is an asset with no children, so the same widget
* serves both directions and there is one code path to keep correct rather than two.
*/
struct FAuditTreeItem
{
/** Set on a level heading. Mutually exclusive with Row. */
FName LevelPackage;
/** Set on an asset node. */
FAssetUsageRowPtr Row;
/** Assets under a level heading. Always empty for an asset node. */
TArray<TSharedPtr<FAuditTreeItem>> Children;
bool IsLevel() const
{
return !LevelPackage.IsNone();
}
};
using FAuditTreeItemPtr = TSharedPtr<FAuditTreeItem>;
/**
* Filter on how an asset is held.
*
* The interesting value is SoftOnly. An asset reached exclusively through soft references is
* loaded on demand and nothing forces it to be present - that is where "it worked in the editor
* and vanished in the build" comes from. Hard vs soft is a property of the edge, never of the
* query (see AssetUsageAudit::MakeTraversalQuery), so this filters the result, not the sweep.
*/
enum class EReferenceStrengthFilter : uint8
{
/** No constraint. */
Any,
/** At least one hard reference and no soft ones. */
HardOnly,
/** Held only by soft references. */
SoftOnly,
/** Both kinds present. */
Mixed,
/** Neither - reached only as a level seed, from config, or not reached at all. */
None
};
/**
* What "export ticked files" writes.
*
* Both modes were agreed, and they answer different questions. A copy is byte-exact and cheap, and
* is what you want when the files are going into another Unreal project. A conversion is what you
* want when they are going to someone who does not run Unreal at all - and it costs a full asset
* load per file, so it is never the silent default.
*/
// EFileExportMode now lives in SAssetExportDialog.h: the dialog owns the mode switch, and keeping
// the definition here would make the two headers include each other.
/**
* Result of widening a ticked set to its dependency closure.
*
* Carries the before and after counts because the ratio is the whole point of showing a dialog:
* "40 ticked, 380 will be written" is the number that stops someone exporting half the project.
*/
struct FDependencyExpansion
{
/** What the user ticked. */
int32 SeedCount = 0;
/** What will actually be written, seeds included. Equals SeedCount when the option is off. */
int32 TotalCount = 0;
/** Human-readable breakdown for the Output Log. */
FString Detail;
int32 AddedCount() const
{
return FMath::Max(0, TotalCount - SeedCount);
}
};
/** Which question the user is asking. Both directions live in one window. */
enum class EAuditViewMode : uint8
{
/** Rows are assets; the Levels column says where each is used. */
ByAsset,
/** Rows are grouped under the level that uses them. */
ByLevel
};
/**
* The audit window.
*
* Holds the last result, a filtered view of it, and the user's tick marks. Runs nothing itself -
* every calculation goes through FAssetUsageAuditor in the Core module, so the panel and the
* console commands cannot diverge.
*/
class SAssetUsageAuditPanel : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetUsageAuditPanel) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
/** Persists the filter state. The tab is destroyed on close, which is when this fires. */
virtual ~SAssetUsageAuditPanel() override;
private:
// --- Running -----------------------------------------------------------------------------
FReply OnRunClicked();
bool CanRun() const;
void RunAudit();
// --- List --------------------------------------------------------------------------------
TSharedRef<ITableRow> OnGenerateRow(FAuditTreeItemPtr Item, const TSharedRef<STableViewBase>& OwnerTable);
void OnGetChildren(FAuditTreeItemPtr Item, TArray<FAuditTreeItemPtr>& OutChildren);
/**
* Double-click reveals the asset in the Content Browser, the same as Browse To in the editor.
*
* Deliberately the FAssetData overload of SyncBrowserToObjects rather than the UObject one:
* revealing a row must not load it. On this project a row can be a 200 MB mesh or a whole map,
* and loading it to point at it would stall the editor for seconds with no visible reason.
*
* A level heading syncs to the .umap itself, which is what someone double-clicking a location
* is asking for.
*/
void OnItemDoubleClicked(FAuditTreeItemPtr Item);
void RebuildFilteredRows();
/**
* Rebuild the tree from FilteredRows.
*
* By-asset mode produces one childless node per row; by-level mode groups rows under the levels
* that use them. A row on several levels appears under each - deliberately, because "which
* locations use this" is the other half of the question the tool answers.
*/
void RebuildTree();
/** Ticking a level heading ticks everything under it. */
ECheckBoxState GetLevelCheckState(FAuditTreeItemPtr Item) const;
void OnLevelCheckChanged(ECheckBoxState NewState, FAuditTreeItemPtr Item);
/** Root nodes: assets in by-asset mode, levels in by-level mode. */
TArray<FAuditTreeItemPtr> RootItems;
// --- Filtering ---------------------------------------------------------------------------
bool PassesFilters(const FAssetUsageRow& Row) const;
void OnSearchTextChanged(const FText& NewText);
TSharedRef<SWidget> MakeVerdictFilterMenu();
TSharedRef<SWidget> MakeTypeFilterMenu();
/**
* Short class names a type preset stands for, expanded through subclasses.
*
* Expansion is the point: "Material" without it misses every MaterialInstanceConstant, which on
* this project is most of what exists. The menu deals in short names because that is what
* PassesFilters compares against, so the expanded class paths are reduced to their asset names.
*/
TSet<FName> ResolvePresetTypeNames(enum class EAssetTypePreset Preset) const;
// --- Persisted panel state ---------------------------------------------------------------
/**
* Restore the previous session's filters.
*
* Every value is validated rather than trusted: the ini is a text file a user can edit, and an
* out-of-range enum read straight into a switch is a crash on startup of the editor.
*/
void LoadUserSettings();
/**
* Persist the current filters.
*
* Called from the destructor, which fires when the tab is closed or the editor shuts down
* normally. Saving on every filter change instead would mean an ini write per keystroke in the
* search box, and SaveConfig is a synchronous disk write.
*
* Known consequence: an editor crash loses the session's filter changes. Accepted rather than
* worked around - the state is cheap to recreate, and the alternative costs disk traffic on
* every interaction.
*/
void SaveUserSettings() const;
TSharedRef<SWidget> MakeLevelFilterMenu();
TSharedRef<SWidget> MakeReferenceFilterMenu();
/**
* Fill the level and type dropdowns from the Asset Registry.
*
* Deliberately NOT derived from the last result. Building them from result rows left both
* menus empty until Run Audit had been pressed, which reads as a broken tool: you open the
* panel, click "All levels", and nothing is there.
*
* It also matters for correctness of the workflow, not just for looks. The task is "assets of
* a chosen type for a chosen location", so the level has to be pickable BEFORE the sweep -
* a menu populated from results can only ever filter a sweep that already happened.
*
* Cached: enumerating the project costs a fraction of a second and the content does not change
* under the user mid-session. Refreshed on demand and after each audit.
*/
void RefreshAvailableFilters();
/**
* The chosen level scopes the sweep, so changing it invalidates the current result rather
* than filtering it. Marks the result stale and tells the user to press Run Audit, instead
* of showing figures that answer a different question than the one on screen.
*/
void OnScopeChanged();
/** True when the level scope changed after the last sweep. */
bool bResultStale = false;
/** Level packages offered by the level dropdown, sorted by name. */
TArray<FName> AvailableLevels;
/** Short type names offered by the type dropdown, including Blueprint generated classes. */
TArray<FName> AvailableTypes;
/** False until RefreshAvailableFilters has completed a full pass. */
bool bAvailableFiltersReady = false;
// --- Sorting -----------------------------------------------------------------------------
EColumnSortMode::Type GetSortModeForColumn(FName ColumnId) const;
void OnSortChanged(EColumnSortPriority::Type Priority, const FName& ColumnId, EColumnSortMode::Type NewMode);
void ApplySorting();
// --- Ticking assets for export -----------------------------------------------------------
/**
* Checked packages, keyed by package name rather than by row pointer.
*
* Deliberately NOT the list view's selection. Selection is rebuilt whenever the filter changes
* or the audit re-runs, so using it would silently discard ticks the user had already made -
* they would filter, tick forty assets, change the type filter and find their work gone.
* Keying by FName means ticks survive both.
*/
TSet<FName> CheckedPackages;
ECheckBoxState GetRowCheckState(FAssetUsageRowPtr Item) const;
void OnRowCheckChanged(ECheckBoxState NewState, FAssetUsageRowPtr Item);
ECheckBoxState GetHeaderCheckState() const;
void OnHeaderCheckChanged(ECheckBoxState NewState);
FText GetSelectionSummaryText() const;
// --- Export ------------------------------------------------------------------------------
FReply OnExportReportClicked();
FReply OnExportFilesClicked();
bool HasCheckedAssets() const;
/** Byte copy of the package files, expanding levels to their OFPA packages. */
void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
/** Load and convert through UExporter. Separate function because the failure modes differ. */
void RunConvertExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
/**
* Hand the packages to IAssetTools::MigratePackages.
*
* The only export whose result opens in another project with references intact: Migrate writes
* each package to the same package path under the destination Content folder, which is what a
* .uasset's stored references need in order to resolve.
*
* Lives here rather than in Core because AssetTools is editor-only, and reports nothing back -
* MigratePackages returns void and shows the engine's own report - so this cannot pretend to
* count files the way the copy path does.
*/
void RunMigrateExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
EFileExportMode ExportMode = EFileExportMode::CopyPackages;
/** Rows currently ticked, resolved against the full result rather than the filtered view. */
void GetCheckedRows(TArray<FAssetUsageRow>& OutRows) const;
/**
* Pull in everything the ticked assets reference, so an exported mesh arrives with its
* materials and textures instead of opening pink.
*
* On by default. Ticking eleven meshes and receiving eleven unusable files is the more
* surprising of the two behaviours, and the confirmation dialog states the real figure before
* anything is written.
*/
bool bIncludeDependencies = true;
/** Seeds plus their dependency closure, or the seeds unchanged when the option is off. */
TArray<FName> ExpandWithDependencies(const TArray<FName>& Seeds, struct FDependencyExpansion& OutExpansion) const;
// --- State -------------------------------------------------------------------------------
FAssetUsageAuditResult LastResult;
TArray<FAssetUsageRowPtr> AllRows;
TArray<FAssetUsageRowPtr> FilteredRows;
TSharedPtr<STreeView<FAuditTreeItemPtr>> TreeView;
TSharedPtr<SHeaderRow> HeaderRow;
EAuditViewMode ViewMode = EAuditViewMode::ByAsset;
FString SearchText;
TSet<EAssetUsageVerdict> VisibleVerdicts;
/**
* Asset classes to show, e.g. StaticMesh, Texture2D, Blueprint.
*
* Only the asset's own class goes here. Blueprint generated classes are deliberately kept out:
* folding them in produced a 2980-entry dropdown on this project, where someone looking for
* "StaticMesh" had to scroll past thousands of BP_Something_C entries. The gameplay class is
* a different question and gets its own text filter below.
*/
TSet<FName> VisibleTypeNames;
/**
* Substring match against a Blueprint's generated gameplay class.
*
* A separate axis from the asset class: every Blueprint asset is /Script/Engine.Blueprint, so
* "which Blueprints derive from something Pickup-shaped" cannot be answered by asset class.
*/
FString GeneratedClassFilter;
/** How the asset must be held for its row to show. */
EReferenceStrengthFilter ReferenceStrength = EReferenceStrengthFilter::Any;
/**
* Provenance flags a row must carry, ANDed together.
*
* None means no constraint. Combining flags answers questions the columns alone cannot, e.g.
* "editor-only AND reached through an external actor" - content placed in a level that will
* not survive a cook.
*/
EAssetUsageProvenance RequiredProvenance = EAssetUsageProvenance::None;
/**
* Levels the audit is scoped to. Empty means every level in the project.
*
* A set rather than a single name, and the same shape as VisibleTypeNames, because the two
* menus do the same job and a checklist beside a radio list is a UI that has to be explained.
* FAssetUsageAuditRequest::LevelPackages was already an array - the panel was the only thing
* limiting this to one.
*/
TSet<FName> LevelFilters;
FName SortColumn;
EColumnSortMode::Type SortMode = EColumnSortMode::None;
bool bHasRun = false;
FText StatusText;
};
@@ -0,0 +1,43 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
class IConsoleObject;
class SDockTab;
class FSpawnTabArgs;
ASSETUSAGEAUDITEDITOR_API DECLARE_LOG_CATEGORY_EXTERN(LogAssetUsageAuditEditor, Log, All);
/**
* Editor-side host: settings, console entry points and (from phase 3) the Slate panel.
*
* All analysis lives in AssetUsageAuditCore. This module only drives it and presents results,
* so a commandlet can reach exactly the same code path without dragging UI in.
*/
class FAssetUsageAuditEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
/** Nomad tab id, also used as the invoke target from the Tools menu. */
static const FName PanelTabId;
private:
void RegisterConsoleCommands();
void UnregisterConsoleCommands();
void RegisterTabSpawner();
void UnregisterTabSpawner();
void RegisterMenus();
TSharedRef<SDockTab> SpawnPanelTab(const FSpawnTabArgs& Args);
/** Shared implementation behind every console entry point. */
void ExecuteAudit(const TArray<FString>& Args, bool bOnlyUnused);
TArray<IConsoleObject*> ConsoleCommands;
};
@@ -0,0 +1,61 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "AssetUsageAuditLibrary.generated.h"
/**
* Blueprint and Python entry points for the audit.
*
* Phase 1 uses these as the only way to run the analysis, before the Slate panel exists. They
* remain useful afterwards: a TA can script a one-off audit from an Editor Utility Widget without
* touching the main tool, and the same calls drive the Python bridge.
*
* These are thin wrappers. All logic lives in AssetUsageAuditCore so that the panel, these
* functions and a future commandlet cannot drift apart.
*/
UCLASS()
class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/**
* Run a full audit and write JSON + CSV reports.
*
* @param LevelPackageNames Levels to analyse, e.g. "/Game/Space/Maps/WP_Main". Empty means all.
* @param OutputDirectory Destination. Empty uses the configured default.
* @param OutReportPath Absolute path of the JSON report on success.
* @return true when both reports were written.
*/
UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit", meta = (AutoCreateRefTerm = "LevelPackageNames"))
static bool RunAudit(const TArray<FString>& LevelPackageNames, const FString& OutputDirectory, FString& OutReportPath);
/**
* Assets reachable from one level, resolved transitively through Blueprints and other assets.
*
* This is the question the tool exists to answer, exposed on its own for scripting.
*/
UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit")
static TArray<FString> GetAssetsUsedOnLevel(const FString& LevelPackageName);
/**
* Levels that use the given asset.
*
* Includes both a sublevel and its parent map when the asset sits in a sublevel, because the
* parent's traversal reaches through ULevelStreaming::WorldAsset.
*/
UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit")
static TArray<FString> GetLevelsUsingAsset(const FString& AssetPackageName);
/**
* Time a full sweep and log the breakdown.
*
* Exists to answer the open question in the plan: whether an in-memory graph cache is needed
* at all. Measure before adding one.
*/
UFUNCTION(BlueprintCallable, Category = "Asset Usage Audit")
static float MeasureFullSweepSeconds();
};
@@ -0,0 +1,90 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "AssetUsageAuditTypes.h"
#include "Engine/DeveloperSettings.h"
#include "AssetUsageAuditSettings.generated.h"
/**
* Team-shared configuration, written to Config/DefaultEditor.ini.
*
* That file is Perforce-tracked (only Plugins/** is p4-ignored), so the exclusion list and the
* type presets are reviewable and shared. Per-user state - last filter, column layout - belongs
* in UAssetUsageAuditUserSettings instead, which stays in Saved/Config.
*/
UCLASS(config = Editor, defaultconfig, meta = (DisplayName = "Asset Usage Audit"))
class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UAssetUsageAuditSettings();
/** Default folder for exported files and reports. Empty means <Project>/Saved/AssetUsageAudit. */
UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Default destination for exported assets and reports. Leave empty to use Saved/AssetUsageAudit."))
FString DefaultExportDirectory;
/** Overwrite existing files on export, or add/increment a numeric suffix. */
UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "When a file of the same name already exists: replace it, or write Foo_1, Foo_2 and so on."))
bool bOverwriteExistingFiles = false;
/**
* Asset class short name -> interchange file extension, used by the "Convert" export mode.
*
* Empty falls back to the built-in table. Lookup follows the class hierarchy, so one entry for
* MaterialInterface would cover every material instance. Only add a class the engine ships a
* UExporter for: an entry with no exporter behind it produces no file and reads as a bug.
*/
UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Which file format each asset class converts to, e.g. StaticMesh -> fbx. Leave empty for the built-in defaults."))
TMap<FString, FString> ExchangeFormatByClass;
/**
* How exported files are arranged under the destination.
*
* Replaced an earlier bMirrorFolderStructure boolean. Adding "one folder per asset" as a second
* boolean would have made "mirror the tree AND a folder per asset" expressible, and it means
* nothing - the enum makes the three arrangements mutually exclusive, which they are.
*/
UPROPERTY(config, EditAnywhere, Category = "Export", meta = (ToolTip = "Flat: every file side by side. Mirror: recreate the /Game tree. Folder per asset: each ticked asset gets its own folder with its dependencies beside it."))
EExportLayout ExportLayout = EExportLayout::Flat;
/**
* Within each asset's folder, sort its dependencies into per-type subfolders.
*
* Only has an effect with the folder-per-asset layout - the other two have no per-asset folder
* to sort inside. Folder names come from the same type presets the Types filter shows, so the
* folders match the words used to filter.
*/
UPROPERTY(config, EditAnywhere, Category = "Export", meta = (EditCondition = "ExportLayout == EExportLayout::FolderPerAsset", ToolTip = "Put textures, materials and meshes into their own subfolders inside each asset's folder."))
bool bGroupDependenciesByType = false;
/**
* Package or content-relative prefixes excluded from the sweep.
*
* Defaults cover this project's bought content packs. Roughly 65% of the project's assets are
* third-party, so without this the report is dominated by content nobody audits.
*/
UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Folders to skip entirely. Accepts /Game/... or Content/... form."))
TArray<FString> ExcludedPackagePaths;
/** Roots to sweep. Empty means /Game. */
UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Folders to analyse. Leave empty to sweep all of /Game."))
TArray<FString> IncludedPackagePaths;
/** Read Config/ and Source/ for path literals the Asset Registry cannot see. */
UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Scan Config/ and Source/ for /Game paths. Without this the project's GameMode and GameInstance report as unused, because only DefaultEngine.ini names them."))
bool bScanIndirectReferences = true;
/** Hide __ExternalActors__ / __ExternalObjects__ rows; they are plumbing, not artist-facing. */
UPROPERTY(config, EditAnywhere, Category = "Filtering", meta = (ToolTip = "Hide One File Per Actor packages from the results. They are still traversed."))
bool bHideExternalPackages = true;
static const UAssetUsageAuditSettings* Get();
/** Resolved absolute export directory, applying the Saved/AssetUsageAudit fallback. */
FString GetResolvedExportDirectory() const;
virtual FName GetCategoryName() const override;
};
@@ -0,0 +1,75 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "AssetUsageAuditUserSettings.generated.h"
/**
* Per-user panel state, written to Saved/Config/.../EditorPerProjectUserSettings.ini.
*
* Separate from UAssetUsageAuditSettings on purpose. That one is DefaultConfig and lands in
* Config/DefaultEditor.ini, which is version-controlled: exclusion lists and export formats are
* team decisions and should be reviewable. Which types someone happened to tick last Tuesday is
* not, and committing it would make every teammate's panel jump around on sync.
*
* Not shown in Project Settings - GetCategoryName is inherited but the class carries no
* EditAnywhere properties, so there is nothing to render. This is state, not configuration.
*/
UCLASS(config = EditorPerProjectUserSettings)
class ASSETUSAGEAUDITEDITOR_API UAssetUsageAuditUserSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
static UAssetUsageAuditUserSettings* Get();
/** Audit scope: level packages to sweep. Empty means all levels. */
UPROPERTY(config)
TArray<FString> LastLevelPackages;
/** Short class names ticked in the type menu. */
UPROPERTY(config)
TArray<FString> VisibleTypeNames;
/** Substring filter on a Blueprint's generated class. */
UPROPERTY(config)
FString GeneratedClassFilter;
/**
* Verdicts left visible, by name rather than by index.
*
* Names because the enum will gain values: an index saved today would silently mean a different
* verdict after the next one is inserted, and the user would find their filter quietly changed.
*/
UPROPERTY(config)
TArray<FString> VisibleVerdictNames;
/** EAuditViewMode as an integer. Validated on load. */
UPROPERTY(config)
int32 ViewMode = 0;
/** EFileExportMode as an integer. Validated on load. */
UPROPERTY(config)
int32 ExportMode = 0;
/** EReferenceStrengthFilter as an integer. Validated on load. */
UPROPERTY(config)
int32 ReferenceStrength = 0;
/** EAssetUsageProvenance bitmask that a row must carry. */
UPROPERTY(config)
int32 RequiredProvenance = 0;
/** Whether the export pulls in referenced assets. */
UPROPERTY(config)
bool bIncludeDependencies = true;
/**
* Deliberately absent: the search box.
*
* Restoring it would reopen the panel showing nothing, with the reason sitting in a text field
* the user is not looking at. A filter that hides everything must be something they just typed.
*/
};