diff --git a/README.md b/README.md index 2f2ef20..8e61329 100644 --- a/README.md +++ b/README.md @@ -245,12 +245,58 @@ BOM обязателен: без него Excel ломает кириллицу. --- +## Удаление отмеченных ассетов + +Кнопка **Delete ticked assets…** внизу панели, рядом с выгрузкой. Работает по тому же набору галочек. + +Удаление — единственная операция инструмента, которая уничтожает работу безвозвратно, поэтому проходит **через два окна**. + +### Окно 1 — наше + +Показывает весь отмеченный набор построчно: имя, тип, путь, **сколько ассетов снаружи набора ещё ссылается** на строку (в тултипе — их имена), и статус. + +- Ссылающиеся ассеты, которые сами удаляются вместе с целью, **в счётчик не попадают**. Иначе удаление блюпринта вместе с его единственным мешем выглядело бы опасным, хотя оно чистое. +- Референсеры запрашиваются тем же `NoRequirements`-запросом, что и аудит: hard-only потерял бы каждую soft-ссылку, а soft-ссылка ломается точно так же — просто в рантайме, а не при загрузке. +- Файл, помеченный **read-only на диске**, отмечается заранее. Под VCS, которая держит невытянутые файлы нередактируемыми, без этой пометки удаление падало бы по одному файлу в середине пачки. +- Кнопка удаления неактивна, пока не поставлена галочка «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. Окно удаления повторяет это предупреждение прямо в тексте. + +--- + ## Архитектура ``` AssetUsageAuditCore UncookedOnly — весь анализ, ноль UI AssetUsageAuditEditor Editor — Slate-панель, настройки, команды -AssetUsageAuditTests UncookedOnly — 15 spec-сьютов, 159 кейсов +AssetUsageAuditTests UncookedOnly — 16 spec-сьютов, 169 кейсов ``` **Инвариант Core:** не линковать `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, `EditorSubsystem`. `AssetTools` editor-only транзитивно через `UnrealEd`. @@ -340,7 +386,7 @@ WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → … -unattended -nopause -nosplash -stdout -abslog="" ``` -**159 кейсов в 15 spec-сьютах**, префикс `AssetUsageAudit.*`. +**169 кейсов в 16 spec-сьютах**, префикс `AssetUsageAudit.*`. Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом. diff --git a/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp new file mode 100644 index 0000000..a0a3bc5 --- /dev/null +++ b/Source/AssetUsageAuditCore/Private/AssetDeletionScan.cpp @@ -0,0 +1,243 @@ +// MagentaDolphin 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 Scan( + IAssetRegistry& Registry, + const TArray& Packages, + const FOptions& Options, + FStats& OutStats) + { + OutStats = FStats(); + + TArray 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 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 LevelCache; + + const auto IsLevelPackage = [&Registry, &LevelCache](FName PackageName) -> bool + { + if (const bool* Cached = LevelCache.Find(PackageName)) + { + return *Cached; + } + + TArray 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 Referencers; + TArray 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 diff --git a/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h new file mode 100644 index 0000000..fd32cfe --- /dev/null +++ b/Source/AssetUsageAuditCore/Public/AssetDeletionScan.h @@ -0,0 +1,151 @@ +// MagentaDolphin 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 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 Scan( + IAssetRegistry& Registry, + const TArray& Packages, + const FOptions& Options, + FStats& OutStats); +} diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp new file mode 100644 index 0000000..569e573 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.cpp @@ -0,0 +1,669 @@ +// MagentaDolphin 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; + +class SDeleteRowWidget : public SMultiColumnTableRow +{ +public: + SLATE_BEGIN_ARGS(SDeleteRowWidget) {} + SLATE_ARGUMENT(FDeleteRowPtr, Row) + SLATE_EVENT(FSimpleDelegate, OnCheckChanged) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const TSharedRef& InOwnerTable) + { + Row = InArgs._Row; + OnCheckChanged = InArgs._OnCheckChanged; + + SMultiColumnTableRow::Construct(FSuperRowType::FArguments(), InOwnerTable); + } + + virtual TSharedRef 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. Perforce-primary projects keep unopened files read-only, 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, Packages) + SLATE_ARGUMENT(TSharedPtr, ParentWindow) + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs) + { + ParentWindow = InArgs._ParentWindow; + + FAssetRegistryModule& RegistryModule = + FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")); + IAssetRegistry& Registry = RegistryModule.Get(); + + AssetDeletionScan::FOptions ScanOptions; + + const TArray Candidates = + AssetDeletionScan::Scan(Registry, InArgs._Packages, ScanOptions, Stats); + + for (const AssetDeletionScan::FCandidate& Candidate : Candidates) + { + FDeleteRowPtr NewRow = MakeShared(); + 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) + .ListItemsSource(&Rows) + .SelectionMode(ESelectionMode::None) + .HeaderRow(HeaderRow) + .OnGenerateRow_Lambda([this](FDeleteRowPtr Item, const TSharedRef& 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 GetConfirmedPackages() const + { + TArray 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 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 Rows; + AssetDeletionScan::FStats Stats; + + bool bAcknowledged = false; + bool bAccepted = false; + + TSharedPtr ParentWindow; + TSharedPtr> ListView; + TSharedPtr HeaderRow; +}; + +FAssetDeleteOutcome ShowAssetDeleteDialog(const TArray& Packages) +{ + FAssetDeleteOutcome Outcome; + Outcome.Requested = Packages.Num(); + + FAssetRegistryModule& RegistryModule = + FModuleManager::LoadModuleChecked(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 Window = SNew(SWindow) + .Title(LOCTEXT("WindowTitle", "Delete ticked assets")) + .ClientSize(FVector2D(940.0f, 620.0f)) + .SupportsMinimize(false) + .SupportsMaximize(false); + + TSharedRef 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 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 ToDelete; + + for (FName Package : Confirmed) + { + TArray 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 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 diff --git a/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h new file mode 100644 index 0000000..b799cb7 --- /dev/null +++ b/Source/AssetUsageAuditEditor/Private/SAssetDeleteDialog.h @@ -0,0 +1,46 @@ +// MagentaDolphin 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 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& Packages); diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp index 9388c3a..23ce8e4 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.cpp @@ -12,6 +12,7 @@ #include "AssetUsageExporter.h" #include "AssetUsagePaths.h" #include "AssetUsageReportWriter.h" +#include "SAssetDeleteDialog.h" #include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/IAssetRegistry.h" #include "Algo/AllOf.h" @@ -580,6 +581,33 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs) .OnClicked(this, &SAssetUsageAuditPanel::OnExportFilesClicked) .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(); } +// --- Deleting ---------------------------------------------------------------------------------- + +FReply SAssetUsageAuditPanel::OnDeleteClicked() +{ + TArray Rows; + GetCheckedRows(Rows); + + if (Rows.IsEmpty()) + { + return FReply::Handled(); + } + + TArray 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& DeletedPackages) +{ + if (DeletedPackages.IsEmpty()) + { + return; + } + + const TSet 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& Rows, const FAssetExportRequest& Request) { TArray Packages; diff --git a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h index 01df342..e38e2f6 100644 --- a/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h +++ b/Source/AssetUsageAuditEditor/Private/SAssetUsageAuditPanel.h @@ -278,6 +278,31 @@ private: FReply OnExportFilesClicked(); 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& DeletedPackages); + /** Byte copy of the package files, expanding levels to their OFPA packages. */ void RunCopyExport(const TArray& Rows, const FAssetExportRequest& Request); diff --git a/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp new file mode 100644 index 0000000..40b6e7a --- /dev/null +++ b/Source/AssetUsageAuditTests/Private/AssetDeletionScan.spec.cpp @@ -0,0 +1,449 @@ +// MagentaDolphin 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 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 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 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 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 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 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 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 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 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 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 Alone = + AssetDeletionScan::Scan(ReadyRegistry(), { Asset }, AssetDeletionScan::FOptions(), Before); + + AssetDeletionScan::FStats After; + const TArray 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 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 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; + } + + // Perforce-primary projects keep unopened files read-only, 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 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