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,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;
};