feat: asset deletion scan and delete dialog (WIP)

Adds AssetDeletionScan to Core (no UI/editor-pipeline deps, invariant holds)
and SAssetDeleteDialog to the Editor module, plus 10 spec cases.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:14:39 +07:00
parent 569ec56a19
commit fbdd0ff5e0
8 changed files with 1734 additions and 0 deletions
+46
View File
@@ -245,6 +245,52 @@ BOM обязателен: без него Excel ломает кириллицу.
--- ---
## Удаление отмеченных ассетов
Кнопка **Delete ticked assets…** внизу панели, рядом с выгрузкой. Работает по тому же набору галочек.
Удаление — единственная операция инструмента, которая уничтожает работу безвозвратно, поэтому проходит **через два окна**.
### Окно 1 — наше
Показывает весь отмеченный набор построчно: имя, тип, путь, **сколько ассетов снаружи набора ещё ссылается** на строку (в тултипе — их имена), и статус.
- Ссылающиеся ассеты, которые сами удаляются вместе с целью, **в счётчик не попадают**. Иначе удаление блюпринта вместе с его единственным мешем выглядело бы опасным, хотя оно чистое.
- Референсеры запрашиваются тем же `NoRequirements`-запросом, что и аудит: hard-only потерял бы каждую soft-ссылку, а soft-ссылка ломается точно так же — просто в рантайме, а не при загрузке.
- Файл, помеченный **read-only на диске**, отмечается заранее. Проект Perforce-primary, невытянутый файл нередактируемый, и без этой пометки удаление падало бы по одному файлу в середине пачки.
- Кнопка удаления неактивна, пока не поставлена галочка «I understand these files will be removed from the project».
### Окно 2 — родное от UE
После подтверждения набор уходит в `ObjectTools::DeleteAssets(..., bShowConfirmation=true)`, и открывается **штатное окно Delete Assets** движка — со списком ссылок, `Force Delete` и `Replace References`.
Оно не имитировано, а вызвано: своя реализация замены ссылок означала бы свою реализацию `FAssetDeleteModel`, ошибки которой всплыли бы на чужом проекте.
### Что удалять запрещено
Отказы показываются в списке с причиной, строку нельзя отметить — молча выкинуть её из набора было бы хуже, чем отказать вслух.
| Отказ | Почему |
|---|---|
| **Уровень** (`.umap`) | Уровень — это единица, относительно которой инструмент меряет использование. Удалив его, обесцениваешь каждую другую строку результата. Плюс движок сам отказывается удалять открытую карту, и итог зависел бы от того, какая карта сейчас загружена |
| **OFPA-пакет** (`__ExternalActors__`, `__ExternalObjects__`) | Это не ассет, а размещённый на карте актор. Удалять его мимо level-редактора — значит править карту за его спиной, без его undo |
| **`/Engine`, `/Script`, `/Temp`** | Не контент этого проекта |
| **Уже отсутствует** | В реестре нет ассета под этим именем — удалять нечего |
### ⚠️ Набор для удаления не расширяется зависимостями
В отличие от выгрузки. Выгрузка тянет зависимости, чтобы меш приехал с материалами; удаление, расширенное так же, снесло бы контент, который никто не отмечал, — одна общая текстура утащила бы половину проекта.
Удаляется ровно отмеченное и никогда больше.
### ⚠️ После удаления результат помечается устаревшим
Удалённые строки убираются из таблицы, но всё, что на них ссылалось, сохраняет прежние счётчики ссылок, а ассет, бывший `UsedByAssetsOnly` через удалённый блюпринт, теперь имеет другой вердикт. Панель говорит об этом прямо и просит перезапустить аудит, а не выдаёт старые числа за свежий замер.
И главное — то же, что и везде в этом инструменте: **`Unreferenced` не означает «не используется»**. Реестр не видит путей, собранных строками, содержимого DataTable и событий FMOD. Окно удаления повторяет это предупреждение прямо в тексте.
---
## Архитектура ## Архитектура
``` ```
@@ -0,0 +1,243 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetDeletionScan.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/PackageName.h"
#define LOCTEXT_NAMESPACE "AssetDeletionScan"
namespace AssetDeletionScan
{
const TCHAR* LexToString(ERefusal Refusal)
{
switch (Refusal)
{
case ERefusal::None: return TEXT("None");
case ERefusal::IsLevel: return TEXT("IsLevel");
case ERefusal::IsExternalPackage: return TEXT("IsExternalPackage");
case ERefusal::NotProjectContent: return TEXT("NotProjectContent");
case ERefusal::Missing: return TEXT("Missing");
}
return TEXT("Unknown");
}
FText DescribeRefusal(ERefusal Refusal)
{
switch (Refusal)
{
case ERefusal::None:
return FText::GetEmpty();
case ERefusal::IsLevel:
return LOCTEXT("RefusalLevel",
"Levels are never deleted by this tool. Delete a map from the Content Browser if you really mean to.");
case ERefusal::IsExternalPackage:
return LOCTEXT("RefusalExternal",
"This is a single actor stored outside its map (One File Per Actor). Delete it in the level editor, where it can be undone.");
case ERefusal::NotProjectContent:
return LOCTEXT("RefusalNotProject",
"Engine or plugin content - not part of this project.");
case ERefusal::Missing:
return LOCTEXT("RefusalMissing",
"No asset found under this package name; nothing to delete.");
}
return FText::GetEmpty();
}
FString FStats::Summarise() const
{
return FString::Printf(
TEXT("%d ticked: %d deletable, %d refused (levels %d, external actors %d, engine %d, missing %d); ")
TEXT("%d still referenced from outside the set, %d read-only on disk."),
Requested,
Deletable,
Requested - Deletable,
RefusedLevels,
RefusedExternal,
RefusedNotProjectContent,
RefusedMissing,
WithOutsideReferencers,
ReadOnly);
}
TArray<FCandidate> Scan(
IAssetRegistry& Registry,
const TArray<FName>& Packages,
const FOptions& Options,
FStats& OutStats)
{
OutStats = FStats();
TArray<FCandidate> Candidates;
Candidates.Reserve(Packages.Num());
// Membership is needed while classifying referencers, so it is built first and in full.
// Deleting A and B where B is A's only referencer must not report A as still in use.
TSet<FName> DeleteSet(Packages);
// Answering "is this a map" costs a registry query that allocates; the same package shows up
// as a referencer of many candidates, so the answer is cached across the whole scan.
TMap<FName, bool> LevelCache;
const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool
{
if (const bool* Cached = LevelCache.Find(PackageName))
{
return *Cached;
}
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
bool bIsLevel = false;
for (const FAssetData& AssetData : Assets)
{
if (AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
bIsLevel = true;
break;
}
}
LevelCache.Add(PackageName, bIsLevel);
return bIsLevel;
};
TArray<FName> Referencers;
TArray<FAssetData> Assets;
for (FName PackageName : Packages)
{
if (PackageName.IsNone())
{
continue;
}
++OutStats.Requested;
FCandidate Candidate;
Candidate.PackageName = PackageName;
const FString PackageString = PackageName.ToString();
// Filled from the path before any refusal, so a refused row still shows a name and a
// path. A row that says only "refused" with an empty name is not an explanation.
Candidate.AssetName = FName(*FPackageName::GetShortName(PackageString));
Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, /*bIsLevel=*/false);
// Path-shaped refusals come first: they need no registry lookup, and two of them
// (script and engine content) would not produce a sensible FAssetData anyway.
if (AssetUsagePaths::IsScriptPackage(PackageName) || AssetUsagePaths::IsEngineOrTempPackage(PackageString))
{
Candidate.Refusal = ERefusal::NotProjectContent;
++OutStats.RefusedNotProjectContent;
Candidates.Add(MoveTemp(Candidate));
continue;
}
if (AssetUsagePaths::IsExternalPackage(PackageName))
{
Candidate.Refusal = ERefusal::IsExternalPackage;
++OutStats.RefusedExternal;
Candidates.Add(MoveTemp(Candidate));
continue;
}
Assets.Reset();
Registry.GetAssetsByPackageName(PackageName, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
if (Assets.Num() == 0)
{
Candidate.Refusal = ERefusal::Missing;
++OutStats.RefusedMissing;
Candidates.Add(MoveTemp(Candidate));
continue;
}
// The package's own asset, preferring the one the package is named after. A package
// usually holds exactly one, but the first entry is not guaranteed to be the UAsset.
const FAssetData* Primary = Assets.FindByPredicate([](const FAssetData& Data) { return Data.IsUAsset(); });
const FAssetData& AssetData = Primary ? *Primary : Assets[0];
Candidate.AssetName = AssetData.AssetName;
Candidate.ClassPath = AssetData.AssetClassPath;
const bool bIsLevel = AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName();
LevelCache.Add(PackageName, bIsLevel);
Candidate.PathFromProjectRoot = AssetUsagePaths::ToProjectRelativePath(PackageName, bIsLevel);
if (bIsLevel)
{
Candidate.Refusal = ERefusal::IsLevel;
++OutStats.RefusedLevels;
Candidates.Add(MoveTemp(Candidate));
continue;
}
// From here the candidate is deletable, and the remaining work is describing the
// consequences rather than deciding them.
Referencers.Reset();
Registry.GetReferencers(
PackageName, Referencers,
AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery());
for (FName Referencer : Referencers)
{
if (Referencer == PackageName || DeleteSet.Contains(Referencer))
{
continue;
}
++Candidate.OutsideReferencerCount;
// An OFPA package is counted as a level reference without a registry lookup: it
// exists only to hold an actor placed in some map, so a reference from one means
// the asset is in a level right now.
if (AssetUsagePaths::IsExternalPackage(Referencer) || IsLevelPackage(Referencer))
{
++Candidate.LevelReferencerCount;
}
if (Candidate.OutsideReferencers.Num() < Options.MaxReferencersListed)
{
Candidate.OutsideReferencers.Add(Referencer);
}
}
FString Filename;
if (FPackageName::DoesPackageExist(PackageString, &Filename))
{
Candidate.bReadOnlyOnDisk = IFileManager::Get().IsReadOnly(*Filename);
}
++OutStats.Deletable;
if (Candidate.OutsideReferencerCount > 0)
{
++OutStats.WithOutsideReferencers;
}
if (Candidate.bReadOnlyOnDisk)
{
++OutStats.ReadOnly;
}
Candidates.Add(MoveTemp(Candidate));
}
return Candidates;
}
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,151 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
#include "UObject/TopLevelAssetPath.h"
class IAssetRegistry;
/**
* What would happen if this set of packages were deleted - worked out before anything is touched.
*
* Deletion is the one operation in this tool that destroys work, and the audit's own verdicts are
* not a safe basis for it on their own: Unknown means "the registry cannot see it", not "unused",
* and the whole point of the five-state verdict is that the tool never presents a guess as a fact.
* So the scan answers a narrower, checkable question - who still points at this, right now, in the
* registry - and leaves the judgement to the person reading it.
*
* Pure registry and filesystem work. It loads nothing, deletes nothing, and is deliberately
* separate from the code that performs the delete: the analysis has to be testable without an
* editor, and the actual removal is ObjectTools' job in the Editor module, not ours.
*/
namespace AssetDeletionScan
{
/**
* Why a ticked package will not be deleted.
*
* Refusals are policy, not failures, and each one is shown with its reason. Silently dropping a
* row from the delete set would be worse than refusing loudly - the user ticked it and is owed
* an explanation.
*/
enum class ERefusal : uint8
{
/** No objection. */
None,
/**
* The package is a map.
*
* Refused by decision, not by capability. A level is the unit the whole tool measures usage
* against, and deleting one silently invalidates every other row in the result. The engine
* also refuses to delete a level that is currently open, which would make the outcome depend
* on which map the user happens to have loaded.
*/
IsLevel,
/**
* A One File Per Actor package - an actor or object belonging to some level.
*
* Deleting one is not deleting an asset, it is deleting a placed actor out of a map behind
* the level editor's back. That belongs in the level editor, with its undo.
*/
IsExternalPackage,
/** /Engine, /Script or /Temp. Not this project's to delete. */
NotProjectContent,
/** The registry has no asset under this package name; nothing to delete. */
Missing
};
ASSETUSAGEAUDITCORE_API const TCHAR* LexToString(ERefusal Refusal);
/** One line of explanation, suitable for showing beside the row. */
ASSETUSAGEAUDITCORE_API FText DescribeRefusal(ERefusal Refusal);
/** One ticked package and what stands in the way of deleting it. */
struct FCandidate
{
FName PackageName;
FName AssetName;
FTopLevelAssetPath ClassPath;
/** Content/... form, as the report shows it. */
FString PathFromProjectRoot;
ERefusal Refusal = ERefusal::None;
/**
* Packages that reference this one and are NOT themselves being deleted.
*
* The exclusion matters: deleting a Blueprint together with the mesh only it uses is a
* clean operation, and listing the Blueprint as a blocker would make every sensible
* multi-asset delete look dangerous. Truncated to FOptions::MaxReferencersListed - the list
* is there to be read, and forty names is not read.
*/
TArray<FName> OutsideReferencers;
/** Full count before truncation. */
int32 OutsideReferencerCount = 0;
/** How many of those referencers are maps or OFPA packages - i.e. it is placed in a level. */
int32 LevelReferencerCount = 0;
/**
* The file exists on disk and is marked read-only.
*
* Worth surfacing here rather than discovering it as a failure afterwards: this project is
* Perforce-primary and unopened files are read-only by default, so a delete attempted
* without checking out first fails per-file, halfway through.
*/
bool bReadOnlyOnDisk = false;
bool CanDelete() const
{
return Refusal == ERefusal::None;
}
};
struct FOptions
{
/** How many referencer names to keep per candidate. The count is always exact. */
int32 MaxReferencersListed = 8;
};
// Exported: Summarise is defined out of line, so the Editor module cannot link without this.
struct ASSETUSAGEAUDITCORE_API FStats
{
int32 Requested = 0;
int32 Deletable = 0;
int32 RefusedLevels = 0;
int32 RefusedExternal = 0;
int32 RefusedNotProjectContent = 0;
int32 RefusedMissing = 0;
/** Deletable candidates that something outside the set still points at. */
int32 WithOutsideReferencers = 0;
/** Deletable candidates whose file is read-only on disk. */
int32 ReadOnly = 0;
FString Summarise() const;
};
/**
* Classify the packages and collect their outside referencers.
*
* Order is preserved, refusals included, so the caller can show the whole ticked set with a
* reason against each row instead of a shorter list that quietly lost entries.
*
* Referencers are queried with AssetUsageAudit::MakeTraversalQuery() for the same reason the
* audit uses it: a Hard-only query misses every soft reference, and a soft reference still
* breaks when its target disappears - it just breaks at runtime instead of at load.
*/
ASSETUSAGEAUDITCORE_API TArray<FCandidate> Scan(
IAssetRegistry& Registry,
const TArray<FName>& Packages,
const FOptions& Options,
FStats& OutStats);
}
@@ -0,0 +1,669 @@
// NextGenium 2026. Asset Usage Audit.
#include "SAssetDeleteDialog.h"
#include "AssetDeletionScan.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditEditorModule.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Docking/TabManager.h"
#include "ObjectTools.h"
#include "Styling/AppStyle.h"
#include "Styling/CoreStyle.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/SWindow.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Views/SHeaderRow.h"
#include "Widgets/Views/SListView.h"
#define LOCTEXT_NAMESPACE "AssetDeleteDialog"
namespace DeleteColumns
{
static const FName Check(TEXT("Check"));
static const FName Name(TEXT("Name"));
static const FName Type(TEXT("Type"));
static const FName Path(TEXT("Path"));
static const FName Referencers(TEXT("Referencers"));
static const FName Status(TEXT("Status"));
}
namespace
{
/** Red enough to read as a warning, light enough to stay legible on the dark editor theme. */
const FLinearColor DangerColor(0.95f, 0.35f, 0.30f);
const FLinearColor CautionColor(0.95f, 0.72f, 0.25f);
const FLinearColor MutedColor(0.6f, 0.6f, 0.6f);
}
/** One candidate plus its tick state. */
struct FDeleteRow
{
AssetDeletionScan::FCandidate Candidate;
/**
* Ticked by default when nothing objects.
*
* The user already ticked these rows in the panel; re-ticking them here would be busywork.
* What the window adds is the chance to *un*tick, having now been shown the referencers.
*/
bool bChecked = false;
};
using FDeleteRowPtr = TSharedPtr<FDeleteRow>;
class SDeleteRowWidget : public SMultiColumnTableRow<FDeleteRowPtr>
{
public:
SLATE_BEGIN_ARGS(SDeleteRowWidget) {}
SLATE_ARGUMENT(FDeleteRowPtr, Row)
SLATE_EVENT(FSimpleDelegate, OnCheckChanged)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTable)
{
Row = InArgs._Row;
OnCheckChanged = InArgs._OnCheckChanged;
SMultiColumnTableRow<FDeleteRowPtr>::Construct(FSuperRowType::FArguments(), InOwnerTable);
}
virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& ColumnName) override
{
if (!Row.IsValid())
{
return SNullWidget::NullWidget;
}
const AssetDeletionScan::FCandidate& Candidate = Row->Candidate;
const bool bCanDelete = Candidate.CanDelete();
if (ColumnName == DeleteColumns::Check)
{
return SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsEnabled(bCanDelete)
.IsChecked_Lambda([this]()
{
return Row->bChecked ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
Row->bChecked = (NewState == ECheckBoxState::Checked);
OnCheckChanged.ExecuteIfBound();
})
];
}
if (ColumnName == DeleteColumns::Name)
{
return SNew(STextBlock)
.Text(FText::FromName(Candidate.AssetName))
.ColorAndOpacity(bCanDelete ? FSlateColor::UseForeground() : FSlateColor(MutedColor))
.ToolTipText(FText::FromName(Candidate.PackageName));
}
if (ColumnName == DeleteColumns::Type)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.ClassPath.GetAssetName().ToString()))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (ColumnName == DeleteColumns::Path)
{
return SNew(STextBlock)
.Text(FText::FromString(Candidate.PathFromProjectRoot))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(FText::FromString(Candidate.PathFromProjectRoot));
}
if (ColumnName == DeleteColumns::Referencers)
{
if (Candidate.OutsideReferencerCount == 0)
{
return SNew(STextBlock)
.Text(LOCTEXT("NoReferencers", "none"))
.ColorAndOpacity(FSlateColor(MutedColor))
.ToolTipText(LOCTEXT("NoReferencersTip", "Nothing outside this delete set points at it, as far as the Asset Registry can see. Config files, C++ string paths and FMOD events are outside what it can see."));
}
// The names matter more than the number: "3" tells you to stop, the names tell you
// whether stopping was right.
FString Tooltip;
for (FName Referencer : Candidate.OutsideReferencers)
{
Tooltip += Referencer.ToString() + TEXT("\n");
}
if (Candidate.OutsideReferencerCount > Candidate.OutsideReferencers.Num())
{
Tooltip += FString::Printf(TEXT("... and %d more"),
Candidate.OutsideReferencerCount - Candidate.OutsideReferencers.Num());
}
const FText Label = Candidate.LevelReferencerCount > 0
? FText::Format(
LOCTEXT("ReferencersWithLevels", "{0} ({1} on levels)"),
FText::AsNumber(Candidate.OutsideReferencerCount),
FText::AsNumber(Candidate.LevelReferencerCount))
: FText::AsNumber(Candidate.OutsideReferencerCount);
return SNew(STextBlock)
.Text(Label)
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(FText::FromString(Tooltip));
}
if (ColumnName == DeleteColumns::Status)
{
if (!bCanDelete)
{
return SNew(STextBlock)
.Text(AssetDeletionScan::DescribeRefusal(Candidate.Refusal))
.ColorAndOpacity(FSlateColor(MutedColor));
}
if (Candidate.bReadOnlyOnDisk)
{
return SNew(STextBlock)
.Text(LOCTEXT("ReadOnly", "read-only on disk"))
.ColorAndOpacity(FSlateColor(CautionColor))
.ToolTipText(LOCTEXT("ReadOnlyTip", "The file is not writable. This project is Perforce-primary, so an unopened file is read-only and the delete will fail on it. Check it out first."));
}
return SNew(STextBlock)
.Text(LOCTEXT("WillDelete", "will be deleted"))
.ColorAndOpacity(FSlateColor(DangerColor));
}
return SNullWidget::NullWidget;
}
private:
FDeleteRowPtr Row;
FSimpleDelegate OnCheckChanged;
};
/**
* The window body.
*
* Owns the scan result and the tick state; deletes nothing until Delete is pressed, and reports
* back through the outcome struct rather than mutating anything the panel owns.
*/
class SAssetDeleteDialog : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SAssetDeleteDialog) {}
SLATE_ARGUMENT(TArray<FName>, Packages)
SLATE_ARGUMENT(TSharedPtr<SWindow>, ParentWindow)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
ParentWindow = InArgs._ParentWindow;
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
AssetDeletionScan::FOptions ScanOptions;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(Registry, InArgs._Packages, ScanOptions, Stats);
for (const AssetDeletionScan::FCandidate& Candidate : Candidates)
{
FDeleteRowPtr NewRow = MakeShared<FDeleteRow>();
NewRow->Candidate = Candidate;
NewRow->bChecked = Candidate.CanDelete();
Rows.Add(NewRow);
}
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete scan: %s"), *Stats.Summarise());
BuildHeaderRow();
ChildSlot
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.GroupBorder"))
.Padding(12.0f)
[
SNew(SVerticalBox)
// --- The headline ------------------------------------------------------------
//
// Stated as a consequence rather than as a question. "Delete 12 assets?" invites a
// reflex click; naming what disappears does not.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 6.0f)
[
SNew(STextBlock)
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 13))
.ColorAndOpacity(FSlateColor(DangerColor))
.Text(FText::Format(
LOCTEXT("Headline", "{0} asset file(s) will be deleted from this project."),
FText::AsNumber(Stats.Deletable)))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.Text(LOCTEXT("Explanation",
"This removes the files from disk. It is not an export and there is no undo - what protects you is source control, so make sure this content is submitted or checked out before continuing.\n\n"
"Bear in mind what the audit cannot see: assets named from C++ or Blueprint strings, rows inside DataTables, and FMOD events are invisible to the Asset Registry. An asset with no referencers here is not proof that nothing uses it."))
]
// --- Reference warning -------------------------------------------------------
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SBorder)
.BorderImage(FAppStyle::GetBrush("ToolPanel.DarkGroupBorder"))
.Padding(8.0f)
.Visibility_Lambda([this]()
{
return Stats.WithOutsideReferencers > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(CautionColor))
.Text(FText::Format(
LOCTEXT("ReferencerWarning",
"{0} of them are still referenced by something outside this selection. Deleting those leaves the referencing assets pointing at nothing.\n"
"Unreal's own Delete Assets window opens next and lists every reference, where you can replace them with another asset or force the delete."),
FText::AsNumber(Stats.WithOutsideReferencers)))
]
]
// --- Refusals ----------------------------------------------------------------
//
// Shown, not hidden. The user ticked those rows; a delete that quietly acts on
// fewer assets than were ticked is how trust in a tool is lost.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(STextBlock)
.AutoWrapText(true)
.ColorAndOpacity(FSlateColor(MutedColor))
.Visibility_Lambda([this]()
{
return (Stats.Requested - Stats.Deletable) > 0 ? EVisibility::Visible : EVisibility::Collapsed;
})
.Text_Lambda([this]() { return GetRefusalText(); })
]
+ SVerticalBox::Slot()
.FillHeight(1.0f)
[
SNew(SBox)
.MinDesiredHeight(240.0f)
[
SAssignNew(ListView, SListView<FDeleteRowPtr>)
.ListItemsSource(&Rows)
.SelectionMode(ESelectionMode::None)
.HeaderRow(HeaderRow)
.OnGenerateRow_Lambda([this](FDeleteRowPtr Item, const TSharedRef<STableViewBase>& OwnerTable)
{
return SNew(SDeleteRowWidget, OwnerTable)
.Row(Item)
.OnCheckChanged(FSimpleDelegate::CreateLambda([this]() { Invalidate(EInvalidateWidgetReason::Paint); }));
})
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 8.0f)
[
SNew(SSeparator)
]
// --- Acknowledgement ---------------------------------------------------------
//
// A deliberate second action. The Delete button stays disabled until this is
// ticked, which costs one click and removes the "I meant to press Cancel" class
// of accident entirely.
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.0f, 0.0f, 0.0f, 8.0f)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return bAcknowledged ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { bAcknowledged = (NewState == ECheckBoxState::Checked); })
[
SNew(STextBlock)
.Margin(FMargin(4.0f, 0.0f, 0.0f, 0.0f))
.Text(LOCTEXT("Acknowledge", "I understand these files will be removed from the project."))
]
]
+ SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.ColorAndOpacity(FSlateColor(MutedColor))
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("Footer", "{0} ticked of {1} that can be deleted."),
FText::AsNumber(CountChecked()),
FText::AsNumber(Stats.Deletable));
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("Cancel", "Cancel"))
.OnClicked(this, &SAssetDeleteDialog::OnCancel)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(4.0f, 0.0f)
[
SNew(SButton)
.Text_Lambda([this]()
{
return FText::Format(
LOCTEXT("DeleteButton", "Delete {0} asset(s)"),
FText::AsNumber(CountChecked()));
})
.ToolTipText(LOCTEXT("DeleteButtonTip", "Hand the ticked assets to Unreal's delete, which will show its own reference window before anything is removed."))
.IsEnabled_Lambda([this]() { return bAcknowledged && CountChecked() > 0; })
.OnClicked(this, &SAssetDeleteDialog::OnDelete)
]
]
]
];
}
bool WasAccepted() const
{
return bAccepted;
}
/** Packages the user confirmed, in the order they were listed. */
TArray<FName> GetConfirmedPackages() const
{
TArray<FName> Result;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
Result.Add(Row->Candidate.PackageName);
}
}
return Result;
}
const AssetDeletionScan::FStats& GetStats() const
{
return Stats;
}
private:
void BuildHeaderRow()
{
HeaderRow = SNew(SHeaderRow)
+ SHeaderRow::Column(DeleteColumns::Check)
.DefaultLabel(FText::GetEmpty())
.FixedWidth(28.0f)
[
SNew(SBox)
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return GetHeaderCheckState(); })
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState) { SetAllChecked(NewState == ECheckBoxState::Checked); })
]
]
+ SHeaderRow::Column(DeleteColumns::Name)
.DefaultLabel(LOCTEXT("ColName", "Asset"))
.FillWidth(0.24f)
+ SHeaderRow::Column(DeleteColumns::Type)
.DefaultLabel(LOCTEXT("ColType", "Type"))
.FillWidth(0.13f)
+ SHeaderRow::Column(DeleteColumns::Path)
.DefaultLabel(LOCTEXT("ColPath", "Path"))
.FillWidth(0.33f)
+ SHeaderRow::Column(DeleteColumns::Referencers)
.DefaultLabel(LOCTEXT("ColReferencers", "Referenced by"))
.FillWidth(0.15f)
+ SHeaderRow::Column(DeleteColumns::Status)
.DefaultLabel(LOCTEXT("ColStatus", "Status"))
.FillWidth(0.15f);
}
ECheckBoxState GetHeaderCheckState() const
{
const int32 Checked = CountChecked();
if (Checked == 0)
{
return ECheckBoxState::Unchecked;
}
return Checked == Stats.Deletable ? ECheckBoxState::Checked : ECheckBoxState::Undetermined;
}
void SetAllChecked(bool bNewChecked)
{
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->Candidate.CanDelete())
{
Row->bChecked = bNewChecked;
}
}
if (ListView.IsValid())
{
ListView->RequestListRefresh();
}
}
int32 CountChecked() const
{
int32 Count = 0;
for (const FDeleteRowPtr& Row : Rows)
{
if (Row->bChecked && Row->Candidate.CanDelete())
{
++Count;
}
}
return Count;
}
FText GetRefusalText() const
{
TArray<FString> Parts;
if (Stats.RefusedLevels > 0)
{
Parts.Add(FString::Printf(TEXT("%d level(s)"), Stats.RefusedLevels));
}
if (Stats.RefusedExternal > 0)
{
Parts.Add(FString::Printf(TEXT("%d actor package(s)"), Stats.RefusedExternal));
}
if (Stats.RefusedNotProjectContent > 0)
{
Parts.Add(FString::Printf(TEXT("%d outside project content"), Stats.RefusedNotProjectContent));
}
if (Stats.RefusedMissing > 0)
{
Parts.Add(FString::Printf(TEXT("%d already gone"), Stats.RefusedMissing));
}
return FText::Format(
LOCTEXT("RefusalSummary", "Kept out of the delete: {0}. They are listed below with a reason, and cannot be ticked."),
FText::FromString(FString::Join(Parts, TEXT(", "))));
}
FReply OnDelete()
{
bAccepted = true;
CloseWindow();
return FReply::Handled();
}
FReply OnCancel()
{
bAccepted = false;
CloseWindow();
return FReply::Handled();
}
void CloseWindow()
{
if (ParentWindow.IsValid())
{
ParentWindow->RequestDestroyWindow();
}
}
TArray<FDeleteRowPtr> Rows;
AssetDeletionScan::FStats Stats;
bool bAcknowledged = false;
bool bAccepted = false;
TSharedPtr<SWindow> ParentWindow;
TSharedPtr<SListView<FDeleteRowPtr>> ListView;
TSharedPtr<SHeaderRow> HeaderRow;
};
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages)
{
FAssetDeleteOutcome Outcome;
Outcome.Requested = Packages.Num();
FAssetRegistryModule& RegistryModule =
FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry"));
IAssetRegistry& Registry = RegistryModule.Get();
// The engine refuses to delete while the registry is still discovering, and does so with a
// transient notification that is easy to miss. Better to wait here than to have the delete
// quietly do nothing.
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
TSharedRef<SWindow> Window = SNew(SWindow)
.Title(LOCTEXT("WindowTitle", "Delete ticked assets"))
.ClientSize(FVector2D(940.0f, 620.0f))
.SupportsMinimize(false)
.SupportsMaximize(false);
TSharedRef<SAssetDeleteDialog> Dialog = SNew(SAssetDeleteDialog)
.Packages(Packages)
.ParentWindow(Window);
Window->SetContent(Dialog);
FSlateApplication::Get().AddModalWindow(Window, FGlobalTabmanager::Get()->GetRootWindow());
Outcome.Refused = Dialog->GetStats().Requested - Dialog->GetStats().Deletable;
if (!Dialog->WasAccepted())
{
UE_LOG(LogAssetUsageAuditEditor, Log, TEXT("Delete cancelled; nothing was touched."));
return Outcome;
}
const TArray<FName> Confirmed = Dialog->GetConfirmedPackages();
Outcome.bCancelled = false;
Outcome.Attempted = Confirmed.Num();
if (Confirmed.Num() == 0)
{
return Outcome;
}
// Resolve to FAssetData at the last moment. The scan ran before the window opened and the
// user may have spent a while reading it; a stale FAssetData would ask the engine to delete
// something that has already moved.
TArray<FAssetData> ToDelete;
for (FName Package : Confirmed)
{
TArray<FAssetData> Assets;
Registry.GetAssetsByPackageName(Package, Assets, /*bIncludeOnlyOnDiskAssets=*/true);
ToDelete.Append(Assets);
}
if (ToDelete.Num() == 0)
{
UE_LOG(LogAssetUsageAuditEditor, Warning,
TEXT("Nothing was deleted: none of the %d confirmed packages still hold an asset."), Confirmed.Num());
return Outcome;
}
// The engine's own delete, with its confirmation left on. That window is where referencers are
// listed and where Force Delete and Replace References live - the behaviour the request asked
// to match, obtained by using it rather than by imitating it.
const int32 ObjectsDeleted = ObjectTools::DeleteAssets(ToDelete, /*bShowConfirmation=*/true);
// Count packages, not objects: the engine returns deleted UObjects, and asking the registry
// what actually disappeared is the only figure that matches the Content Browser.
for (FName Package : Confirmed)
{
TArray<FAssetData> Remaining;
Registry.GetAssetsByPackageName(Package, Remaining, /*bIncludeOnlyOnDiskAssets=*/true);
if (Remaining.Num() == 0)
{
Outcome.DeletedPackages.Add(Package);
}
}
UE_LOG(LogAssetUsageAuditEditor, Display,
TEXT("Delete finished: %d package(s) gone of %d confirmed (%d object(s) reported by the engine)."),
Outcome.DeletedPackages.Num(), Confirmed.Num(), ObjectsDeleted);
return Outcome;
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,46 @@
// NextGenium 2026. Asset Usage Audit.
#pragma once
#include "CoreMinimal.h"
/** What the delete window ended up doing. Everything the panel needs to report afterwards. */
struct FAssetDeleteOutcome
{
/** Packages handed to the window. */
int32 Requested = 0;
/** Refused by policy - levels, OFPA packages, engine content. Never reaches the engine. */
int32 Refused = 0;
/** Ticked and passed to ObjectTools. */
int32 Attempted = 0;
/**
* Packages the registry can no longer find afterwards.
*
* Measured rather than taken from the engine's return value: ObjectTools reports a count of
* deleted *objects*, and one package can hold several. Asking the registry what is actually
* gone is the only figure that matches what the user will see in the Content Browser.
*/
TArray<FName> DeletedPackages;
/** True when the user closed or cancelled the window; nothing was touched. */
bool bCancelled = true;
};
/**
* Show the delete window: what is about to be destroyed, what still points at it, and why some
* rows are refused.
*
* Modal, and deliberately heavier than the export window. Export is reversible by deleting the
* output folder; this is not, and the window's job is to be read rather than clicked through -
* hence the per-row referencer count, the explicit acknowledgement, and Cancel as the default.
*
* The window does not delete anything itself. Once confirmed it hands the assets to
* ObjectTools::DeleteAssets with the engine's own confirmation enabled, so the familiar Delete
* Assets dialog - referencer list, Force Delete, Replace References - appears exactly as it does
* from the Content Browser. Reimplementing that would mean reimplementing reference replacement,
* and getting it subtly wrong on someone's project.
*/
FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray<FName>& Packages);
@@ -12,6 +12,7 @@
#include "AssetUsageExporter.h" #include "AssetUsageExporter.h"
#include "AssetUsagePaths.h" #include "AssetUsagePaths.h"
#include "AssetUsageReportWriter.h" #include "AssetUsageReportWriter.h"
#include "SAssetDeleteDialog.h"
#include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h" #include "AssetRegistry/IAssetRegistry.h"
#include "Algo/AllOf.h" #include "Algo/AllOf.h"
@@ -580,6 +581,33 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs)
.OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked) .OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets) .IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
] ]
// Delete sits last and behind a separator, deliberately. It acts on the same ticked
// set as Export, so it belongs on this bar, but it is the only irreversible action in
// the tool and must never be the button someone reaches by muscle memory.
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(8.0f, 2.0f, 2.0f, 2.0f)
[
SNew(SSeparator)
.Orientation(Orient_Vertical)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(2.0f, 0.0f)
[
SNew(SButton)
.Text(LOCTEXT("DeleteTicked", "Delete ticked assets..."))
.ToolTipText(LOCTEXT("DeleteTickedTip",
"Permanently delete the ticked assets from the project. Levels are never deleted. "
"Shows what still references them before anything is removed."))
.ButtonColorAndOpacity(FLinearColor(0.55f, 0.16f, 0.16f))
.OnClicked(this, &SAssetUsageAuditPanel::OnDeleteClicked)
.IsEnabled(this, &SAssetUsageAuditPanel::HasCheckedAssets)
]
] ]
]; ];
} }
@@ -2224,6 +2252,83 @@ FReply SAssetUsageAuditPanel::OnExportFilesClicked()
return FReply::Handled(); return FReply::Handled();
} }
// --- Deleting ----------------------------------------------------------------------------------
FReply SAssetUsageAuditPanel::OnDeleteClicked()
{
TArray<FAssetUsageRow> Rows;
GetCheckedRows(Rows);
if (Rows.IsEmpty())
{
return FReply::Handled();
}
TArray<FName> Packages;
Packages.Reserve(Rows.Num());
for (const FAssetUsageRow& Row : Rows)
{
Packages.Add(Row.PackageName);
}
// Deliberately NOT widened with the dependency closure, unlike export. Export widens so that a
// mesh arrives with its materials; widening a delete the same way would destroy content nobody
// ticked, and a single shared texture would drag half the project along with it. Delete acts on
// exactly what was ticked, and never on more.
const FAssetDeleteOutcome Outcome = ShowAssetDeleteDialog(Packages);
if (Outcome.bCancelled)
{
StatusText = LOCTEXT("DeleteCancelled", "Delete cancelled - nothing was changed.");
return FReply::Handled();
}
ForgetDeletedPackages(Outcome.DeletedPackages);
StatusText = FText::Format(
LOCTEXT("DeleteFinished",
"Deleted {0} of {1} ticked asset(s); {2} were refused. Reference counts elsewhere in this result are now out of date - run the audit again."),
FText::AsNumber(Outcome.DeletedPackages.Num()),
FText::AsNumber(Outcome.Requested),
FText::AsNumber(Outcome.Refused));
return FReply::Handled();
}
void SAssetUsageAuditPanel::ForgetDeletedPackages(const TArray<FName>& DeletedPackages)
{
if (DeletedPackages.IsEmpty())
{
return;
}
const TSet<FName> Gone(DeletedPackages);
LastResult.Rows.RemoveAll([&Gone](const FAssetUsageRow& Row)
{
return Gone.Contains(Row.PackageName);
});
AllRows.RemoveAll([&Gone](const FAssetUsageRowPtr& Row)
{
return Row.IsValid() && Gone.Contains(Row->PackageName);
});
for (FName Package : DeletedPackages)
{
CheckedPackages.Remove(Package);
}
// Removing the rows is honest but incomplete: everything that referenced them still carries the
// reference counts it had before, and an asset that was UsedByAssetsOnly through a now-deleted
// Blueprint has a different verdict than the one on screen. Marking the result stale says so,
// rather than leaving figures that look freshly measured.
bResultStale = true;
RebuildFilteredRows();
}
void SAssetUsageAuditPanel::RunMigrateExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request) void SAssetUsageAuditPanel::RunMigrateExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request)
{ {
TArray<FName> Packages; TArray<FName> Packages;
@@ -278,6 +278,31 @@ private:
FReply OnExportFilesClicked(); FReply OnExportFilesClicked();
bool HasCheckedAssets() const; bool HasCheckedAssets() const;
// --- Deleting ----------------------------------------------------------------------------
/**
* Delete the ticked assets, after two confirmations.
*
* Sits beside Export because it acts on the same ticked set, and is the reason the audit is
* worth running: the point of finding unused content is to be able to remove it. It is kept
* visually apart and never becomes the default action.
*
* Levels are refused outright - see AssetDeletionScan::ERefusal::IsLevel. Everything else the
* window explains before anything is touched, and the destruction itself is ObjectTools' code,
* not ours.
*/
FReply OnDeleteClicked();
/**
* Drop deleted packages from the result without re-running the sweep.
*
* A full re-run after a delete would cost minutes on this project, and the rows that remain are
* still correct - what changed is that some assets are gone, plus the referencer counts of
* whatever pointed at them. The result is marked stale so the numbers are not mistaken for a
* fresh sweep.
*/
void ForgetDeletedPackages(const TArray<FName>& DeletedPackages);
/** Byte copy of the package files, expanding levels to their OFPA packages. */ /** Byte copy of the package files, expanding levels to their OFPA packages. */
void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request); void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
@@ -0,0 +1,449 @@
// NextGenium 2026. Asset Usage Audit.
#include "AssetDeletionScan.h"
#include "AssetRegistry/ARFilter.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "AssetUsageAuditTypes.h"
#include "AssetUsagePaths.h"
#include "Engine/World.h"
#include "HAL/FileManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/PackageName.h"
#if WITH_DEV_AUTOMATION_TESTS
/**
* The refusals, and the referencer arithmetic behind the warning.
*
* This is the only part of the tool that leads to something being destroyed, so the tests are
* written against the two claims the delete window makes to the user: "these will not be touched",
* and "this many things still point at that". Both are decided here; the window only renders them.
*
* Nothing in this spec deletes anything. AssetDeletionScan cannot - it holds no delete code at all,
* which is exactly why the analysis was split out of the Editor module.
*/
BEGIN_DEFINE_SPEC(AssetDeletionScanSpec,
"AssetUsageAudit.DeletionScan",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
static IAssetRegistry& ReadyRegistry()
{
IAssetRegistry& Registry = IAssetRegistry::GetChecked();
if (Registry.IsLoadingAssets())
{
Registry.WaitForCompletion();
}
return Registry;
}
/** First map in /Game, chosen deterministically so a failure is reproducible. */
static FName FindLevelPackage()
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
Filter.ClassPaths.Add(UWorld::StaticClass()->GetClassPathName());
TArray<FName> Levels;
Registry.EnumerateAssets(Filter, [&Levels](const FAssetData& AssetData)
{
Levels.Add(AssetData.PackageName);
return Levels.Num() < 512;
});
if (Levels.IsEmpty())
{
return NAME_None;
}
Levels.Sort(FNameLexicalLess());
return Levels[0];
}
/** First One File Per Actor package, if this project has any. */
static FName FindExternalPackage()
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game/__ExternalActors__")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Found;
Registry.EnumerateAssets(Filter, [&Found](const FAssetData& AssetData)
{
Found.Add(AssetData.PackageName);
return false;
});
return Found.IsEmpty() ? NAME_None : Found[0];
}
/**
* An ordinary asset together with one package that references it.
*
* Both halves are needed to prove the delete-set exclusion: the referencer has to be a real one
* for "referenced from outside" and "referenced from inside the set" to be the same edge seen
* twice, which is the whole point of the check.
*/
static FName FindReferencedAsset(FName& OutReferencer)
{
IAssetRegistry& Registry = ReadyRegistry();
FARFilter Filter;
Filter.PackagePaths.Add(FName(TEXT("/Game")));
Filter.bRecursivePaths = true;
Filter.bIncludeOnlyOnDiskAssets = true;
TArray<FName> Candidates;
Registry.EnumerateAssets(Filter, [&Candidates](const FAssetData& AssetData)
{
if (AssetData.IsRedirector() || AssetData.AssetClassPath == UWorld::StaticClass()->GetClassPathName())
{
return true;
}
if (AssetUsagePaths::IsExternalPackage(AssetData.PackageName))
{
return true;
}
Candidates.Add(AssetData.PackageName);
return Candidates.Num() < 4096;
});
Candidates.Sort(FNameLexicalLess());
TArray<FName> Referencers;
for (FName Candidate : Candidates)
{
Referencers.Reset();
Registry.GetReferencers(Candidate, Referencers,
AssetUsageAudit::MakeTraversalCategory(), AssetUsageAudit::MakeTraversalQuery());
for (FName Referencer : Referencers)
{
if (Referencer != Candidate)
{
OutReferencer = Referencer;
return Candidate;
}
}
}
return NAME_None;
}
END_DEFINE_SPEC(AssetDeletionScanSpec)
void AssetDeletionScanSpec::Define()
{
Describe("Refusing what must not be deleted", [this]()
{
It("refuses a map", [this]()
{
const FName Level = FindLevelPackage();
if (Level.IsNone())
{
AddWarning(TEXT("This project has no maps; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Level }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
// The user's rule, and the one refusal that is policy rather than capability: the engine
// would happily delete an unopened map.
TestEqual(TEXT("refused as a level"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsLevel);
TestFalse(TEXT("not deletable"), Candidates[0].CanDelete());
TestEqual(TEXT("counted as a refused level"), Stats.RefusedLevels, 1);
TestEqual(TEXT("nothing deletable"), Stats.Deletable, 0);
});
It("refuses a One File Per Actor package", [this]()
{
const FName External = FindExternalPackage();
if (External.IsNone())
{
AddWarning(TEXT("This project has no external actor packages; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { External }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
// Deleting one of these is removing a placed actor from a map behind the level editor's
// back - no undo, and the map keeps a reference to a package that no longer exists.
TestEqual(TEXT("refused as external"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::IsExternalPackage);
TestEqual(TEXT("counted"), Stats.RefusedExternal, 1);
});
It("refuses engine content", [this]()
{
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(),
{ FName(TEXT("/Engine/BasicShapes/Cube")) },
AssetDeletionScan::FOptions(),
Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
TestEqual(TEXT("refused as engine content"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::NotProjectContent);
TestEqual(TEXT("counted"), Stats.RefusedNotProjectContent, 1);
});
It("refuses a package the registry does not know", [this]()
{
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(),
{ FName(TEXT("/Game/AssetUsageAuditNoSuchPackage")) },
AssetDeletionScan::FOptions(),
Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected exactly one candidate"));
return;
}
TestEqual(TEXT("reported as missing"),
Candidates[0].Refusal, AssetDeletionScan::ERefusal::Missing);
TestEqual(TEXT("counted"), Stats.RefusedMissing, 1);
});
It("keeps refused rows in the result rather than dropping them", [this]()
{
const FName Level = FindLevelPackage();
if (Level.IsNone())
{
AddWarning(TEXT("This project has no maps; skipping."));
return;
}
const FName Missing(TEXT("/Game/AssetUsageAuditNoSuchPackage"));
const FName Engine(TEXT("/Engine/BasicShapes/Cube"));
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates = AssetDeletionScan::Scan(
ReadyRegistry(), { Level, Missing, Engine }, AssetDeletionScan::FOptions(), Stats);
// A refusal the user cannot see is indistinguishable from the tool losing their
// selection, so every ticked package must come back with a reason attached.
TestEqual(TEXT("all three returned"), Candidates.Num(), 3);
TestEqual(TEXT("in the original order"), Candidates[0].PackageName, Level);
TestEqual(TEXT("in the original order"), Candidates[1].PackageName, Missing);
TestEqual(TEXT("in the original order"), Candidates[2].PackageName, Engine);
TestEqual(TEXT("requested count matches"), Stats.Requested, 3);
TestEqual(TEXT("none deletable"), Stats.Deletable, 0);
});
});
Describe("Counting what still points at the asset", [this]()
{
It("lists a referencer that is not itself being deleted", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1 || !Candidates[0].CanDelete())
{
AddError(TEXT("expected one deletable candidate"));
return;
}
TestTrue(TEXT("at least one outside referencer"), Candidates[0].OutsideReferencerCount > 0);
TestTrue(TEXT("the known referencer is listed"),
Candidates[0].OutsideReferencers.Contains(Referencer)
|| Candidates[0].OutsideReferencerCount > Candidates[0].OutsideReferencers.Num());
TestEqual(TEXT("counted in the stats"), Stats.WithOutsideReferencers, 1);
});
It("does not count a referencer that is in the delete set", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone() || Referencer.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Before;
const TArray<AssetDeletionScan::FCandidate> Alone =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Before);
AssetDeletionScan::FStats After;
const TArray<AssetDeletionScan::FCandidate> Together =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset, Referencer }, AssetDeletionScan::FOptions(), After);
const AssetDeletionScan::FCandidate* Widened =
Together.FindByPredicate([Asset](const AssetDeletionScan::FCandidate& C) { return C.PackageName == Asset; });
if (Alone.Num() != 1 || !Widened)
{
AddError(TEXT("expected the asset in both scans"));
return;
}
// Deleting a Blueprint together with the mesh only it uses is a clean operation. If the
// Blueprint were still counted, every sensible multi-asset delete would look dangerous
// and the warning would stop meaning anything.
TestTrue(TEXT("the count dropped once its referencer joined the set"),
Widened->OutsideReferencerCount < Alone[0].OutsideReferencerCount);
TestFalse(TEXT("the referencer is no longer listed"),
Widened->OutsideReferencers.Contains(Referencer));
});
It("truncates the listed names but not the count", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No referenced asset was found; skipping."));
return;
}
AssetDeletionScan::FOptions Options;
Options.MaxReferencersListed = 1;
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, Options, Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected one candidate"));
return;
}
TestTrue(TEXT("at most one name kept"), Candidates[0].OutsideReferencers.Num() <= 1);
TestTrue(TEXT("the count is not truncated with it"),
Candidates[0].OutsideReferencerCount >= Candidates[0].OutsideReferencers.Num());
});
});
Describe("Describing a deletable asset", [this]()
{
It("reports the read-only flag the way the filesystem does", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1 || !Candidates[0].CanDelete())
{
AddError(TEXT("expected one deletable candidate"));
return;
}
FString Filename;
if (!FPackageName::DoesPackageExist(Asset.ToString(), &Filename))
{
AddError(TEXT("the package has no file on disk"));
return;
}
// This project is Perforce-primary, so read-only is the normal state of an unopened
// file. Reporting it up front is the difference between a warning and a delete that
// fails halfway through the selection.
TestEqual(TEXT("matches the filesystem"),
Candidates[0].bReadOnlyOnDisk, IFileManager::Get().IsReadOnly(*Filename));
TestEqual(TEXT("read-only count agrees"),
Stats.ReadOnly, Candidates[0].bReadOnlyOnDisk ? 1 : 0);
AddInfo(FString::Printf(TEXT("%s: %d outside referencer(s), %d from levels, read-only %s."),
*Asset.ToString(),
Candidates[0].OutsideReferencerCount,
Candidates[0].LevelReferencerCount,
Candidates[0].bReadOnlyOnDisk ? TEXT("yes") : TEXT("no")));
});
It("fills in the name, class and project-relative path", [this]()
{
FName Referencer = NAME_None;
const FName Asset = FindReferencedAsset(Referencer);
if (Asset.IsNone())
{
AddWarning(TEXT("No suitable asset was found; skipping."));
return;
}
AssetDeletionScan::FStats Stats;
const TArray<AssetDeletionScan::FCandidate> Candidates =
AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Stats);
if (Candidates.Num() != 1)
{
AddError(TEXT("expected one candidate"));
return;
}
// The window shows these three and nothing else per row, so an empty one is a blank
// line in front of someone about to press Delete.
TestFalse(TEXT("name filled"), Candidates[0].AssetName.IsNone());
TestTrue(TEXT("class filled"), Candidates[0].ClassPath.IsValid());
TestTrue(TEXT("path starts at Content/"),
Candidates[0].PathFromProjectRoot.StartsWith(TEXT("Content/")));
});
});
}
#endif // WITH_DEV_AUTOMATION_TESTS