Files
AssetUsageAudit/README.md
T
Admin 2c82680a42 docs: record the version control arrangement
The plugin is its own repository with no remote yet. Writing down why
there is no .gitmodules entry: an entry without a working URL breaks
git submodule update --init for everyone running SetupProject.bat.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 17:10:29 +07:00

393 lines
34 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_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`). Вынос в сабмодуль — после согласования.
---
## Контроль версий
Плагин — **самостоятельный git-репозиторий** внутри `Plugins/`, как остальные 26 плагинов проекта. Удалённого сервера пока нет: репозиторий и выделенный remote ещё не согласованы.
Из этого следуют две вещи:
- в `.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` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням**