e92306c797
Co-Authored-By: Claude Code <noreply@anthropic.com>
461 lines
42 KiB
Markdown
461 lines
42 KiB
Markdown
# 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 — 128 спек
|
||
```
|
||
|
||
**Инвариант 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>"
|
||
```
|
||
|
||
**128 спек**, префикс `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 после переделки в дерево руками не проверялся.** Компилируется, 121 автотест зелёный, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
|
||
- **Коммандлет не написан.** Упирается в дефект headless выше.
|
||
- **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено.
|
||
- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но пользователя нужно об этом предупредить явно.
|
||
- GameMode может давать заметную долю строк отчёта по главной карте — нужен ли фильтр «исключить достижимое только через GameMode», решать по месту.
|
||
- Ассеты сторонних аудио-плагинов попадают в `Unknown` — скрывать по умолчанию или помечать, решать по месту.
|
||
|
||
---
|
||
|
||
## Контроль версий
|
||
|
||
Плагин — **самостоятельный git-репозиторий**. Артефакты сборки (`Binaries/`, `Intermediate/`) исключены локальным `.gitignore`: они пересобираются UBT из исходников и в коммит попадать не должны.
|
||
|
||
При подключении к проекту как сабмодуль:
|
||
|
||
```bash
|
||
git submodule add <url> Plugins/AssetUsageAudit
|
||
```
|
||
|
||
|
||
## Связанное
|
||
|
||
- `Plugins/AssetsCleaner` — ищет неиспользуемые ассеты, но **не умеет привязку к уровням**
|