Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4161cdc77 | |||
| 63c4054aef | |||
| a0a061656e | |||
| 640e940ab4 | |||
| e92306c797 | |||
| 0363b07b65 | |||
| f3c82d3815 | |||
| 430fc18d5f | |||
| aeb4a1067c | |||
| ee1c8bad3e | |||
| 243bb1fc55 | |||
| 1e351cbb9b | |||
| b7f5343a73 |
@@ -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")));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user