Files
AssetUsageAudit/README.md
T
2026-09-07 18:47:20 +07:00

461 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Asset Usage Audit
Плагин редактора UE 5.6: показывает, какие ассеты используются на каких уровнях, и какие не используются нигде. Отмеченное галочками выгружается файлами или отчётом.
Задача: OV — «подготовить утилиты для UE по экспорту ассетов, используемых в локациях».
---
## Быстрый старт
1. `Window → Tools → Asset Usage Audit` (или в консоли `AssetUsageAudit.OpenPanel`)
2. Выбрать локацию в списке **Level** — это **область прогона**, а не фильтр результата
3. **Run Audit**
4. Отфильтровать по типу, отметить галочками
5. Внизу — **Export report** (JSON+CSV) или **Export ticked assets…**, открывающая окно выгрузки
Двойной клик по строке показывает ассет в Content Browser — как **Browse To** в редакторе. Ассет при этом не загружается: строка может быть картой или мешем на сотни мегабайт.
Полный свип по всем 1072 уровням — ~1–3 с. По одному уровню — быстрее.
---
## Интерфейс
### Тулбар
| Контрол | Назначение |
|---|---|
| **By asset / By level** | Направление вопроса. `By level` строит **дерево**: уровни-заголовки, под ними ассеты. Без выбранного уровня показывает все уровни сразу — так сравниваются две локации. Галочка на заголовке отмечает всё под ним; частичный выбор рисуется третьим состоянием, а не полной галочкой |
| **Level** | Область аудита, **чеклист** с множественным выбором. Пусто = все уровни. Смена помечает результат устаревшим, но **не запускает прогон**: «All levels» это 1072 уровня |
| **Types** | Сверху **пресеты** (StaticMesh, Material, VFX, Sound…), ниже все 136 сырых классов. Пресет разворачивается в подклассы, поэтому `Material` ловит и `MaterialInstanceConstant`; пресеты, которых в проекте нет, не показываются. Повторный клик по выбранному пресету снимает его целиком |
| **Blueprint class** | Подстрока геймплейного класса. Непустое значение скрывает не-блюпринты |
| **Verdicts** | Пять состояний, все включены по умолчанию |
| **References** | Как ассет удерживается и через что достигнут |
| **Поиск** | По имени и пути |
### Колонки
`Check · Type · Name · Path · Verdict · Levels · Hard · Soft · Provenance · Route`
**Route** — цепочка, объясняющая вердикт:
```
WP_Example → BP_Child_C_UAID_... → BP_Master → SM_Station
```
Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда.
**Type** показывает класс ассета (`Blueprint`), геймплейный класс — в тултипе. Это сделано намеренно: если бы в колонке стоял `BP_Pickup_Child_C`, строка попадала бы под фильтр `Blueprint`, и таблица противоречила бы сама себе.
---
## Вердикты
Пять состояний, **никогда не булево**.
| Вердикт | Значение |
|---|---|
| `UsedOnLevel` | Достижим от карты. Колонки уточняют hard/soft и через какой уровень |
| `UsedByAssetsOnly` | Есть референсеры, но ни одна цепочка не доходит до карты |
| `ReferencedFromConfigOrSource` | Найден grep-ом по `Config/` и `Source/`. В провенансе — файл и строка |
| `Unreferenced` | Ни одной входящей ссылки |
| `Unknown` | Попадает в слепую зону реестра |
### ⚠️ `Unknown` — это не «не используется»
Asset Registry принципиально не видит:
- пути, собранные конкатенацией строк в C++/BP;
- `OpenLevel(FName)`;
- строки внутри DataTable — уровень зависит от таблицы целиком, поэтому любая строка выглядит используемой;
- **FMOD** — резолвит события строковыми путями мимо UObject-графа. Аудио систематически попадает в `Unknown`.
**Инструмент никогда не удаляет и не предлагает удалить.** Только отбор и выгрузка.
Смягчение: скан `Config/` и `Source/` по `\/Game([A-Za-z0-9_.\/]+)\b`. Без него GameMode, GameInstance и стартовая карта помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`.
---
## Настройки
`Project Settings → <Проект> → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл под контролем версий, настройки общие для команды.
Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится **отдельно** — в `Saved/Config/.../EditorPerProjectUserSettings.ini`, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной.
| Параметр | По умолчанию |
|---|---|
| `DefaultExportDirectory` | пусто → `Saved/AssetUsageAudit` |
| `bOverwriteExistingFiles` | `false` → индексирование имён |
| `ExcludedPackagePaths` | `3rdParty`, `StarterContent`, `StarterBundle`, `Megascans`, `MSPresets` |
| `IncludedPackagePaths` | пусто → `/Game` |
| `bScanIndirectReferences` | `true` |
| `bHideExternalPackages` | `true` |
| `bMirrorFolderStructure` | `false` → плоская папка |
| `ExchangeFormatByClass` | пусто → встроенные умолчания |
⚠️ Покупной пак **не следует исключать вслепую**: он может поставлять класс, который `DefaultEngine.ini` назначает GameMode или GameInstance проекта. Проверяйте `ExcludedPackagePaths` против конфига.
---
## Консольные команды
```
AssetUsageAudit.OpenPanel
AssetUsageAudit.Run [/Game/Maps/YourLevel ...]
AssetUsageAudit.FindUnused
```
Без аргументов `Run` обходит все уровни. Первый вызов после старта редактора ждёт догрузки Asset Registry.
## Blueprint / Python
Категория `Asset Usage Audit`: `RunAudit`, `GetAssetsUsedOnLevel`, `GetLevelsUsingAsset`, `MeasureFullSweepSeconds`.
---
## Отчёты
Пишутся парой, `Saved/AssetUsageAudit/`:
- **JSON** — вложенные списки уровней, метаданные фильтров
- **CSV** — UTF-8 **с BOM**, разделитель `;`, многозначные поля через `|`
BOM обязателен: без него Excel ломает кириллицу. Шапка CSV содержит применённые фильтры, версию движка, длительность и предупреждение про `Unknown`.
---
## Выгрузка файлов
Все параметры собраны в отдельном окне: кнопка **Export ticked assets…** внизу панели. Наверху остались только фильтры и **Run Audit** — режим, раскладка, папка и зависимости трогаются лишь в момент выгрузки, и окно может показать, к чему они приведут.
Окно наследует значения из настроек проекта и из прошлого выбора этого пользователя, но **ничего не пишет обратно при отмене**.
### Раскладка папок
| Вариант | Что делает |
|---|---|
| **Flat** | Всё в одну папку. Ради этого и существует политика имён |
| **Mirror the content tree** | Воспроизводит дерево `/Game` |
| **One folder per asset** | Каждому отмеченному ассету — своя папка, зависимости рядом |
| **Migrate into another Unreal project** | Передаёт всё движковому `MigratePackages`. **Единственный вариант, после которого ссылки работают** |
### ⚠️ Копия `.uasset` не переносит зависимости сама по себе
Ссылки внутри `.uasset` — это **полные имена пакетов** (`/Game/Art/T_Rock_D`), а не относительные пути. Файл резолвится, только если лежит ровно по этому пути от `Content/` в целевом проекте.
Отсюда: **`Flat` и `One folder per asset` дают файлы для людей, а не для движка.** Скопированные в проект, они откроются с битыми ссылками. Диалог говорит это прямо в подсказке под настройками.
Для переноса в другой UE-проект есть `Migrate`. Указывать надо папку `Content/` целевого проекта.
⚠️ **Папка назначения обязана удовлетворять двум условиям движка**, иначе перенос молча не состоится:
1. путь оканчивается на `/Content/`;
2. на уровень выше лежит `.uproject` **или ровно один** `.uplugin` — из этого движок выводит точку монтирования.
Проверка повторена у нас (`AssetUsagePaths::ValidateMigrateDestination`) и показывается прямо в окне: кнопка **Export** гаснет, причина стоит внизу. Так вышло не от аккуратности — первая версия отправляла папку `Saved/AssetUsageAudit`, движок отвечал `does not appear to be a game Content folder` **только в Output Log**, и в панели не происходило ничего. При переключении на `Migrate` подставленный путь сбрасывается: он заведомо непригоден.
Про Migrate стоит знать:
- Ему передаются **только отмеченные** пакеты — замыкание он строит сам. Скармливать ему ещё и наше означало бы тот же результат медленнее, с чужими ассетами в его отчёте.
- `MigratePackages` возвращает `void` и **отчитывается сам**. Поэтому в статусе панели не будет числа скопированных файлов: подделывать его нельзя.
- Политика имён к нему неприменима — Migrate обязан положить пакет по исходному пути, иначе теряется весь смысл. `Keep both` вырождается в `Skip`.
- ⚠️ Снятая галочка зависимостей означает `bIgnoreDependencies`, а он, по комментарию движка, **не переносит OFPA-акторов уровня**. Уровень приедет пустым. Панель спрашивает подтверждение отдельно.
### Манифест зависимостей
Для раскладок, ломающих ссылки, рядом с файлами пишется `AssetUsageAudit.manifest.json` — галочка **Write dependency manifest**, включена по умолчанию.
Содержит: исходное имя пакета каждого файла, **фактический** путь на диске, был ли ассет отмечен вручную или пришёл зависимостью, и список прямых зависимостей с пометкой, уехали ли они в ту же папку.
⚠️ **Фактический путь, а не задуманный.** Экспортёр возвращает карту записанного (`bRecordWrittenFiles`), и манифест строится из неё. Манифест из задуманных путей врал бы ровно в случае сработавшей политики имён — то есть когда он нужнее всего.
Пустой список записанных файлов — **ошибка**, а не пустой манифест: почти всегда это забытый флаг, а пустой манифест рядом с полной папкой будет принят за правду. По той же причине манифест не пишется под `Migrate`.
⚠️ **`One folder per asset` пишет больше файлов, чем ассетов.** Текстура на сорока мешах копируется в сорок папок — в этом и смысл: папку можно отдать целиком. Диалог подтверждения называет число файлов, а не число ассетов; это разные числа, и путать их дорого.
Галочка **Sort dependencies into type subfolders** раскладывает зависимости внутри папки ассета по типам — `Texture/`, `Material/`, `StaticMesh/`. Имена берутся из пресетов фильтра типов, а не выдуманы отдельно: в фильтре и на диске должны быть те же слова. Сам ассет остаётся в корне своей папки — он её предмет. Галочка активна только при `One folder per asset`; при других раскладках она **выключена, но видима** — исчезающий контрол читается как поломка.
### ⚠️ Include dependencies — включено по умолчанию
Пакет меша **не содержит** материалов и текстур, только ссылки на них. Без этой галочки отмеченный `SM_Rock` приезжает один и открывается розовым; Niagara-система — без спрайтов и модулей.
Поэтому по умолчанию выгружается замыкание: отмеченное **плюс всё, на что оно ссылается**, транзитивно. Перед записью показывается диалог с реальным числом — «отмечено 40, будет записано 380». Это та цифра, которая останавливает человека, случайно выгружающего пол-проекта.
Границы обхода:
| | |
|---|---|
| Запрос | тот же `NoRequirements` — soft-ссылки ловятся наравне с hard |
| `/Engine`, `/Temp` | **не копируются** — в целевом проекте они уже есть, перезаписать хуже, чем пропустить |
| `/Script` | не копируются, это код |
| Исключения путей | те же, что у аудита: выгрузка не тянет то, что отчёт игнорирует |
| Отмеченное вручную | проходит **мимо** фильтров — если человек отметил строку, он получит файл, даже из исключённой папки |
Это **не** `MigratePackages`: тот ходит по тому же замыканию, но сам решает, куда класть файлы, спрашивает и не умеет останавливаться на границе папки.
### Копия `.uasset`
Побайтовое копирование, ничего не загружается. Уровень тянет за собой свои OFPA-пакеты — без них выгруженная карта откроется пустой.
⚠️ **С источника снимается атрибут read-only.** В проектах под VCS, которая держит невытянутые файлы read-only (Perforce и подобные), Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования.
### Конвертация в обменные форматы
Через `UAssetExportTask` — меши в FBX, текстуры в PNG, звук в WAV. Загружает каждый ассет, поэтому счёт идёт на минуты, а не на секунды; GC каждые 64 ассета, иначе память кончится раньше экспорта.
Соответствие класса и расширения — в настройках (`ExchangeFormatByClass`), поиск идёт вверх по иерархии: запись `Texture` покрывает `Texture2D`. Пустая карта означает встроенные умолчания.
**Blueprint в умолчаниях отсутствует намеренно.** Обменного формата у него нет; запись породила бы `.t3d`-дамп с подписью «экспортировано». Вместо этого класс попадает в счётчик `нет настроенного формата`.
Причины пропуска разделены на три счётчика — `нет формата` / `нет экспортёра` / `не загрузился`. «17 пропущено» не говорит ничего; «17 без настроенного формата» ведёт прямо в нужную настройку.
Перед стартом показывается диалог с числом реально конвертируемых: узнать об ошибке в выборе режима лучше до нескольких минут заблокированного редактора, а не после.
---
## Импорт из внешней папки
`Window → Tools → Import Assets from Folder…` — обратное направление: прочитать папку выгруженных `.uasset` и вернуть выбранное в проект.
Список с галочками, колонки: файл · во что превратится · **откуда взят путь** · статус.
### Откуда берётся целевой путь
Это главная сложность импорта, а не копирование. Ссылка внутри `.uasset` называет цель **полным путём пакета**, поэтому файл заработает только если положить его туда, откуда он пришёл. Ошибка здесь не падает громко — она даёт ассет с отсутствующими ссылками, который обнаружат сильно позже.
Четыре источника по убыванию доверия, и колонка показывает, какой сработал:
| Источник | Надёжность |
|---|---|
| **Manifest** | Мы сами его написали из фактически записанного. Точно |
| **Package header** | `FPackageFileSummary::PackageName` — имя, с которым файл сохраняли. **Замерено на этом проекте: работает** |
| **Folder structure** | Позиция файла под корнем импорта. Верно для зеркальной выгрузки, догадка для прочих |
| **Unresolved** | Не восстановить. Импортируется только в явно названную папку, ссылки не сработают |
⚠️ Про `Package header` есть оговорка **в самом движке** (`AssetHeaderPatcher.cpp:1214`): поле сериализуется не всегда, и движок сам предусматривает откат. Поэтому цепочка, а не одна проверка.
### Что импорт делать откажется
Импорт — единственная операция инструмента, способная **уничтожить работу**: запись поверх `/Game/Art/SM_Rock` заменяет то, что там было, без отмены и без копии.
- Существующий ассет **пропускается**, пока явно не включена перезапись. Перед перезаписью — отдельное подтверждение.
- Пакет, **открытый в редакторе**, не перезаписывается никогда, даже с включённой галочкой: подмена файла под загруженным `UPackage` оставляет сессию с устаревшими объектами, которые потом сохранятся поверх импорта.
- Файлы без восстановимого пути пропускаются, если не названа папка-приёмник.
После копирования выполняется `ScanFilesSynchronous` — без него файлы лежат на диске и невидимы в Content Browser, что читается как «импорт ничего не сделал».
---
## Удаление отмеченных ассетов
Кнопка **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 — 16 spec-сьютов, 169 кейсов
```
**Инвариант Core:** не линковать `UnrealEd`, `AssetTools`, `ToolMenus`, `Slate`, `SlateCore`, `EditorSubsystem`. `AssetTools` editor-only транзитивно через `UnrealEd`.
`Engine` в Core **разрешён** — Runtime-модуль, доступен в коммандлете. Нужен для `ULevel::GetExternalActorsPaths`.
---
## ⚠️ Инварианты, которые нельзя нарушать
Проверено чтением исходников UE 5.6. Без этого инструмент молча даёт неверный ответ.
### 1. Запрос зависимостей — всегда `NoRequirements`
```cpp
AssetUsageAudit::MakeTraversalCategory() // EDependencyCategory::Package
AssetUsageAudit::MakeTraversalQuery() // FDependencyQuery{} — пустой
```
Два факта движка:
**`ExternalObjectAndActorDependencyGatherer.cpp:22`** выдаёт рёбра карта→внешний актор с маской `Game | Build` — **без `Hard`**. А `AssetRegistryInterface.h:95`: отсутствие `Hard` **и есть** soft-зависимость. Запрос с `EDependencyQuery::Hard` теряет все **16 117** OFPA-пакетов проекта.
**`Soft` определён как `NotHard`.** Значит `Hard | Soft` = «требуется Hard И требуется не-Hard» = пустое множество.
Hard/soft — это **колонка в отчёте**, а не фильтр запроса. Отдельной константы `Hard` в коде нет — ошибиться негде.
Есть канарейка: если достижимых ассетов меньше четверти от числа OFPA-пакетов, в лог падает предупреждение. Это сигнатура регресса к `Hard`-запросу.
### 2. Границу карты пересекать только от уровня или его внешнего пакета
Первая версия проваливалась в любой встреченный World. Замер на `WP_Example`: **18 136** строк, из них **9 994** приходили через чужую карту:
```
WP_Example → BP_GameMode → PDA_MenuConfig → L_Other → …
```
Две трети ответа были содержимым другого уровня. После исправления — **8 644**.
Правило: пересечение разрешено, только если источник ребра — сам уровень или его внешний пакет. Это покрывает сублевелы и Level Instance, но не ссылку из дата-ассета. Флаг `bTraverseIntoOtherLevels` возвращает прежнее поведение.
### 3. Пути OFPA не собирать строками
Только `ULevel::GetExternalActorsPaths` / `GetExternalObjectsPaths` (**множественная** форма — плагины регистрируют пути делегатами). Content Bundles вставляют `/CB/<Guid>/`, External Data Layers — `/EDL/<UID>/`.
Обратное отображение «внешний актор → его карта» **не делать**: `PackageDependencyData.cpp:57-96` намеренно снимает флаг `UsedInGame` с этого ребра. Идти только вперёд от карты.
### 4. Blueprint не находится фильтром по классу
У ассета `BP_Foo` класс всегда `/Script/Engine.Blueprint`. Геймплейный класс живёт в теге `GeneratedClass`. Нужны два запроса — как в `SAssetAuditBrowser::AddAssetsOfClass`.
### 5. `ScanLevelAssets` перед обходом уровня
Гейтерер сообщает только те внешние пакеты, которые реестр уже отсканировал. Без этого уровень, который никто не открывал в сессии, отдаёт пустой список — неотличимо от уровня без акторов.
---
## Проектные решения
**Чекбоксы — `TSet<FName>`, не селекция `SListView`.** План предписывал селекцию (Ctrl+A бесплатно), но она пересоздаётся при смене фильтра: пользователь, отметивший 40 ассетов и переключивший тип, потерял бы отметки молча.
**Тип ассета и геймплейный класс — разные оси.** Смешение давало **2980** записей в выпадающем списке. Разделено: 136 классов в меню, геймплейный класс — текстовым полем.
**Смена области не запускает аудит.** «All levels» = 1072 уровня; долгий прогон по клику в списке был бы неприятным сюрпризом. Результат помечается устаревшим.
**Выгрузка — копирование, не `MigratePackages`.** Migrate тянет всё дерево зависимостей, чего никто не просил, и требует editor-only модуля.
**Конвертация живёт в Core, а не в Editor.** `UExporter` и `UAssetExportTask` — это `ENGINE_API` в `Runtime/Engine`, не в `UnrealEd`. Core уже линкует `Engine`, так что инвариант не нарушен. Конкретные экспортёры (FBX, PNG) резолвятся рефлексией в рантайме: их отсутствие даёт честное «нет экспортёра», а не ошибку линковки — и открывает дорогу коммандлету.
---
## Сборка и тесты
⚠️ Закрыть редактор — иначе линковка DLL упадёт.
```bash
"<UE>/Engine/Build/BatchFiles/Build.bat" \
<Project>Editor Win64 Development \
-project="<abs path>/<Project>.uproject" -waitmutex
```
```bash
"C:/Program Files/Epic Games/UE_5.6/Engine/Binaries/Win64/UnrealEditor-Cmd.exe" \
"<project>.uproject" \
-ExecCmds="Automation RunTests AssetUsageAudit" \
-TestExit="Automation Test Queue Empty" \
-unattended -nopause -nosplash -stdout -abslog="<abs path>"
```
**169 кейсов в 16 spec-сьютах**, префикс `AssetUsageAudit.*`.
Три набора работают на **настоящем контенте проекта**, а не на выдуманных именах: `ExporterLive` (копирование, коллизии, раскрытие OFPA), `ExchangeExport` (конвертация) и `GraphFidelity` (сверка графа с реестром). Субъект они ищут через Asset Registry и берут **самый маленький** OFPA-уровень — на `WP_Example` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом.
### `GraphFidelity` — замена ручной сверке с Reference Viewer
Reference Viewer сам по себе не источник истины: он рисует те же рёбра, что отдаёт Asset Registry. Поэтому «сверить глазами десять ассетов» сведено к воспроизводимой проверке — граф против сырого ответа реестра тем же запросом:
- ни одного выдуманного ребра и ни одного потерянного (кроме отфильтрованных по путям);
- флаг hard совпадает с ответом реестра;
- каждый переход в колонке **Route** — настоящее ребро, а не склейка;
- ассет сублевела приписан и сублевелу, и родительской карте.
⚠️ **Выборка обязана быть по имени пакета, а не по индексу.** Индексы зависят от порядка перечисления реестра: граф между прогонами идентичен (84 505 пакетов, 370 722 ребра), а шаг по индексу каждый раз брал разные пакеты, и спека падала через раз. Сейчас проверяется одно и то же: 872 пакета и 3393 цели, число в число во всех прогонах.
⚠️ **Hard сравнивать по цели, а не по ребру.** Реестр может отдать одну и ту же цель дважды с разными масками — hard и soft одновременно, — и граф честно хранит оба. Проверка «это ребро hard?» против множества «есть ли hard-ребро к этой цели» помечает soft-двойника расхождением.
### ⚠️ Два флага ломают прогон на этом проекте
| Флаг | Симптом |
|---|---|
| `-nullrhi` | `Fatal error: Null assigned to TNotNull` (`NotNull.cpp:12`). **Дефект проекта, не плагина** — воспроизводится при полностью отключённых плагинах |
| `-NoShaderCompile` | `Failed to find shader map for default material` (`MaterialShared.cpp:2905`) |
Следствие: **headless без GPU на этом проекте недоступен**. Это блокирует серверную автоматизацию.
Ещё: тест, намеренно провоцирующий `UE_LOG(Error)`, обязан объявить `AddExpectedError` — иначе фреймворк считает его упавшим.
---
## Замеры
| Метрика | Значение |
|---|---|
| Пакетов в графе | 62 517 (после исключений; без них — 84 505) |
| Рёбер | 290 170 (без исключений — 370 722) |
| Уровней | 1072 |
| OFPA-пакетов | 16 117 |
| Полный свип | ~1.1 с |
| `WP_Example` | ~1.6 с, 8 644 ассета |
| Все уровни | 33 261 `UsedOnLevel` |
| Отчёт | 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ |
**Кэш не нужен** — вопрос закрыт замером.
---
## Незакрытое
- **UI после переделки в дерево руками не проверялся.** Компилируется, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
- **Коммандлет не написан.** Упирается в дефект headless выше.
- **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено.
- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно.
- GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту.
- Ассеты сторонних аудио-плагинов попадают в `Unknown` — скрывать по умолчанию или помечать, решать по месту.
---
## Контроль версий
Плагин — **самостоятельный git-репозиторий**. Артефакты сборки (`Binaries/`, `Intermediate/`) исключены локальным `.gitignore`: они пересобираются UBT из исходников и в коммит попадать не должны.
При подключении к проекту как сабмодуль:
```bash
git submodule add <url> Plugins/AssetUsageAudit
```
## Связанное
- `Plugins/AssetsCleaner` — ищет неиспользуемые ассеты, но **не умеет привязку к уровням**