# 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 без настроенного формата» ведёт прямо в нужную настройку. Перед стартом показывается диалог с числом реально конвертируемых: узнать об ошибке в выборе режима лучше до нескольких минут заблокированного редактора, а не после. --- ## Импорт из внешней папки `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, что читается как «импорт ничего не сделал». --- ## Архитектура ``` 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//`, External Data Layers — `/EDL//`. Обратное отображение «внешний актор → его карта» **не делать**: `PackageDependencyData.cpp:57-96` намеренно снимает флаг `UsedInGame` с этого ребра. Идти только вперёд от карты. ### 4. Blueprint не находится фильтром по классу У ассета `BP_Foo` класс всегда `/Script/Engine.Blueprint`. Геймплейный класс живёт в теге `GeneratedClass`. Нужны два запроса — как в `SAssetAuditBrowser::AddAssetsOfClass`. ### 5. `ScanLevelAssets` перед обходом уровня Гейтерер сообщает только те внешние пакеты, которые реестр уже отсканировал. Без этого уровень, который никто не открывал в сессии, отдаёт пустой список — неотличимо от уровня без акторов. --- ## Проектные решения **Чекбоксы — `TSet`, не селекция `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" \ ".uproject" \ -ExecCmds="Automation RunTests AssetUsageAudit" \ -TestExit="Automation Test Queue Empty" \ -unattended -nopause -nosplash -stdout -abslog="" ``` **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 git -C Plugins/AssetUsageAudit push -u origin master git submodule add Plugins/AssetUsageAudit ``` Артефакты сборки (`Binaries/`, `Intermediate/` — 231 МБ) исключены локальным `.gitignore`. В Perforce плагин не попадает: `Plugins/**` исключён в `.p4ignore`. ## Связанное - `.docs/architecture/asset-usage-audit.md` — заметка в базе знаний - `.docs/guides/build-and-run.md` — сборка и прогон тестов - `Plugins/AssetsCleaner` — уже включён, ищет неиспользуемые ассеты, но **не умеет привязку к уровням**