Compare commits

...

9 Commits

Author SHA1 Message Date
MagentaDolphin d4161cdc77 fix: asset paths cannot contain spaces in test fixture
The anonymisation pass replaced a pack name with a phrase containing
spaces, and the scanner splits package paths on whitespace, so the
expectation became /Game/a. Uses a single-token folder name instead.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 18:57:14 +07:00
MagentaDolphin 63c4054aef docs: correct test counts (169 cases in 16 spec suites)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 18:47:20 +07:00
MagentaDolphin a0a061656e docs: anonymise remaining project names in test fixtures
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 18:41:51 +07:00
MagentaDolphin 640e940ab4 docs: anonymise project asset names in source comments
Measured figures are kept; only the studio-specific level and asset
names are generalised, and the Perforce rationale is reworded to
describe the class of setups rather than this one project.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 18:39:39 +07:00
MagentaDolphin e92306c797 docs: anonymise project asset names in examples
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 17:44:44 +07:00
MagentaDolphin 0363b07b65 docs: remove studio-specific paths and references from README
Generalises the settings path, build command, console example and the
read-only rationale; drops internal knowledge-base links and team roles.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 17:44:01 +07:00
MagentaDolphin f3c82d3815 fix: correct authorship attribution in deletion feature files
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-07 17:35:50 +07:00
MagentaDolphin 430fc18d5f Merge branch 'master' into wip/asset-deletion 2026-09-07 17:35:49 +07:00
MagentaDolphin ee1c8bad3e 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>
2026-09-07 17:14:39 +07:00
27 changed files with 1822 additions and 99 deletions
+68 -33
View File
@@ -40,7 +40,7 @@
**Route** — цепочка, объясняющая вердикт:
```
WP_Main → BP_Pickup_Child_C_UAID_...1381106233 → BP_Pickup_Master → SM_ReaperStation
WP_Example → BP_Child_C_UAID_... → BP_Master → SM_Station
```
Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда.
@@ -71,13 +71,13 @@ Asset Registry принципиально не видит:
**Инструмент никогда не удаляет и не предлагает удалить.** Только отбор и выгрузка.
Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него `BP_FirstPersonGameMode`, `BP_MenuSystemGameInstance` и `RefinedMenuMap` помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`.
Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него GameMode, GameInstance и стартовая карта помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`.
---
## Настройки
`Project Settings → 14Overmind → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл Perforce-трекается, настройки видны команде.
`Project Settings → <Проект> → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл под контролем версий, настройки общие для команды.
Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится **отдельно** — в `Saved/Config/.../EditorPerProjectUserSettings.ini`, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной.
@@ -92,7 +92,7 @@ Asset Registry принципиально не видит:
| `bMirrorFolderStructure` | `false` → плоская папка |
| `ExchangeFormatByClass` | пусто → встроенные умолчания |
⚠️ `Content/MenuSystemPro` **намеренно не исключён**, хотя это покупной пак: он поставляет `BP_MenuSystemGameInstance`, который `DefaultEngine.ini` назначает GameInstance проекта.
⚠️ Покупной пак **не следует исключать вслепую**: он может поставлять класс, который `DefaultEngine.ini` назначает GameMode или GameInstance проекта. Проверяйте `ExcludedPackagePaths` против конфига.
---
@@ -100,7 +100,7 @@ Asset Registry принципиально не видит:
```
AssetUsageAudit.OpenPanel
AssetUsageAudit.Run [/Game/Space/Maps/WP_Main ...]
AssetUsageAudit.Run [/Game/Maps/YourLevel ...]
AssetUsageAudit.FindUnused
```
@@ -196,7 +196,7 @@ BOM обязателен: без него Excel ломает кириллицу.
Побайтовое копирование, ничего не загружается. Уровень тянет за собой свои OFPA-пакеты — без них выгруженная карта откроется пустой.
⚠️ **С источника снимается атрибут read-only.** Проект Perforce-primary, неоткрытые файлы на диске read-only, а Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования.
⚠️ **С источника снимается атрибут read-only.** В проектах под VCS, которая держит невытянутые файлы read-only (Perforce и подобные), Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования.
### Конвертация в обменные форматы
@@ -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 — 128 спек
AssetUsageAuditTests UncookedOnly — 16 spec-сьютов, 169 кейсов
```
**Инвариант Core:** не линковать `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, `EditorSubsystem`. `AssetTools` editor-only транзитивно через `UnrealEd`.
@@ -282,10 +328,10 @@ Hard/soft — это **колонка в отчёте**, а не фильтр з
### 2. Границу карты пересекать только от уровня или его внешнего пакета
Первая версия проваливалась в любой встреченный World. Замер на `WP_Main`: **18 136** строк, из них **9 994** приходили через чужую карту:
Первая версия проваливалась в любой встреченный World. Замер на `WP_Example`: **18 136** строк, из них **9 994** приходили через чужую карту:
```
WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel → …
WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → …
```
Две трети ответа были содержимым другого уровня. После исправления — **8 644**.
@@ -327,9 +373,9 @@ WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel →
⚠️ Закрыть редактор — иначе линковка DLL упадёт.
```bash
"C:/Program Files/Epic Games/UE_5.6/Engine/Build/BatchFiles/Build.bat" \
SpaceEditor Win64 Development \
-project="D:\Work\NG\ng_MagentaDolphin_space\overmind\14Overmind.uproject" -waitmutex
"<UE>/Engine/Build/BatchFiles/Build.bat" \
<Project>Editor Win64 Development \
-project="<abs path>/<Project>.uproject" -waitmutex
```
```bash
@@ -340,9 +386,9 @@ WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel →
-unattended -nopause -nosplash -stdout -abslog="<abs path>"
```
**128 спек**, префикс `AssetUsageAudit.*`.
**169 кейсов в 16 spec-сьютах**, префикс `AssetUsageAudit.*`.
Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Main` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом.
Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом.
### `GraphFidelity` — замена ручной сверке с Reference Viewer
@@ -379,7 +425,7 @@ Reference Viewer сам по себе не источник истины: он
| Уровней | 1072 |
| OFPA-пакетов | 16 117 |
| Полный свип | ~1.1 с |
| `WP_Main` | ~1.6 с, 8 644 ассета |
| `WP_Example` | ~1.6 с, 8 644 ассета |
| Все уровни | 33 261 `UsedOnLevel` |
| Отчёт | 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ |
@@ -389,37 +435,26 @@ Reference Viewer сам по себе не источник истины: он
## Незакрытое
- **UI после переделки в дерево руками не проверялся.** Компилируется, 121 автотест зелёный, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
- **UI после переделки в дерево руками не проверялся.** Компилируется, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
- **Коммандлет не написан.** Упирается в дефект headless выше.
- **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено.
- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но LA/3D нужно об этом сказать явно.
- `BP_FirstPersonGameMode` даёт 29% строк `WP_Main` — нужен ли фильтр «исключить достижимое только через GameMode», решать LA/3D.
- FMOD в `Unknown` — скрывать по умолчанию или помечать, решать аудио-отделу.
- Плагин лежит в git-части репозитория (`Plugins/**` вайтлистится в `.gitignore` и исключается в `.p4ignore`). Вынос в сабмодуль — после согласования.
- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно.
- GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту.
- Ассеты сторонних аудио-плагинов попадают в `Unknown` — скрывать по умолчанию или помечать, решать по месту.
---
## Контроль версий
Плагин — **самостоятельный git-репозиторий** внутри `Plugins/`, как остальные 26 плагинов проекта. Удалённого сервера пока нет: репозиторий и выделенный remote ещё не согласованы.
Плагин — **самостоятельный git-репозиторий**. Артефакты сборки (`Binaries/`, `Intermediate/`) исключены локальным `.gitignore`: они пересобираются UBT из исходников и в коммит попадать не должны.
Из этого следуют две вещи:
- в `.gitmodules` записи **нет** и быть не должно, пока нет URL. Запись без рабочего адреса уронит `git submodule update --init` в `SetupProject.bat` у всех, кто дёрнет проект;
- родительский репозиторий видит папку как `?? Plugins/AssetUsageAudit/`. Так и задумано: коммитить её содержимое в родителя нельзя, иначе при появлении сабмодуля будут две копии одних файлов.
**История живёт только на машине разработчика.** Как только появится remote:
При подключении к проекту как сабмодуль:
```bash
git -C Plugins/AssetUsageAudit remote add origin <url>
git -C Plugins/AssetUsageAudit push -u origin master
git submodule add <url> Plugins/AssetUsageAudit
```
Артефакты сборки (`Binaries/`, `Intermediate/` — 231 МБ) исключены локальным `.gitignore`. В Perforce плагин не попадает: `Plugins/**` исключён в `.p4ignore`.
## Связанное
- `.docs/architecture/asset-usage-audit.md` — заметка в базе знаний
- `.docs/guides/build-and-run.md` — сборка и прогон тестов
- `Plugins/AssetsCleaner` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням**
- `Plugins/AssetsCleaner` — ищет неиспользуемые ассеты, но **не умеет привязку к уровням**
@@ -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<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
@@ -252,8 +252,8 @@ namespace AssetDependencyClosure
// comes from a level or from one of its external actor packages - the shape a
// streaming sublevel or a Level Instance actually has. A World named by an ordinary
// asset is a different location that happens to be mentioned, and following it
// exports someone else's map: measured on WP_Main, the chain
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel accounts for 9994
// exports someone else's map: measured on WP_Example, the chain
// BP_GameMode -> PDA_MenuConfig -> L_Other accounts for 9994
// packages. FLevelUsageResolver applies the same rule; the two must not disagree
// about the same graph.
if (IsLevelPackage(Dependency))
@@ -170,7 +170,7 @@ namespace AssetImportScanner
/**
* Package path implied by a file's position under the import root.
*
* Correct for a MirrorTree export, which writes Game/Space/Art/SM_Rock.uasset, and a guess
* Correct for a MirrorTree export, which writes Game/Art/SM_Rock.uasset, and a guess
* for anything else. Returns None when the result would not be a valid package name.
*/
FName PackageFromFolderStructure(const FString& RelativePath)
@@ -146,7 +146,7 @@ namespace AssetImporter
continue;
}
// This project is Perforce-primary, so an existing target is very likely read-only.
// Perforce-primary projects keep unopened files read-only, so an existing target is very likely read-only.
// Without clearing the flag the copy fails with an error that reads like a permissions
// problem rather than "the file is not checked out".
if (bTargetOnDisk && FileManager.IsReadOnly(*DestinationPath))
@@ -536,7 +536,7 @@ FAssetUsageExporter::FResult FAssetUsageExporter::CopyExpandedPackages(const TAr
const bool bWasPresent = FileManager.FileExists(*DestinationPath);
// This repository is Perforce-primary, so the source .uasset is very often read-only on
// Perforce-primary repositories keep unopened files read-only, so the source .uasset is very often read-only on
// disk. Windows CopyFile carries that attribute across, which would make the export folder
// read-only too - and then an Overwrite-policy re-export fails on its own previous output.
// Clear the flag on both ends of the problem: before writing over an existing file, and
@@ -243,8 +243,8 @@ void FLevelUsageResolver::TraverseLevel(int32 LevelIndex, const FLevelUsageResol
}
// Reaching another World is NOT automatically a sublevel. An earlier version assumed it
// was, and on WP_Main that assumption pulled the whole of L_MainLevel in through
// BP_FirstPersonGameMode -> PDA_MenuSystemConfig: 9994 of 18136 rows belonged to a
// was, and on WP_Example that assumption pulled the whole of L_Other in through
// BP_GameMode -> PDA_MenuConfig: 9994 of 18136 rows belonged to a
// different map. A crossing only counts when the reference comes from the level package
// itself or from one of its external actor packages, which is the shape a streaming
// sublevel or a Level Instance actually has.
@@ -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<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);
}
@@ -73,8 +73,8 @@ namespace AssetDependencyClosure
* Maps reached through an ordinary asset rather than through a level, and therefore not
* followed - nor included.
*
* The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Main
* the chain BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in 9994
* The same rule FLevelUsageResolver applies, and for the same measured reason: on WP_Example
* the chain BP_GameMode -> PDA_MenuConfig -> L_Other drags in 9994
* packages belonging to a different map. Without this the export dialog would quote an
* honest number for a wrong set.
*
@@ -139,7 +139,7 @@ enum class EExportCollisionPolicy : uint8
*/
struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
{
/** Package name, e.g. /Game/Space/Art/SM_Rock. */
/** Package name, e.g. /Game/Art/SM_Rock. */
FName PackageName;
/** Asset name without path. */
@@ -154,7 +154,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
*/
FTopLevelAssetPath GeneratedClassPath;
/** Path relative to the project root, e.g. Content/Space/Art/SM_Rock.uasset. */
/** Path relative to the project root, e.g. Content/Art/SM_Rock.uasset. */
FString PathFromProjectRoot;
EAssetUsageVerdict Verdict = EAssetUsageVerdict::Unknown;
@@ -172,7 +172,7 @@ struct ASSETUSAGEAUDITCORE_API FAssetUsageRow
/**
* Human-readable chain explaining the verdict, e.g.
* "WP_Main -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* "WP_Example -> __ExternalActors__/.../A2B -> BP_Rock -> SM_Rock".
* A verdict without a route is unactionable for an artist.
*/
FString Route;
@@ -51,7 +51,7 @@ namespace AssetUsagePaths
/**
* Package name to a path relative to the project root, as the report requires.
* "/Game/Space/Art/SM_Rock" -> "Content/Space/Art/SM_Rock.uasset"
* "/Game/Art/SM_Rock" -> "Content/Art/SM_Rock.uasset"
*
* @param bIsLevel Chooses the .umap extension over .uasset.
* @return Empty for packages with no project-relative form, e.g. /Script or /Engine.
@@ -9,7 +9,7 @@
*/
struct FIndirectReference
{
/** Package name the literal resolved to, e.g. /Game/Space/Core/GameModes/BP_FirstPersonGameMode. */
/** Package name the literal resolved to, e.g. /Game/Core/GameModes/BP_GameMode. */
FName PackageName;
/** Absolute path of the file the literal was found in. */
@@ -28,7 +28,7 @@ struct FIndirectReference
* Finds asset paths referenced from text rather than from a package.
*
* This exists because of a concrete, measured failure mode on this project: Config/DefaultEngine.ini
* names BP_FirstPersonGameMode, BP_MenuSystemGameInstance, RefinedMenuMap and Gyms_Geoda as plain
* names BP_GameMode, BP_MenuGameInstance, RefinedMenuMap and Gyms_Geoda as plain
* strings. No asset references them, so the Asset Registry reports zero referencers and a naive
* audit calls the project's GameMode unused.
*
@@ -37,8 +37,8 @@ struct FLevelUsageResolveOptions
*
* Off by default, and that default is load-bearing. A map does not only reference its own
* sublevels: anything it can reach may name an unrelated map, and on this project it does.
* WP_Main -> BP_FirstPersonGameMode -> PDA_MenuSystemConfig -> L_MainLevel drags in the whole
* of L_MainLevel, which measured at 9994 of WP_Main's 18136 rows - two thirds of the answer
* WP_Example -> BP_GameMode -> PDA_MenuConfig -> L_Other drags in the whole
* of L_Other, which measured at 9994 of WP_Example's 18136 rows - two thirds of the answer
* was another level's content.
*
* With this off, a foreign map is still reported as referenced, but its contents are attributed
@@ -72,7 +72,7 @@ struct FLevelUsageStats
* Foreign maps referenced but deliberately not expanded into.
*
* A non-zero value here is the amount of another level's content that would otherwise have
* been attributed to this one. Worth surfacing: on WP_Main it was two thirds of the report.
* been attributed to this one. Worth surfacing: on WP_Example it was two thirds of the report.
*/
int32 ForeignLevelsNotExpanded = 0;
double ScanLevelAssetsSeconds = 0.0;
@@ -11,8 +11,8 @@ UAssetUsageAuditSettings::UAssetUsageAuditSettings()
// third-party, so leaving these in makes the first run unreadable for an artist looking for
// their own content.
//
// Deliberately NOT excluded, despite being a bought pack: Content/MenuSystemPro. It supplies
// BP_MenuSystemGameInstance, which DefaultEngine.ini names as the project's GameInstance.
// Deliberately NOT excluded, despite being a bought pack: Content/a purchased UI pack. It supplies
// BP_MenuGameInstance, which DefaultEngine.ini names as the project's GameInstance.
// Excluding it would hide a load-bearing asset from every report.
ExcludedPackagePaths = {
TEXT("Content/3rdParty"),
@@ -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<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. 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<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 @@
// 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<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 "AssetUsagePaths.h"
#include "AssetUsageReportWriter.h"
#include "SAssetDeleteDialog.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Algo/AllOf.h"
@@ -374,7 +375,7 @@ void SAssetUsageAuditPanel::Construct(const FArguments& InArgs)
}
// Name the level when there is one, count them when there are several:
// a button reading "WP_Main" is worth more than one reading "1 level".
// a button reading "WP_Example" is worth more than one reading "1 level".
if (LevelFilters.Num() == 1)
{
return FText::FromString(FPackageName::GetShortName(*LevelFilters.CreateConstIterator()));
@@ -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<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)
{
TArray<FName> Packages;
@@ -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<FName>& DeletedPackages);
/** Byte copy of the package files, expanding levels to their OFPA packages. */
void RunCopyExport(const TArray<FAssetUsageRow>& Rows, const FAssetExportRequest& Request);
@@ -25,7 +25,7 @@ public:
/**
* Run a full audit and write JSON + CSV reports.
*
* @param LevelPackageNames Levels to analyse, e.g. "/Game/Space/Maps/WP_Main". Empty means all.
* @param LevelPackageNames Levels to analyse, e.g. "/Game/Maps/WP_Example". Empty means all.
* @param OutputDirectory Destination. Empty uses the configured default.
* @param OutReportPath Absolute path of the JSON report on success.
* @return true when both reports were written.
@@ -10,7 +10,7 @@
/**
* Team-shared configuration, written to Config/DefaultEditor.ini.
*
* That file is Perforce-tracked (only Plugins/** is p4-ignored), so the exclusion list and the
* That file is normally under version control, so the exclusion list and the
* type presets are reviewable and shared. Per-user state - last filter, column layout - belongs
* in UAssetUsageAuditUserSettings instead, which stays in Saved/Config.
*/
@@ -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<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;
}
// 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<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
@@ -232,8 +232,8 @@ void AssetDependencyClosureSpec::Define()
It("does not follow a map that an ordinary asset merely mentions", [this]()
{
// The defect this guards against: exporting WP_Main pulled in the whole of L_MainLevel
// through BP_FirstPersonGameMode -> PDA_MenuSystemConfig, 9994 packages belonging to a
// The defect this guards against: exporting WP_Example pulled in the whole of L_Other
// through BP_GameMode -> PDA_MenuConfig, 9994 packages belonging to a
// different location. FLevelUsageResolver already refused that crossing; the closure
// did not, so the two disagreed about the same graph.
IAssetRegistry& Registry = ReadyRegistry();
@@ -176,7 +176,7 @@ BEGIN_DEFINE_SPEC(AssetUsageExporterLiveSpec,
/**
* The OFPA level with the fewest external actors.
*
* Smallest on purpose: WP_Main owns roughly 16000 external packages and copying those in a test
* Smallest on purpose: WP_Example owns roughly 16000 external packages and copying those in a test
* would move gigabytes. Search stops as soon as something small enough turns up.
*/
static FName FindSmallestOfpaLevel(int32& OutExternalCount)
@@ -254,7 +254,7 @@ void AssetUsageExporterLiveSpec::Define()
It("leaves the exported file writable even when the source is read-only", [this]()
{
// This project is Perforce-primary, so unopened files are read-only on disk and Windows
// Perforce-primary projects keep unopened files read-only, so unopened files are read-only on disk and Windows
// CopyFile carries the attribute across. A read-only export folder is useless to an
// artist and breaks the exporter's own Overwrite policy on the next run.
const FName Package = FindOrdinaryAssetPackage();
@@ -16,27 +16,27 @@ void AssetUsagePathsSpec::Define()
{
It("detects an OFPA external actor package", [this]()
{
// Real shape from this project: /Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/<22 chars>
// Real shape from a live project: /Game/__ExternalActors__/Maps/WP_Example/8/AB/<22 chars>
TestTrue(TEXT("external actor"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/QWERTYUIOPASDFGHJKLZXC"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/QWERTYUIOPASDFGHJKLZXC"))));
});
It("detects an external objects package", [this]()
{
TestTrue(TEXT("external object"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Space/Maps/L_3i_Hub/1/2A/ABC"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/__ExternalObjects__/Maps/L_3i_Hub/1/2A/ABC"))));
});
It("does not flag an ordinary content package", [this]()
{
TestFalse(TEXT("ordinary asset"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Art/SM_Rock"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Art/SM_Rock"))));
});
It("does not flag a level that merely lives next to external actors", [this]()
{
TestFalse(TEXT("the level itself is not external"),
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Space/Maps/WP_Main"))));
AssetUsagePaths::IsExternalPackage(FName(TEXT("/Game/Maps/WP_Example"))));
});
});
@@ -44,7 +44,7 @@ void AssetUsagePathsSpec::Define()
{
It("flags /Script packages", [this]()
{
TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/Space"))));
TestTrue(TEXT("script"), AssetUsagePaths::IsScriptPackage(FName(TEXT("/Script/MyGame"))));
});
It("does not flag content that starts with the same letters", [this]()
@@ -123,7 +123,7 @@ void AssetUsagePathsSpec::Define()
{
const TArray<FString> Excluded;
TestFalse(TEXT("nothing excluded"),
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Space/Art/SM_Rock")), Excluded));
AssetUsagePaths::IsPathExcluded(FName(TEXT("/Game/Art/SM_Rock")), Excluded));
});
});
@@ -132,21 +132,21 @@ void AssetUsagePathsSpec::Define()
It("converts an asset package", [this]()
{
TestEqual(TEXT("asset"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Art/SM_Rock")), false),
FString(TEXT("Content/Space/Art/SM_Rock.uasset")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Art/SM_Rock")), false),
FString(TEXT("Content/Art/SM_Rock.uasset")));
});
It("uses the umap extension for levels", [this]()
{
TestEqual(TEXT("level"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Space/Maps/WP_Main")), true),
FString(TEXT("Content/Space/Maps/WP_Main.umap")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/Maps/WP_Example")), true),
FString(TEXT("Content/Maps/WP_Example.umap")));
});
It("returns empty for script and engine packages rather than inventing a path", [this]()
{
TestTrue(TEXT("script"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/Space")), false).IsEmpty());
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Script/MyGame")), false).IsEmpty());
TestTrue(TEXT("engine"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Engine/BasicShapes/Cube")), false).IsEmpty());
});
@@ -154,8 +154,8 @@ void AssetUsagePathsSpec::Define()
It("converts an external actor package, which is still under /Game", [this]()
{
TestEqual(TEXT("external actor"),
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC")), false),
FString(TEXT("Content/__ExternalActors__/Space/Maps/WP_Main/8/AB/ABC.uasset")));
AssetUsagePaths::ToProjectRelativePath(FName(TEXT("/Game/__ExternalActors__/Maps/WP_Example/8/AB/ABC")), false),
FString(TEXT("Content/__ExternalActors__/Maps/WP_Example/8/AB/ABC.uasset")));
});
});
}
@@ -17,7 +17,7 @@ namespace
FAssetUsageAuditResult Result;
Result.Header.GeneratedAt = FDateTime(2026, 9, 2, 12, 0, 0);
Result.Header.ProjectName = TEXT("Space");
Result.Header.ProjectName = TEXT("MyProject");
Result.Header.EngineVersion = TEXT("5.6.1");
Result.Header.ToolVersion = TEXT("0.1");
Result.Header.LevelsScanned = 2;
@@ -25,22 +25,22 @@ namespace
Result.Header.AppliedFilters.Add(TEXT("Types: StaticMesh"));
FAssetUsageRow& Used = Result.Rows.AddDefaulted_GetRef();
Used.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Used.PackageName = FName(TEXT("/Game/Art/SM_Rock"));
Used.AssetName = FName(TEXT("SM_Rock"));
Used.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Used.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Rock.uasset");
Used.PathFromProjectRoot = TEXT("Content/Art/SM_Rock.uasset");
Used.Verdict = EAssetUsageVerdict::UsedOnLevel;
Used.Levels = { FName(TEXT("/Game/Space/Maps/WP_Main")), FName(TEXT("/Game/Space/Maps/L_3i_Hub")) };
Used.Levels = { FName(TEXT("/Game/Maps/WP_Example")), FName(TEXT("/Game/Maps/L_3i_Hub")) };
Used.HardReferenceCount = 3;
Used.SoftReferenceCount = 1;
Used.Provenance = EAssetUsageProvenance::HardReference | EAssetUsageProvenance::ExternalActor;
Used.Route = TEXT("WP_Main -> BP_Rock -> SM_Rock");
Used.Route = TEXT("WP_Example -> BP_Rock -> SM_Rock");
FAssetUsageRow& Unused = Result.Rows.AddDefaulted_GetRef();
Unused.PackageName = FName(TEXT("/Game/Space/Art/SM_Orphan"));
Unused.PackageName = FName(TEXT("/Game/Art/SM_Orphan"));
Unused.AssetName = FName(TEXT("SM_Orphan"));
Unused.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
Unused.PathFromProjectRoot = TEXT("Content/Space/Art/SM_Orphan.uasset");
Unused.PathFromProjectRoot = TEXT("Content/Art/SM_Orphan.uasset");
Unused.Verdict = EAssetUsageVerdict::Unreferenced;
return Result;
@@ -123,7 +123,7 @@ void AssetUsageReportWriterSpec::Define()
const FString Csv = FAssetUsageReportWriter::BuildCsv(Sample, Options);
TestTrue(TEXT("levels joined"),
Csv.Contains(TEXT("/Game/Space/Maps/WP_Main|/Game/Space/Maps/L_3i_Hub")));
Csv.Contains(TEXT("/Game/Maps/WP_Example|/Game/Maps/L_3i_Hub")));
});
It("carries the verdict verbatim", [this]()
@@ -172,7 +172,7 @@ void AssetUsageReportWriterSpec::Define()
TestTrue(TEXT("header object"), Root->TryGetObjectField(TEXT("header"), Header));
if (Header)
{
TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("Space")));
TestEqual(TEXT("project"), (*Header)->GetStringField(TEXT("project")), FString(TEXT("MyProject")));
TestEqual(TEXT("levelsScanned"), (*Header)->GetIntegerField(TEXT("levelsScanned")), 2);
}
@@ -45,7 +45,7 @@ void AssetUsageVerdictSpec::Define()
It("reports ReferencedFromConfigOrSource when only a text literal names it", [this]()
{
// This is the BP_FirstPersonGameMode case: no asset references it, only
// This is the BP_GameMode case: no asset references it, only
// Config/DefaultEngine.ini does. Reporting it Unreferenced would be a real defect.
const EAssetUsageVerdict Verdict = FAssetUsageAuditor::ClassifyVerdict(false, false, true, false);
@@ -126,7 +126,7 @@ void AssetUsageVerdictSpec::Define()
{
// A level depends on the whole table, so every row looks used even when none is.
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Space/Data/DT_Weapons"));
Node.PackageName = FName(TEXT("/Game/Data/DT_Weapons"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.DataTable"));
TestTrue(TEXT("DataTable is a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
@@ -135,7 +135,7 @@ void AssetUsageVerdictSpec::Define()
It("does not flag an ordinary static mesh", [this]()
{
FAssetUsageNode Node;
Node.PackageName = FName(TEXT("/Game/Space/Art/SM_Rock"));
Node.PackageName = FName(TEXT("/Game/Art/SM_Rock"));
Node.ClassPath = FTopLevelAssetPath(TEXT("/Script/Engine.StaticMesh"));
TestFalse(TEXT("StaticMesh is not a blind spot"), FAssetUsageAuditor::IsRegistryBlindSpot(Node));
@@ -28,70 +28,70 @@ void IndirectReferenceScannerSpec::Define()
It("finds the GameMode from DefaultEngine.ini and strips the _C class suffix", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GlobalDefaultGameMode=/Game/Space/Core/GameModes/BP_FirstPersonGameMode.BP_FirstPersonGameMode_C"));
TEXT("GlobalDefaultGameMode=/Game/Core/GameModes/BP_GameMode.BP_GameMode_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Core/GameModes/BP_FirstPersonGameMode")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Core/GameModes/BP_GameMode")));
}
});
It("finds the GameInstance from MenuSystemPro", [this]()
It("finds the GameInstance from a purchased pack", [this]()
{
const TArray<FString> Found = Extract(
TEXT("GameInstanceClass=/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance.BP_MenuSystemGameInstance_C"));
TEXT("GameInstanceClass=/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance.BP_MenuGameInstance_C"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0],
FString(TEXT("/Game/MenuSystemPro/Blueprints/GameFramework/BP_MenuSystemGameInstance")));
FString(TEXT("/Game/UIPack/Blueprints/GameFramework/BP_MenuGameInstance")));
}
});
It("finds a map reference with an object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("EditorStartupMap=/Game/Space/Maps/Gyms/Gyms_Geoda.Gyms_Geoda"));
const TArray<FString> Found = Extract(TEXT("EditorStartupMap=/Game/Maps/Gyms/Gyms_Geoda.Gyms_Geoda"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/Gyms/Gyms_Geoda")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/Gyms/Gyms_Geoda")));
}
});
It("finds a bare package path with no object suffix", [this]()
{
const TArray<FString> Found = Extract(TEXT("GameDefaultMap=/Game/Space/Maps/RefinedMenuMap"));
const TArray<FString> Found = Extract(TEXT("GameDefaultMap=/Game/Maps/RefinedMenuMap"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Maps/RefinedMenuMap")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Maps/RefinedMenuMap")));
}
});
It("strips surrounding quotes and parentheses from an ini struct value", [this]()
{
const TArray<FString> Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Space/Art\")"));
const TArray<FString> Found = Extract(TEXT("+DirectoriesToAlwaysCook=(Path=\"/Game/Art\")"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art")));
}
});
It("handles a C++ literal wrapped in TEXT()", [this]()
{
const TArray<FString> Found = Extract(
TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Space/Art/SM_Rock\"));"));
TEXT("static const FSoftObjectPath Path(TEXT(\"/Game/Art/SM_Rock\"));"));
TestEqual(TEXT("one hit"), Found.Num(), 1);
if (Found.Num() == 1)
{
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Space/Art/SM_Rock")));
TestEqual(TEXT("package"), Found[0], FString(TEXT("/Game/Art/SM_Rock")));
}
});