// NextGenium 2026. Asset Usage Audit. #include "SAssetUsageAuditPanel.h" #include "AssetDependencyClosure.h" #include "AssetTypeFilter.h" #include "AssetExportLayout.h" #include "AssetExportManifest.h" #include "AssetUsageAuditEditorModule.h" #include "AssetUsageAuditSettings.h" #include "AssetUsageAuditUserSettings.h" #include "AssetUsageExporter.h" #include "AssetUsagePaths.h" #include "AssetUsageReportWriter.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" #include "Algo/AllOf.h" #include "AssetToolsModule.h" #include "DesktopPlatformModule.h" #include "IAssetTools.h" #include "Framework/Application/SlateApplication.h" #include "Framework/MultiBox/MultiBoxBuilder.h" #include "IDesktopPlatform.h" #include "Misc/MessageDialog.h" #include "Misc/PackageName.h" #include "Misc/Paths.h" #include "Misc/ScopedSlowTask.h" #include "Styling/AppStyle.h" #include "Widgets/Input/SButton.h" #include "Widgets/Input/SCheckBox.h" #include "Widgets/Input/SComboButton.h" #include "Widgets/Input/SSearchBox.h" #include "Widgets/Input/SSegmentedControl.h" #include "Widgets/Layout/SBorder.h" #include "Widgets/Layout/SScrollBox.h" #include "Widgets/Layout/SSeparator.h" #include "Widgets/Layout/SWidgetSwitcher.h" #include "Widgets/Notifications/SProgressBar.h" #include "Widgets/Text/STextBlock.h" #include "Widgets/Views/SExpanderArrow.h" #define LOCTEXT_NAMESPACE "SAssetUsageAuditPanel" namespace AuditColumns { static const FName Check("Check"); static const FName Type("Type"); static const FName Name("Name"); static const FName Path("Path"); static const FName Verdict("Verdict"); static const FName Levels("Levels"); static const FName Hard("Hard"); static const FName Soft("Soft"); static const FName Provenance("Provenance"); static const FName Route("Route"); } namespace { /** Colour-code the verdict so a 45k-row list is scannable rather than a wall of text. */ FSlateColor VerdictColour(EAssetUsageVerdict Verdict) { switch (Verdict) { case EAssetUsageVerdict::UsedOnLevel: return FSlateColor(FLinearColor(0.35f, 0.78f, 0.35f)); case EAssetUsageVerdict::UsedByAssetsOnly: return FSlateColor(FLinearColor(0.55f, 0.72f, 0.90f)); case EAssetUsageVerdict::ReferencedFromConfigOrSource: return FSlateColor(FLinearColor(0.85f, 0.75f, 0.35f)); case EAssetUsageVerdict::Unreferenced: return FSlateColor(FLinearColor(0.90f, 0.45f, 0.40f)); case EAssetUsageVerdict::Unknown: return FSlateColor(FLinearColor(0.65f, 0.65f, 0.65f)); } return FSlateColor::UseForeground(); } /** Short class label for the Type column: "/Script/Engine.StaticMesh" -> "StaticMesh". */ FString ShortTypeName(const FTopLevelAssetPath& ClassPath) { if (ClassPath.IsNull()) { return FString(); } return ClassPath.GetAssetName().ToString(); } } /** One row widget. Kept in this file: it has no use outside the panel. */ class SAssetUsageRowWidget : public SMultiColumnTableRow { public: SLATE_BEGIN_ARGS(SAssetUsageRowWidget) {} SLATE_ARGUMENT(FAssetUsageRowPtr, Item) SLATE_EVENT(FOnCheckStateChanged, OnCheckChanged) SLATE_ATTRIBUTE(ECheckBoxState, CheckState) SLATE_END_ARGS() void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) { Item = InArgs._Item; OnCheckChanged = InArgs._OnCheckChanged; CheckState = InArgs._CheckState; SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); } virtual TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override { if (!Item.IsValid()) { return SNullWidget::NullWidget; } const auto TextCell = [](const FString& Text, const FSlateColor& Colour = FSlateColor::UseForeground()) { return SNew(SBox) .VAlign(VAlign_Center) .Padding(FMargin(4.0f, 0.0f)) [ SNew(STextBlock) .Text(FText::FromString(Text)) .ColorAndOpacity(Colour) .ToolTipText(FText::FromString(Text)) ]; }; if (ColumnName == AuditColumns::Check) { return SNew(SBox) .HAlign(HAlign_Center) .VAlign(VAlign_Center) [ SNew(SCheckBox) .IsChecked(CheckState) .OnCheckStateChanged(OnCheckChanged) ]; } if (ColumnName == AuditColumns::Type) { // Asset class, matching the type dropdown and PassesFilters exactly. Showing the // gameplay class here instead would mean a row labelled "BP_Pickup_Child_C" is caught // by the "Blueprint" filter - visibly inconsistent, and the sort of thing that makes // someone distrust the whole table. // // The gameplay class is the more interesting fact for an artist, so it goes in the // tooltip rather than being dropped. The report keeps it as its own column. const FString Label = ShortTypeName(Item->ClassPath); FString Tooltip = Item->ClassPath.ToString(); if (!Item->GeneratedClassPath.IsNull()) { Tooltip += FString::Printf(TEXT("\nGameplay class: %s"), *Item->GeneratedClassPath.ToString()); } return SNew(SBox) .VAlign(VAlign_Center) .Padding(FMargin(4.0f, 0.0f)) [ SNew(STextBlock) .Text(FText::FromString(Label)) .ToolTipText(FText::FromString(Tooltip)) ]; } if (ColumnName == AuditColumns::Name) { return TextCell(Item->AssetName.ToString()); } if (ColumnName == AuditColumns::Path) { return TextCell(Item->PathFromProjectRoot); } if (ColumnName == AuditColumns::Verdict) { return TextCell(LexToString(Item->Verdict), VerdictColour(Item->Verdict)); } if (ColumnName == AuditColumns::Levels) { // The count is what fits; the full list goes in the tooltip. TArray LevelNames; LevelNames.Reserve(Item->Levels.Num()); for (const FName& Level : Item->Levels) { LevelNames.Add(Level.ToString()); } const FText Tooltip = LevelNames.IsEmpty() ? LOCTEXT("NoLevels", "Not reached by any level.") : FText::FromString(FString::Join(LevelNames, TEXT("\n"))); return SNew(SBox) .VAlign(VAlign_Center) .HAlign(HAlign_Right) .Padding(FMargin(4.0f, 0.0f)) [ SNew(STextBlock) .Text(FText::AsNumber(Item->Levels.Num())) .ToolTipText(Tooltip) ]; } if (ColumnName == AuditColumns::Hard) { return TextCell(FString::FromInt(Item->HardReferenceCount)); } if (ColumnName == AuditColumns::Soft) { return TextCell(FString::FromInt(Item->SoftReferenceCount)); } if (ColumnName == AuditColumns::Provenance) { return TextCell(ProvenanceToString(Item->Provenance)); } if (ColumnName == AuditColumns::Route) { const FString RouteText = Item->Route.IsEmpty() ? Item->ProvenanceDetail : Item->Route; return TextCell(RouteText); } return SNullWidget::NullWidget; } private: FAssetUsageRowPtr Item; FOnCheckStateChanged OnCheckChanged; TAttribute CheckState; }; void SAssetUsageAuditPanel::Construct(const FArguments& InArgs) { // Everything visible until the user narrows it down. VisibleVerdicts = TSet(GetAllAssetUsageVerdicts()); // Restore after the defaults are in place, so a config that resolves to nothing leaves the // panel usable rather than empty. Must also happen before the widget tree is built: the // segmented controls and checkboxes read these fields for their initial state. LoadUserSettings(); StatusText = LOCTEXT("NotRunYet", "Press Run Audit to scan the project."); HeaderRow = SNew(SHeaderRow) + SHeaderRow::Column(AuditColumns::Check) .DefaultLabel(FText::GetEmpty()) .FixedWidth(28.0f) .HAlignHeader(HAlign_Center) [ SNew(SCheckBox) .IsChecked(this, &SAssetUsageAuditPanel::GetHeaderCheckState) .OnCheckStateChanged(this, &SAssetUsageAuditPanel::OnHeaderCheckChanged) .ToolTipText(LOCTEXT("CheckAllTip", "Tick or clear every row currently shown by the filters.")) ] + SHeaderRow::Column(AuditColumns::Type) .DefaultLabel(LOCTEXT("ColType", "Type")) .FillWidth(0.12f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Type) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Name) .DefaultLabel(LOCTEXT("ColName", "Name")) .FillWidth(0.16f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Name) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Path) .DefaultLabel(LOCTEXT("ColPath", "Path from project root")) .FillWidth(0.26f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Path) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Verdict) .DefaultLabel(LOCTEXT("ColVerdict", "Verdict")) .FillWidth(0.13f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Verdict) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Levels) .DefaultLabel(LOCTEXT("ColLevels", "Levels")) .FixedWidth(60.0f) .HAlignHeader(HAlign_Right) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Levels) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Hard) .DefaultLabel(LOCTEXT("ColHard", "Hard")) .FixedWidth(50.0f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Hard) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Soft) .DefaultLabel(LOCTEXT("ColSoft", "Soft")) .FixedWidth(50.0f) .SortMode(this, &SAssetUsageAuditPanel::GetSortModeForColumn, AuditColumns::Soft) .OnSort(this, &SAssetUsageAuditPanel::OnSortChanged) + SHeaderRow::Column(AuditColumns::Provenance) .DefaultLabel(LOCTEXT("ColProvenance", "Provenance")) .FillWidth(0.13f) + SHeaderRow::Column(AuditColumns::Route) .DefaultLabel(LOCTEXT("ColRoute", "Route")) .FillWidth(0.20f); ChildSlot [ SNew(SVerticalBox) // --- Toolbar ------------------------------------------------------------------------- + SVerticalBox::Slot() .AutoHeight() .Padding(4.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SButton) .Text(LOCTEXT("RunAudit", "Run Audit")) .ToolTipText(LOCTEXT("RunAuditTip", "Sweep the Asset Registry and work out which assets each level uses.")) .OnClicked(this, &SAssetUsageAuditPanel::OnRunClicked) .IsEnabled(this, &SAssetUsageAuditPanel::CanRun) ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(8.0f, 0.0f, 2.0f, 0.0f) [ SNew(SSegmentedControl) .Value_Lambda([this]() { return ViewMode; }) .OnValueChanged_Lambda([this](EAuditViewMode NewMode) { ViewMode = NewMode; RebuildFilteredRows(); }) + SSegmentedControl::Slot(EAuditViewMode::ByAsset) .Text(LOCTEXT("ByAsset", "By asset")) .ToolTip(LOCTEXT("ByAssetTip", "List assets; the Levels column says where each one is used.")) + SSegmentedControl::Slot(EAuditViewMode::ByLevel) .Text(LOCTEXT("ByLevel", "By level")) .ToolTip(LOCTEXT("ByLevelTip", "Group the results under the levels that use them. Leave the level scope on \"All levels\" to compare locations side by side.")) ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(8.0f, 0.0f, 2.0f, 0.0f) [ SNew(SComboButton) .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeLevelFilterMenu) .ButtonContent() [ SNew(STextBlock) .Text_Lambda([this]() { if (LevelFilters.IsEmpty()) { return LOCTEXT("AllLevels", "All levels"); } // Name the level when there is one, count them when there are several: // a button reading "WP_Main" is worth more than one reading "1 level". if (LevelFilters.Num() == 1) { return FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator())); } return FText::Format(LOCTEXT("NLevels", "{0} levels"), FText::AsNumber(LevelFilters.Num())); }) ] ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SComboButton) .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeTypeFilterMenu) .ButtonContent() [ SNew(STextBlock) .Text_Lambda([this]() { return VisibleTypeNames.IsEmpty() ? LOCTEXT("AllTypes", "All types") : FText::Format(LOCTEXT("NTypes", "{0} types"), FText::AsNumber(VisibleTypeNames.Num())); }) ] ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SComboButton) .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeVerdictFilterMenu) .ButtonContent() [ SNew(STextBlock) .Text_Lambda([this]() { return FText::Format(LOCTEXT("NVerdicts", "Verdicts ({0}/5)"), FText::AsNumber(VisibleVerdicts.Num())); }) ] ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SComboButton) .OnGetMenuContent(this, &SAssetUsageAuditPanel::MakeReferenceFilterMenu) .ButtonContent() [ SNew(STextBlock) // Label states the active constraint so a filtered view is never mistaken for // the whole result - the mistake that makes someone delete a "missing" asset. .Text_Lambda([this]() { const bool bHasProvenance = RequiredProvenance != EAssetUsageProvenance::None; if (ReferenceStrength == EReferenceStrengthFilter::Any && !bHasProvenance) { return LOCTEXT("RefsAny", "References"); } FText StrengthLabel; switch (ReferenceStrength) { case EReferenceStrengthFilter::HardOnly: StrengthLabel = LOCTEXT("RefsHard", "Hard only"); break; case EReferenceStrengthFilter::SoftOnly: StrengthLabel = LOCTEXT("RefsSoft", "Soft only"); break; case EReferenceStrengthFilter::Mixed: StrengthLabel = LOCTEXT("RefsMixed", "Mixed"); break; case EReferenceStrengthFilter::None: StrengthLabel = LOCTEXT("RefsNone", "No edges"); break; default: StrengthLabel = LOCTEXT("RefsAnyShort", "Any"); break; } if (!bHasProvenance) { return FText::Format(LOCTEXT("RefsStrengthOnly", "Refs: {0}"), StrengthLabel); } return FText::Format( LOCTEXT("RefsStrengthAndProv", "Refs: {0} +{1}"), StrengthLabel, FText::FromString(ProvenanceToString(RequiredProvenance))); }) ] ] // Gameplay class is a text box, not a dropdown: there are thousands of them on this // project, which is exactly why they were taken out of the type menu. + SHorizontalBox::Slot() .FillWidth(0.5f) .VAlign(VAlign_Center) .Padding(8.0f, 0.0f, 0.0f, 0.0f) [ SNew(SSearchBox) .HintText(LOCTEXT("ClassHint", "Gameplay class, e.g. Pickup")) .ToolTipText(LOCTEXT("ClassTip", "Matches the Blueprint's generated class. Rows that are not Blueprints are hidden while this is set.")) .OnTextChanged_Lambda([this](const FText& NewText) { GeneratedClassFilter = NewText.ToString(); RebuildFilteredRows(); }) ] + SHorizontalBox::Slot() .FillWidth(1.0f) .VAlign(VAlign_Center) .Padding(8.0f, 0.0f) [ SNew(SSearchBox) .HintText(LOCTEXT("SearchHint", "Filter by name or path")) .OnTextChanged(this, &SAssetUsageAuditPanel::OnSearchTextChanged) ] ] + SVerticalBox::Slot() .AutoHeight() [ SNew(SSeparator) ] // --- List ---------------------------------------------------------------------------- + SVerticalBox::Slot() .FillHeight(1.0f) [ SAssignNew(TreeView, STreeView) .TreeItemsSource(&RootItems) .OnGenerateRow(this, &SAssetUsageAuditPanel::OnGenerateRow) .OnGetChildren(this, &SAssetUsageAuditPanel::OnGetChildren) .OnMouseButtonDoubleClick(this, &SAssetUsageAuditPanel::OnItemDoubleClicked) .SelectionMode(ESelectionMode::Multi) .HeaderRow(HeaderRow) ] // --- Status -------------------------------------------------------------------------- + SVerticalBox::Slot() .AutoHeight() .Padding(4.0f) [ SNew(STextBlock) .Text_Lambda([this]() { return StatusText; }) ] // --- Export bar ---------------------------------------------------------------------- // // Below the results, not above them. The top strip is for narrowing down what you are // looking at; exporting acts on what you have already found, and reads left to right as // "N ticked -> export". Keeping it at the top put an action button between two filters. + SVerticalBox::Slot() .AutoHeight() [ SNew(SSeparator) ] + SVerticalBox::Slot() .AutoHeight() .Padding(4.0f, 4.0f, 4.0f, 4.0f) [ SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(STextBlock) .Text(this, &SAssetUsageAuditPanel::GetSelectionSummaryText) ] + SHorizontalBox::Slot() .FillWidth(1.0f) [ SNullWidget::NullWidget ] + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SButton) .Text(LOCTEXT("ExportReport", "Export report (JSON + CSV)")) .ToolTipText(LOCTEXT("ExportReportTip", "Write the current, filtered result as a JSON and a CSV file.")) .OnClicked(this, &SAssetUsageAuditPanel::OnExportReportClicked) .IsEnabled_Lambda([this]() { return bHasRun; }) ] // Mode, layout, destination and the dependency switch all moved into the export window. // They were four controls competing with the filters for the same strip of toolbar, and // they are only ever touched at the moment of exporting - which is exactly when a window // can show what the choices will produce, instead of leaving the user to guess. + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) .Padding(2.0f, 0.0f) [ SNew(SButton) .Text(LOCTEXT("ExportFilesEllipsis", "Export ticked assets...")) .ToolTipText(LOCTEXT("ExportFilesTip", "Open the export window: choose copy or convert, the folder layout and the destination.")) .OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked) .IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets) ] ] ]; } // --- Running --------------------------------------------------------------------------------- bool SAssetUsageAuditPanel::CanRun() const { return true; } FReply SAssetUsageAuditPanel::OnRunClicked() { RunAudit(); return FReply::Handled(); } void SAssetUsageAuditPanel::RunAudit() { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); if (AssetRegistry.IsLoadingAssets()) { StatusText = LOCTEXT("WaitingForRegistry", "Asset Registry is still scanning; waiting for it to finish..."); AssetRegistry.WaitForCompletion(); } const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); FAssetUsageAuditRequest Request; if (Settings) { Request.IncludePackagePaths = Settings->IncludedPackagePaths; Request.ExcludePackagePaths = Settings->ExcludedPackagePaths; Request.bScanIndirectReferences = Settings->bScanIndirectReferences; Request.bHideExternalPackages = Settings->bHideExternalPackages; } // Scope the sweep to the chosen levels. Previously this was never set, so the panel always // swept all 1072 levels and the level dropdown could only filter the result afterwards - // which is not the question the task asks ("assets of type X for location Y"). // // An empty set means every level, which is what the resolver already does with an empty array. for (FName Level : LevelFilters) { Request.LevelPackages.Add(Level); } // Sorted so two runs over the same selection produce the same report ordering, and so the // header line in the log is comparable between runs. Request.LevelPackages.Sort(FNameLexicalLess()); FScopedSlowTask SlowTask(100.0f, LOCTEXT("Auditing", "Auditing asset usage...")); SlowTask.MakeDialog(true); Request.ResolveOptions.OnLevelProgress = [&SlowTask](int32 Current, int32 Total) { if (Total > 0 && Current % 16 == 0) { SlowTask.EnterProgressFrame(16.0f * 100.0f / static_cast(Total)); } }; Request.ResolveOptions.ShouldAbort = [&SlowTask]() { return SlowTask.ShouldCancel(); }; UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Panel: starting audit. Include=[%s] Exclude=[%s] IndirectScan=%s HideExternal=%s"), Request.IncludePackagePaths.IsEmpty() ? TEXT("/Game") : *FString::Join(Request.IncludePackagePaths, TEXT(", ")), Request.ExcludePackagePaths.IsEmpty() ? TEXT("none") : *FString::Join(Request.ExcludePackagePaths, TEXT(", ")), Request.bScanIndirectReferences ? TEXT("on") : TEXT("off"), Request.bHideExternalPackages ? TEXT("on") : TEXT("off")); LastResult = FAssetUsageAuditor::Run(AssetRegistry, Request); // Logged as well as shown on screen: when someone reports "the numbers look wrong", the status // line is already gone and the settings that produced them are not recoverable from a // screenshot. The pair above and below is what makes a report reproducible after the fact. UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Panel: audit finished in %.2fs. %d rows - OnLevel %d, AssetsOnly %d, Config/Source %d, Unreferenced %d, Unknown %d."), LastResult.Header.ScanDurationSeconds, LastResult.Rows.Num(), LastResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel), LastResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly), LastResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource), LastResult.CountByVerdict(EAssetUsageVerdict::Unreferenced), LastResult.CountByVerdict(EAssetUsageVerdict::Unknown)); AllRows.Reset(LastResult.Rows.Num()); for (const FAssetUsageRow& Row : LastResult.Rows) { AllRows.Add(MakeShared(Row)); } bHasRun = true; // Ticks are keyed by package name, so they survive a re-run. Drop only the ones whose asset // no longer exists, rather than silently clearing the user's whole selection. if (!CheckedPackages.IsEmpty()) { TSet StillPresent; for (const FAssetUsageRowPtr& Row : AllRows) { if (CheckedPackages.Contains(Row->PackageName)) { StillPresent.Add(Row->PackageName); } } CheckedPackages = MoveTemp(StillPresent); } RebuildFilteredRows(); StatusText = FText::Format( LOCTEXT("AuditDone", "{0} rows in {1} s. On level: {2} | Assets only: {3} | Config/Source: {4} | Unreferenced: {5} | Unknown: {6}"), FText::AsNumber(LastResult.Rows.Num()), FText::AsNumber(FMath::RoundToFloat(LastResult.Header.ScanDurationSeconds * 100.0f) / 100.0f), FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::UsedOnLevel)), FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::UsedByAssetsOnly)), FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::ReferencedFromConfigOrSource)), FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::Unreferenced)), FText::AsNumber(LastResult.CountByVerdict(EAssetUsageVerdict::Unknown))); } /** * A level heading in by-level mode. * * Deliberately not the asset row widget with blank cells: a heading answers a different question * ("this location, this many assets") and filling the asset columns with empty space made the two * kinds of line hard to tell apart at a glance. */ class SAuditLevelRowWidget : public SMultiColumnTableRow { public: SLATE_BEGIN_ARGS(SAuditLevelRowWidget) {} SLATE_ARGUMENT(FAuditTreeItemPtr, Item) SLATE_EVENT(FOnCheckStateChanged, OnCheckChanged) SLATE_ATTRIBUTE(ECheckBoxState, CheckState) SLATE_END_ARGS() void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) { Item = InArgs._Item; OnCheckChanged = InArgs._OnCheckChanged; CheckState = InArgs._CheckState; SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); } virtual TSharedRef GenerateWidgetForColumn(const FName& ColumnName) override { if (!Item.IsValid()) { return SNullWidget::NullWidget; } if (ColumnName == AuditColumns::Check) { return SNew(SBox) .VAlign(VAlign_Center) .HAlign(HAlign_Center) [ SNew(SCheckBox) .IsChecked(CheckState) .OnCheckStateChanged(OnCheckChanged) .ToolTipText(LOCTEXT("LevelCheckTip", "Tick every asset shown under this level.")) ]; } if (ColumnName == AuditColumns::Name) { const FString LevelPath = Item->LevelPackage.ToString(); return SNew(SHorizontalBox) + SHorizontalBox::Slot() .AutoWidth() .VAlign(VAlign_Center) [ SNew(SExpanderArrow, SharedThis(this)) ] + SHorizontalBox::Slot() .FillWidth(1.0f) .VAlign(VAlign_Center) [ SNew(STextBlock) .Text(FText::FromString(FPackageName::GetShortName(LevelPath))) .ToolTipText(FText::FromString(LevelPath)) .Font(FAppStyle::GetFontStyle("BoldFont")) ]; } if (ColumnName == AuditColumns::Path) { return SNew(SBox) .VAlign(VAlign_Center) .Padding(FMargin(4.0f, 0.0f)) [ SNew(STextBlock) .Text(FText::Format( LOCTEXT("LevelAssetCount", "{0} asset(s)"), FText::AsNumber(Item->Children.Num()))) .ColorAndOpacity(FSlateColor::UseSubduedForeground()) ]; } return SNullWidget::NullWidget; } private: FAuditTreeItemPtr Item; FOnCheckStateChanged OnCheckChanged; TAttribute CheckState; }; // --- Tree ------------------------------------------------------------------------------------ TSharedRef SAssetUsageAuditPanel::OnGenerateRow(FAuditTreeItemPtr Item, const TSharedRef& OwnerTable) { if (Item.IsValid() && Item->IsLevel()) { return SNew(SAuditLevelRowWidget, OwnerTable) .Item(Item) .CheckState(TAttribute::CreateLambda([this, Item]() { return GetLevelCheckState(Item); })) .OnCheckChanged(FOnCheckStateChanged::CreateLambda([this, Item](ECheckBoxState NewState) { OnLevelCheckChanged(NewState, Item); })); } const FAssetUsageRowPtr Row = Item.IsValid() ? Item->Row : nullptr; return SNew(SAssetUsageRowWidget, OwnerTable) .Item(Row) .CheckState(TAttribute::CreateLambda([this, Row]() { return GetRowCheckState(Row); })) .OnCheckChanged(FOnCheckStateChanged::CreateLambda([this, Row](ECheckBoxState NewState) { OnRowCheckChanged(NewState, Row); })); } void SAssetUsageAuditPanel::OnGetChildren(FAuditTreeItemPtr Item, TArray& OutChildren) { if (Item.IsValid()) { OutChildren = Item->Children; } } void SAssetUsageAuditPanel::OnItemDoubleClicked(FAuditTreeItemPtr Item) { if (!Item.IsValid()) { return; } const FName PackageName = Item->IsLevel() ? Item->LevelPackage : (Item->Row.IsValid() ? Item->Row->PackageName : NAME_None); if (PackageName.IsNone()) { return; } if (!GEditor) { UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Cannot reveal '%s': no editor engine."), *PackageName.ToString()); return; } IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); TArray Assets; Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true); // A package can hold several assets. Sync to the one the row is actually about; falling back to // the whole package would reveal a redirector or a secondary object and look like the wrong row // was selected. if (!Item->IsLevel() && Item->Row.IsValid()) { const FName WantedAsset = Item->Row->AssetName; Assets.RemoveAll([WantedAsset](const FAssetData& Candidate) { return !WantedAsset.IsNone() && Candidate.AssetName != WantedAsset; }); } if (Assets.IsEmpty()) { // Ordinary for a package the registry knows only as a dependency target, and for anything // that has no file on disk. Say so rather than silently doing nothing on a double-click. StatusText = FText::Format( LOCTEXT("RevealMissing", "'{0}' has no asset in the Content Browser - it may have been deleted or never saved."), FText::FromName(PackageName)); UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Browse To: no on-disk asset for package '%s'."), *PackageName.ToString()); return; } GEditor->SyncBrowserToObjects(Assets); } ECheckBoxState SAssetUsageAuditPanel::GetLevelCheckState(FAuditTreeItemPtr Item) const { if (!Item.IsValid() || Item->Children.IsEmpty()) { return ECheckBoxState::Unchecked; } int32 Ticked = 0; for (const FAuditTreeItemPtr& Child : Item->Children) { if (Child.IsValid() && Child->Row.IsValid() && CheckedPackages.Contains(Child->Row->PackageName)) { ++Ticked; } } if (Ticked == 0) { return ECheckBoxState::Unchecked; } // Undetermined rather than Checked for a partial selection: showing a full tick over a level // where half the assets are ticked would misreport what the export is about to do. return Ticked == Item->Children.Num() ? ECheckBoxState::Checked : ECheckBoxState::Undetermined; } void SAssetUsageAuditPanel::OnLevelCheckChanged(ECheckBoxState NewState, FAuditTreeItemPtr Item) { if (!Item.IsValid()) { return; } const bool bTick = (NewState == ECheckBoxState::Checked); for (const FAuditTreeItemPtr& Child : Item->Children) { if (!Child.IsValid() || !Child->Row.IsValid()) { continue; } if (bTick) { CheckedPackages.Add(Child->Row->PackageName); } else { CheckedPackages.Remove(Child->Row->PackageName); } } } void SAssetUsageAuditPanel::RebuildTree() { RootItems.Reset(); if (ViewMode == EAuditViewMode::ByAsset) { RootItems.Reserve(FilteredRows.Num()); for (const FAssetUsageRowPtr& Row : FilteredRows) { FAuditTreeItemPtr Node = MakeShared(); Node->Row = Row; RootItems.Add(MoveTemp(Node)); } return; } // By level. Group in one pass, preserving the sorted order of FilteredRows inside each level so // the column sort still means something after grouping. TMap LevelNodes; for (const FAssetUsageRowPtr& Row : FilteredRows) { if (!Row.IsValid()) { continue; } for (FName LevelPackage : Row->Levels) { // A chosen scope narrows the tree to those levels; without a scope every level that uses // the asset gets a heading, which is what makes comparing locations possible. if (!LevelFilters.IsEmpty() && !LevelFilters.Contains(LevelPackage)) { continue; } FAuditTreeItemPtr& LevelNode = LevelNodes.FindOrAdd(LevelPackage); if (!LevelNode.IsValid()) { LevelNode = MakeShared(); LevelNode->LevelPackage = LevelPackage; } FAuditTreeItemPtr Child = MakeShared(); Child->Row = Row; LevelNode->Children.Add(MoveTemp(Child)); } } LevelNodes.GenerateValueArray(RootItems); // Biggest first: on a full sweep this is 1072 headings, and the levels worth looking at are the // ones with content. Alphabetical would bury them. RootItems.Sort([](const FAuditTreeItemPtr& A, const FAuditTreeItemPtr& B) { if (A->Children.Num() != B->Children.Num()) { return A->Children.Num() > B->Children.Num(); } return A->LevelPackage.LexicalLess(B->LevelPackage); }); // Expand automatically only when there is exactly one level to look at. Expanding 1072 headings // would render tens of thousands of rows and defeat the point of grouping them. if (RootItems.Num() == 1 && TreeView.IsValid()) { TreeView->SetItemExpansion(RootItems[0], true); } } void SAssetUsageAuditPanel::RebuildFilteredRows() { FilteredRows.Reset(); for (const FAssetUsageRowPtr& Row : AllRows) { if (Row.IsValid() && PassesFilters(*Row)) { FilteredRows.Add(Row); } } ApplySorting(); RebuildTree(); if (TreeView.IsValid()) { TreeView->RequestTreeRefresh(); } } // --- Filtering ------------------------------------------------------------------------------- bool SAssetUsageAuditPanel::PassesFilters(const FAssetUsageRow& Row) const { if (!VisibleVerdicts.Contains(Row.Verdict)) { return false; } // Asset class only, matching what the dropdown offers. A Blueprint reads as "Blueprint" here; // asking "which of these is a StaticMeshActor" is the generated-class filter below, because // mixing the two axes is what made the type menu unusable. if (!VisibleTypeNames.IsEmpty()) { if (!VisibleTypeNames.Contains(FName(*ShortTypeName(Row.ClassPath)))) { return false; } } // Substring rather than an exact list: gameplay classes number in the thousands, so this is a // text box, and "Pickup" usefully matches BP_Pickup_Child_C and BP_Pickup_Master alike. if (!GeneratedClassFilter.IsEmpty()) { if (Row.GeneratedClassPath.IsNull()) { return false; } if (!ShortTypeName(Row.GeneratedClassPath).Contains(GeneratedClassFilter, ESearchCase::IgnoreCase)) { return false; } } if (ReferenceStrength != EReferenceStrengthFilter::Any) { const bool bHasHard = Row.HardReferenceCount > 0; const bool bHasSoft = Row.SoftReferenceCount > 0; bool bStrengthMatches = false; switch (ReferenceStrength) { case EReferenceStrengthFilter::HardOnly: bStrengthMatches = bHasHard && !bHasSoft; break; case EReferenceStrengthFilter::SoftOnly: bStrengthMatches = bHasSoft && !bHasHard; break; case EReferenceStrengthFilter::Mixed: bStrengthMatches = bHasHard && bHasSoft; break; case EReferenceStrengthFilter::None: bStrengthMatches = !bHasHard && !bHasSoft; break; default: bStrengthMatches = true; break; } if (!bStrengthMatches) { return false; } } // ANDed, not ORed: ticking two flags asks for rows carrying both, which is what makes // combinations like "editor-only AND through an external actor" expressible. if (RequiredProvenance != EAssetUsageProvenance::None) { if (!EnumHasAllFlags(Row.Provenance, RequiredProvenance)) { return false; } } // Any of the chosen levels is enough. Requiring all of them would answer "what do these // locations share", which is a different and much rarer question than "what is on these". if (!LevelFilters.IsEmpty()) { bool bOnAnyChosenLevel = false; for (FName Level : Row.Levels) { if (LevelFilters.Contains(Level)) { bOnAnyChosenLevel = true; break; } } if (!bOnAnyChosenLevel) { return false; } } // By-level groups rows under level headings, so a row belonging to no level has nowhere to sit. // Before the tree existed this mode showed nothing at all until a level was picked; grouping // makes the unpicked case the useful one - every level at once, which is what comparing two // locations requires. if (ViewMode == EAuditViewMode::ByLevel && Row.Levels.IsEmpty()) { return false; } if (!SearchText.IsEmpty()) { const bool bMatches = Row.AssetName.ToString().Contains(SearchText, ESearchCase::IgnoreCase) || Row.PathFromProjectRoot.Contains(SearchText, ESearchCase::IgnoreCase); if (!bMatches) { return false; } } return true; } void SAssetUsageAuditPanel::OnSearchTextChanged(const FText& NewText) { SearchText = NewText.ToString(); RebuildFilteredRows(); } TSharedRef SAssetUsageAuditPanel::MakeVerdictFilterMenu() { FMenuBuilder MenuBuilder(false, nullptr); const TArray AllVerdicts = GetAllAssetUsageVerdicts(); for (EAssetUsageVerdict Verdict : AllVerdicts) { MenuBuilder.AddMenuEntry( FText::FromString(LexToString(Verdict)), Verdict == EAssetUsageVerdict::Unknown ? LOCTEXT("UnknownTip", "The registry cannot see this asset's references (FMOD, DataTable rows, runtime-built paths). Unknown does NOT mean unused.") : FText::GetEmpty(), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, Verdict]() { if (VisibleVerdicts.Contains(Verdict)) { VisibleVerdicts.Remove(Verdict); } else { VisibleVerdicts.Add(Verdict); } RebuildFilteredRows(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, Verdict]() { return VisibleVerdicts.Contains(Verdict); })), NAME_None, EUserInterfaceActionType::ToggleButton); } return MenuBuilder.MakeWidget(); } void SAssetUsageAuditPanel::RefreshAvailableFilters() { AvailableLevels.Reset(); AvailableTypes.Reset(); bAvailableFiltersReady = false; FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); IAssetRegistry& AssetRegistry = AssetRegistryModule.Get(); if (AssetRegistry.IsLoadingAssets()) { // Never block the UI thread on a full project scan just to populate a dropdown. The menu // shows an explanatory entry instead, and the next open retries. UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Filter lists not built yet: the Asset Registry is still scanning.")); return; } const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); const TArray ExcludedPaths = Settings ? Settings->ExcludedPackagePaths : TArray(); FARFilter Filter; Filter.bRecursivePaths = true; Filter.bIncludeOnlyOnDiskAssets = true; if (Settings && !Settings->IncludedPackagePaths.IsEmpty()) { for (const FString& Path : Settings->IncludedPackagePaths) { Filter.PackagePaths.Add(FName(*Path)); } } else { Filter.PackagePaths.Add(FName(TEXT("/Game"))); } FARCompiledFilter CompiledFilter; AssetRegistry.CompileFilter(Filter, CompiledFilter); const FTopLevelAssetPath WorldClassPath = UWorld::StaticClass()->GetClassPathName(); static const FName GeneratedClassTag(TEXT("GeneratedClass")); TSet TypeSet; const double StartTime = FPlatformTime::Seconds(); AssetRegistry.EnumerateAssets(CompiledFilter, [&](const FAssetData& AssetData) { const FName PackageName = AssetData.PackageName; if (AssetUsagePaths::IsPathExcluded(PackageName, ExcludedPaths)) { return true; } // OFPA packages are machine-named hashes. Offering 16117 of them in a dropdown would bury // the levels a person is actually looking for. if (AssetUsagePaths::IsExternalPackage(PackageName)) { return true; } if (AssetData.AssetClassPath == WorldClassPath) { AvailableLevels.Add(PackageName); } // Asset class only - deliberately NOT the Blueprint's generated class. // // Folding generated classes in here produced 2980 entries on this project, because every // Blueprint contributes its own. A dropdown that long is no more usable than an empty one: // someone looking for "StaticMesh" has to scroll past BP_Pickup_Child_C and its thousand // siblings. Asset class alone is a bounded list of real asset types. // // Filtering by gameplay class is a different question and gets its own text box; the type // presets in FAssetTypeFilter still expand through GetDerivedClassNames + the GeneratedClass // tag, so nothing is lost from the analysis - only from this menu. const FString AssetTypeName = ShortTypeName(AssetData.AssetClassPath); if (!AssetTypeName.IsEmpty()) { TypeSet.Add(FName(*AssetTypeName)); } return true; }, UE::AssetRegistry::EEnumerateAssetsFlags::OnlyOnDiskAssets); AvailableLevels.Sort([](const FName& A, const FName& B) { return A.LexicalLess(B); }); AvailableTypes = TypeSet.Array(); AvailableTypes.Sort([](const FName& A, const FName& B) { return A.LexicalLess(B); }); bAvailableFiltersReady = true; UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Filter lists built in %.2fs: %d levels, %d asset types."), FPlatformTime::Seconds() - StartTime, AvailableLevels.Num(), AvailableTypes.Num()); } SAssetUsageAuditPanel::~SAssetUsageAuditPanel() { SaveUserSettings(); } void SAssetUsageAuditPanel::LoadUserSettings() { const UAssetUsageAuditUserSettings* Saved = UAssetUsageAuditUserSettings::Get(); if (!Saved) { return; } for (const FString& LevelPackage : Saved->LastLevelPackages) { if (!LevelPackage.IsEmpty()) { LevelFilters.Add(FName(*LevelPackage)); } } GeneratedClassFilter = Saved->GeneratedClassFilter; bIncludeDependencies = Saved->bIncludeDependencies; for (const FString& TypeName : Saved->VisibleTypeNames) { VisibleTypeNames.Add(FName(*TypeName)); } // Verdicts are stored by name, so an enum that gains a value does not silently reinterpret an // old index. An unrecognised name is dropped rather than guessed at. if (Saved->VisibleVerdictNames.Num() > 0) { TSet Restored; for (const FString& VerdictName : Saved->VisibleVerdictNames) { for (EAssetUsageVerdict Verdict : GetAllAssetUsageVerdicts()) { if (VerdictName.Equals(LexToString(Verdict), ESearchCase::IgnoreCase)) { Restored.Add(Verdict); break; } } } // An empty restored set would hide every row with no way to tell why, so the default // "everything visible" wins over a config file that resolved to nothing. if (Restored.Num() > 0) { VisibleVerdicts = MoveTemp(Restored); } } // Integers are validated rather than cast: an out-of-range value in a hand-edited ini would // otherwise become an invalid enum and take a branch nobody wrote. if (Saved->ViewMode >= 0 && Saved->ViewMode <= static_cast(EAuditViewMode::ByLevel)) { ViewMode = static_cast(Saved->ViewMode); } if (Saved->ExportMode >= 0 && Saved->ExportMode <= static_cast(EFileExportMode::ConvertToExchangeFormat)) { ExportMode = static_cast(Saved->ExportMode); } if (Saved->ReferenceStrength >= 0 && Saved->ReferenceStrength <= static_cast(EReferenceStrengthFilter::None)) { ReferenceStrength = static_cast(Saved->ReferenceStrength); } RequiredProvenance = static_cast( static_cast(Saved->RequiredProvenance & 0xFF)); } void SAssetUsageAuditPanel::SaveUserSettings() const { UAssetUsageAuditUserSettings* Saved = UAssetUsageAuditUserSettings::Get(); if (!Saved) { return; } Saved->LastLevelPackages.Reset(); for (FName Level : LevelFilters) { Saved->LastLevelPackages.Add(Level.ToString()); } Saved->GeneratedClassFilter = GeneratedClassFilter; Saved->bIncludeDependencies = bIncludeDependencies; Saved->VisibleTypeNames.Reset(); for (FName TypeName : VisibleTypeNames) { Saved->VisibleTypeNames.Add(TypeName.ToString()); } Saved->VisibleVerdictNames.Reset(); for (EAssetUsageVerdict Verdict : VisibleVerdicts) { Saved->VisibleVerdictNames.Add(LexToString(Verdict)); } Saved->ViewMode = static_cast(ViewMode); Saved->ExportMode = static_cast(ExportMode); Saved->ReferenceStrength = static_cast(ReferenceStrength); Saved->RequiredProvenance = static_cast(RequiredProvenance); Saved->SaveConfig(); } TSet SAssetUsageAuditPanel::ResolvePresetTypeNames(EAssetTypePreset Preset) const { TSet Out; IAssetRegistry* Registry = IAssetRegistry::Get(); if (!Registry) { return Out; } const TArray Seeds = GetPresetClassPaths(Preset); if (Seeds.IsEmpty()) { return Out; } TSet Derived; Registry->GetDerivedClassNames(Seeds, TSet(), Derived); // GetDerivedClassNames returns strict descendants in some builds and includes the seeds in // others. Adding the seeds explicitly makes the result the same either way - the same reason // FAssetTypeFilter::Compile does it. for (const FTopLevelAssetPath& ClassPath : Seeds) { Out.Add(ClassPath.GetAssetName()); } for (const FTopLevelAssetPath& ClassPath : Derived) { Out.Add(ClassPath.GetAssetName()); } return Out; } TSharedRef SAssetUsageAuditPanel::MakeTypeFilterMenu() { if (!bAvailableFiltersReady) { RefreshAvailableFilters(); } // Counts come from the last result when there is one, so the menu reads as // "StaticMesh (1204)" after a run and simply "StaticMesh" before it. // Keyed on asset class, the same axis the menu lists and PassesFilters matches. Keying these // on the generated class instead would silently show "Blueprint (0)" next to thousands of // Blueprint rows, because the counts and the entries would be on different axes. TMap TypeCounts; for (const FAssetUsageRowPtr& Row : AllRows) { const FName TypeName(*ShortTypeName(Row->ClassPath)); if (!TypeName.IsNone()) { ++TypeCounts.FindOrAdd(TypeName); } } // Offer every type in the project, not only the ones the last sweep happened to return. TArray> Sorted; Sorted.Reserve(AvailableTypes.Num()); for (const FName& TypeName : AvailableTypes) { Sorted.Emplace(TypeName, TypeCounts.FindRef(TypeName)); } // Types present in the result first, by count; then the rest alphabetically. Sorted.Sort([](const TPair& A, const TPair& B) { if (A.Value != B.Value) { return A.Value > B.Value; } return A.Key.LexicalLess(B.Key); }); FMenuBuilder MenuBuilder(false, nullptr); if (Sorted.IsEmpty()) { MenuBuilder.AddWidget( SNew(STextBlock) .Text(bAvailableFiltersReady ? LOCTEXT("NoTypesFound", "No asset types found in the scanned paths.") : LOCTEXT("TypesNotReady", "Asset Registry is still scanning; reopen this menu shortly.")) .ColorAndOpacity(FSlateColor::UseSubduedForeground()), FText::GetEmpty()); return MenuBuilder.MakeWidget(); } MenuBuilder.AddMenuEntry( LOCTEXT("ClearTypes", "Show all types"), FText::GetEmpty(), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([this]() { VisibleTypeNames.Reset(); RebuildFilteredRows(); }))); // Presets first. The agreed requirement was "presets plus raw classes", and an artist looking // for VFX should not have to know that it means NiagaraSystem and not ParticleSystem. The raw // list stays below for anything a preset does not cover. MenuBuilder.BeginSection(TEXT("Presets"), LOCTEXT("PresetSection", "Presets")); { const TSet Available(AvailableTypes); for (EAssetTypePreset Preset : GetAllAssetTypePresets()) { // Resolve to the short names this menu deals in, expanded through subclasses: without // expansion "Material" would miss every MaterialInstanceConstant, which is most of what // a project actually contains. TSet PresetTypes = ResolvePresetTypeNames(Preset); // Only offer what the project has. A preset that selects nothing looks broken, and this // project has no skeletal meshes at all - offering the entry would invite exactly that // conclusion. TSet Present; for (FName TypeName : PresetTypes) { if (Available.Contains(TypeName)) { Present.Add(TypeName); } } if (Present.IsEmpty()) { continue; } const FText Label = FText::Format( LOCTEXT("PresetEntry", "{0} ({1} class(es))"), FText::FromString(LexToString(Preset)), FText::AsNumber(Present.Num())); MenuBuilder.AddMenuEntry( Label, FText::Format( LOCTEXT("PresetEntryTip", "Select the {0} classes present in this project. Adds to the current selection."), FText::FromString(LexToString(Preset))), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, Present]() { // Toggle as a group: clicking a fully selected preset clears it, which is // the only way to undo it without hunting its classes in the raw list. const bool bAllSelected = Algo::AllOf(Present, [this](FName TypeName) { return VisibleTypeNames.Contains(TypeName); }); for (FName TypeName : Present) { if (bAllSelected) { VisibleTypeNames.Remove(TypeName); } else { VisibleTypeNames.Add(TypeName); } } RebuildFilteredRows(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, Present]() { return Algo::AllOf(Present, [this](FName TypeName) { return VisibleTypeNames.Contains(TypeName); }); })), NAME_None, EUserInterfaceActionType::Check); } } MenuBuilder.EndSection(); MenuBuilder.BeginSection(TEXT("RawClasses"), LOCTEXT("RawClassSection", "All classes")); for (const TPair& Entry : Sorted) { const FName TypeName = Entry.Key; MenuBuilder.AddMenuEntry( FText::Format(LOCTEXT("TypeEntry", "{0} ({1})"), FText::FromName(TypeName), FText::AsNumber(Entry.Value)), FText::GetEmpty(), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, TypeName]() { if (VisibleTypeNames.Contains(TypeName)) { VisibleTypeNames.Remove(TypeName); } else { VisibleTypeNames.Add(TypeName); } RebuildFilteredRows(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, TypeName]() { return VisibleTypeNames.Contains(TypeName); })), NAME_None, EUserInterfaceActionType::ToggleButton); } MenuBuilder.EndSection(); return SNew(SBox) .MaxDesiredHeight(600.0f) [ SNew(SScrollBox) + SScrollBox::Slot() [ MenuBuilder.MakeWidget() ] ]; } void SAssetUsageAuditPanel::OnScopeChanged() { // Deliberately does NOT re-run. Scope is chosen from a dropdown, and "All levels" means 1072 // levels on this project - kicking that off because someone browsed the list would be a nasty // surprise. Mark the result stale, say so, and let them press Run. if (!bHasRun) { return; } bResultStale = true; // Name the scope rather than just saying it changed: the whole point of the message is that the // rows on screen answer a different question than the one now selected. FText ScopeText; if (LevelFilters.IsEmpty()) { ScopeText = LOCTEXT("ScopeAll", "all levels"); } else if (LevelFilters.Num() == 1) { ScopeText = FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator())); } else { ScopeText = FText::Format(LOCTEXT("ScopeNLevels", "{0} levels"), FText::AsNumber(LevelFilters.Num())); } StatusText = FText::Format( LOCTEXT("ScopeChanged", "Scope changed to {0}. Press Run Audit to refresh - the rows below are from the previous scope."), ScopeText); FString ScopeDescription; if (LevelFilters.IsEmpty()) { ScopeDescription = TEXT("all levels"); } else { TArray Names; Names.Reserve(LevelFilters.Num()); for (FName Level : LevelFilters) { Names.Add(Level.ToString()); } Names.Sort(); ScopeDescription = FString::Join(Names, TEXT(", ")); } UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Panel: audit scope changed to '%s'; previous result marked stale."), *ScopeDescription); } TSharedRef SAssetUsageAuditPanel::MakeReferenceFilterMenu() { FMenuBuilder MenuBuilder(false, nullptr); const auto AddStrength = [this, &MenuBuilder](EReferenceStrengthFilter Value, const FText& Label, const FText& Tooltip) { MenuBuilder.AddMenuEntry( Label, Tooltip, FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, Value]() { ReferenceStrength = Value; RebuildFilteredRows(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, Value]() { return ReferenceStrength == Value; })), NAME_None, EUserInterfaceActionType::RadioButton); }; MenuBuilder.BeginSection("Strength", LOCTEXT("StrengthSection", "How the asset is held")); AddStrength(EReferenceStrengthFilter::Any, LOCTEXT("StrengthAny", "Any"), LOCTEXT("StrengthAnyTip", "No constraint on reference strength.")); AddStrength(EReferenceStrengthFilter::HardOnly, LOCTEXT("StrengthHard", "Hard only"), LOCTEXT("StrengthHardTip", "Held by hard references and no soft ones. Always loaded with its referencer.")); AddStrength(EReferenceStrengthFilter::SoftOnly, LOCTEXT("StrengthSoft", "Soft only"), LOCTEXT("StrengthSoftTip", "Held ONLY by soft references - loaded on demand, nothing forces it to be present. This is where missing-in-build problems come from, and where every external actor edge lands.")); AddStrength(EReferenceStrengthFilter::Mixed, LOCTEXT("StrengthMixed", "Mixed"), LOCTEXT("StrengthMixedTip", "Both hard and soft references point at it.")); AddStrength(EReferenceStrengthFilter::None, LOCTEXT("StrengthNone", "No incoming edges"), LOCTEXT("StrengthNoneTip", "Neither hard nor soft. A level itself, or an asset reached only from config or source.")); MenuBuilder.EndSection(); MenuBuilder.BeginSection("Provenance", LOCTEXT("ProvenanceSection", "Must be reached via (all ticked)")); const auto AddProvenance = [this, &MenuBuilder](EAssetUsageProvenance Flag, const FText& Label, const FText& Tooltip) { MenuBuilder.AddMenuEntry( Label, Tooltip, FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, Flag]() { if (EnumHasAnyFlags(RequiredProvenance, Flag)) { EnumRemoveFlags(RequiredProvenance, Flag); } else { EnumAddFlags(RequiredProvenance, Flag); } RebuildFilteredRows(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, Flag]() { return EnumHasAnyFlags(RequiredProvenance, Flag); })), NAME_None, EUserInterfaceActionType::ToggleButton); }; AddProvenance(EAssetUsageProvenance::ExternalActor, LOCTEXT("ProvExternal", "External actor (OFPA)"), LOCTEXT("ProvExternalTip", "Reached through a One File Per Actor package - the asset is placed in a level rather than referenced by another asset.")); AddProvenance(EAssetUsageProvenance::Sublevel, LOCTEXT("ProvSublevel", "Sublevel or Level Instance"), LOCTEXT("ProvSublevelTip", "Reached by crossing into a streaming sublevel or a Level Instance.")); AddProvenance(EAssetUsageProvenance::EditorOnly, LOCTEXT("ProvEditorOnly", "Editor-only edge"), LOCTEXT("ProvEditorOnlyTip", "At least one incoming edge lacks the Game property, so that path will not survive a cook.")); AddProvenance(EAssetUsageProvenance::Redirector, LOCTEXT("ProvRedirector", "Through a redirector"), LOCTEXT("ProvRedirectorTip", "The path to this asset passes through an ObjectRedirector left by a rename or move.")); AddProvenance(EAssetUsageProvenance::ConfigFile, LOCTEXT("ProvConfig", "Named in Config"), LOCTEXT("ProvConfigTip", "A /Game path literal in Config/ names this asset. No asset references it.")); AddProvenance(EAssetUsageProvenance::SourceFile, LOCTEXT("ProvSource", "Named in Source"), LOCTEXT("ProvSourceTip", "A /Game path literal in Source/ names this asset.")); MenuBuilder.EndSection(); MenuBuilder.AddSeparator(); MenuBuilder.AddMenuEntry( LOCTEXT("ClearRefFilters", "Clear reference filters"), FText::GetEmpty(), FSlateIcon(), FUIAction(FExecuteAction::CreateLambda([this]() { ReferenceStrength = EReferenceStrengthFilter::Any; RequiredProvenance = EAssetUsageProvenance::None; RebuildFilteredRows(); }))); return MenuBuilder.MakeWidget(); } TSharedRef SAssetUsageAuditPanel::MakeLevelFilterMenu() { if (!bAvailableFiltersReady) { RefreshAvailableFilters(); } FMenuBuilder MenuBuilder(true, nullptr); if (AvailableLevels.IsEmpty()) { MenuBuilder.AddWidget( SNew(STextBlock) .Text(bAvailableFiltersReady ? LOCTEXT("NoLevelsFound", "No levels found in the scanned paths.") : LOCTEXT("LevelsNotReady", "Asset Registry is still scanning; reopen this menu shortly.")) .ColorAndOpacity(FSlateColor::UseSubduedForeground()), FText::GetEmpty()); return MenuBuilder.MakeWidget(); } // Choosing levels SCOPES THE NEXT SWEEP, it does not merely filter the last one. Auditing all // 1072 levels to then hide 1071 of them would be both slow and misleading: an asset's verdict // depends on which levels were considered. // // A checklist rather than a single pick, because FAssetUsageAuditRequest::LevelPackages has // always been an array and the tree groups by level: "compare these three locations" is a // question the resolver could already answer and the UI could not ask. MenuBuilder.AddMenuEntry( LOCTEXT("AllLevelsEntry", "All levels"), LOCTEXT("AllLevelsTip", "Audit every level in the project. Slower, and the level column then lists every level that reaches each asset."), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this]() { LevelFilters.Reset(); OnScopeChanged(); }), FCanExecuteAction(), // Checked when nothing is ticked: an empty set means no restriction, which is the same // thing "All levels" says. FIsActionChecked::CreateLambda([this]() { return LevelFilters.IsEmpty(); })), NAME_None, EUserInterfaceActionType::RadioButton); MenuBuilder.AddSeparator(); for (const FName& Level : AvailableLevels) { MenuBuilder.AddMenuEntry( FText::FromString(FPackageName::GetShortName(Level)), FText::Format(LOCTEXT("LevelEntryTip", "Include {0} in the sweep. Press Run Audit to apply."), FText::FromName(Level)), FSlateIcon(), FUIAction( FExecuteAction::CreateLambda([this, Level]() { if (LevelFilters.Contains(Level)) { LevelFilters.Remove(Level); } else { LevelFilters.Add(Level); } OnScopeChanged(); }), FCanExecuteAction(), FIsActionChecked::CreateLambda([this, Level]() { return LevelFilters.Contains(Level); })), NAME_None, EUserInterfaceActionType::ToggleButton); } return SNew(SBox) .MaxDesiredHeight(600.0f) [ SNew(SScrollBox) + SScrollBox::Slot() [ MenuBuilder.MakeWidget() ] ]; } // --- Sorting --------------------------------------------------------------------------------- EColumnSortMode::Type SAssetUsageAuditPanel::GetSortModeForColumn(FName ColumnId) const { return SortColumn == ColumnId ? SortMode : EColumnSortMode::None; } void SAssetUsageAuditPanel::OnSortChanged(EColumnSortPriority::Type Priority, const FName& ColumnId, EColumnSortMode::Type NewMode) { SortColumn = ColumnId; SortMode = NewMode; ApplySorting(); // The tree holds its own nodes, so sorting FilteredRows underneath it changes nothing on screen // until the nodes are rebuilt. A plain refresh here would look like the sort was ignored. RebuildTree(); if (TreeView.IsValid()) { TreeView->RequestTreeRefresh(); } } void SAssetUsageAuditPanel::ApplySorting() { if (SortColumn.IsNone() || SortMode == EColumnSortMode::None) { return; } const bool bAscending = SortMode == EColumnSortMode::Ascending; FilteredRows.Sort([this, bAscending](const FAssetUsageRowPtr& A, const FAssetUsageRowPtr& B) { if (!A.IsValid() || !B.IsValid()) { return false; } auto Compare = [bAscending](auto Left, auto Right) { return bAscending ? Left < Right : Right < Left; }; if (SortColumn == AuditColumns::Name) { return Compare(A->AssetName.ToString(), B->AssetName.ToString()); } if (SortColumn == AuditColumns::Path) { return Compare(A->PathFromProjectRoot, B->PathFromProjectRoot); } if (SortColumn == AuditColumns::Type) { return Compare(ShortTypeName(A->ClassPath), ShortTypeName(B->ClassPath)); } if (SortColumn == AuditColumns::Verdict) { return Compare(static_cast(A->Verdict), static_cast(B->Verdict)); } if (SortColumn == AuditColumns::Levels) { return Compare(A->Levels.Num(), B->Levels.Num()); } if (SortColumn == AuditColumns::Hard) { return Compare(A->HardReferenceCount, B->HardReferenceCount); } if (SortColumn == AuditColumns::Soft) { return Compare(A->SoftReferenceCount, B->SoftReferenceCount); } return false; }); } // --- Ticking --------------------------------------------------------------------------------- ECheckBoxState SAssetUsageAuditPanel::GetRowCheckState(FAssetUsageRowPtr Item) const { if (!Item.IsValid()) { return ECheckBoxState::Unchecked; } return CheckedPackages.Contains(Item->PackageName) ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; } void SAssetUsageAuditPanel::OnRowCheckChanged(ECheckBoxState NewState, FAssetUsageRowPtr Item) { if (!Item.IsValid()) { return; } if (NewState == ECheckBoxState::Checked) { CheckedPackages.Add(Item->PackageName); } else { CheckedPackages.Remove(Item->PackageName); } } ECheckBoxState SAssetUsageAuditPanel::GetHeaderCheckState() const { if (FilteredRows.IsEmpty()) { return ECheckBoxState::Unchecked; } int32 CheckedCount = 0; for (const FAssetUsageRowPtr& Row : FilteredRows) { if (Row.IsValid() && CheckedPackages.Contains(Row->PackageName)) { ++CheckedCount; } } if (CheckedCount == 0) { return ECheckBoxState::Unchecked; } return CheckedCount == FilteredRows.Num() ? ECheckBoxState::Checked : ECheckBoxState::Undetermined; } void SAssetUsageAuditPanel::OnHeaderCheckChanged(ECheckBoxState NewState) { // Acts on the filtered view only: ticking "all" while a filter is active must not silently // select 45000 rows the user cannot see. const bool bCheck = NewState != ECheckBoxState::Unchecked; for (const FAssetUsageRowPtr& Row : FilteredRows) { if (!Row.IsValid()) { continue; } if (bCheck) { CheckedPackages.Add(Row->PackageName); } else { CheckedPackages.Remove(Row->PackageName); } } } FText SAssetUsageAuditPanel::GetSelectionSummaryText() const { return FText::Format( LOCTEXT("SelectionSummary", "Showing {0} of {1} rows | {2} ticked"), FText::AsNumber(FilteredRows.Num()), FText::AsNumber(AllRows.Num()), FText::AsNumber(CheckedPackages.Num())); } TArray SAssetUsageAuditPanel::ExpandWithDependencies(const TArray& Seeds, FDependencyExpansion& OutExpansion) const { OutExpansion = FDependencyExpansion(); OutExpansion.SeedCount = Seeds.Num(); OutExpansion.TotalCount = Seeds.Num(); if (!bIncludeDependencies || Seeds.IsEmpty()) { return Seeds; } IAssetRegistry* Registry = IAssetRegistry::Get(); if (!Registry) { // Not fatal: exporting exactly what was ticked is a worse answer, not a wrong one. Say so // rather than silently returning a narrower set than the checkbox promised. UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Asset Registry unavailable; exporting only the ticked assets without their dependencies.")); return Seeds; } const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); AssetDependencyClosure::FOptions ClosureOptions; if (Settings) { // Same exclusions as the audit. An export must not quietly pull in content the report was // told to ignore. ClosureOptions.ExcludePackagePaths = Settings->ExcludedPackagePaths; } AssetDependencyClosure::FStats Stats; TArray Expanded = AssetDependencyClosure::Gather(*Registry, Seeds, ClosureOptions, Stats); OutExpansion.TotalCount = Expanded.Num(); OutExpansion.Detail = Stats.Summarise(); return Expanded; } bool SAssetUsageAuditPanel::HasCheckedAssets() const { return CheckedPackages.Num() > 0; } void SAssetUsageAuditPanel::GetCheckedRows(TArray& OutRows) const { OutRows.Reset(); for (const FAssetUsageRowPtr& Row : AllRows) { if (Row.IsValid() && CheckedPackages.Contains(Row->PackageName)) { OutRows.Add(*Row); } } } // --- Export ---------------------------------------------------------------------------------- FReply SAssetUsageAuditPanel::OnExportReportClicked() { const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); const FString DefaultDir = Settings ? Settings->GetResolvedExportDirectory() : FPaths::ProjectSavedDir(); FString ChosenDir; IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get(); // No picker means no destination. Falling through with an empty ChosenDir would previously // have handed the writer a blank path and produced no file with no explanation. if (!DesktopPlatform) { StatusText = LOCTEXT("NoPicker", "Could not open a folder picker (DesktopPlatform is unavailable)."); UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Report export: DesktopPlatform module unavailable; cannot ask for a destination folder.")); return FReply::Handled(); } const void* ParentHandle = FSlateApplication::Get().FindBestParentWindowHandleForDialogs(AsShared()); if (!DesktopPlatform->OpenDirectoryDialog(ParentHandle, LOCTEXT("PickReportDir", "Choose a folder for the report").ToString(), DefaultDir, ChosenDir)) { UE_LOG(LogAssetUsageAuditEditor, Verbose, TEXT("Report export cancelled by the user at the folder picker.")); return FReply::Handled(); } // Report what is on screen, not the raw result: the filters ARE the user's question, and a // report that quietly widens them would not match what they are looking at. FAssetUsageAuditResult Filtered = LastResult; Filtered.Rows.Reset(); for (const FAssetUsageRowPtr& Row : FilteredRows) { if (Row.IsValid()) { Filtered.Rows.Add(*Row); } } Filtered.Header.AppliedFilters.Add(FString::Printf(TEXT("UI filter: %d of %d rows shown"), FilteredRows.Num(), AllRows.Num())); FAssetUsageReportWriter::FOptions Options; Options.OutputDirectory = ChosenDir; Options.BaseFileName = FString::Printf(TEXT("AssetUsageReport_%s"), *FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S"))); const FAssetUsageReportWriter::FResult WriteResult = FAssetUsageReportWriter::Write(Filtered, Options); if (WriteResult.bSuccess) { StatusText = FText::Format(LOCTEXT("ReportWritten", "Report written: {0}"), FText::FromString(WriteResult.CsvPath)); } else { StatusText = FText::Format(LOCTEXT("ReportFailed", "Report failed: {0}"), FText::FromString(WriteResult.ErrorMessage)); } return FReply::Handled(); } // The mode label, the button label and the mode menu all lived here. They are gone rather than // left unused: the export window now owns every one of those choices, and a second place that // still knew how to phrase them would drift out of step with it. FReply SAssetUsageAuditPanel::OnExportFilesClicked() { TArray Rows; GetCheckedRows(Rows); if (Rows.IsEmpty()) { return FReply::Handled(); } const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); // Seed the dialog from the project defaults and from whatever this user chose last time. The // project settings are the team's starting point; the dialog is where one person deviates from // it for one export, without editing a version-controlled ini to do so. FAssetExportRequest Request; Request.Mode = ExportMode; Request.bIncludeDependencies = bIncludeDependencies; Request.TargetDirectory = Settings ? Settings->GetResolvedExportDirectory() : FPaths::ProjectSavedDir(); Request.Layout = Settings ? Settings->ExportLayout : EExportLayout::Flat; Request.bGroupDependenciesByType = Settings && Settings->bGroupDependenciesByType; Request.CollisionPolicy = (Settings && Settings->bOverwriteExistingFiles) ? EExportCollisionPolicy::Overwrite : EExportCollisionPolicy::Index; if (!ShowAssetExportDialog(Request, Rows.Num())) { return FReply::Handled(); } // Remember the two the panel owns, so the next export opens where this one left off. ExportMode = Request.Mode; bIncludeDependencies = Request.bIncludeDependencies; SaveUserSettings(); UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Export requested: %d ticked, mode %s, layout %s, dependencies %s, target '%s'."), Rows.Num(), Request.Mode == EFileExportMode::CopyPackages ? TEXT("copy") : TEXT("convert"), LexToString(Request.Layout), Request.bIncludeDependencies ? TEXT("on") : TEXT("off"), *Request.TargetDirectory); // Migrate is checked before the mode. It is not a way of arranging copied files - it is the // engine performing the whole transfer itself, so neither the copy nor the convert path applies. if (Request.Layout == EExportLayout::Migrate) { RunMigrateExport(Rows, Request); } else if (Request.Mode == EFileExportMode::CopyPackages) { RunCopyExport(Rows, Request); } else { RunConvertExport(Rows, Request); } return FReply::Handled(); } void SAssetUsageAuditPanel::RunMigrateExport(const TArray& Rows, const FAssetExportRequest& Request) { TArray Packages; Packages.Reserve(Rows.Num()); for (const FAssetUsageRow& Row : Rows) { Packages.Add(Row.PackageName); } // Checked here as well as in the dialog. Migrate validates the destination only after it has // been called, and reports the refusal to the Output Log alone - which is exactly how this // arrived as "the Export button does nothing". const FText DestinationProblem = ValidateMigrateDestination(Request.TargetDirectory); if (!DestinationProblem.IsEmpty()) { StatusText = DestinationProblem; UE_LOG(LogAssetUsageAuditEditor, Error, TEXT("Migrate refused: '%s' - %s"), *Request.TargetDirectory, *DestinationProblem.ToString()); FMessageDialog::Open(EAppMsgType::Ok, DestinationProblem); return; } // Only the ticked packages are handed over. Migrate walks the dependency graph itself, and // feeding it our own closure as well would make it re-walk from every dependency - the same // result, reached slowly, with the engine's report listing assets the user never chose. FMigrationOptions Options; Options.bPrompt = false; Options.bIgnoreDependencies = !Request.bIncludeDependencies; Options.AssetConflict = (Request.CollisionPolicy == EExportCollisionPolicy::Overwrite) ? EAssetMigrationConflict::Overwrite : EAssetMigrationConflict::Skip; // The naming policy has no equivalent here. Migrate must land each package at its original // package path or the references it exists to preserve would break, so "keep both under a new // name" is not on offer - the closest honest mapping is skip. if (Request.CollisionPolicy == EExportCollisionPolicy::Index) { UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Migrate: the Keep-both naming policy does not apply; existing assets at the destination will be skipped.")); } if (Options.bIgnoreDependencies) { // Engine comment on the flag, worth repeating where someone can act on it: with dependencies // ignored, a level's One File Per Actor packages are not migrated either, so the level // arrives empty rather than merely unreferenced. const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo, LOCTEXT("MigrateNoDeps", "Migrate without dependencies?\n\n" "The assets will arrive with their references unresolved, and any level among them " "will arrive empty - Unreal skips the One File Per Actor packages when dependencies " "are ignored.\n\n" "Tick \"Include referenced assets\" unless you know the destination already has them.")); if (Answer != EAppReturnType::Yes) { UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Migrate cancelled at the no-dependencies warning.")); return; } } UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Migrating %d ticked package(s) to '%s'. Dependencies %s, conflicts %s."), Packages.Num(), *Request.TargetDirectory, Options.bIgnoreDependencies ? TEXT("ignored") : TEXT("included"), Options.AssetConflict == EAssetMigrationConflict::Overwrite ? TEXT("overwritten") : TEXT("skipped")); IAssetTools::Get().MigratePackages(Packages, Request.TargetDirectory, Options); // MigratePackages returns void and reports through its own notifications, so there is no count // to quote here. Saying "N files copied" would be an invention. StatusText = FText::Format( LOCTEXT("MigrateStarted", "Migrate handed {0} asset(s) to Unreal for transfer to {1}. Unreal reports the outcome itself - check the Output Log and any notification it raises."), FText::AsNumber(Packages.Num()), FText::FromString(Request.TargetDirectory)); } void SAssetUsageAuditPanel::RunCopyExport(const TArray& Rows, const FAssetExportRequest& Request) { const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); const FString& TargetDirectory = Request.TargetDirectory; TArray Seeds; Seeds.Reserve(Rows.Num()); for (const FAssetUsageRow& Row : Rows) { Seeds.Add(Row.PackageName); } const EExportLayout Layout = Request.Layout; IAssetRegistry* Registry = IAssetRegistry::Get(); // Folder-per-asset needs to know which seed pulled each dependency in, so it takes the slower // per-seed closure. The other layouts collapse to one list and use the cheap path. TArray Closures; FDependencyExpansion Expansion; if (Layout == EExportLayout::FolderPerAsset && Request.bIncludeDependencies && Registry) { AssetDependencyClosure::FOptions ClosureOptions; if (Settings) { ClosureOptions.ExcludePackagePaths = Settings->ExcludedPackagePaths; } AssetDependencyClosure::FStats ClosureStats; Closures = AssetDependencyClosure::GatherPerSeed(*Registry, Seeds, ClosureOptions, ClosureStats); Expansion.SeedCount = ClosureStats.SeedCount; Expansion.TotalCount = ClosureStats.TotalCount; Expansion.Detail = ClosureStats.Summarise(); } else { const TArray PackageNames = ExpandWithDependencies(Seeds, Expansion); AssetDependencyClosure::FSeedClosure& Single = Closures.AddDefaulted_GetRef(); Single.Packages = PackageNames; } AssetExportLayout::FOptions LayoutOptions; LayoutOptions.Layout = Layout; LayoutOptions.bGroupDependenciesByType = Request.bGroupDependenciesByType; AssetExportLayout::FStats LayoutStats; const TArray LayoutGroups = Registry ? AssetExportLayout::BuildGroups(*Registry, Closures, LayoutOptions, LayoutStats) : TArray(); // Ask before writing several times more files than were ticked. Without this the folder simply // arrives much larger than expected, and the natural conclusion is that the tool is broken. // // Under folder-per-asset the number that matters is placements, not distinct packages: a shared // texture is written once per folder, so the file count can far exceed the asset count. if (LayoutStats.FilePlacements > Expansion.SeedCount) { const FText Detail = (Layout == EExportLayout::FolderPerAsset) ? FText::Format( LOCTEXT("CopyConfirmFolders", "Write {0} files into {1} folders?\n\n" "You ticked {2} assets. Each gets its own folder with the materials, textures and " "other assets it references beside it, so anything shared is copied into every " "folder that needs it - {3} distinct assets become {0} files."), FText::AsNumber(LayoutStats.FilePlacements), FText::AsNumber(LayoutStats.GroupCount), FText::AsNumber(Expansion.SeedCount), FText::AsNumber(LayoutStats.DistinctPackages)) : FText::Format( LOCTEXT("CopyConfirmDeps", "Copy {0} files?\n\n" "You ticked {1}; the other {2} are materials, textures and other assets they reference. " "Without those, the exported files open with missing content.\n\n" "Untick \"Include dependencies\" to copy only what you ticked."), FText::AsNumber(LayoutStats.FilePlacements), FText::AsNumber(Expansion.SeedCount), FText::AsNumber(FMath::Max(0, LayoutStats.FilePlacements - Expansion.SeedCount))); if (FMessageDialog::Open(EAppMsgType::YesNo, Detail) != EAppReturnType::Yes) { UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Copy cancelled at the confirmation dialog.")); return; } UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Dependency expansion: %s"), *Expansion.Detail); } FAssetUsageExporter::FOptions ExportOptions; ExportOptions.TargetDirectory = Request.TargetDirectory; ExportOptions.CollisionPolicy = Request.CollisionPolicy; // bFlatten is the exporter's mirror-the-tree switch, applied inside whatever folder a group // lands in. The folder-per-asset layout wants flat contents within each asset's own folder, // so only MirrorTree turns it off. ExportOptions.bFlatten = Request.Layout != EExportLayout::MirrorTree; ExportOptions.bRecordWrittenFiles = Request.bWriteManifest; UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Copying to '%s': layout %s, %d group(s), %d file(s) from %d distinct package(s)."), *TargetDirectory, LexToString(Layout), LayoutStats.GroupCount, LayoutStats.FilePlacements, LayoutStats.DistinctPackages); FScopedSlowTask SlowTask(static_cast(LayoutStats.FilePlacements), LOCTEXT("Exporting", "Copying asset files...")); SlowTask.MakeDialog(true); // Returning false cancels; the exporter keeps whatever it has already copied. ExportOptions.OnProgress = [&SlowTask](int32, int32) { SlowTask.EnterProgressFrame(1.0f); return !SlowTask.ShouldCancel(); }; const FAssetUsageExporter::FResult ExportResult = FAssetUsageExporter::ExportPackageGroups(LayoutGroups, ExportOptions); // The manifest describes what was written, so it is written after the copy and only when the // copy produced something. Writing it first would leave a manifest describing files that a // cancelled or failed export never created. if (Request.bWriteManifest && Registry && ExportResult.FilesCopied > 0) { AssetExportManifest::FOptions ManifestOptions; ManifestOptions.TargetDirectory = Request.TargetDirectory; ManifestOptions.Layout = Request.Layout; ManifestOptions.CollisionPolicy = Request.CollisionPolicy; ManifestOptions.SeedPackages = TSet(Seeds); const AssetExportManifest::FResult ManifestResult = AssetExportManifest::Write(*Registry, ExportResult.WrittenFiles, ManifestOptions); if (!ManifestResult.bSuccess) { // Not fatal - the files are already on disk and are what the user asked for. Said out // loud all the same: silently skipping it would leave someone believing they can restore // the references later. UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Files were exported but the dependency manifest was not written: %s"), *ManifestResult.ErrorMessage); } } StatusText = FText::Format( LOCTEXT("ExportDone", "Export to {0}: {1}"), FText::FromString(TargetDirectory), FText::FromString(ExportResult.Summarise())); if (!ExportResult.Errors.IsEmpty()) { FMessageDialog::Open(EAppMsgType::Ok, FText::Format( LOCTEXT("ExportHadFailures", "{0} file(s) could not be copied. See the Output Log for details."), FText::AsNumber(ExportResult.Errors.Num()))); } } void SAssetUsageAuditPanel::RunConvertExport(const TArray& Rows, const FAssetExportRequest& Request) { const UAssetUsageAuditSettings* Settings = UAssetUsageAuditSettings::Get(); const FString& TargetDirectory = Request.TargetDirectory; TArray Seeds; Seeds.Reserve(Rows.Num()); // Rows carry the asset name and class already; dependencies do not, and asking the registry for // every package would be wasteful when most of them are right here. TMap RowByPackage; RowByPackage.Reserve(Rows.Num()); for (const FAssetUsageRow& Row : Rows) { Seeds.Add(Row.PackageName); RowByPackage.Add(Row.PackageName, &Row); } // Honour the dialog's choice rather than the panel's remembered one: the user may have just // unticked dependencies for this export alone. FDependencyExpansion Expansion; const TArray Packages = Request.bIncludeDependencies ? ExpandWithDependencies(Seeds, Expansion) : Seeds; if (!Request.bIncludeDependencies) { Expansion.SeedCount = Seeds.Num(); Expansion.TotalCount = Seeds.Num(); } IAssetRegistry* Registry = IAssetRegistry::Get(); TArray Items; Items.Reserve(Packages.Num()); for (FName PackageName : Packages) { if (const FAssetUsageRow* const* Found = RowByPackage.Find(PackageName)) { FAssetUsageExporter::FExportItem& Item = Items.AddDefaulted_GetRef(); Item.PackageName = (*Found)->PackageName; Item.AssetName = (*Found)->AssetName; Item.ClassPath = (*Found)->ClassPath; continue; } if (!Registry) { continue; } // A package can hold several assets. Take them all: skipping the extras would drop content // silently, and the format lookup rejects anything unconvertible anyway. TArray AssetsInPackage; Registry->GetAssetsByPackageName(PackageName, AssetsInPackage, /*bIncludeOnlyOnDiskAssets=*/true); for (const FAssetData& AssetData : AssetsInPackage) { FAssetUsageExporter::FExportItem& Item = Items.AddDefaulted_GetRef(); Item.PackageName = AssetData.PackageName; Item.AssetName = AssetData.AssetName; Item.ClassPath = AssetData.AssetClassPath; } } FAssetUsageExporter::FExchangeOptions ConvertOptions; ConvertOptions.TargetDirectory = TargetDirectory; ConvertOptions.CollisionPolicy = Request.CollisionPolicy; ConvertOptions.bFlatten = Request.Layout != EExportLayout::MirrorTree; if (Settings) { ConvertOptions.FormatByClass = Settings->ExchangeFormatByClass; } // Warn before starting rather than after: conversion loads every asset, and on a few thousand // rows that is minutes of a blocked editor. Someone who ticked half the project and picked the // wrong mode should find that out here. const TMap& EffectiveFormats = ConvertOptions.FormatByClass.Num() > 0 ? ConvertOptions.FormatByClass : FAssetUsageExporter::GetDefaultFormatByClass(); int32 Convertible = 0; for (const FAssetUsageExporter::FExportItem& Item : Items) { if (!FAssetUsageExporter::FindFormatForClass(Item.ClassPath, EffectiveFormats).IsEmpty()) { ++Convertible; } } if (Convertible == 0) { StatusText = LOCTEXT("ConvertNothing", "None of the ticked assets have a configured exchange format."); UE_LOG(LogAssetUsageAuditEditor, Warning, TEXT("Conversion not started: none of the %d ticked assets map to a format. Check Project Settings > Asset Usage Audit > Exchange Format By Class."), Items.Num()); FMessageDialog::Open(EAppMsgType::Ok, LOCTEXT("ConvertNothingDialog", "None of the ticked assets have a configured exchange format.\n\n" "Set one in Project Settings > Asset Usage Audit > Exchange Format By Class, or use the copy mode instead.")); return; } // The count shown must be the expanded one, since that is what will actually be processed. // Saying "of N ticked" here after silently widening the set would be a lie of exactly the kind // that makes people stop trusting a number. const FText Origin = Expansion.AddedCount() > 0 ? FText::Format( LOCTEXT("ConvertOriginExpanded", "{0} ticked plus {1} they reference"), FText::AsNumber(Expansion.SeedCount), FText::AsNumber(Expansion.AddedCount())) : FText::Format(LOCTEXT("ConvertOriginPlain", "{0} ticked"), FText::AsNumber(Expansion.SeedCount)); const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo, FText::Format( LOCTEXT("ConvertConfirm", "Convert {0} of {1} assets ({2})?\n\n" "{3} have no configured format and will be skipped.\n\n" "Conversion loads every asset, so this can take several minutes and cannot be undone once files are written."), FText::AsNumber(Convertible), FText::AsNumber(Items.Num()), Origin, FText::AsNumber(Items.Num() - Convertible))); if (Answer != EAppReturnType::Yes) { UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Conversion cancelled at the confirmation dialog.")); return; } UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Converting %d of %d ticked asset(s) into '%s'."), Convertible, Items.Num(), *TargetDirectory); FScopedSlowTask SlowTask(static_cast(Items.Num()), LOCTEXT("Converting", "Converting assets...")); SlowTask.MakeDialog(true); ConvertOptions.OnProgress = [&SlowTask](int32, int32) { SlowTask.EnterProgressFrame(1.0f); return !SlowTask.ShouldCancel(); }; const FAssetUsageExporter::FExchangeResult ConvertResult = FAssetUsageExporter::ExportConvertedAssets(Items, ConvertOptions); StatusText = FText::Format( LOCTEXT("ConvertDone", "Conversion to {0}: {1}"), FText::FromString(TargetDirectory), FText::FromString(ConvertResult.Summarise())); if (!ConvertResult.Errors.IsEmpty()) { FMessageDialog::Open(EAppMsgType::Ok, FText::Format( LOCTEXT("ConvertHadFailures", "{0} asset(s) could not be converted. See the Output Log for details."), FText::AsNumber(ConvertResult.Errors.Num()))); } } #undef LOCTEXT_NAMESPACE