Files
AssetUsageAudit/README.md
T
MagentaDolphin b7f5343a73 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>
2026-09-03 17:07:59 +07:00

33 KiB
Raw Blame History

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

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 упадёт.

"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
"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 — уже включён, ищет неиспользуемые ассеты, но не умеет привязку к уровням