feat: asset usage audit plugin

Editor tool for the LA and 3D departments: which assets each level uses,
which are used nowhere, and export of a chosen set.

Three modules. AssetUsageAuditCore holds the whole analysis and links no
UI and no editor-only asset pipeline, so it stays runnable from a
commandlet; AssetUsageAuditEditor holds the Slate panel and everything
that needs UnrealEd or AssetTools; AssetUsageAuditTests holds 152 specs.

Load-bearing decisions, each of which produces a wrong answer if undone:

- Dependency queries are always Package + NoRequirements, never Hard. The
  map-to-external-actor edges the OFPA gatherer emits carry Game|Build
  without Hard, so a Hard query drops all 16117 external actor packages
  in this project. There is deliberately no Hard constant in the code.
- Crossing into another map is allowed only from a level or its external
  actor package. Without that rule WP_Main reported 18136 assets, of
  which 9994 belonged to L_MainLevel, reached through the GameMode.
- The verdict has five states, never a bool. The registry cannot see
  FMOD events, DataTable rows or string-built paths; those are Unknown,
  and the tool never proposes a deletion.
- Copying .uasset files does not preserve references - they are stored as
  full package paths. Only the Migrate layout produces something Unreal
  can open; the others write a manifest so the graph can be rebuilt.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:07:59 +07:00
commit 72bf94d142
60 changed files with 13673 additions and 0 deletions
+373
View File
@@ -0,0 +1,373 @@
# 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_Main → BP_Pickup_Child_C_UAID_...1381106233 → BP_Pickup_Master → SM_ReaperStation
```
Вердикт без маршрута для художника бесполезен, поэтому маршрут пишется всегда.
**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`. Без него `BP_FirstPersonGameMode`, `BP_MenuSystemGameInstance` и `RefinedMenuMap` помечались бы мусором — на них не ссылается ни один ассет, только `DefaultEngine.ini`.
---
## Настройки
`Project Settings → 14Overmind → Asset Usage Audit`. Пишутся в `Config/DefaultEditor.ini` — файл Perforce-трекается, настройки видны команде.
Состояние панели (выбранный уровень, типы, режим выгрузки, чекбокс зависимостей) хранится **отдельно** — в `Saved/Config/.../EditorPerProjectUserSettings.ini`, пер-юзерно и вне контроля версий. Сохраняется при закрытии вкладки; аварийное завершение редактора теряет изменения. Строка поиска намеренно не восстанавливается: панель, открывшаяся пустой из-за забытого фильтра, выглядит сломанной.
| Параметр | По умолчанию |
|---|---|
| `DefaultExportDirectory` | пусто → `Saved/AssetUsageAudit` |
| `bOverwriteExistingFiles` | `false` → индексирование имён |
| `ExcludedPackagePaths` | `3rdParty`, `StarterContent`, `StarterBundle`, `Megascans`, `MSPresets` |
| `IncludedPackagePaths` | пусто → `/Game` |
| `bScanIndirectReferences` | `true` |
| `bHideExternalPackages` | `true` |
| `bMirrorFolderStructure` | `false` → плоская папка |
| `ExchangeFormatByClass` | пусто → встроенные умолчания |
⚠️ `Content/MenuSystemPro` **намеренно не исключён**, хотя это покупной пак: он поставляет `BP_MenuSystemGameInstance`, который `DefaultEngine.ini` назначает GameInstance проекта.
---
## Консольные команды
```
AssetUsageAudit.OpenPanel
AssetUsageAudit.Run [/Game/Space/Maps/WP_Main ...]
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.** Проект Perforce-primary, неоткрытые файлы на диске read-only, а Windows `CopyFile` переносит атрибут на копию. Без этого артист получал бы нередактируемую папку, а повторный прогон с политикой `Overwrite` падал бы на собственном предыдущем выводе. Флаг снимается с обеих сторон: перед перезаписью и после копирования.
### Конвертация в обменные форматы
Через `UAssetExportTask` — меши в FBX, текстуры в PNG, звук в WAV. Загружает каждый ассет, поэтому счёт идёт на минуты, а не на секунды; GC каждые 64 ассета, иначе память кончится раньше экспорта.
Соответствие класса и расширения — в настройках (`ExchangeFormatByClass`), поиск идёт вверх по иерархии: запись `Texture` покрывает `Texture2D`. Пустая карта означает встроенные умолчания.
**Blueprint в умолчаниях отсутствует намеренно.** Обменного формата у него нет; запись породила бы `.t3d`-дамп с подписью «экспортировано». Вместо этого класс попадает в счётчик `нет настроенного формата`.
Причины пропуска разделены на три счётчика — `нет формата` / `нет экспортёра` / `не загрузился`. «17 пропущено» не говорит ничего; «17 без настроенного формата» ведёт прямо в нужную настройку.
Перед стартом показывается диалог с числом реально конвертируемых: узнать об ошибке в выборе режима лучше до нескольких минут заблокированного редактора, а не после.
---
## Архитектура
```
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_Main`: **18 136** строк, из них **9 994** приходили через чужую карту:
```
WP_Main → BP_FirstPersonGameMode → PDA_MenuSystemConfig → L_MainLevel → …
```
Две трети ответа были содержимым другого уровня. После исправления — **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
"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
```
```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_Main` тест копировал бы гигабайты. Если контент не найден, тест пишет предупреждение и не падает, поэтому в логе стоит смотреть на предупреждения: их отсутствие означает, что тесты реально работали с контентом.
### `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_Main` | ~1.6 с, 8 644 ассета |
| Все уровни | 33 261 `UsedOnLevel` |
| Отчёт | 45 167 строк, JSON 21.8 МБ + CSV 10.2 МБ |
**Кэш не нужен** — вопрос закрыт замером.
---
## Незакрытое
- **UI после переделки в дерево руками не проверялся.** Компилируется, 121 автотест зелёный, но автотесты не трогают Slate: раскрытие уровней, третье состояние галочки на заголовке и сортировка внутри группы проверены только чтением кода.
- **Коммандлет не написан.** Упирается в дефект headless выше.
- **Конвертация не прогонялась на большом объёме.** Тесты покрывают единицы ассетов; поведение GC и времени на тысячах — не замерено.
- **Карта форматов узкая.** В дефолте только классы, для которых движок реально поставляет `UExporter`. Материалы, блюпринты и Niagara не конвертируются никуда — это ограничение движка, а не недоделка, но LA/3D нужно об этом сказать явно.
- `BP_FirstPersonGameMode` даёт 29% строк `WP_Main` — нужен ли фильтр «исключить достижимое только через GameMode», решать LA/3D.
- FMOD в `Unknown` — скрывать по умолчанию или помечать, решать аудио-отделу.
- Плагин лежит в git-части репозитория (`Plugins/**` вайтлистится в `.gitignore` и исключается в `.p4ignore`). Вынос в сабмодуль — после согласования.
---
## Связанное
- `.docs/architecture/asset-usage-audit.md` — заметка в базе знаний
- `.docs/guides/build-and-run.md` — сборка и прогон тестов
- `Plugins/AssetsCleaner` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням**